Compare commits

6 Commits

Author SHA1 Message Date
merrycoral 7dc039361f Event-AI Kafka 통신 개선 및 타입 헤더 불일치 문제 해결
주요 변경사항:
- event-service KafkaConfig: JsonSerializer로 변경, 타입 헤더 비활성화
- ai-service application.yml: 타입 헤더 사용 안 함, 기본 타입 지정
- AIEventGenerationJobMessage: region, targetAudience, budget 필드 추가
- AiRecommendationRequest: region, targetAudience, budget 필드 추가
- AIJobKafkaProducer: 객체 직접 전송으로 변경 (이중 직렬화 문제 해결)
- AIJobKafkaConsumer: 양방향 통신 이슈로 비활성화 (.bak)
- EventService: Kafka producer 호출 시 새 필드 전달

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 15:58:23 +09:00
merrycoral 8ff79ca1ab 테스트 결과 파일들을 test/ 폴더로 이동
- API-TEST-RESULT.md → test/
- content-service-integration-analysis.md → test/
- content-service-integration-test-results.md → test/
- test-kafka-integration-results.md → test/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 01:40:21 +09:00
merrycoral 336d811f55 content-service 통합 테스트 완료 및 보고서 작성
- content-service HTTP 통신 테스트 완료 (9개 시나리오 성공)
- Job 관리 메커니즘 검증 (Redis 기반)
- EventId 기반 콘텐츠 조회 및 필터링 테스트
- 이미지 재생성 기능 검증
- Kafka 연동 현황 분석 (Consumer 미구현 확인)
- 통합 테스트 결과 보고서 작성
- 테스트 자동화 스크립트 추가

테스트 성공률: 100% (9/9)
응답 성능: < 150ms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 01:24:29 +09:00
merrycoral ee941e4910 Event-AI Kafka 연동 개선 및 메시지 필드명 camelCase 변경
주요 변경사항:
- AI Service Kafka 브로커 설정 수정 (4.230.50.63:9092 → 20.249.182.13:9095,4.217.131.59:9095)
- IntelliJ 실행 프로파일 Kafka 환경 변수 수정 (3개 파일)
- Kafka 메시지 DTO 필드명 snake_case → camelCase 변경
- @JsonProperty 어노테이션 제거로 코드 간결성 향상 (18줄 감소)

개선 효과:
- Event-AI Kafka 연동 정상 작동 확인
- 메시지 필드 매핑 성공률 0% → 100%
- jobId, eventId, storeName 등 모든 필드 정상 매핑
- AI 추천 생성 로직 정상 실행

테스트 결과:
- Kafka 메시지 발행/수신: Offset 34로 정상 동작 확인
- AI Service에서 메시지 처리 완료 (COMPLETED)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 22:55:20 +09:00
merrycoral b71d27aa8b 비즈니스 친화적 eventId 및 jobId 생성 로직 구현
- EventIdGenerator 추가: EVT-{storeId}-{yyyyMMddHHmmss}-{random8} 형식
- JobIdGenerator 추가: JOB-{type}-{timestamp}-{random8} 형식
- EventService, JobService에 Generator 주입 및 사용
- AIJobKafkaProducer에 eventId 및 메시지 필드 추가
- AIEventGenerationJobMessage DTO 필드 확장
- Javadoc에서 UUID 표현 제거 및 실제 형식 명시
- Event.java의 UUID 백업 생성 로직 제거

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 20:54:10 +09:00
merrycoral 34291e1613 백엔드 서비스 구조 개선 및 데이터베이스 스키마 추가 2025-10-29 17:51:48 +09:00
113 changed files with 3476 additions and 1282 deletions
+8 -8
View File
@@ -1,14 +1,14 @@
name: Backend CI/CD Pipeline name: Backend CI/CD Pipeline
on: on:
# push: push:
# branches: branches:
# - develop - develop
# - main - main
# paths: paths:
# - '*-service/**' - '*-service/**'
# - '.github/workflows/backend-cicd.yaml' - '.github/workflows/backend-cicd.yaml'
# - '.github/kustomize/**' - '.github/kustomize/**'
pull_request: pull_request:
branches: branches:
- develop - develop
+1 -1
View File
@@ -19,7 +19,7 @@
<env name="REDIS_HOST" value="20.214.210.71" /> <env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" /> <env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" /> <env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" /> <env name="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<env name="KAFKA_CONSUMER_GROUP" value="ai" /> <env name="KAFKA_CONSUMER_GROUP" value="ai" />
<env name="JPA_DDL_AUTO" value="update" /> <env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" /> <env name="JPA_SHOW_SQL" value="false" />
+2
View File
@@ -21,6 +21,8 @@
<env name="REDIS_PASSWORD" value="Hi5Jessica!" /> <env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="JPA_DDL_AUTO" value="update" /> <env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" /> <env name="JPA_SHOW_SQL" value="false" />
<env name="REPLICATE_API_TOKEN" value="r8_cqE8IzQr9DZ8Dr72ozbomiXe6IFPL0005Vuq9" />
<env name="REPLICATE_MOCK_ENABLED" value="true" />
</envs> </envs>
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
+1 -1
View File
@@ -24,7 +24,7 @@
<!-- Kafka Configuration (원격 서버) --> <!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" /> <entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" /> <entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" /> <entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
<!-- Sample Data Configuration (MVP Only) --> <!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) --> <!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
@@ -19,7 +19,7 @@ spring:
# Kafka Consumer Configuration # Kafka Consumer Configuration
kafka: kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092} bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer: consumer:
group-id: ${KAFKA_CONSUMER_GROUP:ai-service-consumers} group-id: ${KAFKA_CONSUMER_GROUP:ai-service-consumers}
auto-offset-reset: earliest auto-offset-reset: earliest
@@ -28,6 +28,8 @@ spring:
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties: properties:
spring.json.trusted.packages: "*" 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 max.poll.records: 10
session.timeout.ms: 30000 session.timeout.ms: 30000
listener: listener:
@@ -24,7 +24,7 @@
<!-- Kafka Configuration (원격 서버) --> <!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" /> <entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" /> <entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" /> <entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
<!-- Sample Data Configuration (MVP Only) --> <!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) --> <!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
@@ -63,7 +63,7 @@ public class AnalyticsBatchScheduler {
event.getEventId(), event.getEventTitle()); event.getEventId(), event.getEventTitle());
// refresh=true로 호출하여 캐시 갱신 및 외부 API 호출 // refresh=true로 호출하여 캐시 갱신 및 외부 API 호출
analyticsService.getDashboardData(event.getEventId(), true); analyticsService.getDashboardData(event.getEventId(), null, null, true);
successCount++; successCount++;
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId()); log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
@@ -99,7 +99,7 @@ public class AnalyticsBatchScheduler {
for (EventStats event : allEvents) { for (EventStats event : allEvents) {
try { try {
analyticsService.getDashboardData(event.getEventId(), true); analyticsService.getDashboardData(event.getEventId(), null, null, true);
log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId()); log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
} catch (Exception e) { } catch (Exception e) {
log.warn("초기 데이터 로딩 실패: eventId={}, error={}", log.warn("초기 데이터 로딩 실패: eventId={}, error={}",
@@ -17,13 +17,13 @@ import java.util.Map;
* Kafka Consumer 설정 * Kafka Consumer 설정
*/ */
@Configuration @Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false) @ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true)
public class KafkaConsumerConfig { public class KafkaConsumerConfig {
@Value("${spring.kafka.bootstrap-servers}") @Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers; private String bootstrapServers;
@Value("${spring.kafka.consumer.group-id:analytics-service-consumers-v3}") @Value("${spring.kafka.consumer.group-id:analytics-service}")
private String groupId; private String groupId;
@Bean @Bean
@@ -1,46 +0,0 @@
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());
}
}
@@ -11,23 +11,19 @@ import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult;
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
import org.apache.kafka.common.TopicPartition;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaAdmin;
import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.ArrayList;
import java.util.concurrent.TimeUnit; import java.util.List;
import java.util.Random;
import java.util.UUID;
/** /**
* 샘플 데이터 로더 (Kafka Producer 방식) * 샘플 데이터 로더 (Kafka Producer 방식)
@@ -51,7 +47,6 @@ import java.util.concurrent.TimeUnit;
public class SampleDataLoader implements ApplicationRunner { public class SampleDataLoader implements ApplicationRunner {
private final KafkaTemplate<String, String> kafkaTemplate; private final KafkaTemplate<String, String> kafkaTemplate;
private final KafkaAdmin kafkaAdmin;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final EventStatsRepository eventStatsRepository; private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository; private final ChannelStatsRepository channelStatsRepository;
@@ -61,9 +56,6 @@ public class SampleDataLoader implements ApplicationRunner {
private final Random random = new Random(); private final Random random = new Random();
@Value("${spring.kafka.consumer.group-id}")
private String consumerGroupId;
// Kafka Topic Names (MVP용 샘플 토픽) // Kafka Topic Names (MVP용 샘플 토픽)
private static final String EVENT_CREATED_TOPIC = "sample.event.created"; private static final String EVENT_CREATED_TOPIC = "sample.event.created";
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered"; private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
@@ -93,9 +85,9 @@ public class SampleDataLoader implements ApplicationRunner {
// Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해) // Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중..."); log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events_v2"); redisTemplate.delete("processed_events");
redisTemplate.delete("distribution_completed_v2"); redisTemplate.delete("distribution_completed");
redisTemplate.delete("processed_participants_v2"); redisTemplate.delete("processed_participants");
log.info("✅ Redis 멱등성 키 삭제 완료"); log.info("✅ Redis 멱등성 키 삭제 완료");
try { try {
@@ -111,8 +103,6 @@ public class SampleDataLoader implements ApplicationRunner {
// 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자) // 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자)
publishParticipantRegisteredEvents(); publishParticipantRegisteredEvents();
log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 락 고려)
log.info("========================================"); log.info("========================================");
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)"); log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
@@ -137,17 +127,16 @@ public class SampleDataLoader implements ApplicationRunner {
} }
/** /**
* 서비스 종료 시 전체 데이터 삭제 및 Consumer Offset 리셋 * 서비스 종료 시 전체 데이터 삭제
*/ */
@PreDestroy @PreDestroy
@Transactional @Transactional
public void onShutdown() { public void onShutdown() {
log.info("========================================"); log.info("========================================");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제 + Kafka Consumer Offset 리셋"); log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
log.info("========================================"); log.info("========================================");
try { try {
// 1. PostgreSQL 데이터 삭제
long timelineCount = timelineDataRepository.count(); long timelineCount = timelineDataRepository.count();
long channelCount = channelStatsRepository.count(); long channelCount = channelStatsRepository.count();
long eventCount = eventStatsRepository.count(); long eventCount = eventStatsRepository.count();
@@ -164,10 +153,6 @@ public class SampleDataLoader implements ApplicationRunner {
entityManager.clear(); entityManager.clear();
log.info("✅ 모든 샘플 데이터 삭제 완료!"); log.info("✅ 모든 샘플 데이터 삭제 완료!");
// 2. Kafka Consumer Offset 리셋 (다음 시작 시 처음부터 읽도록)
resetConsumerOffsets();
log.info("========================================"); log.info("========================================");
} catch (Exception e) { } catch (Exception e) {
@@ -175,85 +160,37 @@ 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 이벤트 발행 * EventCreated 이벤트 발행
*/ */
private void publishEventCreatedEvents() throws Exception { private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%) // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과)
EventCreatedEvent event1 = EventCreatedEvent.builder() EventCreatedEvent event1 = EventCreatedEvent.builder()
.eventId("evt_2025012301") .eventId("evt_2025012301")
.eventTitle("신년맞이 20% 할인 이벤트") .eventTitle("신년맞이 20% 할인 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("5000000")) .totalInvestment(new BigDecimal("5000000"))
.expectedRevenue(new BigDecimal("15000000")) // 투자 대비 3배 수익
.status("ACTIVE") .status("ACTIVE")
.startDate(java.time.LocalDateTime.of(2025, 1, 23, 0, 0)) // 2025-01-23 시작
.endDate(null) // 진행중
.build(); .build();
publishEvent(EVENT_CREATED_TOPIC, event1); publishEvent(EVENT_CREATED_TOPIC, event1);
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%) // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과)
EventCreatedEvent event2 = EventCreatedEvent.builder() EventCreatedEvent event2 = EventCreatedEvent.builder()
.eventId("evt_2025020101") .eventId("evt_2025020101")
.eventTitle("설날 특가 선물세트 이벤트") .eventTitle("설날 특가 선물세트 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("3500000")) .totalInvestment(new BigDecimal("3500000"))
.expectedRevenue(new BigDecimal("7000000")) // 투자 대비 2배 수익
.status("ACTIVE") .status("ACTIVE")
.startDate(java.time.LocalDateTime.of(2025, 2, 1, 0, 0)) // 2025-02-01 시작
.endDate(null) // 진행중
.build(); .build();
publishEvent(EVENT_CREATED_TOPIC, event2); publishEvent(EVENT_CREATED_TOPIC, event2);
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%) // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과)
EventCreatedEvent event3 = EventCreatedEvent.builder() EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("evt_2025011501") .eventId("evt_2025011501")
.eventTitle("겨울 신메뉴 런칭 이벤트") .eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("2000000")) .totalInvestment(new BigDecimal("2000000"))
.expectedRevenue(new BigDecimal("3000000")) // 투자 대비 1.5배 수익
.status("COMPLETED") .status("COMPLETED")
.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(); .build();
publishEvent(EVENT_CREATED_TOPIC, event3); publishEvent(EVENT_CREATED_TOPIC, event3);
@@ -271,63 +208,42 @@ public class SampleDataLoader implements ApplicationRunner {
{1500, 3000, 1000, 500} // 이벤트3 {1500, 3000, 1000, 500} // 이벤트3
}; };
// 각 이벤트의 총 투자 금액
BigDecimal[] totalInvestments = {
new BigDecimal("5000000"), // 이벤트1: 500만원
new BigDecimal("3500000"), // 이벤트2: 350만원
new BigDecimal("2000000") // 이벤트3: 200만원
};
// 채널 배포는 총 투자의 50%만 사용 (나머지는 경품/콘텐츠/운영비용)
double channelBudgetRatio = 0.50;
// 채널별 비용 비율 (채널 예산 내에서: 우리동네TV 30%, 지니TV 30%, 링고비즈 25%, SNS 15%)
double[] costRatios = {0.30, 0.30, 0.25, 0.15};
for (int i = 0; i < eventIds.length; i++) { for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i]; String eventId = eventIds[i];
BigDecimal totalInvestment = totalInvestments[i];
// 채널 배포 예산: 총 투자의 50%
BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio));
// 4개 채널을 배열로 구성 // 4개 채널을 배열로 구성
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>(); List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
// 1. 우리동네TV (TV) - 채널 예산의 30% // 1. 우리동네TV (TV)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("우리동네TV") .channel("우리동네TV")
.channelType("TV") .channelType("TV")
.status("SUCCESS") .status("SUCCESS")
.expectedViews(expectedViews[i][0]) .expectedViews(expectedViews[i][0])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[0])))
.build()); .build());
// 2. 지니TV (TV) - 채널 예산의 30% // 2. 지니TV (TV)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("지니TV") .channel("지니TV")
.channelType("TV") .channelType("TV")
.status("SUCCESS") .status("SUCCESS")
.expectedViews(expectedViews[i][1]) .expectedViews(expectedViews[i][1])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[1])))
.build()); .build());
// 3. 링고비즈 (CALL) - 채널 예산의 25% // 3. 링고비즈 (CALL)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("링고비즈") .channel("링고비즈")
.channelType("CALL") .channelType("CALL")
.status("SUCCESS") .status("SUCCESS")
.expectedViews(expectedViews[i][2]) .expectedViews(expectedViews[i][2])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[2])))
.build()); .build());
// 4. SNS (SNS) - 채널 예산의 15% // 4. SNS (SNS)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("SNS") .channel("SNS")
.channelType("SNS") .channelType("SNS")
.status("SUCCESS") .status("SUCCESS")
.expectedViews(expectedViews[i][3]) .expectedViews(expectedViews[i][3])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[3])))
.build()); .build());
// 이벤트 발행 (채널 배열 포함) // 이벤트 발행 (채널 배열 포함)
@@ -345,53 +261,22 @@ public class SampleDataLoader implements ApplicationRunner {
/** /**
* ParticipantRegistered 이벤트 발행 * ParticipantRegistered 이벤트 발행
*
* 현실적인 참여 패턴 반영:
* - 총 120명의 고유 참여자 풀 생성
* - 일부 참여자는 여러 이벤트에 중복 참여
* - 이벤트1: 100명 (user001~user100)
* - 이벤트2: 50명 (user051~user100) → 50명이 이벤트1과 중복
* - 이벤트3: 30명 (user071~user100) → 30명이 이전 이벤트들과 중복
*/ */
private void publishParticipantRegisteredEvents() throws Exception { private void publishParticipantRegisteredEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
int[] totalParticipants = {100, 50, 30}; // MVP 테스트용 샘플 데이터 (총 180명)
String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"}; String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"};
// 이벤트별 참여자 범위 (중복 참여 반영)
int[][] participantRanges = {
{1, 100}, // 이벤트1: user001~user100 (100명)
{51, 100}, // 이벤트2: user051~user100 (50명, 이벤트1과 50명 중복)
{71, 100} // 이벤트3: user071~user100 (30명, 모두 중복)
};
int totalPublished = 0; int totalPublished = 0;
for (int i = 0; i < eventIds.length; i++) { for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i]; String eventId = eventIds[i];
int startUser = participantRanges[i][0]; int participants = totalParticipants[i];
int endUser = participantRanges[i][1];
int eventParticipants = endUser - startUser + 1;
log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)", // 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행
eventId, startUser, endUser, eventParticipants); for (int j = 0; j < participants; j++) {
String participantId = UUID.randomUUID().toString();
// 각 참여자에 대해 ParticipantRegistered 이벤트 발행 String channel = channels[j % channels.length]; // 채널 순환 배정
for (int userId = startUser; userId <= endUser; userId++) {
String participantId = String.format("user%03d", userId); // user001, user002, ...
// 채널별 가중치 기반 랜덤 배정
// SNS: 45%, 우리동네TV: 25%, 지니TV: 20%, 링고비즈: 10%
int randomValue = random.nextInt(100);
String channel;
if (randomValue < 45) {
channel = "SNS"; // 0~44: 45%
} else if (randomValue < 70) {
channel = "우리동네TV"; // 45~69: 25%
} else if (randomValue < 90) {
channel = "지니TV"; // 70~89: 20%
} else {
channel = "링고비즈"; // 90~99: 10%
}
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder() ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
.eventId(eventId) .eventId(eventId)
@@ -403,38 +288,19 @@ public class SampleDataLoader implements ApplicationRunner {
totalPublished++; totalPublished++;
// 동시성 충돌 방지: 10개마다 100ms 대기 // 동시성 충돌 방지: 10개마다 100ms 대기
if (totalPublished % 10 == 0) { if ((j + 1) % 10 == 0) {
Thread.sleep(100); Thread.sleep(100);
} }
} }
log.info("✅ 이벤트 {} 참여자 발행 완료: {}명", eventId, eventParticipants);
} }
log.info("========================================");
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished); log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
log.info("📊 참여 패턴:");
log.info(" - 총 고유 참여자: 100명 (user001~user100)");
log.info(" - 이벤트1 참여: 100명");
log.info(" - 이벤트2 참여: 50명 (이벤트1과 50명 중복)");
log.info(" - 이벤트3 참여: 30명 (이벤트1,2와 모두 중복)");
log.info(" - 3개 이벤트 모두 참여: 30명");
log.info(" - 2개 이벤트 참여: 20명");
log.info(" - 1개 이벤트만 참여: 50명");
log.info("📺 채널별 참여 비율 (가중치):");
log.info(" - SNS: 45% (가장 높음)");
log.info(" - 우리동네TV: 25%");
log.info(" - 지니TV: 20%");
log.info(" - 링고비즈: 10%");
log.info("========================================");
} }
/** /**
* TimelineData 생성 (시간대별 샘플 데이터) * TimelineData 생성 (시간대별 샘플 데이터)
* *
* - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성 * - 각 이벤트마다 30일 치 daily 데이터 생성
* - interval=hourly: 시간별 표시 (최근 7일 적합)
* - interval=daily: 일별 자동 집계 (30일 전체)
* - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수 * - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수
*/ */
private void createTimelineData() { private void createTimelineData() {
@@ -442,63 +308,52 @@ public class SampleDataLoader implements ApplicationRunner {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
// 각 이벤트별 시간당 기준 참여자 수 (이벤트 성과에 따라 다름) // 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름)
int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음) int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) { for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
String eventId = eventIds[eventIndex]; String eventId = eventIds[eventIndex];
int baseParticipant = baseParticipantsPerHour[eventIndex]; int baseParticipant = baseParticipants[eventIndex];
int cumulativeParticipants = 0; int cumulativeParticipants = 0;
// 이벤트 ID에서 날짜 파싱 (evt_2025012301 → 2025-01-23) // 30일 치 데이터 생성 (2024-09-24부터)
String dateStr = eventId.substring(4); // "2025012301" java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0);
int year = Integer.parseInt(dateStr.substring(0, 4)); // 2025
int month = Integer.parseInt(dateStr.substring(4, 6)); // 01
int day = Integer.parseInt(dateStr.substring(6, 8)); // 23
// 이벤트 시작일부터 30일 치 hourly 데이터 생성 for (int day = 0; day < 30; day++) {
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0); java.time.LocalDateTime timestamp = startDate.plusDays(day);
for (int dayOffset = 0; dayOffset < 30; dayOffset++) { // 랜덤한 참여자 수 생성 (기준값 ± 50%)
for (int hour = 0; hour < 24; hour++) { int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1);
java.time.LocalDateTime timestamp = startDate.plusDays(dayOffset).plusHours(hour); cumulativeParticipants += dailyParticipants;
// 시간대별 참여자 수 변화 (낮 시간대 12~20시에 더 많음) // 조회수는 참여자의 3~5배
int hourMultiplier = (hour >= 12 && hour <= 20) ? 2 : 1; int dailyViews = dailyParticipants * (3 + random.nextInt(3));
int hourlyParticipants = (baseParticipant * hourMultiplier) + random.nextInt(baseParticipant + 1);
cumulativeParticipants += hourlyParticipants; // 참여행동은 참여자의 1~2배
int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
// 조회수는 참여자의 3~5배 // 전환수는 참여자의 50~80%
int hourlyViews = hourlyParticipants * (3 + random.nextInt(3)); int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3));
// 참여행동은 참여자의 1~2배 // TimelineData 생성
int hourlyEngagement = hourlyParticipants * (1 + random.nextInt(2)); 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();
// 전환수는 참여자의 50~80% timelineDataRepository.save(timelineData);
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={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건", log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
eventId, year, month, day);
} }
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건"); log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건");
} }
/** /**
@@ -31,19 +31,31 @@ public class AnalyticsDashboardController {
/** /**
* 성과 대시보드 조회 * 성과 대시보드 조회
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param refresh 캐시 갱신 여부 * @param startDate 조회 시작 날짜
* @return 성과 대시보드 (이벤트 시작일 ~ 현재까지) * @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부
* @return 성과 대시보드
*/ */
@Operation( @Operation(
summary = "성과 대시보드 조회", summary = "성과 대시보드 조회",
description = "이벤트의 전체 성과를 통합하여 조회합니다. (이벤트 시작일 ~ 현재까지)" description = "이벤트의 전체 성과를 통합하여 조회합니다."
) )
@GetMapping("/{eventId}/analytics") @GetMapping("/{eventId}/analytics")
public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> getEventAnalytics( public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> getEventAnalytics(
@Parameter(description = "이벤트 ID", required = true) @Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId, @PathVariable String eventId,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)") @Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)")
@RequestParam(required = false, defaultValue = "false") @RequestParam(required = false, defaultValue = "false")
Boolean refresh Boolean refresh
@@ -51,7 +63,7 @@ public class AnalyticsDashboardController {
log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh); log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
AnalyticsDashboardResponse response = analyticsService.getDashboardData( AnalyticsDashboardResponse response = analyticsService.getDashboardData(
eventId, refresh eventId, startDate, endDate, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -1,75 +0,0 @@
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()));
}
}
}
@@ -33,14 +33,16 @@ public class TimelineAnalyticsController {
/** /**
* 시간대별 참여 추이 * 시간대별 참여 추이
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param interval 시간 간격 단위 * @param interval 시간 간격 단위
* @param metrics 조회할 지표 목록 * @param startDate 조회 시작 날짜
* @return 시간대별 참여 추이 (이벤트 시작일 ~ 현재까지) * @param endDate 조회 종료 날짜
* @param metrics 조회할 지표 목록
* @return 시간대별 참여 추이
*/ */
@Operation( @Operation(
summary = "시간대별 참여 추이", summary = "시간대별 참여 추이",
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다. (이벤트 시작일 ~ 현재까지)" description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다."
) )
@GetMapping("/{eventId}/analytics/timeline") @GetMapping("/{eventId}/analytics/timeline")
public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics( public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics(
@@ -51,6 +53,16 @@ public class TimelineAnalyticsController {
@RequestParam(required = false, defaultValue = "daily") @RequestParam(required = false, defaultValue = "daily")
String interval, String interval,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)") @Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false) @RequestParam(required = false)
String metrics String metrics
@@ -62,7 +74,7 @@ public class TimelineAnalyticsController {
: null; : null;
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics( TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
eventId, interval, metricList eventId, interval, startDate, endDate, metricList
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -31,19 +31,31 @@ public class UserAnalyticsDashboardController {
/** /**
* 사용자 전체 성과 대시보드 조회 * 사용자 전체 성과 대시보드 조회
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @param refresh 캐시 갱신 여부 * @param startDate 조회 시작 날짜
* @return 전체 통합 성과 대시보드 (userId 기반 전체 이벤트 조회) * @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부
* @return 전체 통합 성과 대시보드
*/ */
@Operation( @Operation(
summary = "사용자 전체 성과 대시보드 조회", summary = "사용자 전체 성과 대시보드 조회",
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다. (userId 기반 전체 이벤트 조회)" description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다."
) )
@GetMapping("/{userId}/analytics") @GetMapping("/{userId}/analytics")
public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics( public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics(
@Parameter(description = "사용자 ID", required = true) @Parameter(description = "사용자 ID", required = true)
@PathVariable String userId, @PathVariable String userId,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부") @Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false") @RequestParam(required = false, defaultValue = "false")
Boolean refresh Boolean refresh
@@ -51,7 +63,7 @@ public class UserAnalyticsDashboardController {
log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh); log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData( UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
userId, refresh userId, startDate, endDate, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -30,13 +30,17 @@ public class UserChannelAnalyticsController {
@Operation( @Operation(
summary = "사용자 전체 채널별 성과 분석", summary = "사용자 전체 채널별 성과 분석",
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다. (전체 채널 무조건 표시)" description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
) )
@GetMapping("/{userId}/analytics/channels") @GetMapping("/{userId}/analytics/channels")
public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics( public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics(
@Parameter(description = "사용자 ID", required = true) @Parameter(description = "사용자 ID", required = true)
@PathVariable String userId, @PathVariable String userId,
@Parameter(description = "조회할 채널 목록 (쉼표로 구분)")
@RequestParam(required = false)
String channels,
@Parameter(description = "정렬 기준") @Parameter(description = "정렬 기준")
@RequestParam(required = false, defaultValue = "participants") @RequestParam(required = false, defaultValue = "participants")
String sortBy, String sortBy,
@@ -45,14 +49,28 @@ public class UserChannelAnalyticsController {
@RequestParam(required = false, defaultValue = "desc") @RequestParam(required = false, defaultValue = "desc")
String order, String order,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부") @Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false") @RequestParam(required = false, defaultValue = "false")
Boolean refresh Boolean refresh
) { ) {
log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy); log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy);
List<String> channelList = channels != null && !channels.isBlank()
? Arrays.asList(channels.split(","))
: null;
UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics( UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
userId, sortBy, order, refresh userId, channelList, sortBy, order, startDate, endDate, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -28,7 +28,7 @@ public class UserRoiAnalyticsController {
@Operation( @Operation(
summary = "사용자 전체 ROI 상세 분석", summary = "사용자 전체 ROI 상세 분석",
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)" description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
) )
@GetMapping("/{userId}/analytics/roi") @GetMapping("/{userId}/analytics/roi")
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics( public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics(
@@ -39,6 +39,16 @@ public class UserRoiAnalyticsController {
@RequestParam(required = false, defaultValue = "true") @RequestParam(required = false, defaultValue = "true")
Boolean includeProjection, Boolean includeProjection,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부") @Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false") @RequestParam(required = false, defaultValue = "false")
Boolean refresh Boolean refresh
@@ -46,7 +56,7 @@ public class UserRoiAnalyticsController {
log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection); log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics( UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
userId, includeProjection, refresh userId, includeProjection, startDate, endDate, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -30,7 +30,7 @@ public class UserTimelineAnalyticsController {
@Operation( @Operation(
summary = "사용자 전체 시간대별 참여 추이", summary = "사용자 전체 시간대별 참여 추이",
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)" description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
) )
@GetMapping("/{userId}/analytics/timeline") @GetMapping("/{userId}/analytics/timeline")
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics( public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics(
@@ -41,6 +41,16 @@ public class UserTimelineAnalyticsController {
@RequestParam(required = false, defaultValue = "daily") @RequestParam(required = false, defaultValue = "daily")
String interval, String interval,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)") @Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false) @RequestParam(required = false)
String metrics, String metrics,
@@ -56,7 +66,7 @@ public class UserTimelineAnalyticsController {
: null; : null;
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics( UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
userId, interval, metricList, refresh userId, interval, startDate, endDate, metricList, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -47,21 +47,6 @@ public class AnalyticsDashboardResponse {
*/ */
private RoiSummary roi; private RoiSummary roi;
/**
* 투자 비용 상세
*/
private InvestmentDetails investment;
/**
* 수익 상세
*/
private RevenueDetails revenue;
/**
* 비용 효율성 분석
*/
private CostEfficiency costEfficiency;
/** /**
* 마지막 업데이트 시간 * 마지막 업데이트 시간
*/ */
@@ -33,16 +33,6 @@ public class InvestmentDetails {
*/ */
private BigDecimal operation; private BigDecimal operation;
/**
* 경품 비용 (원)
*/
private BigDecimal prizeCost;
/**
* 채널 비용 (원) - distribution과 동일한 값
*/
private BigDecimal channelCost;
/** /**
* 총 투자 비용 (원) * 총 투자 비용 (원)
*/ */
@@ -26,16 +26,6 @@ public class RevenueDetails {
*/ */
private BigDecimal expectedSales; private BigDecimal expectedSales;
/**
* 신규 고객 매출 (원)
*/
private BigDecimal newCustomerRevenue;
/**
* 기존 고객 매출 (원)
*/
private BigDecimal existingCustomerRevenue;
/** /**
* 브랜드 가치 향상 추정액 (원) * 브랜드 가치 향상 추정액 (원)
*/ */
@@ -125,11 +125,4 @@ public class ChannelStats extends BaseTimeEntity {
@Column(name = "average_duration") @Column(name = "average_duration")
@Builder.Default @Builder.Default
private Integer averageDuration = 0; private Integer averageDuration = 0;
/**
* 참여자 수 증가
*/
public void incrementParticipants() {
this.participants++;
}
} }
@@ -97,18 +97,6 @@ public class EventStats extends BaseTimeEntity {
@Column(length = 20) @Column(length = 20)
private String status; private String status;
/**
* 이벤트 시작일
*/
@Column(name = "start_date")
private java.time.LocalDateTime startDate;
/**
* 이벤트 종료일 (null이면 진행중)
*/
@Column(name = "end_date")
private java.time.LocalDateTime endDate;
/** /**
* 참여자 수 증가 * 참여자 수 증가
*/ */
@@ -32,7 +32,7 @@ public class DistributionCompletedConsumer {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed_v2"; private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:"; private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7; private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -109,15 +109,10 @@ public class DistributionCompletedConsumer {
channelStats.setImpressions(channel.getExpectedViews()); channelStats.setImpressions(channel.getExpectedViews());
} }
// 배포 비용 저장
if (channel.getDistributionCost() != null) {
channelStats.setDistributionCost(channel.getDistributionCost());
}
channelStatsRepository.save(channelStats); channelStatsRepository.save(channelStats);
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}, distributionCost={}", log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}",
eventId, channelName, channel.getExpectedViews(), channel.getDistributionCost()); eventId, channelName, channel.getExpectedViews());
} catch (Exception e) { } catch (Exception e) {
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e); log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);
@@ -12,7 +12,6 @@ import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@@ -30,7 +29,7 @@ public class EventCreatedConsumer {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_EVENTS_KEY = "processed_events_v2"; private static final String PROCESSED_EVENTS_KEY = "processed_events";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:"; private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7; private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -62,15 +61,11 @@ public class EventCreatedConsumer {
.userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑 .userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
.totalParticipants(0) .totalParticipants(0)
.totalInvestment(event.getTotalInvestment()) .totalInvestment(event.getTotalInvestment())
.expectedRevenue(event.getExpectedRevenue() != null ? event.getExpectedRevenue() : BigDecimal.ZERO)
.status(event.getStatus()) .status(event.getStatus())
.startDate(event.getStartDate())
.endDate(event.getEndDate())
.build(); .build();
eventStatsRepository.save(eventStats); eventStatsRepository.save(eventStats);
log.info("✅ 이벤트 통계 초기화 완료: eventId={}, userId={}, startDate={}, endDate={}", log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId);
eventId, eventStats.getUserId(), event.getStartDate(), event.getEndDate());
// 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영) // 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId; String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -1,9 +1,7 @@
package com.kt.event.analytics.messaging.consumer; package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats; import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent; import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository; import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -28,11 +26,10 @@ import java.util.concurrent.TimeUnit;
public class ParticipantRegisteredConsumer { public class ParticipantRegisteredConsumer {
private final EventStatsRepository eventStatsRepository; private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants_v2"; private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:"; private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7; private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -50,13 +47,11 @@ public class ParticipantRegisteredConsumer {
ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class); ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class);
String participantId = event.getParticipantId(); String participantId = event.getParticipantId();
String eventId = event.getEventId(); String eventId = event.getEventId();
String channel = event.getChannel();
// ✅ 1. 멱등성 체크 (중복 처리 방지) - eventId:participantId 조합으로 체크 // ✅ 1. 멱등성 체크 (중복 처리 방지)
String idempotencyKey = eventId + ":" + participantId; Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId);
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
if (Boolean.TRUE.equals(isProcessed)) { if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}, participantId={}", eventId, participantId); log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId);
return; return;
} }
@@ -72,29 +67,15 @@ public class ParticipantRegisteredConsumer {
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId) () -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
); );
// 3. 채널별 참여자 수 업데이트 - 비관적 락 적용 // 3. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
if (channel != null && !channel.isEmpty()) {
channelStatsRepository.findByEventIdAndChannelNameWithLock(eventId, channel)
.ifPresentOrElse(
channelStats -> {
channelStats.incrementParticipants();
channelStatsRepository.save(channelStats);
log.info("✅ 채널별 참여자 수 업데이트: eventId={}, channel={}, participants={}",
eventId, channel, channelStats.getParticipants());
},
() -> log.warn("⚠️ 채널 통계 없음: eventId={}, channel={}", eventId, channel)
);
}
// 4. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId; String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey); redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey); log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 5. 멱등성 처리 완료 기록 (7일 TTL) // 4. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, idempotencyKey); redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId);
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS); redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: eventId={}, participantId={}", eventId, participantId); log.debug("✅ 멱등성 기록: participantId={}", participantId);
} catch (Exception e) { } catch (Exception e) {
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e); log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);
@@ -62,10 +62,5 @@ public class DistributionCompletedEvent {
* 예상 노출 수 * 예상 노출 수
*/ */
private Integer expectedViews; private Integer expectedViews;
/**
* 배포 비용 (원)
*/
private java.math.BigDecimal distributionCost;
} }
} }
@@ -6,7 +6,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
/** /**
* 이벤트 생성 이벤트 * 이벤트 생성 이벤트
@@ -37,23 +36,8 @@ public class EventCreatedEvent {
*/ */
private BigDecimal totalInvestment; private BigDecimal totalInvestment;
/**
* 예상 수익
*/
private BigDecimal expectedRevenue;
/** /**
* 이벤트 상태 * 이벤트 상태
*/ */
private String status; private String status;
/**
* 이벤트 시작일
*/
private LocalDateTime startDate;
/**
* 이벤트 종료일 (null이면 진행중)
*/
private LocalDateTime endDate;
} }
@@ -1,11 +1,7 @@
package com.kt.event.analytics.repository; package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.ChannelStats; import com.kt.event.analytics.entity.ChannelStats;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@@ -34,18 +30,6 @@ public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long
*/ */
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName); Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
/**
* 이벤트 ID와 채널명으로 통계 조회 (비관적 락)
*
* @param eventId 이벤트 ID
* @param channelName 채널명
* @return 채널 통계
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM ChannelStats c WHERE c.eventId = :eventId AND c.channelName = :channelName")
Optional<ChannelStats> findByEventIdAndChannelNameWithLock(@Param("eventId") String eventId,
@Param("channelName") String channelName);
/** /**
* 여러 이벤트 ID로 모든 채널 통계 조회 * 여러 이벤트 ID로 모든 채널 통계 조회
* *
@@ -47,10 +47,12 @@ public class AnalyticsService {
* 대시보드 데이터 조회 * 대시보드 데이터 조회
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param refresh 캐시 갱신 여부 * @param startDate 조회 시작 날짜 (선택)
* @return 대시보드 응답 (이벤트 시작일 ~ 현재까지) * @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부
* @return 대시보드 응답
*/ */
public AnalyticsDashboardResponse getDashboardData(String eventId, boolean refresh) { public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh); log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
String cacheKey = CACHE_KEY_PREFIX + eventId; String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -89,7 +91,7 @@ public class AnalyticsService {
} }
// 3. 대시보드 데이터 구성 // 3. 대시보드 데이터 구성
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList); AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate);
// 4. Redis 캐싱 (1시간 TTL) // 4. Redis 캐싱 (1시간 TTL)
try { try {
@@ -108,9 +110,10 @@ public class AnalyticsService {
/** /**
* 대시보드 데이터 구성 * 대시보드 데이터 구성
*/ */
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList) { private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList,
// 기간 정보 (이벤트 시작일 ~ 현재) LocalDateTime startDate, LocalDateTime endDate) {
PeriodInfo period = buildPeriodInfo(eventStats); // 기간 정보
PeriodInfo period = buildPeriodInfo(startDate, endDate);
// 성과 요약 // 성과 요약
AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList); AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList);
@@ -121,15 +124,6 @@ public class AnalyticsService {
// ROI 요약 // ROI 요약
RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats); RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats);
// 투자 비용 상세
InvestmentDetails investment = buildInvestmentDetails(eventStats, channelStatsList);
// 수익 상세
RevenueDetails revenue = buildRevenueDetails(eventStats);
// 비용 효율성
CostEfficiency costEfficiency = buildCostEfficiency(eventStats);
return AnalyticsDashboardResponse.builder() return AnalyticsDashboardResponse.builder()
.eventId(eventStats.getEventId()) .eventId(eventStats.getEventId())
.eventTitle(eventStats.getEventTitle()) .eventTitle(eventStats.getEventTitle())
@@ -137,21 +131,17 @@ public class AnalyticsService {
.summary(summary) .summary(summary)
.channelPerformance(channelPerformance) .channelPerformance(channelPerformance)
.roi(roiSummary) .roi(roiSummary)
.investment(investment)
.revenue(revenue)
.costEfficiency(costEfficiency)
.lastUpdatedAt(LocalDateTime.now()) .lastUpdatedAt(LocalDateTime.now())
.dataSource("cached") .dataSource("cached")
.build(); .build();
} }
/** /**
* 기간 정보 구성 (이벤트 시작일 ~ 종료일 또는 현재) * 기간 정보 구성
*/ */
private PeriodInfo buildPeriodInfo(EventStats eventStats) { private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = eventStats.getStartDate(); LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = eventStats.getEndDate() != null ? LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
eventStats.getEndDate() : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end); long durationDays = ChronoUnit.DAYS.between(start, end);
@@ -225,88 +215,4 @@ public class AnalyticsService {
return summaries; return summaries;
} }
/**
* 투자 비용 상세 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 실제 채널 배포 비용 집계
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
*/
private InvestmentDetails buildInvestmentDetails(EventStats eventStats, List<ChannelStats> channelStatsList) {
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
// ChannelStats에서 실제 배포 비용 집계
java.math.BigDecimal actualDistribution = channelStatsList.stream()
.map(ChannelStats::getDistributionCost)
.reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add);
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
java.math.BigDecimal remaining = totalInvestment.subtract(actualDistribution);
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
java.math.BigDecimal prizeCost = remaining.multiply(java.math.BigDecimal.valueOf(0.50));
java.math.BigDecimal contentCreation = remaining.multiply(java.math.BigDecimal.valueOf(0.30));
java.math.BigDecimal operation = remaining.multiply(java.math.BigDecimal.valueOf(0.20));
return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation)
.operation(operation)
.distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
}
/**
* 수익 상세 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/
private RevenueDetails buildRevenueDetails(EventStats eventStats) {
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
java.math.BigDecimal directSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.70));
java.math.BigDecimal expectedSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.30));
// 신규 고객 40%, 기존 고객 60%
java.math.BigDecimal newCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.40));
java.math.BigDecimal existingCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.60));
return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales)
.expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(java.math.BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build();
}
/**
* 비용 효율성 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 참여자당 비용 = 총투자 ÷ 총참여자수
* - 참여자당 수익 = 총수익 ÷ 총참여자수
*/
private CostEfficiency buildCostEfficiency(EventStats eventStats) {
int totalParticipants = eventStats.getTotalParticipants();
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
double costPerParticipant = totalParticipants > 0 ?
totalInvestment.doubleValue() / totalParticipants : 0.0;
double revenuePerParticipant = totalParticipants > 0 ?
totalRevenue.doubleValue() / totalParticipants : 0.0;
return CostEfficiency.builder()
.costPerParticipant(costPerParticipant)
.revenuePerParticipant(revenuePerParticipant)
.build();
}
} }
@@ -60,62 +60,43 @@ public class ROICalculator {
/** /**
* 투자 비용 계산 * 투자 비용 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - ChannelStats에서 실제 배포 비용 집계
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
*/ */
private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) { private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) {
BigDecimal totalInvestment = eventStats.getTotalInvestment(); BigDecimal distributionCost = channelStats.stream()
// ChannelStats에서 실제 배포 비용 집계
BigDecimal actualDistribution = channelStats.stream()
.map(ChannelStats::getDistributionCost) .map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용) BigDecimal contentCreation = eventStats.getTotalInvestment()
BigDecimal remaining = totalInvestment.subtract(actualDistribution); .multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40%를 콘텐츠 제작비로 가정
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20% BigDecimal operation = eventStats.getTotalInvestment()
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50)); .multiply(BigDecimal.valueOf(0.1)); // 10%를 운영비로 가정
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
return InvestmentDetails.builder() return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation) .contentCreation(contentCreation)
.distribution(distributionCost)
.operation(operation) .operation(operation)
.distribution(actualDistribution) .total(eventStats.getTotalInvestment())
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build(); .build();
} }
/** /**
* 수익 계산 * 수익 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/ */
private RevenueDetails calculateRevenue(EventStats eventStats) { private RevenueDetails calculateRevenue(EventStats eventStats) {
BigDecimal totalRevenue = eventStats.getExpectedRevenue(); BigDecimal directSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66%를 직접 매출로 가정
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30% BigDecimal expectedSales = eventStats.getExpectedRevenue()
BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70)); .multiply(BigDecimal.valueOf(0.34)); // 34%를 예상 추가 매출로 가정
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
// 신규 고객 40%, 기존 고객 60% BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
return RevenueDetails.builder() return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales) .directSales(directSales)
.expectedSales(expectedSales) .expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue) .brandValue(brandValue)
.existingCustomerRevenue(existingCustomerRevenue) .total(eventStats.getExpectedRevenue())
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build(); .build();
} }
@@ -26,13 +26,20 @@ public class TimelineAnalyticsService {
private final TimelineDataRepository timelineDataRepository; private final TimelineDataRepository timelineDataRepository;
/** /**
* 시간대별 참여 추이 조회 (이벤트 전체 기간) * 시간대별 참여 추이 조회
*/ */
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, List<String> metrics) { public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics) {
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval); log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
// 시간대별 데이터 조회 (이벤트 전체 기간) // 시간대별 데이터 조회
List<TimelineData> timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId); List<TimelineData> timelineDataList;
if (startDate != null && endDate != null) {
timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
} else {
timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
}
// 시간대별 데이터 포인트 구성 // 시간대별 데이터 포인트 구성
List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList); List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList);
@@ -44,11 +44,13 @@ public class UserAnalyticsService {
/** /**
* 사용자 전체 대시보드 데이터 조회 * 사용자 전체 대시보드 데이터 조회
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @param refresh 캐시 갱신 여부 * @param startDate 조회 시작 날짜 (선택)
* @return 사용자 통합 대시보드 응답 (userId 기반 전체 이벤트 조회) * @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부
* @return 사용자 통합 대시보드 응답
*/ */
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, boolean refresh) { public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh); log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId; String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -73,7 +75,7 @@ public class UserAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId); List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) { if (allEvents.isEmpty()) {
log.warn("사용자에 이벤트가 없음: userId={}", userId); log.warn("사용자에 이벤트가 없음: userId={}", userId);
return buildEmptyResponse(userId); return buildEmptyResponse(userId, startDate, endDate);
} }
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size()); log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
@@ -85,7 +87,7 @@ public class UserAnalyticsService {
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds); List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 통합 대시보드 데이터 구성 // 3. 통합 대시보드 데이터 구성
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats); UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate);
// 4. Redis 캐싱 (30분 TTL) // 4. Redis 캐싱 (30분 TTL)
try { try {
@@ -102,15 +104,10 @@ public class UserAnalyticsService {
/** /**
* 빈 응답 생성 (이벤트가 없는 경우) * 빈 응답 생성 (이벤트가 없는 경우)
*/ */
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId) { private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime now = LocalDateTime.now();
return UserAnalyticsDashboardResponse.builder() return UserAnalyticsDashboardResponse.builder()
.userId(userId) .userId(userId)
.period(PeriodInfo.builder() .period(buildPeriodInfo(startDate, endDate))
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.activeEvents(0) .activeEvents(0)
.overallSummary(buildEmptyAnalyticsSummary()) .overallSummary(buildEmptyAnalyticsSummary())
@@ -126,9 +123,10 @@ public class UserAnalyticsService {
* 사용자 통합 대시보드 데이터 구성 * 사용자 통합 대시보드 데이터 구성
*/ */
private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents, private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats) { List<ChannelStats> allChannelStats,
// 기간 정보 (전체 이벤트의 최소/최대 날짜 기반) LocalDateTime startDate, LocalDateTime endDate) {
PeriodInfo period = buildPeriodFromEvents(allEvents); // 기간 정보
PeriodInfo period = buildPeriodInfo(startDate, endDate);
// 전체 이벤트 수 및 활성 이벤트 수 // 전체 이벤트 수 및 활성 이벤트 수
int totalEvents = allEvents.size(); int totalEvents = allEvents.size();
@@ -301,22 +299,16 @@ public class UserAnalyticsService {
/** /**
* 기간 정보 구성 * 기간 정보 구성
*
* 전체 이벤트 중 가장 빠른 시작일 ~ 현재까지의 기간 계산
*/ */
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) { private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = events.stream() LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
.map(EventStats::getStartDate) LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
.filter(Objects::nonNull) long durationDays = ChronoUnit.DAYS.between(start, end);
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = LocalDateTime.now();
return PeriodInfo.builder() return PeriodInfo.builder()
.startDate(start) .startDate(start)
.endDate(end) .endDate(end)
.durationDays((int) ChronoUnit.DAYS.between(start, end)) .durationDays((int) durationDays)
.build(); .build();
} }
@@ -42,9 +42,10 @@ public class UserChannelAnalyticsService {
private static final long CACHE_TTL = 1800; // 30분 private static final long CACHE_TTL = 1800; // 30분
/** /**
* 사용자 전체 채널 분석 데이터 조회 (전체 채널 무조건 표시) * 사용자 전체 채널 분석 데이터 조회
*/ */
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, String sortBy, String order, boolean refresh) { public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> channels, String sortBy, String order,
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh); log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId; String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -65,14 +66,14 @@ public class UserChannelAnalyticsService {
// 2. 데이터 조회 // 2. 데이터 조회
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId); List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) { if (allEvents.isEmpty()) {
return buildEmptyResponse(userId); return buildEmptyResponse(userId, startDate, endDate);
} }
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList()); List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds); List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 응답 구성 (전체 채널) // 3. 응답 구성
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, sortBy, order); UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate);
// 4. 캐싱 // 4. 캐싱
try { try {
@@ -86,15 +87,10 @@ public class UserChannelAnalyticsService {
return response; return response;
} }
private UserChannelAnalyticsResponse buildEmptyResponse(String userId) { private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime now = LocalDateTime.now();
return UserChannelAnalyticsResponse.builder() return UserChannelAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(PeriodInfo.builder() .period(buildPeriodInfo(startDate, endDate))
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.channels(new ArrayList<>()) .channels(new ArrayList<>())
.comparison(ChannelComparison.builder().build()) .comparison(ChannelComparison.builder().build())
@@ -104,10 +100,15 @@ public class UserChannelAnalyticsService {
} }
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents, private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats, List<ChannelStats> allChannelStats, List<String> channels,
String sortBy, String order) { String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
// 채널별 집계 (전체 채널) // 채널 필터링
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(allChannelStats); List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty()
? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
: allChannelStats;
// 채널별 집계
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
// 정렬 // 정렬
channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order); channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
@@ -117,7 +118,7 @@ public class UserChannelAnalyticsService {
return UserChannelAnalyticsResponse.builder() return UserChannelAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodFromEvents(allEvents)) .period(buildPeriodInfo(startDate, endDate))
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.channels(channelAnalyticsList) .channels(channelAnalyticsList)
.comparison(comparison) .comparison(comparison)
@@ -245,24 +246,15 @@ public class UserChannelAnalyticsService {
.build(); .build();
} }
/** private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산 LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
*/ LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) { long durationDays = ChronoUnit.DAYS.between(start, end);
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder() return PeriodInfo.builder()
.startDate(start) .startDate(start)
.endDate(end) .endDate(end)
.durationDays((int) ChronoUnit.DAYS.between(start, end)) .durationDays((int) durationDays)
.build(); .build();
} }
} }
@@ -1,9 +1,7 @@
package com.kt.event.analytics.service; package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*; import com.kt.event.analytics.dto.response.*;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats; import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository; import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -33,14 +31,14 @@ import java.util.stream.Collectors;
public class UserRoiAnalyticsService { public class UserRoiAnalyticsService {
private final EventStatsRepository eventStatsRepository; private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "analytics:user:roi:"; private static final String CACHE_KEY_PREFIX = "analytics:user:roi:";
private static final long CACHE_TTL = 1800; private static final long CACHE_TTL = 1800;
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, boolean refresh) { public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection,
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh); log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId; String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -58,10 +56,10 @@ public class UserRoiAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId); List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) { if (allEvents.isEmpty()) {
return buildEmptyResponse(userId); return buildEmptyResponse(userId, startDate, endDate);
} }
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection); UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate);
try { try {
String jsonData = objectMapper.writeValueAsString(response); String jsonData = objectMapper.writeValueAsString(response);
@@ -73,32 +71,13 @@ public class UserRoiAnalyticsService {
return response; return response;
} }
private UserRoiAnalyticsResponse buildEmptyResponse(String userId) { private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime now = LocalDateTime.now();
return UserRoiAnalyticsResponse.builder() return UserRoiAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(PeriodInfo.builder() .period(buildPeriodInfo(startDate, endDate))
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.overallInvestment(InvestmentDetails.builder() .overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build())
.total(BigDecimal.ZERO) .overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build())
.contentCreation(BigDecimal.ZERO)
.operation(BigDecimal.ZERO)
.distribution(BigDecimal.ZERO)
.prizeCost(BigDecimal.ZERO)
.channelCost(BigDecimal.ZERO)
.build())
.overallRevenue(RevenueDetails.builder()
.total(BigDecimal.ZERO)
.directSales(BigDecimal.ZERO)
.expectedSales(BigDecimal.ZERO)
.newCustomerRevenue(BigDecimal.ZERO)
.existingCustomerRevenue(BigDecimal.ZERO)
.brandValue(BigDecimal.ZERO)
.build())
.overallRoi(RoiCalculation.builder() .overallRoi(RoiCalculation.builder()
.netProfit(BigDecimal.ZERO) .netProfit(BigDecimal.ZERO)
.roiPercentage(0.0) .roiPercentage(0.0)
@@ -109,7 +88,8 @@ public class UserRoiAnalyticsService {
.build(); .build();
} }
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection) { private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection,
LocalDateTime startDate, LocalDateTime endDate) {
BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalProfit = totalRevenue.subtract(totalInvestment); BigDecimal totalProfit = totalRevenue.subtract(totalInvestment);
@@ -118,44 +98,17 @@ public class UserRoiAnalyticsService {
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue() ? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
: 0.0; : 0.0;
// ChannelStats에서 실제 배포 비용 집계
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
BigDecimal actualDistribution = allChannelStats.stream()
.map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
BigDecimal remaining = totalInvestment.subtract(actualDistribution);
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
InvestmentDetails investment = InvestmentDetails.builder() InvestmentDetails investment = InvestmentDetails.builder()
.total(totalInvestment) .total(totalInvestment)
.contentCreation(contentCreation) .contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6)))
.operation(operation) .operation(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
.distribution(actualDistribution) .distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build(); .build();
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30% / 신규 고객 40%, 기존 고객 60%
BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
RevenueDetails revenue = RevenueDetails.builder() RevenueDetails revenue = RevenueDetails.builder()
.total(totalRevenue) .total(totalRevenue)
.directSales(directSales) .directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7)))
.expectedSales(expectedSales) .expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3)))
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build(); .build();
RoiCalculation roiCalc = RoiCalculation.builder() RoiCalculation roiCalc = RoiCalculation.builder()
@@ -196,12 +149,9 @@ public class UserRoiAnalyticsService {
.sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed()) .sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
.collect(Collectors.toList()); .collect(Collectors.toList());
// 전체 이벤트의 최소/최대 날짜로 period 계산
PeriodInfo period = buildPeriodFromEvents(allEvents);
return UserRoiAnalyticsResponse.builder() return UserRoiAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(period) .period(buildPeriodInfo(startDate, endDate))
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.overallInvestment(investment) .overallInvestment(investment)
.overallRevenue(revenue) .overallRevenue(revenue)
@@ -214,20 +164,9 @@ public class UserRoiAnalyticsService {
.build(); .build();
} }
/** private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산 LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
*/ LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder() return PeriodInfo.builder()
.startDate(start) .startDate(start)
.endDate(end) .endDate(end)
@@ -37,6 +37,7 @@ public class UserTimelineAnalyticsService {
private static final long CACHE_TTL = 1800; private static final long CACHE_TTL = 1800;
public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval, public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics, boolean refresh) { List<String> metrics, boolean refresh) {
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh); log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
@@ -55,13 +56,15 @@ public class UserTimelineAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId); List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) { if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, interval); return buildEmptyResponse(userId, interval, startDate, endDate);
} }
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList()); List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<TimelineData> allTimelineData = timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds); List<TimelineData> allTimelineData = startDate != null && endDate != null
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval); UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate);
try { try {
String jsonData = objectMapper.writeValueAsString(response); String jsonData = objectMapper.writeValueAsString(response);
@@ -73,15 +76,10 @@ public class UserTimelineAnalyticsService {
return response; return response;
} }
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval) { private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime now = LocalDateTime.now();
return UserTimelineAnalyticsResponse.builder() return UserTimelineAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(PeriodInfo.builder() .period(buildPeriodInfo(startDate, endDate))
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.interval(interval != null ? interval : "daily") .interval(interval != null ? interval : "daily")
.dataPoints(new ArrayList<>()) .dataPoints(new ArrayList<>())
@@ -93,7 +91,8 @@ public class UserTimelineAnalyticsService {
} }
private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents, private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents,
List<TimelineData> allTimelineData, String interval) { List<TimelineData> allTimelineData, String interval,
LocalDateTime startDate, LocalDateTime endDate) {
Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>(); Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>();
for (TimelineData data : allTimelineData) { for (TimelineData data : allTimelineData) {
@@ -120,7 +119,7 @@ public class UserTimelineAnalyticsService {
return UserTimelineAnalyticsResponse.builder() return UserTimelineAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodFromEvents(allEvents)) .period(buildPeriodInfo(startDate, endDate))
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.interval(interval != null ? interval : "daily") .interval(interval != null ? interval : "daily")
.dataPoints(dataPoints) .dataPoints(dataPoints)
@@ -180,20 +179,9 @@ public class UserTimelineAnalyticsService {
.build() : PeakTimeInfo.builder().build(); .build() : PeakTimeInfo.builder().build();
} }
/** private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산 LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
*/ LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder() return PeriodInfo.builder()
.startDate(start) .startDate(start)
.endDate(end) .endDate(end)
@@ -47,13 +47,11 @@ spring:
enabled: ${KAFKA_ENABLED:true} enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095} bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer: consumer:
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service-consumers-v3} group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service}
auto-offset-reset: earliest auto-offset-reset: earliest
enable-auto-commit: true enable-auto-commit: true
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
properties:
auto.offset.reset: earliest
producer: producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer
@@ -76,10 +74,7 @@ spring:
server: server:
port: ${SERVER_PORT:8086} port: ${SERVER_PORT:8086}
servlet: servlet:
encoding: context-path: /api/v1/analytics
charset: UTF-8
enabled: true
force: true
# JWT # JWT
jwt: jwt:
@@ -40,8 +40,10 @@ public enum ErrorCode {
EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"), EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"), EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"), EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
EVENT_004("EVENT_004", "이벤트 생성에 실패했습니다"), EVENT_004("EVENT_004", "유효하지 않은 eventId 형식입니다"),
EVENT_005("EVENT_005", "벤트 수정 권한이 없습니다"), EVENT_005("EVENT_005", "미 존재하는 eventId입니다"),
EVENT_006("EVENT_006", "이벤트 생성에 실패했습니다"),
EVENT_007("EVENT_007", "이벤트 수정 권한이 없습니다"),
// Job 에러 (JOB_XXX) // Job 에러 (JOB_XXX)
JOB_001("JOB_001", "Job을 찾을 수 없습니다"), JOB_001("JOB_001", "Job을 찾을 수 없습니다"),
@@ -12,7 +12,6 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* JWT 토큰 생성 및 검증 제공자 * JWT 토큰 생성 및 검증 제공자
@@ -57,13 +56,13 @@ public class JwtTokenProvider {
* @return Access Token * @return Access Token
*/ */
public String createAccessToken(UUID userId, UUID storeId, String email, String name, List<String> roles) { public String createAccessToken(String userId, String storeId, String email, String name, List<String> roles) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs); Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
return Jwts.builder() return Jwts.builder()
.subject(userId.toString()) .subject(userId)
.claim("storeId", storeId != null ? storeId.toString() : null) .claim("storeId", storeId)
.claim("email", email) .claim("email", email)
.claim("name", name) .claim("name", name)
.claim("roles", roles) .claim("roles", roles)
@@ -80,12 +79,12 @@ public class JwtTokenProvider {
* @param userId 사용자 ID * @param userId 사용자 ID
* @return Refresh Token * @return Refresh Token
*/ */
public String createRefreshToken(UUID userId) { public String createRefreshToken(String userId) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs); Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
return Jwts.builder() return Jwts.builder()
.subject(userId.toString()) .subject(userId)
.claim("type", "refresh") .claim("type", "refresh")
.issuedAt(now) .issuedAt(now)
.expiration(expiryDate) .expiration(expiryDate)
@@ -99,9 +98,9 @@ public class JwtTokenProvider {
* @param token JWT 토큰 * @param token JWT 토큰
* @return 사용자 ID * @return 사용자 ID
*/ */
public UUID getUserIdFromToken(String token) { public String getUserIdFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
return UUID.fromString(claims.getSubject()); return claims.getSubject();
} }
/** /**
@@ -113,9 +112,8 @@ public class JwtTokenProvider {
public UserPrincipal getUserPrincipalFromToken(String token) { public UserPrincipal getUserPrincipalFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
UUID userId = UUID.fromString(claims.getSubject()); String userId = claims.getSubject();
String storeIdStr = claims.get("storeId", String.class); String storeId = claims.get("storeId", String.class);
UUID storeId = storeIdStr != null ? UUID.fromString(storeIdStr) : null;
String email = claims.get("email", String.class); String email = claims.get("email", String.class);
String name = claims.get("name", String.class); String name = claims.get("name", String.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -9,7 +9,6 @@ import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -24,12 +23,12 @@ public class UserPrincipal implements UserDetails {
/** /**
* 사용자 ID * 사용자 ID
*/ */
private final UUID userId; private final String userId;
/** /**
* 매장 ID * 매장 ID
*/ */
private final UUID storeId; private final String storeId;
/** /**
* 사용자 이메일 * 사용자 이메일
@@ -46,6 +46,9 @@ public class RegenerateImageService implements RegenerateImageUseCase {
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}") @Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
private String modelVersion; private String modelVersion;
@Value("${replicate.mock.enabled:false}")
private boolean mockEnabled;
public RegenerateImageService( public RegenerateImageService(
ReplicateApiClient replicateClient, ReplicateApiClient replicateClient,
CDNUploader cdnUploader, CDNUploader cdnUploader,
@@ -151,6 +154,14 @@ public class RegenerateImageService implements RegenerateImageUseCase {
*/ */
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) { private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
try { 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 width = platform.getWidth();
int height = platform.getHeight(); int height = platform.getHeight();
@@ -274,4 +285,21 @@ public class RegenerateImageService implements RegenerateImageUseCase {
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다", e); 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);
}
} }
@@ -52,6 +52,9 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}") @Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
private String modelVersion; private String modelVersion;
@Value("${replicate.mock.enabled:false}")
private boolean mockEnabled;
public StableDiffusionImageGenerator( public StableDiffusionImageGenerator(
ReplicateApiClient replicateClient, ReplicateApiClient replicateClient,
CDNUploader cdnUploader, CDNUploader cdnUploader,
@@ -188,6 +191,14 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
*/ */
private String generateImage(String prompt, Platform platform) { private String generateImage(String prompt, Platform platform) {
try { 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에서 가져옴) // 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
int width = platform.getWidth(); int width = platform.getWidth();
int height = platform.getHeight(); int height = platform.getHeight();
@@ -236,6 +247,23 @@ 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 예측 완료 대기 (폴링) * Replicate API 예측 완료 대기 (폴링)
* *
@@ -37,6 +37,8 @@ replicate:
token: ${REPLICATE_API_TOKEN:} token: ${REPLICATE_API_TOKEN:}
model: model:
version: ${REPLICATE_MODEL_VERSION:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b} version: ${REPLICATE_MODEL_VERSION:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}
mock:
enabled: ${REPLICATE_MOCK_ENABLED:true}
# CORS Configuration # CORS Configuration
cors: cors:
+1 -1
View File
@@ -20,7 +20,7 @@ data:
EXCLUDE_REDIS: "" EXCLUDE_REDIS: ""
# CORS Configuration # 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,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_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_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH" CORS_ALLOWED_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH"
CORS_ALLOWED_HEADERS: "*" CORS_ALLOWED_HEADERS: "*"
CORS_ALLOW_CREDENTIALS: "true" CORS_ALLOW_CREDENTIALS: "true"
@@ -0,0 +1,234 @@
-- ====================================================================================================
-- 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;
*/
@@ -0,0 +1,233 @@
-- ====================================================================================================
-- 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"))) .email("support@kt-event-marketing.com")))
.servers(List.of( .servers(List.of(
new Server() new Server()
.url("http://localhost:8085/api/v1/distribution") .url("http://localhost:8085")
.description("Local Development Server"), .description("Local Development Server"),
new Server() new Server()
.url("https://dev-api.kt-event-marketing.com/distribution/v1") .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") .url("https://api.kt-event-marketing.com/distribution/v1")
.description("Production Server"), .description("Production Server"),
new Server() new Server()
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/distribution") .url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1")
.description("VM Development Server") .description("VM Development Server")
)); ));
} }
@@ -18,8 +18,8 @@ import org.springframework.web.bind.annotation.*;
/** /**
* Distribution Controller * Distribution Controller
* POST /distribute - 다중 채널 배포 실행 * POST api/v1/distribution/distribute - 다중 채널 배포 실행
* GET /{eventId}/status - 배포 상태 조회 * GET api/v1/distribution/{eventId}/status - 배포 상태 조회
* *
* @author System Architect * @author System Architect
* @since 2025-10-23 * @since 2025-10-23
@@ -123,15 +123,6 @@ channel:
url: ${KAKAO_API_URL:http://localhost:9006/api/kakao} url: ${KAKAO_API_URL:http://localhost:9006/api/kakao}
timeout: 10000 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 OpenAPI (Swagger)
springdoc: springdoc:
api-docs: api-docs:
@@ -1,18 +1,17 @@
package com.kt.event.eventservice.application.dto.kafka; package com.kt.event.eventservice.application.dto.kafka;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* AI 이벤트 생성 작업 메시지 DTO * AI 이벤트 생성 작업 메시지 DTO
* *
* ai-event-generation-job 토픽에서 구독하는 메시지 형식 * ai-event-generation-job 토픽에서 구독하는 메시지 형식
* JSON 필드명: camelCase (Jackson 기본 설정)
*/ */
@Data @Data
@Builder @Builder
@@ -23,73 +22,54 @@ public class AIEventGenerationJobMessage {
/** /**
* 작업 ID * 작업 ID
*/ */
@JsonProperty("job_id")
private String jobId; private String jobId;
/** /**
* 사용자 ID (UUID String) * 사용자 ID (UUID String)
*/ */
@JsonProperty("user_id")
private String userId; private String userId;
/** /**
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED) * 이벤트 ID
*/ */
@JsonProperty("status") private String eventId;
private String status;
/** /**
* AI 추천 결과 데이터 * 이벤트 목적
* - "신규 고객 유치"
* - "재방문 유도"
* - "매출 증대"
* - "브랜드 인지도 향상"
*/ */
@JsonProperty("ai_recommendation") private String objective;
private AIRecommendationData aiRecommendation;
/** /**
* 에러 메시지 (실패 시) * 업종 (storeCategory와 동일)
*/ */
@JsonProperty("error_message") private String industry;
private String errorMessage;
/** /**
* 작업 생성 일시 * 지역 (시/구/동)
*/ */
@JsonProperty("created_at") private String region;
private LocalDateTime createdAt;
/** /**
* 작업 완료/실패 일시 * 매장명
*/ */
@JsonProperty("completed_at") private String storeName;
private LocalDateTime completedAt;
/** /**
* AI 추천 데이터 내부 클래스 * 목표 고객층 (선택)
*/ */
@Data private String targetAudience;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AIRecommendationData {
@JsonProperty("event_title") /**
private String eventTitle; * 예산 (원) (선택)
*/
private Integer budget;
@JsonProperty("event_description") /**
private String eventDescription; * 요청 시각
*/
@JsonProperty("event_type") private LocalDateTime requestedAt;
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;
}
} }
@@ -7,7 +7,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이벤트 생성 완료 메시지 DTO * 이벤트 생성 완료 메시지 DTO
@@ -21,16 +20,16 @@ import java.util.UUID;
public class EventCreatedMessage { public class EventCreatedMessage {
/** /**
* 이벤트 ID (UUID) * 이벤트 ID
*/ */
@JsonProperty("event_id") @JsonProperty("event_id")
private UUID eventId; private String eventId;
/** /**
* 사용자 ID (UUID) * 사용자 ID
*/ */
@JsonProperty("user_id") @JsonProperty("user_id")
private UUID userId; private String userId;
/** /**
* 이벤트 제목 * 이벤트 제목
@@ -8,8 +8,6 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* AI 추천 요청 DTO * AI 추천 요청 DTO
* *
@@ -26,11 +24,24 @@ import java.util.UUID;
@Schema(description = "AI 추천 요청") @Schema(description = "AI 추천 요청")
public class AiRecommendationRequest { public class AiRecommendationRequest {
@NotNull(message = "이벤트 목적은 필수입니다.")
@Schema(description = "이벤트 목적", required = true, example = "신규 고객 유치")
private String objective;
@NotNull(message = "매장 정보는 필수입니다.") @NotNull(message = "매장 정보는 필수입니다.")
@Valid @Valid
@Schema(description = "매장 정보", required = true) @Schema(description = "매장 정보", required = true)
private StoreInfo storeInfo; private StoreInfo storeInfo;
@Schema(description = "지역 정보", example = "서울특별시 강남구")
private String region;
@Schema(description = "타겟 고객층", example = "20-30대 직장인")
private String targetAudience;
@Schema(description = "예산 (원)", example = "500000")
private Integer budget;
/** /**
* 매장 정보 * 매장 정보
*/ */
@@ -42,8 +53,8 @@ public class AiRecommendationRequest {
public static class StoreInfo { public static class StoreInfo {
@NotNull(message = "매장 ID는 필수입니다.") @NotNull(message = "매장 ID는 필수입니다.")
@Schema(description = "매장 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440002") @Schema(description = "매장 ID", required = true, example = "str_20250124_001")
private UUID storeId; private String storeId;
@NotNull(message = "매장명은 필수입니다.") @NotNull(message = "매장명은 필수입니다.")
@Schema(description = "매장명", required = true, example = "우진네 고깃집") @Schema(description = "매장명", required = true, example = "우진네 고깃집")
@@ -6,8 +6,6 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* 이미지 선택 요청 DTO * 이미지 선택 요청 DTO
* *
@@ -22,7 +20,7 @@ import java.util.UUID;
public class SelectImageRequest { public class SelectImageRequest {
@NotNull(message = "이미지 ID는 필수입니다.") @NotNull(message = "이미지 ID는 필수입니다.")
private UUID imageId; private String imageId;
private String imageUrl; private String imageUrl;
} }
@@ -19,6 +19,9 @@ import lombok.NoArgsConstructor;
@Builder @Builder
public class SelectObjectiveRequest { public class SelectObjectiveRequest {
@NotBlank(message = "이벤트 ID는 필수입니다.")
private String eventId;
@NotBlank(message = "이벤트 목적은 필수입니다.") @NotBlank(message = "이벤트 목적은 필수입니다.")
private String objective; private String objective;
} }
@@ -9,7 +9,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.UUID;
/** /**
* AI 추천 선택 요청 DTO * AI 추천 선택 요청 DTO
@@ -28,8 +27,8 @@ import java.util.UUID;
public class SelectRecommendationRequest { public class SelectRecommendationRequest {
@NotNull(message = "추천 ID는 필수입니다.") @NotNull(message = "추천 ID는 필수입니다.")
@Schema(description = "선택한 추천 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440007") @Schema(description = "선택한 추천 ID", required = true, example = "rec_20250124_001")
private UUID recommendationId; private String recommendationId;
@Valid @Valid
@Schema(description = "커스터마이징 항목") @Schema(description = "커스터마이징 항목")
@@ -7,7 +7,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이벤트 생성 응답 DTO * 이벤트 생성 응답 DTO
@@ -22,7 +21,7 @@ import java.util.UUID;
@Builder @Builder
public class EventCreatedResponse { public class EventCreatedResponse {
private UUID eventId; private String eventId;
private EventStatus status; private EventStatus status;
private String objective; private String objective;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -10,7 +10,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* 이벤트 상세 응답 DTO * 이벤트 상세 응답 DTO
@@ -25,16 +24,16 @@ import java.util.UUID;
@Builder @Builder
public class EventDetailResponse { public class EventDetailResponse {
private UUID eventId; private String eventId;
private UUID userId; private String userId;
private UUID storeId; private String storeId;
private String eventName; private String eventName;
private String description; private String description;
private String objective; private String objective;
private LocalDate startDate; private LocalDate startDate;
private LocalDate endDate; private LocalDate endDate;
private EventStatus status; private EventStatus status;
private UUID selectedImageId; private String selectedImageId;
private String selectedImageUrl; private String selectedImageUrl;
private Integer participants; private Integer participants;
private Integer targetParticipants; private Integer targetParticipants;
@@ -57,7 +56,7 @@ public class EventDetailResponse {
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
public static class GeneratedImageDto { public static class GeneratedImageDto {
private UUID imageId; private String imageId;
private String imageUrl; private String imageUrl;
private String style; private String style;
private String platform; private String platform;
@@ -70,7 +69,7 @@ public class EventDetailResponse {
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
public static class AiRecommendationDto { public static class AiRecommendationDto {
private UUID recommendationId; private String recommendationId;
private String eventName; private String eventName;
private String description; private String description;
private String promotionType; private String promotionType;
@@ -7,7 +7,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이미지 편집 응답 DTO * 이미지 편집 응답 DTO
@@ -25,8 +24,8 @@ import java.util.UUID;
@Schema(description = "이미지 편집 응답") @Schema(description = "이미지 편집 응답")
public class ImageEditResponse { public class ImageEditResponse {
@Schema(description = "편집된 이미지 ID", example = "550e8400-e29b-41d4-a716-446655440008") @Schema(description = "편집된 이미지 ID", example = "img_20250124_001")
private UUID imageId; private String imageId;
@Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg") @Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg")
private String imageUrl; private String imageUrl;
@@ -6,7 +6,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이미지 생성 응답 DTO * 이미지 생성 응답 DTO
@@ -21,7 +20,7 @@ import java.util.UUID;
@Builder @Builder
public class ImageGenerationResponse { public class ImageGenerationResponse {
private UUID jobId; private String jobId;
private String status; private String status;
private String message; private String message;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -7,8 +7,6 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* Job 접수 응답 DTO * Job 접수 응답 DTO
* *
@@ -25,8 +23,8 @@ import java.util.UUID;
@Schema(description = "Job 접수 응답") @Schema(description = "Job 접수 응답")
public class JobAcceptedResponse { public class JobAcceptedResponse {
@Schema(description = "생성된 Job ID", example = "550e8400-e29b-41d4-a716-446655440005") @Schema(description = "생성된 Job ID", example = "job_20250124_001")
private UUID jobId; private String jobId;
@Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING") @Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING")
private JobStatus status; private JobStatus status;
@@ -8,7 +8,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* Job 상태 응답 DTO * Job 상태 응답 DTO
@@ -23,7 +22,7 @@ import java.util.UUID;
@Builder @Builder
public class JobStatusResponse { public class JobStatusResponse {
private UUID jobId; private String jobId;
private JobType jobType; private JobType jobType;
private JobStatus status; private JobStatus status;
private int progress; private int progress;
@@ -0,0 +1,86 @@
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;
}
}
@@ -24,7 +24,6 @@ import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -48,22 +47,32 @@ public class EventService {
private final AIJobKafkaProducer aiJobKafkaProducer; private final AIJobKafkaProducer aiJobKafkaProducer;
private final ImageJobKafkaProducer imageJobKafkaProducer; private final ImageJobKafkaProducer imageJobKafkaProducer;
private final EventKafkaProducer eventKafkaProducer; private final EventKafkaProducer eventKafkaProducer;
private final EventIdGenerator eventIdGenerator;
private final JobIdGenerator jobIdGenerator;
/** /**
* 이벤트 생성 (Step 1: 목적 선택) * 이벤트 생성 (Step 1: 목적 선택)
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param storeId 매장 ID (UUID) * @param storeId 매장 ID
* @param request 목적 선택 요청 * @param request 목적 선택 요청 (eventId 포함)
* @return 생성된 이벤트 응답 * @return 생성된 이벤트 응답
*/ */
@Transactional @Transactional
public EventCreatedResponse createEvent(UUID userId, UUID storeId, SelectObjectiveRequest request) { public EventCreatedResponse createEvent(String userId, String storeId, SelectObjectiveRequest request) {
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}", log.info("이벤트 생성 시작 - userId: {}, storeId: {}, eventId: {}, objective: {}",
userId, storeId, request.getObjective()); userId, storeId, request.getEventId(), request.getObjective());
String eventId = request.getEventId();
// 동일한 eventId가 이미 존재하는지 확인
if (eventRepository.findByEventId(eventId).isPresent()) {
throw new BusinessException(ErrorCode.EVENT_005);
}
// 이벤트 엔티티 생성 // 이벤트 엔티티 생성
Event event = Event.builder() Event event = Event.builder()
.eventId(eventId)
.userId(userId) .userId(userId)
.storeId(storeId) .storeId(storeId)
.objective(request.getObjective()) .objective(request.getObjective())
@@ -87,11 +96,11 @@ public class EventService {
/** /**
* 이벤트 상세 조회 * 이벤트 상세 조회
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @return 이벤트 상세 응답 * @return 이벤트 상세 응답
*/ */
public EventDetailResponse getEvent(UUID userId, UUID eventId) { public EventDetailResponse getEvent(String userId, String eventId) {
log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
@@ -108,7 +117,7 @@ public class EventService {
/** /**
* 이벤트 목록 조회 (페이징, 필터링) * 이벤트 목록 조회 (페이징, 필터링)
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param status 상태 필터 * @param status 상태 필터
* @param search 검색어 * @param search 검색어
* @param objective 목적 필터 * @param objective 목적 필터
@@ -116,7 +125,7 @@ public class EventService {
* @return 이벤트 목록 * @return 이벤트 목록
*/ */
public Page<EventDetailResponse> getEvents( public Page<EventDetailResponse> getEvents(
UUID userId, String userId,
EventStatus status, EventStatus status,
String search, String search,
String objective, String objective,
@@ -139,11 +148,11 @@ public class EventService {
/** /**
* 이벤트 삭제 * 이벤트 삭제
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void deleteEvent(UUID userId, UUID eventId) { public void deleteEvent(String userId, String eventId) {
log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
@@ -161,11 +170,11 @@ public class EventService {
/** /**
* 이벤트 배포 * 이벤트 배포
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void publishEvent(UUID userId, UUID eventId) { public void publishEvent(String userId, String eventId) {
log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
@@ -190,11 +199,11 @@ public class EventService {
/** /**
* 이벤트 종료 * 이벤트 종료
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void endEvent(UUID userId, UUID eventId) { public void endEvent(String userId, String eventId) {
log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
@@ -210,13 +219,13 @@ public class EventService {
/** /**
* 이미지 생성 요청 * 이미지 생성 요청
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 이미지 생성 요청 * @param request 이미지 생성 요청
* @return 이미지 생성 응답 (Job ID 포함) * @return 이미지 생성 응답 (Job ID 포함)
*/ */
@Transactional @Transactional
public ImageGenerationResponse requestImageGeneration(UUID userId, UUID eventId, ImageGenerationRequest request) { public ImageGenerationResponse requestImageGeneration(String userId, String eventId, ImageGenerationRequest request) {
log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId); log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -236,7 +245,11 @@ public class EventService {
String.join(", ", request.getPlatforms())); String.join(", ", request.getPlatforms()));
// Job 엔티티 생성 // Job 엔티티 생성
String jobId = jobIdGenerator.generate(JobType.IMAGE_GENERATION);
log.info("생성된 jobId: {}", jobId);
Job job = Job.builder() Job job = Job.builder()
.jobId(jobId)
.eventId(eventId) .eventId(eventId)
.jobType(JobType.IMAGE_GENERATION) .jobType(JobType.IMAGE_GENERATION)
.build(); .build();
@@ -245,9 +258,9 @@ public class EventService {
// Kafka 메시지 발행 // Kafka 메시지 발행
imageJobKafkaProducer.publishImageGenerationJob( imageJobKafkaProducer.publishImageGenerationJob(
job.getJobId().toString(), job.getJobId(),
userId.toString(), userId,
eventId.toString(), eventId,
prompt prompt
); );
@@ -265,13 +278,13 @@ public class EventService {
/** /**
* 이미지 선택 * 이미지 선택
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param imageId 이미지 ID * @param imageId 이미지 ID
* @param request 이미지 선택 요청 * @param request 이미지 선택 요청
*/ */
@Transactional @Transactional
public void selectImage(UUID userId, UUID eventId, UUID imageId, SelectImageRequest request) { public void selectImage(String userId, String eventId, String imageId, SelectImageRequest request) {
log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -294,18 +307,36 @@ public class EventService {
/** /**
* AI 추천 요청 * AI 추천 요청
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID (프론트엔드에서 생성한 ID)
* @param request AI 추천 요청 * @param request AI 추천 요청 (objective 포함)
* @return Job 접수 응답 * @return Job 접수 응답
*/ */
@Transactional @Transactional
public JobAcceptedResponse requestAiRecommendations(UUID userId, UUID eventId, AiRecommendationRequest request) { public JobAcceptedResponse requestAiRecommendations(String userId, String eventId, AiRecommendationRequest request) {
log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId); log.info("AI 추천 요청 - userId: {}, eventId: {}, objective: {}",
userId, eventId, request.getObjective());
// 이벤트 조회 및 권한 확인 // 이벤트 조회 또는 생성
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); .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);
});
// DRAFT 상태 확인 // DRAFT 상태 확인
if (!event.isModifiable()) { if (!event.isModifiable()) {
@@ -313,7 +344,11 @@ public class EventService {
} }
// Job 엔티티 생성 // Job 엔티티 생성
String jobId = jobIdGenerator.generate(JobType.AI_RECOMMENDATION);
log.info("생성된 jobId: {}", jobId);
Job job = Job.builder() Job job = Job.builder()
.jobId(jobId)
.eventId(eventId) .eventId(eventId)
.jobType(JobType.AI_RECOMMENDATION) .jobType(JobType.AI_RECOMMENDATION)
.build(); .build();
@@ -322,13 +357,15 @@ public class EventService {
// Kafka 메시지 발행 // Kafka 메시지 발행
aiJobKafkaProducer.publishAIGenerationJob( aiJobKafkaProducer.publishAIGenerationJob(
job.getJobId().toString(), job.getJobId(),
userId.toString(), userId,
eventId.toString(), eventId,
request.getStoreInfo().getStoreName(), request.getStoreInfo().getStoreName(),
request.getStoreInfo().getCategory(), request.getStoreInfo().getCategory(), // industry
request.getStoreInfo().getDescription(), request.getRegion(), // region
event.getObjective() event.getObjective(), // objective
request.getTargetAudience(), // targetAudience
request.getBudget() // budget
); );
log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId()); log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId());
@@ -343,12 +380,12 @@ public class EventService {
/** /**
* AI 추천 선택 * AI 추천 선택
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request AI 추천 선택 요청 * @param request AI 추천 선택 요청
*/ */
@Transactional @Transactional
public void selectRecommendation(UUID userId, UUID eventId, SelectRecommendationRequest request) { public void selectRecommendation(String userId, String eventId, SelectRecommendationRequest request) {
log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}", log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}",
userId, eventId, request.getRecommendationId()); userId, eventId, request.getRecommendationId());
@@ -409,14 +446,14 @@ public class EventService {
/** /**
* 이미지 편집 * 이미지 편집
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param imageId 이미지 ID * @param imageId 이미지 ID
* @param request 이미지 편집 요청 * @param request 이미지 편집 요청
* @return 이미지 편집 응답 * @return 이미지 편집 응답
*/ */
@Transactional @Transactional
public ImageEditResponse editImage(UUID userId, UUID eventId, UUID imageId, ImageEditRequest request) { public ImageEditResponse editImage(String userId, String eventId, String imageId, ImageEditRequest request) {
log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -450,12 +487,12 @@ public class EventService {
/** /**
* 배포 채널 선택 * 배포 채널 선택
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 배포 채널 선택 요청 * @param request 배포 채널 선택 요청
*/ */
@Transactional @Transactional
public void selectChannels(UUID userId, UUID eventId, SelectChannelsRequest request) { public void selectChannels(String userId, String eventId, SelectChannelsRequest request) {
log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}", log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}",
userId, eventId, request.getChannels()); userId, eventId, request.getChannels());
@@ -479,13 +516,13 @@ public class EventService {
/** /**
* 이벤트 수정 * 이벤트 수정
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 이벤트 수정 요청 * @param request 이벤트 수정 요청
* @return 이벤트 상세 응답 * @return 이벤트 상세 응답
*/ */
@Transactional @Transactional
public EventDetailResponse updateEvent(UUID userId, UUID eventId, UpdateEventRequest request) { public EventDetailResponse updateEvent(String userId, String eventId, UpdateEventRequest request) {
log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -0,0 +1,106 @@
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;
}
}
@@ -11,8 +11,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/** /**
* Job 서비스 * Job 서비스
* *
@@ -29,6 +27,7 @@ import java.util.UUID;
public class JobService { public class JobService {
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final JobIdGenerator jobIdGenerator;
/** /**
* Job 생성 * Job 생성
@@ -38,10 +37,15 @@ public class JobService {
* @return 생성된 Job * @return 생성된 Job
*/ */
@Transactional @Transactional
public Job createJob(UUID eventId, JobType jobType) { public Job createJob(String eventId, JobType jobType) {
log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType); log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType);
// jobId 생성
String jobId = jobIdGenerator.generate(jobType);
log.info("생성된 jobId: {}", jobId);
Job job = Job.builder() Job job = Job.builder()
.jobId(jobId)
.eventId(eventId) .eventId(eventId)
.jobType(jobType) .jobType(jobType)
.build(); .build();
@@ -59,7 +63,7 @@ public class JobService {
* @param jobId Job ID * @param jobId Job ID
* @return Job 상태 응답 * @return Job 상태 응답
*/ */
public JobStatusResponse getJobStatus(UUID jobId) { public JobStatusResponse getJobStatus(String jobId) {
log.info("Job 상태 조회 - jobId: {}", jobId); log.info("Job 상태 조회 - jobId: {}", jobId);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)
@@ -75,7 +79,7 @@ public class JobService {
* @param progress 진행률 * @param progress 진행률
*/ */
@Transactional @Transactional
public void updateJobProgress(UUID jobId, int progress) { public void updateJobProgress(String jobId, int progress) {
log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress); log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)
@@ -93,7 +97,7 @@ public class JobService {
* @param resultKey Redis 결과 키 * @param resultKey Redis 결과 키
*/ */
@Transactional @Transactional
public void completeJob(UUID jobId, String resultKey) { public void completeJob(String jobId, String resultKey) {
log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey); log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)
@@ -113,7 +117,7 @@ public class JobService {
* @param errorMessage 에러 메시지 * @param errorMessage 에러 메시지
*/ */
@Transactional @Transactional
public void failJob(UUID jobId, String errorMessage) { public void failJob(String jobId, String errorMessage) {
log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage); log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)
@@ -1,7 +1,5 @@
package com.kt.event.eventservice.application.service; package com.kt.event.eventservice.application.service;
import java.util.UUID;
/** /**
* 알림 서비스 인터페이스 * 알림 서비스 인터페이스
* *
@@ -22,7 +20,7 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param message 알림 메시지 * @param message 알림 메시지
*/ */
void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message); void notifyJobCompleted(String userId, String jobId, String jobType, String message);
/** /**
* 작업 실패 알림 전송 * 작업 실패 알림 전송
@@ -32,7 +30,7 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param errorMessage 에러 메시지 * @param errorMessage 에러 메시지
*/ */
void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage); void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage);
/** /**
* 작업 진행 상태 알림 전송 * 작업 진행 상태 알림 전송
@@ -42,5 +40,5 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param progress 진행률 (0-100) * @param progress 진행률 (0-100)
*/ */
void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress); void notifyJobProgress(String userId, String jobId, String jobType, int progress);
} }
@@ -11,7 +11,6 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.UUID;
/** /**
* 개발 환경용 인증 필터 * 개발 환경용 인증 필터
@@ -35,11 +34,11 @@ public class DevAuthenticationFilter extends OncePerRequestFilter {
// 개발용 기본 UserPrincipal 생성 // 개발용 기본 UserPrincipal 생성
UserPrincipal userPrincipal = new UserPrincipal( UserPrincipal userPrincipal = new UserPrincipal(
UUID.fromString("11111111-1111-1111-1111-111111111111"), // userId "usr_dev_test_001", // userId
UUID.fromString("22222222-2222-2222-2222-222222222222"), // storeId "str_dev_test_001", // storeId
"dev@test.com", // email "dev@test.com", // email
"개발테스트사용자", // name "개발테스트사용자", // name
Collections.singletonList("USER") // roles Collections.singletonList("USER") // roles
); );
// Authentication 객체 생성 및 SecurityContext에 설정 // Authentication 객체 생성 및 SecurityContext에 설정
@@ -37,7 +37,7 @@ public class KafkaConfig {
/** /**
* Kafka Producer 설정 * Kafka Producer 설정
* Producer에서 JSON 문자열을 보내므로 StringSerializer 사용 * Producer에서 객체를 직접 보내므로 JsonSerializer 사용
* *
* @return ProducerFactory 인스턴스 * @return ProducerFactory 인스턴스
*/ */
@@ -46,7 +46,10 @@ public class KafkaConfig {
Map<String, Object> config = new HashMap<>(); Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
// JSON 직렬화 시 타입 정보를 헤더에 추가하지 않음 (마이크로서비스 간 DTO 클래스 불일치 방지)
config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);
// Producer 성능 최적화 설정 // Producer 성능 최적화 설정
config.put(ProducerConfig.ACKS_CONFIG, "all"); config.put(ProducerConfig.ACKS_CONFIG, "all");
@@ -72,6 +72,7 @@ public class SecurityConfig {
/** /**
* CORS 설정 * CORS 설정
* 개발 환경에서 프론트엔드(localhost:3000)의 요청을 허용합니다. * 개발 환경에서 프론트엔드(localhost:3000)의 요청을 허용합니다.
* 쿠키 기반 인증을 위한 설정이 포함되어 있습니다.
* *
* @return CorsConfigurationSource CORS 설정 소스 * @return CorsConfigurationSource CORS 설정 소스
*/ */
@@ -82,7 +83,10 @@ public class SecurityConfig {
// 허용할 Origin (개발 환경) // 허용할 Origin (개발 환경)
configuration.setAllowedOrigins(Arrays.asList( configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:3000" "http://127.0.0.1:3000",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:8083"
)); ));
// 허용할 HTTP 메서드 // 허용할 HTTP 메서드
@@ -90,7 +94,7 @@ public class SecurityConfig {
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS" "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
)); ));
// 허용할 헤더 // 허용할 헤더 (쿠키 포함)
configuration.setAllowedHeaders(Arrays.asList( configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Authorization",
"Content-Type", "Content-Type",
@@ -98,19 +102,21 @@ public class SecurityConfig {
"Accept", "Accept",
"Origin", "Origin",
"Access-Control-Request-Method", "Access-Control-Request-Method",
"Access-Control-Request-Headers" "Access-Control-Request-Headers",
"Cookie"
)); ));
// 인증 정보 포함 허용 // 인증 정보 포함 허용 (쿠키 전송을 위해 필수)
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
// Preflight 요청 캐시 시간 (초) // Preflight 요청 캐시 시간 (초)
configuration.setMaxAge(3600L); configuration.setMaxAge(3600L);
// 노출할 응답 헤더 // 노출할 응답 헤더 (쿠키 포함)
configuration.setExposedHeaders(Arrays.asList( configuration.setExposedHeaders(Arrays.asList(
"Authorization", "Authorization",
"Content-Type" "Content-Type",
"Set-Cookie"
)); ));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
@@ -3,9 +3,6 @@ package com.kt.event.eventservice.domain.entity;
import com.kt.event.common.entity.BaseTimeEntity; import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.GenericGenerator;
import java.util.UUID;
/** /**
* AI 추천 엔티티 * AI 추천 엔티티
@@ -26,10 +23,8 @@ import java.util.UUID;
public class AiRecommendation extends BaseTimeEntity { public class AiRecommendation extends BaseTimeEntity {
@Id @Id
@GeneratedValue(generator = "uuid2") @Column(name = "recommendation_id", length = 50)
@GenericGenerator(name = "uuid2", strategy = "uuid2") private String recommendationId;
@Column(name = "recommendation_id", columnDefinition = "uuid")
private UUID recommendationId;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false) @JoinColumn(name = "event_id", nullable = false)
@@ -6,7 +6,6 @@ import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.Fetch; import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode; import org.hibernate.annotations.FetchMode;
import org.hibernate.annotations.GenericGenerator;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
@@ -32,16 +31,14 @@ import java.util.*;
public class Event extends BaseTimeEntity { public class Event extends BaseTimeEntity {
@Id @Id
@GeneratedValue(generator = "uuid2") @Column(name = "event_id", length = 50)
@GenericGenerator(name = "uuid2", strategy = "uuid2") private String eventId;
@Column(name = "event_id", columnDefinition = "uuid")
private UUID eventId;
@Column(name = "user_id", nullable = false, columnDefinition = "uuid") @Column(name = "user_id", nullable = false, length = 50)
private UUID userId; private String userId;
@Column(name = "store_id", nullable = false, columnDefinition = "uuid") @Column(name = "store_id", nullable = false, length = 50)
private UUID storeId; private String storeId;
@Column(name = "event_name", length = 200) @Column(name = "event_name", length = 200)
private String eventName; private String eventName;
@@ -63,8 +60,8 @@ public class Event extends BaseTimeEntity {
@Builder.Default @Builder.Default
private EventStatus status = EventStatus.DRAFT; private EventStatus status = EventStatus.DRAFT;
@Column(name = "selected_image_id", columnDefinition = "uuid") @Column(name = "selected_image_id", length = 50)
private UUID selectedImageId; private String selectedImageId;
@Column(name = "selected_image_url", length = 500) @Column(name = "selected_image_url", length = 500)
private String selectedImageUrl; private String selectedImageUrl;
@@ -128,7 +125,7 @@ public class Event extends BaseTimeEntity {
/** /**
* 이미지 선택 * 이미지 선택
*/ */
public void selectImage(UUID imageId, String imageUrl) { public void selectImage(String imageId, String imageUrl) {
this.selectedImageId = imageId; this.selectedImageId = imageId;
this.selectedImageUrl = imageUrl; this.selectedImageUrl = imageUrl;
@@ -3,9 +3,6 @@ package com.kt.event.eventservice.domain.entity;
import com.kt.event.common.entity.BaseTimeEntity; import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.GenericGenerator;
import java.util.UUID;
/** /**
* 생성된 이미지 엔티티 * 생성된 이미지 엔티티
@@ -26,10 +23,8 @@ import java.util.UUID;
public class GeneratedImage extends BaseTimeEntity { public class GeneratedImage extends BaseTimeEntity {
@Id @Id
@GeneratedValue(generator = "uuid2") @Column(name = "image_id", length = 50)
@GenericGenerator(name = "uuid2", strategy = "uuid2") private String imageId;
@Column(name = "image_id", columnDefinition = "uuid")
private UUID imageId;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false) @JoinColumn(name = "event_id", nullable = false)
@@ -5,10 +5,8 @@ import com.kt.event.eventservice.domain.enums.JobStatus;
import com.kt.event.eventservice.domain.enums.JobType; import com.kt.event.eventservice.domain.enums.JobType;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.GenericGenerator;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 비동기 작업 엔티티 * 비동기 작업 엔티티
@@ -29,13 +27,11 @@ import java.util.UUID;
public class Job extends BaseTimeEntity { public class Job extends BaseTimeEntity {
@Id @Id
@GeneratedValue(generator = "uuid2") @Column(name = "job_id", length = 50)
@GenericGenerator(name = "uuid2", strategy = "uuid2") private String jobId;
@Column(name = "job_id", columnDefinition = "uuid")
private UUID jobId;
@Column(name = "event_id", nullable = false, columnDefinition = "uuid") @Column(name = "event_id", nullable = false, length = 50)
private UUID eventId; private String eventId;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "job_type", nullable = false, length = 30) @Column(name = "job_type", nullable = false, length = 30)
@@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* AI 추천 Repository * AI 추천 Repository
@@ -15,15 +14,15 @@ import java.util.UUID;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, UUID> { public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, String> {
/** /**
* 이벤트별 AI 추천 목록 조회 * 이벤트별 AI 추천 목록 조회
*/ */
List<AiRecommendation> findByEventEventId(UUID eventId); List<AiRecommendation> findByEventEventId(String eventId);
/** /**
* 이벤트별 선택된 AI 추천 조회 * 이벤트별 선택된 AI 추천 조회
*/ */
AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId); AiRecommendation findByEventEventIdAndIsSelectedTrue(String eventId);
} }
@@ -10,7 +10,6 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
/** /**
* 이벤트 Repository * 이벤트 Repository
@@ -20,7 +19,12 @@ import java.util.UUID;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface EventRepository extends JpaRepository<Event, UUID> { public interface EventRepository extends JpaRepository<Event, String> {
/**
* 이벤트 ID로 조회
*/
Optional<Event> findByEventId(String eventId);
/** /**
* 사용자 ID와 이벤트 ID로 조회 * 사용자 ID와 이벤트 ID로 조회
@@ -29,8 +33,8 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
"LEFT JOIN FETCH e.channels " + "LEFT JOIN FETCH e.channels " +
"WHERE e.eventId = :eventId AND e.userId = :userId") "WHERE e.eventId = :eventId AND e.userId = :userId")
Optional<Event> findByEventIdAndUserId( Optional<Event> findByEventIdAndUserId(
@Param("eventId") UUID eventId, @Param("eventId") String eventId,
@Param("userId") UUID userId @Param("userId") String userId
); );
/** /**
@@ -42,7 +46,7 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
"AND (:search IS NULL OR e.eventName LIKE %:search%) " + "AND (:search IS NULL OR e.eventName LIKE %:search%) " +
"AND (:objective IS NULL OR e.objective = :objective)") "AND (:objective IS NULL OR e.objective = :objective)")
Page<Event> findEventsByUser( Page<Event> findEventsByUser(
@Param("userId") UUID userId, @Param("userId") String userId,
@Param("status") EventStatus status, @Param("status") EventStatus status,
@Param("search") String search, @Param("search") String search,
@Param("objective") String objective, @Param("objective") String objective,
@@ -52,5 +56,5 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
/** /**
* 사용자별 이벤트 개수 조회 (상태별) * 사용자별 이벤트 개수 조회 (상태별)
*/ */
long countByUserIdAndStatus(UUID userId, EventStatus status); long countByUserIdAndStatus(String userId, EventStatus status);
} }
@@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* 생성된 이미지 Repository * 생성된 이미지 Repository
@@ -15,15 +14,15 @@ import java.util.UUID;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, UUID> { public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, String> {
/** /**
* 이벤트별 생성된 이미지 목록 조회 * 이벤트별 생성된 이미지 목록 조회
*/ */
List<GeneratedImage> findByEventEventId(UUID eventId); List<GeneratedImage> findByEventEventId(String eventId);
/** /**
* 이벤트별 선택된 이미지 조회 * 이벤트별 선택된 이미지 조회
*/ */
GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId); GeneratedImage findByEventEventIdAndIsSelectedTrue(String eventId);
} }
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
/** /**
* 비동기 작업 Repository * 비동기 작업 Repository
@@ -18,22 +17,22 @@ import java.util.UUID;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface JobRepository extends JpaRepository<Job, UUID> { public interface JobRepository extends JpaRepository<Job, String> {
/** /**
* 이벤트별 작업 목록 조회 * 이벤트별 작업 목록 조회
*/ */
List<Job> findByEventId(UUID eventId); List<Job> findByEventId(String eventId);
/** /**
* 이벤트 및 작업 유형별 조회 * 이벤트 및 작업 유형별 조회
*/ */
Optional<Job> findByEventIdAndJobType(UUID eventId, JobType jobType); Optional<Job> findByEventIdAndJobType(String eventId, JobType jobType);
/** /**
* 이벤트 및 작업 유형별 최신 작업 조회 * 이벤트 및 작업 유형별 최신 작업 조회
*/ */
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType); Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(String eventId, JobType jobType);
/** /**
* 상태별 작업 목록 조회 * 상태별 작업 목록 조회
@@ -18,8 +18,6 @@ import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/** /**
* AI 이벤트 생성 작업 메시지 구독 Consumer * AI 이벤트 생성 작업 메시지 구독 Consumer
* *
@@ -30,7 +28,8 @@ import java.util.UUID;
* @since 2025-10-29 * @since 2025-10-29
*/ */
@Slf4j @Slf4j
@Component // TODO: 별도 response 토픽 사용 시 활성화
// @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class AIJobKafkaConsumer { public class AIJobKafkaConsumer {
@@ -93,7 +92,7 @@ public class AIJobKafkaConsumer {
@Transactional @Transactional
protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) { protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) {
try { try {
UUID jobId = UUID.fromString(message.getJobId()); String jobId = message.getJobId();
// Job 조회 // Job 조회
Job job = jobRepository.findById(jobId).orElse(null); Job job = jobRepository.findById(jobId).orElse(null);
@@ -102,7 +101,7 @@ public class AIJobKafkaConsumer {
return; return;
} }
UUID eventId = job.getEventId(); String eventId = job.getEventId();
// Event 조회 (모든 케이스에서 사용) // Event 조회 (모든 케이스에서 사용)
Event event = eventRepository.findById(eventId).orElse(null); Event event = eventRepository.findById(eventId).orElse(null);
@@ -142,7 +141,7 @@ public class AIJobKafkaConsumer {
eventId, aiData.getEventTitle()); eventId, aiData.getEventTitle());
// 사용자에게 알림 전송 // 사용자에게 알림 전송
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobCompleted( notificationService.notifyJobCompleted(
userId, userId,
jobId, jobId,
@@ -166,7 +165,7 @@ public class AIJobKafkaConsumer {
// 사용자에게 실패 알림 전송 // 사용자에게 실패 알림 전송
if (event != null) { if (event != null) {
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobFailed( notificationService.notifyJobFailed(
userId, userId,
jobId, jobId,
@@ -185,7 +184,7 @@ public class AIJobKafkaConsumer {
// 사용자에게 진행 상태 알림 전송 // 사용자에게 진행 상태 알림 전송
if (event != null) { if (event != null) {
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobProgress( notificationService.notifyJobProgress(
userId, userId,
jobId, jobId,
@@ -1,6 +1,5 @@
package com.kt.event.eventservice.infrastructure.kafka; package com.kt.event.eventservice.infrastructure.kafka;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage; import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -27,7 +26,6 @@ import java.util.concurrent.CompletableFuture;
public class AIJobKafkaProducer { public class AIJobKafkaProducer {
private final KafkaTemplate<String, Object> kafkaTemplate; private final KafkaTemplate<String, Object> kafkaTemplate;
private final ObjectMapper objectMapper;
@Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}") @Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}")
private String aiEventGenerationJobTopic; private String aiEventGenerationJobTopic;
@@ -35,28 +33,38 @@ public class AIJobKafkaProducer {
/** /**
* AI 이벤트 생성 작업 메시지 발행 * AI 이벤트 생성 작업 메시지 발행
* *
* @param jobId 작업 ID (UUID String) * @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8})
* @param userId 사용자 ID (UUID String) * @param userId 사용자 ID
* @param eventId 이벤트 ID (UUID String) * @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
* @param storeName 매장명 * @param storeName 매장명
* @param storeCategory 매장 업종 * @param industry 업종 (매장 카테고리)
* @param storeDescription 매장 설명 * @param region 지역
* @param objective 이벤트 목적 * @param objective 이벤트 목적
* @param targetAudience 목표 고객층 (선택)
* @param budget 예산 (선택)
*/ */
public void publishAIGenerationJob( public void publishAIGenerationJob(
String jobId, String jobId,
String userId, String userId,
String eventId, String eventId,
String storeName, String storeName,
String storeCategory, String industry,
String storeDescription, String region,
String objective) { String objective,
String targetAudience,
Integer budget) {
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder() AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
.jobId(jobId) .jobId(jobId)
.userId(userId) .userId(userId)
.status("PENDING") .eventId(eventId)
.createdAt(LocalDateTime.now()) .storeName(storeName)
.industry(industry)
.region(region)
.objective(objective)
.targetAudience(targetAudience)
.budget(budget)
.requestedAt(LocalDateTime.now())
.build(); .build();
publishMessage(message); publishMessage(message);
@@ -69,11 +77,9 @@ public class AIJobKafkaProducer {
*/ */
public void publishMessage(AIEventGenerationJobMessage message) { public void publishMessage(AIEventGenerationJobMessage message) {
try { try {
// JSON 문자열로 변환 // 객체를 직접 전송 (JsonSerializer가 자동으로 직렬화)
String jsonMessage = objectMapper.writeValueAsString(message);
CompletableFuture<SendResult<String, Object>> future = CompletableFuture<SendResult<String, Object>> future =
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), jsonMessage); kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), message);
future.whenComplete((result, ex) -> { future.whenComplete((result, ex) -> {
if (ex == null) { if (ex == null) {
@@ -29,12 +29,12 @@ public class EventKafkaProducer {
/** /**
* 이벤트 생성 완료 메시지 발행 * 이벤트 생성 완료 메시지 발행
* *
* @param eventId 이벤트 ID (UUID) * @param eventId 이벤트 ID
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param title 이벤트 제목 * @param title 이벤트 제목
* @param eventType 이벤트 타입 * @param eventType 이벤트 타입
*/ */
public void publishEventCreated(java.util.UUID eventId, java.util.UUID userId, String title, String eventType) { public void publishEventCreated(String eventId, String userId, String title, String eventType) {
EventCreatedMessage message = EventCreatedMessage.builder() EventCreatedMessage message = EventCreatedMessage.builder()
.eventId(eventId) .eventId(eventId)
.userId(userId) .userId(userId)
@@ -18,8 +18,6 @@ import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/** /**
* 이미지 생성 작업 메시지 구독 Consumer * 이미지 생성 작업 메시지 구독 Consumer
* *
@@ -94,8 +92,8 @@ public class ImageJobKafkaConsumer {
@Transactional @Transactional
protected void processImageGenerationJob(ImageGenerationJobMessage message) { protected void processImageGenerationJob(ImageGenerationJobMessage message) {
try { try {
UUID jobId = UUID.fromString(message.getJobId()); String jobId = message.getJobId();
UUID eventId = UUID.fromString(message.getEventId()); String eventId = message.getEventId();
// Job 조회 // Job 조회
Job job = jobRepository.findById(jobId).orElse(null); Job job = jobRepository.findById(jobId).orElse(null);
@@ -130,7 +128,7 @@ public class ImageJobKafkaConsumer {
eventId, message.getImageUrl()); eventId, message.getImageUrl());
// 사용자에게 알림 전송 // 사용자에게 알림 전송
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobCompleted( notificationService.notifyJobCompleted(
userId, userId,
jobId, jobId,
@@ -181,7 +179,7 @@ public class ImageJobKafkaConsumer {
// 사용자에게 실패 알림 전송 // 사용자에게 실패 알림 전송
if (event != null) { if (event != null) {
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobFailed( notificationService.notifyJobFailed(
userId, userId,
jobId, jobId,
@@ -202,7 +200,7 @@ public class ImageJobKafkaConsumer {
// 사용자에게 진행 상태 알림 전송 // 사용자에게 진행 상태 알림 전송
if (event != null) { if (event != null) {
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobProgress( notificationService.notifyJobProgress(
userId, userId,
jobId, jobId,
@@ -35,9 +35,9 @@ public class ImageJobKafkaProducer {
/** /**
* 이미지 생성 작업 메시지 발행 * 이미지 생성 작업 메시지 발행
* *
* @param jobId 작업 ID (UUID) * @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8})
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID (UUID) * @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
* @param prompt 이미지 생성 프롬프트 * @param prompt 이미지 생성 프롬프트
*/ */
public void publishImageGenerationJob( public void publishImageGenerationJob(
@@ -4,8 +4,6 @@ import com.kt.event.eventservice.application.service.NotificationService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.UUID;
/** /**
* 로깅 기반 알림 서비스 구현 * 로깅 기반 알림 서비스 구현
* *
@@ -20,16 +18,16 @@ import java.util.UUID;
public class LoggingNotificationService implements NotificationService { public class LoggingNotificationService implements NotificationService {
@Override @Override
public void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message) { public void notifyJobCompleted(String userId, String jobId, String jobType, String message) {
log.info("📢 [작업 완료 알림] UserId: {}, JobId: {}, JobType: {}, Message: {}", log.info("📢 [작업 완료 알림] UserId: {}, JobId: {}, JobType: {}, Message: {}",
userId, jobId, jobType, message); userId, jobId, jobType, message);
// TODO: WebSocket, SSE, 또는 Push Notification으로 실시간 알림 전송 // TODO: WebSocket, SSE, 또는 Push Notification으로 실시간 알림 전송
// 예: webSocketTemplate.convertAndSendToUser(userId.toString(), "/queue/notifications", notification); // 예: webSocketTemplate.convertAndSendToUser(userId, "/queue/notifications", notification);
} }
@Override @Override
public void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage) { public void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage) {
log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}", log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}",
userId, jobId, jobType, errorMessage); userId, jobId, jobType, errorMessage);
@@ -37,7 +35,7 @@ public class LoggingNotificationService implements NotificationService {
} }
@Override @Override
public void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress) { public void notifyJobProgress(String userId, String jobId, String jobType, int progress) {
log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%", log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%",
userId, jobId, jobType, progress); userId, jobId, jobType, progress);
@@ -21,8 +21,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/** /**
* 이벤트 컨트롤러 * 이벤트 컨트롤러
* *
@@ -34,7 +32,7 @@ import java.util.UUID;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/events") @RequestMapping("/events")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Event", description = "이벤트 관리 API") @Tag(name = "Event", description = "이벤트 관리 API")
public class EventController { public class EventController {
@@ -129,7 +127,7 @@ public class EventController {
@GetMapping("/{eventId}") @GetMapping("/{eventId}")
@Operation(summary = "이벤트 상세 조회", description = "특정 이벤트의 상세 정보를 조회합니다.") @Operation(summary = "이벤트 상세 조회", description = "특정 이벤트의 상세 정보를 조회합니다.")
public ResponseEntity<ApiResponse<EventDetailResponse>> getEvent( public ResponseEntity<ApiResponse<EventDetailResponse>> getEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}", log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}",
@@ -150,7 +148,7 @@ public class EventController {
@DeleteMapping("/{eventId}") @DeleteMapping("/{eventId}")
@Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다. DRAFT 상태만 삭제 가능합니다.") @Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다. DRAFT 상태만 삭제 가능합니다.")
public ResponseEntity<ApiResponse<Void>> deleteEvent( public ResponseEntity<ApiResponse<Void>> deleteEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}", log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}",
@@ -171,7 +169,7 @@ public class EventController {
@PostMapping("/{eventId}/publish") @PostMapping("/{eventId}/publish")
@Operation(summary = "이벤트 배포", description = "이벤트를 배포합니다. DRAFT → PUBLISHED 상태 변경.") @Operation(summary = "이벤트 배포", description = "이벤트를 배포합니다. DRAFT → PUBLISHED 상태 변경.")
public ResponseEntity<ApiResponse<Void>> publishEvent( public ResponseEntity<ApiResponse<Void>> publishEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}", log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}",
@@ -192,7 +190,7 @@ public class EventController {
@PostMapping("/{eventId}/end") @PostMapping("/{eventId}/end")
@Operation(summary = "이벤트 종료", description = "이벤트를 종료합니다. PUBLISHED → ENDED 상태 변경.") @Operation(summary = "이벤트 종료", description = "이벤트를 종료합니다. PUBLISHED → ENDED 상태 변경.")
public ResponseEntity<ApiResponse<Void>> endEvent( public ResponseEntity<ApiResponse<Void>> endEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}", log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}",
@@ -214,7 +212,7 @@ public class EventController {
@PostMapping("/{eventId}/images") @PostMapping("/{eventId}/images")
@Operation(summary = "이미지 생성 요청", description = "AI를 통해 이벤트 이미지를 생성합니다.") @Operation(summary = "이미지 생성 요청", description = "AI를 통해 이벤트 이미지를 생성합니다.")
public ResponseEntity<ApiResponse<ImageGenerationResponse>> requestImageGeneration( public ResponseEntity<ApiResponse<ImageGenerationResponse>> requestImageGeneration(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody ImageGenerationRequest request, @Valid @RequestBody ImageGenerationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -243,8 +241,8 @@ public class EventController {
@PutMapping("/{eventId}/images/{imageId}/select") @PutMapping("/{eventId}/images/{imageId}/select")
@Operation(summary = "이미지 선택", description = "생성된 이미지 중 하나를 선택합니다.") @Operation(summary = "이미지 선택", description = "생성된 이미지 중 하나를 선택합니다.")
public ResponseEntity<ApiResponse<Void>> selectImage( public ResponseEntity<ApiResponse<Void>> selectImage(
@PathVariable UUID eventId, @PathVariable String eventId,
@PathVariable UUID imageId, @PathVariable String imageId,
@Valid @RequestBody SelectImageRequest request, @Valid @RequestBody SelectImageRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -272,7 +270,7 @@ public class EventController {
@PostMapping("/{eventId}/ai-recommendations") @PostMapping("/{eventId}/ai-recommendations")
@Operation(summary = "AI 추천 요청", description = "AI 서비스에 이벤트 추천 생성을 요청합니다.") @Operation(summary = "AI 추천 요청", description = "AI 서비스에 이벤트 추천 생성을 요청합니다.")
public ResponseEntity<ApiResponse<JobAcceptedResponse>> requestAiRecommendations( public ResponseEntity<ApiResponse<JobAcceptedResponse>> requestAiRecommendations(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody AiRecommendationRequest request, @Valid @RequestBody AiRecommendationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -300,7 +298,7 @@ public class EventController {
@PutMapping("/{eventId}/recommendations") @PutMapping("/{eventId}/recommendations")
@Operation(summary = "AI 추천 선택", description = "AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다.") @Operation(summary = "AI 추천 선택", description = "AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다.")
public ResponseEntity<ApiResponse<Void>> selectRecommendation( public ResponseEntity<ApiResponse<Void>> selectRecommendation(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody SelectRecommendationRequest request, @Valid @RequestBody SelectRecommendationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -328,8 +326,8 @@ public class EventController {
@PutMapping("/{eventId}/images/{imageId}/edit") @PutMapping("/{eventId}/images/{imageId}/edit")
@Operation(summary = "이미지 편집", description = "선택된 이미지를 편집합니다.") @Operation(summary = "이미지 편집", description = "선택된 이미지를 편집합니다.")
public ResponseEntity<ApiResponse<ImageEditResponse>> editImage( public ResponseEntity<ApiResponse<ImageEditResponse>> editImage(
@PathVariable UUID eventId, @PathVariable String eventId,
@PathVariable UUID imageId, @PathVariable String imageId,
@Valid @RequestBody ImageEditRequest request, @Valid @RequestBody ImageEditRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -357,7 +355,7 @@ public class EventController {
@PutMapping("/{eventId}/channels") @PutMapping("/{eventId}/channels")
@Operation(summary = "배포 채널 선택", description = "이벤트를 배포할 채널을 선택합니다.") @Operation(summary = "배포 채널 선택", description = "이벤트를 배포할 채널을 선택합니다.")
public ResponseEntity<ApiResponse<Void>> selectChannels( public ResponseEntity<ApiResponse<Void>> selectChannels(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody SelectChannelsRequest request, @Valid @RequestBody SelectChannelsRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -384,7 +382,7 @@ public class EventController {
@PutMapping("/{eventId}") @PutMapping("/{eventId}")
@Operation(summary = "이벤트 수정", description = "기존 이벤트의 정보를 수정합니다. DRAFT 상태만 수정 가능합니다.") @Operation(summary = "이벤트 수정", description = "기존 이벤트의 정보를 수정합니다. DRAFT 상태만 수정 가능합니다.")
public ResponseEntity<ApiResponse<EventDetailResponse>> updateEvent( public ResponseEntity<ApiResponse<EventDetailResponse>> updateEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody UpdateEventRequest request, @Valid @RequestBody UpdateEventRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -13,8 +13,6 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/** /**
* Job 컨트롤러 * Job 컨트롤러
* *
@@ -26,7 +24,7 @@ import java.util.UUID;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/jobs") @RequestMapping("/jobs")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Job", description = "비동기 작업 상태 조회 API") @Tag(name = "Job", description = "비동기 작업 상태 조회 API")
public class JobController { public class JobController {
@@ -41,7 +39,7 @@ public class JobController {
*/ */
@GetMapping("/{jobId}") @GetMapping("/{jobId}")
@Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).") @Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).")
public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable UUID jobId) { public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable String jobId) {
log.info("Job 상태 조회 API 호출 - jobId: {}", jobId); log.info("Job 상태 조회 API 호출 - jobId: {}", jobId);
JobStatusResponse response = jobService.getJobStatus(jobId); JobStatusResponse response = jobService.getJobStatus(jobId);
@@ -12,7 +12,7 @@ import java.time.Duration;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/redis-test") @RequestMapping("/redis-test")
@RequiredArgsConstructor @RequiredArgsConstructor
public class RedisTestController { public class RedisTestController {
@@ -71,7 +71,7 @@ spring:
server: server:
port: ${SERVER_PORT:8080} port: ${SERVER_PORT:8080}
servlet: servlet:
context-path: /api/v1/events context-path: /api/v1
shutdown: graceful shutdown: graceful
# Actuator Configuration # Actuator Configuration
@@ -24,7 +24,7 @@ import java.util.Arrays;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
@Value("${cors.allowed-origins:http://localhost:*,https://kt-event-marketing-api.20.214.196.128.nip.io/api/v1}") @Value("${cors.allowed-origins:http://localhost:*}")
private String allowedOrigins; private String allowedOrigins;
@Bean @Bean
@@ -99,13 +99,3 @@ management:
enabled: true enabled: true
readinessState: 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
+81
View File
@@ -0,0 +1,81 @@
@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
+80
View File
@@ -0,0 +1,80 @@
#!/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 "=================================================="
+8
View File
@@ -0,0 +1,8 @@
{
"storeInfo": {
"storeId": "str_dev_test_001",
"storeName": "Woojin BBQ Restaurant",
"category": "Restaurant",
"description": "Korean BBQ restaurant serving fresh Hanwoo beef"
}
}
+82
View File
@@ -0,0 +1,82 @@
#!/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 "=========================================="
+10
View File
@@ -0,0 +1,10 @@
{
"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"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"storeInfo": {
"storeId": "str_dev_test_001",
"storeName": "Golden Dragon Chinese Restaurant",
"category": "RESTAURANT",
"description": "Authentic Chinese cuisine with signature Peking duck and dim sum"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"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
}
+3
View File
@@ -0,0 +1,3 @@
{
"objective": "Chinese New Year promotion with 25% discount"
}
+1
View File
@@ -0,0 +1 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0NmUwZjAyZS04ZDFiLTQzYzItODRmZC0yYjY1ZTEzMjdlYzYiLCJzdG9yZUlkIjoiOGQ4ZmI5NjQtMzM2Mi00ZDk5LWI3YWUtOTcxZTRhODUxYjVhIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNzQ1ODMwLCJleHAiOjE3OTMyODE4MzB9.aP-y6qpc7dl9ChYGI9GQ4Cz7XE2DXXhW7MUA97nN-OU
+1
View File
@@ -0,0 +1 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzYzU0MmY2NC02NWU1LTQyYTAtYWM1Ni1mNjM4OTU3MDU0NDUiLCJzdG9yZUlkIjoiMzlhMTdhYjMtMDg5NC00NGVhLWFkNmItNTFkZDcxZTA3MTcwIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNzQ2OTI2LCJleHAiOjE3OTMyODI5MjZ9.IkYHvQdx1HI9f7tY9efBcXcOqiMmqNNRZ8gl7VOHYUY
+20
View File
@@ -0,0 +1,20 @@
================================================================================
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