Compare commits

..

55 Commits

Author SHA1 Message Date
Hyowon Yang
2c4f2b0516 CORS설정변경 2025-10-30 23:10:20 +09:00
Hyowon Yang
6280ff8ce1 샘플데이터 json저장 2025-10-30 22:16:18 +09:00
cherry2250
c66decce42 event-service Kafka Snappy 압축 오류 수정 및 설정 개선
- Dockerfile에 gcompat 패키지 추가하여 Snappy 네이티브 라이브러리 지원
- application.yml에 AI Service Feign Client URL 설정 추가
- deployment.yaml 수정:
  * 이미지 태그를 latest에서 dev로 변경
  * Health check 경로 수정 (/api/v1/events/actuator → /api/v1/actuator)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 21:56:41 +09:00
이선민
9ce62738a1
Merge pull request #38 from ktds-dg0501/feature/distribution
Feature/distribution
2025-10-30 20:42:22 +09:00
sunmingLee
06ea838547 Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into feature/distribution 2025-10-30 20:30:54 +09:00
sunmingLee
efcec065ec 네이버 블로그 포스팅 선택사항으로 고쳐서 도커 ì파일 ã… 수정 2025-10-30 20:30:44 +09:00
SWPARK
262a5fea33
Merge pull request #37 from ktds-dg0501/feature/ai
edit
2025-10-30 20:12:49 +09:00
박세원
d14a7349bc edit 2025-10-30 20:12:14 +09:00
Hyowon Yang
6e7a9386f6 CORS설정변경 2025-10-30 19:34:27 +09:00
이선민
047703fb89
Merge pull request #36 from ktds-dg0501/feature/distribution
merge feature/distribution into develop
2025-10-30 19:01:35 +09:00
Hyowon Yang
17278ad045 샘플데이터 수정 2025-10-30 18:47:28 +09:00
SWPARK
cf379407e8
Merge pull request #35 from ktds-dg0501/feature/ai
remove api path
2025-10-30 18:43:35 +09:00
박세원
f13bfe6a6e remove api path 2025-10-30 18:42:49 +09:00
sunmingLee
4bc7f87663 Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into feature/distribution 2025-10-30 18:38:14 +09:00
sunmingLee
ae8f540d46 네이버 블로그 ìž 배포 개발(ì이미지 없음) 2025-10-30 18:37:31 +09:00
kkkd-max
c6dfc74bda
Merge pull request #34 from ktds-dg0501/feature/ai
Feature/ai
2025-10-30 18:10:47 +09:00
jhbkjh
027ab86e8d 파티시페이션 2025-10-30 18:07:28 +09:00
박세원
c95c47d630 edit api 2025-10-30 18:06:18 +09:00
Hyowon Yang
b92307d564 Analytics 서비스 인증 제거 - 전체 접근 허용
- SecurityConfig를 content-service처럼 단순화
- 모든 요청에 대해 인증 없이 접근 가능하도록 변경
- Swagger UI 및 API 엔드포인트 접근 문제 해결

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 18:03:28 +09:00
Hyowon Yang
2663baf615 Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into develop 2025-10-30 17:50:19 +09:00
Hyowon Yang
349b644617 Analytics 서비스 Swagger 및 보안 설정 개선
- Redis read-only replica 에러 처리 추가 (SampleDataLoader)
  - MVP 환경에서 샘플 데이터 로딩 시 Redis 삭제 실패해도 계속 진행
- Swagger UI context-path 설정 수정 (SwaggerConfig)
  - 서버 URL에 /api/v1/analytics context-path 포함하여 올바른 curl 명령 생성
- Spring Security 경로 매칭 수정 (SecurityConfig)
  - context-path 제거된 실제 경로 (/events/**, /users/**) 매칭
  - 403 Forbidden 에러 해결
- Dockerfile 빌드 경로 수정
  - 멀티 모듈 프로젝트 구조에 맞게 JAR 복사 경로 수정

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 17:49:47 +09:00
SWPARK
ea4d551d3e
Merge pull request #33 from ktds-dg0501/feature/ai
edit CORS error
2025-10-30 17:39:10 +09:00
박세원
d81c5be90d edit CORS error 2025-10-30 17:38:02 +09:00
박세원
e080acbcb9 Merge branch 'develop' of https://github.com/ktds-dg0501/kt-event-marketing into develop 2025-10-30 17:04:31 +09:00
박세원
29285d8576 AI Service CORS 설정 추가로 Swagger UI 테스트 지원
- SecurityConfig에 CORS 설정 추가
- 모든 Origin 허용 (AllowedOriginPatterns: *)
- 모든 HTTP Method 허용 (GET, POST, PUT, DELETE, OPTIONS, PATCH)
- 모든 Header 허용
- Credentials 지원
- Swagger UI에서 API 테스트 시 CORS 에러 해결

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 17:04:11 +09:00
kkkd-max
f2e8f7499f
Merge pull request #32 from ktds-dg0501/feature/partici2
Feature/partici2
2025-10-30 17:01:19 +09:00
jhbkjh
52b63fb0f0 frontend 연동을 위해 임시 커밋 2025-10-30 16:49:31 +09:00
박세원
a23b4eb505 Merge branch 'feature/ai' into develop 2025-10-30 16:45:26 +09:00
박세원
c6b33885e0 AI Service Security 설정 단순화 및 워크플로우 문서 추가
- SecurityConfig CORS 설정 제거 및 단순화
- 모든 요청 허용으로 변경 (내부 API 특성 반영)
- DevTools 요청 정적 리소스 제외 처리
- AI Service 워크플로우 문서 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 16:44:23 +09:00
jhbkjh
ac7fcbd2fe api경로 수정(participation) 2025-10-30 16:21:10 +09:00
Hyowon Yang
97f50fd751 Analytics Service context-path 설정 및 Controller 경로 최적화
- context-path 추가: /api/v1/analytics
- Swagger UI 경로를 기본값으로 수정 (/swagger-ui.html)
- 모든 Controller의 @RequestMapping에서 /api/v1 제거
  - Events 관련 Controller 4개: /api/v1/events → /events
  - Users 관련 Controller 4개: /api/v1/users → /users
  - DebugController: /api/debug → /debug

이제 Ingress를 통한 접근 및 Swagger UI가 정상 작동합니다.
- Swagger UI: /api/v1/analytics/swagger-ui/index.html
- API: /api/v1/analytics/events/{eventId}/analytics

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 16:16:24 +09:00
merrycoral
c53cbdf4f8 Merge feature/event into develop
Event-AI Kafka 통신 개선 및 타입 헤더 불일치 문제 해결

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 15:59:46 +09:00
merrycoral
7dc039361f Event-AI Kafka 통신 개선 및 타입 헤더 불일치 문제 해결
주요 변경사항:
- event-service KafkaConfig: JsonSerializer로 변경, 타입 헤더 비활성화
- ai-service application.yml: 타입 헤더 사용 안 함, 기본 타입 지정
- AIEventGenerationJobMessage: region, targetAudience, budget 필드 추가
- AiRecommendationRequest: region, targetAudience, budget 필드 추가
- AIJobKafkaProducer: 객체 직접 전송으로 변경 (이중 직렬화 문제 해결)
- AIJobKafkaConsumer: 양방향 통신 이슈로 비활성화 (.bak)
- EventService: Kafka producer 호출 시 새 필드 전달

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 15:58:23 +09:00
kkkd-max
48c76db83a
Merge pull request #31 from ktds-dg0501/feature/partici2
security 수정
2025-10-30 15:47:30 +09:00
jhbkjh
72728841db security 수정 2025-10-30 15:45:22 +09:00
Hyowon Yang
9e2d0a3889 Analytics Service Swagger 설정 개선
- Swagger UI 경로를 Ingress 경로와 일치하도록 수정 (/api/v1/analytics/swagger-ui.html)
- AKS 환경 서버 URL을 Swagger 서버 목록에 추가
- API 테스트 편의성 향상

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 15:31:32 +09:00
Hyowon Yang
14823a17c4 Analytics 서비스 CORS 설정 추가
- WebConfig.java 추가하여 CORS 정책 설정
- 프론트엔드에서 Analytics API 호출 시 CORS 에러 해결
- 모든 origin 패턴 허용 및 credentials 지원

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 14:48:09 +09:00
Hyowon Yang
a3781a279a
Merge pull request #30 from ktds-dg0501/feature/analytics
이벤트별 성과분석 날짜 로직 수정 및 설정 개선
2025-10-30 12:54:56 +09:00
kkkd-max
5c365fe899
Merge pull request #29 from ktds-dg0501/feature/partici2
Feature/partici2
2025-10-30 12:25:44 +09:00
jhbkjh
a3381cc540 cors문제 수정 2025-10-30 12:22:19 +09:00
jhbkjh
7ed2465d57 participation-service CORS 설정 수정
- SecurityConfig.java @Value 어노테이션 문법 오류 수정
- application.yml CORS allowed-origins에 localhost:3000 추가
- Frontend UI (localhost:3000)에서 API 호출 시 CORS 에러 해결
2025-10-30 10:37:36 +09:00
jhbkjh
5cac8ccc12 participation-service WebConfig 추가
- CORS 설정 적용
- 모든 origin 패턴 허용
- 모든 HTTP 메서드 허용
- Credentials 허용
2025-10-30 10:21:08 +09:00
Hyowon Yang
acd827b226 Merge branch 'origin/develop' into develop
- 이벤트 ID 단순화 변경사항 병합 (1, 2, 3)
- 원격 변경사항 통합

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 10:10:47 +09:00
Hyowon Yang
ea53bd13a8 analytics 서비스 샘플 데이터 이벤트 ID 단순화
- 이벤트 ID를 evt_2025012301 형식에서 1, 2, 3으로 변경
- 다른 마이크로서비스와의 연동을 위한 단순 ID 체계 적용

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 10:08:48 +09:00
jhbkjh
6948b48498 participation-service pod 연결 문제 해결
- Service selector를 app=participation-service만으로 간소화하여 영구적 해결
- Pod restart 시에도 자동 연결되도록 수정
- Swagger UI 외부 접근 정상화 확인

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 09:47:45 +09:00
Hyowon Yang
aa8db3bf2f
Merge pull request #27 from ktds-dg0501/feature/analytics
Feature/analytics
2025-10-30 09:40:29 +09:00
sunmingLee
be59934f78 swagger 문제 수정 2025-10-30 09:37:54 +09:00
hiondal
3afee053d0 Kustomize commonLabels 제거 및 API 토큰 추가
- Deployment selector 불변성 에러 해결을 위해 commonLabels 제거
- base/kustomization.yaml: app.kubernetes.io/managed-by, app.kubernetes.io/part-of 레이블 제거
- overlays/dev/kustomization.yaml: environment 레이블 제거
- content-service: Replicate API 토큰 추가
2025-10-30 09:35:17 +09:00
merrycoral
27a3111dd8 develop 브랜치 변경사항 요약 문서 작성
- feature/event 머지 내역 상세 정리
- EventId/JobId 생성 로직 설명
- Kafka 메시지 구조 개선 내역
- 데이터베이스 스키마 변경사항
- 테스트 및 문서화 완료 내역
- 성능 지표 및 배포 준비 상태

총 60개 파일 변경 (+2,795줄, -222줄)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 01:45:46 +09:00
merrycoral
3465a35827 Merge branch 'feature/event' into develop 2025-10-30 01:42:33 +09:00
merrycoral
8ff79ca1ab 테스트 결과 파일들을 test/ 폴더로 이동
- API-TEST-RESULT.md → test/
- content-service-integration-analysis.md → test/
- content-service-integration-test-results.md → test/
- test-kafka-integration-results.md → test/

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 20:54:10 +09:00
merrycoral
34291e1613 백엔드 서비스 구조 개선 및 데이터베이스 스키마 추가 2025-10-29 17:51:48 +09:00
115 changed files with 5698 additions and 749 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

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

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

@ -23,6 +23,11 @@
<env name="KAFKA_CONSUMER_GROUP" value="distribution-service" /> <env name="KAFKA_CONSUMER_GROUP" value="distribution-service" />
<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="NAVER_BLOG_USERNAME" value="" />
<env name="NAVER_BLOG_PASSWORD" value="" />
<env name="NAVER_BLOG_BLOG_ID" value="" />
<env name="NAVER_BLOG_HEADLESS" value="false" />
<env name="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
</envs> </envs>
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />

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

@ -32,7 +32,7 @@ public class HealthController {
* 서비스 헬스체크 * 서비스 헬스체크
*/ */
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인") @Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
@GetMapping("/api/v1/ai-service/health") @GetMapping("/health")
public ResponseEntity<HealthCheckResponse> healthCheck() { public ResponseEntity<HealthCheckResponse> healthCheck() {
// Redis 상태 확인 // Redis 상태 확인
ServiceStatus redisStatus = checkRedis(); ServiceStatus redisStatus = checkRedis();

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:
@ -37,7 +39,7 @@ spring:
server: server:
port: ${SERVER_PORT:8083} port: ${SERVER_PORT:8083}
servlet: servlet:
context-path: /api/v1/ai-service context-path: /api/v1/ai
encoding: encoding:
charset: UTF-8 charset: UTF-8
enabled: true enabled: true
@ -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

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

@ -11,6 +11,7 @@ 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 lombok.Data;
import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult; import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult;
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult; import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
@ -18,6 +19,7 @@ import org.apache.kafka.common.TopicPartition;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.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.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaAdmin; import org.springframework.kafka.core.KafkaAdmin;
@ -25,6 +27,7 @@ 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.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -69,6 +72,8 @@ public class SampleDataLoader implements ApplicationRunner {
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered"; private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed"; private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed";
private SampleDataConfig sampleDataConfig;
@Override @Override
@Transactional @Transactional
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
@ -93,34 +98,47 @@ public class SampleDataLoader implements ApplicationRunner {
// Redis 멱등성 삭제 (새로운 이벤트 처리를 위해) // Redis 멱등성 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중..."); log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events_v2"); try {
redisTemplate.delete("distribution_completed_v2"); redisTemplate.delete("processed_events_v2");
redisTemplate.delete("processed_participants_v2"); 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개 이벤트) // JSON 파일에서 샘플 데이터 로드
log.info("📄 sample-data.json 파일 로드 중...");
sampleDataConfig = loadSampleData();
log.info("✅ sample-data.json 로드 완료: 이벤트 {}건, 배포 {}건, 참여자 패턴 {}건",
sampleDataConfig.getEvents().size(),
sampleDataConfig.getDistributions().size(),
sampleDataConfig.getParticipants().size());
// 1. EventCreated 이벤트 발행
publishEventCreatedEvents(); publishEventCreatedEvents();
log.info("⏳ EventStats 생성 대기 중... (5초)"); log.info("⏳ EventStats 생성 대기 중... (5초)");
Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간 Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간
// 2. DistributionCompleted 이벤트 발행 ( 이벤트당 4개 채널) // 2. DistributionCompleted 이벤트 발행
publishDistributionCompletedEvents(); publishDistributionCompletedEvents();
log.info("⏳ ChannelStats 생성 대기 중... (3초)"); log.info("⏳ ChannelStats 생성 대기 중... (3초)");
Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간 Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간
// 3. ParticipantRegistered 이벤트 발행 ( 이벤트당 다수 참여자) // 3. ParticipantRegistered 이벤트 발행
publishParticipantRegisteredEvents(); int totalParticipants = publishParticipantRegisteredEvents();
log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)"); log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 고려) Thread.sleep(20000); // ParticipantRegisteredConsumer가 이벤트 처리할 시간 (비관적 고려)
log.info("========================================"); log.info("========================================");
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)"); log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
log.info("========================================"); log.info("========================================");
log.info("발행된 이벤트:"); log.info("발행된 이벤트:");
log.info(" - EventCreated: 3건"); log.info(" - EventCreated: {}건", sampleDataConfig.getEvents().size());
log.info(" - DistributionCompleted: 3건 (각 이벤트당 4개 채널 배열)"); log.info(" - DistributionCompleted: {}건", sampleDataConfig.getDistributions().size());
log.info(" - ParticipantRegistered: 180건 (MVP 테스트용)"); log.info(" - ParticipantRegistered: {}건", totalParticipants);
log.info("========================================"); log.info("========================================");
// Consumer 처리 대기 (5초) // Consumer 처리 대기 (5초)
@ -215,189 +233,135 @@ public class SampleDataLoader implements ApplicationRunner {
} }
/** /**
* EventCreated 이벤트 발행 * EventCreated 이벤트 발행 (JSON 기반)
*/ */
private void publishEventCreatedEvents() throws Exception { private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%) for (EventData eventData : sampleDataConfig.getEvents()) {
EventCreatedEvent event1 = EventCreatedEvent.builder() EventCreatedEvent event = EventCreatedEvent.builder()
.eventId("evt_2025012301") .eventId(eventData.getEventId())
.eventTitle("신년맞이 20% 할인 이벤트") .eventTitle(eventData.getEventTitle())
.storeId("store_001") .storeId(eventData.getStoreId())
.totalInvestment(new BigDecimal("5000000")) .totalInvestment(eventData.getTotalInvestment())
.expectedRevenue(new BigDecimal("15000000")) // 투자 대비 3배 수익 .expectedRevenue(eventData.getExpectedRevenue())
.status("ACTIVE") .status(eventData.getStatus())
.startDate(java.time.LocalDateTime.of(2025, 1, 23, 0, 0)) // 2025-01-23 시작 .startDate(parseDateTime(eventData.getStartDate()))
.endDate(null) // 진행중 .endDate(eventData.getEndDate() != null ? parseDateTime(eventData.getEndDate()) : null)
.build(); .build();
publishEvent(EVENT_CREATED_TOPIC, event1);
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%) publishEvent(EVENT_CREATED_TOPIC, event);
EventCreatedEvent event2 = EventCreatedEvent.builder() log.info(" → EventCreated 발행: eventId={}, title={}",
.eventId("evt_2025020101") eventData.getEventId(), eventData.getEventTitle());
.eventTitle("설날 특가 선물세트 이벤트") }
.storeId("store_001")
.totalInvestment(new BigDecimal("3500000"))
.expectedRevenue(new BigDecimal("7000000")) // 투자 대비 2배 수익
.status("ACTIVE")
.startDate(java.time.LocalDateTime.of(2025, 2, 1, 0, 0)) // 2025-02-01 시작
.endDate(null) // 진행중
.build();
publishEvent(EVENT_CREATED_TOPIC, event2);
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%) log.info("✅ EventCreated 이벤트 {}건 발행 완료", sampleDataConfig.getEvents().size());
EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("evt_2025011501")
.eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("2000000"))
.expectedRevenue(new BigDecimal("3000000")) // 투자 대비 1.5배 수익
.status("COMPLETED")
.startDate(java.time.LocalDateTime.of(2025, 1, 15, 0, 0)) // 2025-01-15 시작
.endDate(java.time.LocalDateTime.of(2025, 1, 31, 23, 59)) // 2025-01-31 종료
.build();
publishEvent(EVENT_CREATED_TOPIC, event3);
log.info("✅ EventCreated 이벤트 3건 발행 완료");
} }
/** /**
* DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열) * ISO 8601 형식 문자열을 LocalDateTime으로 파싱
*/
private java.time.LocalDateTime parseDateTime(String dateTimeStr) {
return java.time.LocalDateTime.parse(dateTimeStr);
}
/**
* DistributionCompleted 이벤트 발행 (JSON 기반)
*/ */
private void publishDistributionCompletedEvents() throws Exception { private void publishDistributionCompletedEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; double channelBudgetRatio = sampleDataConfig.getConfig().getChannelBudgetRatio();
int[][] expectedViews = {
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
{3500, 7000, 2000, 1500}, // 이벤트2
{1500, 3000, 1000, 500} // 이벤트3
};
// 이벤트의 투자 금액 for (DistributionData distributionData : sampleDataConfig.getDistributions()) {
BigDecimal[] totalInvestments = { String eventId = distributionData.getEventId();
new BigDecimal("5000000"), // 이벤트1: 500만원
new BigDecimal("3500000"), // 이벤트2: 350만원
new BigDecimal("2000000") // 이벤트3: 200만원
};
// 채널 배포는 투자의 50% 사용 (나머지는 경품/콘텐츠/운영비용) // 해당 이벤트의 투자 금액 조회
double channelBudgetRatio = 0.50; EventData eventData = sampleDataConfig.getEvents().stream()
.filter(e -> e.getEventId().equals(eventId))
.findFirst()
.orElseThrow(() -> new IllegalStateException("이벤트를 찾을 수 없습니다: " + eventId));
// 채널별 비용 비율 (채널 예산 내에서: 우리동네TV 30%, 지니TV 30%, 링고비즈 25%, SNS 15%) BigDecimal totalInvestment = eventData.getTotalInvestment();
double[] costRatios = {0.30, 0.30, 0.25, 0.15};
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
BigDecimal totalInvestment = totalInvestments[i];
// 채널 배포 예산: 투자의 50%
BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio)); BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio));
// 4개 채널을 배열로 // 채널 배열 생성
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>(); List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
// 1. 우리동네TV (TV) - 채널 예산의 30% for (ChannelData channelData : distributionData.getChannels()) {
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() DistributionCompletedEvent.ChannelDistribution channel =
.channel("우리동네TV") DistributionCompletedEvent.ChannelDistribution.builder()
.channelType("TV") .channel(channelData.getChannel())
.status("SUCCESS") .channelType(channelData.getChannelType())
.expectedViews(expectedViews[i][0]) .status(channelData.getStatus())
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[0]))) .expectedViews(channelData.getExpectedViews())
.build()); .distributionCost(channelBudget.multiply(BigDecimal.valueOf(channelData.getDistributionCostRatio())))
.build();
// 2. 지니TV (TV) - 채널 예산의 30% channels.add(channel);
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() }
.channel("지니TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][1])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[1])))
.build());
// 3. 링고비즈 (CALL) - 채널 예산의 25% // 이벤트 발행
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("링고비즈")
.channelType("CALL")
.status("SUCCESS")
.expectedViews(expectedViews[i][2])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[2])))
.build());
// 4. SNS (SNS) - 채널 예산의 15%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("SNS")
.channelType("SNS")
.status("SUCCESS")
.expectedViews(expectedViews[i][3])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[3])))
.build());
// 이벤트 발행 (채널 배열 포함)
DistributionCompletedEvent event = DistributionCompletedEvent.builder() DistributionCompletedEvent event = DistributionCompletedEvent.builder()
.eventId(eventId) .eventId(eventId)
.distributedChannels(channels) .distributedChannels(channels)
.completedAt(java.time.LocalDateTime.now()) .completedAt(parseDateTime(distributionData.getCompletedAt()))
.build(); .build();
publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event); publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event);
log.info(" → DistributionCompleted 발행: eventId={}, 채널={}개",
eventId, channels.size());
} }
log.info("✅ DistributionCompleted 이벤트 3건 발행 완료 (3 이벤트 × 4 채널 배열)"); log.info("✅ DistributionCompleted 이벤트 {}건 발행 완료", sampleDataConfig.getDistributions().size());
} }
/** /**
* ParticipantRegistered 이벤트 발행 * ParticipantRegistered 이벤트 발행 (JSON 기반)
*
* 현실적인 참여 패턴 반영:
* - 120명의 고유 참여자 생성
* - 일부 참여자는 여러 이벤트에 중복 참여
* - 이벤트1: 100명 (user001~user100)
* - 이벤트2: 50명 (user051~user100) 50명이 이벤트1과 중복
* - 이벤트3: 30명 (user071~user100) 30명이 이전 이벤트들과 중복
*/ */
private void publishParticipantRegisteredEvents() throws Exception { private int publishParticipantRegisteredEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; String participantIdPrefix = sampleDataConfig.getConfig().getParticipantIdPrefix();
String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"}; int participantIdPadding = sampleDataConfig.getConfig().getParticipantIdPadding();
// 이벤트별 참여자 범위 (중복 참여 반영)
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 (ParticipantData participantData : sampleDataConfig.getParticipants()) {
String eventId = eventIds[i]; String eventId = participantData.getEventId();
int startUser = participantRanges[i][0]; int startUser = participantData.getParticipantRange().getStart();
int endUser = participantRanges[i][1]; int endUser = participantData.getParticipantRange().getEnd();
int eventParticipants = endUser - startUser + 1; int eventParticipants = endUser - startUser + 1;
log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)", log.info("이벤트 {} 참여자 발행 시작: {}{:0" + participantIdPadding + "d}~{}{:0" + participantIdPadding + "d} ({}명)",
eventId, startUser, endUser, eventParticipants); eventId, participantIdPrefix, startUser, participantIdPrefix, endUser, eventParticipants);
// 채널별 가중치 누적 합계 계산 (: SNS=45, 우리동네TV=70, 지니TV=90, 링고비즈=100)
Map<String, Integer> channelWeights = participantData.getChannelWeights();
List<String> channels = new ArrayList<>(channelWeights.keySet());
int[] cumulativeWeights = new int[channels.size()];
int cumulative = 0;
for (int i = 0; i < channels.size(); i++) {
cumulative += channelWeights.get(channels.get(i));
cumulativeWeights[i] = cumulative;
}
// 참여자에 대해 ParticipantRegistered 이벤트 발행 // 참여자에 대해 ParticipantRegistered 이벤트 발행
for (int userId = startUser; userId <= endUser; userId++) { for (int userId = startUser; userId <= endUser; userId++) {
String participantId = String.format("user%03d", userId); // user001, user002, ... String participantId = String.format("%s%0" + participantIdPadding + "d",
participantIdPrefix, userId);
// 채널별 가중치 기반 랜덤 배정 // 채널별 가중치 기반 랜덤 배정
// SNS: 45%, 우리동네TV: 25%, 지니TV: 20%, 링고비즈: 10% int randomValue = random.nextInt(cumulative);
int randomValue = random.nextInt(100); String channel = channels.get(0); // 기본값
String channel;
if (randomValue < 45) { for (int i = 0; i < cumulativeWeights.length; i++) {
channel = "SNS"; // 0~44: 45% if (randomValue < cumulativeWeights[i]) {
} else if (randomValue < 70) { channel = channels.get(i);
channel = "우리동네TV"; // 45~69: 25% break;
} 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)
.participantId(participantId) .participantId(participantId)
.channel(channel) .channel(channel)
.build(); .build();
publishEvent(PARTICIPANT_REGISTERED_TOPIC, event); publishEvent(PARTICIPANT_REGISTERED_TOPIC, event);
totalPublished++; totalPublished++;
@ -413,24 +377,13 @@ public class SampleDataLoader implements ApplicationRunner {
log.info("========================================"); 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("========================================"); log.info("========================================");
return totalPublished;
} }
/** /**
* TimelineData 생성 (시간대별 샘플 데이터) * TimelineData 생성 (시간대별 샘플 데이터) - JSON 기반
* *
* - 이벤트마다 30일 × 24시간 = 720시간 hourly 데이터 생성 * - 이벤트마다 30일 × 24시간 = 720시간 hourly 데이터 생성
* - interval=hourly: 시간별 표시 (최근 7일 적합) * - interval=hourly: 시간별 표시 (최근 7일 적합)
@ -440,24 +393,32 @@ public class SampleDataLoader implements ApplicationRunner {
private void createTimelineData() { private void createTimelineData() {
log.info("📊 TimelineData 생성 시작..."); log.info("📊 TimelineData 생성 시작...");
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; // 이벤트별 시간당 기준 참여자 계산 (참여자 범위 기반)
List<EventData> events = sampleDataConfig.getEvents();
List<ParticipantData> participants = sampleDataConfig.getParticipants();
// 이벤트별 시간당 기준 참여자 (이벤트 성과에 따라 다름) for (int eventIndex = 0; eventIndex < events.size(); eventIndex++) {
int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음) EventData event = events.get(eventIndex);
String eventId = event.getEventId();
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) { // 해당 이벤트의 참여자 계산
String eventId = eventIds[eventIndex]; ParticipantData participantData = participants.stream()
int baseParticipant = baseParticipantsPerHour[eventIndex]; .filter(p -> p.getEventId().equals(eventId))
.findFirst()
.orElse(null);
int totalParticipants = 100; // 기본값
if (participantData != null) {
totalParticipants = participantData.getParticipantRange().getEnd()
- participantData.getParticipantRange().getStart() + 1;
}
// 30일 × 24시간 = 720시간 데이터로 나눔
int baseParticipant = Math.max(1, totalParticipants / (30 * 24));
int cumulativeParticipants = 0; int cumulativeParticipants = 0;
// 이벤트 ID에서 날짜 파싱 (evt_2025012301 2025-01-23) // 이벤트 시작일 파싱
String dateStr = eventId.substring(4); // "2025012301" java.time.LocalDateTime startDate = parseDateTime(event.getStartDate());
int year = Integer.parseInt(dateStr.substring(0, 4)); // 2025
int month = Integer.parseInt(dateStr.substring(4, 6)); // 01
int day = Integer.parseInt(dateStr.substring(6, 8)); // 23
// 이벤트 시작일부터 30일 hourly 데이터 생성
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0);
for (int dayOffset = 0; dayOffset < 30; dayOffset++) { for (int dayOffset = 0; dayOffset < 30; dayOffset++) {
for (int hour = 0; hour < 24; hour++) { for (int hour = 0; hour < 24; hour++) {
@ -480,25 +441,26 @@ public class SampleDataLoader implements ApplicationRunner {
// TimelineData 생성 // TimelineData 생성
com.kt.event.analytics.entity.TimelineData timelineData = com.kt.event.analytics.entity.TimelineData timelineData =
com.kt.event.analytics.entity.TimelineData.builder() com.kt.event.analytics.entity.TimelineData.builder()
.eventId(eventId) .eventId(eventId)
.timestamp(timestamp) .timestamp(timestamp)
.participants(hourlyParticipants) .participants(hourlyParticipants)
.views(hourlyViews) .views(hourlyViews)
.engagement(hourlyEngagement) .engagement(hourlyEngagement)
.conversions(hourlyConversions) .conversions(hourlyConversions)
.cumulativeParticipants(cumulativeParticipants) .cumulativeParticipants(cumulativeParticipants)
.build(); .build();
timelineDataRepository.save(timelineData); timelineDataRepository.save(timelineData);
} }
} }
log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건", log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}, 30일 × 24시간 = 720건",
eventId, year, month, day); eventId, startDate.toLocalDate());
} }
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건"); log.info("✅ 전체 TimelineData 생성 완료: {}개 이벤트 × 30일 × 24시간 = {}건",
events.size(), events.size() * 30 * 24);
} }
/** /**
@ -508,4 +470,73 @@ public class SampleDataLoader implements ApplicationRunner {
String jsonMessage = objectMapper.writeValueAsString(event); String jsonMessage = objectMapper.writeValueAsString(event);
kafkaTemplate.send(topic, jsonMessage); kafkaTemplate.send(topic, jsonMessage);
} }
/**
* JSON 파일에서 샘플 데이터 로드
*/
private SampleDataConfig loadSampleData() throws IOException {
ClassPathResource resource = new ClassPathResource("sample-data.json");
return objectMapper.readValue(resource.getInputStream(), SampleDataConfig.class);
}
// ========================================
// JSON 데이터 구조 (Inner Classes)
// ========================================
@Data
static class SampleDataConfig {
private List<EventData> events;
private List<DistributionData> distributions;
private List<ParticipantData> participants;
private ConfigData config;
}
@Data
static class EventData {
private String eventId;
private String eventTitle;
private String storeId;
private BigDecimal totalInvestment;
private BigDecimal expectedRevenue;
private String status;
private String startDate; // ISO 8601 형식: "2025-01-23T00:00:00"
private String endDate; // null 가능
private String createdAt;
}
@Data
static class DistributionData {
private String eventId;
private String completedAt;
private List<ChannelData> channels;
}
@Data
static class ChannelData {
private String channel;
private String channelType;
private String status;
private Integer expectedViews;
private Double distributionCostRatio;
}
@Data
static class ParticipantData {
private String eventId;
private ParticipantRange participantRange;
private Map<String, Integer> channelWeights;
}
@Data
static class ParticipantRange {
private Integer start;
private Integer end;
}
@Data
static class ConfigData {
private Double channelBudgetRatio;
private String participantIdPrefix;
private Integer participantIdPadding;
}
} }

View File

@ -1,79 +1,47 @@
package com.kt.event.analytics.config; package com.kt.event.analytics.config;
import com.kt.event.common.security.JwtAuthenticationFilter;
import com.kt.event.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/** /**
* Spring Security 설정 * Spring Security 설정
* JWT 기반 인증 API 보안 설정 * API 테스트를 위해 일단 모든 요청 허용
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins:http://localhost:*}")
private String allowedOrigins;
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http http
.csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화 (REST API는 CSRF 불필요)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth // 세션 사용 (JWT 기반 인증)
// Actuator endpoints .sessionManagement(session ->
.requestMatchers("/actuator/**").permitAll() session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// Swagger UI endpoints )
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
// Health check // 모든 요청 허용 (테스트용)
.requestMatchers("/health").permitAll() .authorizeHttpRequests(auth -> auth
// Analytics API endpoints (테스트 개발 용도로 공개) .anyRequest().permitAll()
.requestMatchers("/api/**").permitAll() );
// All other requests require authentication
.anyRequest().authenticated() return http.build();
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
} }
/**
* Chrome DevTools 요청 정적 리소스 요청을 Spring Security에서 제외
*/
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public WebSecurityCustomizer webSecurityCustomizer() {
CorsConfiguration configuration = new CorsConfiguration(); return (web) -> web.ignoring()
.requestMatchers("/.well-known/**");
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
} }
} }

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 {

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

@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Debug", description = "디버그 API (개발/테스트 전용)") @Tag(name = "Debug", description = "디버그 API (개발/테스트 전용)")
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/debug") @RequestMapping("/debug")
@RequiredArgsConstructor @RequiredArgsConstructor
public class DebugController { public class DebugController {

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 {

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 {

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 {

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 {

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 {

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

@ -76,6 +76,7 @@ spring:
server: server:
port: ${SERVER_PORT:8086} port: ${SERVER_PORT:8086}
servlet: servlet:
context-path: /api/v1/analytics
encoding: encoding:
charset: UTF-8 charset: UTF-8
enabled: true enabled: true

View File

@ -0,0 +1,187 @@
{
"events": [
{
"eventId": "evt_2025012301",
"eventTitle": "신규 고객 환영 이벤트",
"storeId": "store_001",
"totalInvestment": 5000000,
"expectedRevenue": 15000000,
"status": "ACTIVE",
"startDate": "2025-01-23T00:00:00",
"endDate": "2025-02-23T23:59:59",
"createdAt": "2025-01-23T10:00:00"
},
{
"eventId": "evt_2025011502",
"eventTitle": "재방문 고객 감사 이벤트",
"storeId": "store_001",
"totalInvestment": 3500000,
"expectedRevenue": 7000000,
"status": "ACTIVE",
"startDate": "2025-01-15T00:00:00",
"endDate": "2025-02-15T23:59:59",
"createdAt": "2025-01-15T14:30:00"
},
{
"eventId": "evt_2025010803",
"eventTitle": "신년 특별 할인 이벤트",
"storeId": "store_001",
"totalInvestment": 2000000,
"expectedRevenue": 3000000,
"status": "COMPLETED",
"startDate": "2025-01-01T00:00:00",
"endDate": "2025-01-08T23:59:00",
"createdAt": "2024-12-28T09:00:00"
}
],
"distributions": [
{
"eventId": "evt_2025012301",
"completedAt": "2025-01-23T12:00:00",
"channels": [
{
"channel": "우리동네TV",
"channelType": "TV",
"status": "SUCCESS",
"expectedViews": 5000,
"distributionCostRatio": 0.30
},
{
"channel": "지니TV",
"channelType": "TV",
"status": "SUCCESS",
"expectedViews": 10000,
"distributionCostRatio": 0.30
},
{
"channel": "링고비즈",
"channelType": "CALL",
"status": "SUCCESS",
"expectedViews": 3000,
"distributionCostRatio": 0.25
},
{
"channel": "SNS",
"channelType": "SNS",
"status": "SUCCESS",
"expectedViews": 2000,
"distributionCostRatio": 0.15
}
]
},
{
"eventId": "evt_2025011502",
"completedAt": "2025-02-01T12:00:00",
"channels": [
{
"channel": "우리동네TV",
"channelType": "TV",
"status": "SUCCESS",
"expectedViews": 3500,
"distributionCostRatio": 0.30
},
{
"channel": "지니TV",
"channelType": "TV",
"status": "SUCCESS",
"expectedViews": 7000,
"distributionCostRatio": 0.30
},
{
"channel": "링고비즈",
"channelType": "CALL",
"status": "SUCCESS",
"expectedViews": 2000,
"distributionCostRatio": 0.25
},
{
"channel": "SNS",
"channelType": "SNS",
"status": "SUCCESS",
"expectedViews": 1500,
"distributionCostRatio": 0.15
}
]
},
{
"eventId": "evt_2025010803",
"completedAt": "2025-01-15T12:00:00",
"channels": [
{
"channel": "우리동네TV",
"channelType": "TV",
"status": "SUCCESS",
"expectedViews": 1500,
"distributionCostRatio": 0.30
},
{
"channel": "지니TV",
"channelType": "TV",
"status": "SUCCESS",
"expectedViews": 3000,
"distributionCostRatio": 0.30
},
{
"channel": "링고비즈",
"channelType": "CALL",
"status": "SUCCESS",
"expectedViews": 1000,
"distributionCostRatio": 0.25
},
{
"channel": "SNS",
"channelType": "SNS",
"status": "SUCCESS",
"expectedViews": 500,
"distributionCostRatio": 0.15
}
]
}
],
"participants": [
{
"eventId": "evt_2025012301",
"participantRange": {
"start": 1,
"end": 100
},
"channelWeights": {
"SNS": 45,
"우리동네TV": 25,
"지니TV": 20,
"링고비즈": 10
}
},
{
"eventId": "evt_2025011502",
"participantRange": {
"start": 51,
"end": 100
},
"channelWeights": {
"SNS": 45,
"우리동네TV": 25,
"지니TV": 20,
"링고비즈": 10
}
},
{
"eventId": "evt_2025010803",
"participantRange": {
"start": 71,
"end": 100
},
"channelWeights": {
"SNS": 45,
"우리동네TV": 25,
"지니TV": 20,
"링고비즈": 10
}
}
],
"config": {
"channelBudgetRatio": 0.50,
"participantIdPrefix": "user",
"participantIdPadding": 3
}
}

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,6 +37,8 @@ replicate:
token: ${REPLICATE_API_TOKEN:} token: ${REPLICATE_API_TOKEN:}
model: model:
version: ${REPLICATE_MODEL_VERSION:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b} version: ${REPLICATE_MODEL_VERSION:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}
mock:
enabled: ${REPLICATE_MOCK_ENABLED:true}
# CORS Configuration # CORS Configuration
cors: cors:

View File

@ -19,7 +19,7 @@ spec:
- name: kt-event-marketing - name: kt-event-marketing
containers: containers:
- name: ai-service - name: ai-service
image: acrdigitalgarage01.azurecr.io/kt-event-marketing/ai-service:latest image: acrdigitalgarage01.azurecr.io/kt-event-marketing/ai-service:dev
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8083 - containerPort: 8083
@ -42,21 +42,21 @@ spec:
memory: "1024Mi" memory: "1024Mi"
startupProbe: startupProbe:
httpGet: httpGet:
path: /api/v1/ai-service/actuator/health path: /api/v1/ai/actuator/health
port: 8083 port: 8083
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
failureThreshold: 30 failureThreshold: 30
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /api/v1/ai-service/actuator/health/readiness path: /api/v1/ai/actuator/health/readiness
port: 8083 port: 8083
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 5 periodSeconds: 5
failureThreshold: 3 failureThreshold: 3
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /api/v1/ai-service/actuator/health/liveness path: /api/v1/ai/actuator/health/liveness
port: 8083 port: 8083
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10

View File

@ -56,7 +56,7 @@ spec:
number: 80 number: 80
# AI Service # AI Service
- path: /api/v1/ai-service - path: /api/v1/ai
pathType: Prefix pathType: Prefix
backend: backend:
service: service:

View File

@ -19,7 +19,7 @@ spec:
- name: kt-event-marketing - name: kt-event-marketing
containers: containers:
- name: event-service - name: event-service
image: acrdigitalgarage01.azurecr.io/kt-event-marketing/event-service:latest image: acrdigitalgarage01.azurecr.io/kt-event-marketing/event-service:dev
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8080 - containerPort: 8080
@ -42,21 +42,21 @@ spec:
memory: "1024Mi" memory: "1024Mi"
startupProbe: startupProbe:
httpGet: httpGet:
path: /api/v1/events/actuator/health path: /api/v1/actuator/health
port: 8080 port: 8080
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
failureThreshold: 30 failureThreshold: 30
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /api/v1/events/actuator/health/readiness path: /api/v1/actuator/health/readiness
port: 8080 port: 8080
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 5 periodSeconds: 5
failureThreshold: 3 failureThreshold: 3
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /api/v1/events/actuator/health/liveness path: /api/v1/actuator/health/liveness
port: 8080 port: 8080
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

@ -11,6 +11,11 @@
<entry key="KAKAO_API_URL" value="http://localhost:9006/api/kakao" /> <entry key="KAKAO_API_URL" value="http://localhost:9006/api/kakao" />
<entry key="LOG_FILE" value="logs/distribution-service.log" /> <entry key="LOG_FILE" value="logs/distribution-service.log" />
<entry key="NAVER_API_URL" value="http://localhost:9005/api/naver" /> <entry key="NAVER_API_URL" value="http://localhost:9005/api/naver" />
<entry key="NAVER_BLOG_BLOG_ID" value="bokchi_13" />
<entry key="NAVER_BLOG_HEADLESS" value="false" />
<entry key="NAVER_BLOG_PASSWORD" value="" />
<entry key="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
<entry key="NAVER_BLOG_USERNAME" value="" />
<entry key="RINGOBIZ_API_URL" value="http://localhost:9002/api/ringobiz" /> <entry key="RINGOBIZ_API_URL" value="http://localhost:9002/api/ringobiz" />
<entry key="SERVER_PORT" value="8085" /> <entry key="SERVER_PORT" value="8085" />
<entry key="URIDONGNETV_API_URL" value="http://localhost:9001/api/uridongnetv" /> <entry key="URIDONGNETV_API_URL" value="http://localhost:9001/api/uridongnetv" />

View File

@ -1,15 +1,40 @@
# 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 AS builder
WORKDIR /app WORKDIR /app
COPY build/libs/*.jar app.jar COPY 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
WORKDIR /app WORKDIR /app
# Create non-root user # Install Playwright essential dependencies only
RUN addgroup -S spring && adduser -S spring -G spring RUN apt-get update && apt-get install -y --no-install-recommends \
USER spring:spring wget \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2t64 \
libpango-1.0-0 \
libcairo2 \
libatspi2.0-0 \
libxshmfence1 \
fonts-liberation \
libappindicator3-1 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
# Create browser installation directory with proper permissions
RUN mkdir -p /app/playwright && chmod 777 /app/playwright
# Copy layers from builder # Copy layers from builder
COPY --from=builder /app/dependencies/ ./ COPY --from=builder /app/dependencies/ ./
@ -17,6 +42,17 @@ COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./ COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./ COPY --from=builder /app/application/ ./
# Set Playwright browsers path
ENV PLAYWRIGHT_BROWSERS_PATH=/app/playwright
# Create non-root user
RUN groupadd -r spring && useradd -r -g spring spring
# Change ownership to spring user
RUN chown -R spring:spring /app
USER spring:spring
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8085/distribution/actuator/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:8085/distribution/actuator/health || exit 1

View File

@ -0,0 +1,248 @@
# 네이버 블로그 포스팅 설정 가이드
## 개요
Distribution Service는 Playwright를 사용하여 네이버 블로그에 자동으로 포스팅합니다.
## 사전 준비
### 1. Playwright 설치
처음 실행 시 Playwright 브라우저가 자동으로 다운로드됩니다. 수동으로 설치하려면:
```bash
# Windows (PowerShell)
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
# Linux/Mac
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
```
### 2. 네이버 계정 준비
- 네이버 계정 (아이디/비밀번호)
- 네이버 블로그 개설 (blog.naver.com에서 블로그 만들기)
- 블로그 ID 확인 (예: blog.naver.com/YOUR_BLOG_ID)
## 환경 변수 설정
### IntelliJ 실행 프로파일 설정
`.run/DistributionServiceApplication.run.xml` 파일에서 다음 환경 변수를 설정:
```xml
<env name="NAVER_BLOG_USERNAME" value="your_naver_id" />
<env name="NAVER_BLOG_PASSWORD" value="your_password" />
<env name="NAVER_BLOG_BLOG_ID" value="your_blog_id" />
<env name="NAVER_BLOG_HEADLESS" value="false" /> <!-- 브라우저 표시 여부 -->
<env name="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
```
### 환경 변수 설명
| 환경 변수 | 설명 | 기본값 | 필수 |
|----------|------|--------|------|
| `NAVER_BLOG_USERNAME` | 네이버 아이디 | - | ✅ |
| `NAVER_BLOG_PASSWORD` | 네이버 비밀번호 | - | ✅ |
| `NAVER_BLOG_BLOG_ID` | 네이버 블로그 ID | - | ✅ |
| `NAVER_BLOG_HEADLESS` | Headless 모드 (true/false) | true | ❌ |
| `NAVER_BLOG_SESSION_PATH` | 세션 저장 경로 | playwright-sessions | ❌ |
### Headless 모드
- **false**: 브라우저 창이 표시되어 디버깅에 유용 (개발 환경 권장)
- **true**: 백그라운드 실행, 서버 환경에 적합 (운영 환경 권장)
## 사용 방법
### API 호출 예시
```bash
# 배포 요청
curl -X POST http://localhost:8085/distribution/api/v1/distributions \
-H "Content-Type: application/json" \
-d '{
"eventId": "EVT001",
"title": "신규 이벤트 안내",
"content": "이벤트 상세 내용입니다.",
"imageUrl": "https://example.com/event.jpg",
"channels": ["NAVER"]
}'
```
### 응답 예시
```json
{
"eventId": "EVT001",
"status": "SUCCESS",
"totalChannels": 1,
"successCount": 1,
"failureCount": 0,
"channels": [
{
"channel": "NAVER",
"success": true,
"distributionId": "NAVER-abc123",
"distributionUrl": "https://blog.naver.com/your_blog_id/222999999999",
"estimatedReach": 2000,
"executionTimeMs": 5234
}
],
"distributedAt": "2025-10-29T10:30:00"
}
```
## 세션 관리
### 자동 로그인
- 최초 실행 시 네이버에 로그인하고 세션이 저장됩니다
- 이후 요청은 저장된 세션을 사용하여 로그인 없이 진행됩니다
- 세션 파일 위치: `playwright-sessions/naver-blog-session.json`
### 세션 만료 시
세션이 만료되면 자동으로 재로그인을 시도합니다.
### 수동 세션 초기화
```bash
# 세션 파일 삭제
rm -rf playwright-sessions/naver-blog-session.json
```
## 문제 해결
### 1. 로그인 실패
**증상**: "Login failed" 에러 발생
**해결 방법**:
- 네이버 아이디/비밀번호 확인
- 네이버 로그인 보안 설정 확인 (캡차, 2단계 인증 등)
- Headless 모드를 false로 설정하여 브라우저 동작 확인
- 세션 파일 삭제 후 재시도
### 2. 브라우저 실행 실패
**증상**: "Failed to initialize Playwright" 에러
**해결 방법**:
```bash
# Playwright 브라우저 재설치
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
```
### 3. 포스팅 실패
**증상**: 포스팅 URL이 반환되지 않음
**해결 방법**:
- Headless 모드를 false로 설정하여 UI 확인
- 네이버 블로그 에디터 구조 변경 여부 확인
- 로그 확인: `logs/distribution-service.log`
### 4. 성능 이슈
브라우저 자동화는 리소스를 많이 사용하므로:
- Resilience4j Bulkhead 설정으로 동시 실행 제한 (현재 10개)
- Circuit Breaker로 반복 실패 방지
- 실패 시 자동 재시도 (최대 3회)
## 보안 고려사항
### 1. 비밀번호 관리
- **절대로** 소스 코드에 비밀번호를 하드코딩하지 마세요
- 환경 변수 또는 시크릿 관리 서비스 사용
- Git에 `.run/*.xml` 파일을 커밋하지 마세요 (`.gitignore` 추가)
### 2. 세션 파일 보안
- `playwright-sessions/` 디렉토리를 `.gitignore`에 추가
- 서버 환경에서 파일 권한 설정 (chmod 600)
### 3. 네트워크 보안
- HTTPS만 사용
- 프록시 사용 시 안전한 프록시 설정
## 운영 환경 배포
### Docker 환경
```dockerfile
# Dockerfile에 Playwright 설치 추가
RUN apt-get update && apt-get install -y \
libnss3 \
libatk-bridge2.0-0 \
libdrm2 \
libxkbcommon0 \
libgbm1 \
libasound2
# Playwright 브라우저 설치
RUN mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
```
### Kubernetes 환경
```yaml
apiVersion: v1
kind: Secret
metadata:
name: naver-blog-credentials
type: Opaque
stringData:
username: your_naver_id
password: your_password
blog-id: your_blog_id
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: distribution-service
env:
- name: NAVER_BLOG_USERNAME
valueFrom:
secretKeyRef:
name: naver-blog-credentials
key: username
- name: NAVER_BLOG_PASSWORD
valueFrom:
secretKeyRef:
name: naver-blog-credentials
key: password
- name: NAVER_BLOG_BLOG_ID
valueFrom:
secretKeyRef:
name: naver-blog-credentials
key: blog-id
- name: NAVER_BLOG_HEADLESS
value: "true"
```
## 제약사항
1. **동시 실행 제한**: Bulkhead 설정으로 최대 10개 동시 실행
2. **실행 시간**: 브라우저 자동화는 API 호출보다 느림 (평균 5-10초)
3. **네이버 정책**: 네이버 블로그 정책 변경 시 업데이트 필요
4. **UI 변경**: 네이버 블로그 UI 변경 시 코드 수정 필요
## 모니터링
### 로그 확인
```bash
# 실시간 로그
tail -f logs/distribution-service.log
# 에러만 필터
grep ERROR logs/distribution-service.log
```
### 주요 로그 메시지
- `Initializing Playwright for Naver Blog`: Playwright 초기화
- `Starting Naver login process`: 로그인 시작
- `Naver login successful`: 로그인 성공
- `Post published successfully`: 포스팅 성공
- `Failed to post to Naver blog`: 포스팅 실패
## 참고 자료
- [Playwright for Java](https://playwright.dev/java/)
- [네이버 블로그 고객센터](https://help.naver.com/service/5614/)
- [Resilience4j 문서](https://resilience4j.readme.io/)
## 지원
문제 발생 시:
1. 로그 파일 확인: `logs/distribution-service.log`
2. Headless 모드를 false로 설정하여 브라우저 동작 확인
3. GitHub Issue 등록 (로그 첨부)

View File

@ -15,6 +15,9 @@ dependencies {
implementation "io.github.resilience4j:resilience4j-retry:${resilience4jVersion}" implementation "io.github.resilience4j:resilience4j-retry:${resilience4jVersion}"
implementation "io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}" implementation "io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}"
// Playwright for browser automation
implementation 'com.microsoft.playwright:playwright:1.41.0'
// Jackson for JSON // Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.core:jackson-databind'
} }

View File

@ -1,27 +1,30 @@
package com.kt.distribution.adapter; package com.kt.distribution.adapter;
import com.kt.distribution.client.NaverBlogClient;
import com.kt.distribution.dto.ChannelDistributionResult; import com.kt.distribution.dto.ChannelDistributionResult;
import com.kt.distribution.dto.ChannelType; import com.kt.distribution.dto.ChannelType;
import com.kt.distribution.dto.DistributionRequest; import com.kt.distribution.dto.DistributionRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.UUID; import java.util.UUID;
/** /**
* Naver Blog Adapter * Naver Blog Adapter
* Naver Blog 포스팅 API 호출 * Naver Blog 포스팅 (Playwright 기반)
* *
* @author System Architect * @author Backend Developer
* @since 2025-10-23 * @since 2025-10-29
*/ */
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "naver.blog.enabled", havingValue = "true", matchIfMissing = false)
public class NaverAdapter extends AbstractChannelAdapter { public class NaverAdapter extends AbstractChannelAdapter {
@Value("${channel.apis.naver.url}") private final NaverBlogClient naverBlogClient;
private String apiUrl;
@Override @Override
public ChannelType getChannelType() { public ChannelType getChannelType() {
@ -30,16 +33,35 @@ public class NaverAdapter extends AbstractChannelAdapter {
@Override @Override
protected ChannelDistributionResult executeDistribution(DistributionRequest request) { protected ChannelDistributionResult executeDistribution(DistributionRequest request) {
log.debug("Calling Naver API: url={}, eventId={}", apiUrl, request.getEventId()); log.debug("Posting to Naver Blog: eventId={}, title={}",
request.getEventId(), request.getTitle());
// TODO: 실제 API 호출 (현재는 Mock) try {
String distributionId = "NAVER-" + UUID.randomUUID().toString(); // 네이버 블로그에 포스팅
String postUrl = naverBlogClient.postToBlog(request);
String distributionId = "NAVER-" + UUID.randomUUID().toString();
return ChannelDistributionResult.builder() log.info("Naver blog post created successfully: eventId={}, postUrl={}",
.channel(ChannelType.NAVER) request.getEventId(), postUrl);
.success(true)
.distributionId(distributionId) return ChannelDistributionResult.builder()
.estimatedReach(2000) // 블로그 방문자 기반 .channel(ChannelType.NAVER)
.build(); .success(true)
.distributionId(distributionId)
.postUrl(postUrl)
.estimatedReach(2000) // 블로그 방문자 기반
.build();
} catch (Exception e) {
log.error("Failed to post to Naver blog: eventId={}, error={}",
request.getEventId(), e.getMessage(), e);
return ChannelDistributionResult.builder()
.channel(ChannelType.NAVER)
.success(false)
.errorMessage("Naver blog posting failed: " + e.getMessage())
.estimatedReach(0)
.build();
}
} }
} }

View File

@ -0,0 +1,319 @@
package com.kt.distribution.client;
import com.kt.distribution.dto.DistributionRequest;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Naver Blog Client using Playwright
* 네이버 블로그 포스팅 자동화 클라이언트
*
* @author Backend Developer
* @since 2025-10-29
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "naver.blog.enabled", havingValue = "true", matchIfMissing = false)
public class NaverBlogClient {
@Value("${naver.blog.username:}")
private String username;
@Value("${naver.blog.password:}")
private String password;
@Value("${naver.blog.blog-id:}")
private String blogId;
@Value("${naver.blog.headless:false}")
private boolean headless;
@Value("${naver.blog.session-path:playwright-sessions}")
private String sessionPath;
private Playwright playwright;
private Browser browser;
private BrowserContext context;
/**
* Playwright 초기화
*/
@PostConstruct
public void init() {
try {
log.info("Initializing Playwright for Naver Blog");
playwright = Playwright.create();
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(headless)
.setSlowMo(100)); // 안정성을 위한 느린 실행
// 세션 디렉토리 생성
File sessionDir = new File(sessionPath);
if (!sessionDir.exists()) {
sessionDir.mkdirs();
log.info("Created session directory: {}", sessionPath);
}
// 세션 파일 경로
Path sessionFilePath = Paths.get(sessionPath, "naver-blog-session.json");
// 세션 파일이 있으면 로드, 없으면 새로운 컨텍스트 생성
if (Files.exists(sessionFilePath)) {
log.info("Loading existing session from: {}", sessionFilePath);
context = browser.newContext(new Browser.NewContextOptions()
.setStorageStatePath(sessionFilePath));
} else {
log.info("No existing session found, creating new context");
context = browser.newContext();
}
log.info("Playwright initialized successfully");
} catch (Exception e) {
log.error("Failed to initialize Playwright", e);
throw new RuntimeException("Playwright initialization failed", e);
}
}
/**
* 네이버 블로그에 포스팅
*
* @param request DistributionRequest
* @return 포스팅 URL
* @throws Exception 포스팅 실패
*/
public String postToBlog(DistributionRequest request) throws Exception {
Page page = null;
try {
page = context.newPage();
// 타임아웃을 5분(300000ms)으로 설정
page.setDefaultTimeout(300000);
// 로그인 확인 처리
if (!isLoggedIn(page)) {
login(page);
}
// 블로그 글쓰기 페이지로 이동
String writeUrl = String.format("https://blog.naver.com/%s/postwrite", blogId);
page.navigate(writeUrl);
page.waitForLoadState(LoadState.NETWORKIDLE);
// 도움말 팝업이 있으면 닫기
try {
page.waitForTimeout(5000); // 충분히 대기 필요
Locator helpPanel = page.locator("[class*='help-panel']");
if (helpPanel.isVisible(new Locator.IsVisibleOptions().setTimeout(2000))) {
log.debug("Help dialog detected, closing it");
// 팝업 안의 닫기 버튼 찾기
Locator closeBtn = page.locator("button[class*='se-help-panel-close-button']");
closeBtn.click();
Thread.sleep(500);
log.debug("Help dialog closed");
} else{
log.debug("--------------------- 도움말 없음");
}
} catch (Exception e) {
log.debug("No help dialog found or already closed");
}
// 제목 입력
Locator titleInput = page.locator(".se-text-paragraph").first();
titleInput.click();
titleInput.pressSequentially(request.getTitle(), new Locator.PressSequentiallyOptions().setDelay(50));
log.debug("Title entered: {}", request.getTitle());
// 본문 입력
Locator editorInput = page.locator(".se-text-paragraph").nth(1);
editorInput.click();
titleInput.pressSequentially(request.getDescription(), new Locator.PressSequentiallyOptions().setDelay(50));
log.debug("Content entered");
// 이미지가 있으면 업로드
if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) {
uploadImage(page, request.getImageUrl());
}
// 발행 버튼 클릭
page.locator("button[class*='publish_btn']").click();
page.waitForLoadState(LoadState.NETWORKIDLE);
page.locator("button[class*='confirm_btn']").click();
page.waitForLoadState(LoadState.NETWORKIDLE);
page.waitForTimeout(5000); // 충분히 대기 필요
// 포스팅 URL 가져오기
String postUrl = page.url();
log.info("Post published successfully: {}", postUrl);
return postUrl;
} catch (Exception e) {
log.error("Failed to post to Naver blog: eventId={}, error={}",
request.getEventId(), e.getMessage(), e);
throw e;
} finally {
if (page != null) {
page.close();
}
}
}
/**
* 로그인 상태 확인
*
* @param page Page
* @return 로그인 여부
*/
private boolean isLoggedIn(Page page) {
try {
page.navigate("https://blog.naver.com");
page.waitForLoadState(LoadState.NETWORKIDLE);
// 로그인 버튼이 보이지 않으면 로그인된 상태
// ID 기반 선택자 사용으로 strict mode violation 방지
return !page.locator("#gnb_login_button").isVisible();
} catch (Exception e) {
log.warn("Failed to check login status", e);
return false;
}
}
/**
* 네이버 로그인 (수동 로그인 대기 방식)
*
* @param page Page
* @throws Exception 로그인 실패
*/
private void login(Page page) throws Exception {
try {
log.info("Starting Naver manual login process");
log.info("=================================================");
log.info("Please login manually in the browser window");
log.info("브라우저 창에서 수동으로 로그인해주세요");
log.info("=================================================");
// 네이버 로그인 페이지로 이동
page.navigate("https://nid.naver.com/nidlogin.login");
page.waitForLoadState(LoadState.NETWORKIDLE);
// 사용자가 수동으로 로그인할 때까지 대기 (URL이 변경될 때까지)
// 로그인 성공 URL이 nid.naver.com에서 벗어남
log.info("Waiting for manual login... (Timeout: 30 seconds)");
try {
// 30초 동안 URL이 nid.naver.com을 벗어날 때까지 대기
page.waitForURL(url -> !url.contains("nid.naver.com"),
new Page.WaitForURLOptions().setTimeout(30000));
log.info("Login URL changed, assuming login successful");
} catch (Exception e) {
log.error("Login timeout or failed", e);
throw new Exception("Manual login timeout or failed after 30 seconds");
}
// 추가 안정화 대기
page.waitForLoadState(LoadState.NETWORKIDLE);
Thread.sleep(2000); // 2초 추가 대기
// 세션 저장
context.storageState(new BrowserContext.StorageStateOptions()
.setPath(Paths.get(sessionPath, "naver-blog-session.json")));
log.info("Naver manual login successful, session saved");
log.info("Current URL: {}", page.url());
} catch (Exception e) {
log.error("Naver manual login process failed", e);
throw new Exception("Naver manual login failed: " + e.getMessage(), e);
}
}
/**
* 이미지 업로드
*
* @param page Page
* @param imageUrl 이미지 URL
*/
private void uploadImage(Page page, String imageUrl) {
try {
log.debug("Uploading image: {}", imageUrl);
// 이미지 업로드 버튼 클릭
page.locator("button[aria-label='사진']").click();
// URL로 이미지 추가 (실제 구현은 네이버 블로그 UI에 따라 조정 필요)
// 여기서는 간단히 로그만 남김
log.info("Image upload placeholder - URL: {}", imageUrl);
} catch (Exception e) {
log.warn("Failed to upload image: {}", e.getMessage());
}
}
/**
* Playwright 리소스 정리
*/
@PreDestroy
public void cleanup() {
try {
if (context != null) {
context.close();
}
if (browser != null) {
browser.close();
}
if (playwright != null) {
playwright.close();
}
log.info("Playwright resources cleaned up");
} catch (Exception e) {
log.error("Failed to cleanup Playwright resources", e);
}
}
/**
* 수동으로 브라우저 컨텍스트 새로고침
* 장시간 사용 세션 만료 방지용
*/
public void refreshContext() {
try {
if (context != null) {
context.close();
}
// 세션 파일 경로
Path sessionFilePath = Paths.get(sessionPath, "naver-blog-session.json");
// 세션 파일이 있으면 로드, 없으면 새로운 컨텍스트 생성
if (Files.exists(sessionFilePath)) {
log.info("Refreshing context with existing session");
context = browser.newContext(new Browser.NewContextOptions()
.setStorageStatePath(sessionFilePath));
} else {
log.info("Refreshing context without session");
context = browser.newContext();
}
log.info("Browser context refreshed");
} catch (Exception e) {
log.error("Failed to refresh context", e);
}
}
}

View File

@ -32,6 +32,11 @@ public class ChannelDistributionResult {
*/ */
private String distributionId; private String distributionId;
/**
* 배포 URL (성공 ) - 실제 포스팅된 URL
*/
private String postUrl;
/** /**
* 예상 노출 (성공 ) * 예상 노출 (성공 )
*/ */

View File

@ -225,6 +225,7 @@ public class DistributionService {
.channel(result.getChannel()) .channel(result.getChannel())
.status(result.isSuccess() ? "COMPLETED" : "FAILED") .status(result.isSuccess() ? "COMPLETED" : "FAILED")
.distributionId(result.getDistributionId()) .distributionId(result.getDistributionId())
.postUrl(result.getPostUrl())
.estimatedViews(result.getEstimatedReach()) .estimatedViews(result.getEstimatedReach())
.eventId(eventId) .eventId(eventId)
.completedAt(completedAt) .completedAt(completedAt)

View File

@ -126,10 +126,11 @@ channel:
# Naver Blog Configuration (Playwright 기반) # Naver Blog Configuration (Playwright 기반)
naver: naver:
blog: blog:
enabled: ${NAVER_BLOG_ENABLED:false}
username: ${NAVER_BLOG_USERNAME:} username: ${NAVER_BLOG_USERNAME:}
password: ${NAVER_BLOG_PASSWORD:} password: ${NAVER_BLOG_PASSWORD:}
blog-id: ${NAVER_BLOG_ID:} blog-id: ${NAVER_BLOG_ID:}
headless: ${NAVER_BLOG_HEADLESS:true} headless: ${NAVER_BLOG_HEADLESS:false}
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions} session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
# Springdoc OpenAPI (Swagger) # Springdoc OpenAPI (Swagger)

View File

@ -7,6 +7,9 @@ RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine FROM eclipse-temurin:21-jre-alpine
WORKDIR /app WORKDIR /app
# Install glibc compatibility for Snappy native library
RUN apk add --no-cache gcompat
# Create non-root user # Create non-root user
RUN addgroup -S spring && adduser -S spring -G spring RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring USER spring:spring

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

@ -10,7 +10,9 @@ import com.kt.event.eventservice.domain.entity.*;
import com.kt.event.eventservice.domain.enums.EventStatus; import com.kt.event.eventservice.domain.enums.EventStatus;
import com.kt.event.eventservice.domain.repository.EventRepository; import com.kt.event.eventservice.domain.repository.EventRepository;
import com.kt.event.eventservice.domain.repository.JobRepository; import com.kt.event.eventservice.domain.repository.JobRepository;
import com.kt.event.eventservice.infrastructure.client.AIServiceClient;
import com.kt.event.eventservice.infrastructure.client.ContentServiceClient; import com.kt.event.eventservice.infrastructure.client.ContentServiceClient;
import com.kt.event.eventservice.infrastructure.client.dto.AIRecommendationResponse;
import com.kt.event.eventservice.infrastructure.client.dto.ContentImageGenerationRequest; import com.kt.event.eventservice.infrastructure.client.dto.ContentImageGenerationRequest;
import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse; import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse;
import com.kt.event.eventservice.infrastructure.kafka.AIJobKafkaProducer; import com.kt.event.eventservice.infrastructure.kafka.AIJobKafkaProducer;
@ -24,7 +26,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;
/** /**
@ -44,26 +45,37 @@ public class EventService {
private final EventRepository eventRepository; private final EventRepository eventRepository;
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final AIServiceClient aiServiceClient;
private final ContentServiceClient contentServiceClient; private final ContentServiceClient contentServiceClient;
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 +99,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 +120,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 +128,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 +151,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 +173,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 +202,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 +222,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 +248,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 +261,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 +281,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 +310,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 +347,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 +360,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 +383,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 +449,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 +490,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 +519,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);
// 이벤트 조회 권한 확인 // 이벤트 조회 권한 확인
@ -574,4 +614,30 @@ public class EventService {
.updatedAt(event.getUpdatedAt()) .updatedAt(event.getUpdatedAt())
.build(); .build();
} }
/**
* AI 추천안 조회 (AI Service에서 직접 조회)
*
* @param userId 사용자 ID
* @param eventId 이벤트 ID
* @return AI 추천 결과
*/
public AIRecommendationResponse getAiRecommendations(String userId, String eventId) {
log.info("AI 추천안 조회 - userId: {}, eventId: {}", userId, eventId);
// 이벤트 권한 확인
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
// AI Service에서 추천안 조회
try {
AIRecommendationResponse response = aiServiceClient.getRecommendation(eventId);
log.info("AI 추천안 조회 성공 - eventId: {}, 추천안 수: {}",
eventId, response.getRecommendations() != null ? response.getRecommendations().size() : 0);
return response;
} catch (Exception e) {
log.error("AI 추천안 조회 실패 - eventId: {}", eventId, e);
throw new BusinessException(ErrorCode.AI_004);
}
}
} }

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

@ -0,0 +1,31 @@
package com.kt.event.eventservice.infrastructure.client;
import com.kt.event.eventservice.infrastructure.client.dto.AIRecommendationResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
/**
* AI Service Feign Client
*
* AI Service의 추천안 조회 API를 호출합니다.
*
* @author Event Service Team
* @version 1.0.0
* @since 2025-10-30
*/
@FeignClient(
name = "ai-service",
url = "${feign.ai-service.url:http://localhost:8083}"
)
public interface AIServiceClient {
/**
* AI 추천 결과 조회
*
* @param eventId 이벤트 ID
* @return AI 추천 결과
*/
@GetMapping("/recommendations/{eventId}")
AIRecommendationResponse getRecommendation(@PathVariable("eventId") String eventId);
}

View File

@ -0,0 +1,123 @@
package com.kt.event.eventservice.infrastructure.client.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* AI Service 추천안 응답 DTO
*
* @author Event Service Team
* @version 1.0.0
* @since 2025-10-30
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AIRecommendationResponse {
private String eventId;
private TrendAnalysis trendAnalysis;
private List<EventRecommendation> recommendations;
private LocalDateTime generatedAt;
private LocalDateTime expiresAt;
private String aiProvider;
/**
* 트렌드 분석
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TrendAnalysis {
private List<TrendKeyword> industryTrends;
private List<TrendKeyword> regionalTrends;
private List<TrendKeyword> seasonalTrends;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TrendKeyword {
private String keyword;
private Double relevance;
private String description;
}
}
/**
* 이벤트 추천안
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class EventRecommendation {
private Integer optionNumber;
private String concept;
private String title;
private String description;
private String targetAudience;
private Duration duration;
private Mechanics mechanics;
private List<String> promotionChannels;
private EstimatedCost estimatedCost;
private ExpectedMetrics expectedMetrics;
private String differentiator;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Duration {
private Integer recommendedDays;
private String recommendedPeriod;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Mechanics {
private String type;
private String details;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class EstimatedCost {
private Integer min;
private Integer max;
private Map<String, Integer> breakdown;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ExpectedMetrics {
private Range newCustomers;
private Range revenueIncrease;
private Range roi;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Range {
private Double min;
private Double max;
}
}
}
}

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

@ -6,6 +6,7 @@ import com.kt.event.common.security.UserPrincipal;
import com.kt.event.eventservice.application.dto.request.*; import com.kt.event.eventservice.application.dto.request.*;
import com.kt.event.eventservice.application.dto.response.*; import com.kt.event.eventservice.application.dto.response.*;
import com.kt.event.eventservice.application.service.EventService; import com.kt.event.eventservice.application.service.EventService;
import com.kt.event.eventservice.infrastructure.client.dto.AIRecommendationResponse;
import com.kt.event.eventservice.domain.enums.EventStatus; import com.kt.event.eventservice.domain.enums.EventStatus;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@ -21,8 +22,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 +33,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 +128,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 +149,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 +170,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 +191,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 +213,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 +242,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 +271,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) {
@ -289,6 +288,30 @@ public class EventController {
.body(ApiResponse.success(response)); .body(ApiResponse.success(response));
} }
/**
* AI 추천안 조회 (Step 2-1)
*
* @param eventId 이벤트 ID
* @param userPrincipal 인증된 사용자 정보
* @return AI 추천 결과
*/
@GetMapping("/{eventId}/ai-recommendations")
@Operation(summary = "AI 추천안 조회", description = "AI Service에서 생성된 추천안을 조회합니다.")
public ResponseEntity<ApiResponse<AIRecommendationResponse>> getAiRecommendations(
@PathVariable String eventId,
@AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("AI 추천안 조회 API 호출 - userId: {}, eventId: {}",
userPrincipal.getUserId(), eventId);
AIRecommendationResponse response = eventService.getAiRecommendations(
userPrincipal.getUserId(),
eventId
);
return ResponseEntity.ok(ApiResponse.success(response));
}
/** /**
* AI 추천 선택 (Step 2-2) * AI 추천 선택 (Step 2-2)
* *
@ -300,7 +323,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 +351,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 +380,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 +407,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) {

View File

@ -13,8 +13,6 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/** /**
* Job 컨트롤러 * Job 컨트롤러
* *
@ -26,7 +24,7 @@ import java.util.UUID;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/jobs") @RequestMapping("/jobs")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Job", description = "비동기 작업 상태 조회 API") @Tag(name = "Job", description = "비동기 작업 상태 조회 API")
public class JobController { public class JobController {
@ -41,7 +39,7 @@ public class JobController {
*/ */
@GetMapping("/{jobId}") @GetMapping("/{jobId}")
@Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).") @Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).")
public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable UUID jobId) { public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable String jobId) {
log.info("Job 상태 조회 API 호출 - jobId: {}", jobId); log.info("Job 상태 조회 API 호출 - jobId: {}", jobId);
JobStatusResponse response = jobService.getJobStatus(jobId); JobStatusResponse response = jobService.getJobStatus(jobId);

View File

@ -12,7 +12,7 @@ import java.time.Duration;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/redis-test") @RequestMapping("/redis-test")
@RequiredArgsConstructor @RequiredArgsConstructor
public class RedisTestController { public class RedisTestController {

View File

@ -71,7 +71,7 @@ spring:
server: server:
port: ${SERVER_PORT:8080} port: ${SERVER_PORT:8080}
servlet: servlet:
context-path: /api/v1/events context-path: /api/v1
shutdown: graceful shutdown: graceful
# Actuator Configuration # Actuator Configuration
@ -141,6 +141,10 @@ feign:
distribution-service: distribution-service:
url: ${DISTRIBUTION_SERVICE_URL:http://localhost:8085} url: ${DISTRIBUTION_SERVICE_URL:http://localhost:8085}
# AI Service Client
ai-service:
url: ${AI_SERVICE_URL:http://ai-service/api/v1/ai}
# Application Configuration # Application Configuration
app: app:
kafka: kafka:

View File

@ -0,0 +1,38 @@
apiVersion: v1
kind: Service
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"labels":{"app":"participation-service","app.kubernetes.io/managed-by":"kustomize","app.kubernetes.io/part-of":"kt-event-marketing","environment":"dev"},"name":"participation-service","namespace":"kt-event-marketing"},"spec":{"ports":[{"name":"http","port":80,"protocol":"TCP","targetPort":8084}],"selector":{"app":"participation-service","app.kubernetes.io/managed-by":"kustomize","app.kubernetes.io/part-of":"kt-event-marketing","environment":"dev"},"type":"ClusterIP"}}
creationTimestamp: "2025-10-28T08:59:06Z"
labels:
app: participation-service
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/part-of: kt-event-marketing
environment: dev
name: participation-service
namespace: kt-event-marketing
resourceVersion: "125107611"
uid: da5b7f82-37d3-41bd-ad87-e2864c8bcd18
spec:
clusterIP: 10.0.130.146
clusterIPs:
- 10.0.130.146
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: http
port: 80
protocol: TCP
targetPort: 8084
selector:
app: participation-service
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/part-of: kt-event-marketing
environment: dev
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}

View File

@ -0,0 +1,27 @@
apiVersion: v1
kind: Service
metadata:
labels:
app: participation-service
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/part-of: kt-event-marketing
environment: dev
name: participation-service
namespace: kt-event-marketing
spec:
clusterIP: 10.0.130.146
clusterIPs:
- 10.0.130.146
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: http
port: 80
protocol: TCP
targetPort: 8084
selector:
app: participation-service
sessionAffinity: None
type: ClusterIP

View File

@ -1,17 +1,13 @@
package com.kt.event.participation.infrastructure.config; package com.kt.event.participation.infrastructure.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/** /**
* Security Configuration for Participation Service * Security Configuration for Participation Service
@ -24,43 +20,31 @@ import java.util.Arrays;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
@Value("${cors.allowed-origins:http://localhost:*,https://kt-event-marketing-api.20.214.196.128.nip.io/api/v1}")
private String allowedOrigins;
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(csrf -> csrf.disable()) // CSRF 비활성화 (REST API는 CSRF 불필요)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth // 세션 사용 (JWT 기반 인증)
// Actuator endpoints .sessionManagement(session ->
.requestMatchers("/actuator/**").permitAll() session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.anyRequest().permitAll() )
);
// 모든 요청 허용 (테스트용)
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build(); return http.build();
} }
/**
* Chrome DevTools 요청 정적 리소스 요청을 Spring Security에서 제외
*/
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public WebSecurityCustomizer webSecurityCustomizer() {
CorsConfiguration configuration = new CorsConfiguration(); return (web) -> web.ignoring()
.requestMatchers("/.well-known/**");
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
} }
} }

View File

@ -0,0 +1,32 @@
package com.kt.event.participation.infrastructure.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web Configuration
* CORS 설정 기타 관련 설정
*
* @author System Architect
* @since 2025-10-30
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
/**
* CORS 설정
* - 모든 origin 허용 (개발 환경)
* - 모든 HTTP 메서드 허용
* - Credentials 허용
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

View File

@ -1,104 +0,0 @@
package com.kt.event.participation.presentation.controller;
import com.kt.event.participation.domain.participant.Participant;
import com.kt.event.participation.domain.participant.ParticipantRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 디버깅용 컨트롤러
*/
@Slf4j
@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("/debug")
@RequiredArgsConstructor
public class DebugController {
private final ParticipantRepository participantRepository;
/**
* 중복 참여 체크 테스트
*/
@GetMapping("/exists/{eventId}/{phoneNumber}")
public String testExists(@PathVariable String eventId, @PathVariable String phoneNumber) {
try {
log.info("디버그: 중복 체크 시작 - eventId: {}, phoneNumber: {}", eventId, phoneNumber);
boolean exists = participantRepository.existsByEventIdAndPhoneNumber(eventId, phoneNumber);
log.info("디버그: 중복 체크 결과 - exists: {}", exists);
long totalCount = participantRepository.count();
long eventCount = participantRepository.countByEventId(eventId);
return String.format(
"eventId: %s, phoneNumber: %s, exists: %s, totalCount: %d, eventCount: %d",
eventId, phoneNumber, exists, totalCount, eventCount
);
} catch (Exception e) {
log.error("디버그: 예외 발생", e);
return "ERROR: " + e.getMessage();
}
}
/**
* 모든 참여자 데이터 조회
*/
@GetMapping("/participants")
public String getAllParticipants() {
try {
List<Participant> participants = participantRepository.findAll();
StringBuilder sb = new StringBuilder();
sb.append("Total participants: ").append(participants.size()).append("\n\n");
for (Participant p : participants) {
sb.append(String.format("ID: %s, EventID: %s, Phone: %s, Name: %s\n",
p.getParticipantId(), p.getEventId(), p.getPhoneNumber(), p.getName()));
}
return sb.toString();
} catch (Exception e) {
log.error("디버그: 참여자 조회 예외 발생", e);
return "ERROR: " + e.getMessage();
}
}
/**
* 특정 전화번호의 참여 이력 조회
*/
@GetMapping("/phone/{phoneNumber}")
public String getByPhoneNumber(@PathVariable String phoneNumber) {
try {
List<Participant> participants = participantRepository.findAll();
StringBuilder sb = new StringBuilder();
sb.append("Participants with phone: ").append(phoneNumber).append("\n\n");
int count = 0;
for (Participant p : participants) {
if (phoneNumber.equals(p.getPhoneNumber())) {
sb.append(String.format("ID: %s, EventID: %s, Name: %s\n",
p.getParticipantId(), p.getEventId(), p.getName()));
count++;
}
}
if (count == 0) {
sb.append("No participants found with this phone number.");
}
return sb.toString();
} catch (Exception e) {
log.error("디버그: 전화번호별 조회 예외 발생", e);
return "ERROR: " + e.getMessage();
}
}
}

View File

@ -25,9 +25,8 @@ import org.springframework.web.bind.annotation.*;
* @since 2025-01-24 * @since 2025-01-24
*/ */
@Slf4j @Slf4j
@CrossOrigin(origins = "http://localhost:3000") @RequestMapping
@RestController @RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ParticipationController { public class ParticipationController {
@ -68,7 +67,7 @@ public class ParticipationController {
description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " + description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
"정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt" "정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
) )
@GetMapping("/events/{eventId}/participants") @GetMapping({"/events/{eventId}/participants"})
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants( public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
@Parameter(description = "이벤트 ID", example = "evt_20250124_001") @Parameter(description = "이벤트 ID", example = "evt_20250124_001")
@PathVariable String eventId, @PathVariable String eventId,
@ -91,7 +90,7 @@ public class ParticipationController {
* 참여자 상세 조회 * 참여자 상세 조회
* GET /events/{eventId}/participants/{participantId} * GET /events/{eventId}/participants/{participantId}
*/ */
@GetMapping("/events/{eventId}/participants/{participantId}") @GetMapping({"/events/{eventId}/participants/{participantId}"})
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant( public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
@PathVariable String eventId, @PathVariable String eventId,
@PathVariable String participantId) { @PathVariable String participantId) {

View File

@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.*;
@Slf4j @Slf4j
@CrossOrigin(origins = "http://localhost:3000") @CrossOrigin(origins = "http://localhost:3000")
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping
@RequiredArgsConstructor @RequiredArgsConstructor
public class WinnerController { public class WinnerController {
@ -50,7 +50,7 @@ public class WinnerController {
/** /**
* 당첨자 목록 조회 * 당첨자 목록 조회
* GET /events/{eventId}/winners * GET /participations/{eventId}/winners
*/ */
@Operation( @Operation(
summary = "당첨자 목록 조회", summary = "당첨자 목록 조회",

81
run-content-service.bat Normal file
View File

@ -0,0 +1,81 @@
@echo off
REM Content Service 실행 스크립트
REM Port: 8084
REM Context Path: /api/v1/content
setlocal enabledelayedexpansion
set SERVICE_NAME=content-service
set PORT=8084
set LOG_DIR=logs
set LOG_FILE=%LOG_DIR%\%SERVICE_NAME%.log
REM 로그 디렉토리 생성
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
REM 환경 변수 설정
set SERVER_PORT=8084
set REDIS_HOST=20.214.210.71
set REDIS_PORT=6379
set REDIS_PASSWORD=Hi5Jessica!
set REDIS_DATABASE=0
set JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
set JWT_ACCESS_TOKEN_VALIDITY=3600000
set JWT_REFRESH_TOKEN_VALIDITY=604800000
REM Azure Blob Storage
set AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net
set AZURE_CONTAINER_NAME=content-images
REM CORS
set CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io
set CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS,PATCH
set CORS_ALLOWED_HEADERS=*
set CORS_ALLOW_CREDENTIALS=true
set CORS_MAX_AGE=3600
REM Logging
set LOG_LEVEL_APP=DEBUG
set LOG_LEVEL_WEB=INFO
set LOG_LEVEL_ROOT=INFO
set LOG_FILE_PATH=%LOG_FILE%
set LOG_FILE_MAX_SIZE=10MB
set LOG_FILE_MAX_HISTORY=7
set LOG_FILE_TOTAL_CAP=100MB
echo ==================================================
echo Content Service 시작
echo ==================================================
echo 포트: %PORT%
echo 로그 파일: %LOG_FILE%
echo Context Path: /api/v1/content
echo ==================================================
REM 기존 프로세스 확인
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%PORT%.*LISTENING"') do (
echo ⚠️ 포트 %PORT%가 이미 사용 중입니다. PID: %%a
set /p answer="기존 프로세스를 종료하시겠습니까? (y/n): "
if /i "!answer!"=="y" (
taskkill /F /PID %%a
timeout /t 2 /nobreak > nul
) else (
echo 서비스 시작을 취소합니다.
exit /b 1
)
)
REM 서비스 시작
echo 서비스를 시작합니다...
start /b cmd /c "gradlew.bat %SERVICE_NAME%:bootRun > %LOG_FILE% 2>&1"
timeout /t 3 /nobreak > nul
echo ✅ Content Service가 시작되었습니다.
echo 로그 확인: tail -f %LOG_FILE% 또는 type %LOG_FILE%
echo.
echo Health Check: curl http://localhost:%PORT%/api/v1/content/actuator/health
echo.
echo 서비스 종료: 작업 관리자에서 java 프로세스 종료
echo ==================================================
endlocal

80
run-content-service.sh Normal file
View File

@ -0,0 +1,80 @@
#!/bin/bash
# Content Service 실행 스크립트
# Port: 8084
# Context Path: /api/v1/content
SERVICE_NAME="content-service"
PORT=8084
LOG_DIR="logs"
LOG_FILE="${LOG_DIR}/${SERVICE_NAME}.log"
# 로그 디렉토리 생성
mkdir -p ${LOG_DIR}
# 환경 변수 설정
export SERVER_PORT=8084
export REDIS_HOST=20.214.210.71
export REDIS_PORT=6379
export REDIS_PASSWORD=Hi5Jessica!
export REDIS_DATABASE=0
export JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
export JWT_ACCESS_TOKEN_VALIDITY=3600000
export JWT_REFRESH_TOKEN_VALIDITY=604800000
# Azure Blob Storage
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net"
export AZURE_CONTAINER_NAME=content-images
# CORS
export CORS_ALLOWED_ORIGINS="http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io"
export CORS_ALLOWED_METHODS="GET,POST,PUT,DELETE,OPTIONS,PATCH"
export CORS_ALLOWED_HEADERS="*"
export CORS_ALLOW_CREDENTIALS=true
export CORS_MAX_AGE=3600
# Logging
export LOG_LEVEL_APP=DEBUG
export LOG_LEVEL_WEB=INFO
export LOG_LEVEL_ROOT=INFO
export LOG_FILE_PATH="${LOG_FILE}"
export LOG_FILE_MAX_SIZE=10MB
export LOG_FILE_MAX_HISTORY=7
export LOG_FILE_TOTAL_CAP=100MB
echo "=================================================="
echo "Content Service 시작"
echo "=================================================="
echo "포트: ${PORT}"
echo "로그 파일: ${LOG_FILE}"
echo "Context Path: /api/v1/content"
echo "=================================================="
# 기존 프로세스 확인
if netstat -ano | grep -q ":${PORT}.*LISTENING"; then
echo "⚠️ 포트 ${PORT}가 이미 사용 중입니다."
echo "기존 프로세스를 종료하시겠습니까? (y/n)"
read -r answer
if [ "$answer" = "y" ]; then
PID=$(netstat -ano | grep ":${PORT}.*LISTENING" | awk '{print $5}' | head -1)
taskkill //F //PID ${PID}
sleep 2
else
echo "서비스 시작을 취소합니다."
exit 1
fi
fi
# 서비스 시작
echo "서비스를 시작합니다..."
nohup ./gradlew ${SERVICE_NAME}:bootRun > ${LOG_FILE} 2>&1 &
SERVICE_PID=$!
echo "✅ Content Service가 시작되었습니다."
echo "PID: ${SERVICE_PID}"
echo "로그 확인: tail -f ${LOG_FILE}"
echo ""
echo "Health Check: curl http://localhost:${PORT}/api/v1/content/actuator/health"
echo ""
echo "서비스 종료: kill ${SERVICE_PID}"
echo "=================================================="

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