Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into feature/distribution

This commit is contained in:
sunmingLee 2025-10-30 18:38:14 +09:00
commit 4bc7f87663
138 changed files with 5568 additions and 1057 deletions

View File

@ -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: "" REPLICATE_API_TOKEN: "r8_BsGCJtAg5U5kkMBXSe3pgMkPufSKnUR4NY9gJ"
# HuggingFace API Token # HuggingFace API Token
HUGGINGFACE_API_TOKEN: "" HUGGINGFACE_API_TOKEN: ""

View File

@ -41,21 +41,21 @@ spec:
memory: "1024Mi" memory: "1024Mi"
startupProbe: startupProbe:
httpGet: httpGet:
path: /actuator/health path: /api/v1/distribution/actuator/health
port: 8085 port: 8085
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
failureThreshold: 30 failureThreshold: 30
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /actuator/health/readiness path: /api/v1/distribution/actuator/health/readiness
port: 8085 port: 8085
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 5 periodSeconds: 5
failureThreshold: 3 failureThreshold: 3
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /actuator/health/liveness path: /api/v1/distribution/actuator/health/liveness
port: 8085 port: 8085
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10

View File

@ -53,11 +53,6 @@ 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

View File

@ -6,10 +6,6 @@ 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

View File

@ -1,14 +1,14 @@
name: Backend CI/CD Pipeline name: Backend CI/CD Pipeline
on: on:
push: # push:
branches: # branches:
- develop # - develop
- main # - main
paths: # paths:
- '*-service/**' # - '*-service/**'
- '.github/workflows/backend-cicd.yaml' # - '.github/workflows/backend-cicd.yaml'
- '.github/kustomize/**' # - '.github/kustomize/**'
pull_request: pull_request:
branches: branches:
- develop - develop
@ -107,8 +107,8 @@ jobs:
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew ${{ matrix.service }}:build -x test run: ./gradlew ${{ matrix.service }}:build -x test
- name: Run tests # - name: Run tests
run: ./gradlew ${{ matrix.service }}:test # run: ./gradlew ${{ matrix.service }}:test
- name: Build JAR - name: Build JAR
run: ./gradlew ${{ matrix.service }}:bootJar run: ./gradlew ${{ matrix.service }}:bootJar

View File

@ -19,7 +19,7 @@
<env name="REDIS_HOST" value="20.214.210.71" /> <env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" /> <env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" /> <env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" /> <env name="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<env name="KAFKA_CONSUMER_GROUP" value="ai" /> <env name="KAFKA_CONSUMER_GROUP" value="ai" />
<env name="JPA_DDL_AUTO" value="update" /> <env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" /> <env name="JPA_SHOW_SQL" value="false" />

View File

@ -21,6 +21,8 @@
<env name="REDIS_PASSWORD" value="Hi5Jessica!" /> <env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="JPA_DDL_AUTO" value="update" /> <env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" /> <env name="JPA_SHOW_SQL" value="false" />
<env name="REPLICATE_API_TOKEN" value="r8_cqE8IzQr9DZ8Dr72ozbomiXe6IFPL0005Vuq9" />
<env name="REPLICATE_MOCK_ENABLED" value="true" />
</envs> </envs>
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />

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
DEVELOP_CHANGELOG.md Normal file
View File

@ -0,0 +1,620 @@
# 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

View File

@ -4,6 +4,7 @@ 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.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;
@ -27,21 +28,22 @@ import java.util.List;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
/**
* Security Filter Chain 설정
* - 모든 요청 허용 (내부 API)
* - CSRF 비활성화
* - Stateless 세션
*/
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
// CSRF 비활성화 (REST API는 CSRF 불필요)
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
// CORS 설정
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 세션 사용 (JWT 기반 인증)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 모든 요청 허용 (테스트용)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/internal/**").permitAll() // Internal API
.anyRequest().permitAll() .anyRequest().permitAll()
); );
@ -50,11 +52,14 @@ public class SecurityConfig {
/** /**
* CORS 설정 * CORS 설정
* - 모든 Origin 허용 (Swagger UI 테스트를 위해)
* - 모든 HTTP Method 허용
* - 모든 Header 허용
*/ */
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080")); configuration.setAllowedOriginPatterns(List.of("*")); // 모든 Origin 허용
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
@ -64,4 +69,13 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
/**
* Chrome DevTools 요청 정적 리소스 요청을 Spring Security에서 제외
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.requestMatchers("/.well-known/**");
}
} }

View File

@ -20,6 +20,10 @@ public class SwaggerConfig {
@Bean @Bean
public OpenAPI openAPI() { public OpenAPI openAPI() {
Server vmServer = new Server();
vmServer.setUrl("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/ai");
vmServer.setDescription("VM Development Server");
Server localServer = new Server(); Server localServer = new Server();
localServer.setUrl("http://localhost:8083"); localServer.setUrl("http://localhost:8083");
localServer.setDescription("Local Development Server"); localServer.setDescription("Local Development Server");
@ -59,6 +63,6 @@ public class SwaggerConfig {
return new OpenAPI() return new OpenAPI()
.info(info) .info(info)
.servers(List.of(localServer, devServer, prodServer)); .servers(List.of(vmServer, localServer, devServer, prodServer));
} }
} }

View File

@ -27,7 +27,7 @@ import java.util.Map;
@Slf4j @Slf4j
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API") @Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
@RestController @RestController
@RequestMapping("/api/v1/ai-service/internal/jobs") @RequestMapping("/jobs")
@RequiredArgsConstructor @RequiredArgsConstructor
public class InternalJobController { public class InternalJobController {

View File

@ -31,7 +31,7 @@ import java.util.Set;
@Slf4j @Slf4j
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API") @Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
@RestController @RestController
@RequestMapping("/api/v1/ai-service/internal/recommendations") @RequestMapping("/recommendations")
@RequiredArgsConstructor @RequiredArgsConstructor
public class InternalRecommendationController { public class InternalRecommendationController {

View File

@ -19,7 +19,7 @@ spring:
# Kafka Consumer Configuration # Kafka Consumer Configuration
kafka: kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092} bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer: consumer:
group-id: ${KAFKA_CONSUMER_GROUP:ai-service-consumers} group-id: ${KAFKA_CONSUMER_GROUP:ai-service-consumers}
auto-offset-reset: earliest auto-offset-reset: earliest
@ -28,6 +28,8 @@ spring:
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties: properties:
spring.json.trusted.packages: "*" spring.json.trusted.packages: "*"
spring.json.use.type.headers: false
spring.json.value.default.type: com.kt.ai.kafka.message.AIJobMessage
max.poll.records: 10 max.poll.records: 10
session.timeout.ms: 30000 session.timeout.ms: 30000
listener: listener:
@ -51,7 +53,7 @@ jwt:
# CORS Configuration # CORS Configuration
cors: cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*} allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*,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}

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가 처리) -->
@ -39,7 +39,7 @@
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" /> <entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
<!-- CORS Configuration --> <!-- CORS Configuration -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" /> <entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*,http://*.nip.io:*" />
<!-- Logging Configuration --> <!-- Logging Configuration -->
<entry key="LOG_FILE" value="logs/analytics-service.log" /> <entry key="LOG_FILE" value="logs/analytics-service.log" />

View File

@ -1,7 +1,7 @@
# Multi-stage build for Spring Boot application # Multi-stage build for Spring Boot application
FROM eclipse-temurin:21-jre-alpine AS builder FROM eclipse-temurin:21-jre-alpine AS builder
WORKDIR /app WORKDIR /app
COPY build/libs/*.jar app.jar COPY analytics-service/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine FROM eclipse-temurin:21-jre-alpine

View File

@ -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={}",

View File

@ -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

View File

@ -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());
}
}

View File

@ -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,10 +93,15 @@ public class SampleDataLoader implements ApplicationRunner {
// Redis 멱등성 삭제 (새로운 이벤트 처리를 위해) // Redis 멱등성 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중..."); log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events"); try {
redisTemplate.delete("distribution_completed"); redisTemplate.delete("processed_events_v2");
redisTemplate.delete("processed_participants"); redisTemplate.delete("distribution_completed_v2");
log.info("✅ Redis 멱등성 키 삭제 완료"); redisTemplate.delete("processed_participants_v2");
log.info("✅ Redis 멱등성 키 삭제 완료");
} catch (Exception e) {
log.warn("⚠️ Redis 삭제 실패 (read-only replica일 수 있음): {}", e.getMessage());
log.info("→ Redis 삭제 건너뛰고 계속 진행...");
}
try { try {
// 1. EventCreated 이벤트 발행 (3개 이벤트) // 1. EventCreated 이벤트 발행 (3개 이벤트)
@ -103,6 +116,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 +142,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 +169,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 +180,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("1")
.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("2")
.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("3")
.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);
@ -201,49 +269,70 @@ public class SampleDataLoader implements ApplicationRunner {
* DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열) * DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열)
*/ */
private void publishDistributionCompletedEvents() throws Exception { private void publishDistributionCompletedEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; String[] eventIds = {"1", "2", "3"};
int[][] expectedViews = { int[][] expectedViews = {
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS {5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
{3500, 7000, 2000, 1500}, // 이벤트2 {3500, 7000, 2000, 1500}, // 이벤트2
{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 +350,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 = {"1", "2", "3"};
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,72 +408,102 @@ 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() {
log.info("📊 TimelineData 생성 시작..."); log.info("📊 TimelineData 생성 시작...");
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
// 이벤트별 기준 참여자 (이벤트 성과에 따라 다름) // 이벤트별 시간당 기준 참여자 (이벤트 성과에 따라 다름)
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건");
} }
/** /**

View File

@ -39,16 +39,7 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Actuator endpoints .anyRequest().permitAll()
.requestMatchers("/actuator/**").permitAll()
// Swagger UI endpoints
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
// Health check
.requestMatchers("/health").permitAll()
// Analytics API endpoints (테스트 개발 용도로 공개)
.requestMatchers("/api/**").permitAll()
// All other requests require authentication
.anyRequest().authenticated()
) )
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class) UsernamePasswordAuthenticationFilter.class)

View File

@ -22,8 +22,11 @@ public class SwaggerConfig {
return new OpenAPI() return new OpenAPI()
.info(apiInfo()) .info(apiInfo())
.addServersItem(new Server() .addServersItem(new Server()
.url("http://localhost:8086") .url("http://localhost:8086/api/v1/analytics")
.description("Local Development")) .description("Local Development"))
.addServersItem(new Server()
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/analytics")
.description("AKS Development"))
.addServersItem(new Server() .addServersItem(new Server()
.url("{protocol}://{host}:{port}") .url("{protocol}://{host}:{port}")
.description("Custom Server") .description("Custom Server")

View File

@ -22,7 +22,7 @@ import java.time.LocalDateTime;
@Tag(name = "Analytics", description = "이벤트 성과 분석 및 대시보드 API") @Tag(name = "Analytics", description = "이벤트 성과 분석 및 대시보드 API")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/events") @RequestMapping("/events")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AnalyticsDashboardController { public class AnalyticsDashboardController {
@ -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));

View File

@ -22,7 +22,7 @@ import java.util.List;
@Tag(name = "Channels", description = "채널별 성과 분석 API") @Tag(name = "Channels", description = "채널별 성과 분석 API")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/events") @RequestMapping("/events")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ChannelAnalyticsController { public class ChannelAnalyticsController {

View File

@ -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("/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()));
}
}
}

View File

@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.*;
@Tag(name = "ROI", description = "투자 대비 수익률 분석 API") @Tag(name = "ROI", description = "투자 대비 수익률 분석 API")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/events") @RequestMapping("/events")
@RequiredArgsConstructor @RequiredArgsConstructor
public class RoiAnalyticsController { public class RoiAnalyticsController {

View File

@ -24,7 +24,7 @@ import java.util.List;
@Tag(name = "Timeline", description = "시간대별 분석 API") @Tag(name = "Timeline", description = "시간대별 분석 API")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/events") @RequestMapping("/events")
@RequiredArgsConstructor @RequiredArgsConstructor
public class TimelineAnalyticsController { public class TimelineAnalyticsController {
@ -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));

View File

@ -22,7 +22,7 @@ import java.time.LocalDateTime;
@Tag(name = "User Analytics", description = "사용자 전체 이벤트 통합 성과 분석 API") @Tag(name = "User Analytics", description = "사용자 전체 이벤트 통합 성과 분석 API")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/users") @RequestMapping("/users")
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserAnalyticsDashboardController { public class UserAnalyticsDashboardController {
@ -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));

View File

@ -22,7 +22,7 @@ import java.util.List;
@Tag(name = "User Channels", description = "사용자 전체 이벤트 채널별 성과 분석 API") @Tag(name = "User Channels", description = "사용자 전체 이벤트 채널별 성과 분석 API")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/users") @RequestMapping("/users")
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserChannelAnalyticsController { public class UserChannelAnalyticsController {
@ -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));

View File

@ -20,7 +20,7 @@ import java.time.LocalDateTime;
@Tag(name = "User ROI", description = "사용자 전체 이벤트 ROI 분석 API") @Tag(name = "User ROI", description = "사용자 전체 이벤트 ROI 분석 API")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/users") @RequestMapping("/users")
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserRoiAnalyticsController { public class UserRoiAnalyticsController {
@ -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));

View File

@ -22,7 +22,7 @@ import java.util.List;
@Tag(name = "User Timeline", description = "사용자 전체 이벤트 시간대별 분석 API") @Tag(name = "User Timeline", description = "사용자 전체 이벤트 시간대별 분석 API")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/users") @RequestMapping("/users")
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserTimelineAnalyticsController { public class UserTimelineAnalyticsController {
@ -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));

View File

@ -47,6 +47,21 @@ public class AnalyticsDashboardResponse {
*/ */
private RoiSummary roi; private RoiSummary roi;
/**
* 투자 비용 상세
*/
private InvestmentDetails investment;
/**
* 수익 상세
*/
private RevenueDetails revenue;
/**
* 비용 효율성 분석
*/
private CostEfficiency costEfficiency;
/** /**
* 마지막 업데이트 시간 * 마지막 업데이트 시간
*/ */

View File

@ -33,6 +33,16 @@ public class InvestmentDetails {
*/ */
private BigDecimal operation; private BigDecimal operation;
/**
* 경품 비용 ()
*/
private BigDecimal prizeCost;
/**
* 채널 비용 () - distribution과 동일한
*/
private BigDecimal channelCost;
/** /**
* 투자 비용 () * 투자 비용 ()
*/ */

View File

@ -26,6 +26,16 @@ public class RevenueDetails {
*/ */
private BigDecimal expectedSales; private BigDecimal expectedSales;
/**
* 신규 고객 매출 ()
*/
private BigDecimal newCustomerRevenue;
/**
* 기존 고객 매출 ()
*/
private BigDecimal existingCustomerRevenue;
/** /**
* 브랜드 가치 향상 추정액 () * 브랜드 가치 향상 추정액 ()
*/ */

View File

@ -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++;
}
} }

View File

@ -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;
/** /**
* 참여자 증가 * 참여자 증가
*/ */

View File

@ -0,0 +1,32 @@
package com.kt.event.analytics.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);
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -62,5 +62,10 @@ public class DistributionCompletedEvent {
* 예상 노출 * 예상 노출
*/ */
private Integer expectedViews; private Integer expectedViews;
/**
* 배포 비용 ()
*/
private java.math.BigDecimal distributionCost;
} }
} }

View File

@ -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;
} }

View File

@ -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로 모든 채널 통계 조회
* *

View File

@ -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();
}
} }

View File

@ -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();
} }

View File

@ -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);

View File

@ -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();
} }

View File

@ -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();
} }
} }

View File

@ -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)

View File

@ -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)

View File

@ -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
@ -75,6 +77,10 @@ server:
port: ${SERVER_PORT:8086} port: ${SERVER_PORT:8086}
servlet: servlet:
context-path: /api/v1/analytics context-path: /api/v1/analytics
encoding:
charset: UTF-8
enabled: true
force: true
# JWT # JWT
jwt: jwt:
@ -84,7 +90,11 @@ jwt:
# CORS Configuration # CORS Configuration
cors: cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*} 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-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# Actuator # Actuator
management: management:

View File

@ -40,8 +40,10 @@ public enum ErrorCode {
EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"), EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"), EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"), EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
EVENT_004("EVENT_004", "이벤트 생성에 실패했습니다"), EVENT_004("EVENT_004", "유효하지 않은 eventId 형식입니다"),
EVENT_005("EVENT_005", "이벤트 수정 권한이 없습니다"), EVENT_005("EVENT_005", "이미 존재하는 eventId입니다"),
EVENT_006("EVENT_006", "이벤트 생성에 실패했습니다"),
EVENT_007("EVENT_007", "이벤트 수정 권한이 없습니다"),
// Job 에러 (JOB_XXX) // Job 에러 (JOB_XXX)
JOB_001("JOB_001", "Job을 찾을 수 없습니다"), JOB_001("JOB_001", "Job을 찾을 수 없습니다"),

View File

@ -12,7 +12,6 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* JWT 토큰 생성 검증 제공자 * JWT 토큰 생성 검증 제공자
@ -57,13 +56,13 @@ public class JwtTokenProvider {
* @return Access Token * @return Access Token
*/ */
public String createAccessToken(UUID userId, UUID storeId, String email, String name, List<String> roles) { public String createAccessToken(String userId, String storeId, String email, String name, List<String> roles) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs); Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
return Jwts.builder() return Jwts.builder()
.subject(userId.toString()) .subject(userId)
.claim("storeId", storeId != null ? storeId.toString() : null) .claim("storeId", storeId)
.claim("email", email) .claim("email", email)
.claim("name", name) .claim("name", name)
.claim("roles", roles) .claim("roles", roles)
@ -80,12 +79,12 @@ public class JwtTokenProvider {
* @param userId 사용자 ID * @param userId 사용자 ID
* @return Refresh Token * @return Refresh Token
*/ */
public String createRefreshToken(UUID userId) { public String createRefreshToken(String userId) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs); Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
return Jwts.builder() return Jwts.builder()
.subject(userId.toString()) .subject(userId)
.claim("type", "refresh") .claim("type", "refresh")
.issuedAt(now) .issuedAt(now)
.expiration(expiryDate) .expiration(expiryDate)
@ -99,9 +98,9 @@ public class JwtTokenProvider {
* @param token JWT 토큰 * @param token JWT 토큰
* @return 사용자 ID * @return 사용자 ID
*/ */
public UUID getUserIdFromToken(String token) { public String getUserIdFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
return UUID.fromString(claims.getSubject()); return claims.getSubject();
} }
/** /**
@ -113,9 +112,8 @@ public class JwtTokenProvider {
public UserPrincipal getUserPrincipalFromToken(String token) { public UserPrincipal getUserPrincipalFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
UUID userId = UUID.fromString(claims.getSubject()); String userId = claims.getSubject();
String storeIdStr = claims.get("storeId", String.class); String storeId = claims.get("storeId", String.class);
UUID storeId = storeIdStr != null ? UUID.fromString(storeIdStr) : null;
String email = claims.get("email", String.class); String email = claims.get("email", String.class);
String name = claims.get("name", String.class); String name = claims.get("name", String.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

View File

@ -9,7 +9,6 @@ import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -24,12 +23,12 @@ public class UserPrincipal implements UserDetails {
/** /**
* 사용자 ID * 사용자 ID
*/ */
private final UUID userId; private final String userId;
/** /**
* 매장 ID * 매장 ID
*/ */
private final UUID storeId; private final String storeId;
/** /**
* 사용자 이메일 * 사용자 이메일

View File

@ -46,6 +46,9 @@ public class RegenerateImageService implements RegenerateImageUseCase {
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}") @Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
private String modelVersion; private String modelVersion;
@Value("${replicate.mock.enabled:false}")
private boolean mockEnabled;
public RegenerateImageService( public RegenerateImageService(
ReplicateApiClient replicateClient, ReplicateApiClient replicateClient,
CDNUploader cdnUploader, CDNUploader cdnUploader,
@ -151,6 +154,14 @@ public class RegenerateImageService implements RegenerateImageUseCase {
*/ */
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) { private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
try { try {
// Mock 모드일 경우 Mock 데이터 반환
// if (mockEnabled) {
// log.info("[MOCK] 이미지 재생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
// String mockUrl = generateMockImageUrl(platform);
// log.info("[MOCK] 이미지 재생성 완료: url={}", mockUrl);
// return mockUrl;
// }
int width = platform.getWidth(); int width = platform.getWidth();
int height = platform.getHeight(); int height = platform.getHeight();
@ -274,4 +285,21 @@ public class RegenerateImageService implements RegenerateImageUseCase {
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다", e); throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다", e);
} }
} }
/**
* Mock 이미지 URL 생성 (dev 환경용)
*
* @param platform 플랫폼 (이미지 크기 결정)
* @return Mock 이미지 URL
*/
private String generateMockImageUrl(com.kt.event.content.biz.domain.Platform platform) {
// 플랫폼별 크기에 맞는 placeholder 이미지 URL 생성
int width = platform.getWidth();
int height = platform.getHeight();
// placeholder.com을 사용한 Mock 이미지 URL
String mockId = UUID.randomUUID().toString().substring(0, 8);
return String.format("https://via.placeholder.com/%dx%d/6BCF7F/FFFFFF?text=Regenerated+%s+%s",
width, height, platform.name(), mockId);
}
} }

View File

@ -52,6 +52,9 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}") @Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
private String modelVersion; private String modelVersion;
@Value("${replicate.mock.enabled:false}")
private boolean mockEnabled;
public StableDiffusionImageGenerator( public StableDiffusionImageGenerator(
ReplicateApiClient replicateClient, ReplicateApiClient replicateClient,
CDNUploader cdnUploader, CDNUploader cdnUploader,
@ -188,6 +191,14 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
*/ */
private String generateImage(String prompt, Platform platform) { private String generateImage(String prompt, Platform platform) {
try { try {
// Mock 모드일 경우 Mock 데이터 반환
// if (mockEnabled) {
// log.info("[MOCK] 이미지 생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
// String mockUrl = generateMockImageUrl(platform);
// log.info("[MOCK] 이미지 생성 완료: url={}", mockUrl);
// return mockUrl;
// }
// 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴) // 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
int width = platform.getWidth(); int width = platform.getWidth();
int height = platform.getHeight(); int height = platform.getHeight();
@ -236,6 +247,23 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
} }
} }
/**
* Mock 이미지 URL 생성 (dev 환경용)
*
* @param platform 플랫폼 (이미지 크기 결정)
* @return Mock 이미지 URL
*/
private String generateMockImageUrl(Platform platform) {
// 플랫폼별 크기에 맞는 placeholder 이미지 URL 생성
int width = platform.getWidth();
int height = platform.getHeight();
// placeholder.com을 사용한 Mock 이미지 URL
String mockId = UUID.randomUUID().toString().substring(0, 8);
return String.format("https://via.placeholder.com/%dx%d/FF6B6B/FFFFFF?text=%s+Event+%s",
width, height, platform.name(), mockId);
}
/** /**
* Replicate API 예측 완료 대기 (폴링) * Replicate API 예측 완료 대기 (폴링)
* *

View File

@ -37,10 +37,16 @@ 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:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*} 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-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# Actuator # Actuator
management: management:

View File

@ -1,68 +1,57 @@
# 백엔드 컨테이너 이미지 작성 결과 # 백엔드 컨테이너 이미지 빌드 결과
## 작업 개요 ## 개요
- **작업일시**: 2025-10-29 KT 이벤트 마케팅 서비스의 백엔드 마이크로서비스들에 대한 컨테이너 이미지를 생성하였습니다.
- **작성자**: DevOps Engineer (송근정 "데브옵스 마스터")
- **대상 서비스**: 6개 백엔드 마이크로서비스
## 1. 서비스 확인 ## 작업 일시
- 날짜: 2025-10-29
- 빌드 환경: Windows (MINGW64_NT-10.0-19045)
### settings.gradle 분석 ## 서비스 목록 확인
```gradle
settings.gradle에서 확인한 서비스 목록:
```
rootProject.name = 'kt-event-marketing' rootProject.name = 'kt-event-marketing'
// Common module
include 'common' include 'common'
// Microservices
include 'user-service' include 'user-service'
include 'event-service' include 'event-service'
include 'ai-service' include 'ai-service'
include 'content-service'
include 'distribution-service' include 'distribution-service'
include 'participation-service' include 'participation-service'
include 'analytics-service' include 'analytics-service'
``` ```
### 빌드 가능한 서비스 (6개) **빌드 대상 서비스 (6개):**
Main Application 클래스가 존재하는 서비스: - user-service (Java/Spring Boot)
1. **user-service** - `UserServiceApplication.java` - event-service (Java/Spring Boot)
2. **event-service** - `EventServiceApplication.java` - ai-service (Java/Spring Boot)
3. **ai-service** - `AiServiceApplication.java` - distribution-service (Java/Spring Boot)
4. **content-service** - `ContentApplication.java` - participation-service (Java/Spring Boot)
5. **participation-service** - `ParticipationServiceApplication.java` - analytics-service (Java/Spring Boot)
6. **analytics-service** - `AnalyticsServiceApplication.java`
### 제외된 서비스 **제외 대상:**
- **distribution-service**: 소스 코드 미구현 상태 (src/main/java 디렉토리 없음) - common: 공통 라이브러리 모듈 (독립 실행 서비스 아님)
- content-service: Python 기반 서비스 (별도 빌드 필요)
## 2. bootJar 설정 ## bootJar 설정 확인
각 서비스의 `build.gradle`에 bootJar 설정 추가/수정: 모든 Java 서비스의 build.gradle에 bootJar 설정이 올바르게 구성되어 있음을 확인:
### 설정 추가된 서비스 (5개) | 서비스명 | JAR 파일명 | 경로 |
```gradle |---------|-----------|------|
bootJar { | user-service | user-service.jar | user-service/build/libs/user-service.jar |
archiveFileName = '{service-name}.jar' | event-service | event-service.jar | event-service/build/libs/event-service.jar |
} | ai-service | ai-service.jar | ai-service/build/libs/ai-service.jar |
``` | distribution-service | distribution-service.jar | distribution-service/build/libs/distribution-service.jar |
| participation-service | participation-service.jar | participation-service/build/libs/participation-service.jar |
| analytics-service | analytics-service.jar | analytics-service/build/libs/analytics-service.jar |
- user-service/build.gradle ## Dockerfile 생성
- ai-service/build.gradle
- distribution-service/build.gradle (향후 구현 대비)
- participation-service/build.gradle
- analytics-service/build.gradle
### 기존 설정 확인된 서비스 (2개) **파일 위치:** `deployment/container/Dockerfile-backend`
- event-service/build.gradle ✅
- content-service/build.gradle ✅
## 3. Dockerfile 생성 **Dockerfile 구성:**
### 파일 경로
`deployment/container/Dockerfile-backend`
### Dockerfile 내용
```dockerfile ```dockerfile
# Build stage # Build stage
FROM openjdk:23-oraclelinux8 AS builder FROM openjdk:23-oraclelinux8 AS builder
@ -91,58 +80,34 @@ ENTRYPOINT [ "sh", "-c" ]
CMD ["java ${JAVA_OPTS} -jar app.jar"] CMD ["java ${JAVA_OPTS} -jar app.jar"]
``` ```
### Dockerfile 특징 **주요 특징:**
- **Multi-stage build**: 빌드와 실행 스테이지 분리 - Multi-stage 빌드: 빌드 이미지와 런타임 이미지 분리
- **Non-root user**: 보안을 위한 k8s 사용자 실행 - Base Image: openjdk:23-slim (경량화)
- **플랫폼**: linux/amd64 (K8s 클러스터 호환) - 보안: 비root 사용자(k8s)로 실행
- **Java 버전**: OpenJDK 23 - 플랫폼: linux/amd64
## 4. JAR 파일 빌드 ## Gradle 빌드 실행
### 빌드 명령어 **실행 명령:**
```bash ```bash
./gradlew user-service:bootJar ai-service:bootJar event-service:bootJar \ ./gradlew clean build -x test
content-service:bootJar participation-service:bootJar analytics-service:bootJar
``` ```
### 빌드 결과 **빌드 결과:**
``` - 상태: ✅ BUILD SUCCESSFUL
BUILD SUCCESSFUL in 27s - 소요 시간: 33초
33 actionable tasks: 15 executed, 18 up-to-date - 실행된 태스크: 56개
```
### 생성된 JAR 파일 ## 컨테이너 이미지 빌드
```bash
$ ls -lh */build/libs/*.jar
-rw-r--r-- 1 KTDS 197121 94M 10월 29 09:49 ai-service/build/libs/ai-service.jar ### 병렬 빌드 전략
-rw-r--r-- 1 KTDS 197121 95M 10월 29 09:48 analytics-service/build/libs/analytics-service.jar 서브 에이전트를 활용하여 6개 서비스를 동시에 빌드하여 시간 단축
-rw-r--r-- 1 KTDS 197121 78M 10월 29 09:49 content-service/build/libs/content-service.jar
-rw-r--r-- 1 KTDS 197121 94M 10월 29 09:49 event-service/build/libs/event-service.jar
-rw-r--r-- 1 KTDS 197121 85M 10월 29 09:49 participation-service/build/libs/participation-service.jar
-rw-r--r-- 1 KTDS 197121 96M 10월 29 09:49 user-service/build/libs/user-service.jar
```
## 5. Docker 이미지 빌드 ### 1. user-service
### 사전 준비사항 **빌드 명령:**
⚠️ **Docker Desktop이 실행 중이어야 합니다**
Docker Desktop 시작 확인:
```bash
# Docker 상태 확인
docker version
docker ps
# Docker Desktop이 정상 실행되면 위 명령들이 정상 동작합니다
```
### 빌드 명령어
#### 5.1 user-service
```bash ```bash
DOCKER_FILE=deployment/container/Dockerfile-backend DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \ docker build \
--platform linux/amd64 \ --platform linux/amd64 \
--build-arg BUILD_LIB_DIR="user-service/build/libs" \ --build-arg BUILD_LIB_DIR="user-service/build/libs" \
@ -151,22 +116,17 @@ docker build \
-t user-service:latest . -t user-service:latest .
``` ```
#### 5.2 ai-service **결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: fb07547604be
- 이미지 크기: 1.09GB
- Image SHA: sha256:fb07547604bee7e8ff69e56e8423299b7dec277e80d865ee5013ddd876a0b4c6
### 2. event-service
**빌드 명령:**
```bash ```bash
DOCKER_FILE=deployment/container/Dockerfile-backend DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="ai-service/build/libs" \
--build-arg ARTIFACTORY_FILE="ai-service.jar" \
-f ${DOCKER_FILE} \
-t ai-service:latest .
```
#### 5.3 event-service
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \ docker build \
--platform linux/amd64 \ --platform linux/amd64 \
--build-arg BUILD_LIB_DIR="event-service/build/libs" \ --build-arg BUILD_LIB_DIR="event-service/build/libs" \
@ -175,22 +135,56 @@ docker build \
-t event-service:latest . -t event-service:latest .
``` ```
#### 5.4 content-service **결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: 191a9882a628
- 이미지 크기: 1.08GB
- 빌드 시간: ~20초
### 3. ai-service
**빌드 명령:**
```bash ```bash
DOCKER_FILE=deployment/container/Dockerfile-backend DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \ docker build \
--platform linux/amd64 \ --platform linux/amd64 \
--build-arg BUILD_LIB_DIR="content-service/build/libs" \ --build-arg BUILD_LIB_DIR="ai-service/build/libs" \
--build-arg ARTIFACTORY_FILE="content-service.jar" \ --build-arg ARTIFACTORY_FILE="ai-service.jar" \
-f ${DOCKER_FILE} \ -f ${DOCKER_FILE} \
-t content-service:latest . -t ai-service:latest .
``` ```
#### 5.5 participation-service **결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: 498feb888dc5
- 이미지 크기: 1.08GB
- Image SHA: sha256:498feb888dc58a98715841c4e50f191bc8434eccd12baefa79e82b0e44a5bc40
### 4. distribution-service
**빌드 명령:**
```bash ```bash
DOCKER_FILE=deployment/container/Dockerfile-backend DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="distribution-service/build/libs" \
--build-arg ARTIFACTORY_FILE="distribution-service.jar" \
-f ${DOCKER_FILE} \
-t distribution-service:latest .
```
**결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: e0ad31c51b63
- 이미지 크기: 1.08GB
- Image SHA: sha256:e0ad31c51b63b44d67f017cca8a729ae9cbb5e9e9503feddb308c09f19b70fba
- 빌드 시간: ~60초
### 5. participation-service
**빌드 명령:**
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \ docker build \
--platform linux/amd64 \ --platform linux/amd64 \
--build-arg BUILD_LIB_DIR="participation-service/build/libs" \ --build-arg BUILD_LIB_DIR="participation-service/build/libs" \
@ -199,10 +193,18 @@ docker build \
-t participation-service:latest . -t participation-service:latest .
``` ```
#### 5.6 analytics-service **결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: 9bd60358659b
- 이미지 크기: 1.04GB
- Image SHA: sha256:9bd60358659b528190edcab699152b5126dc906070e05d355310303ac292f02b
- 빌드 시간: ~37초
### 6. analytics-service
**빌드 명령:**
```bash ```bash
DOCKER_FILE=deployment/container/Dockerfile-backend DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \ docker build \
--platform linux/amd64 \ --platform linux/amd64 \
--build-arg BUILD_LIB_DIR="analytics-service/build/libs" \ --build-arg BUILD_LIB_DIR="analytics-service/build/libs" \
@ -211,186 +213,55 @@ docker build \
-t analytics-service:latest . -t analytics-service:latest .
``` ```
### 빌드 스크립트 (일괄 실행) **결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: 33b53299ec16
- 이미지 크기: 1.08GB
- Image SHA: sha256:33b53299ec16e0021a9adca4fb32535708021073df03c30b8a0ea335348547de
## 생성된 이미지 확인
**확인 명령:**
```bash ```bash
#!/bin/bash docker images | grep -E "(user-service|event-service|ai-service|distribution-service|participation-service|analytics-service)" | grep latest
# build-all-images.sh
DOCKER_FILE=deployment/container/Dockerfile-backend
services=(
"user-service"
"ai-service"
"event-service"
"content-service"
"participation-service"
"analytics-service"
)
for service in "${services[@]}"; do
echo "Building ${service}..."
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="${service}/build/libs" \
--build-arg ARTIFACTORY_FILE="${service}.jar" \
-f ${DOCKER_FILE} \
-t ${service}:latest .
if [ $? -eq 0 ]; then
echo "✅ ${service} build successful"
else
echo "❌ ${service} build failed"
exit 1
fi
done
echo "🎉 All images built successfully!"
``` ```
## 6. 이미지 확인 **확인 결과:**
```
### 생성된 이미지 확인 명령어 event-service latest 191a9882a628 39 seconds ago 1.08GB
```bash ai-service latest 498feb888dc5 46 seconds ago 1.08GB
# 모든 서비스 이미지 확인 analytics-service latest 33b53299ec16 46 seconds ago 1.08GB
docker images | grep -E "(user-service|ai-service|event-service|content-service|participation-service|analytics-service)" user-service latest fb07547604be 47 seconds ago 1.09GB
participation-service latest 9bd60358659b 48 seconds ago 1.04GB
# 개별 서비스 확인 distribution-service latest e0ad31c51b63 48 seconds ago 1.08GB
docker images user-service:latest
docker images ai-service:latest
docker images event-service:latest
docker images content-service:latest
docker images participation-service:latest
docker images analytics-service:latest
``` ```
### 빌드 결과 ✅ ## 빌드 결과 요약
```
REPOSITORY TAG IMAGE ID CREATED SIZE
user-service latest 91c511ef86bd About a minute ago 1.09GB
ai-service latest 9477022fa493 About a minute ago 1.08GB
event-service latest add81de69536 About a minute ago 1.08GB
content-service latest aa9cc16ad041 About a minute ago 1.01GB
participation-service latest 9b044a3854dd About a minute ago 1.04GB
analytics-service latest ac569de42545 About a minute ago 1.08GB
```
**빌드 일시**: 2025-10-29 09:50 KST | 서비스명 | 이미지 태그 | 이미지 ID | 크기 | 상태 |
**빌드 소요 시간**: 약 13초 (병렬 빌드) |---------|-----------|----------|------|------|
**총 이미지 크기**: 6.48GB | user-service | user-service:latest | fb07547604be | 1.09GB | ✅ |
| event-service | event-service:latest | 191a9882a628 | 1.08GB | ✅ |
| ai-service | ai-service:latest | 498feb888dc5 | 1.08GB | ✅ |
| distribution-service | distribution-service:latest | e0ad31c51b63 | 1.08GB | ✅ |
| participation-service | participation-service:latest | 9bd60358659b | 1.04GB | ✅ |
| analytics-service | analytics-service:latest | 33b53299ec16 | 1.08GB | ✅ |
## 7. 이미지 테스트 **총 6개 서비스 이미지 빌드 성공**
### 로컬 실행 테스트 (예시: user-service) ## 다음 단계
```bash
# 컨테이너 실행
docker run -d \
--name user-service-test \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=dev \
user-service:latest
# 로그 확인 생성된 이미지를 사용하여 다음 작업을 진행할 수 있습니다:
docker logs -f user-service-test
# 헬스체크 1. **로컬 테스트:** Docker Compose 또는 개별 컨테이너 실행
curl http://localhost:8080/actuator/health 2. **ACR 푸시:** Azure Container Registry에 이미지 업로드
3. **AKS 배포:** Kubernetes 클러스터에 배포
4. **CI/CD 통합:** GitHub Actions 또는 Jenkins 파이프라인 연동
# 정리 ## 참고사항
docker stop user-service-test
docker rm user-service-test
```
## 8. 다음 단계 - 모든 이미지는 linux/amd64 플랫폼용으로 빌드됨
- 보안을 위해 비root 사용자(k8s)로 실행 구성
### 8.1 컨테이너 레지스트리 푸시 - Multi-stage 빌드로 이미지 크기 최적화
```bash - Java 23 (OpenJDK) 기반 런타임 사용
# Docker Hub 예시 - content-service(Python)는 별도의 Dockerfile로 빌드 필요
docker tag user-service:latest <your-registry>/user-service:latest
docker push <your-registry>/user-service:latest
# Azure Container Registry 예시
docker tag user-service:latest <acr-name>.azurecr.io/user-service:latest
docker push <acr-name>.azurecr.io/user-service:latest
```
### 8.2 Kubernetes 배포
- Kubernetes Deployment 매니페스트 작성
- Service 리소스 정의
- ConfigMap/Secret 설정
- Ingress 구성
### 8.3 CI/CD 파이프라인 구성
- GitHub Actions 또는 Jenkins 파이프라인 작성
- 자동 빌드 및 배포 설정
- 이미지 태깅 전략 수립 (semantic versioning)
## 9. 트러블슈팅
### Issue 1: Docker Desktop 미실행
**증상**:
```
ERROR: error during connect: open //./pipe/dockerDesktopLinuxEngine:
The system cannot find the file specified.
```
**해결**:
1. Docker Desktop 애플리케이션 시작
2. 시스템 트레이의 Docker 아이콘이 안정화될 때까지 대기
3. `docker ps` 명령으로 정상 동작 확인
### Issue 2: JAR 파일 없음
**증상**:
```
COPY failed: file not found in build context
```
**해결**:
```bash
# JAR 파일 재빌드
./gradlew {service-name}:clean {service-name}:bootJar
# 생성 확인
ls -l {service-name}/build/libs/{service-name}.jar
```
### Issue 3: 플랫폼 불일치
**증상**: K8s 클러스터에서 실행 안됨
**해결**: `--platform linux/amd64` 옵션 사용 (이미 적용됨)
## 10. 요약
### ✅ 완료된 작업
1. ✅ 6개 서비스의 bootJar 설정 완료
2. ✅ Dockerfile-backend 생성 완료
3. ✅ 6개 서비스 JAR 파일 빌드 완료 (총 542MB)
4. ✅ 6개 서비스 Docker 이미지 빌드 완료 (총 6.48GB)
### 📊 최종 서비스 현황
| 서비스 | JAR 빌드 | Docker 이미지 | 이미지 크기 | Image ID | 상태 |
|--------|---------|--------------|-----------|----------|------|
| user-service | ✅ 96MB | ✅ | 1.09GB | 91c511ef86bd | ✅ Ready |
| ai-service | ✅ 94MB | ✅ | 1.08GB | 9477022fa493 | ✅ Ready |
| event-service | ✅ 94MB | ✅ | 1.08GB | add81de69536 | ✅ Ready |
| content-service | ✅ 78MB | ✅ | 1.01GB | aa9cc16ad041 | ✅ Ready |
| participation-service | ✅ 85MB | ✅ | 1.04GB | 9b044a3854dd | ✅ Ready |
| analytics-service | ✅ 95MB | ✅ | 1.08GB | ac569de42545 | ✅ Ready |
| distribution-service | ❌ | ❌ | - | - | 소스 미구현 |
### 🎯 빌드 성능 메트릭
- **JAR 빌드 시간**: 27초
- **Docker 이미지 빌드**: 병렬 실행으로 약 13초
- **총 소요 시간**: 약 40초
- **빌드 성공률**: 100% (6/6 서비스)
### 🚀 다음 단계 권장사항
1. **컨테이너 레지스트리 푸시** (예: Azure ACR, Docker Hub)
2. **Kubernetes 배포 매니페스트 작성**
3. **CI/CD 파이프라인 구성** (GitHub Actions 또는 Jenkins)
4. **모니터링 및 로깅 설정**
---
**작성일**: 2025-10-29 09:50 KST
**작성자**: DevOps Engineer (송근정 "데브옵스 마스터")
**빌드 완료**: ✅ 모든 서비스 이미지 빌드 성공

View File

@ -20,7 +20,7 @@ data:
EXCLUDE_REDIS: "" EXCLUDE_REDIS: ""
# CORS Configuration # CORS Configuration
CORS_ALLOWED_ORIGINS: "http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io" CORS_ALLOWED_ORIGINS: "http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io,http://kt-event-marketing-api.20.214.196.128.nip.io,http://*.20.214.196.128.nip.io,https://kt-event-marketing.20.214.196.128.nip.io,https://kt-event-marketing-api.20.214.196.128.nip.io,https://*.20.214.196.128.nip.io"
CORS_ALLOWED_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH" CORS_ALLOWED_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH"
CORS_ALLOWED_HEADERS: "*" CORS_ALLOWED_HEADERS: "*"
CORS_ALLOW_CREDENTIALS: "true" CORS_ALLOW_CREDENTIALS: "true"

View File

@ -42,21 +42,21 @@ spec:
memory: "1024Mi" memory: "1024Mi"
startupProbe: startupProbe:
httpGet: httpGet:
path: /distribution/actuator/health path: /api/v1/distribution/actuator/health
port: 8085 port: 8085
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
failureThreshold: 30 failureThreshold: 30
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /distribution/actuator/health/readiness path: /api/v1/distribution/actuator/health/readiness
port: 8085 port: 8085
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 5 periodSeconds: 5
failureThreshold: 3 failureThreshold: 3
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /distribution/actuator/health/liveness path: /api/v1/distribution/actuator/health/liveness
port: 8085 port: 8085
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10

View File

@ -0,0 +1,234 @@
-- ====================================================================================================
-- Event ID 타입 변경 DDL (UUID → VARCHAR(50)) - PostgreSQL
-- ====================================================================================================
-- 작성일: 2025-10-29
-- 작성자: Backend Development Team
-- 설명: Event 엔티티의 eventId가 String 타입으로 변경됨에 따라 관련 테이블들의 event_id 컬럼 타입을 UUID에서 VARCHAR(50)으로 변경합니다.
-- 영향 범위:
-- - events 테이블 (Primary Key)
-- - event_channels 테이블 (Foreign Key)
-- - generated_images 테이블 (Foreign Key)
-- - ai_recommendations 테이블 (Foreign Key)
-- - jobs 테이블 (Foreign Key)
-- ====================================================================================================
-- 0. 현재 상태 확인 (실행 전 확인용)
-- ====================================================================================================
-- 각 테이블의 event_id 컬럼 타입 확인
-- SELECT table_name, column_name, data_type
-- FROM information_schema.columns
-- WHERE column_name = 'event_id'
-- AND table_schema = 'public'
-- ORDER BY table_name;
-- event_id 관련 모든 외래키 제약조건 확인
-- SELECT
-- tc.constraint_name,
-- tc.table_name,
-- kcu.column_name,
-- ccu.table_name AS foreign_table_name,
-- ccu.column_name AS foreign_column_name
-- FROM information_schema.table_constraints AS tc
-- JOIN information_schema.key_column_usage AS kcu
-- ON tc.constraint_name = kcu.constraint_name
-- AND tc.table_schema = kcu.table_schema
-- JOIN information_schema.constraint_column_usage AS ccu
-- ON ccu.constraint_name = tc.constraint_name
-- AND ccu.table_schema = tc.table_schema
-- WHERE tc.constraint_type = 'FOREIGN KEY'
-- AND kcu.column_name = 'event_id'
-- AND tc.table_schema = 'public';
-- 1. 외래키 제약조건 전체 제거
-- ====================================================================================================
-- JPA가 자동 생성한 제약조건 이름도 포함하여 모두 제거
-- event_channels 테이블의 모든 event_id 관련 외래키 제거
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = 'event_channels'
AND kcu.column_name = 'event_id'
AND tc.table_schema = 'public'
LOOP
EXECUTE 'ALTER TABLE event_channels DROP CONSTRAINT IF EXISTS ' || constraint_name;
END LOOP;
END $$;
-- generated_images 테이블의 모든 event_id 관련 외래키 제거
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = 'generated_images'
AND kcu.column_name = 'event_id'
AND tc.table_schema = 'public'
LOOP
EXECUTE 'ALTER TABLE generated_images DROP CONSTRAINT IF EXISTS ' || constraint_name;
END LOOP;
END $$;
-- ai_recommendations 테이블의 모든 event_id 관련 외래키 제거
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = 'ai_recommendations'
AND kcu.column_name = 'event_id'
AND tc.table_schema = 'public'
LOOP
EXECUTE 'ALTER TABLE ai_recommendations DROP CONSTRAINT IF EXISTS ' || constraint_name;
END LOOP;
END $$;
-- jobs 테이블의 모든 event_id 관련 외래키 제거
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = 'jobs'
AND kcu.column_name = 'event_id'
AND tc.table_schema = 'public'
LOOP
EXECUTE 'ALTER TABLE jobs DROP CONSTRAINT IF EXISTS ' || constraint_name;
END LOOP;
END $$;
-- 2. 컬럼 타입 변경 (UUID/기타 → VARCHAR)
-- ====================================================================================================
-- 현재 타입에 관계없이 VARCHAR(50)으로 변환
-- UUID, BIGINT 등 모든 타입을 텍스트로 변환
-- events 테이블의 event_id 컬럼 타입 변경 (Primary Key)
DO $$
BEGIN
ALTER TABLE events ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'events.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- event_channels 테이블의 event_id 컬럼 타입 변경
DO $$
BEGIN
ALTER TABLE event_channels ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'event_channels.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- generated_images 테이블의 event_id 컬럼 타입 변경
DO $$
BEGIN
ALTER TABLE generated_images ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'generated_images.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- ai_recommendations 테이블의 event_id 컬럼 타입 변경
DO $$
BEGIN
ALTER TABLE ai_recommendations ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'ai_recommendations.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- jobs 테이블의 event_id 컬럼 타입 변경 (NULL 허용)
DO $$
BEGIN
ALTER TABLE jobs ALTER COLUMN event_id TYPE VARCHAR(50) USING event_id::text;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'jobs.event_id 변환 중 오류: %', SQLERRM;
END $$;
-- 3. 외래키 제약조건 재생성
-- ====================================================================================================
-- event_channels 테이블의 외래키 재생성
ALTER TABLE event_channels
ADD CONSTRAINT fk_event_channels_event
FOREIGN KEY (event_id) REFERENCES events(event_id)
ON DELETE CASCADE;
-- generated_images 테이블의 외래키 재생성
ALTER TABLE generated_images
ADD CONSTRAINT fk_generated_images_event
FOREIGN KEY (event_id) REFERENCES events(event_id)
ON DELETE CASCADE;
-- ai_recommendations 테이블의 외래키 재생성
ALTER TABLE ai_recommendations
ADD CONSTRAINT fk_ai_recommendations_event
FOREIGN KEY (event_id) REFERENCES events(event_id)
ON DELETE CASCADE;
-- jobs 테이블의 외래키 재생성
ALTER TABLE jobs
ADD CONSTRAINT fk_jobs_event
FOREIGN KEY (event_id) REFERENCES events(event_id)
ON DELETE SET NULL;
-- 4. 인덱스 확인 (옵션)
-- ====================================================================================================
-- 기존 인덱스들이 자동으로 유지되는지 확인
-- \d events
-- \d event_channels
-- \d generated_images
-- \d ai_recommendations
-- \d jobs
-- ====================================================================================================
-- 롤백 스크립트 (필요시 사용)
-- ====================================================================================================
/*
-- 1. 외래키 제약조건 제거
ALTER TABLE event_channels DROP CONSTRAINT IF EXISTS fk_event_channels_event;
ALTER TABLE generated_images DROP CONSTRAINT IF EXISTS fk_generated_images_event;
ALTER TABLE ai_recommendations DROP CONSTRAINT IF EXISTS fk_ai_recommendations_event;
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS fk_jobs_event;
-- 2. 컬럼 타입 원복 (VARCHAR → UUID)
ALTER TABLE events ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
ALTER TABLE event_channels ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
ALTER TABLE generated_images ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
ALTER TABLE ai_recommendations ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
ALTER TABLE jobs ALTER COLUMN event_id TYPE UUID USING event_id::UUID;
-- 4. 외래키 제약조건 재생성
ALTER TABLE event_channels ADD CONSTRAINT fk_event_channels_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
ALTER TABLE generated_images ADD CONSTRAINT fk_generated_images_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
ALTER TABLE ai_recommendations ADD CONSTRAINT fk_ai_recommendations_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE;
ALTER TABLE jobs ADD CONSTRAINT fk_jobs_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE SET NULL;
*/

View File

@ -0,0 +1,233 @@
-- ====================================================================================================
-- Event Service 테이블 생성 스크립트 - PostgreSQL
-- ====================================================================================================
-- 작성일: 2025-10-29
-- 작성자: Backend Development Team
-- 설명: Event 서비스의 모든 테이블을 생성합니다.
-- 참고: FK(Foreign Key) 제약조건은 제외되어 있습니다.
-- ====================================================================================================
-- ====================================================================================================
-- 1. events 테이블 - 이벤트 메인 테이블
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS events (
event_id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
store_id VARCHAR(50) NOT NULL,
event_name VARCHAR(200),
description TEXT,
objective VARCHAR(100) NOT NULL,
start_date DATE,
end_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
selected_image_id VARCHAR(50),
selected_image_url VARCHAR(500),
participants INTEGER DEFAULT 0,
target_participants INTEGER,
roi DOUBLE PRECISION DEFAULT 0.0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- events 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
CREATE INDEX IF NOT EXISTS idx_events_store_id ON events(store_id);
CREATE INDEX IF NOT EXISTS idx_events_status ON events(status);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
COMMENT ON TABLE events IS '이벤트 메인 테이블';
COMMENT ON COLUMN events.event_id IS '이벤트 ID (Primary Key)';
COMMENT ON COLUMN events.user_id IS '사용자 ID';
COMMENT ON COLUMN events.store_id IS '상점 ID';
COMMENT ON COLUMN events.event_name IS '이벤트명';
COMMENT ON COLUMN events.description IS '이벤트 설명';
COMMENT ON COLUMN events.objective IS '이벤트 목적';
COMMENT ON COLUMN events.start_date IS '이벤트 시작일';
COMMENT ON COLUMN events.end_date IS '이벤트 종료일';
COMMENT ON COLUMN events.status IS '이벤트 상태 (DRAFT, PUBLISHED, ENDED)';
COMMENT ON COLUMN events.selected_image_id IS '선택된 이미지 ID';
COMMENT ON COLUMN events.selected_image_url IS '선택된 이미지 URL';
COMMENT ON COLUMN events.participants IS '참여자 수';
COMMENT ON COLUMN events.target_participants IS '목표 참여자 수';
COMMENT ON COLUMN events.roi IS 'ROI (투자 대비 수익률)';
COMMENT ON COLUMN events.created_at IS '생성일시';
COMMENT ON COLUMN events.updated_at IS '수정일시';
-- ====================================================================================================
-- 2. event_channels 테이블 - 이벤트 배포 채널 (ElementCollection)
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS event_channels (
event_id VARCHAR(50) NOT NULL,
channel VARCHAR(50)
);
-- event_channels 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_event_channels_event_id ON event_channels(event_id);
COMMENT ON TABLE event_channels IS '이벤트 배포 채널 테이블';
COMMENT ON COLUMN event_channels.event_id IS '이벤트 ID';
COMMENT ON COLUMN event_channels.channel IS '배포 채널명';
-- ====================================================================================================
-- 3. generated_images 테이블 - 생성된 이미지
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS generated_images (
image_id VARCHAR(50) PRIMARY KEY,
event_id VARCHAR(50) NOT NULL,
image_url VARCHAR(500) NOT NULL,
style VARCHAR(50),
platform VARCHAR(50),
is_selected BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- generated_images 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_generated_images_event_id ON generated_images(event_id);
CREATE INDEX IF NOT EXISTS idx_generated_images_is_selected ON generated_images(is_selected);
COMMENT ON TABLE generated_images IS 'AI가 생성한 이미지 테이블';
COMMENT ON COLUMN generated_images.image_id IS '이미지 ID (Primary Key)';
COMMENT ON COLUMN generated_images.event_id IS '이벤트 ID';
COMMENT ON COLUMN generated_images.image_url IS '이미지 URL';
COMMENT ON COLUMN generated_images.style IS '이미지 스타일';
COMMENT ON COLUMN generated_images.platform IS '타겟 플랫폼';
COMMENT ON COLUMN generated_images.is_selected IS '선택 여부';
COMMENT ON COLUMN generated_images.created_at IS '생성일시';
COMMENT ON COLUMN generated_images.updated_at IS '수정일시';
-- ====================================================================================================
-- 4. ai_recommendations 테이블 - AI 추천 기획안
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS ai_recommendations (
recommendation_id VARCHAR(50) PRIMARY KEY,
event_id VARCHAR(50) NOT NULL,
event_name VARCHAR(200) NOT NULL,
description TEXT,
promotion_type VARCHAR(50),
target_audience VARCHAR(100),
is_selected BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ai_recommendations 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_ai_recommendations_event_id ON ai_recommendations(event_id);
CREATE INDEX IF NOT EXISTS idx_ai_recommendations_is_selected ON ai_recommendations(is_selected);
COMMENT ON TABLE ai_recommendations IS 'AI 추천 이벤트 기획안 테이블';
COMMENT ON COLUMN ai_recommendations.recommendation_id IS '추천 ID (Primary Key)';
COMMENT ON COLUMN ai_recommendations.event_id IS '이벤트 ID';
COMMENT ON COLUMN ai_recommendations.event_name IS '추천 이벤트명';
COMMENT ON COLUMN ai_recommendations.description IS '추천 설명';
COMMENT ON COLUMN ai_recommendations.promotion_type IS '프로모션 유형';
COMMENT ON COLUMN ai_recommendations.target_audience IS '타겟 고객층';
COMMENT ON COLUMN ai_recommendations.is_selected IS '선택 여부';
COMMENT ON COLUMN ai_recommendations.created_at IS '생성일시';
COMMENT ON COLUMN ai_recommendations.updated_at IS '수정일시';
-- ====================================================================================================
-- 5. jobs 테이블 - 비동기 작업 관리
-- ====================================================================================================
CREATE TABLE IF NOT EXISTS jobs (
job_id VARCHAR(50) PRIMARY KEY,
event_id VARCHAR(50) NOT NULL,
job_type VARCHAR(30) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
progress INTEGER NOT NULL DEFAULT 0,
result_key VARCHAR(200),
error_message VARCHAR(500),
completed_at TIMESTAMP,
retry_count INTEGER NOT NULL DEFAULT 0,
max_retry_count INTEGER NOT NULL DEFAULT 3,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- jobs 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_jobs_event_id ON jobs(event_id);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_job_type ON jobs(job_type);
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at);
COMMENT ON TABLE jobs IS '비동기 작업 관리 테이블';
COMMENT ON COLUMN jobs.job_id IS '작업 ID (Primary Key)';
COMMENT ON COLUMN jobs.event_id IS '이벤트 ID';
COMMENT ON COLUMN jobs.job_type IS '작업 유형 (AI_RECOMMENDATION, IMAGE_GENERATION)';
COMMENT ON COLUMN jobs.status IS '작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)';
COMMENT ON COLUMN jobs.progress IS '진행률 (0-100)';
COMMENT ON COLUMN jobs.result_key IS '결과 키';
COMMENT ON COLUMN jobs.error_message IS '에러 메시지';
COMMENT ON COLUMN jobs.completed_at IS '완료일시';
COMMENT ON COLUMN jobs.retry_count IS '재시도 횟수';
COMMENT ON COLUMN jobs.max_retry_count IS '최대 재시도 횟수';
COMMENT ON COLUMN jobs.created_at IS '생성일시';
COMMENT ON COLUMN jobs.updated_at IS '수정일시';
-- ====================================================================================================
-- 6. updated_at 자동 업데이트를 위한 트리거 함수 생성
-- ====================================================================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ====================================================================================================
-- 7. 각 테이블에 updated_at 자동 업데이트 트리거 적용
-- ====================================================================================================
-- events 테이블 트리거
DROP TRIGGER IF EXISTS update_events_updated_at ON events;
CREATE TRIGGER update_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- generated_images 테이블 트리거
DROP TRIGGER IF EXISTS update_generated_images_updated_at ON generated_images;
CREATE TRIGGER update_generated_images_updated_at
BEFORE UPDATE ON generated_images
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ai_recommendations 테이블 트리거
DROP TRIGGER IF EXISTS update_ai_recommendations_updated_at ON ai_recommendations;
CREATE TRIGGER update_ai_recommendations_updated_at
BEFORE UPDATE ON ai_recommendations
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- jobs 테이블 트리거
DROP TRIGGER IF EXISTS update_jobs_updated_at ON jobs;
CREATE TRIGGER update_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ====================================================================================================
-- 완료 메시지
-- ====================================================================================================
DO $$
BEGIN
RAISE NOTICE '=================================================';
RAISE NOTICE 'Event Service 테이블 생성이 완료되었습니다.';
RAISE NOTICE '=================================================';
RAISE NOTICE '생성된 테이블:';
RAISE NOTICE ' 1. events - 이벤트 메인 테이블';
RAISE NOTICE ' 2. event_channels - 이벤트 배포 채널';
RAISE NOTICE ' 3. generated_images - 생성된 이미지';
RAISE NOTICE ' 4. ai_recommendations - AI 추천 기획안';
RAISE NOTICE ' 5. jobs - 비동기 작업 관리';
RAISE NOTICE '=================================================';
RAISE NOTICE '참고: FK 제약조건은 생성되지 않았습니다.';
RAISE NOTICE '=================================================';
END $$;

View File

@ -0,0 +1,390 @@
# AI Service 전체 메서드 워크플로우
## 1. AI 추천 생성 워크플로우 (Kafka 기반 비동기)
```mermaid
sequenceDiagram
participant ES as Event Service
participant Kafka as Kafka Topic
participant Consumer as AIJobConsumer
participant ARS as AIRecommendationService
participant JSS as JobStatusService
participant TAS as TrendAnalysisService
participant CS as CacheService
participant CAC as ClaudeApiClient
participant Claude as Claude API
participant Redis as Redis
%% 1. Event Service가 Kafka 메시지 발행
ES->>Kafka: Publish AIJobMessage<br/>(ai-event-generation-job topic)
%% 2. Kafka Consumer가 메시지 수신
Kafka->>Consumer: consume(AIJobMessage)
Note over Consumer: @KafkaListener<br/>groupId: ai-service-consumers
%% 3. AI 추천 생성 시작
Consumer->>ARS: generateRecommendations(message)
activate ARS
%% 4. Job 상태: PROCESSING (10%)
ARS->>JSS: updateJobStatus(jobId, PROCESSING, "트렌드 분석 중")
JSS->>CS: saveJobStatus(jobId, status)
CS->>Redis: SET ai:job:status:{jobId}
%% 5. 트렌드 분석
ARS->>ARS: analyzeTrend(message)
ARS->>CS: getTrend(industry, region)
CS->>Redis: GET ai:trend:{industry}:{region}
alt 캐시 HIT
Redis-->>CS: TrendAnalysis (cached)
CS-->>ARS: TrendAnalysis
else 캐시 MISS
ARS->>TAS: analyzeTrend(industry, region)
activate TAS
%% Circuit Breaker 적용
TAS->>TAS: circuitBreakerManager.executeWithCircuitBreaker()
TAS->>CAC: sendMessage(apiKey, version, request)
CAC->>Claude: POST /v1/messages
Note over Claude: Model: claude-sonnet-4-5<br/>System: "트렌드 분석 전문가"<br/>Prompt: 업종/지역/계절 트렌드
Claude-->>CAC: ClaudeResponse
CAC-->>TAS: ClaudeResponse
TAS->>TAS: parseResponse(responseText)
TAS-->>ARS: TrendAnalysis
deactivate TAS
%% 트렌드 캐싱
ARS->>CS: saveTrend(industry, region, analysis)
CS->>Redis: SET ai:trend:{industry}:{region} (TTL: 1시간)
end
%% 6. Job 상태: PROCESSING (50%)
ARS->>JSS: updateJobStatus(jobId, PROCESSING, "이벤트 추천안 생성 중")
JSS->>CS: saveJobStatus(jobId, status)
CS->>Redis: SET ai:job:status:{jobId}
%% 7. 이벤트 추천안 생성
ARS->>ARS: createRecommendations(message, trendAnalysis)
ARS->>ARS: circuitBreakerManager.executeWithCircuitBreaker()
ARS->>CAC: sendMessage(apiKey, version, request)
CAC->>Claude: POST /v1/messages
Note over Claude: Model: claude-sonnet-4-5<br/>System: "이벤트 기획 전문가"<br/>Prompt: 3가지 추천안 생성
Claude-->>CAC: ClaudeResponse
CAC-->>ARS: ClaudeResponse
ARS->>ARS: parseRecommendationResponse(responseText)
%% 8. Job 상태: PROCESSING (90%)
ARS->>JSS: updateJobStatus(jobId, PROCESSING, "결과 저장 중")
JSS->>CS: saveJobStatus(jobId, status)
CS->>Redis: SET ai:job:status:{jobId}
%% 9. 결과 저장
ARS->>CS: saveRecommendation(eventId, result)
CS->>Redis: SET ai:recommendation:{eventId} (TTL: 24시간)
%% 10. Job 상태: COMPLETED (100%)
ARS->>JSS: updateJobStatus(jobId, COMPLETED, "AI 추천 완료")
JSS->>CS: saveJobStatus(jobId, status)
CS->>Redis: SET ai:job:status:{jobId}
deactivate ARS
%% 11. Kafka ACK
Consumer->>Kafka: acknowledgment.acknowledge()
```
---
## 2. Job 상태 조회 워크플로우 (동기)
```mermaid
sequenceDiagram
participant ES as Event Service
participant Controller as InternalJobController
participant JSS as JobStatusService
participant CS as CacheService
participant Redis as Redis
%% 1. Event Service가 Job 상태 조회
ES->>Controller: GET /api/v1/ai-service/internal/jobs/{jobId}/status
%% 2. Job 상태 조회
Controller->>JSS: getJobStatus(jobId)
activate JSS
JSS->>CS: getJobStatus(jobId)
CS->>Redis: GET ai:job:status:{jobId}
alt 상태 존재
Redis-->>CS: JobStatusResponse
CS-->>JSS: Object (JobStatusResponse)
JSS->>JSS: objectMapper.convertValue()
JSS-->>Controller: JobStatusResponse
Controller-->>ES: 200 OK + JobStatusResponse
else 상태 없음
Redis-->>CS: null
CS-->>JSS: null
JSS-->>Controller: JobNotFoundException
Controller-->>ES: 404 Not Found
end
deactivate JSS
```
---
## 3. AI 추천 결과 조회 워크플로우 (동기)
```mermaid
sequenceDiagram
participant ES as Event Service
participant Controller as InternalRecommendationController
participant ARS as AIRecommendationService
participant CS as CacheService
participant Redis as Redis
%% 1. Event Service가 AI 추천 결과 조회
ES->>Controller: GET /api/v1/ai-service/internal/recommendations/{eventId}
%% 2. 추천 결과 조회
Controller->>ARS: getRecommendation(eventId)
activate ARS
ARS->>CS: getRecommendation(eventId)
CS->>Redis: GET ai:recommendation:{eventId}
alt 결과 존재
Redis-->>CS: AIRecommendationResult
CS-->>ARS: Object (AIRecommendationResult)
ARS->>ARS: objectMapper.convertValue()
ARS-->>Controller: AIRecommendationResult
Controller-->>ES: 200 OK + AIRecommendationResult
else 결과 없음
Redis-->>CS: null
CS-->>ARS: null
ARS-->>Controller: RecommendationNotFoundException
Controller-->>ES: 404 Not Found
end
deactivate ARS
```
---
## 4. 헬스체크 워크플로우 (동기)
```mermaid
sequenceDiagram
participant Client as Client/Actuator
participant Controller as HealthController
participant Redis as Redis
%% 1. 헬스체크 요청
Client->>Controller: GET /api/v1/ai-service/health
%% 2. Redis 상태 확인
Controller->>Controller: checkRedis()
alt RedisTemplate 존재
Controller->>Redis: PING
alt Redis 정상
Redis-->>Controller: PONG
Controller->>Controller: redisStatus = UP
else Redis 오류
Redis-->>Controller: Exception
Controller->>Controller: redisStatus = DOWN
end
else RedisTemplate 없음
Controller->>Controller: redisStatus = UNKNOWN
end
%% 3. 전체 상태 판단
alt Redis DOWN
Controller->>Controller: overallStatus = DEGRADED
else Redis UP/UNKNOWN
Controller->>Controller: overallStatus = UP
end
%% 4. 응답
Controller-->>Client: 200 OK + HealthCheckResponse
```
---
## 5. 주요 컴포넌트 메서드 목록
### 5.1 Controller Layer
#### InternalJobController
| 메서드 | HTTP | 엔드포인트 | 설명 |
|--------|------|-----------|------|
| `getJobStatus(jobId)` | GET | `/api/v1/ai-service/internal/jobs/{jobId}/status` | Job 상태 조회 |
| `createTestJob(jobId)` | GET | `/api/v1/ai-service/internal/jobs/debug/create-test-job/{jobId}` | 테스트 Job 생성 (디버그) |
#### InternalRecommendationController
| 메서드 | HTTP | 엔드포인트 | 설명 |
|--------|------|-----------|------|
| `getRecommendation(eventId)` | GET | `/api/v1/ai-service/internal/recommendations/{eventId}` | AI 추천 결과 조회 |
| `debugRedisKeys()` | GET | `/api/v1/ai-service/internal/recommendations/debug/redis-keys` | Redis 모든 키 조회 |
| `debugRedisKey(key)` | GET | `/api/v1/ai-service/internal/recommendations/debug/redis-key/{key}` | Redis 특정 키 조회 |
| `searchAllDatabases()` | GET | `/api/v1/ai-service/internal/recommendations/debug/search-all-databases` | 전체 DB 검색 |
| `createTestData(eventId)` | GET | `/api/v1/ai-service/internal/recommendations/debug/create-test-data/{eventId}` | 테스트 데이터 생성 |
#### HealthController
| 메서드 | HTTP | 엔드포인트 | 설명 |
|--------|------|-----------|------|
| `healthCheck()` | GET | `/api/v1/ai-service/health` | 서비스 헬스체크 |
| `checkRedis()` | - | (내부) | Redis 연결 확인 |
---
### 5.2 Service Layer
#### AIRecommendationService
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `getRecommendation(eventId)` | Controller | Redis에서 추천 결과 조회 |
| `generateRecommendations(message)` | AIJobConsumer | AI 추천 생성 (전체 프로세스) |
| `analyzeTrend(message)` | 내부 | 트렌드 분석 (캐시 확인 포함) |
| `createRecommendations(message, trendAnalysis)` | 내부 | 이벤트 추천안 생성 |
| `callClaudeApiForRecommendations(message, trendAnalysis)` | 내부 | Claude API 호출 (추천안) |
| `buildRecommendationPrompt(message, trendAnalysis)` | 내부 | 추천안 프롬프트 생성 |
| `parseRecommendationResponse(responseText)` | 내부 | 추천안 응답 파싱 |
| `parseEventRecommendation(node)` | 내부 | EventRecommendation 파싱 |
| `parseRange(node)` | 내부 | Range 객체 파싱 |
| `extractJsonFromMarkdown(text)` | 내부 | Markdown에서 JSON 추출 |
#### TrendAnalysisService
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `analyzeTrend(industry, region)` | AIRecommendationService | 트렌드 분석 수행 |
| `callClaudeApi(industry, region)` | 내부 | Claude API 호출 (트렌드) |
| `buildPrompt(industry, region)` | 내부 | 트렌드 분석 프롬프트 생성 |
| `parseResponse(responseText)` | 내부 | 트렌드 응답 파싱 |
| `extractJsonFromMarkdown(text)` | 내부 | Markdown에서 JSON 추출 |
| `parseTrendKeywords(arrayNode)` | 내부 | TrendKeyword 리스트 파싱 |
#### JobStatusService
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `getJobStatus(jobId)` | Controller | Job 상태 조회 |
| `updateJobStatus(jobId, status, message)` | AIRecommendationService | Job 상태 업데이트 |
| `calculateProgress(status)` | 내부 | 상태별 진행률 계산 |
#### CacheService
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `set(key, value, ttlSeconds)` | 내부 | 범용 캐시 저장 |
| `get(key)` | 내부 | 범용 캐시 조회 |
| `delete(key)` | 외부 | 캐시 삭제 |
| `saveJobStatus(jobId, status)` | JobStatusService | Job 상태 저장 |
| `getJobStatus(jobId)` | JobStatusService | Job 상태 조회 |
| `saveRecommendation(eventId, recommendation)` | AIRecommendationService | AI 추천 결과 저장 |
| `getRecommendation(eventId)` | AIRecommendationService | AI 추천 결과 조회 |
| `saveTrend(industry, region, trend)` | AIRecommendationService | 트렌드 분석 결과 저장 |
| `getTrend(industry, region)` | AIRecommendationService | 트렌드 분석 결과 조회 |
---
### 5.3 Consumer Layer
#### AIJobConsumer
| 메서드 | 트리거 | 설명 |
|--------|-------|------|
| `consume(message, topic, offset, ack)` | Kafka Message | Kafka 메시지 수신 및 처리 |
---
### 5.4 Client Layer
#### ClaudeApiClient (Feign)
| 메서드 | 호출자 | 설명 |
|--------|-------|------|
| `sendMessage(apiKey, anthropicVersion, request)` | TrendAnalysisService, AIRecommendationService | Claude API 호출 |
---
## 6. Redis 캐시 키 구조
| 키 패턴 | 설명 | TTL |
|--------|------|-----|
| `ai:job:status:{jobId}` | Job 상태 정보 | 24시간 (86400초) |
| `ai:recommendation:{eventId}` | AI 추천 결과 | 24시간 (86400초) |
| `ai:trend:{industry}:{region}` | 트렌드 분석 결과 | 1시간 (3600초) |
---
## 7. Claude API 호출 정보
### 7.1 트렌드 분석
- **URL**: `https://api.anthropic.com/v1/messages`
- **Model**: `claude-sonnet-4-5-20250929`
- **Max Tokens**: 4096
- **Temperature**: 0.7
- **System Prompt**: "당신은 마케팅 트렌드 분석 전문가입니다. 업종별, 지역별 트렌드를 분석하고 인사이트를 제공합니다."
- **응답 형식**: JSON (industryTrends, regionalTrends, seasonalTrends)
### 7.2 이벤트 추천안 생성
- **URL**: `https://api.anthropic.com/v1/messages`
- **Model**: `claude-sonnet-4-5-20250929`
- **Max Tokens**: 4096
- **Temperature**: 0.7
- **System Prompt**: "당신은 소상공인을 위한 마케팅 이벤트 기획 전문가입니다. 트렌드 분석을 바탕으로 실행 가능한 이벤트 추천안을 제공합니다."
- **응답 형식**: JSON (recommendations: 3가지 옵션)
---
## 8. Circuit Breaker 설정
### 적용 대상
- `claudeApi`: 모든 Claude API 호출
### 설정값
```yaml
failure-rate-threshold: 50%
slow-call-duration-threshold: 60초
sliding-window-size: 10
minimum-number-of-calls: 5
wait-duration-in-open-state: 60초
timeout-duration: 300초 (5분)
```
### Fallback 메서드
- `AIServiceFallback.getDefaultTrendAnalysis()`: 기본 트렌드 분석
- `AIServiceFallback.getDefaultRecommendations()`: 기본 추천안
---
## 9. 에러 처리
### Exception 종류
| Exception | HTTP Code | 발생 조건 |
|-----------|-----------|---------|
| `RecommendationNotFoundException` | 404 | Redis에 추천 결과 없음 |
| `JobNotFoundException` | 404 | Redis에 Job 상태 없음 |
| `AIServiceException` | 500 | AI 서비스 내부 오류 |
### 에러 응답 예시
```json
{
"timestamp": "2025-10-30T15:30:00",
"status": 404,
"error": "Not Found",
"message": "추천 결과를 찾을 수 없습니다: eventId=evt-123",
"path": "/api/v1/ai-service/internal/recommendations/evt-123"
}
```
---
## 10. 로깅 레벨
```yaml
com.kt.ai: DEBUG
org.springframework.kafka: INFO
org.springframework.data.redis: INFO
io.github.resilience4j: DEBUG
```

View File

@ -145,6 +145,14 @@ springdoc:
display-request-duration: true display-request-duration: true
show-actuator: true show-actuator: true
# CORS Configuration
cors:
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-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# Logging # Logging
logging: logging:
file: file:

View File

@ -31,6 +31,9 @@
<!-- JWT Configuration --> <!-- JWT Configuration -->
<entry key="JWT_SECRET" value="kt-event-marketing-secret-key-for-development-only-please-change-in-production" /> <entry key="JWT_SECRET" value="kt-event-marketing-secret-key-for-development-only-please-change-in-production" />
<!-- CORS Configuration -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*,http://*.nip.io:*" />
<!-- Logging Configuration --> <!-- Logging Configuration -->
<entry key="LOG_LEVEL" value="DEBUG" /> <entry key="LOG_LEVEL" value="DEBUG" />
<entry key="SQL_LOG_LEVEL" value="DEBUG" /> <entry key="SQL_LOG_LEVEL" value="DEBUG" />

View File

@ -1,18 +1,17 @@
package com.kt.event.eventservice.application.dto.kafka; package com.kt.event.eventservice.application.dto.kafka;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* AI 이벤트 생성 작업 메시지 DTO * AI 이벤트 생성 작업 메시지 DTO
* *
* ai-event-generation-job 토픽에서 구독하는 메시지 형식 * ai-event-generation-job 토픽에서 구독하는 메시지 형식
* JSON 필드명: camelCase (Jackson 기본 설정)
*/ */
@Data @Data
@Builder @Builder
@ -23,73 +22,54 @@ public class AIEventGenerationJobMessage {
/** /**
* 작업 ID * 작업 ID
*/ */
@JsonProperty("job_id")
private String jobId; private String jobId;
/** /**
* 사용자 ID (UUID String) * 사용자 ID (UUID String)
*/ */
@JsonProperty("user_id")
private String userId; private String userId;
/** /**
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED) * 이벤트 ID
*/ */
@JsonProperty("status") private String eventId;
private String status;
/** /**
* AI 추천 결과 데이터 * 이벤트 목적
* - "신규 고객 유치"
* - "재방문 유도"
* - "매출 증대"
* - "브랜드 인지도 향상"
*/ */
@JsonProperty("ai_recommendation") private String objective;
private AIRecommendationData aiRecommendation;
/** /**
* 에러 메시지 (실패 ) * 업종 (storeCategory와 동일)
*/ */
@JsonProperty("error_message") private String industry;
private String errorMessage;
/** /**
* 작업 생성 일시 * 지역 (//)
*/ */
@JsonProperty("created_at") private String region;
private LocalDateTime createdAt;
/** /**
* 작업 완료/실패 일시 * 매장명
*/ */
@JsonProperty("completed_at") private String storeName;
private LocalDateTime completedAt;
/** /**
* AI 추천 데이터 내부 클래스 * 목표 고객층 (선택)
*/ */
@Data private String targetAudience;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AIRecommendationData {
@JsonProperty("event_title") /**
private String eventTitle; * 예산 () (선택)
*/
private Integer budget;
@JsonProperty("event_description") /**
private String eventDescription; * 요청 시각
*/
@JsonProperty("event_type") private LocalDateTime requestedAt;
private String eventType;
@JsonProperty("target_keywords")
private List<String> targetKeywords;
@JsonProperty("recommended_benefits")
private List<String> recommendedBenefits;
@JsonProperty("start_date")
private String startDate;
@JsonProperty("end_date")
private String endDate;
}
} }

View File

@ -7,7 +7,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이벤트 생성 완료 메시지 DTO * 이벤트 생성 완료 메시지 DTO
@ -21,16 +20,16 @@ import java.util.UUID;
public class EventCreatedMessage { public class EventCreatedMessage {
/** /**
* 이벤트 ID (UUID) * 이벤트 ID
*/ */
@JsonProperty("event_id") @JsonProperty("event_id")
private UUID eventId; private String eventId;
/** /**
* 사용자 ID (UUID) * 사용자 ID
*/ */
@JsonProperty("user_id") @JsonProperty("user_id")
private UUID userId; private String userId;
/** /**
* 이벤트 제목 * 이벤트 제목

View File

@ -8,8 +8,6 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* AI 추천 요청 DTO * AI 추천 요청 DTO
* *
@ -26,11 +24,24 @@ import java.util.UUID;
@Schema(description = "AI 추천 요청") @Schema(description = "AI 추천 요청")
public class AiRecommendationRequest { public class AiRecommendationRequest {
@NotNull(message = "이벤트 목적은 필수입니다.")
@Schema(description = "이벤트 목적", required = true, example = "신규 고객 유치")
private String objective;
@NotNull(message = "매장 정보는 필수입니다.") @NotNull(message = "매장 정보는 필수입니다.")
@Valid @Valid
@Schema(description = "매장 정보", required = true) @Schema(description = "매장 정보", required = true)
private StoreInfo storeInfo; private StoreInfo storeInfo;
@Schema(description = "지역 정보", example = "서울특별시 강남구")
private String region;
@Schema(description = "타겟 고객층", example = "20-30대 직장인")
private String targetAudience;
@Schema(description = "예산 (원)", example = "500000")
private Integer budget;
/** /**
* 매장 정보 * 매장 정보
*/ */
@ -42,8 +53,8 @@ public class AiRecommendationRequest {
public static class StoreInfo { public static class StoreInfo {
@NotNull(message = "매장 ID는 필수입니다.") @NotNull(message = "매장 ID는 필수입니다.")
@Schema(description = "매장 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440002") @Schema(description = "매장 ID", required = true, example = "str_20250124_001")
private UUID storeId; private String storeId;
@NotNull(message = "매장명은 필수입니다.") @NotNull(message = "매장명은 필수입니다.")
@Schema(description = "매장명", required = true, example = "우진네 고깃집") @Schema(description = "매장명", required = true, example = "우진네 고깃집")

View File

@ -6,8 +6,6 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* 이미지 선택 요청 DTO * 이미지 선택 요청 DTO
* *
@ -22,7 +20,7 @@ import java.util.UUID;
public class SelectImageRequest { public class SelectImageRequest {
@NotNull(message = "이미지 ID는 필수입니다.") @NotNull(message = "이미지 ID는 필수입니다.")
private UUID imageId; private String imageId;
private String imageUrl; private String imageUrl;
} }

View File

@ -19,6 +19,9 @@ import lombok.NoArgsConstructor;
@Builder @Builder
public class SelectObjectiveRequest { public class SelectObjectiveRequest {
@NotBlank(message = "이벤트 ID는 필수입니다.")
private String eventId;
@NotBlank(message = "이벤트 목적은 필수입니다.") @NotBlank(message = "이벤트 목적은 필수입니다.")
private String objective; private String objective;
} }

View File

@ -9,7 +9,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.UUID;
/** /**
* AI 추천 선택 요청 DTO * AI 추천 선택 요청 DTO
@ -28,8 +27,8 @@ import java.util.UUID;
public class SelectRecommendationRequest { public class SelectRecommendationRequest {
@NotNull(message = "추천 ID는 필수입니다.") @NotNull(message = "추천 ID는 필수입니다.")
@Schema(description = "선택한 추천 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440007") @Schema(description = "선택한 추천 ID", required = true, example = "rec_20250124_001")
private UUID recommendationId; private String recommendationId;
@Valid @Valid
@Schema(description = "커스터마이징 항목") @Schema(description = "커스터마이징 항목")

View File

@ -7,7 +7,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이벤트 생성 응답 DTO * 이벤트 생성 응답 DTO
@ -22,7 +21,7 @@ import java.util.UUID;
@Builder @Builder
public class EventCreatedResponse { public class EventCreatedResponse {
private UUID eventId; private String eventId;
private EventStatus status; private EventStatus status;
private String objective; private String objective;
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@ -10,7 +10,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* 이벤트 상세 응답 DTO * 이벤트 상세 응답 DTO
@ -25,16 +24,16 @@ import java.util.UUID;
@Builder @Builder
public class EventDetailResponse { public class EventDetailResponse {
private UUID eventId; private String eventId;
private UUID userId; private String userId;
private UUID storeId; private String storeId;
private String eventName; private String eventName;
private String description; private String description;
private String objective; private String objective;
private LocalDate startDate; private LocalDate startDate;
private LocalDate endDate; private LocalDate endDate;
private EventStatus status; private EventStatus status;
private UUID selectedImageId; private String selectedImageId;
private String selectedImageUrl; private String selectedImageUrl;
private Integer participants; private Integer participants;
private Integer targetParticipants; private Integer targetParticipants;
@ -57,7 +56,7 @@ public class EventDetailResponse {
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
public static class GeneratedImageDto { public static class GeneratedImageDto {
private UUID imageId; private String imageId;
private String imageUrl; private String imageUrl;
private String style; private String style;
private String platform; private String platform;
@ -70,7 +69,7 @@ public class EventDetailResponse {
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
public static class AiRecommendationDto { public static class AiRecommendationDto {
private UUID recommendationId; private String recommendationId;
private String eventName; private String eventName;
private String description; private String description;
private String promotionType; private String promotionType;

View File

@ -7,7 +7,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이미지 편집 응답 DTO * 이미지 편집 응답 DTO
@ -25,8 +24,8 @@ import java.util.UUID;
@Schema(description = "이미지 편집 응답") @Schema(description = "이미지 편집 응답")
public class ImageEditResponse { public class ImageEditResponse {
@Schema(description = "편집된 이미지 ID", example = "550e8400-e29b-41d4-a716-446655440008") @Schema(description = "편집된 이미지 ID", example = "img_20250124_001")
private UUID imageId; private String imageId;
@Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg") @Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg")
private String imageUrl; private String imageUrl;

View File

@ -6,7 +6,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 이미지 생성 응답 DTO * 이미지 생성 응답 DTO
@ -21,7 +20,7 @@ import java.util.UUID;
@Builder @Builder
public class ImageGenerationResponse { public class ImageGenerationResponse {
private UUID jobId; private String jobId;
private String status; private String status;
private String message; private String message;
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@ -7,8 +7,6 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.UUID;
/** /**
* Job 접수 응답 DTO * Job 접수 응답 DTO
* *
@ -25,8 +23,8 @@ import java.util.UUID;
@Schema(description = "Job 접수 응답") @Schema(description = "Job 접수 응답")
public class JobAcceptedResponse { public class JobAcceptedResponse {
@Schema(description = "생성된 Job ID", example = "550e8400-e29b-41d4-a716-446655440005") @Schema(description = "생성된 Job ID", example = "job_20250124_001")
private UUID jobId; private String jobId;
@Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING") @Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING")
private JobStatus status; private JobStatus status;

View File

@ -8,7 +8,6 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* Job 상태 응답 DTO * Job 상태 응답 DTO
@ -23,7 +22,7 @@ import java.util.UUID;
@Builder @Builder
public class JobStatusResponse { public class JobStatusResponse {
private UUID jobId; private String jobId;
private JobType jobType; private JobType jobType;
private JobStatus status; private JobStatus status;
private int progress; private int progress;

View File

@ -0,0 +1,86 @@
package com.kt.event.eventservice.application.service;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* 이벤트 ID 생성기
*
* 비즈니스 친화적인 eventId를 생성합니다.
* 형식: EVT-{storeId}-{yyyyMMddHHmmss}-{random8}
* 예시: EVT-store123-20251029143025-a1b2c3d4
*
* VARCHAR(50) 길이 제약사항을 고려하여 설계되었습니다.
*/
@Component
public class EventIdGenerator {
private static final String PREFIX = "EVT";
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private static final int RANDOM_LENGTH = 8;
/**
* 이벤트 ID 생성 (백엔드용)
*
* 참고: 현재는 프론트엔드에서 eventId를 생성하므로 메서드는 거의 사용되지 않습니다.
*
* @param storeId 상점 ID
* @return 생성된 이벤트 ID
*/
public String generate(String storeId) {
// 기본값 처리
if (storeId == null || storeId.isBlank()) {
storeId = "unknown";
}
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER);
String randomPart = generateRandomPart();
// 형식: EVT-{storeId}-{timestamp}-{random}
String eventId = String.format("%s-%s-%s-%s", PREFIX, storeId, timestamp, randomPart);
return eventId;
}
/**
* UUID 기반 랜덤 문자열 생성
*
* @return 8자리 랜덤 문자열 (소문자 영숫자)
*/
private String generateRandomPart() {
return UUID.randomUUID()
.toString()
.replace("-", "")
.substring(0, RANDOM_LENGTH)
.toLowerCase();
}
/**
* eventId 기본 검증
*
* 최소한의 검증만 수행합니다:
* - null/empty 체크
* - 길이 제한 체크 (VARCHAR(50) 제약)
*
* 프론트엔드에서 생성한 eventId를 신뢰하며,
* DB의 PRIMARY KEY 제약조건으로 중복을 방지합니다.
*
* @param eventId 검증할 이벤트 ID
* @return 유효하면 true, 아니면 false
*/
public boolean isValid(String eventId) {
if (eventId == null || eventId.isBlank()) {
return false;
}
// 길이 검증 (DB VARCHAR(50) 제약)
if (eventId.length() > 50) {
return false;
}
return true;
}
}

View File

@ -24,7 +24,6 @@ import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -48,22 +47,32 @@ public class EventService {
private final AIJobKafkaProducer aiJobKafkaProducer; private final AIJobKafkaProducer aiJobKafkaProducer;
private final ImageJobKafkaProducer imageJobKafkaProducer; private final ImageJobKafkaProducer imageJobKafkaProducer;
private final EventKafkaProducer eventKafkaProducer; private final EventKafkaProducer eventKafkaProducer;
private final EventIdGenerator eventIdGenerator;
private final JobIdGenerator jobIdGenerator;
/** /**
* 이벤트 생성 (Step 1: 목적 선택) * 이벤트 생성 (Step 1: 목적 선택)
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param storeId 매장 ID (UUID) * @param storeId 매장 ID
* @param request 목적 선택 요청 * @param request 목적 선택 요청 (eventId 포함)
* @return 생성된 이벤트 응답 * @return 생성된 이벤트 응답
*/ */
@Transactional @Transactional
public EventCreatedResponse createEvent(UUID userId, UUID storeId, SelectObjectiveRequest request) { public EventCreatedResponse createEvent(String userId, String storeId, SelectObjectiveRequest request) {
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}", log.info("이벤트 생성 시작 - userId: {}, storeId: {}, eventId: {}, objective: {}",
userId, storeId, request.getObjective()); userId, storeId, request.getEventId(), request.getObjective());
String eventId = request.getEventId();
// 동일한 eventId가 이미 존재하는지 확인
if (eventRepository.findByEventId(eventId).isPresent()) {
throw new BusinessException(ErrorCode.EVENT_005);
}
// 이벤트 엔티티 생성 // 이벤트 엔티티 생성
Event event = Event.builder() Event event = Event.builder()
.eventId(eventId)
.userId(userId) .userId(userId)
.storeId(storeId) .storeId(storeId)
.objective(request.getObjective()) .objective(request.getObjective())
@ -87,11 +96,11 @@ public class EventService {
/** /**
* 이벤트 상세 조회 * 이벤트 상세 조회
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @return 이벤트 상세 응답 * @return 이벤트 상세 응답
*/ */
public EventDetailResponse getEvent(UUID userId, UUID eventId) { public EventDetailResponse getEvent(String userId, String eventId) {
log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
@ -108,7 +117,7 @@ public class EventService {
/** /**
* 이벤트 목록 조회 (페이징, 필터링) * 이벤트 목록 조회 (페이징, 필터링)
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param status 상태 필터 * @param status 상태 필터
* @param search 검색어 * @param search 검색어
* @param objective 목적 필터 * @param objective 목적 필터
@ -116,7 +125,7 @@ public class EventService {
* @return 이벤트 목록 * @return 이벤트 목록
*/ */
public Page<EventDetailResponse> getEvents( public Page<EventDetailResponse> getEvents(
UUID userId, String userId,
EventStatus status, EventStatus status,
String search, String search,
String objective, String objective,
@ -139,11 +148,11 @@ public class EventService {
/** /**
* 이벤트 삭제 * 이벤트 삭제
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void deleteEvent(UUID userId, UUID eventId) { public void deleteEvent(String userId, String eventId) {
log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
@ -161,11 +170,11 @@ public class EventService {
/** /**
* 이벤트 배포 * 이벤트 배포
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void publishEvent(UUID userId, UUID eventId) { public void publishEvent(String userId, String eventId) {
log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
@ -190,11 +199,11 @@ public class EventService {
/** /**
* 이벤트 종료 * 이벤트 종료
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
*/ */
@Transactional @Transactional
public void endEvent(UUID userId, UUID eventId) { public void endEvent(String userId, String eventId) {
log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
@ -210,13 +219,13 @@ public class EventService {
/** /**
* 이미지 생성 요청 * 이미지 생성 요청
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 이미지 생성 요청 * @param request 이미지 생성 요청
* @return 이미지 생성 응답 (Job ID 포함) * @return 이미지 생성 응답 (Job ID 포함)
*/ */
@Transactional @Transactional
public ImageGenerationResponse requestImageGeneration(UUID userId, UUID eventId, ImageGenerationRequest request) { public ImageGenerationResponse requestImageGeneration(String userId, String eventId, ImageGenerationRequest request) {
log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId); log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId);
// 이벤트 조회 권한 확인 // 이벤트 조회 권한 확인
@ -236,7 +245,11 @@ public class EventService {
String.join(", ", request.getPlatforms())); String.join(", ", request.getPlatforms()));
// Job 엔티티 생성 // Job 엔티티 생성
String jobId = jobIdGenerator.generate(JobType.IMAGE_GENERATION);
log.info("생성된 jobId: {}", jobId);
Job job = Job.builder() Job job = Job.builder()
.jobId(jobId)
.eventId(eventId) .eventId(eventId)
.jobType(JobType.IMAGE_GENERATION) .jobType(JobType.IMAGE_GENERATION)
.build(); .build();
@ -245,9 +258,9 @@ public class EventService {
// Kafka 메시지 발행 // Kafka 메시지 발행
imageJobKafkaProducer.publishImageGenerationJob( imageJobKafkaProducer.publishImageGenerationJob(
job.getJobId().toString(), job.getJobId(),
userId.toString(), userId,
eventId.toString(), eventId,
prompt prompt
); );
@ -265,13 +278,13 @@ public class EventService {
/** /**
* 이미지 선택 * 이미지 선택
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param imageId 이미지 ID * @param imageId 이미지 ID
* @param request 이미지 선택 요청 * @param request 이미지 선택 요청
*/ */
@Transactional @Transactional
public void selectImage(UUID userId, UUID eventId, UUID imageId, SelectImageRequest request) { public void selectImage(String userId, String eventId, String imageId, SelectImageRequest request) {
log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId);
// 이벤트 조회 권한 확인 // 이벤트 조회 권한 확인
@ -294,18 +307,36 @@ public class EventService {
/** /**
* AI 추천 요청 * AI 추천 요청
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID (프론트엔드에서 생성한 ID)
* @param request AI 추천 요청 * @param request AI 추천 요청 (objective 포함)
* @return Job 접수 응답 * @return Job 접수 응답
*/ */
@Transactional @Transactional
public JobAcceptedResponse requestAiRecommendations(UUID userId, UUID eventId, AiRecommendationRequest request) { public JobAcceptedResponse requestAiRecommendations(String userId, String eventId, AiRecommendationRequest request) {
log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId); log.info("AI 추천 요청 - userId: {}, eventId: {}, objective: {}",
userId, eventId, request.getObjective());
// 이벤트 조회 권한 확인 // 이벤트 조회 또는 생성
Event event = eventRepository.findByEventIdAndUserId(eventId, userId) Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); .orElseGet(() -> {
log.info("이벤트가 존재하지 않아 새로 생성합니다 - eventId: {}", eventId);
// storeId 추출 (eventId 형식: EVT-{storeId}-{timestamp}-{random})
String storeId = request.getStoreInfo().getStoreId();
// 이벤트 생성
Event newEvent = Event.builder()
.eventId(eventId)
.userId(userId)
.storeId(storeId)
.objective(request.getObjective())
.eventName("") // 초기에는 비어있음, AI 추천 설정
.status(EventStatus.DRAFT)
.build();
return eventRepository.save(newEvent);
});
// DRAFT 상태 확인 // DRAFT 상태 확인
if (!event.isModifiable()) { if (!event.isModifiable()) {
@ -313,7 +344,11 @@ public class EventService {
} }
// Job 엔티티 생성 // Job 엔티티 생성
String jobId = jobIdGenerator.generate(JobType.AI_RECOMMENDATION);
log.info("생성된 jobId: {}", jobId);
Job job = Job.builder() Job job = Job.builder()
.jobId(jobId)
.eventId(eventId) .eventId(eventId)
.jobType(JobType.AI_RECOMMENDATION) .jobType(JobType.AI_RECOMMENDATION)
.build(); .build();
@ -322,13 +357,15 @@ public class EventService {
// Kafka 메시지 발행 // Kafka 메시지 발행
aiJobKafkaProducer.publishAIGenerationJob( aiJobKafkaProducer.publishAIGenerationJob(
job.getJobId().toString(), job.getJobId(),
userId.toString(), userId,
eventId.toString(), eventId,
request.getStoreInfo().getStoreName(), request.getStoreInfo().getStoreName(),
request.getStoreInfo().getCategory(), request.getStoreInfo().getCategory(), // industry
request.getStoreInfo().getDescription(), request.getRegion(), // region
event.getObjective() event.getObjective(), // objective
request.getTargetAudience(), // targetAudience
request.getBudget() // budget
); );
log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId()); log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId());
@ -343,12 +380,12 @@ public class EventService {
/** /**
* AI 추천 선택 * AI 추천 선택
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request AI 추천 선택 요청 * @param request AI 추천 선택 요청
*/ */
@Transactional @Transactional
public void selectRecommendation(UUID userId, UUID eventId, SelectRecommendationRequest request) { public void selectRecommendation(String userId, String eventId, SelectRecommendationRequest request) {
log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}", log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}",
userId, eventId, request.getRecommendationId()); userId, eventId, request.getRecommendationId());
@ -409,14 +446,14 @@ public class EventService {
/** /**
* 이미지 편집 * 이미지 편집
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param imageId 이미지 ID * @param imageId 이미지 ID
* @param request 이미지 편집 요청 * @param request 이미지 편집 요청
* @return 이미지 편집 응답 * @return 이미지 편집 응답
*/ */
@Transactional @Transactional
public ImageEditResponse editImage(UUID userId, UUID eventId, UUID imageId, ImageEditRequest request) { public ImageEditResponse editImage(String userId, String eventId, String imageId, ImageEditRequest request) {
log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId);
// 이벤트 조회 권한 확인 // 이벤트 조회 권한 확인
@ -450,12 +487,12 @@ public class EventService {
/** /**
* 배포 채널 선택 * 배포 채널 선택
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 배포 채널 선택 요청 * @param request 배포 채널 선택 요청
*/ */
@Transactional @Transactional
public void selectChannels(UUID userId, UUID eventId, SelectChannelsRequest request) { public void selectChannels(String userId, String eventId, SelectChannelsRequest request) {
log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}", log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}",
userId, eventId, request.getChannels()); userId, eventId, request.getChannels());
@ -479,13 +516,13 @@ public class EventService {
/** /**
* 이벤트 수정 * 이벤트 수정
* *
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param request 이벤트 수정 요청 * @param request 이벤트 수정 요청
* @return 이벤트 상세 응답 * @return 이벤트 상세 응답
*/ */
@Transactional @Transactional
public EventDetailResponse updateEvent(UUID userId, UUID eventId, UpdateEventRequest request) { public EventDetailResponse updateEvent(String userId, String eventId, UpdateEventRequest request) {
log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId); log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId);
// 이벤트 조회 권한 확인 // 이벤트 조회 권한 확인

View File

@ -0,0 +1,106 @@
package com.kt.event.eventservice.application.service;
import com.kt.event.eventservice.domain.enums.JobType;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* Job ID 생성기
*
* 비즈니스 친화적인 jobId를 생성합니다.
* 형식: JOB-{jobType}-{timestamp}-{random8}
* 예시: JOB-AI-20251029143025-a1b2c3d4
*
* VARCHAR(50) 길이 제약사항을 고려하여 설계되었습니다.
*/
@Component
public class JobIdGenerator {
private static final String PREFIX = "JOB";
private static final int RANDOM_LENGTH = 8;
/**
* Job ID 생성
*
* @param jobType Job 타입
* @return 생성된 Job ID
* @throws IllegalArgumentException jobType이 null인 경우
*/
public String generate(JobType jobType) {
if (jobType == null) {
throw new IllegalArgumentException("jobType은 필수입니다");
}
String typeCode = getTypeCode(jobType);
String timestamp = String.valueOf(System.currentTimeMillis());
String randomPart = generateRandomPart();
// 형식: JOB-{type}-{timestamp}-{random}
// 예상 길이: 3 + 1 + 5 + 1 + 13 + 1 + 8 = 32자 (최대)
String jobId = String.format("%s-%s-%s-%s", PREFIX, typeCode, timestamp, randomPart);
// 길이 검증
if (jobId.length() > 50) {
throw new IllegalStateException(
String.format("생성된 jobId 길이(%d)가 50자를 초과했습니다: %s",
jobId.length(), jobId)
);
}
return jobId;
}
/**
* JobType을 짧은 코드로 변환
*
* @param jobType Job 타입
* @return 타입 코드
*/
private String getTypeCode(JobType jobType) {
switch (jobType) {
case AI_RECOMMENDATION:
return "AI";
case IMAGE_GENERATION:
return "IMG";
default:
return jobType.name().substring(0, Math.min(5, jobType.name().length()));
}
}
/**
* UUID 기반 랜덤 문자열 생성
*
* @return 8자리 랜덤 문자열 (소문자 영숫자)
*/
private String generateRandomPart() {
return UUID.randomUUID()
.toString()
.replace("-", "")
.substring(0, RANDOM_LENGTH)
.toLowerCase();
}
/**
* jobId 기본 검증
*
* 최소한의 검증만 수행합니다:
* - null/empty 체크
* - 길이 제한 체크 (VARCHAR(50) 제약)
*
* @param jobId 검증할 Job ID
* @return 유효하면 true, 아니면 false
*/
public boolean isValid(String jobId) {
if (jobId == null || jobId.isBlank()) {
return false;
}
// 길이 검증 (DB VARCHAR(50) 제약)
if (jobId.length() > 50) {
return false;
}
return true;
}
}

View File

@ -11,8 +11,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/** /**
* Job 서비스 * Job 서비스
* *
@ -29,6 +27,7 @@ import java.util.UUID;
public class JobService { public class JobService {
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final JobIdGenerator jobIdGenerator;
/** /**
* Job 생성 * Job 생성
@ -38,10 +37,15 @@ public class JobService {
* @return 생성된 Job * @return 생성된 Job
*/ */
@Transactional @Transactional
public Job createJob(UUID eventId, JobType jobType) { public Job createJob(String eventId, JobType jobType) {
log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType); log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType);
// jobId 생성
String jobId = jobIdGenerator.generate(jobType);
log.info("생성된 jobId: {}", jobId);
Job job = Job.builder() Job job = Job.builder()
.jobId(jobId)
.eventId(eventId) .eventId(eventId)
.jobType(jobType) .jobType(jobType)
.build(); .build();
@ -59,7 +63,7 @@ public class JobService {
* @param jobId Job ID * @param jobId Job ID
* @return Job 상태 응답 * @return Job 상태 응답
*/ */
public JobStatusResponse getJobStatus(UUID jobId) { public JobStatusResponse getJobStatus(String jobId) {
log.info("Job 상태 조회 - jobId: {}", jobId); log.info("Job 상태 조회 - jobId: {}", jobId);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)
@ -75,7 +79,7 @@ public class JobService {
* @param progress 진행률 * @param progress 진행률
*/ */
@Transactional @Transactional
public void updateJobProgress(UUID jobId, int progress) { public void updateJobProgress(String jobId, int progress) {
log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress); log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)
@ -93,7 +97,7 @@ public class JobService {
* @param resultKey Redis 결과 * @param resultKey Redis 결과
*/ */
@Transactional @Transactional
public void completeJob(UUID jobId, String resultKey) { public void completeJob(String jobId, String resultKey) {
log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey); log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)
@ -113,7 +117,7 @@ public class JobService {
* @param errorMessage 에러 메시지 * @param errorMessage 에러 메시지
*/ */
@Transactional @Transactional
public void failJob(UUID jobId, String errorMessage) { public void failJob(String jobId, String errorMessage) {
log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage); log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage);
Job job = jobRepository.findById(jobId) Job job = jobRepository.findById(jobId)

View File

@ -1,7 +1,5 @@
package com.kt.event.eventservice.application.service; package com.kt.event.eventservice.application.service;
import java.util.UUID;
/** /**
* 알림 서비스 인터페이스 * 알림 서비스 인터페이스
* *
@ -22,7 +20,7 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param message 알림 메시지 * @param message 알림 메시지
*/ */
void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message); void notifyJobCompleted(String userId, String jobId, String jobType, String message);
/** /**
* 작업 실패 알림 전송 * 작업 실패 알림 전송
@ -32,7 +30,7 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param errorMessage 에러 메시지 * @param errorMessage 에러 메시지
*/ */
void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage); void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage);
/** /**
* 작업 진행 상태 알림 전송 * 작업 진행 상태 알림 전송
@ -42,5 +40,5 @@ public interface NotificationService {
* @param jobType 작업 타입 * @param jobType 작업 타입
* @param progress 진행률 (0-100) * @param progress 진행률 (0-100)
*/ */
void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress); void notifyJobProgress(String userId, String jobId, String jobType, int progress);
} }

View File

@ -11,7 +11,6 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.UUID;
/** /**
* 개발 환경용 인증 필터 * 개발 환경용 인증 필터
@ -35,11 +34,11 @@ public class DevAuthenticationFilter extends OncePerRequestFilter {
// 개발용 기본 UserPrincipal 생성 // 개발용 기본 UserPrincipal 생성
UserPrincipal userPrincipal = new UserPrincipal( UserPrincipal userPrincipal = new UserPrincipal(
UUID.fromString("11111111-1111-1111-1111-111111111111"), // userId "usr_dev_test_001", // userId
UUID.fromString("22222222-2222-2222-2222-222222222222"), // storeId "str_dev_test_001", // storeId
"dev@test.com", // email "dev@test.com", // email
"개발테스트사용자", // name "개발테스트사용자", // name
Collections.singletonList("USER") // roles Collections.singletonList("USER") // roles
); );
// Authentication 객체 생성 SecurityContext에 설정 // Authentication 객체 생성 SecurityContext에 설정

View File

@ -37,7 +37,7 @@ public class KafkaConfig {
/** /**
* Kafka Producer 설정 * Kafka Producer 설정
* Producer에서 JSON 문자열을 보내므로 StringSerializer 사용 * Producer에서 객체를 직접 보내므로 JsonSerializer 사용
* *
* @return ProducerFactory 인스턴스 * @return ProducerFactory 인스턴스
*/ */
@ -46,7 +46,10 @@ public class KafkaConfig {
Map<String, Object> config = new HashMap<>(); Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
// JSON 직렬화 타입 정보를 헤더에 추가하지 않음 (마이크로서비스 DTO 클래스 불일치 방지)
config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);
// Producer 성능 최적화 설정 // Producer 성능 최적화 설정
config.put(ProducerConfig.ACKS_CONFIG, "all"); config.put(ProducerConfig.ACKS_CONFIG, "all");

View File

@ -72,6 +72,7 @@ public class SecurityConfig {
/** /**
* CORS 설정 * CORS 설정
* 개발 환경에서 프론트엔드(localhost:3000) 요청을 허용합니다. * 개발 환경에서 프론트엔드(localhost:3000) 요청을 허용합니다.
* 쿠키 기반 인증을 위한 설정이 포함되어 있습니다.
* *
* @return CorsConfigurationSource CORS 설정 소스 * @return CorsConfigurationSource CORS 설정 소스
*/ */
@ -82,7 +83,10 @@ public class SecurityConfig {
// 허용할 Origin (개발 환경) // 허용할 Origin (개발 환경)
configuration.setAllowedOrigins(Arrays.asList( configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:3000" "http://127.0.0.1:3000",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:8083"
)); ));
// 허용할 HTTP 메서드 // 허용할 HTTP 메서드
@ -90,7 +94,7 @@ public class SecurityConfig {
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS" "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
)); ));
// 허용할 헤더 // 허용할 헤더 (쿠키 포함)
configuration.setAllowedHeaders(Arrays.asList( configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Authorization",
"Content-Type", "Content-Type",
@ -98,19 +102,21 @@ public class SecurityConfig {
"Accept", "Accept",
"Origin", "Origin",
"Access-Control-Request-Method", "Access-Control-Request-Method",
"Access-Control-Request-Headers" "Access-Control-Request-Headers",
"Cookie"
)); ));
// 인증 정보 포함 허용 // 인증 정보 포함 허용 (쿠키 전송을 위해 필수)
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
// Preflight 요청 캐시 시간 () // Preflight 요청 캐시 시간 ()
configuration.setMaxAge(3600L); configuration.setMaxAge(3600L);
// 노출할 응답 헤더 // 노출할 응답 헤더 (쿠키 포함)
configuration.setExposedHeaders(Arrays.asList( configuration.setExposedHeaders(Arrays.asList(
"Authorization", "Authorization",
"Content-Type" "Content-Type",
"Set-Cookie"
)); ));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

View File

@ -3,9 +3,6 @@ package com.kt.event.eventservice.domain.entity;
import com.kt.event.common.entity.BaseTimeEntity; import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.GenericGenerator;
import java.util.UUID;
/** /**
* AI 추천 엔티티 * AI 추천 엔티티
@ -26,10 +23,8 @@ import java.util.UUID;
public class AiRecommendation extends BaseTimeEntity { public class AiRecommendation extends BaseTimeEntity {
@Id @Id
@GeneratedValue(generator = "uuid2") @Column(name = "recommendation_id", length = 50)
@GenericGenerator(name = "uuid2", strategy = "uuid2") private String recommendationId;
@Column(name = "recommendation_id", columnDefinition = "uuid")
private UUID recommendationId;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false) @JoinColumn(name = "event_id", nullable = false)

View File

@ -6,7 +6,6 @@ import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.Fetch; import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode; import org.hibernate.annotations.FetchMode;
import org.hibernate.annotations.GenericGenerator;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
@ -32,16 +31,14 @@ import java.util.*;
public class Event extends BaseTimeEntity { public class Event extends BaseTimeEntity {
@Id @Id
@GeneratedValue(generator = "uuid2") @Column(name = "event_id", length = 50)
@GenericGenerator(name = "uuid2", strategy = "uuid2") private String eventId;
@Column(name = "event_id", columnDefinition = "uuid")
private UUID eventId;
@Column(name = "user_id", nullable = false, columnDefinition = "uuid") @Column(name = "user_id", nullable = false, length = 50)
private UUID userId; private String userId;
@Column(name = "store_id", nullable = false, columnDefinition = "uuid") @Column(name = "store_id", nullable = false, length = 50)
private UUID storeId; private String storeId;
@Column(name = "event_name", length = 200) @Column(name = "event_name", length = 200)
private String eventName; private String eventName;
@ -63,8 +60,8 @@ public class Event extends BaseTimeEntity {
@Builder.Default @Builder.Default
private EventStatus status = EventStatus.DRAFT; private EventStatus status = EventStatus.DRAFT;
@Column(name = "selected_image_id", columnDefinition = "uuid") @Column(name = "selected_image_id", length = 50)
private UUID selectedImageId; private String selectedImageId;
@Column(name = "selected_image_url", length = 500) @Column(name = "selected_image_url", length = 500)
private String selectedImageUrl; private String selectedImageUrl;
@ -128,7 +125,7 @@ public class Event extends BaseTimeEntity {
/** /**
* 이미지 선택 * 이미지 선택
*/ */
public void selectImage(UUID imageId, String imageUrl) { public void selectImage(String imageId, String imageUrl) {
this.selectedImageId = imageId; this.selectedImageId = imageId;
this.selectedImageUrl = imageUrl; this.selectedImageUrl = imageUrl;

View File

@ -3,9 +3,6 @@ package com.kt.event.eventservice.domain.entity;
import com.kt.event.common.entity.BaseTimeEntity; import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.GenericGenerator;
import java.util.UUID;
/** /**
* 생성된 이미지 엔티티 * 생성된 이미지 엔티티
@ -26,10 +23,8 @@ import java.util.UUID;
public class GeneratedImage extends BaseTimeEntity { public class GeneratedImage extends BaseTimeEntity {
@Id @Id
@GeneratedValue(generator = "uuid2") @Column(name = "image_id", length = 50)
@GenericGenerator(name = "uuid2", strategy = "uuid2") private String imageId;
@Column(name = "image_id", columnDefinition = "uuid")
private UUID imageId;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false) @JoinColumn(name = "event_id", nullable = false)

View File

@ -5,10 +5,8 @@ import com.kt.event.eventservice.domain.enums.JobStatus;
import com.kt.event.eventservice.domain.enums.JobType; import com.kt.event.eventservice.domain.enums.JobType;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.GenericGenerator;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* 비동기 작업 엔티티 * 비동기 작업 엔티티
@ -29,13 +27,11 @@ import java.util.UUID;
public class Job extends BaseTimeEntity { public class Job extends BaseTimeEntity {
@Id @Id
@GeneratedValue(generator = "uuid2") @Column(name = "job_id", length = 50)
@GenericGenerator(name = "uuid2", strategy = "uuid2") private String jobId;
@Column(name = "job_id", columnDefinition = "uuid")
private UUID jobId;
@Column(name = "event_id", nullable = false, columnDefinition = "uuid") @Column(name = "event_id", nullable = false, length = 50)
private UUID eventId; private String eventId;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "job_type", nullable = false, length = 30) @Column(name = "job_type", nullable = false, length = 30)

View File

@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* AI 추천 Repository * AI 추천 Repository
@ -15,15 +14,15 @@ import java.util.UUID;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, UUID> { public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, String> {
/** /**
* 이벤트별 AI 추천 목록 조회 * 이벤트별 AI 추천 목록 조회
*/ */
List<AiRecommendation> findByEventEventId(UUID eventId); List<AiRecommendation> findByEventEventId(String eventId);
/** /**
* 이벤트별 선택된 AI 추천 조회 * 이벤트별 선택된 AI 추천 조회
*/ */
AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId); AiRecommendation findByEventEventIdAndIsSelectedTrue(String eventId);
} }

View File

@ -10,7 +10,6 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
/** /**
* 이벤트 Repository * 이벤트 Repository
@ -20,7 +19,12 @@ import java.util.UUID;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface EventRepository extends JpaRepository<Event, UUID> { public interface EventRepository extends JpaRepository<Event, String> {
/**
* 이벤트 ID로 조회
*/
Optional<Event> findByEventId(String eventId);
/** /**
* 사용자 ID와 이벤트 ID로 조회 * 사용자 ID와 이벤트 ID로 조회
@ -29,8 +33,8 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
"LEFT JOIN FETCH e.channels " + "LEFT JOIN FETCH e.channels " +
"WHERE e.eventId = :eventId AND e.userId = :userId") "WHERE e.eventId = :eventId AND e.userId = :userId")
Optional<Event> findByEventIdAndUserId( Optional<Event> findByEventIdAndUserId(
@Param("eventId") UUID eventId, @Param("eventId") String eventId,
@Param("userId") UUID userId @Param("userId") String userId
); );
/** /**
@ -42,7 +46,7 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
"AND (:search IS NULL OR e.eventName LIKE %:search%) " + "AND (:search IS NULL OR e.eventName LIKE %:search%) " +
"AND (:objective IS NULL OR e.objective = :objective)") "AND (:objective IS NULL OR e.objective = :objective)")
Page<Event> findEventsByUser( Page<Event> findEventsByUser(
@Param("userId") UUID userId, @Param("userId") String userId,
@Param("status") EventStatus status, @Param("status") EventStatus status,
@Param("search") String search, @Param("search") String search,
@Param("objective") String objective, @Param("objective") String objective,
@ -52,5 +56,5 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
/** /**
* 사용자별 이벤트 개수 조회 (상태별) * 사용자별 이벤트 개수 조회 (상태별)
*/ */
long countByUserIdAndStatus(UUID userId, EventStatus status); long countByUserIdAndStatus(String userId, EventStatus status);
} }

View File

@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* 생성된 이미지 Repository * 생성된 이미지 Repository
@ -15,15 +14,15 @@ import java.util.UUID;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, UUID> { public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, String> {
/** /**
* 이벤트별 생성된 이미지 목록 조회 * 이벤트별 생성된 이미지 목록 조회
*/ */
List<GeneratedImage> findByEventEventId(UUID eventId); List<GeneratedImage> findByEventEventId(String eventId);
/** /**
* 이벤트별 선택된 이미지 조회 * 이벤트별 선택된 이미지 조회
*/ */
GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId); GeneratedImage findByEventEventIdAndIsSelectedTrue(String eventId);
} }

View File

@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
/** /**
* 비동기 작업 Repository * 비동기 작업 Repository
@ -18,22 +17,22 @@ import java.util.UUID;
* @since 2025-10-23 * @since 2025-10-23
*/ */
@Repository @Repository
public interface JobRepository extends JpaRepository<Job, UUID> { public interface JobRepository extends JpaRepository<Job, String> {
/** /**
* 이벤트별 작업 목록 조회 * 이벤트별 작업 목록 조회
*/ */
List<Job> findByEventId(UUID eventId); List<Job> findByEventId(String eventId);
/** /**
* 이벤트 작업 유형별 조회 * 이벤트 작업 유형별 조회
*/ */
Optional<Job> findByEventIdAndJobType(UUID eventId, JobType jobType); Optional<Job> findByEventIdAndJobType(String eventId, JobType jobType);
/** /**
* 이벤트 작업 유형별 최신 작업 조회 * 이벤트 작업 유형별 최신 작업 조회
*/ */
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType); Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(String eventId, JobType jobType);
/** /**
* 상태별 작업 목록 조회 * 상태별 작업 목록 조회

View File

@ -18,8 +18,6 @@ import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/** /**
* AI 이벤트 생성 작업 메시지 구독 Consumer * AI 이벤트 생성 작업 메시지 구독 Consumer
* *
@ -30,7 +28,8 @@ import java.util.UUID;
* @since 2025-10-29 * @since 2025-10-29
*/ */
@Slf4j @Slf4j
@Component // TODO: 별도 response 토픽 사용 활성화
// @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class AIJobKafkaConsumer { public class AIJobKafkaConsumer {
@ -93,7 +92,7 @@ public class AIJobKafkaConsumer {
@Transactional @Transactional
protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) { protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) {
try { try {
UUID jobId = UUID.fromString(message.getJobId()); String jobId = message.getJobId();
// Job 조회 // Job 조회
Job job = jobRepository.findById(jobId).orElse(null); Job job = jobRepository.findById(jobId).orElse(null);
@ -102,7 +101,7 @@ public class AIJobKafkaConsumer {
return; return;
} }
UUID eventId = job.getEventId(); String eventId = job.getEventId();
// Event 조회 (모든 케이스에서 사용) // Event 조회 (모든 케이스에서 사용)
Event event = eventRepository.findById(eventId).orElse(null); Event event = eventRepository.findById(eventId).orElse(null);
@ -142,7 +141,7 @@ public class AIJobKafkaConsumer {
eventId, aiData.getEventTitle()); eventId, aiData.getEventTitle());
// 사용자에게 알림 전송 // 사용자에게 알림 전송
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobCompleted( notificationService.notifyJobCompleted(
userId, userId,
jobId, jobId,
@ -166,7 +165,7 @@ public class AIJobKafkaConsumer {
// 사용자에게 실패 알림 전송 // 사용자에게 실패 알림 전송
if (event != null) { if (event != null) {
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobFailed( notificationService.notifyJobFailed(
userId, userId,
jobId, jobId,
@ -185,7 +184,7 @@ public class AIJobKafkaConsumer {
// 사용자에게 진행 상태 알림 전송 // 사용자에게 진행 상태 알림 전송
if (event != null) { if (event != null) {
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobProgress( notificationService.notifyJobProgress(
userId, userId,
jobId, jobId,

View File

@ -1,6 +1,5 @@
package com.kt.event.eventservice.infrastructure.kafka; package com.kt.event.eventservice.infrastructure.kafka;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage; import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -27,7 +26,6 @@ import java.util.concurrent.CompletableFuture;
public class AIJobKafkaProducer { public class AIJobKafkaProducer {
private final KafkaTemplate<String, Object> kafkaTemplate; private final KafkaTemplate<String, Object> kafkaTemplate;
private final ObjectMapper objectMapper;
@Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}") @Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}")
private String aiEventGenerationJobTopic; private String aiEventGenerationJobTopic;
@ -35,28 +33,38 @@ public class AIJobKafkaProducer {
/** /**
* AI 이벤트 생성 작업 메시지 발행 * AI 이벤트 생성 작업 메시지 발행
* *
* @param jobId 작업 ID (UUID String) * @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8})
* @param userId 사용자 ID (UUID String) * @param userId 사용자 ID
* @param eventId 이벤트 ID (UUID String) * @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
* @param storeName 매장명 * @param storeName 매장명
* @param storeCategory 매장 업종 * @param industry 업종 (매장 카테고리)
* @param storeDescription 매장 설명 * @param region 지역
* @param objective 이벤트 목적 * @param objective 이벤트 목적
* @param targetAudience 목표 고객층 (선택)
* @param budget 예산 (선택)
*/ */
public void publishAIGenerationJob( public void publishAIGenerationJob(
String jobId, String jobId,
String userId, String userId,
String eventId, String eventId,
String storeName, String storeName,
String storeCategory, String industry,
String storeDescription, String region,
String objective) { String objective,
String targetAudience,
Integer budget) {
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder() AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
.jobId(jobId) .jobId(jobId)
.userId(userId) .userId(userId)
.status("PENDING") .eventId(eventId)
.createdAt(LocalDateTime.now()) .storeName(storeName)
.industry(industry)
.region(region)
.objective(objective)
.targetAudience(targetAudience)
.budget(budget)
.requestedAt(LocalDateTime.now())
.build(); .build();
publishMessage(message); publishMessage(message);
@ -69,11 +77,9 @@ public class AIJobKafkaProducer {
*/ */
public void publishMessage(AIEventGenerationJobMessage message) { public void publishMessage(AIEventGenerationJobMessage message) {
try { try {
// JSON 문자열로 변환 // 객체를 직접 전송 (JsonSerializer가 자동으로 직렬화)
String jsonMessage = objectMapper.writeValueAsString(message);
CompletableFuture<SendResult<String, Object>> future = CompletableFuture<SendResult<String, Object>> future =
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), jsonMessage); kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), message);
future.whenComplete((result, ex) -> { future.whenComplete((result, ex) -> {
if (ex == null) { if (ex == null) {

View File

@ -29,12 +29,12 @@ public class EventKafkaProducer {
/** /**
* 이벤트 생성 완료 메시지 발행 * 이벤트 생성 완료 메시지 발행
* *
* @param eventId 이벤트 ID (UUID) * @param eventId 이벤트 ID
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param title 이벤트 제목 * @param title 이벤트 제목
* @param eventType 이벤트 타입 * @param eventType 이벤트 타입
*/ */
public void publishEventCreated(java.util.UUID eventId, java.util.UUID userId, String title, String eventType) { public void publishEventCreated(String eventId, String userId, String title, String eventType) {
EventCreatedMessage message = EventCreatedMessage.builder() EventCreatedMessage message = EventCreatedMessage.builder()
.eventId(eventId) .eventId(eventId)
.userId(userId) .userId(userId)

View File

@ -18,8 +18,6 @@ import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/** /**
* 이미지 생성 작업 메시지 구독 Consumer * 이미지 생성 작업 메시지 구독 Consumer
* *
@ -94,8 +92,8 @@ public class ImageJobKafkaConsumer {
@Transactional @Transactional
protected void processImageGenerationJob(ImageGenerationJobMessage message) { protected void processImageGenerationJob(ImageGenerationJobMessage message) {
try { try {
UUID jobId = UUID.fromString(message.getJobId()); String jobId = message.getJobId();
UUID eventId = UUID.fromString(message.getEventId()); String eventId = message.getEventId();
// Job 조회 // Job 조회
Job job = jobRepository.findById(jobId).orElse(null); Job job = jobRepository.findById(jobId).orElse(null);
@ -130,7 +128,7 @@ public class ImageJobKafkaConsumer {
eventId, message.getImageUrl()); eventId, message.getImageUrl());
// 사용자에게 알림 전송 // 사용자에게 알림 전송
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobCompleted( notificationService.notifyJobCompleted(
userId, userId,
jobId, jobId,
@ -181,7 +179,7 @@ public class ImageJobKafkaConsumer {
// 사용자에게 실패 알림 전송 // 사용자에게 실패 알림 전송
if (event != null) { if (event != null) {
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobFailed( notificationService.notifyJobFailed(
userId, userId,
jobId, jobId,
@ -202,7 +200,7 @@ public class ImageJobKafkaConsumer {
// 사용자에게 진행 상태 알림 전송 // 사용자에게 진행 상태 알림 전송
if (event != null) { if (event != null) {
UUID userId = event.getUserId(); String userId = event.getUserId();
notificationService.notifyJobProgress( notificationService.notifyJobProgress(
userId, userId,
jobId, jobId,

View File

@ -35,9 +35,9 @@ public class ImageJobKafkaProducer {
/** /**
* 이미지 생성 작업 메시지 발행 * 이미지 생성 작업 메시지 발행
* *
* @param jobId 작업 ID (UUID) * @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8})
* @param userId 사용자 ID (UUID) * @param userId 사용자 ID
* @param eventId 이벤트 ID (UUID) * @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
* @param prompt 이미지 생성 프롬프트 * @param prompt 이미지 생성 프롬프트
*/ */
public void publishImageGenerationJob( public void publishImageGenerationJob(

View File

@ -4,8 +4,6 @@ import com.kt.event.eventservice.application.service.NotificationService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.UUID;
/** /**
* 로깅 기반 알림 서비스 구현 * 로깅 기반 알림 서비스 구현
* *
@ -20,16 +18,16 @@ import java.util.UUID;
public class LoggingNotificationService implements NotificationService { public class LoggingNotificationService implements NotificationService {
@Override @Override
public void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message) { public void notifyJobCompleted(String userId, String jobId, String jobType, String message) {
log.info("📢 [작업 완료 알림] UserId: {}, JobId: {}, JobType: {}, Message: {}", log.info("📢 [작업 완료 알림] UserId: {}, JobId: {}, JobType: {}, Message: {}",
userId, jobId, jobType, message); userId, jobId, jobType, message);
// TODO: WebSocket, SSE, 또는 Push Notification으로 실시간 알림 전송 // TODO: WebSocket, SSE, 또는 Push Notification으로 실시간 알림 전송
// : webSocketTemplate.convertAndSendToUser(userId.toString(), "/queue/notifications", notification); // : webSocketTemplate.convertAndSendToUser(userId, "/queue/notifications", notification);
} }
@Override @Override
public void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage) { public void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage) {
log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}", log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}",
userId, jobId, jobType, errorMessage); userId, jobId, jobType, errorMessage);
@ -37,7 +35,7 @@ public class LoggingNotificationService implements NotificationService {
} }
@Override @Override
public void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress) { public void notifyJobProgress(String userId, String jobId, String jobType, int progress) {
log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%", log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%",
userId, jobId, jobType, progress); userId, jobId, jobType, progress);

View File

@ -21,8 +21,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/** /**
* 이벤트 컨트롤러 * 이벤트 컨트롤러
* *
@ -34,7 +32,7 @@ import java.util.UUID;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/events") @RequestMapping("/events")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Event", description = "이벤트 관리 API") @Tag(name = "Event", description = "이벤트 관리 API")
public class EventController { public class EventController {
@ -129,7 +127,7 @@ public class EventController {
@GetMapping("/{eventId}") @GetMapping("/{eventId}")
@Operation(summary = "이벤트 상세 조회", description = "특정 이벤트의 상세 정보를 조회합니다.") @Operation(summary = "이벤트 상세 조회", description = "특정 이벤트의 상세 정보를 조회합니다.")
public ResponseEntity<ApiResponse<EventDetailResponse>> getEvent( public ResponseEntity<ApiResponse<EventDetailResponse>> getEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}", log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}",
@ -150,7 +148,7 @@ public class EventController {
@DeleteMapping("/{eventId}") @DeleteMapping("/{eventId}")
@Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다. DRAFT 상태만 삭제 가능합니다.") @Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다. DRAFT 상태만 삭제 가능합니다.")
public ResponseEntity<ApiResponse<Void>> deleteEvent( public ResponseEntity<ApiResponse<Void>> deleteEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}", log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}",
@ -171,7 +169,7 @@ public class EventController {
@PostMapping("/{eventId}/publish") @PostMapping("/{eventId}/publish")
@Operation(summary = "이벤트 배포", description = "이벤트를 배포합니다. DRAFT → PUBLISHED 상태 변경.") @Operation(summary = "이벤트 배포", description = "이벤트를 배포합니다. DRAFT → PUBLISHED 상태 변경.")
public ResponseEntity<ApiResponse<Void>> publishEvent( public ResponseEntity<ApiResponse<Void>> publishEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}", log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}",
@ -192,7 +190,7 @@ public class EventController {
@PostMapping("/{eventId}/end") @PostMapping("/{eventId}/end")
@Operation(summary = "이벤트 종료", description = "이벤트를 종료합니다. PUBLISHED → ENDED 상태 변경.") @Operation(summary = "이벤트 종료", description = "이벤트를 종료합니다. PUBLISHED → ENDED 상태 변경.")
public ResponseEntity<ApiResponse<Void>> endEvent( public ResponseEntity<ApiResponse<Void>> endEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}", log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}",
@ -214,7 +212,7 @@ public class EventController {
@PostMapping("/{eventId}/images") @PostMapping("/{eventId}/images")
@Operation(summary = "이미지 생성 요청", description = "AI를 통해 이벤트 이미지를 생성합니다.") @Operation(summary = "이미지 생성 요청", description = "AI를 통해 이벤트 이미지를 생성합니다.")
public ResponseEntity<ApiResponse<ImageGenerationResponse>> requestImageGeneration( public ResponseEntity<ApiResponse<ImageGenerationResponse>> requestImageGeneration(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody ImageGenerationRequest request, @Valid @RequestBody ImageGenerationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@ -243,8 +241,8 @@ public class EventController {
@PutMapping("/{eventId}/images/{imageId}/select") @PutMapping("/{eventId}/images/{imageId}/select")
@Operation(summary = "이미지 선택", description = "생성된 이미지 중 하나를 선택합니다.") @Operation(summary = "이미지 선택", description = "생성된 이미지 중 하나를 선택합니다.")
public ResponseEntity<ApiResponse<Void>> selectImage( public ResponseEntity<ApiResponse<Void>> selectImage(
@PathVariable UUID eventId, @PathVariable String eventId,
@PathVariable UUID imageId, @PathVariable String imageId,
@Valid @RequestBody SelectImageRequest request, @Valid @RequestBody SelectImageRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@ -272,7 +270,7 @@ public class EventController {
@PostMapping("/{eventId}/ai-recommendations") @PostMapping("/{eventId}/ai-recommendations")
@Operation(summary = "AI 추천 요청", description = "AI 서비스에 이벤트 추천 생성을 요청합니다.") @Operation(summary = "AI 추천 요청", description = "AI 서비스에 이벤트 추천 생성을 요청합니다.")
public ResponseEntity<ApiResponse<JobAcceptedResponse>> requestAiRecommendations( public ResponseEntity<ApiResponse<JobAcceptedResponse>> requestAiRecommendations(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody AiRecommendationRequest request, @Valid @RequestBody AiRecommendationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@ -300,7 +298,7 @@ public class EventController {
@PutMapping("/{eventId}/recommendations") @PutMapping("/{eventId}/recommendations")
@Operation(summary = "AI 추천 선택", description = "AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다.") @Operation(summary = "AI 추천 선택", description = "AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다.")
public ResponseEntity<ApiResponse<Void>> selectRecommendation( public ResponseEntity<ApiResponse<Void>> selectRecommendation(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody SelectRecommendationRequest request, @Valid @RequestBody SelectRecommendationRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@ -328,8 +326,8 @@ public class EventController {
@PutMapping("/{eventId}/images/{imageId}/edit") @PutMapping("/{eventId}/images/{imageId}/edit")
@Operation(summary = "이미지 편집", description = "선택된 이미지를 편집합니다.") @Operation(summary = "이미지 편집", description = "선택된 이미지를 편집합니다.")
public ResponseEntity<ApiResponse<ImageEditResponse>> editImage( public ResponseEntity<ApiResponse<ImageEditResponse>> editImage(
@PathVariable UUID eventId, @PathVariable String eventId,
@PathVariable UUID imageId, @PathVariable String imageId,
@Valid @RequestBody ImageEditRequest request, @Valid @RequestBody ImageEditRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@ -357,7 +355,7 @@ public class EventController {
@PutMapping("/{eventId}/channels") @PutMapping("/{eventId}/channels")
@Operation(summary = "배포 채널 선택", description = "이벤트를 배포할 채널을 선택합니다.") @Operation(summary = "배포 채널 선택", description = "이벤트를 배포할 채널을 선택합니다.")
public ResponseEntity<ApiResponse<Void>> selectChannels( public ResponseEntity<ApiResponse<Void>> selectChannels(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody SelectChannelsRequest request, @Valid @RequestBody SelectChannelsRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {
@ -384,7 +382,7 @@ public class EventController {
@PutMapping("/{eventId}") @PutMapping("/{eventId}")
@Operation(summary = "이벤트 수정", description = "기존 이벤트의 정보를 수정합니다. DRAFT 상태만 수정 가능합니다.") @Operation(summary = "이벤트 수정", description = "기존 이벤트의 정보를 수정합니다. DRAFT 상태만 수정 가능합니다.")
public ResponseEntity<ApiResponse<EventDetailResponse>> updateEvent( public ResponseEntity<ApiResponse<EventDetailResponse>> updateEvent(
@PathVariable UUID eventId, @PathVariable String eventId,
@Valid @RequestBody UpdateEventRequest request, @Valid @RequestBody UpdateEventRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) { @AuthenticationPrincipal UserPrincipal userPrincipal) {

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