Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f80418f5ee | |||
| 108ee10293 | |||
| 20e0d24930 | |||
| 640e94bf17 | |||
| 98ed508a6f | |||
| e8d0a1d4b4 | |||
| 857fa5501c | |||
| ab39c76585 | |||
| 1e38d52967 | |||
| 6205a98ca0 | |||
| ebd7ae12b6 | |||
| 2cd1ba76f5 |
@@ -1,14 +1,14 @@
|
||||
name: Backend CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- '*-service/**'
|
||||
- '.github/workflows/backend-cicd.yaml'
|
||||
- '.github/kustomize/**'
|
||||
# push:
|
||||
# branches:
|
||||
# - develop
|
||||
# - main
|
||||
# paths:
|
||||
# - '*-service/**'
|
||||
# - '.github/workflows/backend-cicd.yaml'
|
||||
# - '.github/kustomize/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<env name="REDIS_HOST" value="20.214.210.71" />
|
||||
<env name="REDIS_PORT" value="6379" />
|
||||
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||
<env name="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||
<env name="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
|
||||
<env name="KAFKA_CONSUMER_GROUP" value="ai" />
|
||||
<env name="JPA_DDL_AUTO" value="update" />
|
||||
<env name="JPA_SHOW_SQL" value="false" />
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||
<env name="JPA_DDL_AUTO" value="update" />
|
||||
<env name="JPA_SHOW_SQL" value="false" />
|
||||
<env name="REPLICATE_API_TOKEN" value="r8_cqE8IzQr9DZ8Dr72ozbomiXe6IFPL0005Vuq9" />
|
||||
<env name="REPLICATE_MOCK_ENABLED" value="true" />
|
||||
</envs>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Kafka Configuration (원격 서버) -->
|
||||
<entry key="KAFKA_ENABLED" value="true" />
|
||||
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
|
||||
|
||||
<!-- Sample Data Configuration (MVP Only) -->
|
||||
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
|
||||
|
||||
@@ -19,7 +19,7 @@ spring:
|
||||
|
||||
# Kafka Consumer Configuration
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092}
|
||||
consumer:
|
||||
group-id: ${KAFKA_CONSUMER_GROUP:ai-service-consumers}
|
||||
auto-offset-reset: earliest
|
||||
@@ -28,8 +28,6 @@ spring:
|
||||
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||
properties:
|
||||
spring.json.trusted.packages: "*"
|
||||
spring.json.use.type.headers: false
|
||||
spring.json.value.default.type: com.kt.ai.kafka.message.AIJobMessage
|
||||
max.poll.records: 10
|
||||
session.timeout.ms: 30000
|
||||
listener:
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Kafka Configuration (원격 서버) -->
|
||||
<entry key="KAFKA_ENABLED" value="true" />
|
||||
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
|
||||
|
||||
<!-- Sample Data Configuration (MVP Only) -->
|
||||
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
|
||||
|
||||
+2
-2
@@ -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={}",
|
||||
|
||||
+2
-2
@@ -17,13 +17,13 @@ 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}")
|
||||
private String bootstrapServers;
|
||||
|
||||
@Value("${spring.kafka.consumer.group-id:analytics-service}")
|
||||
@Value("${spring.kafka.consumer.group-id:analytics-service-consumers-v3}")
|
||||
private String groupId;
|
||||
|
||||
@Bean
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.kt.event.analytics.config;
|
||||
|
||||
import org.apache.kafka.clients.producer.ProducerConfig;
|
||||
import org.apache.kafka.common.serialization.StringSerializer;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.kafka.core.ProducerFactory;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Kafka Producer 설정
|
||||
*
|
||||
* ⚠️ MVP 전용: SampleDataLoader가 Kafka 이벤트를 발행하기 위해 필요
|
||||
* ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행하므로 Producer 불필요
|
||||
*
|
||||
* String 직렬화 방식 사용 (SampleDataLoader가 JSON 문자열을 직접 발행)
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class KafkaProducerConfig {
|
||||
|
||||
@Value("${spring.kafka.bootstrap-servers}")
|
||||
private String bootstrapServers;
|
||||
|
||||
@Bean
|
||||
public ProducerFactory<String, String> producerFactory() {
|
||||
Map<String, Object> configProps = new HashMap<>();
|
||||
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||
configProps.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||
configProps.put(ProducerConfig.RETRIES_CONFIG, 3);
|
||||
return new DefaultKafkaProducerFactory<>(configProps);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public KafkaTemplate<String, String> kafkaTemplate() {
|
||||
return new KafkaTemplate<>(producerFactory());
|
||||
}
|
||||
}
|
||||
+199
-54
@@ -11,19 +11,23 @@ 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.beans.factory.annotation.Value;
|
||||
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 +51,7 @@ import java.util.UUID;
|
||||
public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||
private final KafkaAdmin kafkaAdmin;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final EventStatsRepository eventStatsRepository;
|
||||
private final ChannelStatsRepository channelStatsRepository;
|
||||
@@ -56,6 +61,9 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
private final Random random = new Random();
|
||||
|
||||
@Value("${spring.kafka.consumer.group-id}")
|
||||
private String consumerGroupId;
|
||||
|
||||
// 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 +93,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 +111,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 +137,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 +164,10 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
entityManager.clear();
|
||||
|
||||
log.info("✅ 모든 샘플 데이터 삭제 완료!");
|
||||
|
||||
// 2. Kafka Consumer Offset 리셋 (다음 시작 시 처음부터 읽도록)
|
||||
resetConsumerOffsets();
|
||||
|
||||
log.info("========================================");
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -160,37 +175,85 @@ 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={}", consumerGroupId);
|
||||
|
||||
// 모든 토픽의 offset 삭제
|
||||
Set<TopicPartition> partitions = new HashSet<>();
|
||||
|
||||
// 토픽별 파티션 추가 (설계서상 각 토픽은 3개 파티션)
|
||||
for (int i = 0; i < 3; i++) {
|
||||
partitions.add(new TopicPartition(EVENT_CREATED_TOPIC, i));
|
||||
partitions.add(new TopicPartition(PARTICIPANT_REGISTERED_TOPIC, i));
|
||||
partitions.add(new TopicPartition(DISTRIBUTION_COMPLETED_TOPIC, i));
|
||||
}
|
||||
|
||||
// Consumer Group Offset 삭제
|
||||
DeleteConsumerGroupOffsetsResult result = adminClient.deleteConsumerGroupOffsets(
|
||||
consumerGroupId,
|
||||
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")
|
||||
.startDate(java.time.LocalDateTime.of(2025, 1, 23, 0, 0)) // 2025-01-23 시작
|
||||
.endDate(null) // 진행중
|
||||
.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")
|
||||
.startDate(java.time.LocalDateTime.of(2025, 2, 1, 0, 0)) // 2025-02-01 시작
|
||||
.endDate(null) // 진행중
|
||||
.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")
|
||||
.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);
|
||||
|
||||
@@ -208,42 +271,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<DistributionCompletedEvent.ChannelDistribution> 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 +345,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 +403,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 +442,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건");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-17
@@ -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<ApiResponse<AnalyticsDashboardResponse>> 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));
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.kt.event.analytics.controller;
|
||||
|
||||
import com.kt.event.analytics.config.SampleDataLoader;
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 디버그 컨트롤러
|
||||
*
|
||||
* ⚠️ 개발/테스트 전용
|
||||
*/
|
||||
@Tag(name = "Debug", description = "디버그 API (개발/테스트 전용)")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/debug")
|
||||
@RequiredArgsConstructor
|
||||
public class DebugController {
|
||||
|
||||
private final SampleDataLoader sampleDataLoader;
|
||||
|
||||
/**
|
||||
* 샘플 데이터 수동 생성
|
||||
*/
|
||||
@Operation(
|
||||
summary = "샘플 데이터 수동 생성",
|
||||
description = "SampleDataLoader를 수동으로 실행하여 샘플 데이터를 생성합니다."
|
||||
)
|
||||
@PostMapping("/reload-sample-data")
|
||||
public ResponseEntity<ApiResponse<String>> reloadSampleData() {
|
||||
try {
|
||||
log.info("🔧 수동으로 샘플 데이터 생성 요청");
|
||||
|
||||
// SampleDataLoader 실행
|
||||
sampleDataLoader.run(new ApplicationArguments() {
|
||||
@Override
|
||||
public String[] getSourceArgs() {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Set<String> getOptionNames() {
|
||||
return java.util.Collections.emptySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsOption(String name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<String> getOptionValues(String name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<String> getNonOptionArgs() {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
});
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 완료"));
|
||||
} catch (Exception e) {
|
||||
log.error("❌ 샘플 데이터 생성 실패", e);
|
||||
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 실패: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-18
@@ -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<ApiResponse<TimelineAnalyticsResponse>> 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));
|
||||
|
||||
+5
-17
@@ -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<ApiResponse<UserAnalyticsDashboardResponse>> 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));
|
||||
|
||||
+2
-20
@@ -30,17 +30,13 @@ public class UserChannelAnalyticsController {
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 채널별 성과 분석",
|
||||
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
|
||||
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다. (전체 채널 무조건 표시)"
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/channels")
|
||||
public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> 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<String> 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));
|
||||
|
||||
+2
-12
@@ -28,7 +28,7 @@ public class UserRoiAnalyticsController {
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 ROI 상세 분석",
|
||||
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
|
||||
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/roi")
|
||||
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> 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));
|
||||
|
||||
+2
-12
@@ -30,7 +30,7 @@ public class UserTimelineAnalyticsController {
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 시간대별 참여 추이",
|
||||
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
|
||||
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/timeline")
|
||||
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> 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));
|
||||
|
||||
+15
@@ -47,6 +47,21 @@ public class AnalyticsDashboardResponse {
|
||||
*/
|
||||
private RoiSummary roi;
|
||||
|
||||
/**
|
||||
* 투자 비용 상세
|
||||
*/
|
||||
private InvestmentDetails investment;
|
||||
|
||||
/**
|
||||
* 수익 상세
|
||||
*/
|
||||
private RevenueDetails revenue;
|
||||
|
||||
/**
|
||||
* 비용 효율성 분석
|
||||
*/
|
||||
private CostEfficiency costEfficiency;
|
||||
|
||||
/**
|
||||
* 마지막 업데이트 시간
|
||||
*/
|
||||
|
||||
+10
@@ -33,6 +33,16 @@ public class InvestmentDetails {
|
||||
*/
|
||||
private BigDecimal operation;
|
||||
|
||||
/**
|
||||
* 경품 비용 (원)
|
||||
*/
|
||||
private BigDecimal prizeCost;
|
||||
|
||||
/**
|
||||
* 채널 비용 (원) - distribution과 동일한 값
|
||||
*/
|
||||
private BigDecimal channelCost;
|
||||
|
||||
/**
|
||||
* 총 투자 비용 (원)
|
||||
*/
|
||||
|
||||
+10
@@ -26,6 +26,16 @@ public class RevenueDetails {
|
||||
*/
|
||||
private BigDecimal expectedSales;
|
||||
|
||||
/**
|
||||
* 신규 고객 매출 (원)
|
||||
*/
|
||||
private BigDecimal newCustomerRevenue;
|
||||
|
||||
/**
|
||||
* 기존 고객 매출 (원)
|
||||
*/
|
||||
private BigDecimal existingCustomerRevenue;
|
||||
|
||||
/**
|
||||
* 브랜드 가치 향상 추정액 (원)
|
||||
*/
|
||||
|
||||
@@ -125,4 +125,11 @@ public class ChannelStats extends BaseTimeEntity {
|
||||
@Column(name = "average_duration")
|
||||
@Builder.Default
|
||||
private Integer averageDuration = 0;
|
||||
|
||||
/**
|
||||
* 참여자 수 증가
|
||||
*/
|
||||
public void incrementParticipants() {
|
||||
this.participants++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,18 @@ public class EventStats extends BaseTimeEntity {
|
||||
@Column(length = 20)
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 이벤트 시작일
|
||||
*/
|
||||
@Column(name = "start_date")
|
||||
private java.time.LocalDateTime startDate;
|
||||
|
||||
/**
|
||||
* 이벤트 종료일 (null이면 진행중)
|
||||
*/
|
||||
@Column(name = "end_date")
|
||||
private java.time.LocalDateTime endDate;
|
||||
|
||||
/**
|
||||
* 참여자 수 증가
|
||||
*/
|
||||
|
||||
+8
-3
@@ -32,7 +32,7 @@ public class DistributionCompletedConsumer {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed";
|
||||
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed_v2";
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
|
||||
private static final 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);
|
||||
|
||||
+7
-2
@@ -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<String, String> redisTemplate;
|
||||
|
||||
private static final String PROCESSED_EVENTS_KEY = "processed_events";
|
||||
private static final String PROCESSED_EVENTS_KEY = "processed_events_v2";
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
|
||||
private static final long IDEMPOTENCY_TTL_DAYS = 7;
|
||||
|
||||
@@ -61,11 +62,15 @@ 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())
|
||||
.startDate(event.getStartDate())
|
||||
.endDate(event.getEndDate())
|
||||
.build();
|
||||
|
||||
eventStatsRepository.save(eventStats);
|
||||
log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId);
|
||||
log.info("✅ 이벤트 통계 초기화 완료: eventId={}, userId={}, startDate={}, endDate={}",
|
||||
eventId, eventStats.getUserId(), event.getStartDate(), event.getEndDate());
|
||||
|
||||
// 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영)
|
||||
String cacheKey = CACHE_KEY_PREFIX + eventId;
|
||||
|
||||
+27
-8
@@ -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<String, String> redisTemplate;
|
||||
|
||||
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants";
|
||||
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants_v2";
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
|
||||
private static final 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);
|
||||
|
||||
+5
@@ -62,5 +62,10 @@ public class DistributionCompletedEvent {
|
||||
* 예상 노출 수
|
||||
*/
|
||||
private Integer expectedViews;
|
||||
|
||||
/**
|
||||
* 배포 비용 (원)
|
||||
*/
|
||||
private java.math.BigDecimal distributionCost;
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -6,6 +6,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 이벤트 생성 이벤트
|
||||
@@ -36,8 +37,23 @@ public class EventCreatedEvent {
|
||||
*/
|
||||
private BigDecimal totalInvestment;
|
||||
|
||||
/**
|
||||
* 예상 수익
|
||||
*/
|
||||
private BigDecimal expectedRevenue;
|
||||
|
||||
/**
|
||||
* 이벤트 상태
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 이벤트 시작일
|
||||
*/
|
||||
private LocalDateTime startDate;
|
||||
|
||||
/**
|
||||
* 이벤트 종료일 (null이면 진행중)
|
||||
*/
|
||||
private LocalDateTime endDate;
|
||||
}
|
||||
|
||||
+16
@@ -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<ChannelStats, Long
|
||||
*/
|
||||
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
|
||||
|
||||
/**
|
||||
* 이벤트 ID와 채널명으로 통계 조회 (비관적 락)
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param channelName 채널명
|
||||
* @return 채널 통계
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT c FROM ChannelStats c WHERE c.eventId = :eventId AND c.channelName = :channelName")
|
||||
Optional<ChannelStats> findByEventIdAndChannelNameWithLock(@Param("eventId") String eventId,
|
||||
@Param("channelName") String channelName);
|
||||
|
||||
/**
|
||||
* 여러 이벤트 ID로 모든 채널 통계 조회
|
||||
*
|
||||
|
||||
+108
-14
@@ -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<ChannelStats> channelStatsList,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
// 기간 정보
|
||||
PeriodInfo period = buildPeriodInfo(startDate, endDate);
|
||||
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> 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,21 @@ 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.getStartDate();
|
||||
LocalDateTime end = eventStats.getEndDate() != null ?
|
||||
eventStats.getEndDate() : LocalDateTime.now();
|
||||
|
||||
long durationDays = ChronoUnit.DAYS.between(start, end);
|
||||
|
||||
@@ -215,4 +225,88 @@ public class AnalyticsService {
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 투자 비용 상세 구성
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - 실제 채널 배포 비용 집계
|
||||
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
*/
|
||||
private InvestmentDetails buildInvestmentDetails(EventStats eventStats, List<ChannelStats> channelStatsList) {
|
||||
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
|
||||
|
||||
// ChannelStats에서 실제 배포 비용 집계
|
||||
java.math.BigDecimal actualDistribution = channelStatsList.stream()
|
||||
.map(ChannelStats::getDistributionCost)
|
||||
.reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add);
|
||||
|
||||
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
|
||||
java.math.BigDecimal remaining = totalInvestment.subtract(actualDistribution);
|
||||
|
||||
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
java.math.BigDecimal prizeCost = remaining.multiply(java.math.BigDecimal.valueOf(0.50));
|
||||
java.math.BigDecimal contentCreation = remaining.multiply(java.math.BigDecimal.valueOf(0.30));
|
||||
java.math.BigDecimal operation = remaining.multiply(java.math.BigDecimal.valueOf(0.20));
|
||||
|
||||
return InvestmentDetails.builder()
|
||||
.total(totalInvestment)
|
||||
.contentCreation(contentCreation)
|
||||
.operation(operation)
|
||||
.distribution(actualDistribution)
|
||||
.prizeCost(prizeCost)
|
||||
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수익 상세 구성
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - 직접 매출 70%, 예상 추가 매출 30%
|
||||
* - 신규 고객 40%, 기존 고객 60%
|
||||
*/
|
||||
private RevenueDetails buildRevenueDetails(EventStats eventStats) {
|
||||
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
|
||||
|
||||
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
|
||||
java.math.BigDecimal directSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.70));
|
||||
java.math.BigDecimal expectedSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.30));
|
||||
|
||||
// 신규 고객 40%, 기존 고객 60%
|
||||
java.math.BigDecimal newCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.40));
|
||||
java.math.BigDecimal existingCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.60));
|
||||
|
||||
return RevenueDetails.builder()
|
||||
.total(totalRevenue)
|
||||
.directSales(directSales)
|
||||
.expectedSales(expectedSales)
|
||||
.newCustomerRevenue(newCustomerRevenue)
|
||||
.existingCustomerRevenue(existingCustomerRevenue)
|
||||
.brandValue(java.math.BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 효율성 구성
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - 참여자당 비용 = 총투자 ÷ 총참여자수
|
||||
* - 참여자당 수익 = 총수익 ÷ 총참여자수
|
||||
*/
|
||||
private CostEfficiency buildCostEfficiency(EventStats eventStats) {
|
||||
int totalParticipants = eventStats.getTotalParticipants();
|
||||
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
|
||||
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
|
||||
|
||||
double costPerParticipant = totalParticipants > 0 ?
|
||||
totalInvestment.doubleValue() / totalParticipants : 0.0;
|
||||
double revenuePerParticipant = totalParticipants > 0 ?
|
||||
totalRevenue.doubleValue() / totalParticipants : 0.0;
|
||||
|
||||
return CostEfficiency.builder()
|
||||
.costPerParticipant(costPerParticipant)
|
||||
.revenuePerParticipant(revenuePerParticipant)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,43 +60,62 @@ public class ROICalculator {
|
||||
|
||||
/**
|
||||
* 투자 비용 계산
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - ChannelStats에서 실제 배포 비용 집계
|
||||
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
*/
|
||||
private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> 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();
|
||||
}
|
||||
|
||||
|
||||
+4
-11
@@ -26,20 +26,13 @@ public class TimelineAnalyticsService {
|
||||
private final TimelineDataRepository timelineDataRepository;
|
||||
|
||||
/**
|
||||
* 시간대별 참여 추이 조회
|
||||
* 시간대별 참여 추이 조회 (이벤트 전체 기간)
|
||||
*/
|
||||
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval,
|
||||
LocalDateTime startDate, LocalDateTime endDate,
|
||||
List<String> metrics) {
|
||||
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, List<String> metrics) {
|
||||
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
|
||||
|
||||
// 시간대별 데이터 조회
|
||||
List<TimelineData> timelineDataList;
|
||||
if (startDate != null && endDate != null) {
|
||||
timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
|
||||
} else {
|
||||
timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
|
||||
}
|
||||
// 시간대별 데이터 조회 (이벤트 전체 기간)
|
||||
List<TimelineData> timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
|
||||
|
||||
// 시간대별 데이터 포인트 구성
|
||||
List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList);
|
||||
|
||||
+27
-19
@@ -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<EventStats> 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<ChannelStats> 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<EventStats> allEvents,
|
||||
List<ChannelStats> allChannelStats,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
// 기간 정보
|
||||
PeriodInfo period = buildPeriodInfo(startDate, endDate);
|
||||
List<ChannelStats> allChannelStats) {
|
||||
// 기간 정보 (전체 이벤트의 최소/최대 날짜 기반)
|
||||
PeriodInfo period = buildPeriodFromEvents(allEvents);
|
||||
|
||||
// 전체 이벤트 수 및 활성 이벤트 수
|
||||
int totalEvents = allEvents.size();
|
||||
@@ -299,16 +301,22 @@ 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);
|
||||
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
|
||||
LocalDateTime start = events.stream()
|
||||
.map(EventStats::getStartDate)
|
||||
.filter(Objects::nonNull)
|
||||
.min(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
LocalDateTime end = LocalDateTime.now();
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
.durationDays((int) durationDays)
|
||||
.durationDays((int) ChronoUnit.DAYS.between(start, end))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
+31
-23
@@ -42,10 +42,9 @@ public class UserChannelAnalyticsService {
|
||||
private static final long CACHE_TTL = 1800; // 30분
|
||||
|
||||
/**
|
||||
* 사용자 전체 채널 분석 데이터 조회
|
||||
* 사용자 전체 채널 분석 데이터 조회 (전체 채널 무조건 표시)
|
||||
*/
|
||||
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> 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<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
return buildEmptyResponse(userId, startDate, endDate);
|
||||
return buildEmptyResponse(userId);
|
||||
}
|
||||
|
||||
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||
List<ChannelStats> 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<EventStats> allEvents,
|
||||
List<ChannelStats> allChannelStats, List<String> channels,
|
||||
String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
// 채널 필터링
|
||||
List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty()
|
||||
? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
|
||||
: allChannelStats;
|
||||
|
||||
// 채널별 집계
|
||||
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
|
||||
List<ChannelStats> allChannelStats,
|
||||
String sortBy, String order) {
|
||||
// 채널별 집계 (전체 채널)
|
||||
List<ChannelAnalytics> 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<EventStats> events) {
|
||||
LocalDateTime start = events.stream()
|
||||
.map(EventStats::getCreatedAt)
|
||||
.min(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
LocalDateTime end = events.stream()
|
||||
.map(EventStats::getUpdatedAt)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
.durationDays((int) durationDays)
|
||||
.durationDays((int) ChronoUnit.DAYS.between(start, end))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+80
-19
@@ -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<String, String> 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<EventStats> 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<EventStats> allEvents, boolean includeProjection,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> 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<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
|
||||
|
||||
BigDecimal actualDistribution = allChannelStats.stream()
|
||||
.map(ChannelStats::getDistributionCost)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
|
||||
BigDecimal remaining = totalInvestment.subtract(actualDistribution);
|
||||
|
||||
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
|
||||
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
|
||||
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
|
||||
|
||||
InvestmentDetails investment = InvestmentDetails.builder()
|
||||
.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<EventStats> events) {
|
||||
LocalDateTime start = events.stream()
|
||||
.map(EventStats::getCreatedAt)
|
||||
.min(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
LocalDateTime end = events.stream()
|
||||
.map(EventStats::getUpdatedAt)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
|
||||
+26
-14
@@ -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<String> metrics, boolean refresh) {
|
||||
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
|
||||
|
||||
@@ -56,15 +55,13 @@ public class UserTimelineAnalyticsService {
|
||||
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
return buildEmptyResponse(userId, interval, startDate, endDate);
|
||||
return buildEmptyResponse(userId, interval);
|
||||
}
|
||||
|
||||
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||
List<TimelineData> allTimelineData = startDate != null && endDate != null
|
||||
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
|
||||
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
|
||||
List<TimelineData> 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<EventStats> allEvents,
|
||||
List<TimelineData> allTimelineData, String interval,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
List<TimelineData> allTimelineData, String interval) {
|
||||
Map<LocalDateTime, TimelineDataPoint> 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<EventStats> events) {
|
||||
LocalDateTime start = events.stream()
|
||||
.map(EventStats::getCreatedAt)
|
||||
.min(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
LocalDateTime end = events.stream()
|
||||
.map(EventStats::getUpdatedAt)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
|
||||
@@ -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,7 +76,10 @@ spring:
|
||||
server:
|
||||
port: ${SERVER_PORT:8086}
|
||||
servlet:
|
||||
context-path: /api/v1/analytics
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
enabled: true
|
||||
force: true
|
||||
|
||||
# JWT
|
||||
jwt:
|
||||
|
||||
@@ -40,10 +40,8 @@ public enum ErrorCode {
|
||||
EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
|
||||
EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
|
||||
EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
|
||||
EVENT_004("EVENT_004", "유효하지 않은 eventId 형식입니다"),
|
||||
EVENT_005("EVENT_005", "이미 존재하는 eventId입니다"),
|
||||
EVENT_006("EVENT_006", "이벤트 생성에 실패했습니다"),
|
||||
EVENT_007("EVENT_007", "이벤트 수정 권한이 없습니다"),
|
||||
EVENT_004("EVENT_004", "이벤트 생성에 실패했습니다"),
|
||||
EVENT_005("EVENT_005", "이벤트 수정 권한이 없습니다"),
|
||||
|
||||
// Job 에러 (JOB_XXX)
|
||||
JOB_001("JOB_001", "Job을 찾을 수 없습니다"),
|
||||
|
||||
@@ -12,6 +12,7 @@ import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* JWT 토큰 생성 및 검증 제공자
|
||||
@@ -56,13 +57,13 @@ public class JwtTokenProvider {
|
||||
* @return Access Token
|
||||
*/
|
||||
|
||||
public String createAccessToken(String userId, String storeId, String email, String name, List<String> roles) {
|
||||
public String createAccessToken(UUID userId, UUID storeId, String email, String name, List<String> roles) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(userId)
|
||||
.claim("storeId", storeId)
|
||||
.subject(userId.toString())
|
||||
.claim("storeId", storeId != null ? storeId.toString() : null)
|
||||
.claim("email", email)
|
||||
.claim("name", name)
|
||||
.claim("roles", roles)
|
||||
@@ -79,12 +80,12 @@ public class JwtTokenProvider {
|
||||
* @param userId 사용자 ID
|
||||
* @return Refresh Token
|
||||
*/
|
||||
public String createRefreshToken(String userId) {
|
||||
public String createRefreshToken(UUID userId) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(userId)
|
||||
.subject(userId.toString())
|
||||
.claim("type", "refresh")
|
||||
.issuedAt(now)
|
||||
.expiration(expiryDate)
|
||||
@@ -98,9 +99,9 @@ public class JwtTokenProvider {
|
||||
* @param token JWT 토큰
|
||||
* @return 사용자 ID
|
||||
*/
|
||||
public String getUserIdFromToken(String token) {
|
||||
public UUID getUserIdFromToken(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
return claims.getSubject();
|
||||
return UUID.fromString(claims.getSubject());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +113,9 @@ public class JwtTokenProvider {
|
||||
public UserPrincipal getUserPrincipalFromToken(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
|
||||
String userId = claims.getSubject();
|
||||
String storeId = claims.get("storeId", String.class);
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
String storeIdStr = claims.get("storeId", String.class);
|
||||
UUID storeId = storeIdStr != null ? UUID.fromString(storeIdStr) : null;
|
||||
String email = claims.get("email", String.class);
|
||||
String name = claims.get("name", String.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -23,12 +24,12 @@ public class UserPrincipal implements UserDetails {
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private final String userId;
|
||||
private final UUID userId;
|
||||
|
||||
/**
|
||||
* 매장 ID
|
||||
*/
|
||||
private final String storeId;
|
||||
private final UUID storeId;
|
||||
|
||||
/**
|
||||
* 사용자 이메일
|
||||
|
||||
-28
@@ -46,9 +46,6 @@ public class RegenerateImageService implements RegenerateImageUseCase {
|
||||
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
|
||||
private String modelVersion;
|
||||
|
||||
@Value("${replicate.mock.enabled:false}")
|
||||
private boolean mockEnabled;
|
||||
|
||||
public RegenerateImageService(
|
||||
ReplicateApiClient replicateClient,
|
||||
CDNUploader cdnUploader,
|
||||
@@ -154,14 +151,6 @@ public class RegenerateImageService implements RegenerateImageUseCase {
|
||||
*/
|
||||
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
|
||||
try {
|
||||
// Mock 모드일 경우 Mock 데이터 반환
|
||||
// if (mockEnabled) {
|
||||
// log.info("[MOCK] 이미지 재생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
|
||||
// String mockUrl = generateMockImageUrl(platform);
|
||||
// log.info("[MOCK] 이미지 재생성 완료: url={}", mockUrl);
|
||||
// return mockUrl;
|
||||
// }
|
||||
|
||||
int width = platform.getWidth();
|
||||
int height = platform.getHeight();
|
||||
|
||||
@@ -285,21 +274,4 @@ public class RegenerateImageService implements RegenerateImageUseCase {
|
||||
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 이미지 URL 생성 (dev 환경용)
|
||||
*
|
||||
* @param platform 플랫폼 (이미지 크기 결정)
|
||||
* @return Mock 이미지 URL
|
||||
*/
|
||||
private String generateMockImageUrl(com.kt.event.content.biz.domain.Platform platform) {
|
||||
// 플랫폼별 크기에 맞는 placeholder 이미지 URL 생성
|
||||
int width = platform.getWidth();
|
||||
int height = platform.getHeight();
|
||||
|
||||
// placeholder.com을 사용한 Mock 이미지 URL
|
||||
String mockId = UUID.randomUUID().toString().substring(0, 8);
|
||||
return String.format("https://via.placeholder.com/%dx%d/6BCF7F/FFFFFF?text=Regenerated+%s+%s",
|
||||
width, height, platform.name(), mockId);
|
||||
}
|
||||
}
|
||||
|
||||
-28
@@ -52,9 +52,6 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
|
||||
private String modelVersion;
|
||||
|
||||
@Value("${replicate.mock.enabled:false}")
|
||||
private boolean mockEnabled;
|
||||
|
||||
public StableDiffusionImageGenerator(
|
||||
ReplicateApiClient replicateClient,
|
||||
CDNUploader cdnUploader,
|
||||
@@ -191,14 +188,6 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||
*/
|
||||
private String generateImage(String prompt, Platform platform) {
|
||||
try {
|
||||
// Mock 모드일 경우 Mock 데이터 반환
|
||||
// if (mockEnabled) {
|
||||
// log.info("[MOCK] 이미지 생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
|
||||
// String mockUrl = generateMockImageUrl(platform);
|
||||
// log.info("[MOCK] 이미지 생성 완료: url={}", mockUrl);
|
||||
// return mockUrl;
|
||||
// }
|
||||
|
||||
// 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
|
||||
int width = platform.getWidth();
|
||||
int height = platform.getHeight();
|
||||
@@ -247,23 +236,6 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 이미지 URL 생성 (dev 환경용)
|
||||
*
|
||||
* @param platform 플랫폼 (이미지 크기 결정)
|
||||
* @return Mock 이미지 URL
|
||||
*/
|
||||
private String generateMockImageUrl(Platform platform) {
|
||||
// 플랫폼별 크기에 맞는 placeholder 이미지 URL 생성
|
||||
int width = platform.getWidth();
|
||||
int height = platform.getHeight();
|
||||
|
||||
// placeholder.com을 사용한 Mock 이미지 URL
|
||||
String mockId = UUID.randomUUID().toString().substring(0, 8);
|
||||
return String.format("https://via.placeholder.com/%dx%d/FF6B6B/FFFFFF?text=%s+Event+%s",
|
||||
width, height, platform.name(), mockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicate API 예측 완료 대기 (폴링)
|
||||
*
|
||||
|
||||
@@ -37,8 +37,6 @@ replicate:
|
||||
token: ${REPLICATE_API_TOKEN:}
|
||||
model:
|
||||
version: ${REPLICATE_MODEL_VERSION:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}
|
||||
mock:
|
||||
enabled: ${REPLICATE_MOCK_ENABLED:true}
|
||||
|
||||
# CORS Configuration
|
||||
cors:
|
||||
|
||||
@@ -20,7 +20,7 @@ data:
|
||||
EXCLUDE_REDIS: ""
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ALLOWED_ORIGINS: "http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io"
|
||||
CORS_ALLOWED_ORIGINS: "http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io,http://kt-event-marketing-api.20.214.196.128.nip.io,http://*.20.214.196.128.nip.io,https://kt-event-marketing.20.214.196.128.nip.io,https://kt-event-marketing-api.20.214.196.128.nip.io,https://*.20.214.196.128.nip.io"
|
||||
CORS_ALLOWED_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH"
|
||||
CORS_ALLOWED_HEADERS: "*"
|
||||
CORS_ALLOW_CREDENTIALS: "true"
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
-- ====================================================================================================
|
||||
-- Event ID 타입 변경 DDL (UUID → VARCHAR(50)) - PostgreSQL
|
||||
-- ====================================================================================================
|
||||
-- 작성일: 2025-10-29
|
||||
-- 작성자: Backend Development Team
|
||||
-- 설명: Event 엔티티의 eventId가 String 타입으로 변경됨에 따라 관련 테이블들의 event_id 컬럼 타입을 UUID에서 VARCHAR(50)으로 변경합니다.
|
||||
-- 영향 범위:
|
||||
-- - events 테이블 (Primary Key)
|
||||
-- - event_channels 테이블 (Foreign Key)
|
||||
-- - generated_images 테이블 (Foreign Key)
|
||||
-- - ai_recommendations 테이블 (Foreign Key)
|
||||
-- - jobs 테이블 (Foreign Key)
|
||||
-- ====================================================================================================
|
||||
|
||||
-- 0. 현재 상태 확인 (실행 전 확인용)
|
||||
-- ====================================================================================================
|
||||
-- 각 테이블의 event_id 컬럼 타입 확인
|
||||
-- SELECT table_name, column_name, data_type
|
||||
-- FROM information_schema.columns
|
||||
-- WHERE column_name = 'event_id'
|
||||
-- AND table_schema = 'public'
|
||||
-- ORDER BY table_name;
|
||||
|
||||
-- event_id 관련 모든 외래키 제약조건 확인
|
||||
-- SELECT
|
||||
-- tc.constraint_name,
|
||||
-- tc.table_name,
|
||||
-- kcu.column_name,
|
||||
-- ccu.table_name AS foreign_table_name,
|
||||
-- ccu.column_name AS foreign_column_name
|
||||
-- FROM information_schema.table_constraints AS tc
|
||||
-- JOIN information_schema.key_column_usage AS kcu
|
||||
-- ON tc.constraint_name = kcu.constraint_name
|
||||
-- AND tc.table_schema = kcu.table_schema
|
||||
-- JOIN information_schema.constraint_column_usage AS ccu
|
||||
-- ON ccu.constraint_name = tc.constraint_name
|
||||
-- AND ccu.table_schema = tc.table_schema
|
||||
-- WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
-- AND kcu.column_name = 'event_id'
|
||||
-- AND tc.table_schema = 'public';
|
||||
|
||||
-- 1. 외래키 제약조건 전체 제거
|
||||
-- ====================================================================================================
|
||||
-- JPA가 자동 생성한 제약조건 이름도 포함하여 모두 제거
|
||||
|
||||
-- event_channels 테이블의 모든 event_id 관련 외래키 제거
|
||||
DO $$
|
||||
DECLARE
|
||||
constraint_name TEXT;
|
||||
BEGIN
|
||||
FOR constraint_name IN
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_name = 'event_channels'
|
||||
AND kcu.column_name = 'event_id'
|
||||
AND tc.table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'ALTER TABLE event_channels DROP CONSTRAINT IF EXISTS ' || constraint_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- generated_images 테이블의 모든 event_id 관련 외래키 제거
|
||||
DO $$
|
||||
DECLARE
|
||||
constraint_name TEXT;
|
||||
BEGIN
|
||||
FOR constraint_name IN
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_name = 'generated_images'
|
||||
AND kcu.column_name = 'event_id'
|
||||
AND tc.table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'ALTER TABLE generated_images DROP CONSTRAINT IF EXISTS ' || constraint_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ai_recommendations 테이블의 모든 event_id 관련 외래키 제거
|
||||
DO $$
|
||||
DECLARE
|
||||
constraint_name TEXT;
|
||||
BEGIN
|
||||
FOR constraint_name IN
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_name = 'ai_recommendations'
|
||||
AND kcu.column_name = 'event_id'
|
||||
AND tc.table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'ALTER TABLE ai_recommendations DROP CONSTRAINT IF EXISTS ' || constraint_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- jobs 테이블의 모든 event_id 관련 외래키 제거
|
||||
DO $$
|
||||
DECLARE
|
||||
constraint_name TEXT;
|
||||
BEGIN
|
||||
FOR constraint_name IN
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_name = 'jobs'
|
||||
AND kcu.column_name = 'event_id'
|
||||
AND tc.table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'ALTER TABLE jobs DROP CONSTRAINT IF EXISTS ' || constraint_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
|
||||
-- 2. 컬럼 타입 변경 (UUID/기타 → VARCHAR)
|
||||
-- ====================================================================================================
|
||||
-- 현재 타입에 관계없이 VARCHAR(50)으로 변환
|
||||
-- UUID, BIGINT 등 모든 타입을 텍스트로 변환
|
||||
|
||||
-- events 테이블의 event_id 컬럼 타입 변경 (Primary Key)
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE events ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE NOTICE 'events.event_id 변환 중 오류: %', SQLERRM;
|
||||
END $$;
|
||||
|
||||
-- event_channels 테이블의 event_id 컬럼 타입 변경
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE event_channels ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE NOTICE 'event_channels.event_id 변환 중 오류: %', SQLERRM;
|
||||
END $$;
|
||||
|
||||
-- generated_images 테이블의 event_id 컬럼 타입 변경
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE generated_images ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE NOTICE 'generated_images.event_id 변환 중 오류: %', SQLERRM;
|
||||
END $$;
|
||||
|
||||
-- ai_recommendations 테이블의 event_id 컬럼 타입 변경
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE ai_recommendations ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE NOTICE 'ai_recommendations.event_id 변환 중 오류: %', SQLERRM;
|
||||
END $$;
|
||||
|
||||
-- jobs 테이블의 event_id 컬럼 타입 변경 (NULL 허용)
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE jobs ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE NOTICE 'jobs.event_id 변환 중 오류: %', SQLERRM;
|
||||
END $$;
|
||||
|
||||
|
||||
-- 3. 외래키 제약조건 재생성
|
||||
-- ====================================================================================================
|
||||
|
||||
-- event_channels 테이블의 외래키 재생성
|
||||
ALTER TABLE event_channels
|
||||
ADD CONSTRAINT fk_event_channels_event
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- generated_images 테이블의 외래키 재생성
|
||||
ALTER TABLE generated_images
|
||||
ADD CONSTRAINT fk_generated_images_event
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- ai_recommendations 테이블의 외래키 재생성
|
||||
ALTER TABLE ai_recommendations
|
||||
ADD CONSTRAINT fk_ai_recommendations_event
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- jobs 테이블의 외래키 재생성
|
||||
ALTER TABLE jobs
|
||||
ADD CONSTRAINT fk_jobs_event
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
|
||||
-- 4. 인덱스 확인 (옵션)
|
||||
-- ====================================================================================================
|
||||
-- 기존 인덱스들이 자동으로 유지되는지 확인
|
||||
-- \d events
|
||||
-- \d event_channels
|
||||
-- \d generated_images
|
||||
-- \d ai_recommendations
|
||||
-- \d jobs
|
||||
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 롤백 스크립트 (필요시 사용)
|
||||
-- ====================================================================================================
|
||||
/*
|
||||
-- 1. 외래키 제약조건 제거
|
||||
ALTER TABLE event_channels DROP CONSTRAINT IF EXISTS fk_event_channels_event;
|
||||
ALTER TABLE generated_images DROP CONSTRAINT IF EXISTS fk_generated_images_event;
|
||||
ALTER TABLE ai_recommendations DROP CONSTRAINT IF EXISTS fk_ai_recommendations_event;
|
||||
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS fk_jobs_event;
|
||||
|
||||
-- 2. 컬럼 타입 원복 (VARCHAR → UUID)
|
||||
ALTER TABLE events ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
|
||||
ALTER TABLE event_channels ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
|
||||
ALTER TABLE generated_images ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
|
||||
ALTER TABLE ai_recommendations ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
|
||||
ALTER TABLE jobs ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
|
||||
|
||||
-- 4. 외래키 제약조건 재생성
|
||||
ALTER TABLE event_channels ADD CONSTRAINT fk_event_channels_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
|
||||
ALTER TABLE generated_images ADD CONSTRAINT fk_generated_images_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
|
||||
ALTER TABLE ai_recommendations ADD CONSTRAINT fk_ai_recommendations_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
|
||||
ALTER TABLE jobs ADD CONSTRAINT fk_jobs_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE SET NULL;
|
||||
*/
|
||||
@@ -1,233 +0,0 @@
|
||||
-- ====================================================================================================
|
||||
-- Event Service 테이블 생성 스크립트 - PostgreSQL
|
||||
-- ====================================================================================================
|
||||
-- 작성일: 2025-10-29
|
||||
-- 작성자: Backend Development Team
|
||||
-- 설명: Event 서비스의 모든 테이블을 생성합니다.
|
||||
-- 참고: FK(Foreign Key) 제약조건은 제외되어 있습니다.
|
||||
-- ====================================================================================================
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 1. events 테이블 - 이벤트 메인 테이블
|
||||
-- ====================================================================================================
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id VARCHAR(50) PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
store_id VARCHAR(50) NOT NULL,
|
||||
event_name VARCHAR(200),
|
||||
description TEXT,
|
||||
objective VARCHAR(100) NOT NULL,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
selected_image_id VARCHAR(50),
|
||||
selected_image_url VARCHAR(500),
|
||||
participants INTEGER DEFAULT 0,
|
||||
target_participants INTEGER,
|
||||
roi DOUBLE PRECISION DEFAULT 0.0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- events 테이블 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_store_id ON events(store_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_status ON events(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||
|
||||
COMMENT ON TABLE events IS '이벤트 메인 테이블';
|
||||
COMMENT ON COLUMN events.event_id IS '이벤트 ID (Primary Key)';
|
||||
COMMENT ON COLUMN events.user_id IS '사용자 ID';
|
||||
COMMENT ON COLUMN events.store_id IS '상점 ID';
|
||||
COMMENT ON COLUMN events.event_name IS '이벤트명';
|
||||
COMMENT ON COLUMN events.description IS '이벤트 설명';
|
||||
COMMENT ON COLUMN events.objective IS '이벤트 목적';
|
||||
COMMENT ON COLUMN events.start_date IS '이벤트 시작일';
|
||||
COMMENT ON COLUMN events.end_date IS '이벤트 종료일';
|
||||
COMMENT ON COLUMN events.status IS '이벤트 상태 (DRAFT, PUBLISHED, ENDED)';
|
||||
COMMENT ON COLUMN events.selected_image_id IS '선택된 이미지 ID';
|
||||
COMMENT ON COLUMN events.selected_image_url IS '선택된 이미지 URL';
|
||||
COMMENT ON COLUMN events.participants IS '참여자 수';
|
||||
COMMENT ON COLUMN events.target_participants IS '목표 참여자 수';
|
||||
COMMENT ON COLUMN events.roi IS 'ROI (투자 대비 수익률)';
|
||||
COMMENT ON COLUMN events.created_at IS '생성일시';
|
||||
COMMENT ON COLUMN events.updated_at IS '수정일시';
|
||||
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 2. event_channels 테이블 - 이벤트 배포 채널 (ElementCollection)
|
||||
-- ====================================================================================================
|
||||
CREATE TABLE IF NOT EXISTS event_channels (
|
||||
event_id VARCHAR(50) NOT NULL,
|
||||
channel VARCHAR(50)
|
||||
);
|
||||
|
||||
-- event_channels 테이블 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_event_channels_event_id ON event_channels(event_id);
|
||||
|
||||
COMMENT ON TABLE event_channels IS '이벤트 배포 채널 테이블';
|
||||
COMMENT ON COLUMN event_channels.event_id IS '이벤트 ID';
|
||||
COMMENT ON COLUMN event_channels.channel IS '배포 채널명';
|
||||
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 3. generated_images 테이블 - 생성된 이미지
|
||||
-- ====================================================================================================
|
||||
CREATE TABLE IF NOT EXISTS generated_images (
|
||||
image_id VARCHAR(50) PRIMARY KEY,
|
||||
event_id VARCHAR(50) NOT NULL,
|
||||
image_url VARCHAR(500) NOT NULL,
|
||||
style VARCHAR(50),
|
||||
platform VARCHAR(50),
|
||||
is_selected BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- generated_images 테이블 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_images_event_id ON generated_images(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_images_is_selected ON generated_images(is_selected);
|
||||
|
||||
COMMENT ON TABLE generated_images IS 'AI가 생성한 이미지 테이블';
|
||||
COMMENT ON COLUMN generated_images.image_id IS '이미지 ID (Primary Key)';
|
||||
COMMENT ON COLUMN generated_images.event_id IS '이벤트 ID';
|
||||
COMMENT ON COLUMN generated_images.image_url IS '이미지 URL';
|
||||
COMMENT ON COLUMN generated_images.style IS '이미지 스타일';
|
||||
COMMENT ON COLUMN generated_images.platform IS '타겟 플랫폼';
|
||||
COMMENT ON COLUMN generated_images.is_selected IS '선택 여부';
|
||||
COMMENT ON COLUMN generated_images.created_at IS '생성일시';
|
||||
COMMENT ON COLUMN generated_images.updated_at IS '수정일시';
|
||||
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 4. ai_recommendations 테이블 - AI 추천 기획안
|
||||
-- ====================================================================================================
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
recommendation_id VARCHAR(50) PRIMARY KEY,
|
||||
event_id VARCHAR(50) NOT NULL,
|
||||
event_name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
promotion_type VARCHAR(50),
|
||||
target_audience VARCHAR(100),
|
||||
is_selected BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ai_recommendations 테이블 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_recommendations_event_id ON ai_recommendations(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_recommendations_is_selected ON ai_recommendations(is_selected);
|
||||
|
||||
COMMENT ON TABLE ai_recommendations IS 'AI 추천 이벤트 기획안 테이블';
|
||||
COMMENT ON COLUMN ai_recommendations.recommendation_id IS '추천 ID (Primary Key)';
|
||||
COMMENT ON COLUMN ai_recommendations.event_id IS '이벤트 ID';
|
||||
COMMENT ON COLUMN ai_recommendations.event_name IS '추천 이벤트명';
|
||||
COMMENT ON COLUMN ai_recommendations.description IS '추천 설명';
|
||||
COMMENT ON COLUMN ai_recommendations.promotion_type IS '프로모션 유형';
|
||||
COMMENT ON COLUMN ai_recommendations.target_audience IS '타겟 고객층';
|
||||
COMMENT ON COLUMN ai_recommendations.is_selected IS '선택 여부';
|
||||
COMMENT ON COLUMN ai_recommendations.created_at IS '생성일시';
|
||||
COMMENT ON COLUMN ai_recommendations.updated_at IS '수정일시';
|
||||
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 5. jobs 테이블 - 비동기 작업 관리
|
||||
-- ====================================================================================================
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id VARCHAR(50) PRIMARY KEY,
|
||||
event_id VARCHAR(50) NOT NULL,
|
||||
job_type VARCHAR(30) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
result_key VARCHAR(200),
|
||||
error_message VARCHAR(500),
|
||||
completed_at TIMESTAMP,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retry_count INTEGER NOT NULL DEFAULT 3,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- jobs 테이블 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_event_id ON jobs(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_job_type ON jobs(job_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at);
|
||||
|
||||
COMMENT ON TABLE jobs IS '비동기 작업 관리 테이블';
|
||||
COMMENT ON COLUMN jobs.job_id IS '작업 ID (Primary Key)';
|
||||
COMMENT ON COLUMN jobs.event_id IS '이벤트 ID';
|
||||
COMMENT ON COLUMN jobs.job_type IS '작업 유형 (AI_RECOMMENDATION, IMAGE_GENERATION)';
|
||||
COMMENT ON COLUMN jobs.status IS '작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)';
|
||||
COMMENT ON COLUMN jobs.progress IS '진행률 (0-100)';
|
||||
COMMENT ON COLUMN jobs.result_key IS '결과 키';
|
||||
COMMENT ON COLUMN jobs.error_message IS '에러 메시지';
|
||||
COMMENT ON COLUMN jobs.completed_at IS '완료일시';
|
||||
COMMENT ON COLUMN jobs.retry_count IS '재시도 횟수';
|
||||
COMMENT ON COLUMN jobs.max_retry_count IS '최대 재시도 횟수';
|
||||
COMMENT ON COLUMN jobs.created_at IS '생성일시';
|
||||
COMMENT ON COLUMN jobs.updated_at IS '수정일시';
|
||||
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 6. updated_at 자동 업데이트를 위한 트리거 함수 생성
|
||||
-- ====================================================================================================
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 7. 각 테이블에 updated_at 자동 업데이트 트리거 적용
|
||||
-- ====================================================================================================
|
||||
|
||||
-- events 테이블 트리거
|
||||
DROP TRIGGER IF EXISTS update_events_updated_at ON events;
|
||||
CREATE TRIGGER update_events_updated_at
|
||||
BEFORE UPDATE ON events
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- generated_images 테이블 트리거
|
||||
DROP TRIGGER IF EXISTS update_generated_images_updated_at ON generated_images;
|
||||
CREATE TRIGGER update_generated_images_updated_at
|
||||
BEFORE UPDATE ON generated_images
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ai_recommendations 테이블 트리거
|
||||
DROP TRIGGER IF EXISTS update_ai_recommendations_updated_at ON ai_recommendations;
|
||||
CREATE TRIGGER update_ai_recommendations_updated_at
|
||||
BEFORE UPDATE ON ai_recommendations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- jobs 테이블 트리거
|
||||
DROP TRIGGER IF EXISTS update_jobs_updated_at ON jobs;
|
||||
CREATE TRIGGER update_jobs_updated_at
|
||||
BEFORE UPDATE ON jobs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
|
||||
-- ====================================================================================================
|
||||
-- 완료 메시지
|
||||
-- ====================================================================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '=================================================';
|
||||
RAISE NOTICE 'Event Service 테이블 생성이 완료되었습니다.';
|
||||
RAISE NOTICE '=================================================';
|
||||
RAISE NOTICE '생성된 테이블:';
|
||||
RAISE NOTICE ' 1. events - 이벤트 메인 테이블';
|
||||
RAISE NOTICE ' 2. event_channels - 이벤트 배포 채널';
|
||||
RAISE NOTICE ' 3. generated_images - 생성된 이미지';
|
||||
RAISE NOTICE ' 4. ai_recommendations - AI 추천 기획안';
|
||||
RAISE NOTICE ' 5. jobs - 비동기 작업 관리';
|
||||
RAISE NOTICE '=================================================';
|
||||
RAISE NOTICE '참고: FK 제약조건은 생성되지 않았습니다.';
|
||||
RAISE NOTICE '=================================================';
|
||||
END $$;
|
||||
@@ -39,7 +39,7 @@ public class OpenApiConfig {
|
||||
.email("support@kt-event-marketing.com")))
|
||||
.servers(List.of(
|
||||
new Server()
|
||||
.url("http://localhost:8085")
|
||||
.url("http://localhost:8085/api/v1/distribution")
|
||||
.description("Local Development Server"),
|
||||
new Server()
|
||||
.url("https://dev-api.kt-event-marketing.com/distribution/v1")
|
||||
@@ -48,7 +48,7 @@ public class OpenApiConfig {
|
||||
.url("https://api.kt-event-marketing.com/distribution/v1")
|
||||
.description("Production Server"),
|
||||
new Server()
|
||||
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1")
|
||||
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/distribution")
|
||||
.description("VM Development Server")
|
||||
));
|
||||
}
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Distribution Controller
|
||||
* POST api/v1/distribution/distribute - 다중 채널 배포 실행
|
||||
* GET api/v1/distribution/{eventId}/status - 배포 상태 조회
|
||||
* POST /distribute - 다중 채널 배포 실행
|
||||
* GET /{eventId}/status - 배포 상태 조회
|
||||
*
|
||||
* @author System Architect
|
||||
* @since 2025-10-23
|
||||
|
||||
@@ -123,6 +123,15 @@ channel:
|
||||
url: ${KAKAO_API_URL:http://localhost:9006/api/kakao}
|
||||
timeout: 10000
|
||||
|
||||
# Naver Blog Configuration (Playwright 기반)
|
||||
naver:
|
||||
blog:
|
||||
username: ${NAVER_BLOG_USERNAME:}
|
||||
password: ${NAVER_BLOG_PASSWORD:}
|
||||
blog-id: ${NAVER_BLOG_ID:}
|
||||
headless: ${NAVER_BLOG_HEADLESS:true}
|
||||
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
|
||||
|
||||
# Springdoc OpenAPI (Swagger)
|
||||
springdoc:
|
||||
api-docs:
|
||||
|
||||
+45
-25
@@ -1,17 +1,18 @@
|
||||
package com.kt.event.eventservice.application.dto.kafka;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 이벤트 생성 작업 메시지 DTO
|
||||
*
|
||||
* ai-event-generation-job 토픽에서 구독하는 메시지 형식
|
||||
* JSON 필드명: camelCase (Jackson 기본 설정)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -22,54 +23,73 @@ public class AIEventGenerationJobMessage {
|
||||
/**
|
||||
* 작업 ID
|
||||
*/
|
||||
@JsonProperty("job_id")
|
||||
private String jobId;
|
||||
|
||||
/**
|
||||
* 사용자 ID (UUID String)
|
||||
*/
|
||||
@JsonProperty("user_id")
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 이벤트 ID
|
||||
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)
|
||||
*/
|
||||
private String eventId;
|
||||
@JsonProperty("status")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 이벤트 목적
|
||||
* - "신규 고객 유치"
|
||||
* - "재방문 유도"
|
||||
* - "매출 증대"
|
||||
* - "브랜드 인지도 향상"
|
||||
* AI 추천 결과 데이터
|
||||
*/
|
||||
private String objective;
|
||||
@JsonProperty("ai_recommendation")
|
||||
private AIRecommendationData aiRecommendation;
|
||||
|
||||
/**
|
||||
* 업종 (storeCategory와 동일)
|
||||
* 에러 메시지 (실패 시)
|
||||
*/
|
||||
private String industry;
|
||||
@JsonProperty("error_message")
|
||||
private String errorMessage;
|
||||
|
||||
/**
|
||||
* 지역 (시/구/동)
|
||||
* 작업 생성 일시
|
||||
*/
|
||||
private String region;
|
||||
@JsonProperty("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 매장명
|
||||
* 작업 완료/실패 일시
|
||||
*/
|
||||
private String storeName;
|
||||
@JsonProperty("completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
/**
|
||||
* 목표 고객층 (선택)
|
||||
* AI 추천 데이터 내부 클래스
|
||||
*/
|
||||
private String targetAudience;
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class AIRecommendationData {
|
||||
|
||||
/**
|
||||
* 예산 (원) (선택)
|
||||
*/
|
||||
private Integer budget;
|
||||
@JsonProperty("event_title")
|
||||
private String eventTitle;
|
||||
|
||||
/**
|
||||
* 요청 시각
|
||||
*/
|
||||
private LocalDateTime requestedAt;
|
||||
@JsonProperty("event_description")
|
||||
private String eventDescription;
|
||||
|
||||
@JsonProperty("event_type")
|
||||
private String eventType;
|
||||
|
||||
@JsonProperty("target_keywords")
|
||||
private List<String> targetKeywords;
|
||||
|
||||
@JsonProperty("recommended_benefits")
|
||||
private List<String> recommendedBenefits;
|
||||
|
||||
@JsonProperty("start_date")
|
||||
private String startDate;
|
||||
|
||||
@JsonProperty("end_date")
|
||||
private String endDate;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 생성 완료 메시지 DTO
|
||||
@@ -20,16 +21,16 @@ import java.time.LocalDateTime;
|
||||
public class EventCreatedMessage {
|
||||
|
||||
/**
|
||||
* 이벤트 ID
|
||||
* 이벤트 ID (UUID)
|
||||
*/
|
||||
@JsonProperty("event_id")
|
||||
private String eventId;
|
||||
private UUID eventId;
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
* 사용자 ID (UUID)
|
||||
*/
|
||||
@JsonProperty("user_id")
|
||||
private String userId;
|
||||
private UUID userId;
|
||||
|
||||
/**
|
||||
* 이벤트 제목
|
||||
|
||||
+4
-15
@@ -8,6 +8,8 @@ import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AI 추천 요청 DTO
|
||||
*
|
||||
@@ -24,24 +26,11 @@ import lombok.NoArgsConstructor;
|
||||
@Schema(description = "AI 추천 요청")
|
||||
public class AiRecommendationRequest {
|
||||
|
||||
@NotNull(message = "이벤트 목적은 필수입니다.")
|
||||
@Schema(description = "이벤트 목적", required = true, example = "신규 고객 유치")
|
||||
private String objective;
|
||||
|
||||
@NotNull(message = "매장 정보는 필수입니다.")
|
||||
@Valid
|
||||
@Schema(description = "매장 정보", required = true)
|
||||
private StoreInfo storeInfo;
|
||||
|
||||
@Schema(description = "지역 정보", example = "서울특별시 강남구")
|
||||
private String region;
|
||||
|
||||
@Schema(description = "타겟 고객층", example = "20-30대 직장인")
|
||||
private String targetAudience;
|
||||
|
||||
@Schema(description = "예산 (원)", example = "500000")
|
||||
private Integer budget;
|
||||
|
||||
/**
|
||||
* 매장 정보
|
||||
*/
|
||||
@@ -53,8 +42,8 @@ public class AiRecommendationRequest {
|
||||
public static class StoreInfo {
|
||||
|
||||
@NotNull(message = "매장 ID는 필수입니다.")
|
||||
@Schema(description = "매장 ID", required = true, example = "str_20250124_001")
|
||||
private String storeId;
|
||||
@Schema(description = "매장 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440002")
|
||||
private UUID storeId;
|
||||
|
||||
@NotNull(message = "매장명은 필수입니다.")
|
||||
@Schema(description = "매장명", required = true, example = "우진네 고깃집")
|
||||
|
||||
+3
-1
@@ -6,6 +6,8 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이미지 선택 요청 DTO
|
||||
*
|
||||
@@ -20,7 +22,7 @@ import lombok.NoArgsConstructor;
|
||||
public class SelectImageRequest {
|
||||
|
||||
@NotNull(message = "이미지 ID는 필수입니다.")
|
||||
private String imageId;
|
||||
private UUID imageId;
|
||||
|
||||
private String imageUrl;
|
||||
}
|
||||
|
||||
-3
@@ -19,9 +19,6 @@ import lombok.NoArgsConstructor;
|
||||
@Builder
|
||||
public class SelectObjectiveRequest {
|
||||
|
||||
@NotBlank(message = "이벤트 ID는 필수입니다.")
|
||||
private String eventId;
|
||||
|
||||
@NotBlank(message = "이벤트 목적은 필수입니다.")
|
||||
private String objective;
|
||||
}
|
||||
|
||||
+3
-2
@@ -9,6 +9,7 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AI 추천 선택 요청 DTO
|
||||
@@ -27,8 +28,8 @@ import java.time.LocalDate;
|
||||
public class SelectRecommendationRequest {
|
||||
|
||||
@NotNull(message = "추천 ID는 필수입니다.")
|
||||
@Schema(description = "선택한 추천 ID", required = true, example = "rec_20250124_001")
|
||||
private String recommendationId;
|
||||
@Schema(description = "선택한 추천 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440007")
|
||||
private UUID recommendationId;
|
||||
|
||||
@Valid
|
||||
@Schema(description = "커스터마이징 항목")
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 생성 응답 DTO
|
||||
@@ -21,7 +22,7 @@ import java.time.LocalDateTime;
|
||||
@Builder
|
||||
public class EventCreatedResponse {
|
||||
|
||||
private String eventId;
|
||||
private UUID eventId;
|
||||
private EventStatus status;
|
||||
private String objective;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
+7
-6
@@ -10,6 +10,7 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 상세 응답 DTO
|
||||
@@ -24,16 +25,16 @@ import java.util.List;
|
||||
@Builder
|
||||
public class EventDetailResponse {
|
||||
|
||||
private String eventId;
|
||||
private String userId;
|
||||
private String storeId;
|
||||
private UUID eventId;
|
||||
private UUID userId;
|
||||
private UUID storeId;
|
||||
private String eventName;
|
||||
private String description;
|
||||
private String objective;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private EventStatus status;
|
||||
private String selectedImageId;
|
||||
private UUID selectedImageId;
|
||||
private String selectedImageUrl;
|
||||
private Integer participants;
|
||||
private Integer targetParticipants;
|
||||
@@ -56,7 +57,7 @@ public class EventDetailResponse {
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class GeneratedImageDto {
|
||||
private String imageId;
|
||||
private UUID imageId;
|
||||
private String imageUrl;
|
||||
private String style;
|
||||
private String platform;
|
||||
@@ -69,7 +70,7 @@ public class EventDetailResponse {
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class AiRecommendationDto {
|
||||
private String recommendationId;
|
||||
private UUID recommendationId;
|
||||
private String eventName;
|
||||
private String description;
|
||||
private String promotionType;
|
||||
|
||||
+3
-2
@@ -7,6 +7,7 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이미지 편집 응답 DTO
|
||||
@@ -24,8 +25,8 @@ import java.time.LocalDateTime;
|
||||
@Schema(description = "이미지 편집 응답")
|
||||
public class ImageEditResponse {
|
||||
|
||||
@Schema(description = "편집된 이미지 ID", example = "img_20250124_001")
|
||||
private String imageId;
|
||||
@Schema(description = "편집된 이미지 ID", example = "550e8400-e29b-41d4-a716-446655440008")
|
||||
private UUID imageId;
|
||||
|
||||
@Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg")
|
||||
private String imageUrl;
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이미지 생성 응답 DTO
|
||||
@@ -20,7 +21,7 @@ import java.time.LocalDateTime;
|
||||
@Builder
|
||||
public class ImageGenerationResponse {
|
||||
|
||||
private String jobId;
|
||||
private UUID jobId;
|
||||
private String status;
|
||||
private String message;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
+4
-2
@@ -7,6 +7,8 @@ import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Job 접수 응답 DTO
|
||||
*
|
||||
@@ -23,8 +25,8 @@ import lombok.NoArgsConstructor;
|
||||
@Schema(description = "Job 접수 응답")
|
||||
public class JobAcceptedResponse {
|
||||
|
||||
@Schema(description = "생성된 Job ID", example = "job_20250124_001")
|
||||
private String jobId;
|
||||
@Schema(description = "생성된 Job ID", example = "550e8400-e29b-41d4-a716-446655440005")
|
||||
private UUID jobId;
|
||||
|
||||
@Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING")
|
||||
private JobStatus status;
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Job 상태 응답 DTO
|
||||
@@ -22,7 +23,7 @@ import java.time.LocalDateTime;
|
||||
@Builder
|
||||
public class JobStatusResponse {
|
||||
|
||||
private String jobId;
|
||||
private UUID jobId;
|
||||
private JobType jobType;
|
||||
private JobStatus status;
|
||||
private int progress;
|
||||
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
package com.kt.event.eventservice.application.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 ID 생성기
|
||||
*
|
||||
* 비즈니스 친화적인 eventId를 생성합니다.
|
||||
* 형식: EVT-{storeId}-{yyyyMMddHHmmss}-{random8}
|
||||
* 예시: EVT-store123-20251029143025-a1b2c3d4
|
||||
*
|
||||
* VARCHAR(50) 길이 제약사항을 고려하여 설계되었습니다.
|
||||
*/
|
||||
@Component
|
||||
public class EventIdGenerator {
|
||||
|
||||
private static final String PREFIX = "EVT";
|
||||
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private static final int RANDOM_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* 이벤트 ID 생성 (백엔드용)
|
||||
*
|
||||
* 참고: 현재는 프론트엔드에서 eventId를 생성하므로 이 메서드는 거의 사용되지 않습니다.
|
||||
*
|
||||
* @param storeId 상점 ID
|
||||
* @return 생성된 이벤트 ID
|
||||
*/
|
||||
public String generate(String storeId) {
|
||||
// 기본값 처리
|
||||
if (storeId == null || storeId.isBlank()) {
|
||||
storeId = "unknown";
|
||||
}
|
||||
|
||||
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER);
|
||||
String randomPart = generateRandomPart();
|
||||
|
||||
// 형식: EVT-{storeId}-{timestamp}-{random}
|
||||
String eventId = String.format("%s-%s-%s-%s", PREFIX, storeId, timestamp, randomPart);
|
||||
|
||||
return eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID 기반 랜덤 문자열 생성
|
||||
*
|
||||
* @return 8자리 랜덤 문자열 (소문자 영숫자)
|
||||
*/
|
||||
private String generateRandomPart() {
|
||||
return UUID.randomUUID()
|
||||
.toString()
|
||||
.replace("-", "")
|
||||
.substring(0, RANDOM_LENGTH)
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* eventId 기본 검증
|
||||
*
|
||||
* 최소한의 검증만 수행합니다:
|
||||
* - null/empty 체크
|
||||
* - 길이 제한 체크 (VARCHAR(50) 제약)
|
||||
*
|
||||
* 프론트엔드에서 생성한 eventId를 신뢰하며,
|
||||
* DB의 PRIMARY KEY 제약조건으로 중복을 방지합니다.
|
||||
*
|
||||
* @param eventId 검증할 이벤트 ID
|
||||
* @return 유효하면 true, 아니면 false
|
||||
*/
|
||||
public boolean isValid(String eventId) {
|
||||
if (eventId == null || eventId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 길이 검증 (DB VARCHAR(50) 제약)
|
||||
if (eventId.length() > 50) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+45
-82
@@ -24,6 +24,7 @@ import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -47,32 +48,22 @@ public class EventService {
|
||||
private final AIJobKafkaProducer aiJobKafkaProducer;
|
||||
private final ImageJobKafkaProducer imageJobKafkaProducer;
|
||||
private final EventKafkaProducer eventKafkaProducer;
|
||||
private final EventIdGenerator eventIdGenerator;
|
||||
private final JobIdGenerator jobIdGenerator;
|
||||
|
||||
/**
|
||||
* 이벤트 생성 (Step 1: 목적 선택)
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param storeId 매장 ID
|
||||
* @param request 목적 선택 요청 (eventId 포함)
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param storeId 매장 ID (UUID)
|
||||
* @param request 목적 선택 요청
|
||||
* @return 생성된 이벤트 응답
|
||||
*/
|
||||
@Transactional
|
||||
public EventCreatedResponse createEvent(String userId, String storeId, SelectObjectiveRequest request) {
|
||||
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, eventId: {}, objective: {}",
|
||||
userId, storeId, request.getEventId(), request.getObjective());
|
||||
|
||||
String eventId = request.getEventId();
|
||||
|
||||
// 동일한 eventId가 이미 존재하는지 확인
|
||||
if (eventRepository.findByEventId(eventId).isPresent()) {
|
||||
throw new BusinessException(ErrorCode.EVENT_005);
|
||||
}
|
||||
public EventCreatedResponse createEvent(UUID userId, UUID storeId, SelectObjectiveRequest request) {
|
||||
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}",
|
||||
userId, storeId, request.getObjective());
|
||||
|
||||
// 이벤트 엔티티 생성
|
||||
Event event = Event.builder()
|
||||
.eventId(eventId)
|
||||
.userId(userId)
|
||||
.storeId(storeId)
|
||||
.objective(request.getObjective())
|
||||
@@ -96,11 +87,11 @@ public class EventService {
|
||||
/**
|
||||
* 이벤트 상세 조회
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 이벤트 상세 응답
|
||||
*/
|
||||
public EventDetailResponse getEvent(String userId, String eventId) {
|
||||
public EventDetailResponse getEvent(UUID userId, UUID eventId) {
|
||||
log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
@@ -117,7 +108,7 @@ public class EventService {
|
||||
/**
|
||||
* 이벤트 목록 조회 (페이징, 필터링)
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param status 상태 필터
|
||||
* @param search 검색어
|
||||
* @param objective 목적 필터
|
||||
@@ -125,7 +116,7 @@ public class EventService {
|
||||
* @return 이벤트 목록
|
||||
*/
|
||||
public Page<EventDetailResponse> getEvents(
|
||||
String userId,
|
||||
UUID userId,
|
||||
EventStatus status,
|
||||
String search,
|
||||
String objective,
|
||||
@@ -148,11 +139,11 @@ public class EventService {
|
||||
/**
|
||||
* 이벤트 삭제
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteEvent(String userId, String eventId) {
|
||||
public void deleteEvent(UUID userId, UUID eventId) {
|
||||
log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
@@ -170,11 +161,11 @@ public class EventService {
|
||||
/**
|
||||
* 이벤트 배포
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void publishEvent(String userId, String eventId) {
|
||||
public void publishEvent(UUID userId, UUID eventId) {
|
||||
log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
@@ -199,11 +190,11 @@ public class EventService {
|
||||
/**
|
||||
* 이벤트 종료
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void endEvent(String userId, String eventId) {
|
||||
public void endEvent(UUID userId, UUID eventId) {
|
||||
log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
@@ -219,13 +210,13 @@ public class EventService {
|
||||
/**
|
||||
* 이미지 생성 요청
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @param request 이미지 생성 요청
|
||||
* @return 이미지 생성 응답 (Job ID 포함)
|
||||
*/
|
||||
@Transactional
|
||||
public ImageGenerationResponse requestImageGeneration(String userId, String eventId, ImageGenerationRequest request) {
|
||||
public ImageGenerationResponse requestImageGeneration(UUID userId, UUID eventId, ImageGenerationRequest request) {
|
||||
log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
// 이벤트 조회 및 권한 확인
|
||||
@@ -245,11 +236,7 @@ public class EventService {
|
||||
String.join(", ", request.getPlatforms()));
|
||||
|
||||
// Job 엔티티 생성
|
||||
String jobId = jobIdGenerator.generate(JobType.IMAGE_GENERATION);
|
||||
log.info("생성된 jobId: {}", jobId);
|
||||
|
||||
Job job = Job.builder()
|
||||
.jobId(jobId)
|
||||
.eventId(eventId)
|
||||
.jobType(JobType.IMAGE_GENERATION)
|
||||
.build();
|
||||
@@ -258,9 +245,9 @@ public class EventService {
|
||||
|
||||
// Kafka 메시지 발행
|
||||
imageJobKafkaProducer.publishImageGenerationJob(
|
||||
job.getJobId(),
|
||||
userId,
|
||||
eventId,
|
||||
job.getJobId().toString(),
|
||||
userId.toString(),
|
||||
eventId.toString(),
|
||||
prompt
|
||||
);
|
||||
|
||||
@@ -278,13 +265,13 @@ public class EventService {
|
||||
/**
|
||||
* 이미지 선택
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @param imageId 이미지 ID
|
||||
* @param request 이미지 선택 요청
|
||||
*/
|
||||
@Transactional
|
||||
public void selectImage(String userId, String eventId, String imageId, SelectImageRequest request) {
|
||||
public void selectImage(UUID userId, UUID eventId, UUID imageId, SelectImageRequest request) {
|
||||
log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId);
|
||||
|
||||
// 이벤트 조회 및 권한 확인
|
||||
@@ -307,36 +294,18 @@ public class EventService {
|
||||
/**
|
||||
* AI 추천 요청
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param eventId 이벤트 ID (프론트엔드에서 생성한 ID)
|
||||
* @param request AI 추천 요청 (objective 포함)
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @param request AI 추천 요청
|
||||
* @return Job 접수 응답
|
||||
*/
|
||||
@Transactional
|
||||
public JobAcceptedResponse requestAiRecommendations(String userId, String eventId, AiRecommendationRequest request) {
|
||||
log.info("AI 추천 요청 - userId: {}, eventId: {}, objective: {}",
|
||||
userId, eventId, request.getObjective());
|
||||
public JobAcceptedResponse requestAiRecommendations(UUID userId, UUID eventId, AiRecommendationRequest request) {
|
||||
log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
// 이벤트 조회 또는 생성
|
||||
// 이벤트 조회 및 권한 확인
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
.orElseGet(() -> {
|
||||
log.info("이벤트가 존재하지 않아 새로 생성합니다 - eventId: {}", eventId);
|
||||
|
||||
// storeId 추출 (eventId 형식: EVT-{storeId}-{timestamp}-{random})
|
||||
String storeId = request.getStoreInfo().getStoreId();
|
||||
|
||||
// 새 이벤트 생성
|
||||
Event newEvent = Event.builder()
|
||||
.eventId(eventId)
|
||||
.userId(userId)
|
||||
.storeId(storeId)
|
||||
.objective(request.getObjective())
|
||||
.eventName("") // 초기에는 비어있음, AI 추천 후 설정
|
||||
.status(EventStatus.DRAFT)
|
||||
.build();
|
||||
|
||||
return eventRepository.save(newEvent);
|
||||
});
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
|
||||
|
||||
// DRAFT 상태 확인
|
||||
if (!event.isModifiable()) {
|
||||
@@ -344,11 +313,7 @@ public class EventService {
|
||||
}
|
||||
|
||||
// Job 엔티티 생성
|
||||
String jobId = jobIdGenerator.generate(JobType.AI_RECOMMENDATION);
|
||||
log.info("생성된 jobId: {}", jobId);
|
||||
|
||||
Job job = Job.builder()
|
||||
.jobId(jobId)
|
||||
.eventId(eventId)
|
||||
.jobType(JobType.AI_RECOMMENDATION)
|
||||
.build();
|
||||
@@ -357,15 +322,13 @@ public class EventService {
|
||||
|
||||
// Kafka 메시지 발행
|
||||
aiJobKafkaProducer.publishAIGenerationJob(
|
||||
job.getJobId(),
|
||||
userId,
|
||||
eventId,
|
||||
job.getJobId().toString(),
|
||||
userId.toString(),
|
||||
eventId.toString(),
|
||||
request.getStoreInfo().getStoreName(),
|
||||
request.getStoreInfo().getCategory(), // industry
|
||||
request.getRegion(), // region
|
||||
event.getObjective(), // objective
|
||||
request.getTargetAudience(), // targetAudience
|
||||
request.getBudget() // budget
|
||||
request.getStoreInfo().getCategory(),
|
||||
request.getStoreInfo().getDescription(),
|
||||
event.getObjective()
|
||||
);
|
||||
|
||||
log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId());
|
||||
@@ -380,12 +343,12 @@ public class EventService {
|
||||
/**
|
||||
* AI 추천 선택
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @param request AI 추천 선택 요청
|
||||
*/
|
||||
@Transactional
|
||||
public void selectRecommendation(String userId, String eventId, SelectRecommendationRequest request) {
|
||||
public void selectRecommendation(UUID userId, UUID eventId, SelectRecommendationRequest request) {
|
||||
log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}",
|
||||
userId, eventId, request.getRecommendationId());
|
||||
|
||||
@@ -446,14 +409,14 @@ public class EventService {
|
||||
/**
|
||||
* 이미지 편집
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @param imageId 이미지 ID
|
||||
* @param request 이미지 편집 요청
|
||||
* @return 이미지 편집 응답
|
||||
*/
|
||||
@Transactional
|
||||
public ImageEditResponse editImage(String userId, String eventId, String imageId, ImageEditRequest request) {
|
||||
public ImageEditResponse editImage(UUID userId, UUID eventId, UUID imageId, ImageEditRequest request) {
|
||||
log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId);
|
||||
|
||||
// 이벤트 조회 및 권한 확인
|
||||
@@ -487,12 +450,12 @@ public class EventService {
|
||||
/**
|
||||
* 배포 채널 선택
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @param request 배포 채널 선택 요청
|
||||
*/
|
||||
@Transactional
|
||||
public void selectChannels(String userId, String eventId, SelectChannelsRequest request) {
|
||||
public void selectChannels(UUID userId, UUID eventId, SelectChannelsRequest request) {
|
||||
log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}",
|
||||
userId, eventId, request.getChannels());
|
||||
|
||||
@@ -516,13 +479,13 @@ public class EventService {
|
||||
/**
|
||||
* 이벤트 수정
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @param request 이벤트 수정 요청
|
||||
* @return 이벤트 상세 응답
|
||||
*/
|
||||
@Transactional
|
||||
public EventDetailResponse updateEvent(String userId, String eventId, UpdateEventRequest request) {
|
||||
public EventDetailResponse updateEvent(UUID userId, UUID eventId, UpdateEventRequest request) {
|
||||
log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
// 이벤트 조회 및 권한 확인
|
||||
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
package com.kt.event.eventservice.application.service;
|
||||
|
||||
import com.kt.event.eventservice.domain.enums.JobType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Job ID 생성기
|
||||
*
|
||||
* 비즈니스 친화적인 jobId를 생성합니다.
|
||||
* 형식: JOB-{jobType}-{timestamp}-{random8}
|
||||
* 예시: JOB-AI-20251029143025-a1b2c3d4
|
||||
*
|
||||
* VARCHAR(50) 길이 제약사항을 고려하여 설계되었습니다.
|
||||
*/
|
||||
@Component
|
||||
public class JobIdGenerator {
|
||||
|
||||
private static final String PREFIX = "JOB";
|
||||
private static final int RANDOM_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Job ID 생성
|
||||
*
|
||||
* @param jobType Job 타입
|
||||
* @return 생성된 Job ID
|
||||
* @throws IllegalArgumentException jobType이 null인 경우
|
||||
*/
|
||||
public String generate(JobType jobType) {
|
||||
if (jobType == null) {
|
||||
throw new IllegalArgumentException("jobType은 필수입니다");
|
||||
}
|
||||
|
||||
String typeCode = getTypeCode(jobType);
|
||||
String timestamp = String.valueOf(System.currentTimeMillis());
|
||||
String randomPart = generateRandomPart();
|
||||
|
||||
// 형식: JOB-{type}-{timestamp}-{random}
|
||||
// 예상 길이: 3 + 1 + 5 + 1 + 13 + 1 + 8 = 32자 (최대)
|
||||
String jobId = String.format("%s-%s-%s-%s", PREFIX, typeCode, timestamp, randomPart);
|
||||
|
||||
// 길이 검증
|
||||
if (jobId.length() > 50) {
|
||||
throw new IllegalStateException(
|
||||
String.format("생성된 jobId 길이(%d)가 50자를 초과했습니다: %s",
|
||||
jobId.length(), jobId)
|
||||
);
|
||||
}
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* JobType을 짧은 코드로 변환
|
||||
*
|
||||
* @param jobType Job 타입
|
||||
* @return 타입 코드
|
||||
*/
|
||||
private String getTypeCode(JobType jobType) {
|
||||
switch (jobType) {
|
||||
case AI_RECOMMENDATION:
|
||||
return "AI";
|
||||
case IMAGE_GENERATION:
|
||||
return "IMG";
|
||||
default:
|
||||
return jobType.name().substring(0, Math.min(5, jobType.name().length()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID 기반 랜덤 문자열 생성
|
||||
*
|
||||
* @return 8자리 랜덤 문자열 (소문자 영숫자)
|
||||
*/
|
||||
private String generateRandomPart() {
|
||||
return UUID.randomUUID()
|
||||
.toString()
|
||||
.replace("-", "")
|
||||
.substring(0, RANDOM_LENGTH)
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* jobId 기본 검증
|
||||
*
|
||||
* 최소한의 검증만 수행합니다:
|
||||
* - null/empty 체크
|
||||
* - 길이 제한 체크 (VARCHAR(50) 제약)
|
||||
*
|
||||
* @param jobId 검증할 Job ID
|
||||
* @return 유효하면 true, 아니면 false
|
||||
*/
|
||||
public boolean isValid(String jobId) {
|
||||
if (jobId == null || jobId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 길이 검증 (DB VARCHAR(50) 제약)
|
||||
if (jobId.length() > 50) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+7
-11
@@ -11,6 +11,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Job 서비스
|
||||
*
|
||||
@@ -27,7 +29,6 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class JobService {
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
private final JobIdGenerator jobIdGenerator;
|
||||
|
||||
/**
|
||||
* Job 생성
|
||||
@@ -37,15 +38,10 @@ public class JobService {
|
||||
* @return 생성된 Job
|
||||
*/
|
||||
@Transactional
|
||||
public Job createJob(String eventId, JobType jobType) {
|
||||
public Job createJob(UUID eventId, JobType jobType) {
|
||||
log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType);
|
||||
|
||||
// jobId 생성
|
||||
String jobId = jobIdGenerator.generate(jobType);
|
||||
log.info("생성된 jobId: {}", jobId);
|
||||
|
||||
Job job = Job.builder()
|
||||
.jobId(jobId)
|
||||
.eventId(eventId)
|
||||
.jobType(jobType)
|
||||
.build();
|
||||
@@ -63,7 +59,7 @@ public class JobService {
|
||||
* @param jobId Job ID
|
||||
* @return Job 상태 응답
|
||||
*/
|
||||
public JobStatusResponse getJobStatus(String jobId) {
|
||||
public JobStatusResponse getJobStatus(UUID jobId) {
|
||||
log.info("Job 상태 조회 - jobId: {}", jobId);
|
||||
|
||||
Job job = jobRepository.findById(jobId)
|
||||
@@ -79,7 +75,7 @@ public class JobService {
|
||||
* @param progress 진행률
|
||||
*/
|
||||
@Transactional
|
||||
public void updateJobProgress(String jobId, int progress) {
|
||||
public void updateJobProgress(UUID jobId, int progress) {
|
||||
log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress);
|
||||
|
||||
Job job = jobRepository.findById(jobId)
|
||||
@@ -97,7 +93,7 @@ public class JobService {
|
||||
* @param resultKey Redis 결과 키
|
||||
*/
|
||||
@Transactional
|
||||
public void completeJob(String jobId, String resultKey) {
|
||||
public void completeJob(UUID jobId, String resultKey) {
|
||||
log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey);
|
||||
|
||||
Job job = jobRepository.findById(jobId)
|
||||
@@ -117,7 +113,7 @@ public class JobService {
|
||||
* @param errorMessage 에러 메시지
|
||||
*/
|
||||
@Transactional
|
||||
public void failJob(String jobId, String errorMessage) {
|
||||
public void failJob(UUID jobId, String errorMessage) {
|
||||
log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage);
|
||||
|
||||
Job job = jobRepository.findById(jobId)
|
||||
|
||||
+5
-3
@@ -1,5 +1,7 @@
|
||||
package com.kt.event.eventservice.application.service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 알림 서비스 인터페이스
|
||||
*
|
||||
@@ -20,7 +22,7 @@ public interface NotificationService {
|
||||
* @param jobType 작업 타입
|
||||
* @param message 알림 메시지
|
||||
*/
|
||||
void notifyJobCompleted(String userId, String jobId, String jobType, String message);
|
||||
void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message);
|
||||
|
||||
/**
|
||||
* 작업 실패 알림 전송
|
||||
@@ -30,7 +32,7 @@ public interface NotificationService {
|
||||
* @param jobType 작업 타입
|
||||
* @param errorMessage 에러 메시지
|
||||
*/
|
||||
void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage);
|
||||
void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage);
|
||||
|
||||
/**
|
||||
* 작업 진행 상태 알림 전송
|
||||
@@ -40,5 +42,5 @@ public interface NotificationService {
|
||||
* @param jobType 작업 타입
|
||||
* @param progress 진행률 (0-100)
|
||||
*/
|
||||
void notifyJobProgress(String userId, String jobId, String jobType, int progress);
|
||||
void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress);
|
||||
}
|
||||
|
||||
+6
-5
@@ -11,6 +11,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 개발 환경용 인증 필터
|
||||
@@ -34,11 +35,11 @@ public class DevAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
// 개발용 기본 UserPrincipal 생성
|
||||
UserPrincipal userPrincipal = new UserPrincipal(
|
||||
"usr_dev_test_001", // userId
|
||||
"str_dev_test_001", // storeId
|
||||
"dev@test.com", // email
|
||||
"개발테스트사용자", // name
|
||||
Collections.singletonList("USER") // roles
|
||||
UUID.fromString("11111111-1111-1111-1111-111111111111"), // userId
|
||||
UUID.fromString("22222222-2222-2222-2222-222222222222"), // storeId
|
||||
"dev@test.com", // email
|
||||
"개발테스트사용자", // name
|
||||
Collections.singletonList("USER") // roles
|
||||
);
|
||||
|
||||
// Authentication 객체 생성 및 SecurityContext에 설정
|
||||
|
||||
@@ -37,7 +37,7 @@ public class KafkaConfig {
|
||||
|
||||
/**
|
||||
* Kafka Producer 설정
|
||||
* Producer에서 객체를 직접 보내므로 JsonSerializer 사용
|
||||
* Producer에서 JSON 문자열을 보내므로 StringSerializer 사용
|
||||
*
|
||||
* @return ProducerFactory 인스턴스
|
||||
*/
|
||||
@@ -46,10 +46,7 @@ public class KafkaConfig {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
|
||||
|
||||
// JSON 직렬화 시 타입 정보를 헤더에 추가하지 않음 (마이크로서비스 간 DTO 클래스 불일치 방지)
|
||||
config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);
|
||||
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||
|
||||
// Producer 성능 최적화 설정
|
||||
config.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||
|
||||
@@ -72,7 +72,6 @@ public class SecurityConfig {
|
||||
/**
|
||||
* CORS 설정
|
||||
* 개발 환경에서 프론트엔드(localhost:3000)의 요청을 허용합니다.
|
||||
* 쿠키 기반 인증을 위한 설정이 포함되어 있습니다.
|
||||
*
|
||||
* @return CorsConfigurationSource CORS 설정 소스
|
||||
*/
|
||||
@@ -83,10 +82,7 @@ public class SecurityConfig {
|
||||
// 허용할 Origin (개발 환경)
|
||||
configuration.setAllowedOrigins(Arrays.asList(
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:8083"
|
||||
"http://127.0.0.1:3000"
|
||||
));
|
||||
|
||||
// 허용할 HTTP 메서드
|
||||
@@ -94,7 +90,7 @@ public class SecurityConfig {
|
||||
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
|
||||
));
|
||||
|
||||
// 허용할 헤더 (쿠키 포함)
|
||||
// 허용할 헤더
|
||||
configuration.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
@@ -102,21 +98,19 @@ public class SecurityConfig {
|
||||
"Accept",
|
||||
"Origin",
|
||||
"Access-Control-Request-Method",
|
||||
"Access-Control-Request-Headers",
|
||||
"Cookie"
|
||||
"Access-Control-Request-Headers"
|
||||
));
|
||||
|
||||
// 인증 정보 포함 허용 (쿠키 전송을 위해 필수)
|
||||
// 인증 정보 포함 허용
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
// Preflight 요청 캐시 시간 (초)
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
// 노출할 응답 헤더 (쿠키 포함)
|
||||
// 노출할 응답 헤더
|
||||
configuration.setExposedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Set-Cookie"
|
||||
"Content-Type"
|
||||
));
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
+7
-2
@@ -3,6 +3,9 @@ package com.kt.event.eventservice.domain.entity;
|
||||
import com.kt.event.common.entity.BaseTimeEntity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.GenericGenerator;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AI 추천 엔티티
|
||||
@@ -23,8 +26,10 @@ import lombok.*;
|
||||
public class AiRecommendation extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "recommendation_id", length = 50)
|
||||
private String recommendationId;
|
||||
@GeneratedValue(generator = "uuid2")
|
||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
||||
@Column(name = "recommendation_id", columnDefinition = "uuid")
|
||||
private UUID recommendationId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "event_id", nullable = false)
|
||||
|
||||
@@ -6,6 +6,7 @@ import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.Fetch;
|
||||
import org.hibernate.annotations.FetchMode;
|
||||
import org.hibernate.annotations.GenericGenerator;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
@@ -31,14 +32,16 @@ import java.util.*;
|
||||
public class Event extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "event_id", length = 50)
|
||||
private String eventId;
|
||||
@GeneratedValue(generator = "uuid2")
|
||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
||||
@Column(name = "event_id", columnDefinition = "uuid")
|
||||
private UUID eventId;
|
||||
|
||||
@Column(name = "user_id", nullable = false, length = 50)
|
||||
private String userId;
|
||||
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "store_id", nullable = false, length = 50)
|
||||
private String storeId;
|
||||
@Column(name = "store_id", nullable = false, columnDefinition = "uuid")
|
||||
private UUID storeId;
|
||||
|
||||
@Column(name = "event_name", length = 200)
|
||||
private String eventName;
|
||||
@@ -60,8 +63,8 @@ public class Event extends BaseTimeEntity {
|
||||
@Builder.Default
|
||||
private EventStatus status = EventStatus.DRAFT;
|
||||
|
||||
@Column(name = "selected_image_id", length = 50)
|
||||
private String selectedImageId;
|
||||
@Column(name = "selected_image_id", columnDefinition = "uuid")
|
||||
private UUID selectedImageId;
|
||||
|
||||
@Column(name = "selected_image_url", length = 500)
|
||||
private String selectedImageUrl;
|
||||
@@ -125,7 +128,7 @@ public class Event extends BaseTimeEntity {
|
||||
/**
|
||||
* 이미지 선택
|
||||
*/
|
||||
public void selectImage(String imageId, String imageUrl) {
|
||||
public void selectImage(UUID imageId, String imageUrl) {
|
||||
this.selectedImageId = imageId;
|
||||
this.selectedImageUrl = imageUrl;
|
||||
|
||||
|
||||
+7
-2
@@ -3,6 +3,9 @@ package com.kt.event.eventservice.domain.entity;
|
||||
import com.kt.event.common.entity.BaseTimeEntity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.GenericGenerator;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 생성된 이미지 엔티티
|
||||
@@ -23,8 +26,10 @@ import lombok.*;
|
||||
public class GeneratedImage extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "image_id", length = 50)
|
||||
private String imageId;
|
||||
@GeneratedValue(generator = "uuid2")
|
||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
||||
@Column(name = "image_id", columnDefinition = "uuid")
|
||||
private UUID imageId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "event_id", nullable = false)
|
||||
|
||||
@@ -5,8 +5,10 @@ import com.kt.event.eventservice.domain.enums.JobStatus;
|
||||
import com.kt.event.eventservice.domain.enums.JobType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.GenericGenerator;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 비동기 작업 엔티티
|
||||
@@ -27,11 +29,13 @@ import java.time.LocalDateTime;
|
||||
public class Job extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "job_id", length = 50)
|
||||
private String jobId;
|
||||
@GeneratedValue(generator = "uuid2")
|
||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
||||
@Column(name = "job_id", columnDefinition = "uuid")
|
||||
private UUID jobId;
|
||||
|
||||
@Column(name = "event_id", nullable = false, length = 50)
|
||||
private String eventId;
|
||||
@Column(name = "event_id", nullable = false, columnDefinition = "uuid")
|
||||
private UUID eventId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "job_type", nullable = false, length = 30)
|
||||
|
||||
+4
-3
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AI 추천 Repository
|
||||
@@ -14,15 +15,15 @@ import java.util.List;
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Repository
|
||||
public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, String> {
|
||||
public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, UUID> {
|
||||
|
||||
/**
|
||||
* 이벤트별 AI 추천 목록 조회
|
||||
*/
|
||||
List<AiRecommendation> findByEventEventId(String eventId);
|
||||
List<AiRecommendation> findByEventEventId(UUID eventId);
|
||||
|
||||
/**
|
||||
* 이벤트별 선택된 AI 추천 조회
|
||||
*/
|
||||
AiRecommendation findByEventEventIdAndIsSelectedTrue(String eventId);
|
||||
AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId);
|
||||
}
|
||||
|
||||
+6
-10
@@ -10,6 +10,7 @@ import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 Repository
|
||||
@@ -19,12 +20,7 @@ import java.util.Optional;
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Repository
|
||||
public interface EventRepository extends JpaRepository<Event, String> {
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 조회
|
||||
*/
|
||||
Optional<Event> findByEventId(String eventId);
|
||||
public interface EventRepository extends JpaRepository<Event, UUID> {
|
||||
|
||||
/**
|
||||
* 사용자 ID와 이벤트 ID로 조회
|
||||
@@ -33,8 +29,8 @@ public interface EventRepository extends JpaRepository<Event, String> {
|
||||
"LEFT JOIN FETCH e.channels " +
|
||||
"WHERE e.eventId = :eventId AND e.userId = :userId")
|
||||
Optional<Event> findByEventIdAndUserId(
|
||||
@Param("eventId") String eventId,
|
||||
@Param("userId") String userId
|
||||
@Param("eventId") UUID eventId,
|
||||
@Param("userId") UUID userId
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -46,7 +42,7 @@ public interface EventRepository extends JpaRepository<Event, String> {
|
||||
"AND (:search IS NULL OR e.eventName LIKE %:search%) " +
|
||||
"AND (:objective IS NULL OR e.objective = :objective)")
|
||||
Page<Event> findEventsByUser(
|
||||
@Param("userId") String userId,
|
||||
@Param("userId") UUID userId,
|
||||
@Param("status") EventStatus status,
|
||||
@Param("search") String search,
|
||||
@Param("objective") String objective,
|
||||
@@ -56,5 +52,5 @@ public interface EventRepository extends JpaRepository<Event, String> {
|
||||
/**
|
||||
* 사용자별 이벤트 개수 조회 (상태별)
|
||||
*/
|
||||
long countByUserIdAndStatus(String userId, EventStatus status);
|
||||
long countByUserIdAndStatus(UUID userId, EventStatus status);
|
||||
}
|
||||
|
||||
+4
-3
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 생성된 이미지 Repository
|
||||
@@ -14,15 +15,15 @@ import java.util.List;
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Repository
|
||||
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, String> {
|
||||
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, UUID> {
|
||||
|
||||
/**
|
||||
* 이벤트별 생성된 이미지 목록 조회
|
||||
*/
|
||||
List<GeneratedImage> findByEventEventId(String eventId);
|
||||
List<GeneratedImage> findByEventEventId(UUID eventId);
|
||||
|
||||
/**
|
||||
* 이벤트별 선택된 이미지 조회
|
||||
*/
|
||||
GeneratedImage findByEventEventIdAndIsSelectedTrue(String eventId);
|
||||
GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId);
|
||||
}
|
||||
|
||||
+5
-4
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 비동기 작업 Repository
|
||||
@@ -17,22 +18,22 @@ import java.util.Optional;
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Repository
|
||||
public interface JobRepository extends JpaRepository<Job, String> {
|
||||
public interface JobRepository extends JpaRepository<Job, UUID> {
|
||||
|
||||
/**
|
||||
* 이벤트별 작업 목록 조회
|
||||
*/
|
||||
List<Job> findByEventId(String eventId);
|
||||
List<Job> findByEventId(UUID eventId);
|
||||
|
||||
/**
|
||||
* 이벤트 및 작업 유형별 조회
|
||||
*/
|
||||
Optional<Job> findByEventIdAndJobType(String eventId, JobType jobType);
|
||||
Optional<Job> findByEventIdAndJobType(UUID eventId, JobType jobType);
|
||||
|
||||
/**
|
||||
* 이벤트 및 작업 유형별 최신 작업 조회
|
||||
*/
|
||||
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(String eventId, JobType jobType);
|
||||
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType);
|
||||
|
||||
/**
|
||||
* 상태별 작업 목록 조회
|
||||
|
||||
+8
-7
@@ -18,6 +18,8 @@ import org.springframework.messaging.handler.annotation.Payload;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AI 이벤트 생성 작업 메시지 구독 Consumer
|
||||
*
|
||||
@@ -28,8 +30,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* @since 2025-10-29
|
||||
*/
|
||||
@Slf4j
|
||||
// TODO: 별도 response 토픽 사용 시 활성화
|
||||
// @Component
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AIJobKafkaConsumer {
|
||||
|
||||
@@ -92,7 +93,7 @@ public class AIJobKafkaConsumer {
|
||||
@Transactional
|
||||
protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) {
|
||||
try {
|
||||
String jobId = message.getJobId();
|
||||
UUID jobId = UUID.fromString(message.getJobId());
|
||||
|
||||
// Job 조회
|
||||
Job job = jobRepository.findById(jobId).orElse(null);
|
||||
@@ -101,7 +102,7 @@ public class AIJobKafkaConsumer {
|
||||
return;
|
||||
}
|
||||
|
||||
String eventId = job.getEventId();
|
||||
UUID eventId = job.getEventId();
|
||||
|
||||
// Event 조회 (모든 케이스에서 사용)
|
||||
Event event = eventRepository.findById(eventId).orElse(null);
|
||||
@@ -141,7 +142,7 @@ public class AIJobKafkaConsumer {
|
||||
eventId, aiData.getEventTitle());
|
||||
|
||||
// 사용자에게 알림 전송
|
||||
String userId = event.getUserId();
|
||||
UUID userId = event.getUserId();
|
||||
notificationService.notifyJobCompleted(
|
||||
userId,
|
||||
jobId,
|
||||
@@ -165,7 +166,7 @@ public class AIJobKafkaConsumer {
|
||||
|
||||
// 사용자에게 실패 알림 전송
|
||||
if (event != null) {
|
||||
String userId = event.getUserId();
|
||||
UUID userId = event.getUserId();
|
||||
notificationService.notifyJobFailed(
|
||||
userId,
|
||||
jobId,
|
||||
@@ -184,7 +185,7 @@ public class AIJobKafkaConsumer {
|
||||
|
||||
// 사용자에게 진행 상태 알림 전송
|
||||
if (event != null) {
|
||||
String userId = event.getUserId();
|
||||
UUID userId = event.getUserId();
|
||||
notificationService.notifyJobProgress(
|
||||
userId,
|
||||
jobId,
|
||||
+16
-22
@@ -1,5 +1,6 @@
|
||||
package com.kt.event.eventservice.infrastructure.kafka;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -26,6 +27,7 @@ import java.util.concurrent.CompletableFuture;
|
||||
public class AIJobKafkaProducer {
|
||||
|
||||
private final KafkaTemplate<String, Object> kafkaTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}")
|
||||
private String aiEventGenerationJobTopic;
|
||||
@@ -33,38 +35,28 @@ public class AIJobKafkaProducer {
|
||||
/**
|
||||
* AI 이벤트 생성 작업 메시지 발행
|
||||
*
|
||||
* @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8})
|
||||
* @param userId 사용자 ID
|
||||
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
|
||||
* @param jobId 작업 ID (UUID String)
|
||||
* @param userId 사용자 ID (UUID String)
|
||||
* @param eventId 이벤트 ID (UUID String)
|
||||
* @param storeName 매장명
|
||||
* @param industry 업종 (매장 카테고리)
|
||||
* @param region 지역
|
||||
* @param storeCategory 매장 업종
|
||||
* @param storeDescription 매장 설명
|
||||
* @param objective 이벤트 목적
|
||||
* @param targetAudience 목표 고객층 (선택)
|
||||
* @param budget 예산 (선택)
|
||||
*/
|
||||
public void publishAIGenerationJob(
|
||||
String jobId,
|
||||
String userId,
|
||||
String eventId,
|
||||
String storeName,
|
||||
String industry,
|
||||
String region,
|
||||
String objective,
|
||||
String targetAudience,
|
||||
Integer budget) {
|
||||
String storeCategory,
|
||||
String storeDescription,
|
||||
String objective) {
|
||||
|
||||
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
|
||||
.jobId(jobId)
|
||||
.userId(userId)
|
||||
.eventId(eventId)
|
||||
.storeName(storeName)
|
||||
.industry(industry)
|
||||
.region(region)
|
||||
.objective(objective)
|
||||
.targetAudience(targetAudience)
|
||||
.budget(budget)
|
||||
.requestedAt(LocalDateTime.now())
|
||||
.status("PENDING")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
publishMessage(message);
|
||||
@@ -77,9 +69,11 @@ public class AIJobKafkaProducer {
|
||||
*/
|
||||
public void publishMessage(AIEventGenerationJobMessage message) {
|
||||
try {
|
||||
// 객체를 직접 전송 (JsonSerializer가 자동으로 직렬화)
|
||||
// JSON 문자열로 변환
|
||||
String jsonMessage = objectMapper.writeValueAsString(message);
|
||||
|
||||
CompletableFuture<SendResult<String, Object>> future =
|
||||
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), message);
|
||||
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), jsonMessage);
|
||||
|
||||
future.whenComplete((result, ex) -> {
|
||||
if (ex == null) {
|
||||
|
||||
+3
-3
@@ -29,12 +29,12 @@ public class EventKafkaProducer {
|
||||
/**
|
||||
* 이벤트 생성 완료 메시지 발행
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param userId 사용자 ID
|
||||
* @param eventId 이벤트 ID (UUID)
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param title 이벤트 제목
|
||||
* @param eventType 이벤트 타입
|
||||
*/
|
||||
public void publishEventCreated(String eventId, String userId, String title, String eventType) {
|
||||
public void publishEventCreated(java.util.UUID eventId, java.util.UUID userId, String title, String eventType) {
|
||||
EventCreatedMessage message = EventCreatedMessage.builder()
|
||||
.eventId(eventId)
|
||||
.userId(userId)
|
||||
|
||||
+7
-5
@@ -18,6 +18,8 @@ import org.springframework.messaging.handler.annotation.Payload;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이미지 생성 작업 메시지 구독 Consumer
|
||||
*
|
||||
@@ -92,8 +94,8 @@ public class ImageJobKafkaConsumer {
|
||||
@Transactional
|
||||
protected void processImageGenerationJob(ImageGenerationJobMessage message) {
|
||||
try {
|
||||
String jobId = message.getJobId();
|
||||
String eventId = message.getEventId();
|
||||
UUID jobId = UUID.fromString(message.getJobId());
|
||||
UUID eventId = UUID.fromString(message.getEventId());
|
||||
|
||||
// Job 조회
|
||||
Job job = jobRepository.findById(jobId).orElse(null);
|
||||
@@ -128,7 +130,7 @@ public class ImageJobKafkaConsumer {
|
||||
eventId, message.getImageUrl());
|
||||
|
||||
// 사용자에게 알림 전송
|
||||
String userId = event.getUserId();
|
||||
UUID userId = event.getUserId();
|
||||
notificationService.notifyJobCompleted(
|
||||
userId,
|
||||
jobId,
|
||||
@@ -179,7 +181,7 @@ public class ImageJobKafkaConsumer {
|
||||
|
||||
// 사용자에게 실패 알림 전송
|
||||
if (event != null) {
|
||||
String userId = event.getUserId();
|
||||
UUID userId = event.getUserId();
|
||||
notificationService.notifyJobFailed(
|
||||
userId,
|
||||
jobId,
|
||||
@@ -200,7 +202,7 @@ public class ImageJobKafkaConsumer {
|
||||
|
||||
// 사용자에게 진행 상태 알림 전송
|
||||
if (event != null) {
|
||||
String userId = event.getUserId();
|
||||
UUID userId = event.getUserId();
|
||||
notificationService.notifyJobProgress(
|
||||
userId,
|
||||
jobId,
|
||||
|
||||
+3
-3
@@ -35,9 +35,9 @@ public class ImageJobKafkaProducer {
|
||||
/**
|
||||
* 이미지 생성 작업 메시지 발행
|
||||
*
|
||||
* @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8})
|
||||
* @param userId 사용자 ID
|
||||
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
|
||||
* @param jobId 작업 ID (UUID)
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID (UUID)
|
||||
* @param prompt 이미지 생성 프롬프트
|
||||
*/
|
||||
public void publishImageGenerationJob(
|
||||
|
||||
+6
-4
@@ -4,6 +4,8 @@ import com.kt.event.eventservice.application.service.NotificationService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 로깅 기반 알림 서비스 구현
|
||||
*
|
||||
@@ -18,16 +20,16 @@ import org.springframework.stereotype.Service;
|
||||
public class LoggingNotificationService implements NotificationService {
|
||||
|
||||
@Override
|
||||
public void notifyJobCompleted(String userId, String jobId, String jobType, String message) {
|
||||
public void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message) {
|
||||
log.info("📢 [작업 완료 알림] UserId: {}, JobId: {}, JobType: {}, Message: {}",
|
||||
userId, jobId, jobType, message);
|
||||
|
||||
// TODO: WebSocket, SSE, 또는 Push Notification으로 실시간 알림 전송
|
||||
// 예: webSocketTemplate.convertAndSendToUser(userId, "/queue/notifications", notification);
|
||||
// 예: webSocketTemplate.convertAndSendToUser(userId.toString(), "/queue/notifications", notification);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage) {
|
||||
public void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage) {
|
||||
log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}",
|
||||
userId, jobId, jobType, errorMessage);
|
||||
|
||||
@@ -35,7 +37,7 @@ public class LoggingNotificationService implements NotificationService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyJobProgress(String userId, String jobId, String jobType, int progress) {
|
||||
public void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress) {
|
||||
log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%",
|
||||
userId, jobId, jobType, progress);
|
||||
|
||||
|
||||
+16
-14
@@ -21,6 +21,8 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 컨트롤러
|
||||
*
|
||||
@@ -32,7 +34,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/events")
|
||||
@RequestMapping("/api/v1/events")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Event", description = "이벤트 관리 API")
|
||||
public class EventController {
|
||||
@@ -127,7 +129,7 @@ public class EventController {
|
||||
@GetMapping("/{eventId}")
|
||||
@Operation(summary = "이벤트 상세 조회", description = "특정 이벤트의 상세 정보를 조회합니다.")
|
||||
public ResponseEntity<ApiResponse<EventDetailResponse>> getEvent(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}",
|
||||
@@ -148,7 +150,7 @@ public class EventController {
|
||||
@DeleteMapping("/{eventId}")
|
||||
@Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다. DRAFT 상태만 삭제 가능합니다.")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteEvent(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}",
|
||||
@@ -169,7 +171,7 @@ public class EventController {
|
||||
@PostMapping("/{eventId}/publish")
|
||||
@Operation(summary = "이벤트 배포", description = "이벤트를 배포합니다. DRAFT → PUBLISHED 상태 변경.")
|
||||
public ResponseEntity<ApiResponse<Void>> publishEvent(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}",
|
||||
@@ -190,7 +192,7 @@ public class EventController {
|
||||
@PostMapping("/{eventId}/end")
|
||||
@Operation(summary = "이벤트 종료", description = "이벤트를 종료합니다. PUBLISHED → ENDED 상태 변경.")
|
||||
public ResponseEntity<ApiResponse<Void>> endEvent(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}",
|
||||
@@ -212,7 +214,7 @@ public class EventController {
|
||||
@PostMapping("/{eventId}/images")
|
||||
@Operation(summary = "이미지 생성 요청", description = "AI를 통해 이벤트 이미지를 생성합니다.")
|
||||
public ResponseEntity<ApiResponse<ImageGenerationResponse>> requestImageGeneration(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@Valid @RequestBody ImageGenerationRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
@@ -241,8 +243,8 @@ public class EventController {
|
||||
@PutMapping("/{eventId}/images/{imageId}/select")
|
||||
@Operation(summary = "이미지 선택", description = "생성된 이미지 중 하나를 선택합니다.")
|
||||
public ResponseEntity<ApiResponse<Void>> selectImage(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String imageId,
|
||||
@PathVariable UUID eventId,
|
||||
@PathVariable UUID imageId,
|
||||
@Valid @RequestBody SelectImageRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
@@ -270,7 +272,7 @@ public class EventController {
|
||||
@PostMapping("/{eventId}/ai-recommendations")
|
||||
@Operation(summary = "AI 추천 요청", description = "AI 서비스에 이벤트 추천 생성을 요청합니다.")
|
||||
public ResponseEntity<ApiResponse<JobAcceptedResponse>> requestAiRecommendations(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@Valid @RequestBody AiRecommendationRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
@@ -298,7 +300,7 @@ public class EventController {
|
||||
@PutMapping("/{eventId}/recommendations")
|
||||
@Operation(summary = "AI 추천 선택", description = "AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다.")
|
||||
public ResponseEntity<ApiResponse<Void>> selectRecommendation(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@Valid @RequestBody SelectRecommendationRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
@@ -326,8 +328,8 @@ public class EventController {
|
||||
@PutMapping("/{eventId}/images/{imageId}/edit")
|
||||
@Operation(summary = "이미지 편집", description = "선택된 이미지를 편집합니다.")
|
||||
public ResponseEntity<ApiResponse<ImageEditResponse>> editImage(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String imageId,
|
||||
@PathVariable UUID eventId,
|
||||
@PathVariable UUID imageId,
|
||||
@Valid @RequestBody ImageEditRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
@@ -355,7 +357,7 @@ public class EventController {
|
||||
@PutMapping("/{eventId}/channels")
|
||||
@Operation(summary = "배포 채널 선택", description = "이벤트를 배포할 채널을 선택합니다.")
|
||||
public ResponseEntity<ApiResponse<Void>> selectChannels(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@Valid @RequestBody SelectChannelsRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
@@ -382,7 +384,7 @@ public class EventController {
|
||||
@PutMapping("/{eventId}")
|
||||
@Operation(summary = "이벤트 수정", description = "기존 이벤트의 정보를 수정합니다. DRAFT 상태만 수정 가능합니다.")
|
||||
public ResponseEntity<ApiResponse<EventDetailResponse>> updateEvent(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable UUID eventId,
|
||||
@Valid @RequestBody UpdateEventRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
|
||||
+4
-2
@@ -13,6 +13,8 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Job 컨트롤러
|
||||
*
|
||||
@@ -24,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/jobs")
|
||||
@RequestMapping("/api/v1/jobs")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Job", description = "비동기 작업 상태 조회 API")
|
||||
public class JobController {
|
||||
@@ -39,7 +41,7 @@ public class JobController {
|
||||
*/
|
||||
@GetMapping("/{jobId}")
|
||||
@Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).")
|
||||
public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable String jobId) {
|
||||
public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable UUID jobId) {
|
||||
log.info("Job 상태 조회 API 호출 - jobId: {}", jobId);
|
||||
|
||||
JobStatusResponse response = jobService.getJobStatus(jobId);
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import java.time.Duration;
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/redis-test")
|
||||
@RequestMapping("/api/v1/redis-test")
|
||||
@RequiredArgsConstructor
|
||||
public class RedisTestController {
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ spring:
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
servlet:
|
||||
context-path: /api/v1
|
||||
context-path: /api/v1/events
|
||||
shutdown: graceful
|
||||
|
||||
# Actuator Configuration
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import java.util.Arrays;
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Value("${cors.allowed-origins:http://localhost:*}")
|
||||
@Value("${cors.allowed-origins:http://localhost:*,https://kt-event-marketing-api.20.214.196.128.nip.io/api/v1}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -98,4 +98,14 @@ management:
|
||||
livenessState:
|
||||
enabled: true
|
||||
readinessState:
|
||||
enabled: true
|
||||
enabled: true
|
||||
|
||||
# OpenAPI Documentation
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
tags-sorter: alpha
|
||||
operations-sorter: alpha
|
||||
show-actuator: false
|
||||
@@ -1,81 +0,0 @@
|
||||
@echo off
|
||||
REM Content Service 실행 스크립트
|
||||
REM Port: 8084
|
||||
REM Context Path: /api/v1/content
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set SERVICE_NAME=content-service
|
||||
set PORT=8084
|
||||
set LOG_DIR=logs
|
||||
set LOG_FILE=%LOG_DIR%\%SERVICE_NAME%.log
|
||||
|
||||
REM 로그 디렉토리 생성
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
REM 환경 변수 설정
|
||||
set SERVER_PORT=8084
|
||||
set REDIS_HOST=20.214.210.71
|
||||
set REDIS_PORT=6379
|
||||
set REDIS_PASSWORD=Hi5Jessica!
|
||||
set REDIS_DATABASE=0
|
||||
set JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
|
||||
set JWT_ACCESS_TOKEN_VALIDITY=3600000
|
||||
set JWT_REFRESH_TOKEN_VALIDITY=604800000
|
||||
|
||||
REM Azure Blob Storage
|
||||
set AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net
|
||||
set AZURE_CONTAINER_NAME=content-images
|
||||
|
||||
REM CORS
|
||||
set CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io
|
||||
set CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS,PATCH
|
||||
set CORS_ALLOWED_HEADERS=*
|
||||
set CORS_ALLOW_CREDENTIALS=true
|
||||
set CORS_MAX_AGE=3600
|
||||
|
||||
REM Logging
|
||||
set LOG_LEVEL_APP=DEBUG
|
||||
set LOG_LEVEL_WEB=INFO
|
||||
set LOG_LEVEL_ROOT=INFO
|
||||
set LOG_FILE_PATH=%LOG_FILE%
|
||||
set LOG_FILE_MAX_SIZE=10MB
|
||||
set LOG_FILE_MAX_HISTORY=7
|
||||
set LOG_FILE_TOTAL_CAP=100MB
|
||||
|
||||
echo ==================================================
|
||||
echo Content Service 시작
|
||||
echo ==================================================
|
||||
echo 포트: %PORT%
|
||||
echo 로그 파일: %LOG_FILE%
|
||||
echo Context Path: /api/v1/content
|
||||
echo ==================================================
|
||||
|
||||
REM 기존 프로세스 확인
|
||||
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%PORT%.*LISTENING"') do (
|
||||
echo ⚠️ 포트 %PORT%가 이미 사용 중입니다. PID: %%a
|
||||
set /p answer="기존 프로세스를 종료하시겠습니까? (y/n): "
|
||||
if /i "!answer!"=="y" (
|
||||
taskkill /F /PID %%a
|
||||
timeout /t 2 /nobreak > nul
|
||||
) else (
|
||||
echo 서비스 시작을 취소합니다.
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
REM 서비스 시작
|
||||
echo 서비스를 시작합니다...
|
||||
start /b cmd /c "gradlew.bat %SERVICE_NAME%:bootRun > %LOG_FILE% 2>&1"
|
||||
|
||||
timeout /t 3 /nobreak > nul
|
||||
|
||||
echo ✅ Content Service가 시작되었습니다.
|
||||
echo 로그 확인: tail -f %LOG_FILE% 또는 type %LOG_FILE%
|
||||
echo.
|
||||
echo Health Check: curl http://localhost:%PORT%/api/v1/content/actuator/health
|
||||
echo.
|
||||
echo 서비스 종료: 작업 관리자에서 java 프로세스 종료
|
||||
echo ==================================================
|
||||
|
||||
endlocal
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Content Service 실행 스크립트
|
||||
# Port: 8084
|
||||
# Context Path: /api/v1/content
|
||||
|
||||
SERVICE_NAME="content-service"
|
||||
PORT=8084
|
||||
LOG_DIR="logs"
|
||||
LOG_FILE="${LOG_DIR}/${SERVICE_NAME}.log"
|
||||
|
||||
# 로그 디렉토리 생성
|
||||
mkdir -p ${LOG_DIR}
|
||||
|
||||
# 환경 변수 설정
|
||||
export SERVER_PORT=8084
|
||||
export REDIS_HOST=20.214.210.71
|
||||
export REDIS_PORT=6379
|
||||
export REDIS_PASSWORD=Hi5Jessica!
|
||||
export REDIS_DATABASE=0
|
||||
export JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
|
||||
export JWT_ACCESS_TOKEN_VALIDITY=3600000
|
||||
export JWT_REFRESH_TOKEN_VALIDITY=604800000
|
||||
|
||||
# Azure Blob Storage
|
||||
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net"
|
||||
export AZURE_CONTAINER_NAME=content-images
|
||||
|
||||
# CORS
|
||||
export CORS_ALLOWED_ORIGINS="http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io"
|
||||
export CORS_ALLOWED_METHODS="GET,POST,PUT,DELETE,OPTIONS,PATCH"
|
||||
export CORS_ALLOWED_HEADERS="*"
|
||||
export CORS_ALLOW_CREDENTIALS=true
|
||||
export CORS_MAX_AGE=3600
|
||||
|
||||
# Logging
|
||||
export LOG_LEVEL_APP=DEBUG
|
||||
export LOG_LEVEL_WEB=INFO
|
||||
export LOG_LEVEL_ROOT=INFO
|
||||
export LOG_FILE_PATH="${LOG_FILE}"
|
||||
export LOG_FILE_MAX_SIZE=10MB
|
||||
export LOG_FILE_MAX_HISTORY=7
|
||||
export LOG_FILE_TOTAL_CAP=100MB
|
||||
|
||||
echo "=================================================="
|
||||
echo "Content Service 시작"
|
||||
echo "=================================================="
|
||||
echo "포트: ${PORT}"
|
||||
echo "로그 파일: ${LOG_FILE}"
|
||||
echo "Context Path: /api/v1/content"
|
||||
echo "=================================================="
|
||||
|
||||
# 기존 프로세스 확인
|
||||
if netstat -ano | grep -q ":${PORT}.*LISTENING"; then
|
||||
echo "⚠️ 포트 ${PORT}가 이미 사용 중입니다."
|
||||
echo "기존 프로세스를 종료하시겠습니까? (y/n)"
|
||||
read -r answer
|
||||
if [ "$answer" = "y" ]; then
|
||||
PID=$(netstat -ano | grep ":${PORT}.*LISTENING" | awk '{print $5}' | head -1)
|
||||
taskkill //F //PID ${PID}
|
||||
sleep 2
|
||||
else
|
||||
echo "서비스 시작을 취소합니다."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 서비스 시작
|
||||
echo "서비스를 시작합니다..."
|
||||
nohup ./gradlew ${SERVICE_NAME}:bootRun > ${LOG_FILE} 2>&1 &
|
||||
SERVICE_PID=$!
|
||||
|
||||
echo "✅ Content Service가 시작되었습니다."
|
||||
echo "PID: ${SERVICE_PID}"
|
||||
echo "로그 확인: tail -f ${LOG_FILE}"
|
||||
echo ""
|
||||
echo "Health Check: curl http://localhost:${PORT}/api/v1/content/actuator/health"
|
||||
echo ""
|
||||
echo "서비스 종료: kill ${SERVICE_PID}"
|
||||
echo "=================================================="
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"storeInfo": {
|
||||
"storeId": "str_dev_test_001",
|
||||
"storeName": "Woojin BBQ Restaurant",
|
||||
"category": "Restaurant",
|
||||
"description": "Korean BBQ restaurant serving fresh Hanwoo beef"
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Content Service 통합 테스트 스크립트
|
||||
# 작성일: 2025-10-30
|
||||
# 테스트 대상: content-service (포트 8084)
|
||||
|
||||
BASE_URL="http://localhost:8084/api/v1/content"
|
||||
COLOR_GREEN='\033[0;32m'
|
||||
COLOR_RED='\033[0;31m'
|
||||
COLOR_YELLOW='\033[1;33m'
|
||||
COLOR_NC='\033[0m' # No Color
|
||||
|
||||
echo "=========================================="
|
||||
echo "Content Service 통합 테스트 시작"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 테스트 데이터
|
||||
EVENT_ID="EVT-str_dev_test_001-20251029220003-610158ce"
|
||||
TEST_IMAGE_ID=1
|
||||
|
||||
# 1. Health Check
|
||||
echo -e "${COLOR_YELLOW}[1/7] Health Check${COLOR_NC}"
|
||||
curl -s http://localhost:8084/actuator/health | jq . || echo -e "${COLOR_RED}❌ Health check 실패${COLOR_NC}"
|
||||
echo ""
|
||||
|
||||
# 2. 이미지 생성 요청 (HTTP 통신 테스트)
|
||||
echo -e "${COLOR_YELLOW}[2/7] 이미지 생성 요청 (HTTP 통신)${COLOR_NC}"
|
||||
RESPONSE=$(curl -s -X POST "$BASE_URL/images/generate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @test-image-generation.json)
|
||||
|
||||
echo "$RESPONSE" | jq .
|
||||
JOB_ID=$(echo "$RESPONSE" | jq -r '.jobId')
|
||||
echo -e "${COLOR_GREEN}✅ Job ID: $JOB_ID${COLOR_NC}"
|
||||
echo ""
|
||||
|
||||
# 3. Job 상태 조회 (Job 관리 테스트)
|
||||
echo -e "${COLOR_YELLOW}[3/7] Job 상태 조회 (Job 관리)${COLOR_NC}"
|
||||
if [ ! -z "$JOB_ID" ] && [ "$JOB_ID" != "null" ]; then
|
||||
curl -s "$BASE_URL/images/jobs/$JOB_ID" | jq .
|
||||
echo -e "${COLOR_GREEN}✅ Job 상태 조회 성공${COLOR_NC}"
|
||||
else
|
||||
echo -e "${COLOR_RED}❌ JOB_ID가 없어 테스트 건너뜀${COLOR_NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 4. EventId 기반 콘텐츠 조회
|
||||
echo -e "${COLOR_YELLOW}[4/7] EventId 기반 콘텐츠 조회${COLOR_NC}"
|
||||
curl -s "$BASE_URL/events/$EVENT_ID" | jq .
|
||||
echo -e "${COLOR_GREEN}✅ 콘텐츠 조회 성공${COLOR_NC}"
|
||||
echo ""
|
||||
|
||||
# 5. 이미지 목록 조회
|
||||
echo -e "${COLOR_YELLOW}[5/7] 이미지 목록 조회${COLOR_NC}"
|
||||
curl -s "$BASE_URL/events/$EVENT_ID/images" | jq .
|
||||
echo -e "${COLOR_GREEN}✅ 이미지 목록 조회 성공${COLOR_NC}"
|
||||
echo ""
|
||||
|
||||
# 6. 이미지 목록 조회 (필터링: style)
|
||||
echo -e "${COLOR_YELLOW}[6/7] 이미지 필터링 (style=SIMPLE)${COLOR_NC}"
|
||||
curl -s "$BASE_URL/events/$EVENT_ID/images?style=SIMPLE" | jq .
|
||||
echo ""
|
||||
|
||||
# 7. 이미지 재생성 요청
|
||||
echo -e "${COLOR_YELLOW}[7/7] 이미지 재생성 요청${COLOR_NC}"
|
||||
REGEN_RESPONSE=$(curl -s -X POST "$BASE_URL/images/$TEST_IMAGE_ID/regenerate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"newPrompt": "Updated image with modern Korean BBQ theme"}')
|
||||
|
||||
echo "$REGEN_RESPONSE" | jq .
|
||||
REGEN_JOB_ID=$(echo "$REGEN_RESPONSE" | jq -r '.jobId')
|
||||
if [ ! -z "$REGEN_JOB_ID" ] && [ "$REGEN_JOB_ID" != "null" ]; then
|
||||
echo -e "${COLOR_GREEN}✅ 재생성 Job ID: $REGEN_JOB_ID${COLOR_NC}"
|
||||
else
|
||||
echo -e "${COLOR_YELLOW}⚠️ 이미지 ID가 존재하지 않을 수 있음${COLOR_NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo "테스트 완료"
|
||||
echo "=========================================="
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"eventId": "EVT-str_dev_test_001-20251029220003-610158ce",
|
||||
"eventTitle": "Woojin BBQ Restaurant Grand Opening Event",
|
||||
"eventDescription": "Special discount event for Korean BBQ restaurant grand opening. Fresh Hanwoo beef at 20% off!",
|
||||
"industry": "Restaurant",
|
||||
"location": "Seoul",
|
||||
"trends": ["Korean BBQ", "Hanwoo", "Grand Opening"],
|
||||
"styles": ["SIMPLE", "TRENDY"],
|
||||
"platforms": ["INSTAGRAM", "KAKAO"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"storeInfo": {
|
||||
"storeId": "str_dev_test_001",
|
||||
"storeName": "Golden Dragon Chinese Restaurant",
|
||||
"category": "RESTAURANT",
|
||||
"description": "Authentic Chinese cuisine with signature Peking duck and dim sum"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"storeName": "Golden Dragon Chinese Restaurant",
|
||||
"storeCategory": "RESTAURANT",
|
||||
"storeDescription": "Authentic Chinese cuisine with signature Peking duck and dim sum. Family-owned restaurant serving the community for 15 years.",
|
||||
"objective": "Launch Chinese New Year special promotion to attract customers during holiday season with 25% discount on all menu items.",
|
||||
"requestAIRecommendation": true
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"objective": "Chinese New Year promotion with 25% discount"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0NmUwZjAyZS04ZDFiLTQzYzItODRmZC0yYjY1ZTEzMjdlYzYiLCJzdG9yZUlkIjoiOGQ4ZmI5NjQtMzM2Mi00ZDk5LWI3YWUtOTcxZTRhODUxYjVhIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNzQ1ODMwLCJleHAiOjE3OTMyODE4MzB9.aP-y6qpc7dl9ChYGI9GQ4Cz7XE2DXXhW7MUA97nN-OU
|
||||
@@ -1 +0,0 @@
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzYzU0MmY2NC02NWU1LTQyYTAtYWM1Ni1mNjM4OTU3MDU0NDUiLCJzdG9yZUlkIjoiMzlhMTdhYjMtMDg5NC00NGVhLWFkNmItNTFkZDcxZTA3MTcwIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNzQ2OTI2LCJleHAiOjE3OTMyODI5MjZ9.IkYHvQdx1HI9f7tY9efBcXcOqiMmqNNRZ8gl7VOHYUY
|
||||
@@ -1,20 +0,0 @@
|
||||
================================================================================
|
||||
JWT 테스트 토큰 생성
|
||||
================================================================================
|
||||
|
||||
User ID: 5be2284f-c254-47cb-bec8-54a780306dfb
|
||||
Store ID: b3c35c24-ff73-4c3b-bdf9-513b0434d6b0
|
||||
Email: test@example.com
|
||||
Name: Test User
|
||||
Roles: ['ROLE_USER']
|
||||
|
||||
================================================================================
|
||||
Access Token:
|
||||
================================================================================
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1YmUyMjg0Zi1jMjU0LTQ3Y2ItYmVjOC01NGE3ODAzMDZkZmIiLCJzdG9yZUlkIjoiYjNjMzVjMjQtZmY3My00YzNiLWJkZjktNTEzYjA0MzRkNmIwIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNzQ1ODE5LCJleHAiOjE3OTMyODE4MTl9.EEVtRi1VboWmoCOoOmqoZSW681j_s5YqGFYI3aZYsqg
|
||||
|
||||
================================================================================
|
||||
사용 방법:
|
||||
================================================================================
|
||||
curl -H "Authorization: Bearer <token>" http://localhost:8081/api/v1/events
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user