mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 13:26:23 +00:00
Compare commits
55 Commits
f80418f5ee
...
2c4f2b0516
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4f2b0516 | ||
|
|
6280ff8ce1 | ||
|
|
c66decce42 | ||
|
|
9ce62738a1 | ||
|
|
06ea838547 | ||
|
|
efcec065ec | ||
|
|
262a5fea33 | ||
|
|
d14a7349bc | ||
|
|
6e7a9386f6 | ||
|
|
047703fb89 | ||
|
|
17278ad045 | ||
|
|
cf379407e8 | ||
|
|
f13bfe6a6e | ||
|
|
4bc7f87663 | ||
|
|
ae8f540d46 | ||
|
|
c6dfc74bda | ||
|
|
027ab86e8d | ||
|
|
c95c47d630 | ||
|
|
b92307d564 | ||
|
|
2663baf615 | ||
|
|
349b644617 | ||
|
|
ea4d551d3e | ||
|
|
d81c5be90d | ||
|
|
e080acbcb9 | ||
|
|
29285d8576 | ||
|
|
f2e8f7499f | ||
|
|
52b63fb0f0 | ||
|
|
a23b4eb505 | ||
|
|
c6b33885e0 | ||
|
|
ac7fcbd2fe | ||
|
|
97f50fd751 | ||
|
|
c53cbdf4f8 | ||
|
|
7dc039361f | ||
|
|
48c76db83a | ||
|
|
72728841db | ||
|
|
9e2d0a3889 | ||
|
|
14823a17c4 | ||
|
|
a3781a279a | ||
|
|
5c365fe899 | ||
|
|
a3381cc540 | ||
|
|
7ed2465d57 | ||
|
|
5cac8ccc12 | ||
|
|
acd827b226 | ||
|
|
ea53bd13a8 | ||
|
|
6948b48498 | ||
|
|
aa8db3bf2f | ||
|
|
be59934f78 | ||
|
|
3afee053d0 | ||
|
|
27a3111dd8 | ||
|
|
3465a35827 | ||
|
|
8ff79ca1ab | ||
|
|
336d811f55 | ||
|
|
ee941e4910 | ||
|
|
b71d27aa8b | ||
|
|
34291e1613 |
@ -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: ""
|
||||||
|
|||||||
5
.github/kustomize/base/kustomization.yaml
vendored
5
.github/kustomize/base/kustomization.yaml
vendored
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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
620
DEVELOP_CHANGELOG.md
Normal 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
|
||||||
@ -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/**");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
187
analytics-service/src/main/resources/sample-data.json
Normal file
187
analytics-service/src/main/resources/sample-data.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,8 +40,10 @@ public enum ErrorCode {
|
|||||||
EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
|
EVENT_001("EVENT_001", "이벤트를 찾을 수 없습니다"),
|
||||||
EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
|
EVENT_002("EVENT_002", "유효하지 않은 상태 전환입니다"),
|
||||||
EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
|
EVENT_003("EVENT_003", "필수 데이터가 누락되었습니다"),
|
||||||
EVENT_004("EVENT_004", "이벤트 생성에 실패했습니다"),
|
EVENT_004("EVENT_004", "유효하지 않은 eventId 형식입니다"),
|
||||||
EVENT_005("EVENT_005", "이벤트 수정 권한이 없습니다"),
|
EVENT_005("EVENT_005", "이미 존재하는 eventId입니다"),
|
||||||
|
EVENT_006("EVENT_006", "이벤트 생성에 실패했습니다"),
|
||||||
|
EVENT_007("EVENT_007", "이벤트 수정 권한이 없습니다"),
|
||||||
|
|
||||||
// Job 에러 (JOB_XXX)
|
// Job 에러 (JOB_XXX)
|
||||||
JOB_001("JOB_001", "Job을 찾을 수 없습니다"),
|
JOB_001("JOB_001", "Job을 찾을 수 없습니다"),
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import javax.crypto.SecretKey;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT 토큰 생성 및 검증 제공자
|
* JWT 토큰 생성 및 검증 제공자
|
||||||
@ -57,13 +56,13 @@ public class JwtTokenProvider {
|
|||||||
* @return Access Token
|
* @return Access Token
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public String createAccessToken(UUID userId, UUID storeId, String email, String name, List<String> roles) {
|
public String createAccessToken(String userId, String storeId, String email, String name, List<String> roles) {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
|
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.subject(userId.toString())
|
.subject(userId)
|
||||||
.claim("storeId", storeId != null ? storeId.toString() : null)
|
.claim("storeId", storeId)
|
||||||
.claim("email", email)
|
.claim("email", email)
|
||||||
.claim("name", name)
|
.claim("name", name)
|
||||||
.claim("roles", roles)
|
.claim("roles", roles)
|
||||||
@ -80,12 +79,12 @@ public class JwtTokenProvider {
|
|||||||
* @param userId 사용자 ID
|
* @param userId 사용자 ID
|
||||||
* @return Refresh Token
|
* @return Refresh Token
|
||||||
*/
|
*/
|
||||||
public String createRefreshToken(UUID userId) {
|
public String createRefreshToken(String userId) {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
|
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.subject(userId.toString())
|
.subject(userId)
|
||||||
.claim("type", "refresh")
|
.claim("type", "refresh")
|
||||||
.issuedAt(now)
|
.issuedAt(now)
|
||||||
.expiration(expiryDate)
|
.expiration(expiryDate)
|
||||||
@ -99,9 +98,9 @@ public class JwtTokenProvider {
|
|||||||
* @param token JWT 토큰
|
* @param token JWT 토큰
|
||||||
* @return 사용자 ID
|
* @return 사용자 ID
|
||||||
*/
|
*/
|
||||||
public UUID getUserIdFromToken(String token) {
|
public String getUserIdFromToken(String token) {
|
||||||
Claims claims = parseToken(token);
|
Claims claims = parseToken(token);
|
||||||
return UUID.fromString(claims.getSubject());
|
return claims.getSubject();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -113,9 +112,8 @@ public class JwtTokenProvider {
|
|||||||
public UserPrincipal getUserPrincipalFromToken(String token) {
|
public UserPrincipal getUserPrincipalFromToken(String token) {
|
||||||
Claims claims = parseToken(token);
|
Claims claims = parseToken(token);
|
||||||
|
|
||||||
UUID userId = UUID.fromString(claims.getSubject());
|
String userId = claims.getSubject();
|
||||||
String storeIdStr = claims.get("storeId", String.class);
|
String storeId = claims.get("storeId", String.class);
|
||||||
UUID storeId = storeIdStr != null ? UUID.fromString(storeIdStr) : null;
|
|
||||||
String email = claims.get("email", String.class);
|
String email = claims.get("email", String.class);
|
||||||
String name = claims.get("name", String.class);
|
String name = claims.get("name", String.class);
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import org.springframework.security.core.userdetails.UserDetails;
|
|||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,12 +23,12 @@ public class UserPrincipal implements UserDetails {
|
|||||||
/**
|
/**
|
||||||
* 사용자 ID
|
* 사용자 ID
|
||||||
*/
|
*/
|
||||||
private final UUID userId;
|
private final String userId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 ID
|
* 매장 ID
|
||||||
*/
|
*/
|
||||||
private final UUID storeId;
|
private final String storeId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 이메일
|
* 사용자 이메일
|
||||||
|
|||||||
@ -46,6 +46,9 @@ public class RegenerateImageService implements RegenerateImageUseCase {
|
|||||||
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
|
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
|
||||||
private String modelVersion;
|
private String modelVersion;
|
||||||
|
|
||||||
|
@Value("${replicate.mock.enabled:false}")
|
||||||
|
private boolean mockEnabled;
|
||||||
|
|
||||||
public RegenerateImageService(
|
public RegenerateImageService(
|
||||||
ReplicateApiClient replicateClient,
|
ReplicateApiClient replicateClient,
|
||||||
CDNUploader cdnUploader,
|
CDNUploader cdnUploader,
|
||||||
@ -151,6 +154,14 @@ public class RegenerateImageService implements RegenerateImageUseCase {
|
|||||||
*/
|
*/
|
||||||
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
|
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
|
||||||
try {
|
try {
|
||||||
|
// Mock 모드일 경우 Mock 데이터 반환
|
||||||
|
// if (mockEnabled) {
|
||||||
|
// log.info("[MOCK] 이미지 재생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
|
||||||
|
// String mockUrl = generateMockImageUrl(platform);
|
||||||
|
// log.info("[MOCK] 이미지 재생성 완료: url={}", mockUrl);
|
||||||
|
// return mockUrl;
|
||||||
|
// }
|
||||||
|
|
||||||
int width = platform.getWidth();
|
int width = platform.getWidth();
|
||||||
int height = platform.getHeight();
|
int height = platform.getHeight();
|
||||||
|
|
||||||
@ -274,4 +285,21 @@ public class RegenerateImageService implements RegenerateImageUseCase {
|
|||||||
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다", e);
|
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 이미지 URL 생성 (dev 환경용)
|
||||||
|
*
|
||||||
|
* @param platform 플랫폼 (이미지 크기 결정)
|
||||||
|
* @return Mock 이미지 URL
|
||||||
|
*/
|
||||||
|
private String generateMockImageUrl(com.kt.event.content.biz.domain.Platform platform) {
|
||||||
|
// 플랫폼별 크기에 맞는 placeholder 이미지 URL 생성
|
||||||
|
int width = platform.getWidth();
|
||||||
|
int height = platform.getHeight();
|
||||||
|
|
||||||
|
// placeholder.com을 사용한 Mock 이미지 URL
|
||||||
|
String mockId = UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
return String.format("https://via.placeholder.com/%dx%d/6BCF7F/FFFFFF?text=Regenerated+%s+%s",
|
||||||
|
width, height, platform.name(), mockId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,9 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
|||||||
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
|
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
|
||||||
private String modelVersion;
|
private String modelVersion;
|
||||||
|
|
||||||
|
@Value("${replicate.mock.enabled:false}")
|
||||||
|
private boolean mockEnabled;
|
||||||
|
|
||||||
public StableDiffusionImageGenerator(
|
public StableDiffusionImageGenerator(
|
||||||
ReplicateApiClient replicateClient,
|
ReplicateApiClient replicateClient,
|
||||||
CDNUploader cdnUploader,
|
CDNUploader cdnUploader,
|
||||||
@ -188,6 +191,14 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
|||||||
*/
|
*/
|
||||||
private String generateImage(String prompt, Platform platform) {
|
private String generateImage(String prompt, Platform platform) {
|
||||||
try {
|
try {
|
||||||
|
// Mock 모드일 경우 Mock 데이터 반환
|
||||||
|
// if (mockEnabled) {
|
||||||
|
// log.info("[MOCK] 이미지 생성 요청 (실제 API 호출 없음): prompt={}, platform={}", prompt, platform);
|
||||||
|
// String mockUrl = generateMockImageUrl(platform);
|
||||||
|
// log.info("[MOCK] 이미지 생성 완료: url={}", mockUrl);
|
||||||
|
// return mockUrl;
|
||||||
|
// }
|
||||||
|
|
||||||
// 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
|
// 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
|
||||||
int width = platform.getWidth();
|
int width = platform.getWidth();
|
||||||
int height = platform.getHeight();
|
int height = platform.getHeight();
|
||||||
@ -236,6 +247,23 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 이미지 URL 생성 (dev 환경용)
|
||||||
|
*
|
||||||
|
* @param platform 플랫폼 (이미지 크기 결정)
|
||||||
|
* @return Mock 이미지 URL
|
||||||
|
*/
|
||||||
|
private String generateMockImageUrl(Platform platform) {
|
||||||
|
// 플랫폼별 크기에 맞는 placeholder 이미지 URL 생성
|
||||||
|
int width = platform.getWidth();
|
||||||
|
int height = platform.getHeight();
|
||||||
|
|
||||||
|
// placeholder.com을 사용한 Mock 이미지 URL
|
||||||
|
String mockId = UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
return String.format("https://via.placeholder.com/%dx%d/FF6B6B/FFFFFF?text=%s+Event+%s",
|
||||||
|
width, height, platform.name(), mockId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replicate API 예측 완료 대기 (폴링)
|
* Replicate API 예측 완료 대기 (폴링)
|
||||||
*
|
*
|
||||||
|
|||||||
@ -37,6 +37,8 @@ replicate:
|
|||||||
token: ${REPLICATE_API_TOKEN:}
|
token: ${REPLICATE_API_TOKEN:}
|
||||||
model:
|
model:
|
||||||
version: ${REPLICATE_MODEL_VERSION:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}
|
version: ${REPLICATE_MODEL_VERSION:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}
|
||||||
|
mock:
|
||||||
|
enabled: ${REPLICATE_MOCK_ENABLED:true}
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
cors:
|
cors:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
234
develop/database/migration/alter_event_id_to_varchar.sql
Normal file
234
develop/database/migration/alter_event_id_to_varchar.sql
Normal 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;
|
||||||
|
*/
|
||||||
233
develop/database/schema/create_event_tables.sql
Normal file
233
develop/database/schema/create_event_tables.sql
Normal 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 $$;
|
||||||
390
develop/dev/ai-service-workflow.md
Normal file
390
develop/dev/ai-service-workflow.md
Normal 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
|
||||||
|
```
|
||||||
@ -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" />
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
248
distribution-service/NAVER_BLOG_SETUP.md
Normal file
248
distribution-service/NAVER_BLOG_SETUP.md
Normal 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 등록 (로그 첨부)
|
||||||
@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,11 @@ public class ChannelDistributionResult {
|
|||||||
*/
|
*/
|
||||||
private String distributionId;
|
private String distributionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배포 URL (성공 시) - 실제 포스팅된 URL
|
||||||
|
*/
|
||||||
|
private String postUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예상 노출 수 (성공 시)
|
* 예상 노출 수 (성공 시)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
package com.kt.event.eventservice.application.dto.kafka;
|
package com.kt.event.eventservice.application.dto.kafka;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 이벤트 생성 작업 메시지 DTO
|
* AI 이벤트 생성 작업 메시지 DTO
|
||||||
*
|
*
|
||||||
* ai-event-generation-job 토픽에서 구독하는 메시지 형식
|
* ai-event-generation-job 토픽에서 구독하는 메시지 형식
|
||||||
|
* JSON 필드명: camelCase (Jackson 기본 설정)
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@ -23,73 +22,54 @@ public class AIEventGenerationJobMessage {
|
|||||||
/**
|
/**
|
||||||
* 작업 ID
|
* 작업 ID
|
||||||
*/
|
*/
|
||||||
@JsonProperty("job_id")
|
|
||||||
private String jobId;
|
private String jobId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 ID (UUID String)
|
* 사용자 ID (UUID String)
|
||||||
*/
|
*/
|
||||||
@JsonProperty("user_id")
|
|
||||||
private String userId;
|
private String userId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)
|
* 이벤트 ID
|
||||||
*/
|
*/
|
||||||
@JsonProperty("status")
|
private String eventId;
|
||||||
private String status;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 추천 결과 데이터
|
* 이벤트 목적
|
||||||
|
* - "신규 고객 유치"
|
||||||
|
* - "재방문 유도"
|
||||||
|
* - "매출 증대"
|
||||||
|
* - "브랜드 인지도 향상"
|
||||||
*/
|
*/
|
||||||
@JsonProperty("ai_recommendation")
|
private String objective;
|
||||||
private AIRecommendationData aiRecommendation;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 에러 메시지 (실패 시)
|
* 업종 (storeCategory와 동일)
|
||||||
*/
|
*/
|
||||||
@JsonProperty("error_message")
|
private String industry;
|
||||||
private String errorMessage;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업 생성 일시
|
* 지역 (시/구/동)
|
||||||
*/
|
*/
|
||||||
@JsonProperty("created_at")
|
private String region;
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업 완료/실패 일시
|
* 매장명
|
||||||
*/
|
*/
|
||||||
@JsonProperty("completed_at")
|
private String storeName;
|
||||||
private LocalDateTime completedAt;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 추천 데이터 내부 클래스
|
* 목표 고객층 (선택)
|
||||||
*/
|
*/
|
||||||
@Data
|
private String targetAudience;
|
||||||
@Builder
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public static class AIRecommendationData {
|
|
||||||
|
|
||||||
@JsonProperty("event_title")
|
/**
|
||||||
private String eventTitle;
|
* 예산 (원) (선택)
|
||||||
|
*/
|
||||||
|
private Integer budget;
|
||||||
|
|
||||||
@JsonProperty("event_description")
|
/**
|
||||||
private String eventDescription;
|
* 요청 시각
|
||||||
|
*/
|
||||||
@JsonProperty("event_type")
|
private LocalDateTime requestedAt;
|
||||||
private String eventType;
|
|
||||||
|
|
||||||
@JsonProperty("target_keywords")
|
|
||||||
private List<String> targetKeywords;
|
|
||||||
|
|
||||||
@JsonProperty("recommended_benefits")
|
|
||||||
private List<String> recommendedBenefits;
|
|
||||||
|
|
||||||
@JsonProperty("start_date")
|
|
||||||
private String startDate;
|
|
||||||
|
|
||||||
@JsonProperty("end_date")
|
|
||||||
private String endDate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 생성 완료 메시지 DTO
|
* 이벤트 생성 완료 메시지 DTO
|
||||||
@ -21,16 +20,16 @@ import java.util.UUID;
|
|||||||
public class EventCreatedMessage {
|
public class EventCreatedMessage {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 ID (UUID)
|
* 이벤트 ID
|
||||||
*/
|
*/
|
||||||
@JsonProperty("event_id")
|
@JsonProperty("event_id")
|
||||||
private UUID eventId;
|
private String eventId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 ID (UUID)
|
* 사용자 ID
|
||||||
*/
|
*/
|
||||||
@JsonProperty("user_id")
|
@JsonProperty("user_id")
|
||||||
private UUID userId;
|
private String userId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 제목
|
* 이벤트 제목
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import lombok.Builder;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 추천 요청 DTO
|
* AI 추천 요청 DTO
|
||||||
*
|
*
|
||||||
@ -26,11 +24,24 @@ import java.util.UUID;
|
|||||||
@Schema(description = "AI 추천 요청")
|
@Schema(description = "AI 추천 요청")
|
||||||
public class AiRecommendationRequest {
|
public class AiRecommendationRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "이벤트 목적은 필수입니다.")
|
||||||
|
@Schema(description = "이벤트 목적", required = true, example = "신규 고객 유치")
|
||||||
|
private String objective;
|
||||||
|
|
||||||
@NotNull(message = "매장 정보는 필수입니다.")
|
@NotNull(message = "매장 정보는 필수입니다.")
|
||||||
@Valid
|
@Valid
|
||||||
@Schema(description = "매장 정보", required = true)
|
@Schema(description = "매장 정보", required = true)
|
||||||
private StoreInfo storeInfo;
|
private StoreInfo storeInfo;
|
||||||
|
|
||||||
|
@Schema(description = "지역 정보", example = "서울특별시 강남구")
|
||||||
|
private String region;
|
||||||
|
|
||||||
|
@Schema(description = "타겟 고객층", example = "20-30대 직장인")
|
||||||
|
private String targetAudience;
|
||||||
|
|
||||||
|
@Schema(description = "예산 (원)", example = "500000")
|
||||||
|
private Integer budget;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 정보
|
* 매장 정보
|
||||||
*/
|
*/
|
||||||
@ -42,8 +53,8 @@ public class AiRecommendationRequest {
|
|||||||
public static class StoreInfo {
|
public static class StoreInfo {
|
||||||
|
|
||||||
@NotNull(message = "매장 ID는 필수입니다.")
|
@NotNull(message = "매장 ID는 필수입니다.")
|
||||||
@Schema(description = "매장 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440002")
|
@Schema(description = "매장 ID", required = true, example = "str_20250124_001")
|
||||||
private UUID storeId;
|
private String storeId;
|
||||||
|
|
||||||
@NotNull(message = "매장명은 필수입니다.")
|
@NotNull(message = "매장명은 필수입니다.")
|
||||||
@Schema(description = "매장명", required = true, example = "우진네 고깃집")
|
@Schema(description = "매장명", required = true, example = "우진네 고깃집")
|
||||||
|
|||||||
@ -6,8 +6,6 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 선택 요청 DTO
|
* 이미지 선택 요청 DTO
|
||||||
*
|
*
|
||||||
@ -22,7 +20,7 @@ import java.util.UUID;
|
|||||||
public class SelectImageRequest {
|
public class SelectImageRequest {
|
||||||
|
|
||||||
@NotNull(message = "이미지 ID는 필수입니다.")
|
@NotNull(message = "이미지 ID는 필수입니다.")
|
||||||
private UUID imageId;
|
private String imageId;
|
||||||
|
|
||||||
private String imageUrl;
|
private String imageUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,9 @@ import lombok.NoArgsConstructor;
|
|||||||
@Builder
|
@Builder
|
||||||
public class SelectObjectiveRequest {
|
public class SelectObjectiveRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "이벤트 ID는 필수입니다.")
|
||||||
|
private String eventId;
|
||||||
|
|
||||||
@NotBlank(message = "이벤트 목적은 필수입니다.")
|
@NotBlank(message = "이벤트 목적은 필수입니다.")
|
||||||
private String objective;
|
private String objective;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import lombok.Getter;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 추천 선택 요청 DTO
|
* AI 추천 선택 요청 DTO
|
||||||
@ -28,8 +27,8 @@ import java.util.UUID;
|
|||||||
public class SelectRecommendationRequest {
|
public class SelectRecommendationRequest {
|
||||||
|
|
||||||
@NotNull(message = "추천 ID는 필수입니다.")
|
@NotNull(message = "추천 ID는 필수입니다.")
|
||||||
@Schema(description = "선택한 추천 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440007")
|
@Schema(description = "선택한 추천 ID", required = true, example = "rec_20250124_001")
|
||||||
private UUID recommendationId;
|
private String recommendationId;
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
@Schema(description = "커스터마이징 항목")
|
@Schema(description = "커스터마이징 항목")
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 생성 응답 DTO
|
* 이벤트 생성 응답 DTO
|
||||||
@ -22,7 +21,7 @@ import java.util.UUID;
|
|||||||
@Builder
|
@Builder
|
||||||
public class EventCreatedResponse {
|
public class EventCreatedResponse {
|
||||||
|
|
||||||
private UUID eventId;
|
private String eventId;
|
||||||
private EventStatus status;
|
private EventStatus status;
|
||||||
private String objective;
|
private String objective;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import java.time.LocalDate;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 상세 응답 DTO
|
* 이벤트 상세 응답 DTO
|
||||||
@ -25,16 +24,16 @@ import java.util.UUID;
|
|||||||
@Builder
|
@Builder
|
||||||
public class EventDetailResponse {
|
public class EventDetailResponse {
|
||||||
|
|
||||||
private UUID eventId;
|
private String eventId;
|
||||||
private UUID userId;
|
private String userId;
|
||||||
private UUID storeId;
|
private String storeId;
|
||||||
private String eventName;
|
private String eventName;
|
||||||
private String description;
|
private String description;
|
||||||
private String objective;
|
private String objective;
|
||||||
private LocalDate startDate;
|
private LocalDate startDate;
|
||||||
private LocalDate endDate;
|
private LocalDate endDate;
|
||||||
private EventStatus status;
|
private EventStatus status;
|
||||||
private UUID selectedImageId;
|
private String selectedImageId;
|
||||||
private String selectedImageUrl;
|
private String selectedImageUrl;
|
||||||
private Integer participants;
|
private Integer participants;
|
||||||
private Integer targetParticipants;
|
private Integer targetParticipants;
|
||||||
@ -57,7 +56,7 @@ public class EventDetailResponse {
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
@Builder
|
||||||
public static class GeneratedImageDto {
|
public static class GeneratedImageDto {
|
||||||
private UUID imageId;
|
private String imageId;
|
||||||
private String imageUrl;
|
private String imageUrl;
|
||||||
private String style;
|
private String style;
|
||||||
private String platform;
|
private String platform;
|
||||||
@ -70,7 +69,7 @@ public class EventDetailResponse {
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
@Builder
|
||||||
public static class AiRecommendationDto {
|
public static class AiRecommendationDto {
|
||||||
private UUID recommendationId;
|
private String recommendationId;
|
||||||
private String eventName;
|
private String eventName;
|
||||||
private String description;
|
private String description;
|
||||||
private String promotionType;
|
private String promotionType;
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import lombok.Getter;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 편집 응답 DTO
|
* 이미지 편집 응답 DTO
|
||||||
@ -25,8 +24,8 @@ import java.util.UUID;
|
|||||||
@Schema(description = "이미지 편집 응답")
|
@Schema(description = "이미지 편집 응답")
|
||||||
public class ImageEditResponse {
|
public class ImageEditResponse {
|
||||||
|
|
||||||
@Schema(description = "편집된 이미지 ID", example = "550e8400-e29b-41d4-a716-446655440008")
|
@Schema(description = "편집된 이미지 ID", example = "img_20250124_001")
|
||||||
private UUID imageId;
|
private String imageId;
|
||||||
|
|
||||||
@Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg")
|
@Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg")
|
||||||
private String imageUrl;
|
private String imageUrl;
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 생성 응답 DTO
|
* 이미지 생성 응답 DTO
|
||||||
@ -21,7 +20,7 @@ import java.util.UUID;
|
|||||||
@Builder
|
@Builder
|
||||||
public class ImageGenerationResponse {
|
public class ImageGenerationResponse {
|
||||||
|
|
||||||
private UUID jobId;
|
private String jobId;
|
||||||
private String status;
|
private String status;
|
||||||
private String message;
|
private String message;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@ -7,8 +7,6 @@ import lombok.Builder;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 접수 응답 DTO
|
* Job 접수 응답 DTO
|
||||||
*
|
*
|
||||||
@ -25,8 +23,8 @@ import java.util.UUID;
|
|||||||
@Schema(description = "Job 접수 응답")
|
@Schema(description = "Job 접수 응답")
|
||||||
public class JobAcceptedResponse {
|
public class JobAcceptedResponse {
|
||||||
|
|
||||||
@Schema(description = "생성된 Job ID", example = "550e8400-e29b-41d4-a716-446655440005")
|
@Schema(description = "생성된 Job ID", example = "job_20250124_001")
|
||||||
private UUID jobId;
|
private String jobId;
|
||||||
|
|
||||||
@Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING")
|
@Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING")
|
||||||
private JobStatus status;
|
private JobStatus status;
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 상태 응답 DTO
|
* Job 상태 응답 DTO
|
||||||
@ -23,7 +22,7 @@ import java.util.UUID;
|
|||||||
@Builder
|
@Builder
|
||||||
public class JobStatusResponse {
|
public class JobStatusResponse {
|
||||||
|
|
||||||
private UUID jobId;
|
private String jobId;
|
||||||
private JobType jobType;
|
private JobType jobType;
|
||||||
private JobStatus status;
|
private JobStatus status;
|
||||||
private int progress;
|
private int progress;
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
package com.kt.event.eventservice.application.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 ID 생성기
|
||||||
|
*
|
||||||
|
* 비즈니스 친화적인 eventId를 생성합니다.
|
||||||
|
* 형식: EVT-{storeId}-{yyyyMMddHHmmss}-{random8}
|
||||||
|
* 예시: EVT-store123-20251029143025-a1b2c3d4
|
||||||
|
*
|
||||||
|
* VARCHAR(50) 길이 제약사항을 고려하여 설계되었습니다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class EventIdGenerator {
|
||||||
|
|
||||||
|
private static final String PREFIX = "EVT";
|
||||||
|
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||||
|
private static final int RANDOM_LENGTH = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 ID 생성 (백엔드용)
|
||||||
|
*
|
||||||
|
* 참고: 현재는 프론트엔드에서 eventId를 생성하므로 이 메서드는 거의 사용되지 않습니다.
|
||||||
|
*
|
||||||
|
* @param storeId 상점 ID
|
||||||
|
* @return 생성된 이벤트 ID
|
||||||
|
*/
|
||||||
|
public String generate(String storeId) {
|
||||||
|
// 기본값 처리
|
||||||
|
if (storeId == null || storeId.isBlank()) {
|
||||||
|
storeId = "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER);
|
||||||
|
String randomPart = generateRandomPart();
|
||||||
|
|
||||||
|
// 형식: EVT-{storeId}-{timestamp}-{random}
|
||||||
|
String eventId = String.format("%s-%s-%s-%s", PREFIX, storeId, timestamp, randomPart);
|
||||||
|
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID 기반 랜덤 문자열 생성
|
||||||
|
*
|
||||||
|
* @return 8자리 랜덤 문자열 (소문자 영숫자)
|
||||||
|
*/
|
||||||
|
private String generateRandomPart() {
|
||||||
|
return UUID.randomUUID()
|
||||||
|
.toString()
|
||||||
|
.replace("-", "")
|
||||||
|
.substring(0, RANDOM_LENGTH)
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* eventId 기본 검증
|
||||||
|
*
|
||||||
|
* 최소한의 검증만 수행합니다:
|
||||||
|
* - null/empty 체크
|
||||||
|
* - 길이 제한 체크 (VARCHAR(50) 제약)
|
||||||
|
*
|
||||||
|
* 프론트엔드에서 생성한 eventId를 신뢰하며,
|
||||||
|
* DB의 PRIMARY KEY 제약조건으로 중복을 방지합니다.
|
||||||
|
*
|
||||||
|
* @param eventId 검증할 이벤트 ID
|
||||||
|
* @return 유효하면 true, 아니면 false
|
||||||
|
*/
|
||||||
|
public boolean isValid(String eventId) {
|
||||||
|
if (eventId == null || eventId.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 길이 검증 (DB VARCHAR(50) 제약)
|
||||||
|
if (eventId.length() > 50) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.kt.event.eventservice.application.service;
|
||||||
|
|
||||||
|
import com.kt.event.eventservice.domain.enums.JobType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job ID 생성기
|
||||||
|
*
|
||||||
|
* 비즈니스 친화적인 jobId를 생성합니다.
|
||||||
|
* 형식: JOB-{jobType}-{timestamp}-{random8}
|
||||||
|
* 예시: JOB-AI-20251029143025-a1b2c3d4
|
||||||
|
*
|
||||||
|
* VARCHAR(50) 길이 제약사항을 고려하여 설계되었습니다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JobIdGenerator {
|
||||||
|
|
||||||
|
private static final String PREFIX = "JOB";
|
||||||
|
private static final int RANDOM_LENGTH = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job ID 생성
|
||||||
|
*
|
||||||
|
* @param jobType Job 타입
|
||||||
|
* @return 생성된 Job ID
|
||||||
|
* @throws IllegalArgumentException jobType이 null인 경우
|
||||||
|
*/
|
||||||
|
public String generate(JobType jobType) {
|
||||||
|
if (jobType == null) {
|
||||||
|
throw new IllegalArgumentException("jobType은 필수입니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
String typeCode = getTypeCode(jobType);
|
||||||
|
String timestamp = String.valueOf(System.currentTimeMillis());
|
||||||
|
String randomPart = generateRandomPart();
|
||||||
|
|
||||||
|
// 형식: JOB-{type}-{timestamp}-{random}
|
||||||
|
// 예상 길이: 3 + 1 + 5 + 1 + 13 + 1 + 8 = 32자 (최대)
|
||||||
|
String jobId = String.format("%s-%s-%s-%s", PREFIX, typeCode, timestamp, randomPart);
|
||||||
|
|
||||||
|
// 길이 검증
|
||||||
|
if (jobId.length() > 50) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
String.format("생성된 jobId 길이(%d)가 50자를 초과했습니다: %s",
|
||||||
|
jobId.length(), jobId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JobType을 짧은 코드로 변환
|
||||||
|
*
|
||||||
|
* @param jobType Job 타입
|
||||||
|
* @return 타입 코드
|
||||||
|
*/
|
||||||
|
private String getTypeCode(JobType jobType) {
|
||||||
|
switch (jobType) {
|
||||||
|
case AI_RECOMMENDATION:
|
||||||
|
return "AI";
|
||||||
|
case IMAGE_GENERATION:
|
||||||
|
return "IMG";
|
||||||
|
default:
|
||||||
|
return jobType.name().substring(0, Math.min(5, jobType.name().length()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID 기반 랜덤 문자열 생성
|
||||||
|
*
|
||||||
|
* @return 8자리 랜덤 문자열 (소문자 영숫자)
|
||||||
|
*/
|
||||||
|
private String generateRandomPart() {
|
||||||
|
return UUID.randomUUID()
|
||||||
|
.toString()
|
||||||
|
.replace("-", "")
|
||||||
|
.substring(0, RANDOM_LENGTH)
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jobId 기본 검증
|
||||||
|
*
|
||||||
|
* 최소한의 검증만 수행합니다:
|
||||||
|
* - null/empty 체크
|
||||||
|
* - 길이 제한 체크 (VARCHAR(50) 제약)
|
||||||
|
*
|
||||||
|
* @param jobId 검증할 Job ID
|
||||||
|
* @return 유효하면 true, 아니면 false
|
||||||
|
*/
|
||||||
|
public boolean isValid(String jobId) {
|
||||||
|
if (jobId == null || jobId.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 길이 검증 (DB VARCHAR(50) 제약)
|
||||||
|
if (jobId.length() > 50) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,8 +11,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 서비스
|
* Job 서비스
|
||||||
*
|
*
|
||||||
@ -29,6 +27,7 @@ import java.util.UUID;
|
|||||||
public class JobService {
|
public class JobService {
|
||||||
|
|
||||||
private final JobRepository jobRepository;
|
private final JobRepository jobRepository;
|
||||||
|
private final JobIdGenerator jobIdGenerator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 생성
|
* Job 생성
|
||||||
@ -38,10 +37,15 @@ public class JobService {
|
|||||||
* @return 생성된 Job
|
* @return 생성된 Job
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Job createJob(UUID eventId, JobType jobType) {
|
public Job createJob(String eventId, JobType jobType) {
|
||||||
log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType);
|
log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType);
|
||||||
|
|
||||||
|
// jobId 생성
|
||||||
|
String jobId = jobIdGenerator.generate(jobType);
|
||||||
|
log.info("생성된 jobId: {}", jobId);
|
||||||
|
|
||||||
Job job = Job.builder()
|
Job job = Job.builder()
|
||||||
|
.jobId(jobId)
|
||||||
.eventId(eventId)
|
.eventId(eventId)
|
||||||
.jobType(jobType)
|
.jobType(jobType)
|
||||||
.build();
|
.build();
|
||||||
@ -59,7 +63,7 @@ public class JobService {
|
|||||||
* @param jobId Job ID
|
* @param jobId Job ID
|
||||||
* @return Job 상태 응답
|
* @return Job 상태 응답
|
||||||
*/
|
*/
|
||||||
public JobStatusResponse getJobStatus(UUID jobId) {
|
public JobStatusResponse getJobStatus(String jobId) {
|
||||||
log.info("Job 상태 조회 - jobId: {}", jobId);
|
log.info("Job 상태 조회 - jobId: {}", jobId);
|
||||||
|
|
||||||
Job job = jobRepository.findById(jobId)
|
Job job = jobRepository.findById(jobId)
|
||||||
@ -75,7 +79,7 @@ public class JobService {
|
|||||||
* @param progress 진행률
|
* @param progress 진행률
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateJobProgress(UUID jobId, int progress) {
|
public void updateJobProgress(String jobId, int progress) {
|
||||||
log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress);
|
log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress);
|
||||||
|
|
||||||
Job job = jobRepository.findById(jobId)
|
Job job = jobRepository.findById(jobId)
|
||||||
@ -93,7 +97,7 @@ public class JobService {
|
|||||||
* @param resultKey Redis 결과 키
|
* @param resultKey Redis 결과 키
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void completeJob(UUID jobId, String resultKey) {
|
public void completeJob(String jobId, String resultKey) {
|
||||||
log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey);
|
log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey);
|
||||||
|
|
||||||
Job job = jobRepository.findById(jobId)
|
Job job = jobRepository.findById(jobId)
|
||||||
@ -113,7 +117,7 @@ public class JobService {
|
|||||||
* @param errorMessage 에러 메시지
|
* @param errorMessage 에러 메시지
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void failJob(UUID jobId, String errorMessage) {
|
public void failJob(String jobId, String errorMessage) {
|
||||||
log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage);
|
log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage);
|
||||||
|
|
||||||
Job job = jobRepository.findById(jobId)
|
Job job = jobRepository.findById(jobId)
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package com.kt.event.eventservice.application.service;
|
package com.kt.event.eventservice.application.service;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 알림 서비스 인터페이스
|
* 알림 서비스 인터페이스
|
||||||
*
|
*
|
||||||
@ -22,7 +20,7 @@ public interface NotificationService {
|
|||||||
* @param jobType 작업 타입
|
* @param jobType 작업 타입
|
||||||
* @param message 알림 메시지
|
* @param message 알림 메시지
|
||||||
*/
|
*/
|
||||||
void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message);
|
void notifyJobCompleted(String userId, String jobId, String jobType, String message);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업 실패 알림 전송
|
* 작업 실패 알림 전송
|
||||||
@ -32,7 +30,7 @@ public interface NotificationService {
|
|||||||
* @param jobType 작업 타입
|
* @param jobType 작업 타입
|
||||||
* @param errorMessage 에러 메시지
|
* @param errorMessage 에러 메시지
|
||||||
*/
|
*/
|
||||||
void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage);
|
void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업 진행 상태 알림 전송
|
* 작업 진행 상태 알림 전송
|
||||||
@ -42,5 +40,5 @@ public interface NotificationService {
|
|||||||
* @param jobType 작업 타입
|
* @param jobType 작업 타입
|
||||||
* @param progress 진행률 (0-100)
|
* @param progress 진행률 (0-100)
|
||||||
*/
|
*/
|
||||||
void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress);
|
void notifyJobProgress(String userId, String jobId, String jobType, int progress);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개발 환경용 인증 필터
|
* 개발 환경용 인증 필터
|
||||||
@ -35,11 +34,11 @@ public class DevAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
// 개발용 기본 UserPrincipal 생성
|
// 개발용 기본 UserPrincipal 생성
|
||||||
UserPrincipal userPrincipal = new UserPrincipal(
|
UserPrincipal userPrincipal = new UserPrincipal(
|
||||||
UUID.fromString("11111111-1111-1111-1111-111111111111"), // userId
|
"usr_dev_test_001", // userId
|
||||||
UUID.fromString("22222222-2222-2222-2222-222222222222"), // storeId
|
"str_dev_test_001", // storeId
|
||||||
"dev@test.com", // email
|
"dev@test.com", // email
|
||||||
"개발테스트사용자", // name
|
"개발테스트사용자", // name
|
||||||
Collections.singletonList("USER") // roles
|
Collections.singletonList("USER") // roles
|
||||||
);
|
);
|
||||||
|
|
||||||
// Authentication 객체 생성 및 SecurityContext에 설정
|
// Authentication 객체 생성 및 SecurityContext에 설정
|
||||||
|
|||||||
@ -37,7 +37,7 @@ public class KafkaConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Kafka Producer 설정
|
* Kafka Producer 설정
|
||||||
* Producer에서 JSON 문자열을 보내므로 StringSerializer 사용
|
* Producer에서 객체를 직접 보내므로 JsonSerializer 사용
|
||||||
*
|
*
|
||||||
* @return ProducerFactory 인스턴스
|
* @return ProducerFactory 인스턴스
|
||||||
*/
|
*/
|
||||||
@ -46,7 +46,10 @@ public class KafkaConfig {
|
|||||||
Map<String, Object> config = new HashMap<>();
|
Map<String, Object> config = new HashMap<>();
|
||||||
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||||
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||||
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
|
||||||
|
|
||||||
|
// JSON 직렬화 시 타입 정보를 헤더에 추가하지 않음 (마이크로서비스 간 DTO 클래스 불일치 방지)
|
||||||
|
config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);
|
||||||
|
|
||||||
// Producer 성능 최적화 설정
|
// Producer 성능 최적화 설정
|
||||||
config.put(ProducerConfig.ACKS_CONFIG, "all");
|
config.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||||
|
|||||||
@ -72,6 +72,7 @@ public class SecurityConfig {
|
|||||||
/**
|
/**
|
||||||
* CORS 설정
|
* CORS 설정
|
||||||
* 개발 환경에서 프론트엔드(localhost:3000)의 요청을 허용합니다.
|
* 개발 환경에서 프론트엔드(localhost:3000)의 요청을 허용합니다.
|
||||||
|
* 쿠키 기반 인증을 위한 설정이 포함되어 있습니다.
|
||||||
*
|
*
|
||||||
* @return CorsConfigurationSource CORS 설정 소스
|
* @return CorsConfigurationSource CORS 설정 소스
|
||||||
*/
|
*/
|
||||||
@ -82,7 +83,10 @@ public class SecurityConfig {
|
|||||||
// 허용할 Origin (개발 환경)
|
// 허용할 Origin (개발 환경)
|
||||||
configuration.setAllowedOrigins(Arrays.asList(
|
configuration.setAllowedOrigins(Arrays.asList(
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:3000"
|
"http://127.0.0.1:3000",
|
||||||
|
"http://localhost:8081",
|
||||||
|
"http://localhost:8082",
|
||||||
|
"http://localhost:8083"
|
||||||
));
|
));
|
||||||
|
|
||||||
// 허용할 HTTP 메서드
|
// 허용할 HTTP 메서드
|
||||||
@ -90,7 +94,7 @@ public class SecurityConfig {
|
|||||||
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
|
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
|
||||||
));
|
));
|
||||||
|
|
||||||
// 허용할 헤더
|
// 허용할 헤더 (쿠키 포함)
|
||||||
configuration.setAllowedHeaders(Arrays.asList(
|
configuration.setAllowedHeaders(Arrays.asList(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"Content-Type",
|
"Content-Type",
|
||||||
@ -98,19 +102,21 @@ public class SecurityConfig {
|
|||||||
"Accept",
|
"Accept",
|
||||||
"Origin",
|
"Origin",
|
||||||
"Access-Control-Request-Method",
|
"Access-Control-Request-Method",
|
||||||
"Access-Control-Request-Headers"
|
"Access-Control-Request-Headers",
|
||||||
|
"Cookie"
|
||||||
));
|
));
|
||||||
|
|
||||||
// 인증 정보 포함 허용
|
// 인증 정보 포함 허용 (쿠키 전송을 위해 필수)
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(true);
|
||||||
|
|
||||||
// Preflight 요청 캐시 시간 (초)
|
// Preflight 요청 캐시 시간 (초)
|
||||||
configuration.setMaxAge(3600L);
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
// 노출할 응답 헤더
|
// 노출할 응답 헤더 (쿠키 포함)
|
||||||
configuration.setExposedHeaders(Arrays.asList(
|
configuration.setExposedHeaders(Arrays.asList(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"Content-Type"
|
"Content-Type",
|
||||||
|
"Set-Cookie"
|
||||||
));
|
));
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
|||||||
@ -3,9 +3,6 @@ package com.kt.event.eventservice.domain.entity;
|
|||||||
import com.kt.event.common.entity.BaseTimeEntity;
|
import com.kt.event.common.entity.BaseTimeEntity;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.GenericGenerator;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 추천 엔티티
|
* AI 추천 엔티티
|
||||||
@ -26,10 +23,8 @@ import java.util.UUID;
|
|||||||
public class AiRecommendation extends BaseTimeEntity {
|
public class AiRecommendation extends BaseTimeEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(generator = "uuid2")
|
@Column(name = "recommendation_id", length = 50)
|
||||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
private String recommendationId;
|
||||||
@Column(name = "recommendation_id", columnDefinition = "uuid")
|
|
||||||
private UUID recommendationId;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "event_id", nullable = false)
|
@JoinColumn(name = "event_id", nullable = false)
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import jakarta.persistence.*;
|
|||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.Fetch;
|
import org.hibernate.annotations.Fetch;
|
||||||
import org.hibernate.annotations.FetchMode;
|
import org.hibernate.annotations.FetchMode;
|
||||||
import org.hibernate.annotations.GenericGenerator;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@ -32,16 +31,14 @@ import java.util.*;
|
|||||||
public class Event extends BaseTimeEntity {
|
public class Event extends BaseTimeEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(generator = "uuid2")
|
@Column(name = "event_id", length = 50)
|
||||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
private String eventId;
|
||||||
@Column(name = "event_id", columnDefinition = "uuid")
|
|
||||||
private UUID eventId;
|
|
||||||
|
|
||||||
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
|
@Column(name = "user_id", nullable = false, length = 50)
|
||||||
private UUID userId;
|
private String userId;
|
||||||
|
|
||||||
@Column(name = "store_id", nullable = false, columnDefinition = "uuid")
|
@Column(name = "store_id", nullable = false, length = 50)
|
||||||
private UUID storeId;
|
private String storeId;
|
||||||
|
|
||||||
@Column(name = "event_name", length = 200)
|
@Column(name = "event_name", length = 200)
|
||||||
private String eventName;
|
private String eventName;
|
||||||
@ -63,8 +60,8 @@ public class Event extends BaseTimeEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private EventStatus status = EventStatus.DRAFT;
|
private EventStatus status = EventStatus.DRAFT;
|
||||||
|
|
||||||
@Column(name = "selected_image_id", columnDefinition = "uuid")
|
@Column(name = "selected_image_id", length = 50)
|
||||||
private UUID selectedImageId;
|
private String selectedImageId;
|
||||||
|
|
||||||
@Column(name = "selected_image_url", length = 500)
|
@Column(name = "selected_image_url", length = 500)
|
||||||
private String selectedImageUrl;
|
private String selectedImageUrl;
|
||||||
@ -128,7 +125,7 @@ public class Event extends BaseTimeEntity {
|
|||||||
/**
|
/**
|
||||||
* 이미지 선택
|
* 이미지 선택
|
||||||
*/
|
*/
|
||||||
public void selectImage(UUID imageId, String imageUrl) {
|
public void selectImage(String imageId, String imageUrl) {
|
||||||
this.selectedImageId = imageId;
|
this.selectedImageId = imageId;
|
||||||
this.selectedImageUrl = imageUrl;
|
this.selectedImageUrl = imageUrl;
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,6 @@ package com.kt.event.eventservice.domain.entity;
|
|||||||
import com.kt.event.common.entity.BaseTimeEntity;
|
import com.kt.event.common.entity.BaseTimeEntity;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.GenericGenerator;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생성된 이미지 엔티티
|
* 생성된 이미지 엔티티
|
||||||
@ -26,10 +23,8 @@ import java.util.UUID;
|
|||||||
public class GeneratedImage extends BaseTimeEntity {
|
public class GeneratedImage extends BaseTimeEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(generator = "uuid2")
|
@Column(name = "image_id", length = 50)
|
||||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
private String imageId;
|
||||||
@Column(name = "image_id", columnDefinition = "uuid")
|
|
||||||
private UUID imageId;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "event_id", nullable = false)
|
@JoinColumn(name = "event_id", nullable = false)
|
||||||
|
|||||||
@ -5,10 +5,8 @@ import com.kt.event.eventservice.domain.enums.JobStatus;
|
|||||||
import com.kt.event.eventservice.domain.enums.JobType;
|
import com.kt.event.eventservice.domain.enums.JobType;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.GenericGenerator;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 비동기 작업 엔티티
|
* 비동기 작업 엔티티
|
||||||
@ -29,13 +27,11 @@ import java.util.UUID;
|
|||||||
public class Job extends BaseTimeEntity {
|
public class Job extends BaseTimeEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(generator = "uuid2")
|
@Column(name = "job_id", length = 50)
|
||||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
private String jobId;
|
||||||
@Column(name = "job_id", columnDefinition = "uuid")
|
|
||||||
private UUID jobId;
|
|
||||||
|
|
||||||
@Column(name = "event_id", nullable = false, columnDefinition = "uuid")
|
@Column(name = "event_id", nullable = false, length = 50)
|
||||||
private UUID eventId;
|
private String eventId;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "job_type", nullable = false, length = 30)
|
@Column(name = "job_type", nullable = false, length = 30)
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 추천 Repository
|
* AI 추천 Repository
|
||||||
@ -15,15 +14,15 @@ import java.util.UUID;
|
|||||||
* @since 2025-10-23
|
* @since 2025-10-23
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, UUID> {
|
public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, String> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트별 AI 추천 목록 조회
|
* 이벤트별 AI 추천 목록 조회
|
||||||
*/
|
*/
|
||||||
List<AiRecommendation> findByEventEventId(UUID eventId);
|
List<AiRecommendation> findByEventEventId(String eventId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트별 선택된 AI 추천 조회
|
* 이벤트별 선택된 AI 추천 조회
|
||||||
*/
|
*/
|
||||||
AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId);
|
AiRecommendation findByEventEventIdAndIsSelectedTrue(String eventId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import org.springframework.data.repository.query.Param;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 Repository
|
* 이벤트 Repository
|
||||||
@ -20,7 +19,12 @@ import java.util.UUID;
|
|||||||
* @since 2025-10-23
|
* @since 2025-10-23
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface EventRepository extends JpaRepository<Event, UUID> {
|
public interface EventRepository extends JpaRepository<Event, String> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 ID로 조회
|
||||||
|
*/
|
||||||
|
Optional<Event> findByEventId(String eventId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 ID와 이벤트 ID로 조회
|
* 사용자 ID와 이벤트 ID로 조회
|
||||||
@ -29,8 +33,8 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
|
|||||||
"LEFT JOIN FETCH e.channels " +
|
"LEFT JOIN FETCH e.channels " +
|
||||||
"WHERE e.eventId = :eventId AND e.userId = :userId")
|
"WHERE e.eventId = :eventId AND e.userId = :userId")
|
||||||
Optional<Event> findByEventIdAndUserId(
|
Optional<Event> findByEventIdAndUserId(
|
||||||
@Param("eventId") UUID eventId,
|
@Param("eventId") String eventId,
|
||||||
@Param("userId") UUID userId
|
@Param("userId") String userId
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +46,7 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
|
|||||||
"AND (:search IS NULL OR e.eventName LIKE %:search%) " +
|
"AND (:search IS NULL OR e.eventName LIKE %:search%) " +
|
||||||
"AND (:objective IS NULL OR e.objective = :objective)")
|
"AND (:objective IS NULL OR e.objective = :objective)")
|
||||||
Page<Event> findEventsByUser(
|
Page<Event> findEventsByUser(
|
||||||
@Param("userId") UUID userId,
|
@Param("userId") String userId,
|
||||||
@Param("status") EventStatus status,
|
@Param("status") EventStatus status,
|
||||||
@Param("search") String search,
|
@Param("search") String search,
|
||||||
@Param("objective") String objective,
|
@Param("objective") String objective,
|
||||||
@ -52,5 +56,5 @@ public interface EventRepository extends JpaRepository<Event, UUID> {
|
|||||||
/**
|
/**
|
||||||
* 사용자별 이벤트 개수 조회 (상태별)
|
* 사용자별 이벤트 개수 조회 (상태별)
|
||||||
*/
|
*/
|
||||||
long countByUserIdAndStatus(UUID userId, EventStatus status);
|
long countByUserIdAndStatus(String userId, EventStatus status);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생성된 이미지 Repository
|
* 생성된 이미지 Repository
|
||||||
@ -15,15 +14,15 @@ import java.util.UUID;
|
|||||||
* @since 2025-10-23
|
* @since 2025-10-23
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, UUID> {
|
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, String> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트별 생성된 이미지 목록 조회
|
* 이벤트별 생성된 이미지 목록 조회
|
||||||
*/
|
*/
|
||||||
List<GeneratedImage> findByEventEventId(UUID eventId);
|
List<GeneratedImage> findByEventEventId(String eventId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트별 선택된 이미지 조회
|
* 이벤트별 선택된 이미지 조회
|
||||||
*/
|
*/
|
||||||
GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId);
|
GeneratedImage findByEventEventIdAndIsSelectedTrue(String eventId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 비동기 작업 Repository
|
* 비동기 작업 Repository
|
||||||
@ -18,22 +17,22 @@ import java.util.UUID;
|
|||||||
* @since 2025-10-23
|
* @since 2025-10-23
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface JobRepository extends JpaRepository<Job, UUID> {
|
public interface JobRepository extends JpaRepository<Job, String> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트별 작업 목록 조회
|
* 이벤트별 작업 목록 조회
|
||||||
*/
|
*/
|
||||||
List<Job> findByEventId(UUID eventId);
|
List<Job> findByEventId(String eventId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 및 작업 유형별 조회
|
* 이벤트 및 작업 유형별 조회
|
||||||
*/
|
*/
|
||||||
Optional<Job> findByEventIdAndJobType(UUID eventId, JobType jobType);
|
Optional<Job> findByEventIdAndJobType(String eventId, JobType jobType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 및 작업 유형별 최신 작업 조회
|
* 이벤트 및 작업 유형별 최신 작업 조회
|
||||||
*/
|
*/
|
||||||
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType);
|
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(String eventId, JobType jobType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상태별 작업 목록 조회
|
* 상태별 작업 목록 조회
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,8 +18,6 @@ import org.springframework.messaging.handler.annotation.Payload;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 이벤트 생성 작업 메시지 구독 Consumer
|
* AI 이벤트 생성 작업 메시지 구독 Consumer
|
||||||
*
|
*
|
||||||
@ -30,7 +28,8 @@ import java.util.UUID;
|
|||||||
* @since 2025-10-29
|
* @since 2025-10-29
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
// TODO: 별도 response 토픽 사용 시 활성화
|
||||||
|
// @Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AIJobKafkaConsumer {
|
public class AIJobKafkaConsumer {
|
||||||
|
|
||||||
@ -93,7 +92,7 @@ public class AIJobKafkaConsumer {
|
|||||||
@Transactional
|
@Transactional
|
||||||
protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) {
|
protected void processAIEventGenerationJob(AIEventGenerationJobMessage message) {
|
||||||
try {
|
try {
|
||||||
UUID jobId = UUID.fromString(message.getJobId());
|
String jobId = message.getJobId();
|
||||||
|
|
||||||
// Job 조회
|
// Job 조회
|
||||||
Job job = jobRepository.findById(jobId).orElse(null);
|
Job job = jobRepository.findById(jobId).orElse(null);
|
||||||
@ -102,7 +101,7 @@ public class AIJobKafkaConsumer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UUID eventId = job.getEventId();
|
String eventId = job.getEventId();
|
||||||
|
|
||||||
// Event 조회 (모든 케이스에서 사용)
|
// Event 조회 (모든 케이스에서 사용)
|
||||||
Event event = eventRepository.findById(eventId).orElse(null);
|
Event event = eventRepository.findById(eventId).orElse(null);
|
||||||
@ -142,7 +141,7 @@ public class AIJobKafkaConsumer {
|
|||||||
eventId, aiData.getEventTitle());
|
eventId, aiData.getEventTitle());
|
||||||
|
|
||||||
// 사용자에게 알림 전송
|
// 사용자에게 알림 전송
|
||||||
UUID userId = event.getUserId();
|
String userId = event.getUserId();
|
||||||
notificationService.notifyJobCompleted(
|
notificationService.notifyJobCompleted(
|
||||||
userId,
|
userId,
|
||||||
jobId,
|
jobId,
|
||||||
@ -166,7 +165,7 @@ public class AIJobKafkaConsumer {
|
|||||||
|
|
||||||
// 사용자에게 실패 알림 전송
|
// 사용자에게 실패 알림 전송
|
||||||
if (event != null) {
|
if (event != null) {
|
||||||
UUID userId = event.getUserId();
|
String userId = event.getUserId();
|
||||||
notificationService.notifyJobFailed(
|
notificationService.notifyJobFailed(
|
||||||
userId,
|
userId,
|
||||||
jobId,
|
jobId,
|
||||||
@ -185,7 +184,7 @@ public class AIJobKafkaConsumer {
|
|||||||
|
|
||||||
// 사용자에게 진행 상태 알림 전송
|
// 사용자에게 진행 상태 알림 전송
|
||||||
if (event != null) {
|
if (event != null) {
|
||||||
UUID userId = event.getUserId();
|
String userId = event.getUserId();
|
||||||
notificationService.notifyJobProgress(
|
notificationService.notifyJobProgress(
|
||||||
userId,
|
userId,
|
||||||
jobId,
|
jobId,
|
||||||
@ -1,6 +1,5 @@
|
|||||||
package com.kt.event.eventservice.infrastructure.kafka;
|
package com.kt.event.eventservice.infrastructure.kafka;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
|
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -27,7 +26,6 @@ import java.util.concurrent.CompletableFuture;
|
|||||||
public class AIJobKafkaProducer {
|
public class AIJobKafkaProducer {
|
||||||
|
|
||||||
private final KafkaTemplate<String, Object> kafkaTemplate;
|
private final KafkaTemplate<String, Object> kafkaTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}")
|
@Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}")
|
||||||
private String aiEventGenerationJobTopic;
|
private String aiEventGenerationJobTopic;
|
||||||
@ -35,28 +33,38 @@ public class AIJobKafkaProducer {
|
|||||||
/**
|
/**
|
||||||
* AI 이벤트 생성 작업 메시지 발행
|
* AI 이벤트 생성 작업 메시지 발행
|
||||||
*
|
*
|
||||||
* @param jobId 작업 ID (UUID String)
|
* @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8})
|
||||||
* @param userId 사용자 ID (UUID String)
|
* @param userId 사용자 ID
|
||||||
* @param eventId 이벤트 ID (UUID String)
|
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
|
||||||
* @param storeName 매장명
|
* @param storeName 매장명
|
||||||
* @param storeCategory 매장 업종
|
* @param industry 업종 (매장 카테고리)
|
||||||
* @param storeDescription 매장 설명
|
* @param region 지역
|
||||||
* @param objective 이벤트 목적
|
* @param objective 이벤트 목적
|
||||||
|
* @param targetAudience 목표 고객층 (선택)
|
||||||
|
* @param budget 예산 (선택)
|
||||||
*/
|
*/
|
||||||
public void publishAIGenerationJob(
|
public void publishAIGenerationJob(
|
||||||
String jobId,
|
String jobId,
|
||||||
String userId,
|
String userId,
|
||||||
String eventId,
|
String eventId,
|
||||||
String storeName,
|
String storeName,
|
||||||
String storeCategory,
|
String industry,
|
||||||
String storeDescription,
|
String region,
|
||||||
String objective) {
|
String objective,
|
||||||
|
String targetAudience,
|
||||||
|
Integer budget) {
|
||||||
|
|
||||||
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
|
AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder()
|
||||||
.jobId(jobId)
|
.jobId(jobId)
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.status("PENDING")
|
.eventId(eventId)
|
||||||
.createdAt(LocalDateTime.now())
|
.storeName(storeName)
|
||||||
|
.industry(industry)
|
||||||
|
.region(region)
|
||||||
|
.objective(objective)
|
||||||
|
.targetAudience(targetAudience)
|
||||||
|
.budget(budget)
|
||||||
|
.requestedAt(LocalDateTime.now())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
publishMessage(message);
|
publishMessage(message);
|
||||||
@ -69,11 +77,9 @@ public class AIJobKafkaProducer {
|
|||||||
*/
|
*/
|
||||||
public void publishMessage(AIEventGenerationJobMessage message) {
|
public void publishMessage(AIEventGenerationJobMessage message) {
|
||||||
try {
|
try {
|
||||||
// JSON 문자열로 변환
|
// 객체를 직접 전송 (JsonSerializer가 자동으로 직렬화)
|
||||||
String jsonMessage = objectMapper.writeValueAsString(message);
|
|
||||||
|
|
||||||
CompletableFuture<SendResult<String, Object>> future =
|
CompletableFuture<SendResult<String, Object>> future =
|
||||||
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), jsonMessage);
|
kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), message);
|
||||||
|
|
||||||
future.whenComplete((result, ex) -> {
|
future.whenComplete((result, ex) -> {
|
||||||
if (ex == null) {
|
if (ex == null) {
|
||||||
|
|||||||
@ -29,12 +29,12 @@ public class EventKafkaProducer {
|
|||||||
/**
|
/**
|
||||||
* 이벤트 생성 완료 메시지 발행
|
* 이벤트 생성 완료 메시지 발행
|
||||||
*
|
*
|
||||||
* @param eventId 이벤트 ID (UUID)
|
* @param eventId 이벤트 ID
|
||||||
* @param userId 사용자 ID (UUID)
|
* @param userId 사용자 ID
|
||||||
* @param title 이벤트 제목
|
* @param title 이벤트 제목
|
||||||
* @param eventType 이벤트 타입
|
* @param eventType 이벤트 타입
|
||||||
*/
|
*/
|
||||||
public void publishEventCreated(java.util.UUID eventId, java.util.UUID userId, String title, String eventType) {
|
public void publishEventCreated(String eventId, String userId, String title, String eventType) {
|
||||||
EventCreatedMessage message = EventCreatedMessage.builder()
|
EventCreatedMessage message = EventCreatedMessage.builder()
|
||||||
.eventId(eventId)
|
.eventId(eventId)
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
|
|||||||
@ -18,8 +18,6 @@ import org.springframework.messaging.handler.annotation.Payload;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 생성 작업 메시지 구독 Consumer
|
* 이미지 생성 작업 메시지 구독 Consumer
|
||||||
*
|
*
|
||||||
@ -94,8 +92,8 @@ public class ImageJobKafkaConsumer {
|
|||||||
@Transactional
|
@Transactional
|
||||||
protected void processImageGenerationJob(ImageGenerationJobMessage message) {
|
protected void processImageGenerationJob(ImageGenerationJobMessage message) {
|
||||||
try {
|
try {
|
||||||
UUID jobId = UUID.fromString(message.getJobId());
|
String jobId = message.getJobId();
|
||||||
UUID eventId = UUID.fromString(message.getEventId());
|
String eventId = message.getEventId();
|
||||||
|
|
||||||
// Job 조회
|
// Job 조회
|
||||||
Job job = jobRepository.findById(jobId).orElse(null);
|
Job job = jobRepository.findById(jobId).orElse(null);
|
||||||
@ -130,7 +128,7 @@ public class ImageJobKafkaConsumer {
|
|||||||
eventId, message.getImageUrl());
|
eventId, message.getImageUrl());
|
||||||
|
|
||||||
// 사용자에게 알림 전송
|
// 사용자에게 알림 전송
|
||||||
UUID userId = event.getUserId();
|
String userId = event.getUserId();
|
||||||
notificationService.notifyJobCompleted(
|
notificationService.notifyJobCompleted(
|
||||||
userId,
|
userId,
|
||||||
jobId,
|
jobId,
|
||||||
@ -181,7 +179,7 @@ public class ImageJobKafkaConsumer {
|
|||||||
|
|
||||||
// 사용자에게 실패 알림 전송
|
// 사용자에게 실패 알림 전송
|
||||||
if (event != null) {
|
if (event != null) {
|
||||||
UUID userId = event.getUserId();
|
String userId = event.getUserId();
|
||||||
notificationService.notifyJobFailed(
|
notificationService.notifyJobFailed(
|
||||||
userId,
|
userId,
|
||||||
jobId,
|
jobId,
|
||||||
@ -202,7 +200,7 @@ public class ImageJobKafkaConsumer {
|
|||||||
|
|
||||||
// 사용자에게 진행 상태 알림 전송
|
// 사용자에게 진행 상태 알림 전송
|
||||||
if (event != null) {
|
if (event != null) {
|
||||||
UUID userId = event.getUserId();
|
String userId = event.getUserId();
|
||||||
notificationService.notifyJobProgress(
|
notificationService.notifyJobProgress(
|
||||||
userId,
|
userId,
|
||||||
jobId,
|
jobId,
|
||||||
|
|||||||
@ -35,9 +35,9 @@ public class ImageJobKafkaProducer {
|
|||||||
/**
|
/**
|
||||||
* 이미지 생성 작업 메시지 발행
|
* 이미지 생성 작업 메시지 발행
|
||||||
*
|
*
|
||||||
* @param jobId 작업 ID (UUID)
|
* @param jobId 작업 ID (JOB-{type}-{timestamp}-{random8})
|
||||||
* @param userId 사용자 ID (UUID)
|
* @param userId 사용자 ID
|
||||||
* @param eventId 이벤트 ID (UUID)
|
* @param eventId 이벤트 ID (EVT-{storeId}-{yyyyMMddHHmmss}-{random8})
|
||||||
* @param prompt 이미지 생성 프롬프트
|
* @param prompt 이미지 생성 프롬프트
|
||||||
*/
|
*/
|
||||||
public void publishImageGenerationJob(
|
public void publishImageGenerationJob(
|
||||||
|
|||||||
@ -4,8 +4,6 @@ import com.kt.event.eventservice.application.service.NotificationService;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로깅 기반 알림 서비스 구현
|
* 로깅 기반 알림 서비스 구현
|
||||||
*
|
*
|
||||||
@ -20,16 +18,16 @@ import java.util.UUID;
|
|||||||
public class LoggingNotificationService implements NotificationService {
|
public class LoggingNotificationService implements NotificationService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void notifyJobCompleted(UUID userId, UUID jobId, String jobType, String message) {
|
public void notifyJobCompleted(String userId, String jobId, String jobType, String message) {
|
||||||
log.info("📢 [작업 완료 알림] UserId: {}, JobId: {}, JobType: {}, Message: {}",
|
log.info("📢 [작업 완료 알림] UserId: {}, JobId: {}, JobType: {}, Message: {}",
|
||||||
userId, jobId, jobType, message);
|
userId, jobId, jobType, message);
|
||||||
|
|
||||||
// TODO: WebSocket, SSE, 또는 Push Notification으로 실시간 알림 전송
|
// TODO: WebSocket, SSE, 또는 Push Notification으로 실시간 알림 전송
|
||||||
// 예: webSocketTemplate.convertAndSendToUser(userId.toString(), "/queue/notifications", notification);
|
// 예: webSocketTemplate.convertAndSendToUser(userId, "/queue/notifications", notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void notifyJobFailed(UUID userId, UUID jobId, String jobType, String errorMessage) {
|
public void notifyJobFailed(String userId, String jobId, String jobType, String errorMessage) {
|
||||||
log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}",
|
log.error("📢 [작업 실패 알림] UserId: {}, JobId: {}, JobType: {}, Error: {}",
|
||||||
userId, jobId, jobType, errorMessage);
|
userId, jobId, jobType, errorMessage);
|
||||||
|
|
||||||
@ -37,7 +35,7 @@ public class LoggingNotificationService implements NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void notifyJobProgress(UUID userId, UUID jobId, String jobType, int progress) {
|
public void notifyJobProgress(String userId, String jobId, String jobType, int progress) {
|
||||||
log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%",
|
log.info("📢 [작업 진행 알림] UserId: {}, JobId: {}, JobType: {}, Progress: {}%",
|
||||||
userId, jobId, jobType, progress);
|
userId, jobId, jobType, progress);
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,6 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 컨트롤러
|
* Job 컨트롤러
|
||||||
*
|
*
|
||||||
@ -26,7 +24,7 @@ import java.util.UUID;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/jobs")
|
@RequestMapping("/jobs")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Tag(name = "Job", description = "비동기 작업 상태 조회 API")
|
@Tag(name = "Job", description = "비동기 작업 상태 조회 API")
|
||||||
public class JobController {
|
public class JobController {
|
||||||
@ -41,7 +39,7 @@ public class JobController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("/{jobId}")
|
@GetMapping("/{jobId}")
|
||||||
@Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).")
|
@Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).")
|
||||||
public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable UUID jobId) {
|
public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable String jobId) {
|
||||||
log.info("Job 상태 조회 API 호출 - jobId: {}", jobId);
|
log.info("Job 상태 조회 API 호출 - jobId: {}", jobId);
|
||||||
|
|
||||||
JobStatusResponse response = jobService.getJobStatus(jobId);
|
JobStatusResponse response = jobService.getJobStatus(jobId);
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import java.time.Duration;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/redis-test")
|
@RequestMapping("/redis-test")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RedisTestController {
|
public class RedisTestController {
|
||||||
|
|
||||||
|
|||||||
@ -71,7 +71,7 @@ spring:
|
|||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8080}
|
port: ${SERVER_PORT:8080}
|
||||||
servlet:
|
servlet:
|
||||||
context-path: /api/v1/events
|
context-path: /api/v1
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
|
|
||||||
# Actuator Configuration
|
# Actuator Configuration
|
||||||
@ -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:
|
||||||
|
|||||||
38
participation-service-backup.yaml
Normal file
38
participation-service-backup.yaml
Normal 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: {}
|
||||||
27
participation-service-fixed.yaml
Normal file
27
participation-service-fixed.yaml
Normal 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
|
||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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
81
run-content-service.bat
Normal 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
80
run-content-service.sh
Normal 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
Loading…
x
Reference in New Issue
Block a user