Compare commits

4 Commits

Author SHA1 Message Date
Hyowon Yang f80418f5ee 이벤트별 성과분석 날짜 로직 수정 및 설정 개선
- EventCreatedEvent, EventStats에 startDate, endDate 필드 추가
- EventCreatedConsumer에서 이벤트 시작/종료 날짜 저장
- SampleDataLoader에서 실제 날짜로 이벤트 발행
  - evt_2025012301: 2025-01-23 시작 (ACTIVE)
  - evt_2025020101: 2025-02-01 시작 (ACTIVE)
  - evt_2025011501: 2025-01-15~2025-01-31 (COMPLETED)
- AnalyticsService: 이벤트 시작일~종료일(또는 현재) 기간 계산
- UserAnalyticsService: 가장 빠른 이벤트 시작일~현재 기간 계산
- application.yml에서 중복된 context-path 제거
- Consumer Group ID를 analytics-service-consumers-v3로 통일

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 12:47:19 +09:00
Hyowon Yang 108ee10293 Merge branch 'develop' into feature/analytics 2025-10-29 19:31:10 +09:00
Hyowon Yang 20e0d24930 이벤트별 성과분석 대시보드 상세 정보 추가 및 Timeline 날짜 수정
## 주요 변경사항

### 1. Timeline 데이터 날짜 로직 수정
- **파일**: SampleDataLoader.java
- **변경**: 이벤트 ID에서 날짜를 파싱하여 실제 이벤트 시작일 기준으로 Timeline 생성
  - 기존: 모든 이벤트가 2024-09-24부터 시작
  - 수정: evt_2025012301 → 2025-01-23부터 30일치 생성
- **채널 분포**: 가중치 기반 랜덤 배정으로 변경
  - SNS: 45% (최고 비율)
  - 우리동네TV: 25%
  - 지니TV: 20%
  - 링고비즈: 10%

### 2. 이벤트별 API 상세 정보 추가
- **파일**: AnalyticsDashboardResponse.java
- **추가 필드**:
  - investment: InvestmentDetails (투자 비용 상세)
  - revenue: RevenueDetails (수익 상세)
  - costEfficiency: CostEfficiency (비용 효율성)

### 3. 이벤트별 상세 계산 로직 구현
- **파일**: AnalyticsService.java
- **추가 메서드**:
  - buildInvestmentDetails(): 투자 비용 상세 계산
    - 경품비용 50%, 콘텐츠제작비 30%, 운영비 20%, 채널배포비용(실제)
  - buildRevenueDetails(): 수익 상세 계산
    - 직접매출 70%, 예상추가매출 30%, 신규고객 40%, 기존고객 60%
  - buildCostEfficiency(): 비용 효율성 계산
    - 참여자당 비용, 참여자당 수익

### 4. ROI 전용 API 필드 수정
- **파일**: ROICalculator.java
- **수정**: UserRoiAnalyticsService와 동일한 비율 적용
  - investmentDetails에 prizeCost, channelCost 추가
  - revenueDetails에 newCustomerRevenue, existingCustomerRevenue 추가
- **기존 문제**: null 값 반환
- **해결**: 통합분석과 동일한 계산 로직 적용

## API 응답 구조

### GET /api/v1/events/{eventId}/analytics
```json
{
  "investment": {
    "total": 5000000,
    "prizeCost": 1250000,
    "contentCreation": 750000,
    "operation": 500000,
    "distribution": 2500000,
    "channelCost": 2500000
  },
  "revenue": {
    "total": 15000000,
    "directSales": 10500000,
    "expectedSales": 4500000,
    "newCustomerRevenue": 6000000,
    "existingCustomerRevenue": 9000000
  },
  "costEfficiency": {
    "costPerParticipant": 50000,
    "revenuePerParticipant": 150000
  }
}
```

## 테스트 결과
-  Timeline 날짜가 이벤트별로 정확하게 생성됨
-  채널별 참여자 분포가 가중치대로 배정됨
-  이벤트별 API에서 상세 투자/수익 정보 제공
-  ROI API에서 null 값 문제 해결

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 19:28:58 +09:00
Hyowon Yang 98ed508a6f User-level Analytics API 구현 및 Kafka Consumer 설정 개선
주요 변경사항:
- User-level Analytics API 기간 파라미터 제거 (전체 기간 자동 계산)
  * /api/v1/users/{userId}/analytics/dashboard
  * /api/v1/users/{userId}/analytics/channels
  * /api/v1/users/{userId}/analytics/roi
  * /api/v1/users/{userId}/analytics/timeline

- Kafka Consumer 안정성 개선
  * Consumer Group ID를 analytics-service-consumers-v3로 변경
  * Redis 멱등성 키 v2 버전 사용 (processed_events_v2, distribution_completed_v2, processed_participants_v2)
  * ParticipantRegisteredConsumer 멱등성 키를 eventId:participantId 조합으로 변경하여 중복 방지 강화

- 설정 개선
  * UTF-8 인코딩 명시적 설정 추가
  * Kafka auto.offset.reset 설정 명확화

- 테스트 도구 추가
  * tools/reset-analytics-data.ps1: 테스트 데이터 초기화 스크립트
  * DebugController: 개발 환경 디버깅용 엔드포인트

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 18:07:20 +09:00
105 changed files with 1214 additions and 3832 deletions
@@ -8,7 +8,7 @@ stringData:
AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net" AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net"
# Replicate API Token # Replicate API Token
REPLICATE_API_TOKEN: "r8_BsGCJtAg5U5kkMBXSe3pgMkPufSKnUR4NY9gJ" REPLICATE_API_TOKEN: ""
# HuggingFace API Token # HuggingFace API Token
HUGGINGFACE_API_TOKEN: "" HUGGINGFACE_API_TOKEN: ""
@@ -53,6 +53,11 @@ resources:
- analytics-service-cm-analytics-service.yaml - analytics-service-cm-analytics-service.yaml
- analytics-service-secret-analytics-service.yaml - analytics-service-secret-analytics-service.yaml
# Common labels for all resources
commonLabels:
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/part-of: kt-event-marketing
# Image tag replacement (will be overridden by overlays) # Image tag replacement (will be overridden by overlays)
images: images:
- name: acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service - name: acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service
@@ -6,6 +6,10 @@ namespace: kt-event-marketing
bases: bases:
- ../../base - ../../base
# Environment-specific labels
commonLabels:
environment: dev
# Environment-specific patches # Environment-specific patches
patchesStrategicMerge: patchesStrategicMerge:
- user-service-patch.yaml - user-service-patch.yaml
+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="20.249.182.13:9095,4.217.131.59:9095" /> <env name="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
<env name="KAFKA_CONSUMER_GROUP" value="ai" /> <env name="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,8 +21,6 @@
<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" /> <entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
<!-- Sample Data Configuration (MVP Only) --> <!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) --> <!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
-620
View File
@@ -1,620 +0,0 @@
# Develop 브랜치 변경사항 요약
**업데이트 일시**: 2025-10-30
**머지 브랜치**: feature/event → develop
**머지 커밋**: 3465a35
---
## 📊 변경사항 통계
```
60개 파일 변경
+2,795 줄 추가
-222 줄 삭제
```
---
## 🎯 주요 변경사항
### 1. 비즈니스 친화적 ID 생성 시스템 구현
#### EventId 생성 로직
**파일**: `event-service/.../EventIdGenerator.java` (신규)
**ID 포맷**: `EVT-{store_id}-{timestamp}-{random}`
```
예시: EVT-str_dev_test_001-20251030001311-70eea424
```
**특징**:
- ✅ 비즈니스 의미를 담은 접두사 (EVT)
- ✅ 매장 식별자 포함 (store_id)
- ✅ 타임스탬프 기반 시간 추적 가능
- ✅ 랜덤 해시로 유일성 보장
- ✅ 사람이 읽기 쉬운 형식
**구현 내역**:
```java
public class EventIdGenerator {
private static final String PREFIX = "EVT";
public static String generate(String storeId) {
String cleanStoreId = sanitizeStoreId(storeId);
String timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String randomHash = UUID.randomUUID().toString()
.substring(0, 8);
return String.format("%s-%s-%s-%s",
PREFIX, cleanStoreId, timestamp, randomHash);
}
}
```
#### JobId 생성 로직
**파일**: `event-service/.../JobIdGenerator.java` (신규)
**ID 포맷**: `JOB-{type}-{timestamp}-{random}`
```
예시: JOB-IMG-1761750847428-b88d2f54
```
**타입 코드**:
- `IMG`: 이미지 생성 작업
- `AI`: AI 추천 작업
- `REG`: 이미지 재생성 작업
**특징**:
- ✅ 작업 타입 식별 가능
- ✅ 타임스탬프로 작업 시간 추적
- ✅ UUID 기반 유일성 보장
- ✅ 로그 분석 및 디버깅 용이
---
### 2. Kafka 메시지 구조 개선
#### 필드명 표준화 (snake_case → camelCase)
**변경 파일**:
- `AIEventGenerationJobMessage.java`
- `EventCreatedMessage.java`
- `ImageJobKafkaProducer.java`
- `AIJobKafkaProducer.java`
- 관련 Consumer 클래스들
**Before**:
```json
{
"job_id": "...",
"event_id": "...",
"store_id": "...",
"store_name": "..."
}
```
**After**:
```json
{
"jobId": "...",
"eventId": "...",
"storeId": "...",
"storeName": "..."
}
```
**이점**:
- ✅ Java 네이밍 컨벤션 준수
- ✅ JSON 직렬화/역직렬화 간소화
- ✅ 프론트엔드와 일관된 필드명
- ✅ 코드 가독성 향상
**영향받는 메시지**:
1. **이미지 생성 작업 메시지** (`image-generation-job`)
- jobId, eventId, prompt, styles, platforms 등
2. **AI 이벤트 생성 작업 메시지** (`ai-event-generation-job`)
- jobId, eventId, objective, storeInfo 등
3. **이벤트 생성 완료 메시지** (`event-created`)
- eventId, storeId, storeName, objective 등
---
### 3. 데이터베이스 스키마 및 마이그레이션
#### 신규 스키마 파일
**파일**: `develop/database/schema/create_event_tables.sql`
**테이블 구조**:
```sql
-- events 테이블
CREATE TABLE events (
id VARCHAR(100) PRIMARY KEY, -- EVT-{store_id}-{timestamp}-{hash}
user_id VARCHAR(50) NOT NULL,
store_id VARCHAR(50) NOT NULL,
store_name VARCHAR(200),
objective VARCHAR(50),
status VARCHAR(20),
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- jobs 테이블
CREATE TABLE jobs (
id VARCHAR(100) PRIMARY KEY, -- JOB-{type}-{timestamp}-{hash}
event_id VARCHAR(100),
job_type VARCHAR(50),
status VARCHAR(20),
progress INTEGER,
result_message TEXT,
error_message TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- ai_recommendations 테이블
CREATE TABLE ai_recommendations (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(100),
recommendation_text TEXT,
-- ... 기타 필드
);
-- generated_images 테이블
CREATE TABLE generated_images (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(100),
image_url TEXT,
style VARCHAR(50),
platform VARCHAR(50),
-- ... 기타 필드
);
```
#### 마이그레이션 스크립트
**파일**: `develop/database/migration/alter_event_id_to_varchar.sql`
**목적**: 기존 BIGINT 타입의 ID를 VARCHAR로 변경
```sql
-- Step 1: 백업 테이블 생성
CREATE TABLE events_backup AS SELECT * FROM events;
CREATE TABLE jobs_backup AS SELECT * FROM jobs;
-- Step 2: 기존 테이블 삭제
DROP TABLE IF EXISTS events CASCADE;
DROP TABLE IF EXISTS jobs CASCADE;
-- Step 3: 새 스키마로 테이블 재생성
-- (create_event_tables.sql 실행)
-- Step 4: 데이터 마이그레이션
-- (필요시 기존 데이터를 새 형식으로 변환하여 삽입)
```
**주의사항**:
- ⚠️ 프로덕션 환경에서는 반드시 백업 후 실행
- ⚠️ 외래 키 제약조건 재설정 필요
- ⚠️ 애플리케이션 코드와 동시 배포 필요
---
### 4. Content Service 통합 및 개선
#### Content Service 설정 업데이트
**파일**: `content-service/src/main/resources/application.yml`
**변경사항**:
```yaml
# JWT 설정 추가
jwt:
secret: ${JWT_SECRET:kt-event-marketing-jwt-secret...}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
# Azure Blob Storage 설정 추가
azure:
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:...}
container-name: ${AZURE_CONTAINER_NAME:content-images}
```
#### 서비스 개선사항
**파일**: `content-service/.../RegenerateImageService.java`, `StableDiffusionImageGenerator.java`
**주요 개선**:
- ✅ 이미지 재생성 로직 추가 (28줄)
- ✅ Stable Diffusion 통합 개선 (28줄)
- ✅ Mock Mode 개선 (개발 환경)
- ✅ 에러 처리 강화
---
### 5. Event Service 리팩토링
#### DTO 구조 개선
**변경 파일**:
- Request DTO: `AiRecommendationRequest`, `SelectImageRequest`
- Response DTO: `EventCreatedResponse`, `EventDetailResponse`
- Kafka DTO: 모든 메시지 클래스
**주요 변경**:
1. **필드명 표준화**: snake_case → camelCase
2. **ID 타입 변경**: Long → String
3. **Nullable 필드 명시**: @Nullable 어노테이션 추가
4. **Validation 강화**: @NotNull, @NotBlank
#### Service Layer 개선
**파일**: `EventService.java`, `JobService.java`
**Before**:
```java
public EventCreatedResponse createEvent(CreateEventRequest request) {
Event event = new Event();
event.setId(generateSequentialId()); // Long 타입
// ...
}
```
**After**:
```java
public EventCreatedResponse createEvent(CreateEventRequest request) {
String eventId = EventIdGenerator.generate(request.getStoreId());
Event event = Event.builder()
.id(eventId) // String 타입
.storeId(request.getStoreId())
// ...
.build();
}
```
**개선사항**:
- ✅ EventIdGenerator 사용
- ✅ Builder 패턴 적용
- ✅ 비즈니스 로직 분리
- ✅ 에러 처리 개선
---
### 6. Kafka 연동 개선
#### Producer 개선
**파일**: `AIJobKafkaProducer.java`, `ImageJobKafkaProducer.java`
**주요 개선**:
```java
@Service
@RequiredArgsConstructor
@Slf4j
public class ImageJobKafkaProducer {
public void sendImageGenerationJob(ImageGenerationJobMessage message) {
log.info("이미지 생성 작업 메시지 발행 시작 - JobId: {}",
message.getJobId());
kafkaTemplate.send(topicName, message.getJobId(), message)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("메시지 발행 실패: {}", ex.getMessage());
} else {
log.info("메시지 발행 성공 - Offset: {}",
result.getRecordMetadata().offset());
}
});
}
}
```
**개선사항**:
- ✅ 상세한 로깅 추가
- ✅ 비동기 콜백 처리
- ✅ 에러 핸들링 강화
- ✅ 메시지 키 설정 (jobId)
#### Consumer 개선
**파일**: `ImageJobKafkaConsumer.java`, `AIJobKafkaConsumer.java`
**주요 개선**:
```java
@KafkaListener(
topics = "${app.kafka.topics.image-generation-job}",
groupId = "${spring.kafka.consumer.group-id}"
)
public void consumeImageJob(
@Payload ImageGenerationJobMessage message,
Acknowledgment ack
) {
log.info("이미지 작업 메시지 수신 - JobId: {}", message.getJobId());
try {
// 메시지 처리
processImageJob(message);
// Manual Acknowledgment
ack.acknowledge();
log.info("메시지 처리 완료 - JobId: {}", message.getJobId());
} catch (Exception e) {
log.error("메시지 처리 실패: {}", e.getMessage());
// 재시도 로직 또는 DLQ 전송
}
}
```
**개선사항**:
- ✅ Manual Acknowledgment 패턴
- ✅ 상세한 로깅
- ✅ 예외 처리 강화
- ✅ 메시지 재시도 메커니즘
---
### 7. 보안 및 인증 개선
#### JWT 토큰 처리 개선
**파일**: `common/security/JwtTokenProvider.java`, `UserPrincipal.java`
**주요 변경**:
```java
public class JwtTokenProvider {
public String getUserId(String token) {
Claims claims = parseToken(token);
return claims.get("userId", String.class); // 명시적 타입 변환
}
public String getStoreId(String token) {
Claims claims = parseToken(token);
return claims.get("storeId", String.class);
}
}
```
**개선사항**:
- ✅ 타입 안전성 향상
- ✅ null 처리 개선
- ✅ 토큰 파싱 로직 강화
- ✅ 에러 메시지 개선
#### 개발 환경 인증 필터
**파일**: `event-service/.../DevAuthenticationFilter.java`
**개선사항**:
- ✅ 개발 환경용 Mock 인증
- ✅ JWT 토큰 파싱 개선
- ✅ 로깅 추가
---
### 8. 테스트 및 문서화
#### 통합 테스트 보고서
**파일**: `test/content-service-integration-test-results.md` (신규, 673줄)
**내용**:
- ✅ 9개 테스트 시나리오 실행 결과
- ✅ 성공률: 100% (9/9)
- ✅ HTTP 통신 검증
- ✅ Job 관리 메커니즘 검증
- ✅ EventId 기반 조회 검증
- ✅ 이미지 재생성 기능 검증
- ✅ 성능 분석 (평균 응답 시간 < 150ms)
#### 아키텍처 분석 문서
**파일**: `test/content-service-integration-analysis.md` (신규, 504줄)
**내용**:
- ✅ content-service API 구조 분석
- ✅ Redis 기반 Job 관리 메커니즘
- ✅ Kafka 연동 현황 분석
- ✅ 서비스 간 통신 구조
- ✅ 권장사항 및 개선 방향
#### Kafka 연동 테스트 보고서
**파일**: `test/test-kafka-integration-results.md` (신규, 348줄)
**내용**:
- ✅ event-service Kafka Producer/Consumer 검증
- ✅ Kafka 브로커 연결 테스트
- ✅ 메시지 발행/수신 검증
- ✅ Manual Acknowledgment 패턴 검증
- ✅ content-service Kafka Consumer 미구현 확인
#### API 테스트 결과
**파일**: `test/API-TEST-RESULT.md` (이동)
**내용**:
- ✅ 기존 API 테스트 결과
- ✅ test/ 폴더로 이동하여 정리
#### 테스트 자동화 스크립트
**파일**:
- `test-content-service.sh` (신규, 82줄)
- `run-content-service.sh` (신규, 80줄)
- `run-content-service.bat` (신규, 81줄)
**기능**:
- ✅ content-service 자동 테스트
- ✅ 서버 실행 스크립트 (Linux/Windows)
- ✅ 7가지 테스트 시나리오 자동 실행
- ✅ Health Check 및 API 검증
#### 테스트 데이터
**파일**:
- `test-integration-event.json`
- `test-integration-objective.json`
- `test-integration-ai-request.json`
- `test-image-generation.json`
- `test-ai-recommendation.json`
**목적**:
- ✅ 통합 테스트용 샘플 데이터
- ✅ API 테스트 자동화
- ✅ 재현 가능한 테스트 환경
---
### 9. 실행 환경 설정
#### IntelliJ 실행 프로파일 업데이트
**파일**:
- `.run/ContentServiceApplication.run.xml`
- `.run/AiServiceApplication.run.xml`
**변경사항**:
```xml
<envs>
<env name="SERVER_PORT" value="8084" />
<env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="DB_HOST" value="4.217.131.139" />
<env name="DB_PORT" value="5432" />
<env name="REPLICATE_MOCK_ENABLED" value="true" />
<!-- JWT, Azure 설정 추가 -->
</envs>
```
**개선사항**:
- ✅ 환경 변수 명시적 설정
- ✅ Mock Mode 설정 추가
- ✅ 데이터베이스 연결 정보 명시
---
## 🔍 Kafka 아키텍처 현황
### 현재 구현된 아키텍처
```
┌─────────────────┐
│ event-service │
│ (Port 8081) │
└────────┬────────┘
├─── Kafka Producer ───→ Kafka Topic (image-generation-job)
│ │
│ │ (event-service Consumer가 수신)
│ ↓
│ ┌──────────────┐
│ │ event-service│
│ │ Consumer │
│ └──────────────┘
└─── Redis Job Data ───→ Redis Cache
┌───────┴────────┐
│ content-service│
│ (Port 8084) │
└────────────────┘
```
### 주요 발견사항
- ⚠️ **content-service에는 Kafka Consumer 미구현**
- ✅ Redis 기반 Job 관리로 서비스 간 통신
- ✅ event-service에서 Producer/Consumer 모두 구현
- ⚠️ 논리 아키텍처 설계와 실제 구현 불일치
### 권장사항
1. **단기**: 설계 문서를 실제 구현에 맞춰 업데이트
2. **중기**: API 문서 자동화 (Swagger/OpenAPI)
3. **장기**: content-service에 Kafka Consumer 추가 구현
---
## 📊 성능 및 품질 지표
### API 응답 시간
```
Health Check: < 50ms
GET 요청: 50-100ms
POST 요청: 100-150ms
```
### Job 처리 시간 (Mock Mode)
```
이미지 4개 생성: ~0.2초
이미지 1개 재생성: ~0.1초
```
### 테스트 성공률
```
통합 테스트: 100% (9/9 성공)
Kafka 연동: 100% (event-service)
API 엔드포인트: 100% (전체 정상)
```
### 코드 품질
```
추가된 코드: 2,795줄
제거된 코드: 222줄
순 증가: 2,573줄
변경된 파일: 60개
```
---
## 🚀 배포 준비 상태
### ✅ 완료된 작업
- [x] EventId/JobId 생성 로직 구현
- [x] Kafka 메시지 구조 개선
- [x] 데이터베이스 스키마 정의
- [x] content-service 통합 테스트 완료
- [x] API 문서화 및 테스트 보고서 작성
- [x] 테스트 자동화 스크립트 작성
### ⏳ 진행 예정 작업
- [ ] content-service Kafka Consumer 구현 (옵션)
- [ ] 프로덕션 환경 데이터베이스 마이그레이션
- [ ] Swagger/OpenAPI 문서 자동화
- [ ] 성능 모니터링 도구 설정
- [ ] 로그 수집 및 분석 시스템 구축
### ⚠️ 주의사항
1. **데이터베이스 마이그레이션**: 프로덕션 배포 전 백업 필수
2. **Kafka 메시지 호환성**: 기존 Consumer가 있다면 메시지 형식 변경 영향 확인
3. **ID 형식 변경**: 기존 데이터와의 호환성 검토 필요
4. **환경 변수**: 모든 환경에서 필요한 환경 변수 설정 확인
---
## 📝 주요 커밋 히스토리
```
3465a35 Merge branch 'feature/event' into develop
8ff79ca 테스트 결과 파일들을 test/ 폴더로 이동
336d811 content-service 통합 테스트 완료 및 보고서 작성
ee941e4 Event-AI Kafka 연동 개선 및 메시지 필드명 camelCase 변경
b71d27a 비즈니스 친화적 eventId 및 jobId 생성 로직 구현
34291e1 백엔드 서비스 구조 개선 및 데이터베이스 스키마 추가
```
---
## 🔗 관련 문서
1. **테스트 보고서**
- `test/content-service-integration-test-results.md`
- `test/test-kafka-integration-results.md`
- `test/API-TEST-RESULT.md`
2. **아키텍처 문서**
- `test/content-service-integration-analysis.md`
3. **데이터베이스**
- `develop/database/schema/create_event_tables.sql`
- `develop/database/migration/alter_event_id_to_varchar.sql`
4. **테스트 스크립트**
- `test-content-service.sh`
- `run-content-service.sh`
- `run-content-service.bat`
---
**작성자**: Backend Developer
**검토자**: System Architect
**최종 업데이트**: 2025-10-30 01:40
@@ -19,7 +19,7 @@ spring:
# Kafka Consumer Configuration # Kafka Consumer Configuration
kafka: kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095} bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092}
consumer: 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
@@ -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" /> <entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
<!-- 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(), null, null, true); analyticsService.getDashboardData(event.getEventId(), 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(), null, null, true); analyticsService.getDashboardData(event.getEventId(), 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 = true) @ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
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}") @Value("${spring.kafka.consumer.group-id:analytics-service-consumers-v3}")
private String groupId; private String groupId;
@Bean @Bean
@@ -0,0 +1,46 @@
package com.kt.event.analytics.config;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka Producer 설정
*
* ⚠️ MVP 전용: SampleDataLoader가 Kafka 이벤트를 발행하기 위해 필요
* ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행하므로 Producer 불필요
*
* String 직렬화 방식 사용 (SampleDataLoader가 JSON 문자열을 직접 발행)
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaProducerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.ACKS_CONFIG, "all");
configProps.put(ProducerConfig.RETRIES_CONFIG, 3);
return new DefaultKafkaProducerFactory<>(configProps);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
@@ -11,19 +11,23 @@ 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.ArrayList; import java.util.*;
import java.util.List; import java.util.concurrent.TimeUnit;
import java.util.Random;
import java.util.UUID;
/** /**
* 샘플 데이터 로더 (Kafka Producer 방식) * 샘플 데이터 로더 (Kafka Producer 방식)
@@ -47,6 +51,7 @@ import java.util.UUID;
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;
@@ -56,6 +61,9 @@ 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";
@@ -85,9 +93,9 @@ public class SampleDataLoader implements ApplicationRunner {
// Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해) // Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중..."); log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events"); redisTemplate.delete("processed_events_v2");
redisTemplate.delete("distribution_completed"); redisTemplate.delete("distribution_completed_v2");
redisTemplate.delete("processed_participants"); redisTemplate.delete("processed_participants_v2");
log.info("✅ Redis 멱등성 키 삭제 완료"); log.info("✅ Redis 멱등성 키 삭제 완료");
try { try {
@@ -103,6 +111,8 @@ 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가 처리 중...)");
@@ -127,16 +137,17 @@ public class SampleDataLoader implements ApplicationRunner {
} }
/** /**
* 서비스 종료 시 전체 데이터 삭제 * 서비스 종료 시 전체 데이터 삭제 및 Consumer Offset 리셋
*/ */
@PreDestroy @PreDestroy
@Transactional @Transactional
public void onShutdown() { public void onShutdown() {
log.info("========================================"); log.info("========================================");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제"); log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제 + Kafka Consumer Offset 리셋");
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();
@@ -153,6 +164,10 @@ 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) {
@@ -160,37 +175,85 @@ public class SampleDataLoader implements ApplicationRunner {
} }
} }
/**
* Kafka Consumer Group Offset 리셋
*
* 서비스 종료 시 Consumer offset을 삭제하여 다음 시작 시
* auto.offset.reset=earliest 설정에 따라 처음부터 읽도록 함
*/
private void resetConsumerOffsets() {
try (AdminClient adminClient = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
log.info("🔄 Kafka Consumer Offset 리셋 시작: group={}", consumerGroupId);
// 모든 토픽의 offset 삭제
Set<TopicPartition> partitions = new HashSet<>();
// 토픽별 파티션 추가 (설계서상 각 토픽은 3개 파티션)
for (int i = 0; i < 3; i++) {
partitions.add(new TopicPartition(EVENT_CREATED_TOPIC, i));
partitions.add(new TopicPartition(PARTICIPANT_REGISTERED_TOPIC, i));
partitions.add(new TopicPartition(DISTRIBUTION_COMPLETED_TOPIC, i));
}
// Consumer Group Offset 삭제
DeleteConsumerGroupOffsetsResult result = adminClient.deleteConsumerGroupOffsets(
consumerGroupId,
partitions
);
// 완료 대기 (최대 10초)
result.all().get(10, TimeUnit.SECONDS);
log.info("✅ Kafka Consumer Offset 리셋 완료!");
log.info(" → 다음 시작 시 처음부터(earliest) 메시지를 읽습니다.");
} catch (Exception e) {
// Offset 리셋 실패는 치명적이지 않으므로 경고만 출력
log.warn("⚠️ Kafka Consumer Offset 리셋 실패 (무시 가능): {}", e.getMessage());
log.warn(" → 수동으로 Consumer Group ID를 변경하거나, Kafka 도구로 offset을 삭제하세요.");
}
}
/** /**
* EventCreated 이벤트 발행 * EventCreated 이벤트 발행
*/ */
private void publishEventCreatedEvents() throws Exception { private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과) // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%)
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: 설날 특가 이벤트 (진행중, 중간 성과) // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%)
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: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과) // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%)
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);
@@ -208,42 +271,63 @@ 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) // 1. 우리동네TV (TV) - 채널 예산의 30%
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) // 2. 지니TV (TV) - 채널 예산의 30%
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) // 3. 링고비즈 (CALL) - 채널 예산의 25%
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) // 4. SNS (SNS) - 채널 예산의 15%
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());
// 이벤트 발행 (채널 배열 포함) // 이벤트 발행 (채널 배열 포함)
@@ -261,22 +345,53 @@ 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 participants = totalParticipants[i]; int startUser = participantRanges[i][0];
int endUser = participantRanges[i][1];
int eventParticipants = endUser - startUser + 1;
// 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행 log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)",
for (int j = 0; j < participants; j++) { eventId, startUser, endUser, eventParticipants);
String participantId = UUID.randomUUID().toString();
String channel = channels[j % channels.length]; // 채널 순환 배정 // 각 참여자에 대해 ParticipantRegistered 이벤트 발행
for (int userId = startUser; userId <= endUser; userId++) {
String participantId = String.format("user%03d", userId); // user001, user002, ...
// 채널별 가중치 기반 랜덤 배정
// SNS: 45%, 우리동네TV: 25%, 지니TV: 20%, 링고비즈: 10%
int randomValue = random.nextInt(100);
String channel;
if (randomValue < 45) {
channel = "SNS"; // 0~44: 45%
} else if (randomValue < 70) {
channel = "우리동네TV"; // 45~69: 25%
} else if (randomValue < 90) {
channel = "지니TV"; // 70~89: 20%
} else {
channel = "링고비즈"; // 90~99: 10%
}
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder() ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
.eventId(eventId) .eventId(eventId)
@@ -288,19 +403,38 @@ public class SampleDataLoader implements ApplicationRunner {
totalPublished++; totalPublished++;
// 동시성 충돌 방지: 10개마다 100ms 대기 // 동시성 충돌 방지: 10개마다 100ms 대기
if ((j + 1) % 10 == 0) { if (totalPublished % 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일 치 daily 데이터 생성 * - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성
* - interval=hourly: 시간별 표시 (최근 7일 적합)
* - interval=daily: 일별 자동 집계 (30일 전체)
* - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수 * - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수
*/ */
private void createTimelineData() { private void createTimelineData() {
@@ -308,52 +442,63 @@ public class SampleDataLoader implements ApplicationRunner {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
// 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름) // 각 이벤트별 시간당 기준 참여자 수 (이벤트 성과에 따라 다름)
int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음) int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) { for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
String eventId = eventIds[eventIndex]; String eventId = eventIds[eventIndex];
int baseParticipant = baseParticipants[eventIndex]; int baseParticipant = baseParticipantsPerHour[eventIndex];
int cumulativeParticipants = 0; int cumulativeParticipants = 0;
// 30일 치 데이터 생성 (2024-09-24부터) // 이벤트 ID에서 날짜 파싱 (evt_2025012301 → 2025-01-23)
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0); String dateStr = eventId.substring(4); // "2025012301"
int year = Integer.parseInt(dateStr.substring(0, 4)); // 2025
int month = Integer.parseInt(dateStr.substring(4, 6)); // 01
int day = Integer.parseInt(dateStr.substring(6, 8)); // 23
for (int day = 0; day < 30; day++) { // 이벤트 시작일부터 30일 치 hourly 데이터 생성
java.time.LocalDateTime timestamp = startDate.plusDays(day); java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0);
// 랜덤한 참여자 수 생성 (기준값 ± 50%) for (int dayOffset = 0; dayOffset < 30; dayOffset++) {
int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1); for (int hour = 0; hour < 24; hour++) {
cumulativeParticipants += dailyParticipants; java.time.LocalDateTime timestamp = startDate.plusDays(dayOffset).plusHours(hour);
// 조회수는 참여자의 3~5배 // 시간대별 참여자 수 변화 (낮 시간대 12~20시에 더 많음)
int dailyViews = dailyParticipants * (3 + random.nextInt(3)); int hourMultiplier = (hour >= 12 && hour <= 20) ? 2 : 1;
int hourlyParticipants = (baseParticipant * hourMultiplier) + random.nextInt(baseParticipant + 1);
// 참여행동은 참여자의 1~2배 cumulativeParticipants += hourlyParticipants;
int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
// 전환수는 참여자의 50~80% // 조회수는 참여자의 3~5배
int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3)); int hourlyViews = hourlyParticipants * (3 + random.nextInt(3));
// TimelineData 생성 // 참여행동은 참여자의 1~2배
com.kt.event.analytics.entity.TimelineData timelineData = int hourlyEngagement = hourlyParticipants * (1 + random.nextInt(2));
com.kt.event.analytics.entity.TimelineData.builder()
.eventId(eventId)
.timestamp(timestamp)
.participants(dailyParticipants)
.views(dailyViews)
.engagement(dailyEngagement)
.conversions(dailyConversions)
.cumulativeParticipants(cumulativeParticipants)
.build();
timelineDataRepository.save(timelineData); // 전환수는 참여자의 50~80%
int hourlyConversions = (int) (hourlyParticipants * (0.5 + random.nextDouble() * 0.3));
// TimelineData 생성
com.kt.event.analytics.entity.TimelineData timelineData =
com.kt.event.analytics.entity.TimelineData.builder()
.eventId(eventId)
.timestamp(timestamp)
.participants(hourlyParticipants)
.views(hourlyViews)
.engagement(hourlyEngagement)
.conversions(hourlyConversions)
.cumulativeParticipants(cumulativeParticipants)
.build();
timelineDataRepository.save(timelineData);
}
} }
log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId); log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건",
eventId, year, month, day);
} }
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건"); log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건");
} }
/** /**
@@ -31,31 +31,19 @@ public class AnalyticsDashboardController {
/** /**
* 성과 대시보드 조회 * 성과 대시보드 조회
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜 * @param refresh 캐시 갱신 여부
* @param endDate 조회 종료 날짜 * @return 성과 대시보드 (이벤트 시작일 ~ 현재까지)
* @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
@@ -63,7 +51,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, startDate, endDate, refresh eventId, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -0,0 +1,75 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.config.SampleDataLoader;
import com.kt.event.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 디버그 컨트롤러
*
* ⚠️ 개발/테스트 전용
*/
@Tag(name = "Debug", description = "디버그 API (개발/테스트 전용)")
@Slf4j
@RestController
@RequestMapping("/api/debug")
@RequiredArgsConstructor
public class DebugController {
private final SampleDataLoader sampleDataLoader;
/**
* 샘플 데이터 수동 생성
*/
@Operation(
summary = "샘플 데이터 수동 생성",
description = "SampleDataLoader를 수동으로 실행하여 샘플 데이터를 생성합니다."
)
@PostMapping("/reload-sample-data")
public ResponseEntity<ApiResponse<String>> reloadSampleData() {
try {
log.info("🔧 수동으로 샘플 데이터 생성 요청");
// SampleDataLoader 실행
sampleDataLoader.run(new ApplicationArguments() {
@Override
public String[] getSourceArgs() {
return new String[0];
}
@Override
public java.util.Set<String> getOptionNames() {
return java.util.Collections.emptySet();
}
@Override
public boolean containsOption(String name) {
return false;
}
@Override
public java.util.List<String> getOptionValues(String name) {
return null;
}
@Override
public java.util.List<String> getNonOptionArgs() {
return java.util.Collections.emptyList();
}
});
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 완료"));
} catch (Exception e) {
log.error("❌ 샘플 데이터 생성 실패", e);
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 실패: " + e.getMessage()));
}
}
}
@@ -33,16 +33,14 @@ public class TimelineAnalyticsController {
/** /**
* 시간대별 참여 추이 * 시간대별 참여 추이
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param interval 시간 간격 단위 * @param interval 시간 간격 단위
* @param startDate 조회 시작 날짜 * @param metrics 조회할 지표 목록
* @param endDate 조회 종료 날짜 * @return 시간대별 참여 추이 (이벤트 시작일 ~ 현재까지)
* @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(
@@ -53,16 +51,6 @@ 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
@@ -74,7 +62,7 @@ public class TimelineAnalyticsController {
: null; : null;
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics( TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
eventId, interval, startDate, endDate, metricList eventId, interval, metricList
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -31,31 +31,19 @@ public class UserAnalyticsDashboardController {
/** /**
* 사용자 전체 성과 대시보드 조회 * 사용자 전체 성과 대시보드 조회
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @param startDate 조회 시작 날짜 * @param refresh 캐시 갱신 여부
* @param endDate 조회 종료 날짜 * @return 전체 통합 성과 대시보드 (userId 기반 전체 이벤트 조회)
* @param refresh 캐시 갱신 여부
* @return 전체 통합 성과 대시보드
*/ */
@Operation( @Operation(
summary = "사용자 전체 성과 대시보드 조회", summary = "사용자 전체 성과 대시보드 조회",
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다." description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다. (userId 기반 전체 이벤트 조회)"
) )
@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
@@ -63,7 +51,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, startDate, endDate, refresh userId, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -30,17 +30,13 @@ 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,
@@ -49,28 +45,14 @@ 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, channelList, sortBy, order, startDate, endDate, refresh userId, sortBy, order, 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를 통합하여 분석합니다." description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
) )
@GetMapping("/{userId}/analytics/roi") @GetMapping("/{userId}/analytics/roi")
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics( public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics(
@@ -39,16 +39,6 @@ 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
@@ -56,7 +46,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, startDate, endDate, refresh userId, includeProjection, 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 = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다." description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
) )
@GetMapping("/{userId}/analytics/timeline") @GetMapping("/{userId}/analytics/timeline")
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics( public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics(
@@ -41,16 +41,6 @@ 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,
@@ -66,7 +56,7 @@ public class UserTimelineAnalyticsController {
: null; : null;
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics( UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
userId, interval, startDate, endDate, metricList, refresh userId, interval, metricList, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@@ -47,6 +47,21 @@ public class AnalyticsDashboardResponse {
*/ */
private RoiSummary roi; private RoiSummary roi;
/**
* 투자 비용 상세
*/
private InvestmentDetails investment;
/**
* 수익 상세
*/
private RevenueDetails revenue;
/**
* 비용 효율성 분석
*/
private CostEfficiency costEfficiency;
/** /**
* 마지막 업데이트 시간 * 마지막 업데이트 시간
*/ */
@@ -33,6 +33,16 @@ public class InvestmentDetails {
*/ */
private BigDecimal operation; private BigDecimal operation;
/**
* 경품 비용 (원)
*/
private BigDecimal prizeCost;
/**
* 채널 비용 (원) - distribution과 동일한 값
*/
private BigDecimal channelCost;
/** /**
* 총 투자 비용 (원) * 총 투자 비용 (원)
*/ */
@@ -26,6 +26,16 @@ public class RevenueDetails {
*/ */
private BigDecimal expectedSales; private BigDecimal expectedSales;
/**
* 신규 고객 매출 (원)
*/
private BigDecimal newCustomerRevenue;
/**
* 기존 고객 매출 (원)
*/
private BigDecimal existingCustomerRevenue;
/** /**
* 브랜드 가치 향상 추정액 (원) * 브랜드 가치 향상 추정액 (원)
*/ */
@@ -125,4 +125,11 @@ 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,6 +97,18 @@ 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"; private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed_v2";
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,10 +109,15 @@ 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={}", log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}, distributionCost={}",
eventId, channelName, channel.getExpectedViews()); eventId, channelName, channel.getExpectedViews(), channel.getDistributionCost());
} catch (Exception e) { } catch (Exception e) {
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e); log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);
@@ -12,6 +12,7 @@ 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;
/** /**
@@ -29,7 +30,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"; private static final String PROCESSED_EVENTS_KEY = "processed_events_v2";
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;
@@ -61,11 +62,15 @@ 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={}", eventId); log.info("✅ 이벤트 통계 초기화 완료: eventId={}, userId={}, startDate={}, endDate={}",
eventId, eventStats.getUserId(), event.getStartDate(), event.getEndDate());
// 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영) // 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId; String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -1,7 +1,9 @@
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;
@@ -26,10 +28,11 @@ 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"; private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants_v2";
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;
@@ -47,11 +50,13 @@ 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. 멱등성 체크 (중복 처리 방지) // ✅ 1. 멱등성 체크 (중복 처리 방지) - eventId:participantId 조합으로 체크
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId); String idempotencyKey = eventId + ":" + participantId;
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
if (Boolean.TRUE.equals(isProcessed)) { if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId); log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}, participantId={}", eventId, participantId);
return; return;
} }
@@ -67,15 +72,29 @@ 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);
// 4. 멱등성 처리 완료 기록 (7일 TTL) // 5. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId); redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS); redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: participantId={}", participantId); log.debug("✅ 멱등성 기록: eventId={}, participantId={}", eventId, participantId);
} catch (Exception e) { } catch (Exception e) {
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e); log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);
@@ -62,5 +62,10 @@ public class DistributionCompletedEvent {
* 예상 노출 수 * 예상 노출 수
*/ */
private Integer expectedViews; private Integer expectedViews;
/**
* 배포 비용 (원)
*/
private java.math.BigDecimal distributionCost;
} }
} }
@@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
/** /**
* 이벤트 생성 이벤트 * 이벤트 생성 이벤트
@@ -36,8 +37,23 @@ 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,7 +1,11 @@
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;
@@ -30,6 +34,18 @@ 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,12 +47,10 @@ public class AnalyticsService {
* 대시보드 데이터 조회 * 대시보드 데이터 조회
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜 (선택) * @param refresh 캐시 갱신 여부
* @param endDate 조회 종료 날짜 (선택) * @return 대시보드 응답 (이벤트 시작일 ~ 현재까지)
* @param refresh 캐시 갱신 여부
* @return 대시보드 응답
*/ */
public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) { public AnalyticsDashboardResponse getDashboardData(String eventId, boolean refresh) {
log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh); log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
String cacheKey = CACHE_KEY_PREFIX + eventId; String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -91,7 +89,7 @@ public class AnalyticsService {
} }
// 3. 대시보드 데이터 구성 // 3. 대시보드 데이터 구성
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate); AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList);
// 4. Redis 캐싱 (1시간 TTL) // 4. Redis 캐싱 (1시간 TTL)
try { try {
@@ -110,10 +108,9 @@ 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);
@@ -124,6 +121,15 @@ 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())
@@ -131,17 +137,21 @@ 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(LocalDateTime startDate, LocalDateTime endDate) { private PeriodInfo buildPeriodInfo(EventStats eventStats) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); LocalDateTime start = eventStats.getStartDate();
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); LocalDateTime end = eventStats.getEndDate() != null ?
eventStats.getEndDate() : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end); long durationDays = ChronoUnit.DAYS.between(start, end);
@@ -215,4 +225,88 @@ 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,43 +60,62 @@ 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 distributionCost = channelStats.stream() BigDecimal totalInvestment = eventStats.getTotalInvestment();
// 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() // 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
.multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40%를 콘텐츠 제작비로 가정 BigDecimal remaining = totalInvestment.subtract(actualDistribution);
BigDecimal operation = eventStats.getTotalInvestment() // 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
.multiply(BigDecimal.valueOf(0.1)); // 10%를 운영비로 가정 BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
return InvestmentDetails.builder() return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation) .contentCreation(contentCreation)
.distribution(distributionCost)
.operation(operation) .operation(operation)
.total(eventStats.getTotalInvestment()) .distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build(); .build();
} }
/** /**
* 수익 계산 * 수익 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/ */
private RevenueDetails calculateRevenue(EventStats eventStats) { private RevenueDetails calculateRevenue(EventStats eventStats) {
BigDecimal directSales = eventStats.getExpectedRevenue() BigDecimal totalRevenue = eventStats.getExpectedRevenue();
.multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66%를 직접 매출로 가정
BigDecimal expectedSales = eventStats.getExpectedRevenue() // 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
.multiply(BigDecimal.valueOf(0.34)); // 34%를 예상 추가 매출로 가정 BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요 // 신규 고객 40%, 기존 고객 60%
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
return RevenueDetails.builder() return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales) .directSales(directSales)
.expectedSales(expectedSales) .expectedSales(expectedSales)
.brandValue(brandValue) .newCustomerRevenue(newCustomerRevenue)
.total(eventStats.getExpectedRevenue()) .existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build(); .build();
} }
@@ -26,20 +26,13 @@ public class TimelineAnalyticsService {
private final TimelineDataRepository timelineDataRepository; private final TimelineDataRepository timelineDataRepository;
/** /**
* 시간대별 참여 추이 조회 * 시간대별 참여 추이 조회 (이벤트 전체 기간)
*/ */
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, List<String> metrics) {
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics) {
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval); log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
// 시간대별 데이터 조회 // 시간대별 데이터 조회 (이벤트 전체 기간)
List<TimelineData> timelineDataList; List<TimelineData> timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
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,13 +44,11 @@ public class UserAnalyticsService {
/** /**
* 사용자 전체 대시보드 데이터 조회 * 사용자 전체 대시보드 데이터 조회
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @param startDate 조회 시작 날짜 (선택) * @param refresh 캐시 갱신 여부
* @param endDate 조회 종료 날짜 (선택) * @return 사용자 통합 대시보드 응답 (userId 기반 전체 이벤트 조회)
* @param refresh 캐시 갱신 여부
* @return 사용자 통합 대시보드 응답
*/ */
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) { public UserAnalyticsDashboardResponse getUserDashboardData(String userId, 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;
@@ -75,7 +73,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, startDate, endDate); return buildEmptyResponse(userId);
} }
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size()); log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
@@ -87,7 +85,7 @@ public class UserAnalyticsService {
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds); List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 통합 대시보드 데이터 구성 // 3. 통합 대시보드 데이터 구성
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate); UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats);
// 4. Redis 캐싱 (30분 TTL) // 4. Redis 캐싱 (30분 TTL)
try { try {
@@ -104,10 +102,15 @@ public class UserAnalyticsService {
/** /**
* 빈 응답 생성 (이벤트가 없는 경우) * 빈 응답 생성 (이벤트가 없는 경우)
*/ */
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) { private UserAnalyticsDashboardResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserAnalyticsDashboardResponse.builder() return UserAnalyticsDashboardResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.activeEvents(0) .activeEvents(0)
.overallSummary(buildEmptyAnalyticsSummary()) .overallSummary(buildEmptyAnalyticsSummary())
@@ -123,10 +126,9 @@ 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();
@@ -299,16 +301,22 @@ public class UserAnalyticsService {
/** /**
* 기간 정보 구성 * 기간 정보 구성
*
* 전체 이벤트 중 가장 빠른 시작일 ~ 현재까지의 기간 계산
*/ */
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); LocalDateTime start = events.stream()
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); .map(EventStats::getStartDate)
long durationDays = ChronoUnit.DAYS.between(start, end); .filter(Objects::nonNull)
.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) durationDays) .durationDays((int) ChronoUnit.DAYS.between(start, end))
.build(); .build();
} }
@@ -42,10 +42,9 @@ public class UserChannelAnalyticsService {
private static final long CACHE_TTL = 1800; // 30분 private static final long CACHE_TTL = 1800; // 30분
/** /**
* 사용자 전체 채널 분석 데이터 조회 * 사용자 전체 채널 분석 데이터 조회 (전체 채널 무조건 표시)
*/ */
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> channels, String sortBy, String order, public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, String sortBy, String order, boolean refresh) {
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;
@@ -66,14 +65,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, startDate, endDate); return buildEmptyResponse(userId);
} }
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, channels, sortBy, order, startDate, endDate); UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, sortBy, order);
// 4. 캐싱 // 4. 캐싱
try { try {
@@ -87,10 +86,15 @@ public class UserChannelAnalyticsService {
return response; return response;
} }
private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) { private UserChannelAnalyticsResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserChannelAnalyticsResponse.builder() return UserChannelAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(PeriodInfo.builder()
.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())
@@ -100,15 +104,10 @@ public class UserChannelAnalyticsService {
} }
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents, private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats, List<String> channels, List<ChannelStats> allChannelStats,
String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) { String sortBy, String order) {
// 채널 필터링 // 채널별 집계 (전체 채널)
List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty() List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(allChannelStats);
? 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);
@@ -118,7 +117,7 @@ public class UserChannelAnalyticsService {
return UserChannelAnalyticsResponse.builder() return UserChannelAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.channels(channelAnalyticsList) .channels(channelAnalyticsList)
.comparison(comparison) .comparison(comparison)
@@ -246,15 +245,24 @@ public class UserChannelAnalyticsService {
.build(); .build();
} }
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { /**
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); */
long durationDays = ChronoUnit.DAYS.between(start, end); private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::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) durationDays) .durationDays((int) ChronoUnit.DAYS.between(start, end))
.build(); .build();
} }
} }
@@ -1,7 +1,9 @@
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;
@@ -31,14 +33,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, public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, boolean refresh) {
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;
@@ -56,10 +58,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, startDate, endDate); return buildEmptyResponse(userId);
} }
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate); UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection);
try { try {
String jsonData = objectMapper.writeValueAsString(response); String jsonData = objectMapper.writeValueAsString(response);
@@ -71,13 +73,32 @@ public class UserRoiAnalyticsService {
return response; return response;
} }
private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) { private UserRoiAnalyticsResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserRoiAnalyticsResponse.builder() return UserRoiAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build()) .overallInvestment(InvestmentDetails.builder()
.overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build()) .total(BigDecimal.ZERO)
.contentCreation(BigDecimal.ZERO)
.operation(BigDecimal.ZERO)
.distribution(BigDecimal.ZERO)
.prizeCost(BigDecimal.ZERO)
.channelCost(BigDecimal.ZERO)
.build())
.overallRevenue(RevenueDetails.builder()
.total(BigDecimal.ZERO)
.directSales(BigDecimal.ZERO)
.expectedSales(BigDecimal.ZERO)
.newCustomerRevenue(BigDecimal.ZERO)
.existingCustomerRevenue(BigDecimal.ZERO)
.brandValue(BigDecimal.ZERO)
.build())
.overallRoi(RoiCalculation.builder() .overallRoi(RoiCalculation.builder()
.netProfit(BigDecimal.ZERO) .netProfit(BigDecimal.ZERO)
.roiPercentage(0.0) .roiPercentage(0.0)
@@ -88,8 +109,7 @@ 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);
@@ -98,17 +118,44 @@ 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(totalInvestment.multiply(BigDecimal.valueOf(0.6))) .contentCreation(contentCreation)
.operation(totalInvestment.multiply(BigDecimal.valueOf(0.2))) .operation(operation)
.distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2))) .distribution(actualDistribution)
.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(totalRevenue.multiply(BigDecimal.valueOf(0.7))) .directSales(directSales)
.expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3))) .expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build(); .build();
RoiCalculation roiCalc = RoiCalculation.builder() RoiCalculation roiCalc = RoiCalculation.builder()
@@ -149,9 +196,12 @@ 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(buildPeriodInfo(startDate, endDate)) .period(period)
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.overallInvestment(investment) .overallInvestment(investment)
.overallRevenue(revenue) .overallRevenue(revenue)
@@ -164,9 +214,20 @@ public class UserRoiAnalyticsService {
.build(); .build();
} }
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { /**
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
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,7 +37,6 @@ 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);
@@ -56,15 +55,13 @@ 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, startDate, endDate); return buildEmptyResponse(userId, interval);
} }
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 = startDate != null && endDate != null List<TimelineData> allTimelineData = timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate); UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval);
try { try {
String jsonData = objectMapper.writeValueAsString(response); String jsonData = objectMapper.writeValueAsString(response);
@@ -76,10 +73,15 @@ public class UserTimelineAnalyticsService {
return response; return response;
} }
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) { private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval) {
LocalDateTime now = LocalDateTime.now();
return UserTimelineAnalyticsResponse.builder() return UserTimelineAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(PeriodInfo.builder()
.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<>())
@@ -91,8 +93,7 @@ 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) {
@@ -119,7 +120,7 @@ public class UserTimelineAnalyticsService {
return UserTimelineAnalyticsResponse.builder() return UserTimelineAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.interval(interval != null ? interval : "daily") .interval(interval != null ? interval : "daily")
.dataPoints(dataPoints) .dataPoints(dataPoints)
@@ -179,9 +180,20 @@ public class UserTimelineAnalyticsService {
.build() : PeakTimeInfo.builder().build(); .build() : PeakTimeInfo.builder().build();
} }
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { /**
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
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,11 +47,13 @@ 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} group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service-consumers-v3}
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
@@ -74,7 +76,10 @@ spring:
server: server:
port: ${SERVER_PORT:8086} port: ${SERVER_PORT:8086}
servlet: servlet:
context-path: /api/v1/analytics encoding:
charset: UTF-8
enabled: true
force: true
# JWT # JWT
jwt: jwt:
@@ -12,6 +12,7 @@ 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 토큰 생성 및 검증 제공자
@@ -56,13 +57,13 @@ public class JwtTokenProvider {
* @return Access Token * @return Access Token
*/ */
public String createAccessToken(String userId, String storeId, String email, String name, List<String> roles) { public String createAccessToken(UUID userId, UUID storeId, String email, String name, List<String> roles) {
Date now = new Date(); Date 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) .subject(userId.toString())
.claim("storeId", storeId) .claim("storeId", storeId != null ? storeId.toString() : null)
.claim("email", email) .claim("email", email)
.claim("name", name) .claim("name", name)
.claim("roles", roles) .claim("roles", roles)
@@ -79,12 +80,12 @@ public class JwtTokenProvider {
* @param userId 사용자 ID * @param userId 사용자 ID
* @return Refresh Token * @return Refresh Token
*/ */
public String createRefreshToken(String userId) { public String createRefreshToken(UUID 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) .subject(userId.toString())
.claim("type", "refresh") .claim("type", "refresh")
.issuedAt(now) .issuedAt(now)
.expiration(expiryDate) .expiration(expiryDate)
@@ -98,9 +99,9 @@ public class JwtTokenProvider {
* @param token JWT 토큰 * @param token JWT 토큰
* @return 사용자 ID * @return 사용자 ID
*/ */
public String getUserIdFromToken(String token) { public UUID getUserIdFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
return claims.getSubject(); return UUID.fromString(claims.getSubject());
} }
/** /**
@@ -112,8 +113,9 @@ public class JwtTokenProvider {
public UserPrincipal getUserPrincipalFromToken(String token) { public UserPrincipal getUserPrincipalFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
String userId = claims.getSubject(); UUID userId = UUID.fromString(claims.getSubject());
String storeId = claims.get("storeId", String.class); String storeIdStr = 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,6 +9,7 @@ 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;
/** /**
@@ -23,12 +24,12 @@ public class UserPrincipal implements UserDetails {
/** /**
* 사용자 ID * 사용자 ID
*/ */
private final String userId; private final UUID userId;
/** /**
* 매장 ID * 매장 ID
*/ */
private final String storeId; private final UUID storeId;
/** /**
* 사용자 이메일 * 사용자 이메일
@@ -46,9 +46,6 @@ 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,
@@ -154,14 +151,6 @@ 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();
@@ -285,21 +274,4 @@ 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,9 +52,6 @@ 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,
@@ -191,14 +188,6 @@ 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();
@@ -247,23 +236,6 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
} }
} }
/**
* Mock 이미지 URL 생성 (dev 환경용)
*
* @param platform 플랫폼 (이미지 크기 결정)
* @return Mock 이미지 URL
*/
private String generateMockImageUrl(Platform platform) {
// 플랫폼별 크기에 맞는 placeholder 이미지 URL 생성
int width = platform.getWidth();
int height = platform.getHeight();
// placeholder.com을 사용한 Mock 이미지 URL
String mockId = UUID.randomUUID().toString().substring(0, 8);
return String.format("https://via.placeholder.com/%dx%d/FF6B6B/FFFFFF?text=%s+Event+%s",
width, height, platform.name(), mockId);
}
/** /**
* Replicate API 예측 완료 대기 (폴링) * Replicate API 예측 완료 대기 (폴링)
* *
@@ -37,8 +37,6 @@ 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,234 +0,0 @@
-- ====================================================================================================
-- Event ID 타입 변경 DDL (UUID → VARCHAR(50)) - PostgreSQL
-- ====================================================================================================
-- 작성일: 2025-10-29
-- 작성자: Backend Development Team
-- 설명: Event 엔티티의 eventId가 String 타입으로 변경됨에 따라 관련 테이블들의 event_id 컬럼 타입을 UUID에서 VARCHAR(50)으로 변경합니다.
-- 영향 범위:
-- - events 테이블 (Primary Key)
-- - event_channels 테이블 (Foreign Key)
-- - generated_images 테이블 (Foreign Key)
-- - ai_recommendations 테이블 (Foreign Key)
-- - jobs 테이블 (Foreign Key)
-- ====================================================================================================
-- 0. 현재 상태 확인 (실행 전 확인용)
-- ====================================================================================================
-- 각 테이블의 event_id 컬럼 타입 확인
-- SELECT table_name, column_name, data_type
-- FROM information_schema.columns
-- WHERE column_name = 'event_id'
-- AND table_schema = 'public'
-- ORDER BY table_name;
-- event_id 관련 모든 외래키 제약조건 확인
-- SELECT
-- tc.constraint_name,
-- tc.table_name,
-- kcu.column_name,
-- ccu.table_name AS foreign_table_name,
-- ccu.column_name AS foreign_column_name
-- FROM information_schema.table_constraints AS tc
-- JOIN information_schema.key_column_usage AS kcu
-- ON tc.constraint_name = kcu.constraint_name
-- AND tc.table_schema = kcu.table_schema
-- JOIN information_schema.constraint_column_usage AS ccu
-- ON ccu.constraint_name = tc.constraint_name
-- AND ccu.table_schema = tc.table_schema
-- WHERE tc.constraint_type = 'FOREIGN KEY'
-- AND kcu.column_name = 'event_id'
-- AND tc.table_schema = 'public';
-- 1. 외래키 제약조건 전체 제거
-- ====================================================================================================
-- JPA가 자동 생성한 제약조건 이름도 포함하여 모두 제거
-- event_channels 테이블의 모든 event_id 관련 외래키 제거
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = 'event_channels'
AND kcu.column_name = 'event_id'
AND tc.table_schema = 'public'
LOOP
EXECUTE 'ALTER TABLE event_channels DROP CONSTRAINT IF EXISTS ' || constraint_name;
END LOOP;
END $$;
-- generated_images 테이블의 모든 event_id 관련 외래키 제거
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = 'generated_images'
AND kcu.column_name = 'event_id'
AND tc.table_schema = 'public'
LOOP
EXECUTE 'ALTER TABLE generated_images DROP CONSTRAINT IF EXISTS ' || constraint_name;
END LOOP;
END $$;
-- ai_recommendations 테이블의 모든 event_id 관련 외래키 제거
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = 'ai_recommendations'
AND kcu.column_name = 'event_id'
AND tc.table_schema = 'public'
LOOP
EXECUTE 'ALTER TABLE ai_recommendations DROP CONSTRAINT IF EXISTS ' || constraint_name;
END LOOP;
END $$;
-- jobs 테이블의 모든 event_id 관련 외래키 제거
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = 'jobs'
AND kcu.column_name = 'event_id'
AND tc.table_schema = 'public'
LOOP
EXECUTE 'ALTER TABLE jobs DROP CONSTRAINT IF EXISTS ' || constraint_name;
END LOOP;
END $$;
-- 2. 컬럼 타입 변경 (UUID/기타 → VARCHAR)
-- ====================================================================================================
-- 현재 타입에 관계없이 VARCHAR(50)으로 변환
-- UUID, BIGINT 등 모든 타입을 텍스트로 변환
-- events 테이블의 event_id 컬럼 타입 변경 (Primary Key)
DO $$
BEGIN
ALTER TABLE events ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'events.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- event_channels 테이블의 event_id 컬럼 타입 변경
DO $$
BEGIN
ALTER TABLE event_channels ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'event_channels.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- generated_images 테이블의 event_id 컬럼 타입 변경
DO $$
BEGIN
ALTER TABLE generated_images ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'generated_images.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- ai_recommendations 테이블의 event_id 컬럼 타입 변경
DO $$
BEGIN
ALTER TABLE ai_recommendations ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'ai_recommendations.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- jobs 테이블의 event_id 컬럼 타입 변경 (NULL 허용)
DO $$
BEGIN
ALTER TABLE jobs ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'jobs.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- 3. 외래키 제약조건 재생성
-- ====================================================================================================
-- event_channels 테이블의 외래키 재생성
ALTER TABLE event_channels
ADD CONSTRAINT fk_event_channels_event
FOREIGN KEY (event_id) REFERENCES events(event_id)
ON DELETE CASCADE;
-- generated_images 테이블의 외래키 재생성
ALTER TABLE generated_images
ADD CONSTRAINT fk_generated_images_event
FOREIGN KEY (event_id) REFERENCES events(event_id)
ON DELETE CASCADE;
-- ai_recommendations 테이블의 외래키 재생성
ALTER TABLE ai_recommendations
ADD CONSTRAINT fk_ai_recommendations_event
FOREIGN KEY (event_id) REFERENCES events(event_id)
ON DELETE CASCADE;
-- jobs 테이블의 외래키 재생성
ALTER TABLE jobs
ADD CONSTRAINT fk_jobs_event
FOREIGN KEY (event_id) REFERENCES events(event_id)
ON DELETE SET NULL;
-- 4. 인덱스 확인 (옵션)
-- ====================================================================================================
-- 기존 인덱스들이 자동으로 유지되는지 확인
-- \d events
-- \d event_channels
-- \d generated_images
-- \d ai_recommendations
-- \d jobs
-- ====================================================================================================
-- 롤백 스크립트 (필요시 사용)
-- ====================================================================================================
/*
-- 1. 외래키 제약조건 제거
ALTER TABLE event_channels DROP CONSTRAINT IF EXISTS fk_event_channels_event;
ALTER TABLE generated_images DROP CONSTRAINT IF EXISTS fk_generated_images_event;
ALTER TABLE ai_recommendations DROP CONSTRAINT IF EXISTS fk_ai_recommendations_event;
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS fk_jobs_event;
-- 2. 컬럼 타입 원복 (VARCHAR → UUID)
ALTER TABLE events ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
ALTER TABLE event_channels ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
ALTER TABLE generated_images ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
ALTER TABLE ai_recommendations ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
ALTER TABLE jobs ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
-- 4. 외래키 제약조건 재생성
ALTER TABLE event_channels ADD CONSTRAINT fk_event_channels_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
ALTER TABLE generated_images ADD CONSTRAINT fk_generated_images_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
ALTER TABLE ai_recommendations ADD CONSTRAINT fk_ai_recommendations_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
ALTER TABLE jobs ADD CONSTRAINT fk_jobs_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE SET NULL;
*/
@@ -1,233 +0,0 @@
-- ====================================================================================================
-- Event Service 테이블 생성 스크립트 - PostgreSQL
-- ====================================================================================================
-- 작성일: 2025-10-29
-- 작성자: Backend Development Team
-- 설명: Event 서비스의 모든 테이블을 생성합니다.
-- 참고: FK(Foreign Key) 제약조건은 제외되어 있습니다.
-- ====================================================================================================
-- ====================================================================================================
-- 1. events 테이블 - 이벤트 메인 테이블
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS events (
event_id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
store_id VARCHAR(50) NOT NULL,
event_name VARCHAR(200),
description TEXT,
objective VARCHAR(100) NOT NULL,
start_date DATE,
end_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
selected_image_id VARCHAR(50),
selected_image_url VARCHAR(500),
participants INTEGER DEFAULT 0,
target_participants INTEGER,
roi DOUBLE PRECISION DEFAULT 0.0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- events 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
CREATE INDEX IF NOT EXISTS idx_events_store_id ON events(store_id);
CREATE INDEX IF NOT EXISTS idx_events_status ON events(status);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
COMMENT ON TABLE events IS '이벤트 메인 테이블';
COMMENT ON COLUMN events.event_id IS '이벤트 ID (Primary Key)';
COMMENT ON COLUMN events.user_id IS '사용자 ID';
COMMENT ON COLUMN events.store_id IS '상점 ID';
COMMENT ON COLUMN events.event_name IS '이벤트명';
COMMENT ON COLUMN events.description IS '이벤트 설명';
COMMENT ON COLUMN events.objective IS '이벤트 목적';
COMMENT ON COLUMN events.start_date IS '이벤트 시작일';
COMMENT ON COLUMN events.end_date IS '이벤트 종료일';
COMMENT ON COLUMN events.status IS '이벤트 상태 (DRAFT, PUBLISHED, ENDED)';
COMMENT ON COLUMN events.selected_image_id IS '선택된 이미지 ID';
COMMENT ON COLUMN events.selected_image_url IS '선택된 이미지 URL';
COMMENT ON COLUMN events.participants IS '참여자 수';
COMMENT ON COLUMN events.target_participants IS '목표 참여자 수';
COMMENT ON COLUMN events.roi IS 'ROI (투자 대비 수익률)';
COMMENT ON COLUMN events.created_at IS '생성일시';
COMMENT ON COLUMN events.updated_at IS '수정일시';
-- ====================================================================================================
-- 2. event_channels 테이블 - 이벤트 배포 채널 (ElementCollection)
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS event_channels (
event_id VARCHAR(50) NOT NULL,
channel VARCHAR(50)
);
-- event_channels 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_event_channels_event_id ON event_channels(event_id);
COMMENT ON TABLE event_channels IS '이벤트 배포 채널 테이블';
COMMENT ON COLUMN event_channels.event_id IS '이벤트 ID';
COMMENT ON COLUMN event_channels.channel IS '배포 채널명';
-- ====================================================================================================
-- 3. generated_images 테이블 - 생성된 이미지
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS generated_images (
image_id VARCHAR(50) PRIMARY KEY,
event_id VARCHAR(50) NOT NULL,
image_url VARCHAR(500) NOT NULL,
style VARCHAR(50),
platform VARCHAR(50),
is_selected BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- generated_images 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_generated_images_event_id ON generated_images(event_id);
CREATE INDEX IF NOT EXISTS idx_generated_images_is_selected ON generated_images(is_selected);
COMMENT ON TABLE generated_images IS 'AI가 생성한 이미지 테이블';
COMMENT ON COLUMN generated_images.image_id IS '이미지 ID (Primary Key)';
COMMENT ON COLUMN generated_images.event_id IS '이벤트 ID';
COMMENT ON COLUMN generated_images.image_url IS '이미지 URL';
COMMENT ON COLUMN generated_images.style IS '이미지 스타일';
COMMENT ON COLUMN generated_images.platform IS '타겟 플랫폼';
COMMENT ON COLUMN generated_images.is_selected IS '선택 여부';
COMMENT ON COLUMN generated_images.created_at IS '생성일시';
COMMENT ON COLUMN generated_images.updated_at IS '수정일시';
-- ====================================================================================================
-- 4. ai_recommendations 테이블 - AI 추천 기획안
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS ai_recommendations (
recommendation_id VARCHAR(50) PRIMARY KEY,
event_id VARCHAR(50) NOT NULL,
event_name VARCHAR(200) NOT NULL,
description TEXT,
promotion_type VARCHAR(50),
target_audience VARCHAR(100),
is_selected BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ai_recommendations 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_ai_recommendations_event_id ON ai_recommendations(event_id);
CREATE INDEX IF NOT EXISTS idx_ai_recommendations_is_selected ON ai_recommendations(is_selected);
COMMENT ON TABLE ai_recommendations IS 'AI 추천 이벤트 기획안 테이블';
COMMENT ON COLUMN ai_recommendations.recommendation_id IS '추천 ID (Primary Key)';
COMMENT ON COLUMN ai_recommendations.event_id IS '이벤트 ID';
COMMENT ON COLUMN ai_recommendations.event_name IS '추천 이벤트명';
COMMENT ON COLUMN ai_recommendations.description IS '추천 설명';
COMMENT ON COLUMN ai_recommendations.promotion_type IS '프로모션 유형';
COMMENT ON COLUMN ai_recommendations.target_audience IS '타겟 고객층';
COMMENT ON COLUMN ai_recommendations.is_selected IS '선택 여부';
COMMENT ON COLUMN ai_recommendations.created_at IS '생성일시';
COMMENT ON COLUMN ai_recommendations.updated_at IS '수정일시';
-- ====================================================================================================
-- 5. jobs 테이블 - 비동기 작업 관리
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS jobs (
job_id VARCHAR(50) PRIMARY KEY,
event_id VARCHAR(50) NOT NULL,
job_type VARCHAR(30) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
progress INTEGER NOT NULL DEFAULT 0,
result_key VARCHAR(200),
error_message VARCHAR(500),
completed_at TIMESTAMP,
retry_count INTEGER NOT NULL DEFAULT 0,
max_retry_count INTEGER NOT NULL DEFAULT 3,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- jobs 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_jobs_event_id ON jobs(event_id);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_job_type ON jobs(job_type);
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at);
COMMENT ON TABLE jobs IS '비동기 작업 관리 테이블';
COMMENT ON COLUMN jobs.job_id IS '작업 ID (Primary Key)';
COMMENT ON COLUMN jobs.event_id IS '이벤트 ID';
COMMENT ON COLUMN jobs.job_type IS '작업 유형 (AI_RECOMMENDATION, IMAGE_GENERATION)';
COMMENT ON COLUMN jobs.status IS '작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)';
COMMENT ON COLUMN jobs.progress IS '진행률 (0-100)';
COMMENT ON COLUMN jobs.result_key IS '결과 키';
COMMENT ON COLUMN jobs.error_message IS '에러 메시지';
COMMENT ON COLUMN jobs.completed_at IS '완료일시';
COMMENT ON COLUMN jobs.retry_count IS '재시도 횟수';
COMMENT ON COLUMN jobs.max_retry_count IS '최대 재시도 횟수';
COMMENT ON COLUMN jobs.created_at IS '생성일시';
COMMENT ON COLUMN jobs.updated_at IS '수정일시';
-- ====================================================================================================
-- 6. updated_at 자동 업데이트를 위한 트리거 함수 생성
-- ====================================================================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ====================================================================================================
-- 7. 각 테이블에 updated_at 자동 업데이트 트리거 적용
-- ====================================================================================================
-- events 테이블 트리거
DROP TRIGGER IF EXISTS update_events_updated_at ON events;
CREATE TRIGGER update_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- generated_images 테이블 트리거
DROP TRIGGER IF EXISTS update_generated_images_updated_at ON generated_images;
CREATE TRIGGER update_generated_images_updated_at
BEFORE UPDATE ON generated_images
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ai_recommendations 테이블 트리거
DROP TRIGGER IF EXISTS update_ai_recommendations_updated_at ON ai_recommendations;
CREATE TRIGGER update_ai_recommendations_updated_at
BEFORE UPDATE ON ai_recommendations
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- jobs 테이블 트리거
DROP TRIGGER IF EXISTS update_jobs_updated_at ON jobs;
CREATE TRIGGER update_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ====================================================================================================
-- 완료 메시지
-- ====================================================================================================
DO $$
BEGIN
RAISE NOTICE '=================================================';
RAISE NOTICE 'Event Service 테이블 생성이 완료되었습니다.';
RAISE NOTICE '=================================================';
RAISE NOTICE '생성된 테이블:';
RAISE NOTICE ' 1. events - 이벤트 메인 테이블';
RAISE NOTICE ' 2. event_channels - 이벤트 배포 채널';
RAISE NOTICE ' 3. generated_images - 생성된 이미지';
RAISE NOTICE ' 4. ai_recommendations - AI 추천 기획안';
RAISE NOTICE ' 5. jobs - 비동기 작업 관리';
RAISE NOTICE '=================================================';
RAISE NOTICE '참고: FK 제약조건은 생성되지 않았습니다.';
RAISE NOTICE '=================================================';
END $$;
@@ -1,5 +1,6 @@
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;
@@ -12,7 +13,6 @@ import java.util.List;
* AI 이벤트 생성 작업 메시지 DTO * AI 이벤트 생성 작업 메시지 DTO
* *
* ai-event-generation-job 토픽에서 구독하는 메시지 형식 * ai-event-generation-job 토픽에서 구독하는 메시지 형식
* JSON 필드명: camelCase (Jackson 기본 설정)
*/ */
@Data @Data
@Builder @Builder
@@ -23,61 +23,43 @@ 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;
/**
* 이벤트 ID
*/
private String eventId;
/**
* 매장명
*/
private String storeName;
/**
* 매장 업종
*/
private String storeCategory;
/**
* 매장 설명
*/
private String storeDescription;
/**
* 이벤트 목적
*/
private String objective;
/** /**
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED) * 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)
*/ */
@JsonProperty("status")
private String status; private String status;
/** /**
* AI 추천 결과 데이터 * AI 추천 결과 데이터
*/ */
@JsonProperty("ai_recommendation")
private AIRecommendationData aiRecommendation; private AIRecommendationData aiRecommendation;
/** /**
* 에러 메시지 (실패 시) * 에러 메시지 (실패 시)
*/ */
@JsonProperty("error_message")
private String errorMessage; private String errorMessage;
/** /**
* 작업 생성 일시 * 작업 생성 일시
*/ */
@JsonProperty("created_at")
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** /**
* 작업 완료/실패 일시 * 작업 완료/실패 일시
*/ */
@JsonProperty("completed_at")
private LocalDateTime completedAt; private LocalDateTime completedAt;
/** /**
@@ -89,18 +71,25 @@ public class AIEventGenerationJobMessage {
@AllArgsConstructor @AllArgsConstructor
public static class AIRecommendationData { public static class AIRecommendationData {
@JsonProperty("event_title")
private String eventTitle; private String eventTitle;
@JsonProperty("event_description")
private String eventDescription; private String eventDescription;
@JsonProperty("event_type")
private String eventType; private String eventType;
@JsonProperty("target_keywords")
private List<String> targetKeywords; private List<String> targetKeywords;
@JsonProperty("recommended_benefits")
private List<String> recommendedBenefits; private List<String> recommendedBenefits;
@JsonProperty("start_date")
private String startDate; private String startDate;
@JsonProperty("end_date")
private String endDate; private String endDate;
} }
} }
@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이벤트 생성 완료 메시지 DTO * 이벤트 생성 완료 메시지 DTO
@@ -20,16 +21,16 @@ import java.time.LocalDateTime;
public class EventCreatedMessage { public class EventCreatedMessage {
/** /**
* 이벤트 ID * 이벤트 ID (UUID)
*/ */
@JsonProperty("event_id") @JsonProperty("event_id")
private String eventId; private UUID eventId;
/** /**
* 사용자 ID * 사용자 ID (UUID)
*/ */
@JsonProperty("user_id") @JsonProperty("user_id")
private String userId; private UUID userId;
/** /**
* 이벤트 제목 * 이벤트 제목
@@ -8,6 +8,8 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* AI 추천 요청 DTO * AI 추천 요청 DTO
* *
@@ -40,8 +42,8 @@ public class AiRecommendationRequest {
public static class StoreInfo { public static class StoreInfo {
@NotNull(message = "매장 ID는 필수입니다.") @NotNull(message = "매장 ID는 필수입니다.")
@Schema(description = "매장 ID", required = true, example = "str_20250124_001") @Schema(description = "매장 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440002")
private String storeId; private UUID storeId;
@NotNull(message = "매장명은 필수입니다.") @NotNull(message = "매장명은 필수입니다.")
@Schema(description = "매장명", required = true, example = "우진네 고깃집") @Schema(description = "매장명", required = true, example = "우진네 고깃집")
@@ -6,6 +6,8 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* 이미지 선택 요청 DTO * 이미지 선택 요청 DTO
* *
@@ -20,7 +22,7 @@ import lombok.NoArgsConstructor;
public class SelectImageRequest { public class SelectImageRequest {
@NotNull(message = "이미지 ID는 필수입니다.") @NotNull(message = "이미지 ID는 필수입니다.")
private String imageId; private UUID imageId;
private String imageUrl; private String imageUrl;
} }
@@ -9,6 +9,7 @@ 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
@@ -27,8 +28,8 @@ import java.time.LocalDate;
public class SelectRecommendationRequest { public class SelectRecommendationRequest {
@NotNull(message = "추천 ID는 필수입니다.") @NotNull(message = "추천 ID는 필수입니다.")
@Schema(description = "선택한 추천 ID", required = true, example = "rec_20250124_001") @Schema(description = "선택한 추천 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440007")
private String recommendationId; private UUID recommendationId;
@Valid @Valid
@Schema(description = "커스터마이징 항목") @Schema(description = "커스터마이징 항목")
@@ -7,6 +7,7 @@ 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 +22,7 @@ import java.time.LocalDateTime;
@Builder @Builder
public class EventCreatedResponse { public class EventCreatedResponse {
private String eventId; private UUID eventId;
private EventStatus status; private EventStatus status;
private String objective; private String objective;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -10,6 +10,7 @@ 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
@@ -24,16 +25,16 @@ import java.util.List;
@Builder @Builder
public class EventDetailResponse { public class EventDetailResponse {
private String eventId; private UUID eventId;
private String userId; private UUID userId;
private String storeId; private UUID 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 String selectedImageId; private UUID selectedImageId;
private String selectedImageUrl; private String selectedImageUrl;
private Integer participants; private Integer participants;
private Integer targetParticipants; private Integer targetParticipants;
@@ -56,7 +57,7 @@ public class EventDetailResponse {
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
public static class GeneratedImageDto { public static class GeneratedImageDto {
private String imageId; private UUID imageId;
private String imageUrl; private String imageUrl;
private String style; private String style;
private String platform; private String platform;
@@ -69,7 +70,7 @@ public class EventDetailResponse {
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
public static class AiRecommendationDto { public static class AiRecommendationDto {
private String recommendationId; private UUID recommendationId;
private String eventName; private String eventName;
private String description; private String description;
private String promotionType; private String promotionType;
@@ -7,6 +7,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이미지 편집 응답 DTO * 이미지 편집 응답 DTO
@@ -24,8 +25,8 @@ import java.time.LocalDateTime;
@Schema(description = "이미지 편집 응답") @Schema(description = "이미지 편집 응답")
public class ImageEditResponse { public class ImageEditResponse {
@Schema(description = "편집된 이미지 ID", example = "img_20250124_001") @Schema(description = "편집된 이미지 ID", example = "550e8400-e29b-41d4-a716-446655440008")
private String imageId; private UUID 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,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이미지 생성 응답 DTO * 이미지 생성 응답 DTO
@@ -20,7 +21,7 @@ import java.time.LocalDateTime;
@Builder @Builder
public class ImageGenerationResponse { public class ImageGenerationResponse {
private String jobId; private UUID jobId;
private String status; private String status;
private String message; private String message;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -7,6 +7,8 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* Job 접수 응답 DTO * Job 접수 응답 DTO
* *
@@ -23,8 +25,8 @@ import lombok.NoArgsConstructor;
@Schema(description = "Job 접수 응답") @Schema(description = "Job 접수 응답")
public class JobAcceptedResponse { public class JobAcceptedResponse {
@Schema(description = "생성된 Job ID", example = "job_20250124_001") @Schema(description = "생성된 Job ID", example = "550e8400-e29b-41d4-a716-446655440005")
private String jobId; private UUID jobId;
@Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING") @Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING")
private JobStatus status; private JobStatus status;
@@ -8,6 +8,7 @@ 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
@@ -22,7 +23,7 @@ import java.time.LocalDateTime;
@Builder @Builder
public class JobStatusResponse { public class JobStatusResponse {
private String jobId; private UUID jobId;
private JobType jobType; private JobType jobType;
private JobStatus status; private JobStatus status;
private int progress; private int progress;
@@ -1,113 +0,0 @@
package com.kt.event.eventservice.application.service;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* 이벤트 ID 생성기
*
* 비즈니스 친화적인 eventId를 생성합니다.
* 형식: EVT-{storeId}-{yyyyMMddHHmmss}-{random8}
* 예시: EVT-store123-20251029143025-a1b2c3d4
*
* VARCHAR(50) 길이 제약사항을 고려하여 설계되었습니다.
*/
@Component
public class EventIdGenerator {
private static final String PREFIX = "EVT";
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private static final int RANDOM_LENGTH = 8;
/**
* 이벤트 ID 생성
*
* @param storeId 상점 ID (최대 15자 권장)
* @return 생성된 이벤트 ID
* @throws IllegalArgumentException storeId가 null이거나 비어있는 경우
*/
public String generate(String storeId) {
if (storeId == null || storeId.isBlank()) {
throw new IllegalArgumentException("storeId는 필수입니다");
}
// storeId 길이 검증 (전체 길이 50자 제한)
// TODO: 프로덕션에서는 storeId 길이 제한 필요
// if (storeId.length() > 15) {
// throw new IllegalArgumentException("storeId는 15자 이하여야 합니다");
// }
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER);
String randomPart = generateRandomPart();
// 형식: EVT-{storeId}-{timestamp}-{random}
// 예상 길이: 3 + 1 + 15 + 1 + 14 + 1 + 8 = 43자 (최대)
String eventId = String.format("%s-%s-%s-%s", PREFIX, storeId, timestamp, randomPart);
// 길이 검증
if (eventId.length() > 50) {
throw new IllegalStateException(
String.format("생성된 eventId 길이(%d)가 50자를 초과했습니다: %s",
eventId.length(), eventId)
);
}
return eventId;
}
/**
* UUID 기반 랜덤 문자열 생성
*
* @return 8자리 랜덤 문자열 (소문자 영숫자)
*/
private String generateRandomPart() {
return UUID.randomUUID()
.toString()
.replace("-", "")
.substring(0, RANDOM_LENGTH)
.toLowerCase();
}
/**
* eventId 형식 검증
*
* @param eventId 검증할 이벤트 ID
* @return 유효하면 true, 아니면 false
*/
public boolean isValid(String eventId) {
if (eventId == null || eventId.isBlank()) {
return false;
}
// EVT-로 시작하는지 확인
if (!eventId.startsWith(PREFIX + "-")) {
return false;
}
// 길이 검증
if (eventId.length() > 50) {
return false;
}
// 형식 검증: EVT-{storeId}-{14자리숫자}-{8자리영숫자}
String[] parts = eventId.split("-");
if (parts.length != 4) {
return false;
}
// timestamp 부분이 14자리 숫자인지 확인
if (parts[2].length() != 14 || !parts[2].matches("\\d{14}")) {
return false;
}
// random 부분이 8자리 영숫자인지 확인
if (parts[3].length() != 8 || !parts[3].matches("[a-z0-9]{8}")) {
return false;
}
return true;
}
}
@@ -24,6 +24,7 @@ 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;
/** /**
@@ -47,29 +48,22 @@ 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 * @param userId 사용자 ID (UUID)
* @param storeId 매장 ID * @param storeId 매장 ID (UUID)
* @param request 목적 선택 요청 * @param request 목적 선택 요청
* @return 생성된 이벤트 응답 * @return 생성된 이벤트 응답
*/ */
@Transactional @Transactional
public EventCreatedResponse createEvent(String userId, String storeId, SelectObjectiveRequest request) { public EventCreatedResponse createEvent(UUID userId, UUID storeId, SelectObjectiveRequest request) {
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}", log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}",
userId, storeId, request.getObjective()); userId, storeId, request.getObjective());
// eventId 생성
String eventId = eventIdGenerator.generate(storeId);
log.info("생성된 eventId: {}", eventId);
// 이벤트 엔티티 생성 // 이벤트 엔티티 생성
Event event = Event.builder() Event event = Event.builder()
.eventId(eventId)
.userId(userId) .userId(userId)
.storeId(storeId) .storeId(storeId)
.objective(request.getObjective()) .objective(request.getObjective())
@@ -93,11 +87,11 @@ public class EventService {
/** /**
* 이벤트 상세 조회 * 이벤트 상세 조회
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @return 이벤트 상세 응답 * @return 이벤트 상세 응답
*/ */
public EventDetailResponse getEvent(String userId, String eventId) { public EventDetailResponse getEvent(UUID userId, UUID 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)
@@ -114,7 +108,7 @@ public class EventService {
/** /**
* 이벤트 목록 조회 (페이징, 필터링) * 이벤트 목록 조회 (페이징, 필터링)
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param status 상태 필터 * @param status 상태 필터
* @param search 검색어 * @param search 검색어
* @param objective 목적 필터 * @param objective 목적 필터
@@ -122,7 +116,7 @@ public class EventService {
* @return 이벤트 목록 * @return 이벤트 목록
*/ */
public Page<EventDetailResponse> getEvents( public Page<EventDetailResponse> getEvents(
String userId, UUID userId,
EventStatus status, EventStatus status,
String search, String search,
String objective, String objective,
@@ -145,11 +139,11 @@ public class EventService {
/** /**
* 이벤트 삭제 * 이벤트 삭제
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void deleteEvent(String userId, String eventId) { public void deleteEvent(UUID userId, UUID 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)
@@ -167,11 +161,11 @@ public class EventService {
/** /**
* 이벤트 배포 * 이벤트 배포
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void publishEvent(String userId, String eventId) { public void publishEvent(UUID userId, UUID 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)
@@ -196,11 +190,11 @@ public class EventService {
/** /**
* 이벤트 종료 * 이벤트 종료
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void endEvent(String userId, String eventId) { public void endEvent(UUID userId, UUID 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)
@@ -216,13 +210,13 @@ public class EventService {
/** /**
* 이미지 생성 요청 * 이미지 생성 요청
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 이미지 생성 요청 * @param request 이미지 생성 요청
* @return 이미지 생성 응답 (Job ID 포함) * @return 이미지 생성 응답 (Job ID 포함)
*/ */
@Transactional @Transactional
public ImageGenerationResponse requestImageGeneration(String userId, String eventId, ImageGenerationRequest request) { public ImageGenerationResponse requestImageGeneration(UUID userId, UUID eventId, ImageGenerationRequest request) {
log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId); log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -242,11 +236,7 @@ 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();
@@ -255,9 +245,9 @@ public class EventService {
// Kafka 메시지 발행 // Kafka 메시지 발행
imageJobKafkaProducer.publishImageGenerationJob( imageJobKafkaProducer.publishImageGenerationJob(
job.getJobId(), job.getJobId().toString(),
userId, userId.toString(),
eventId, eventId.toString(),
prompt prompt
); );
@@ -275,13 +265,13 @@ public class EventService {
/** /**
* 이미지 선택 * 이미지 선택
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param imageId 이미지 ID * @param imageId 이미지 ID
* @param request 이미지 선택 요청 * @param request 이미지 선택 요청
*/ */
@Transactional @Transactional
public void selectImage(String userId, String eventId, String imageId, SelectImageRequest request) { public void selectImage(UUID userId, UUID eventId, UUID imageId, SelectImageRequest request) {
log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -304,13 +294,13 @@ public class EventService {
/** /**
* AI 추천 요청 * AI 추천 요청
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request AI 추천 요청 * @param request AI 추천 요청
* @return Job 접수 응답 * @return Job 접수 응답
*/ */
@Transactional @Transactional
public JobAcceptedResponse requestAiRecommendations(String userId, String eventId, AiRecommendationRequest request) { public JobAcceptedResponse requestAiRecommendations(UUID userId, UUID eventId, AiRecommendationRequest request) {
log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId); log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -323,11 +313,7 @@ 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();
@@ -336,9 +322,9 @@ public class EventService {
// Kafka 메시지 발행 // Kafka 메시지 발행
aiJobKafkaProducer.publishAIGenerationJob( aiJobKafkaProducer.publishAIGenerationJob(
job.getJobId(), job.getJobId().toString(),
userId, userId.toString(),
eventId, eventId.toString(),
request.getStoreInfo().getStoreName(), request.getStoreInfo().getStoreName(),
request.getStoreInfo().getCategory(), request.getStoreInfo().getCategory(),
request.getStoreInfo().getDescription(), request.getStoreInfo().getDescription(),
@@ -357,12 +343,12 @@ public class EventService {
/** /**
* AI 추천 선택 * AI 추천 선택
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request AI 추천 선택 요청 * @param request AI 추천 선택 요청
*/ */
@Transactional @Transactional
public void selectRecommendation(String userId, String eventId, SelectRecommendationRequest request) { public void selectRecommendation(UUID userId, UUID eventId, SelectRecommendationRequest request) {
log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}", log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}",
userId, eventId, request.getRecommendationId()); userId, eventId, request.getRecommendationId());
@@ -423,14 +409,14 @@ public class EventService {
/** /**
* 이미지 편집 * 이미지 편집
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param imageId 이미지 ID * @param imageId 이미지 ID
* @param request 이미지 편집 요청 * @param request 이미지 편집 요청
* @return 이미지 편집 응답 * @return 이미지 편집 응답
*/ */
@Transactional @Transactional
public ImageEditResponse editImage(String userId, String eventId, String imageId, ImageEditRequest request) { public ImageEditResponse editImage(UUID userId, UUID eventId, UUID imageId, ImageEditRequest request) {
log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -464,12 +450,12 @@ public class EventService {
/** /**
* 배포 채널 선택 * 배포 채널 선택
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 배포 채널 선택 요청 * @param request 배포 채널 선택 요청
*/ */
@Transactional @Transactional
public void selectChannels(String userId, String eventId, SelectChannelsRequest request) { public void selectChannels(UUID userId, UUID eventId, SelectChannelsRequest request) {
log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}", log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}",
userId, eventId, request.getChannels()); userId, eventId, request.getChannels());
@@ -493,13 +479,13 @@ public class EventService {
/** /**
* 이벤트 수정 * 이벤트 수정
* *
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 이벤트 수정 요청 * @param request 이벤트 수정 요청
* @return 이벤트 상세 응답 * @return 이벤트 상세 응답
*/ */
@Transactional @Transactional
public EventDetailResponse updateEvent(String userId, String eventId, UpdateEventRequest request) { public EventDetailResponse updateEvent(UUID userId, UUID eventId, UpdateEventRequest request) {
log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId);
// 이벤트 조회 및 권한 확인 // 이벤트 조회 및 권한 확인
@@ -1,123 +0,0 @@
package com.kt.event.eventservice.application.service;
import com.kt.event.eventservice.domain.enums.JobType;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* Job ID 생성기
*
* 비즈니스 친화적인 jobId를 생성합니다.
* 형식: JOB-{jobType}-{timestamp}-{random8}
* 예시: JOB-AI-20251029143025-a1b2c3d4
*
* VARCHAR(50) 길이 제약사항을 고려하여 설계되었습니다.
*/
@Component
public class JobIdGenerator {
private static final String PREFIX = "JOB";
private static final int RANDOM_LENGTH = 8;
/**
* Job ID 생성
*
* @param jobType Job 타입
* @return 생성된 Job ID
* @throws IllegalArgumentException jobType이 null인 경우
*/
public String generate(JobType jobType) {
if (jobType == null) {
throw new IllegalArgumentException("jobType은 필수입니다");
}
String typeCode = getTypeCode(jobType);
String timestamp = String.valueOf(System.currentTimeMillis());
String randomPart = generateRandomPart();
// 형식: JOB-{type}-{timestamp}-{random}
// 예상 길이: 3 + 1 + 5 + 1 + 13 + 1 + 8 = 32자 (최대)
String jobId = String.format("%s-%s-%s-%s", PREFIX, typeCode, timestamp, randomPart);
// 길이 검증
if (jobId.length() > 50) {
throw new IllegalStateException(
String.format("생성된 jobId 길이(%d)가 50자를 초과했습니다: %s",
jobId.length(), jobId)
);
}
return jobId;
}
/**
* JobType을 짧은 코드로 변환
*
* @param jobType Job 타입
* @return 타입 코드
*/
private String getTypeCode(JobType jobType) {
switch (jobType) {
case AI_RECOMMENDATION:
return "AI";
case IMAGE_GENERATION:
return "IMG";
default:
return jobType.name().substring(0, Math.min(5, jobType.name().length()));
}
}
/**
* UUID 기반 랜덤 문자열 생성
*
* @return 8자리 랜덤 문자열 (소문자 영숫자)
*/
private String generateRandomPart() {
return UUID.randomUUID()
.toString()
.replace("-", "")
.substring(0, RANDOM_LENGTH)
.toLowerCase();
}
/**
* jobId 형식 검증
*
* @param jobId 검증할 Job ID
* @return 유효하면 true, 아니면 false
*/
public boolean isValid(String jobId) {
if (jobId == null || jobId.isBlank()) {
return false;
}
// JOB-로 시작하는지 확인
if (!jobId.startsWith(PREFIX + "-")) {
return false;
}
// 길이 검증
if (jobId.length() > 50) {
return false;
}
// 형식 검증: JOB-{type}-{timestamp}-{8자리영숫자}
String[] parts = jobId.split("-");
if (parts.length != 4) {
return false;
}
// timestamp 부분이 숫자인지 확인
if (!parts[2].matches("\\d+")) {
return false;
}
// random 부분이 8자리 영숫자인지 확인
if (parts[3].length() != 8 || !parts[3].matches("[a-z0-9]{8}")) {
return false;
}
return true;
}
}
@@ -11,6 +11,8 @@ 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 서비스
* *
@@ -27,7 +29,6 @@ import org.springframework.transaction.annotation.Transactional;
public class JobService { public class JobService {
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final JobIdGenerator jobIdGenerator;
/** /**
* Job 생성 * Job 생성
@@ -37,15 +38,10 @@ public class JobService {
* @return 생성된 Job * @return 생성된 Job
*/ */
@Transactional @Transactional
public Job createJob(String eventId, JobType jobType) { public Job createJob(UUID 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();
@@ -63,7 +59,7 @@ public class JobService {
* @param jobId Job ID * @param jobId Job ID
* @return Job 상태 응답 * @return Job 상태 응답
*/ */
public JobStatusResponse getJobStatus(String jobId) { public JobStatusResponse getJobStatus(UUID jobId) {
log.info("Job 상태 조회 - jobId: {}", jobId); log.info("Job 상태 조회 - jobId: {}", jobId);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)
@@ -79,7 +75,7 @@ public class JobService {
* @param progress 진행률 * @param progress 진행률
*/ */
@Transactional @Transactional
public void updateJobProgress(String jobId, int progress) { public void updateJobProgress(UUID 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)
@@ -97,7 +93,7 @@ public class JobService {
* @param resultKey Redis 결과 키 * @param resultKey Redis 결과 키
*/ */
@Transactional @Transactional
public void completeJob(String jobId, String resultKey) { public void completeJob(UUID 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)
@@ -117,7 +113,7 @@ public class JobService {
* @param errorMessage 에러 메시지 * @param errorMessage 에러 메시지
*/ */
@Transactional @Transactional
public void failJob(String jobId, String errorMessage) { public void failJob(UUID 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,5 +1,7 @@
package com.kt.event.eventservice.application.service; package com.kt.event.eventservice.application.service;
import java.util.UUID;
/** /**
* 알림 서비스 인터페이스 * 알림 서비스 인터페이스
* *
@@ -20,7 +22,7 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param message 알림 메시지 * @param message 알림 메시지
*/ */
void notifyJobCompleted(String userId, String jobId, String jobType, String message); void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message);
/** /**
* 작업 실패 알림 전송 * 작업 실패 알림 전송
@@ -30,7 +32,7 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param errorMessage 에러 메시지 * @param errorMessage 에러 메시지
*/ */
void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage); void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage);
/** /**
* 작업 진행 상태 알림 전송 * 작업 진행 상태 알림 전송
@@ -40,5 +42,5 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param progress 진행률 (0-100) * @param progress 진행률 (0-100)
*/ */
void notifyJobProgress(String userId, String jobId, String jobType, int progress); void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress);
} }
@@ -11,6 +11,7 @@ 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;
/** /**
* 개발 환경용 인증 필터 * 개발 환경용 인증 필터
@@ -34,11 +35,11 @@ public class DevAuthenticationFilter extends OncePerRequestFilter {
// 개발용 기본 UserPrincipal 생성 // 개발용 기본 UserPrincipal 생성
UserPrincipal userPrincipal = new UserPrincipal( UserPrincipal userPrincipal = new UserPrincipal(
"usr_dev_test_001", // userId UUID.fromString("11111111-1111-1111-1111-111111111111"), // userId
"str_dev_test_001", // storeId UUID.fromString("22222222-2222-2222-2222-222222222222"), // storeId
"dev@test.com", // email "dev@test.com", // email
"개발테스트사용자", // name "개발테스트사용자", // name
Collections.singletonList("USER") // roles Collections.singletonList("USER") // roles
); );
// Authentication 객체 생성 및 SecurityContext에 설정 // Authentication 객체 생성 및 SecurityContext에 설정
@@ -3,6 +3,9 @@ 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 추천 엔티티
@@ -23,8 +26,10 @@ import lombok.*;
public class AiRecommendation extends BaseTimeEntity { public class AiRecommendation extends BaseTimeEntity {
@Id @Id
@Column(name = "recommendation_id", length = 50) @GeneratedValue(generator = "uuid2")
private String recommendationId; @GenericGenerator(name = "uuid2", strategy = "uuid2")
@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,6 +6,7 @@ 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.*;
@@ -31,14 +32,16 @@ import java.util.*;
public class Event extends BaseTimeEntity { public class Event extends BaseTimeEntity {
@Id @Id
@Column(name = "event_id", length = 50) @GeneratedValue(generator = "uuid2")
private String eventId; @GenericGenerator(name = "uuid2", strategy = "uuid2")
@Column(name = "event_id", columnDefinition = "uuid")
private UUID eventId;
@Column(name = "user_id", nullable = false, length = 50) @Column(name = "user_id", nullable = false, columnDefinition = "uuid")
private String userId; private UUID userId;
@Column(name = "store_id", nullable = false, length = 50) @Column(name = "store_id", nullable = false, columnDefinition = "uuid")
private String storeId; private UUID storeId;
@Column(name = "event_name", length = 200) @Column(name = "event_name", length = 200)
private String eventName; private String eventName;
@@ -60,8 +63,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", length = 50) @Column(name = "selected_image_id", columnDefinition = "uuid")
private String selectedImageId; private UUID selectedImageId;
@Column(name = "selected_image_url", length = 500) @Column(name = "selected_image_url", length = 500)
private String selectedImageUrl; private String selectedImageUrl;
@@ -125,7 +128,7 @@ public class Event extends BaseTimeEntity {
/** /**
* 이미지 선택 * 이미지 선택
*/ */
public void selectImage(String imageId, String imageUrl) { public void selectImage(UUID imageId, String imageUrl) {
this.selectedImageId = imageId; this.selectedImageId = imageId;
this.selectedImageUrl = imageUrl; this.selectedImageUrl = imageUrl;
@@ -3,6 +3,9 @@ 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;
/** /**
* 생성된 이미지 엔티티 * 생성된 이미지 엔티티
@@ -23,8 +26,10 @@ import lombok.*;
public class GeneratedImage extends BaseTimeEntity { public class GeneratedImage extends BaseTimeEntity {
@Id @Id
@Column(name = "image_id", length = 50) @GeneratedValue(generator = "uuid2")
private String imageId; @GenericGenerator(name = "uuid2", strategy = "uuid2")
@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,8 +5,10 @@ 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;
/** /**
* 비동기 작업 엔티티 * 비동기 작업 엔티티
@@ -27,11 +29,13 @@ import java.time.LocalDateTime;
public class Job extends BaseTimeEntity { public class Job extends BaseTimeEntity {
@Id @Id
@Column(name = "job_id", length = 50) @GeneratedValue(generator = "uuid2")
private String jobId; @GenericGenerator(name = "uuid2", strategy = "uuid2")
@Column(name = "job_id", columnDefinition = "uuid")
private UUID jobId;
@Column(name = "event_id", nullable = false, length = 50) @Column(name = "event_id", nullable = false, columnDefinition = "uuid")
private String eventId; private UUID eventId;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "job_type", nullable = false, length = 30) @Column(name = "job_type", nullable = false, length = 30)
@@ -5,6 +5,7 @@ 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
@@ -14,15 +15,15 @@ import java.util.List;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, String> { public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, UUID> {
/** /**
* 이벤트별 AI 추천 목록 조회 * 이벤트별 AI 추천 목록 조회
*/ */
List<AiRecommendation> findByEventEventId(String eventId); List<AiRecommendation> findByEventEventId(UUID eventId);
/** /**
* 이벤트별 선택된 AI 추천 조회 * 이벤트별 선택된 AI 추천 조회
*/ */
AiRecommendation findByEventEventIdAndIsSelectedTrue(String eventId); AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId);
} }
@@ -10,6 +10,7 @@ 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
@@ -19,7 +20,7 @@ import java.util.Optional;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface EventRepository extends JpaRepository<Event, String> { public interface EventRepository extends JpaRepository<Event, UUID> {
/** /**
* 사용자 ID와 이벤트 ID로 조회 * 사용자 ID와 이벤트 ID로 조회
@@ -28,8 +29,8 @@ public interface EventRepository extends JpaRepository<Event, String> {
"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") String eventId, @Param("eventId") UUID eventId,
@Param("userId") String userId @Param("userId") UUID userId
); );
/** /**
@@ -41,7 +42,7 @@ public interface EventRepository extends JpaRepository<Event, String> {
"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") String userId, @Param("userId") UUID 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,
@@ -51,5 +52,5 @@ public interface EventRepository extends JpaRepository<Event, String> {
/** /**
* 사용자별 이벤트 개수 조회 (상태별) * 사용자별 이벤트 개수 조회 (상태별)
*/ */
long countByUserIdAndStatus(String userId, EventStatus status); long countByUserIdAndStatus(UUID userId, EventStatus status);
} }
@@ -5,6 +5,7 @@ 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
@@ -14,15 +15,15 @@ import java.util.List;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, String> { public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, UUID> {
/** /**
* 이벤트별 생성된 이미지 목록 조회 * 이벤트별 생성된 이미지 목록 조회
*/ */
List<GeneratedImage> findByEventEventId(String eventId); List<GeneratedImage> findByEventEventId(UUID eventId);
/** /**
* 이벤트별 선택된 이미지 조회 * 이벤트별 선택된 이미지 조회
*/ */
GeneratedImage findByEventEventIdAndIsSelectedTrue(String eventId); GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId);
} }
@@ -8,6 +8,7 @@ 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
@@ -17,22 +18,22 @@ import java.util.Optional;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface JobRepository extends JpaRepository<Job, String> { public interface JobRepository extends JpaRepository<Job, UUID> {
/** /**
* 이벤트별 작업 목록 조회 * 이벤트별 작업 목록 조회
*/ */
List<Job> findByEventId(String eventId); List<Job> findByEventId(UUID eventId);
/** /**
* 이벤트 및 작업 유형별 조회 * 이벤트 및 작업 유형별 조회
*/ */
Optional<Job> findByEventIdAndJobType(String eventId, JobType jobType); Optional<Job> findByEventIdAndJobType(UUID eventId, JobType jobType);
/** /**
* 이벤트 및 작업 유형별 최신 작업 조회 * 이벤트 및 작업 유형별 최신 작업 조회
*/ */
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(String eventId, JobType jobType); Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType);
/** /**
* 상태별 작업 목록 조회 * 상태별 작업 목록 조회
@@ -18,6 +18,8 @@ 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
* *
@@ -91,7 +93,7 @@ public class AIJobKafkaConsumer {
@Transactional @Transactional
protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) { protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) {
try { try {
String jobId = message.getJobId(); UUID jobId = UUID.fromString(message.getJobId());
// Job 조회 // Job 조회
Job job = jobRepository.findById(jobId).orElse(null); Job job = jobRepository.findById(jobId).orElse(null);
@@ -100,7 +102,7 @@ public class AIJobKafkaConsumer {
return; return;
} }
String eventId = job.getEventId(); UUID eventId = job.getEventId();
// Event 조회 (모든 케이스에서 사용) // Event 조회 (모든 케이스에서 사용)
Event event = eventRepository.findById(eventId).orElse(null); Event event = eventRepository.findById(eventId).orElse(null);
@@ -140,7 +142,7 @@ public class AIJobKafkaConsumer {
eventId, aiData.getEventTitle()); eventId, aiData.getEventTitle());
// 사용자에게 알림 전송 // 사용자에게 알림 전송
String userId = event.getUserId(); UUID userId = event.getUserId();
notificationService.notifyJobCompleted( notificationService.notifyJobCompleted(
userId, userId,
jobId, jobId,
@@ -164,7 +166,7 @@ public class AIJobKafkaConsumer {
// 사용자에게 실패 알림 전송 // 사용자에게 실패 알림 전송
if (event != null) { if (event != null) {
String userId = event.getUserId(); UUID userId = event.getUserId();
notificationService.notifyJobFailed( notificationService.notifyJobFailed(
userId, userId,
jobId, jobId,
@@ -183,7 +185,7 @@ public class AIJobKafkaConsumer {
// 사용자에게 진행 상태 알림 전송 // 사용자에게 진행 상태 알림 전송
if (event != null) { if (event != null) {
String userId = event.getUserId(); UUID userId = event.getUserId();
notificationService.notifyJobProgress( notificationService.notifyJobProgress(
userId, userId,
jobId, jobId,
@@ -35,9 +35,9 @@ public class AIJobKafkaProducer {
/** /**
* AI 이벤트 생성 작업 메시지 발행 * AI 이벤트 생성 작업 메시지 발행
* *
* @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8}) * @param jobId 작업 ID (UUID String)
* @param userId 사용자 ID * @param userId 사용자 ID (UUID String)
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8}) * @param eventId 이벤트 ID (UUID String)
* @param storeName 매장명 * @param storeName 매장명
* @param storeCategory 매장 업종 * @param storeCategory 매장 업종
* @param storeDescription 매장 설명 * @param storeDescription 매장 설명
@@ -55,11 +55,6 @@ public class AIJobKafkaProducer {
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder() AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
.jobId(jobId) .jobId(jobId)
.userId(userId) .userId(userId)
.eventId(eventId)
.storeName(storeName)
.storeCategory(storeCategory)
.storeDescription(storeDescription)
.objective(objective)
.status("PENDING") .status("PENDING")
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.build(); .build();
@@ -29,12 +29,12 @@ public class EventKafkaProducer {
/** /**
* 이벤트 생성 완료 메시지 발행 * 이벤트 생성 완료 메시지 발행
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID (UUID)
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param title 이벤트 제목 * @param title 이벤트 제목
* @param eventType 이벤트 타입 * @param eventType 이벤트 타입
*/ */
public void publishEventCreated(String eventId, String userId, String title, String eventType) { public void publishEventCreated(java.util.UUID eventId, java.util.UUID userId, String title, String eventType) {
EventCreatedMessage message = EventCreatedMessage.builder() EventCreatedMessage message = EventCreatedMessage.builder()
.eventId(eventId) .eventId(eventId)
.userId(userId) .userId(userId)
@@ -18,6 +18,8 @@ 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
* *
@@ -92,8 +94,8 @@ public class ImageJobKafkaConsumer {
@Transactional @Transactional
protected void processImageGenerationJob(ImageGenerationJobMessage message) { protected void processImageGenerationJob(ImageGenerationJobMessage message) {
try { try {
String jobId = message.getJobId(); UUID jobId = UUID.fromString(message.getJobId());
String eventId = message.getEventId(); UUID eventId = UUID.fromString(message.getEventId());
// Job 조회 // Job 조회
Job job = jobRepository.findById(jobId).orElse(null); Job job = jobRepository.findById(jobId).orElse(null);
@@ -128,7 +130,7 @@ public class ImageJobKafkaConsumer {
eventId, message.getImageUrl()); eventId, message.getImageUrl());
// 사용자에게 알림 전송 // 사용자에게 알림 전송
String userId = event.getUserId(); UUID userId = event.getUserId();
notificationService.notifyJobCompleted( notificationService.notifyJobCompleted(
userId, userId,
jobId, jobId,
@@ -179,7 +181,7 @@ public class ImageJobKafkaConsumer {
// 사용자에게 실패 알림 전송 // 사용자에게 실패 알림 전송
if (event != null) { if (event != null) {
String userId = event.getUserId(); UUID userId = event.getUserId();
notificationService.notifyJobFailed( notificationService.notifyJobFailed(
userId, userId,
jobId, jobId,
@@ -200,7 +202,7 @@ public class ImageJobKafkaConsumer {
// 사용자에게 진행 상태 알림 전송 // 사용자에게 진행 상태 알림 전송
if (event != null) { if (event != null) {
String userId = event.getUserId(); UUID userId = event.getUserId();
notificationService.notifyJobProgress( notificationService.notifyJobProgress(
userId, userId,
jobId, jobId,
@@ -35,9 +35,9 @@ public class ImageJobKafkaProducer {
/** /**
* 이미지 생성 작업 메시지 발행 * 이미지 생성 작업 메시지 발행
* *
* @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8}) * @param jobId 작업 ID (UUID)
* @param userId 사용자 ID * @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8}) * @param eventId 이벤트 ID (UUID)
* @param prompt 이미지 생성 프롬프트 * @param prompt 이미지 생성 프롬프트
*/ */
public void publishImageGenerationJob( public void publishImageGenerationJob(
@@ -4,6 +4,8 @@ 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;
/** /**
* 로깅 기반 알림 서비스 구현 * 로깅 기반 알림 서비스 구현
* *
@@ -18,16 +20,16 @@ import org.springframework.stereotype.Service;
public class LoggingNotificationService implements NotificationService { public class LoggingNotificationService implements NotificationService {
@Override @Override
public void notifyJobCompleted(String userId, String jobId, String jobType, String message) { public void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message) {
log.info("📢 [작업 완료 알림] UserId: {}, JobId: {}, JobType: {}, Message: {}", 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, "/queue/notifications", notification); // 예: webSocketTemplate.convertAndSendToUser(userId.toString(), "/queue/notifications", notification);
} }
@Override @Override
public void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage) { public void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage) {
log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}", log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}",
userId, jobId, jobType, errorMessage); userId, jobId, jobType, errorMessage);
@@ -35,7 +37,7 @@ public class LoggingNotificationService implements NotificationService {
} }
@Override @Override
public void notifyJobProgress(String userId, String jobId, String jobType, int progress) { public void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress) {
log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%", log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%",
userId, jobId, jobType, progress); userId, jobId, jobType, progress);
@@ -21,6 +21,8 @@ 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;
/** /**
* 이벤트 컨트롤러 * 이벤트 컨트롤러
* *
@@ -32,7 +34,7 @@ import org.springframework.web.bind.annotation.*;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/events") @RequestMapping("/api/v1/events")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Event", description = "이벤트 관리 API") @Tag(name = "Event", description = "이벤트 관리 API")
public class EventController { public class EventController {
@@ -127,7 +129,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 String eventId, @PathVariable UUID eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}", log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}",
@@ -148,7 +150,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 String eventId, @PathVariable UUID eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}", log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}",
@@ -169,7 +171,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 String eventId, @PathVariable UUID eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}", log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}",
@@ -190,7 +192,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 String eventId, @PathVariable UUID eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}", log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}",
@@ -212,7 +214,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 String eventId, @PathVariable UUID eventId,
@Valid @RequestBody ImageGenerationRequest request, @Valid @RequestBody ImageGenerationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -241,8 +243,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 String eventId, @PathVariable UUID eventId,
@PathVariable String imageId, @PathVariable UUID imageId,
@Valid @RequestBody SelectImageRequest request, @Valid @RequestBody SelectImageRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -270,7 +272,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 String eventId, @PathVariable UUID eventId,
@Valid @RequestBody AiRecommendationRequest request, @Valid @RequestBody AiRecommendationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -298,7 +300,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 String eventId, @PathVariable UUID eventId,
@Valid @RequestBody SelectRecommendationRequest request, @Valid @RequestBody SelectRecommendationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -326,8 +328,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 String eventId, @PathVariable UUID eventId,
@PathVariable String imageId, @PathVariable UUID imageId,
@Valid @RequestBody ImageEditRequest request, @Valid @RequestBody ImageEditRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -355,7 +357,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 String eventId, @PathVariable UUID eventId,
@Valid @RequestBody SelectChannelsRequest request, @Valid @RequestBody SelectChannelsRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -382,7 +384,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 String eventId, @PathVariable UUID eventId,
@Valid @RequestBody UpdateEventRequest request, @Valid @RequestBody UpdateEventRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@@ -13,6 +13,8 @@ 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 컨트롤러
* *
@@ -24,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/jobs") @RequestMapping("/api/v1/jobs")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Job", description = "비동기 작업 상태 조회 API") @Tag(name = "Job", description = "비동기 작업 상태 조회 API")
public class JobController { public class JobController {
@@ -39,7 +41,7 @@ public class JobController {
*/ */
@GetMapping("/{jobId}") @GetMapping("/{jobId}")
@Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).") @Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).")
public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable String jobId) { public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable UUID jobId) {
log.info("Job 상태 조회 API 호출 - jobId: {}", jobId); 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("/redis-test") @RequestMapping("/api/v1/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 context-path: /api/v1/events
shutdown: graceful shutdown: graceful
# Actuator Configuration # Actuator Configuration
-38
View File
@@ -1,38 +0,0 @@
apiVersion: v1
kind: Service
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"labels":{"app":"participation-service","app.kubernetes.io/managed-by":"kustomize","app.kubernetes.io/part-of":"kt-event-marketing","environment":"dev"},"name":"participation-service","namespace":"kt-event-marketing"},"spec":{"ports":[{"name":"http","port":80,"protocol":"TCP","targetPort":8084}],"selector":{"app":"participation-service","app.kubernetes.io/managed-by":"kustomize","app.kubernetes.io/part-of":"kt-event-marketing","environment":"dev"},"type":"ClusterIP"}}
creationTimestamp: "2025-10-28T08:59:06Z"
labels:
app: participation-service
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/part-of: kt-event-marketing
environment: dev
name: participation-service
namespace: kt-event-marketing
resourceVersion: "125107611"
uid: da5b7f82-37d3-41bd-ad87-e2864c8bcd18
spec:
clusterIP: 10.0.130.146
clusterIPs:
- 10.0.130.146
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: http
port: 80
protocol: TCP
targetPort: 8084
selector:
app: participation-service
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/part-of: kt-event-marketing
environment: dev
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}
-27
View File
@@ -1,27 +0,0 @@
apiVersion: v1
kind: Service
metadata:
labels:
app: participation-service
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/part-of: kt-event-marketing
environment: dev
name: participation-service
namespace: kt-event-marketing
spec:
clusterIP: 10.0.130.146
clusterIPs:
- 10.0.130.146
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: http
port: 80
protocol: TCP
targetPort: 8084
selector:
app: participation-service
sessionAffinity: None
type: ClusterIP
@@ -1,13 +1,17 @@
package com.kt.event.participation.infrastructure.config; package com.kt.event.participation.infrastructure.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/** /**
* Security Configuration for Participation Service * Security Configuration for Participation Service
@@ -20,31 +24,43 @@ import org.springframework.security.web.SecurityFilterChain;
@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}")
private String allowedOrigins;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
// CSRF 비활성화 (REST API는 CSRF 불필요) .csrf(csrf -> csrf.disable())
.csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 세션 사용 (JWT 기반 인증) .authorizeHttpRequests(auth -> auth
.sessionManagement(session -> // Actuator endpoints
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) .requestMatchers("/actuator/**").permitAll()
) .anyRequest().permitAll()
);
// 모든 요청 허용 (테스트용)
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build(); return http.build();
} }
/**
* Chrome DevTools 요청 정적 리소스 요청을 Spring Security에서 제외
*/
@Bean @Bean
public WebSecurityCustomizer webSecurityCustomizer() { public CorsConfigurationSource corsConfigurationSource() {
return (web) -> web.ignoring() CorsConfiguration configuration = new CorsConfiguration();
.requestMatchers("/.well-known/**");
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
} }
} }
@@ -1,32 +0,0 @@
package com.kt.event.participation.infrastructure.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web Configuration
* CORS 설정 기타 관련 설정
*
* @author System Architect
* @since 2025-10-30
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
/**
* CORS 설정
* - 모든 origin 허용 (개발 환경)
* - 모든 HTTP 메서드 허용
* - Credentials 허용
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
@@ -0,0 +1,104 @@
package com.kt.event.participation.presentation.controller;
import com.kt.event.participation.domain.participant.Participant;
import com.kt.event.participation.domain.participant.ParticipantRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 디버깅용 컨트롤러
*/
@Slf4j
@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("/debug")
@RequiredArgsConstructor
public class DebugController {
private final ParticipantRepository participantRepository;
/**
* 중복 참여 체크 테스트
*/
@GetMapping("/exists/{eventId}/{phoneNumber}")
public String testExists(@PathVariable String eventId, @PathVariable String phoneNumber) {
try {
log.info("디버그: 중복 체크 시작 - eventId: {}, phoneNumber: {}", eventId, phoneNumber);
boolean exists = participantRepository.existsByEventIdAndPhoneNumber(eventId, phoneNumber);
log.info("디버그: 중복 체크 결과 - exists: {}", exists);
long totalCount = participantRepository.count();
long eventCount = participantRepository.countByEventId(eventId);
return String.format(
"eventId: %s, phoneNumber: %s, exists: %s, totalCount: %d, eventCount: %d",
eventId, phoneNumber, exists, totalCount, eventCount
);
} catch (Exception e) {
log.error("디버그: 예외 발생", e);
return "ERROR: " + e.getMessage();
}
}
/**
* 모든 참여자 데이터 조회
*/
@GetMapping("/participants")
public String getAllParticipants() {
try {
List<Participant> participants = participantRepository.findAll();
StringBuilder sb = new StringBuilder();
sb.append("Total participants: ").append(participants.size()).append("\n\n");
for (Participant p : participants) {
sb.append(String.format("ID: %s, EventID: %s, Phone: %s, Name: %s\n",
p.getParticipantId(), p.getEventId(), p.getPhoneNumber(), p.getName()));
}
return sb.toString();
} catch (Exception e) {
log.error("디버그: 참여자 조회 예외 발생", e);
return "ERROR: " + e.getMessage();
}
}
/**
* 특정 전화번호의 참여 이력 조회
*/
@GetMapping("/phone/{phoneNumber}")
public String getByPhoneNumber(@PathVariable String phoneNumber) {
try {
List<Participant> participants = participantRepository.findAll();
StringBuilder sb = new StringBuilder();
sb.append("Participants with phone: ").append(phoneNumber).append("\n\n");
int count = 0;
for (Participant p : participants) {
if (phoneNumber.equals(p.getPhoneNumber())) {
sb.append(String.format("ID: %s, EventID: %s, Name: %s\n",
p.getParticipantId(), p.getEventId(), p.getName()));
count++;
}
}
if (count == 0) {
sb.append("No participants found with this phone number.");
}
return sb.toString();
} catch (Exception e) {
log.error("디버그: 전화번호별 조회 예외 발생", e);
return "ERROR: " + e.getMessage();
}
}
}
@@ -35,9 +35,9 @@ public class ParticipationController {
/** /**
* 이벤트 참여 * 이벤트 참여
* POST /participations/{eventId}/participate * POST /events/{eventId}/participate
*/ */
@PostMapping("/participations/{eventId}/participate") @PostMapping("/events/{eventId}/participate")
public ResponseEntity<ApiResponse<ParticipationResponse>> participate( public ResponseEntity<ApiResponse<ParticipationResponse>> participate(
@PathVariable String eventId, @PathVariable String eventId,
@Valid @RequestBody ParticipationRequest request) { @Valid @RequestBody ParticipationRequest request) {
@@ -61,15 +61,14 @@ public class ParticipationController {
/** /**
* 참여자 목록 조회 * 참여자 목록 조회
* GET /participations/{eventId}/participants * GET /events/{eventId}/participants
* GET /events/{eventId}/participants (프론트엔드 호환)
*/ */
@Operation( @Operation(
summary = "참여자 목록 조회", summary = "참여자 목록 조회",
description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " + description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
"정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt" "정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
) )
@GetMapping({"/participations/{eventId}/participants", "/events/{eventId}/participants"}) @GetMapping("/events/{eventId}/participants")
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants( public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
@Parameter(description = "이벤트 ID", example = "evt_20250124_001") @Parameter(description = "이벤트 ID", example = "evt_20250124_001")
@PathVariable String eventId, @PathVariable String eventId,
@@ -90,10 +89,9 @@ public class ParticipationController {
/** /**
* 참여자 상세 조회 * 참여자 상세 조회
* GET /participations/{eventId}/participants/{participantId} * GET /events/{eventId}/participants/{participantId}
* GET /events/{eventId}/participants/{participantId} (프론트엔드 호환)
*/ */
@GetMapping({"/participations/{eventId}/participants/{participantId}", "/events/{eventId}/participants/{participantId}"}) @GetMapping("/events/{eventId}/participants/{participantId}")
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant( public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
@PathVariable String eventId, @PathVariable String eventId,
@PathVariable String participantId) { @PathVariable String participantId) {
@@ -35,9 +35,9 @@ public class WinnerController {
/** /**
* 당첨자 추첨 * 당첨자 추첨
* POST /participations/{eventId}/draw-winners * POST /events/{eventId}/draw-winners
*/ */
@PostMapping("/participations/{eventId}/draw-winners") @PostMapping("/events/{eventId}/draw-winners")
public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners( public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners(
@PathVariable String eventId, @PathVariable String eventId,
@Valid @RequestBody DrawWinnersRequest request) { @Valid @RequestBody DrawWinnersRequest request) {
@@ -50,14 +50,14 @@ public class WinnerController {
/** /**
* 당첨자 목록 조회 * 당첨자 목록 조회
* GET /participations/{eventId}/winners * GET /events/{eventId}/winners
*/ */
@Operation( @Operation(
summary = "당첨자 목록 조회", summary = "당첨자 목록 조회",
description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " + description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " +
"정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries" "정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries"
) )
@GetMapping("/participations/{eventId}/winners") @GetMapping("/events/{eventId}/winners")
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners( public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners(
@Parameter(description = "이벤트 ID", example = "evt_20250124_001") @Parameter(description = "이벤트 ID", example = "evt_20250124_001")
@PathVariable String eventId, @PathVariable String eventId,
@@ -56,7 +56,7 @@ jwt:
# CORS 설정 # CORS 설정
cors: cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:*} allowed-origins: ${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}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH} allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*} allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true} allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
-81
View File
@@ -1,81 +0,0 @@
@echo off
REM Content Service 실행 스크립트
REM Port: 8084
REM Context Path: /api/v1/content
setlocal enabledelayedexpansion
set SERVICE_NAME=content-service
set PORT=8084
set LOG_DIR=logs
set LOG_FILE=%LOG_DIR%\%SERVICE_NAME%.log
REM 로그 디렉토리 생성
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
REM 환경 변수 설정
set SERVER_PORT=8084
set REDIS_HOST=20.214.210.71
set REDIS_PORT=6379
set REDIS_PASSWORD=Hi5Jessica!
set REDIS_DATABASE=0
set JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
set JWT_ACCESS_TOKEN_VALIDITY=3600000
set JWT_REFRESH_TOKEN_VALIDITY=604800000
REM Azure Blob Storage
set AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net
set AZURE_CONTAINER_NAME=content-images
REM CORS
set CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io
set CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS,PATCH
set CORS_ALLOWED_HEADERS=*
set CORS_ALLOW_CREDENTIALS=true
set CORS_MAX_AGE=3600
REM Logging
set LOG_LEVEL_APP=DEBUG
set LOG_LEVEL_WEB=INFO
set LOG_LEVEL_ROOT=INFO
set LOG_FILE_PATH=%LOG_FILE%
set LOG_FILE_MAX_SIZE=10MB
set LOG_FILE_MAX_HISTORY=7
set LOG_FILE_TOTAL_CAP=100MB
echo ==================================================
echo Content Service 시작
echo ==================================================
echo 포트: %PORT%
echo 로그 파일: %LOG_FILE%
echo Context Path: /api/v1/content
echo ==================================================
REM 기존 프로세스 확인
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%PORT%.*LISTENING"') do (
echo ⚠️ 포트 %PORT%가 이미 사용 중입니다. PID: %%a
set /p answer="기존 프로세스를 종료하시겠습니까? (y/n): "
if /i "!answer!"=="y" (
taskkill /F /PID %%a
timeout /t 2 /nobreak > nul
) else (
echo 서비스 시작을 취소합니다.
exit /b 1
)
)
REM 서비스 시작
echo 서비스를 시작합니다...
start /b cmd /c "gradlew.bat %SERVICE_NAME%:bootRun > %LOG_FILE% 2>&1"
timeout /t 3 /nobreak > nul
echo ✅ Content Service가 시작되었습니다.
echo 로그 확인: tail -f %LOG_FILE% 또는 type %LOG_FILE%
echo.
echo Health Check: curl http://localhost:%PORT%/api/v1/content/actuator/health
echo.
echo 서비스 종료: 작업 관리자에서 java 프로세스 종료
echo ==================================================
endlocal
-80
View File
@@ -1,80 +0,0 @@
#!/bin/bash
# Content Service 실행 스크립트
# Port: 8084
# Context Path: /api/v1/content
SERVICE_NAME="content-service"
PORT=8084
LOG_DIR="logs"
LOG_FILE="${LOG_DIR}/${SERVICE_NAME}.log"
# 로그 디렉토리 생성
mkdir -p ${LOG_DIR}
# 환경 변수 설정
export SERVER_PORT=8084
export REDIS_HOST=20.214.210.71
export REDIS_PORT=6379
export REDIS_PASSWORD=Hi5Jessica!
export REDIS_DATABASE=0
export JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
export JWT_ACCESS_TOKEN_VALIDITY=3600000
export JWT_REFRESH_TOKEN_VALIDITY=604800000
# Azure Blob Storage
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net"
export AZURE_CONTAINER_NAME=content-images
# CORS
export CORS_ALLOWED_ORIGINS="http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io"
export CORS_ALLOWED_METHODS="GET,POST,PUT,DELETE,OPTIONS,PATCH"
export CORS_ALLOWED_HEADERS="*"
export CORS_ALLOW_CREDENTIALS=true
export CORS_MAX_AGE=3600
# Logging
export LOG_LEVEL_APP=DEBUG
export LOG_LEVEL_WEB=INFO
export LOG_LEVEL_ROOT=INFO
export LOG_FILE_PATH="${LOG_FILE}"
export LOG_FILE_MAX_SIZE=10MB
export LOG_FILE_MAX_HISTORY=7
export LOG_FILE_TOTAL_CAP=100MB
echo "=================================================="
echo "Content Service 시작"
echo "=================================================="
echo "포트: ${PORT}"
echo "로그 파일: ${LOG_FILE}"
echo "Context Path: /api/v1/content"
echo "=================================================="
# 기존 프로세스 확인
if netstat -ano | grep -q ":${PORT}.*LISTENING"; then
echo "⚠️ 포트 ${PORT}가 이미 사용 중입니다."
echo "기존 프로세스를 종료하시겠습니까? (y/n)"
read -r answer
if [ "$answer" = "y" ]; then
PID=$(netstat -ano | grep ":${PORT}.*LISTENING" | awk '{print $5}' | head -1)
taskkill //F //PID ${PID}
sleep 2
else
echo "서비스 시작을 취소합니다."
exit 1
fi
fi
# 서비스 시작
echo "서비스를 시작합니다..."
nohup ./gradlew ${SERVICE_NAME}:bootRun > ${LOG_FILE} 2>&1 &
SERVICE_PID=$!
echo "✅ Content Service가 시작되었습니다."
echo "PID: ${SERVICE_PID}"
echo "로그 확인: tail -f ${LOG_FILE}"
echo ""
echo "Health Check: curl http://localhost:${PORT}/api/v1/content/actuator/health"
echo ""
echo "서비스 종료: kill ${SERVICE_PID}"
echo "=================================================="
-8
View File
@@ -1,8 +0,0 @@
{
"storeInfo": {
"storeId": "str_dev_test_001",
"storeName": "Woojin BBQ Restaurant",
"category": "Restaurant",
"description": "Korean BBQ restaurant serving fresh Hanwoo beef"
}
}
-82
View File
@@ -1,82 +0,0 @@
#!/bin/bash
# Content Service 통합 테스트 스크립트
# 작성일: 2025-10-30
# 테스트 대상: content-service (포트 8084)
BASE_URL="http://localhost:8084/api/v1/content"
COLOR_GREEN='\033[0;32m'
COLOR_RED='\033[0;31m'
COLOR_YELLOW='\033[1;33m'
COLOR_NC='\033[0m' # No Color
echo "=========================================="
echo "Content Service 통합 테스트 시작"
echo "=========================================="
echo ""
# 테스트 데이터
EVENT_ID="EVT-str_dev_test_001-20251029220003-610158ce"
TEST_IMAGE_ID=1
# 1. Health Check
echo -e "${COLOR_YELLOW}[1/7] Health Check${COLOR_NC}"
curl -s http://localhost:8084/actuator/health | jq . || echo -e "${COLOR_RED}❌ Health check 실패${COLOR_NC}"
echo ""
# 2. 이미지 생성 요청 (HTTP 통신 테스트)
echo -e "${COLOR_YELLOW}[2/7] 이미지 생성 요청 (HTTP 통신)${COLOR_NC}"
RESPONSE=$(curl -s -X POST "$BASE_URL/images/generate" \
-H "Content-Type: application/json" \
-d @test-image-generation.json)
echo "$RESPONSE" | jq .
JOB_ID=$(echo "$RESPONSE" | jq -r '.jobId')
echo -e "${COLOR_GREEN}✅ Job ID: $JOB_ID${COLOR_NC}"
echo ""
# 3. Job 상태 조회 (Job 관리 테스트)
echo -e "${COLOR_YELLOW}[3/7] Job 상태 조회 (Job 관리)${COLOR_NC}"
if [ ! -z "$JOB_ID" ] && [ "$JOB_ID" != "null" ]; then
curl -s "$BASE_URL/images/jobs/$JOB_ID" | jq .
echo -e "${COLOR_GREEN}✅ Job 상태 조회 성공${COLOR_NC}"
else
echo -e "${COLOR_RED}❌ JOB_ID가 없어 테스트 건너뜀${COLOR_NC}"
fi
echo ""
# 4. EventId 기반 콘텐츠 조회
echo -e "${COLOR_YELLOW}[4/7] EventId 기반 콘텐츠 조회${COLOR_NC}"
curl -s "$BASE_URL/events/$EVENT_ID" | jq .
echo -e "${COLOR_GREEN}✅ 콘텐츠 조회 성공${COLOR_NC}"
echo ""
# 5. 이미지 목록 조회
echo -e "${COLOR_YELLOW}[5/7] 이미지 목록 조회${COLOR_NC}"
curl -s "$BASE_URL/events/$EVENT_ID/images" | jq .
echo -e "${COLOR_GREEN}✅ 이미지 목록 조회 성공${COLOR_NC}"
echo ""
# 6. 이미지 목록 조회 (필터링: style)
echo -e "${COLOR_YELLOW}[6/7] 이미지 필터링 (style=SIMPLE)${COLOR_NC}"
curl -s "$BASE_URL/events/$EVENT_ID/images?style=SIMPLE" | jq .
echo ""
# 7. 이미지 재생성 요청
echo -e "${COLOR_YELLOW}[7/7] 이미지 재생성 요청${COLOR_NC}"
REGEN_RESPONSE=$(curl -s -X POST "$BASE_URL/images/$TEST_IMAGE_ID/regenerate" \
-H "Content-Type: application/json" \
-d '{"newPrompt": "Updated image with modern Korean BBQ theme"}')
echo "$REGEN_RESPONSE" | jq .
REGEN_JOB_ID=$(echo "$REGEN_RESPONSE" | jq -r '.jobId')
if [ ! -z "$REGEN_JOB_ID" ] && [ "$REGEN_JOB_ID" != "null" ]; then
echo -e "${COLOR_GREEN}✅ 재생성 Job ID: $REGEN_JOB_ID${COLOR_NC}"
else
echo -e "${COLOR_YELLOW}⚠️ 이미지 ID가 존재하지 않을 수 있음${COLOR_NC}"
fi
echo ""
echo "=========================================="
echo "테스트 완료"
echo "=========================================="
-10
View File
@@ -1,10 +0,0 @@
{
"eventId": "EVT-str_dev_test_001-20251029220003-610158ce",
"eventTitle": "Woojin BBQ Restaurant Grand Opening Event",
"eventDescription": "Special discount event for Korean BBQ restaurant grand opening. Fresh Hanwoo beef at 20% off!",
"industry": "Restaurant",
"location": "Seoul",
"trends": ["Korean BBQ", "Hanwoo", "Grand Opening"],
"styles": ["SIMPLE", "TRENDY"],
"platforms": ["INSTAGRAM", "KAKAO"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"storeInfo": {
"storeId": "str_dev_test_001",
"storeName": "Golden Dragon Chinese Restaurant",
"category": "RESTAURANT",
"description": "Authentic Chinese cuisine with signature Peking duck and dim sum"
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"storeName": "Golden Dragon Chinese Restaurant",
"storeCategory": "RESTAURANT",
"storeDescription": "Authentic Chinese cuisine with signature Peking duck and dim sum. Family-owned restaurant serving the community for 15 years.",
"objective": "Launch Chinese New Year special promotion to attract customers during holiday season with 25% discount on all menu items.",
"requestAIRecommendation": true
}
-3
View File
@@ -1,3 +0,0 @@
{
"objective": "Chinese New Year promotion with 25% discount"
}
-1
View File
@@ -1 +0,0 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0NmUwZjAyZS04ZDFiLTQzYzItODRmZC0yYjY1ZTEzMjdlYzYiLCJzdG9yZUlkIjoiOGQ4ZmI5NjQtMzM2Mi00ZDk5LWI3YWUtOTcxZTRhODUxYjVhIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNzQ1ODMwLCJleHAiOjE3OTMyODE4MzB9.aP-y6qpc7dl9ChYGI9GQ4Cz7XE2DXXhW7MUA97nN-OU
-1
View File
@@ -1 +0,0 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzYzU0MmY2NC02NWU1LTQyYTAtYWM1Ni1mNjM4OTU3MDU0NDUiLCJzdG9yZUlkIjoiMzlhMTdhYjMtMDg5NC00NGVhLWFkNmItNTFkZDcxZTA3MTcwIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNzQ2OTI2LCJleHAiOjE3OTMyODI5MjZ9.IkYHvQdx1HI9f7tY9efBcXcOqiMmqNNRZ8gl7VOHYUY

Some files were not shown because too many files have changed in this diff Show More