Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 48c76db83a | |||
| 72728841db | |||
| 9e2d0a3889 | |||
| 14823a17c4 | |||
| a3781a279a | |||
| f80418f5ee | |||
| 5c365fe899 | |||
| a3381cc540 | |||
| 7ed2465d57 | |||
| 5cac8ccc12 | |||
| acd827b226 | |||
| ea53bd13a8 | |||
| 6948b48498 | |||
| aa8db3bf2f | |||
| be59934f78 | |||
| 3afee053d0 | |||
| 27a3111dd8 | |||
| 3465a35827 | |||
| 108ee10293 | |||
| 20e0d24930 | |||
| 640e94bf17 | |||
| 98ed508a6f | |||
| e8d0a1d4b4 | |||
| 857fa5501c | |||
| ab39c76585 | |||
| 1e38d52967 | |||
| 6205a98ca0 | |||
| ebd7ae12b6 | |||
| 2cd1ba76f5 |
@@ -8,7 +8,7 @@ stringData:
|
||||
AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net"
|
||||
|
||||
# Replicate API Token
|
||||
REPLICATE_API_TOKEN: ""
|
||||
REPLICATE_API_TOKEN: "r8_BsGCJtAg5U5kkMBXSe3pgMkPufSKnUR4NY9gJ"
|
||||
|
||||
# HuggingFace API Token
|
||||
HUGGINGFACE_API_TOKEN: ""
|
||||
|
||||
@@ -53,11 +53,6 @@ resources:
|
||||
- analytics-service-cm-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)
|
||||
images:
|
||||
- name: acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service
|
||||
|
||||
@@ -6,10 +6,6 @@ namespace: kt-event-marketing
|
||||
bases:
|
||||
- ../../base
|
||||
|
||||
# Environment-specific labels
|
||||
commonLabels:
|
||||
environment: dev
|
||||
|
||||
# Environment-specific patches
|
||||
patchesStrategicMerge:
|
||||
- user-service-patch.yaml
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
name: Backend CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- '*-service/**'
|
||||
- '.github/workflows/backend-cicd.yaml'
|
||||
- '.github/kustomize/**'
|
||||
# push:
|
||||
# branches:
|
||||
# - develop
|
||||
# - main
|
||||
# paths:
|
||||
# - '*-service/**'
|
||||
# - '.github/workflows/backend-cicd.yaml'
|
||||
# - '.github/kustomize/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
<env name="KAFKA_CONSUMER_GROUP" value="distribution-service" />
|
||||
<env name="JPA_DDL_AUTO" value="update" />
|
||||
<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>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Kafka Configuration (원격 서버) -->
|
||||
<entry key="KAFKA_ENABLED" value="true" />
|
||||
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
|
||||
|
||||
<!-- Sample Data Configuration (MVP Only) -->
|
||||
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
|
||||
|
||||
@@ -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.security.config.annotation.web.builders.HttpSecurity;
|
||||
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.web.SecurityFilterChain;
|
||||
@@ -27,21 +28,22 @@ import java.util.List;
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
/**
|
||||
* Security Filter Chain 설정
|
||||
* - 모든 요청 허용 (내부 API)
|
||||
* - CSRF 비활성화
|
||||
* - Stateless 세션
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF 비활성화 (REST API는 CSRF 불필요)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
// CORS 설정
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
|
||||
// 세션 사용 안 함 (JWT 기반 인증)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
|
||||
// 모든 요청 허용 (테스트용)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||
.requestMatchers("/internal/**").permitAll() // Internal API
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
@@ -50,11 +52,14 @@ public class SecurityConfig {
|
||||
|
||||
/**
|
||||
* CORS 설정
|
||||
* - 모든 Origin 허용 (Swagger UI 테스트를 위해)
|
||||
* - 모든 HTTP Method 허용
|
||||
* - 모든 Header 허용
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
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.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
@@ -64,4 +69,13 @@ public class SecurityConfig {
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
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
|
||||
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();
|
||||
localServer.setUrl("http://localhost:8083");
|
||||
localServer.setDescription("Local Development Server");
|
||||
@@ -59,6 +63,6 @@ public class SwaggerConfig {
|
||||
|
||||
return new OpenAPI()
|
||||
.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 상태 및 외부 연동 확인")
|
||||
@GetMapping("/api/v1/ai-service/health")
|
||||
@GetMapping("/health")
|
||||
public ResponseEntity<HealthCheckResponse> healthCheck() {
|
||||
// Redis 상태 확인
|
||||
ServiceStatus redisStatus = checkRedis();
|
||||
|
||||
@@ -27,7 +27,7 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai-service/internal/jobs")
|
||||
@RequestMapping("/jobs")
|
||||
@RequiredArgsConstructor
|
||||
public class InternalJobController {
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import java.util.Set;
|
||||
@Slf4j
|
||||
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai-service/internal/recommendations")
|
||||
@RequestMapping("/recommendations")
|
||||
@RequiredArgsConstructor
|
||||
public class InternalRecommendationController {
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ spring:
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
servlet:
|
||||
context-path: /api/v1/ai-service
|
||||
context-path: /api/v1/ai
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
enabled: true
|
||||
@@ -53,7 +53,7 @@ jwt:
|
||||
|
||||
# CORS Configuration
|
||||
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-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Kafka Configuration (원격 서버) -->
|
||||
<entry key="KAFKA_ENABLED" value="true" />
|
||||
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
|
||||
|
||||
<!-- Sample Data Configuration (MVP Only) -->
|
||||
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Multi-stage build for Spring Boot application
|
||||
FROM eclipse-temurin:21-jre-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY build/libs/*.jar app.jar
|
||||
COPY analytics-service/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=layertools -jar app.jar extract
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ public class AnalyticsBatchScheduler {
|
||||
event.getEventId(), event.getEventTitle());
|
||||
|
||||
// refresh=true로 호출하여 캐시 갱신 및 외부 API 호출
|
||||
analyticsService.getDashboardData(event.getEventId(), null, null, true);
|
||||
analyticsService.getDashboardData(event.getEventId(), true);
|
||||
|
||||
successCount++;
|
||||
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
|
||||
@@ -99,7 +99,7 @@ public class AnalyticsBatchScheduler {
|
||||
|
||||
for (EventStats event : allEvents) {
|
||||
try {
|
||||
analyticsService.getDashboardData(event.getEventId(), null, null, true);
|
||||
analyticsService.getDashboardData(event.getEventId(), true);
|
||||
log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
|
||||
} catch (Exception e) {
|
||||
log.warn("초기 데이터 로딩 실패: eventId={}, error={}",
|
||||
|
||||
+2
-2
@@ -17,13 +17,13 @@ import java.util.Map;
|
||||
* Kafka Consumer 설정
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true)
|
||||
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class KafkaConsumerConfig {
|
||||
|
||||
@Value("${spring.kafka.bootstrap-servers}")
|
||||
private String bootstrapServers;
|
||||
|
||||
@Value("${spring.kafka.consumer.group-id:analytics-service}")
|
||||
@Value("${spring.kafka.consumer.group-id:analytics-service-consumers-v3}")
|
||||
private String groupId;
|
||||
|
||||
@Bean
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.kt.event.analytics.config;
|
||||
|
||||
import org.apache.kafka.clients.producer.ProducerConfig;
|
||||
import org.apache.kafka.common.serialization.StringSerializer;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.kafka.core.ProducerFactory;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Kafka Producer 설정
|
||||
*
|
||||
* ⚠️ MVP 전용: SampleDataLoader가 Kafka 이벤트를 발행하기 위해 필요
|
||||
* ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행하므로 Producer 불필요
|
||||
*
|
||||
* String 직렬화 방식 사용 (SampleDataLoader가 JSON 문자열을 직접 발행)
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class KafkaProducerConfig {
|
||||
|
||||
@Value("${spring.kafka.bootstrap-servers}")
|
||||
private String bootstrapServers;
|
||||
|
||||
@Bean
|
||||
public ProducerFactory<String, String> producerFactory() {
|
||||
Map<String, Object> configProps = new HashMap<>();
|
||||
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||
configProps.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||
configProps.put(ProducerConfig.RETRIES_CONFIG, 3);
|
||||
return new DefaultKafkaProducerFactory<>(configProps);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public KafkaTemplate<String, String> kafkaTemplate() {
|
||||
return new KafkaTemplate<>(producerFactory());
|
||||
}
|
||||
}
|
||||
+210
-60
@@ -11,19 +11,23 @@ import jakarta.annotation.PreDestroy;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.kafka.clients.admin.AdminClient;
|
||||
import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult;
|
||||
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
|
||||
import org.apache.kafka.common.TopicPartition;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.kafka.core.KafkaAdmin;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 샘플 데이터 로더 (Kafka Producer 방식)
|
||||
@@ -47,6 +51,7 @@ import java.util.UUID;
|
||||
public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||
private final KafkaAdmin kafkaAdmin;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final EventStatsRepository eventStatsRepository;
|
||||
private final ChannelStatsRepository channelStatsRepository;
|
||||
@@ -56,6 +61,9 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
private final Random random = new Random();
|
||||
|
||||
@Value("${spring.kafka.consumer.group-id}")
|
||||
private String consumerGroupId;
|
||||
|
||||
// Kafka Topic Names (MVP용 샘플 토픽)
|
||||
private static final String EVENT_CREATED_TOPIC = "sample.event.created";
|
||||
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
|
||||
@@ -85,10 +93,15 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
// Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해)
|
||||
log.info("Redis 멱등성 키 삭제 중...");
|
||||
redisTemplate.delete("processed_events");
|
||||
redisTemplate.delete("distribution_completed");
|
||||
redisTemplate.delete("processed_participants");
|
||||
log.info("✅ Redis 멱등성 키 삭제 완료");
|
||||
try {
|
||||
redisTemplate.delete("processed_events_v2");
|
||||
redisTemplate.delete("distribution_completed_v2");
|
||||
redisTemplate.delete("processed_participants_v2");
|
||||
log.info("✅ Redis 멱등성 키 삭제 완료");
|
||||
} catch (Exception e) {
|
||||
log.warn("⚠️ Redis 삭제 실패 (read-only replica일 수 있음): {}", e.getMessage());
|
||||
log.info("→ Redis 삭제 건너뛰고 계속 진행...");
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. EventCreated 이벤트 발행 (3개 이벤트)
|
||||
@@ -103,6 +116,8 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
// 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자)
|
||||
publishParticipantRegisteredEvents();
|
||||
log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
|
||||
Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 락 고려)
|
||||
|
||||
log.info("========================================");
|
||||
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
|
||||
@@ -127,16 +142,17 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 종료 시 전체 데이터 삭제
|
||||
* 서비스 종료 시 전체 데이터 삭제 및 Consumer Offset 리셋
|
||||
*/
|
||||
@PreDestroy
|
||||
@Transactional
|
||||
public void onShutdown() {
|
||||
log.info("========================================");
|
||||
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
|
||||
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제 + Kafka Consumer Offset 리셋");
|
||||
log.info("========================================");
|
||||
|
||||
try {
|
||||
// 1. PostgreSQL 데이터 삭제
|
||||
long timelineCount = timelineDataRepository.count();
|
||||
long channelCount = channelStatsRepository.count();
|
||||
long eventCount = eventStatsRepository.count();
|
||||
@@ -153,6 +169,10 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
entityManager.clear();
|
||||
|
||||
log.info("✅ 모든 샘플 데이터 삭제 완료!");
|
||||
|
||||
// 2. Kafka Consumer Offset 리셋 (다음 시작 시 처음부터 읽도록)
|
||||
resetConsumerOffsets();
|
||||
|
||||
log.info("========================================");
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -160,37 +180,85 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kafka Consumer Group Offset 리셋
|
||||
*
|
||||
* 서비스 종료 시 Consumer offset을 삭제하여 다음 시작 시
|
||||
* auto.offset.reset=earliest 설정에 따라 처음부터 읽도록 함
|
||||
*/
|
||||
private void resetConsumerOffsets() {
|
||||
try (AdminClient adminClient = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
|
||||
log.info("🔄 Kafka Consumer Offset 리셋 시작: group={}", consumerGroupId);
|
||||
|
||||
// 모든 토픽의 offset 삭제
|
||||
Set<TopicPartition> partitions = new HashSet<>();
|
||||
|
||||
// 토픽별 파티션 추가 (설계서상 각 토픽은 3개 파티션)
|
||||
for (int i = 0; i < 3; i++) {
|
||||
partitions.add(new TopicPartition(EVENT_CREATED_TOPIC, i));
|
||||
partitions.add(new TopicPartition(PARTICIPANT_REGISTERED_TOPIC, i));
|
||||
partitions.add(new TopicPartition(DISTRIBUTION_COMPLETED_TOPIC, i));
|
||||
}
|
||||
|
||||
// Consumer Group Offset 삭제
|
||||
DeleteConsumerGroupOffsetsResult result = adminClient.deleteConsumerGroupOffsets(
|
||||
consumerGroupId,
|
||||
partitions
|
||||
);
|
||||
|
||||
// 완료 대기 (최대 10초)
|
||||
result.all().get(10, TimeUnit.SECONDS);
|
||||
|
||||
log.info("✅ Kafka Consumer Offset 리셋 완료!");
|
||||
log.info(" → 다음 시작 시 처음부터(earliest) 메시지를 읽습니다.");
|
||||
|
||||
} catch (Exception e) {
|
||||
// Offset 리셋 실패는 치명적이지 않으므로 경고만 출력
|
||||
log.warn("⚠️ Kafka Consumer Offset 리셋 실패 (무시 가능): {}", e.getMessage());
|
||||
log.warn(" → 수동으로 Consumer Group ID를 변경하거나, Kafka 도구로 offset을 삭제하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EventCreated 이벤트 발행
|
||||
*/
|
||||
private void publishEventCreatedEvents() throws Exception {
|
||||
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과)
|
||||
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%)
|
||||
EventCreatedEvent event1 = EventCreatedEvent.builder()
|
||||
.eventId("evt_2025012301")
|
||||
.eventTitle("신년맞이 20% 할인 이벤트")
|
||||
.storeId("store_001")
|
||||
.totalInvestment(new BigDecimal("5000000"))
|
||||
.expectedRevenue(new BigDecimal("15000000")) // 투자 대비 3배 수익
|
||||
.status("ACTIVE")
|
||||
.startDate(java.time.LocalDateTime.of(2025, 1, 23, 0, 0)) // 2025-01-23 시작
|
||||
.endDate(null) // 진행중
|
||||
.build();
|
||||
publishEvent(EVENT_CREATED_TOPIC, event1);
|
||||
|
||||
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과)
|
||||
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%)
|
||||
EventCreatedEvent event2 = EventCreatedEvent.builder()
|
||||
.eventId("evt_2025020101")
|
||||
.eventId("evt_2025012302")
|
||||
.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: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과)
|
||||
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%)
|
||||
EventCreatedEvent event3 = EventCreatedEvent.builder()
|
||||
.eventId("evt_2025011501")
|
||||
.eventId("evt_2025012303")
|
||||
.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);
|
||||
|
||||
@@ -201,49 +269,70 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
* DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열)
|
||||
*/
|
||||
private void publishDistributionCompletedEvents() throws Exception {
|
||||
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
|
||||
String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
|
||||
int[][] expectedViews = {
|
||||
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
|
||||
{3500, 7000, 2000, 1500}, // 이벤트2
|
||||
{1500, 3000, 1000, 500} // 이벤트3
|
||||
};
|
||||
|
||||
// 각 이벤트의 총 투자 금액
|
||||
BigDecimal[] totalInvestments = {
|
||||
new BigDecimal("5000000"), // 이벤트1: 500만원
|
||||
new BigDecimal("3500000"), // 이벤트2: 350만원
|
||||
new BigDecimal("2000000") // 이벤트3: 200만원
|
||||
};
|
||||
|
||||
// 채널 배포는 총 투자의 50%만 사용 (나머지는 경품/콘텐츠/운영비용)
|
||||
double channelBudgetRatio = 0.50;
|
||||
|
||||
// 채널별 비용 비율 (채널 예산 내에서: 우리동네TV 30%, 지니TV 30%, 링고비즈 25%, SNS 15%)
|
||||
double[] costRatios = {0.30, 0.30, 0.25, 0.15};
|
||||
|
||||
for (int i = 0; i < eventIds.length; i++) {
|
||||
String eventId = eventIds[i];
|
||||
BigDecimal totalInvestment = totalInvestments[i];
|
||||
|
||||
// 채널 배포 예산: 총 투자의 50%
|
||||
BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio));
|
||||
|
||||
// 4개 채널을 배열로 구성
|
||||
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
|
||||
|
||||
// 1. 우리동네TV (TV)
|
||||
// 1. 우리동네TV (TV) - 채널 예산의 30%
|
||||
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
|
||||
.channel("우리동네TV")
|
||||
.channelType("TV")
|
||||
.status("SUCCESS")
|
||||
.expectedViews(expectedViews[i][0])
|
||||
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[0])))
|
||||
.build());
|
||||
|
||||
// 2. 지니TV (TV)
|
||||
// 2. 지니TV (TV) - 채널 예산의 30%
|
||||
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)
|
||||
// 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)
|
||||
// 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());
|
||||
|
||||
// 이벤트 발행 (채널 배열 포함)
|
||||
@@ -261,22 +350,53 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
/**
|
||||
* ParticipantRegistered 이벤트 발행
|
||||
*
|
||||
* 현실적인 참여 패턴 반영:
|
||||
* - 총 120명의 고유 참여자 풀 생성
|
||||
* - 일부 참여자는 여러 이벤트에 중복 참여
|
||||
* - 이벤트1: 100명 (user001~user100)
|
||||
* - 이벤트2: 50명 (user051~user100) → 50명이 이벤트1과 중복
|
||||
* - 이벤트3: 30명 (user071~user100) → 30명이 이전 이벤트들과 중복
|
||||
*/
|
||||
private void publishParticipantRegisteredEvents() throws Exception {
|
||||
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
|
||||
int[] totalParticipants = {100, 50, 30}; // MVP 테스트용 샘플 데이터 (총 180명)
|
||||
String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
|
||||
String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"};
|
||||
|
||||
// 이벤트별 참여자 범위 (중복 참여 반영)
|
||||
int[][] participantRanges = {
|
||||
{1, 100}, // 이벤트1: user001~user100 (100명)
|
||||
{51, 100}, // 이벤트2: user051~user100 (50명, 이벤트1과 50명 중복)
|
||||
{71, 100} // 이벤트3: user071~user100 (30명, 모두 중복)
|
||||
};
|
||||
|
||||
int totalPublished = 0;
|
||||
|
||||
for (int i = 0; i < eventIds.length; i++) {
|
||||
String eventId = eventIds[i];
|
||||
int participants = totalParticipants[i];
|
||||
int startUser = participantRanges[i][0];
|
||||
int endUser = participantRanges[i][1];
|
||||
int eventParticipants = endUser - startUser + 1;
|
||||
|
||||
// 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행
|
||||
for (int j = 0; j < participants; j++) {
|
||||
String participantId = UUID.randomUUID().toString();
|
||||
String channel = channels[j % channels.length]; // 채널 순환 배정
|
||||
log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)",
|
||||
eventId, startUser, endUser, eventParticipants);
|
||||
|
||||
// 각 참여자에 대해 ParticipantRegistered 이벤트 발행
|
||||
for (int userId = startUser; userId <= endUser; userId++) {
|
||||
String participantId = String.format("user%03d", userId); // user001, user002, ...
|
||||
|
||||
// 채널별 가중치 기반 랜덤 배정
|
||||
// SNS: 45%, 우리동네TV: 25%, 지니TV: 20%, 링고비즈: 10%
|
||||
int randomValue = random.nextInt(100);
|
||||
String channel;
|
||||
if (randomValue < 45) {
|
||||
channel = "SNS"; // 0~44: 45%
|
||||
} else if (randomValue < 70) {
|
||||
channel = "우리동네TV"; // 45~69: 25%
|
||||
} else if (randomValue < 90) {
|
||||
channel = "지니TV"; // 70~89: 20%
|
||||
} else {
|
||||
channel = "링고비즈"; // 90~99: 10%
|
||||
}
|
||||
|
||||
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
|
||||
.eventId(eventId)
|
||||
@@ -288,72 +408,102 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
totalPublished++;
|
||||
|
||||
// 동시성 충돌 방지: 10개마다 100ms 대기
|
||||
if ((j + 1) % 10 == 0) {
|
||||
if (totalPublished % 10 == 0) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("✅ 이벤트 {} 참여자 발행 완료: {}명", eventId, eventParticipants);
|
||||
}
|
||||
|
||||
log.info("========================================");
|
||||
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
|
||||
log.info("📊 참여 패턴:");
|
||||
log.info(" - 총 고유 참여자: 100명 (user001~user100)");
|
||||
log.info(" - 이벤트1 참여: 100명");
|
||||
log.info(" - 이벤트2 참여: 50명 (이벤트1과 50명 중복)");
|
||||
log.info(" - 이벤트3 참여: 30명 (이벤트1,2와 모두 중복)");
|
||||
log.info(" - 3개 이벤트 모두 참여: 30명");
|
||||
log.info(" - 2개 이벤트 참여: 20명");
|
||||
log.info(" - 1개 이벤트만 참여: 50명");
|
||||
log.info("📺 채널별 참여 비율 (가중치):");
|
||||
log.info(" - SNS: 45% (가장 높음)");
|
||||
log.info(" - 우리동네TV: 25%");
|
||||
log.info(" - 지니TV: 20%");
|
||||
log.info(" - 링고비즈: 10%");
|
||||
log.info("========================================");
|
||||
}
|
||||
|
||||
/**
|
||||
* TimelineData 생성 (시간대별 샘플 데이터)
|
||||
*
|
||||
* - 각 이벤트마다 30일 치 daily 데이터 생성
|
||||
* - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성
|
||||
* - interval=hourly: 시간별 표시 (최근 7일 적합)
|
||||
* - interval=daily: 일별 자동 집계 (30일 전체)
|
||||
* - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수
|
||||
*/
|
||||
private void createTimelineData() {
|
||||
log.info("📊 TimelineData 생성 시작...");
|
||||
|
||||
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
|
||||
String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
|
||||
|
||||
// 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름)
|
||||
int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
|
||||
// 각 이벤트별 시간당 기준 참여자 수 (이벤트 성과에 따라 다름)
|
||||
int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
|
||||
|
||||
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
|
||||
String eventId = eventIds[eventIndex];
|
||||
int baseParticipant = baseParticipants[eventIndex];
|
||||
int baseParticipant = baseParticipantsPerHour[eventIndex];
|
||||
int cumulativeParticipants = 0;
|
||||
|
||||
// 30일 치 데이터 생성 (2024-09-24부터)
|
||||
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0);
|
||||
// 이벤트 ID에서 날짜 파싱 (evt_2025012301 → 2025-01-23)
|
||||
String dateStr = eventId.substring(4); // "2025012301"
|
||||
int year = Integer.parseInt(dateStr.substring(0, 4)); // 2025
|
||||
int month = Integer.parseInt(dateStr.substring(4, 6)); // 01
|
||||
int day = Integer.parseInt(dateStr.substring(6, 8)); // 23
|
||||
|
||||
for (int day = 0; day < 30; day++) {
|
||||
java.time.LocalDateTime timestamp = startDate.plusDays(day);
|
||||
// 이벤트 시작일부터 30일 치 hourly 데이터 생성
|
||||
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0);
|
||||
|
||||
// 랜덤한 참여자 수 생성 (기준값 ± 50%)
|
||||
int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1);
|
||||
cumulativeParticipants += dailyParticipants;
|
||||
for (int dayOffset = 0; dayOffset < 30; dayOffset++) {
|
||||
for (int hour = 0; hour < 24; hour++) {
|
||||
java.time.LocalDateTime timestamp = startDate.plusDays(dayOffset).plusHours(hour);
|
||||
|
||||
// 조회수는 참여자의 3~5배
|
||||
int dailyViews = dailyParticipants * (3 + random.nextInt(3));
|
||||
// 시간대별 참여자 수 변화 (낮 시간대 12~20시에 더 많음)
|
||||
int hourMultiplier = (hour >= 12 && hour <= 20) ? 2 : 1;
|
||||
int hourlyParticipants = (baseParticipant * hourMultiplier) + random.nextInt(baseParticipant + 1);
|
||||
|
||||
// 참여행동은 참여자의 1~2배
|
||||
int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
|
||||
cumulativeParticipants += hourlyParticipants;
|
||||
|
||||
// 전환수는 참여자의 50~80%
|
||||
int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3));
|
||||
// 조회수는 참여자의 3~5배
|
||||
int hourlyViews = hourlyParticipants * (3 + random.nextInt(3));
|
||||
|
||||
// TimelineData 생성
|
||||
com.kt.event.analytics.entity.TimelineData timelineData =
|
||||
com.kt.event.analytics.entity.TimelineData.builder()
|
||||
.eventId(eventId)
|
||||
.timestamp(timestamp)
|
||||
.participants(dailyParticipants)
|
||||
.views(dailyViews)
|
||||
.engagement(dailyEngagement)
|
||||
.conversions(dailyConversions)
|
||||
.cumulativeParticipants(cumulativeParticipants)
|
||||
.build();
|
||||
// 참여행동은 참여자의 1~2배
|
||||
int hourlyEngagement = hourlyParticipants * (1 + random.nextInt(2));
|
||||
|
||||
timelineDataRepository.save(timelineData);
|
||||
// 전환수는 참여자의 50~80%
|
||||
int hourlyConversions = (int) (hourlyParticipants * (0.5 + random.nextDouble() * 0.3));
|
||||
|
||||
// TimelineData 생성
|
||||
com.kt.event.analytics.entity.TimelineData timelineData =
|
||||
com.kt.event.analytics.entity.TimelineData.builder()
|
||||
.eventId(eventId)
|
||||
.timestamp(timestamp)
|
||||
.participants(hourlyParticipants)
|
||||
.views(hourlyViews)
|
||||
.engagement(hourlyEngagement)
|
||||
.conversions(hourlyConversions)
|
||||
.cumulativeParticipants(cumulativeParticipants)
|
||||
.build();
|
||||
|
||||
timelineDataRepository.save(timelineData);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
|
||||
log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건",
|
||||
eventId, year, month, day);
|
||||
}
|
||||
|
||||
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건");
|
||||
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ 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.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
@@ -12,15 +11,12 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
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 설정
|
||||
* JWT 기반 인증 및 API 보안 설정
|
||||
*
|
||||
* ⚠️ CORS 설정은 WebConfig에서 관리합니다.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -29,51 +25,19 @@ public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Value("${cors.allowed-origins:http://localhost:*}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.cors(AbstractHttpConfigurer::disable) // CORS는 WebConfig에서 관리
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Actuator endpoints
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
// Swagger UI endpoints
|
||||
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
|
||||
// Health check
|
||||
.requestMatchers("/health").permitAll()
|
||||
// Analytics API endpoints (테스트 및 개발 용도로 공개)
|
||||
.requestMatchers("/api/**").permitAll()
|
||||
// All other requests require authentication
|
||||
.anyRequest().authenticated()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
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;
|
||||
}
|
||||
// CORS 설정은 WebConfig에서 관리 (모든 origin 허용)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,11 @@ public class SwaggerConfig {
|
||||
return new OpenAPI()
|
||||
.info(apiInfo())
|
||||
.addServersItem(new Server()
|
||||
.url("http://localhost:8086")
|
||||
.url("http://localhost:8086/api/v1/analytics")
|
||||
.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()
|
||||
.url("{protocol}://{host}:{port}")
|
||||
.description("Custom Server")
|
||||
|
||||
+6
-18
@@ -22,7 +22,7 @@ import java.time.LocalDateTime;
|
||||
@Tag(name = "Analytics", description = "이벤트 성과 분석 및 대시보드 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/events")
|
||||
@RequestMapping("/events")
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsDashboardController {
|
||||
|
||||
@@ -31,31 +31,19 @@ public class AnalyticsDashboardController {
|
||||
/**
|
||||
* 성과 대시보드 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param startDate 조회 시작 날짜
|
||||
* @param endDate 조회 종료 날짜
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 성과 대시보드
|
||||
* @param eventId 이벤트 ID
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 성과 대시보드 (이벤트 시작일 ~ 현재까지)
|
||||
*/
|
||||
@Operation(
|
||||
summary = "성과 대시보드 조회",
|
||||
description = "이벤트의 전체 성과를 통합하여 조회합니다."
|
||||
description = "이벤트의 전체 성과를 통합하여 조회합니다. (이벤트 시작일 ~ 현재까지)"
|
||||
)
|
||||
@GetMapping("/{eventId}/analytics")
|
||||
public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> getEventAnalytics(
|
||||
@Parameter(description = "이벤트 ID", required = true)
|
||||
@PathVariable String eventId,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean refresh
|
||||
@@ -63,7 +51,7 @@ public class AnalyticsDashboardController {
|
||||
log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
|
||||
|
||||
AnalyticsDashboardResponse response = analyticsService.getDashboardData(
|
||||
eventId, startDate, endDate, refresh
|
||||
eventId, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import java.util.List;
|
||||
@Tag(name = "Channels", description = "채널별 성과 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/events")
|
||||
@RequestMapping("/events")
|
||||
@RequiredArgsConstructor
|
||||
public class ChannelAnalyticsController {
|
||||
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.kt.event.analytics.controller;
|
||||
|
||||
import com.kt.event.analytics.config.SampleDataLoader;
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 디버그 컨트롤러
|
||||
*
|
||||
* ⚠️ 개발/테스트 전용
|
||||
*/
|
||||
@Tag(name = "Debug", description = "디버그 API (개발/테스트 전용)")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/debug")
|
||||
@RequiredArgsConstructor
|
||||
public class DebugController {
|
||||
|
||||
private final SampleDataLoader sampleDataLoader;
|
||||
|
||||
/**
|
||||
* 샘플 데이터 수동 생성
|
||||
*/
|
||||
@Operation(
|
||||
summary = "샘플 데이터 수동 생성",
|
||||
description = "SampleDataLoader를 수동으로 실행하여 샘플 데이터를 생성합니다."
|
||||
)
|
||||
@PostMapping("/reload-sample-data")
|
||||
public ResponseEntity<ApiResponse<String>> reloadSampleData() {
|
||||
try {
|
||||
log.info("🔧 수동으로 샘플 데이터 생성 요청");
|
||||
|
||||
// SampleDataLoader 실행
|
||||
sampleDataLoader.run(new ApplicationArguments() {
|
||||
@Override
|
||||
public String[] getSourceArgs() {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Set<String> getOptionNames() {
|
||||
return java.util.Collections.emptySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsOption(String name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<String> getOptionValues(String name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<String> getNonOptionArgs() {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
});
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 완료"));
|
||||
} catch (Exception e) {
|
||||
log.error("❌ 샘플 데이터 생성 실패", e);
|
||||
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 실패: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
@Tag(name = "ROI", description = "투자 대비 수익률 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/events")
|
||||
@RequestMapping("/events")
|
||||
@RequiredArgsConstructor
|
||||
public class RoiAnalyticsController {
|
||||
|
||||
|
||||
+7
-19
@@ -24,7 +24,7 @@ import java.util.List;
|
||||
@Tag(name = "Timeline", description = "시간대별 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/events")
|
||||
@RequestMapping("/events")
|
||||
@RequiredArgsConstructor
|
||||
public class TimelineAnalyticsController {
|
||||
|
||||
@@ -33,16 +33,14 @@ public class TimelineAnalyticsController {
|
||||
/**
|
||||
* 시간대별 참여 추이
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param interval 시간 간격 단위
|
||||
* @param startDate 조회 시작 날짜
|
||||
* @param endDate 조회 종료 날짜
|
||||
* @param metrics 조회할 지표 목록
|
||||
* @return 시간대별 참여 추이
|
||||
* @param eventId 이벤트 ID
|
||||
* @param interval 시간 간격 단위
|
||||
* @param metrics 조회할 지표 목록
|
||||
* @return 시간대별 참여 추이 (이벤트 시작일 ~ 현재까지)
|
||||
*/
|
||||
@Operation(
|
||||
summary = "시간대별 참여 추이",
|
||||
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다."
|
||||
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다. (이벤트 시작일 ~ 현재까지)"
|
||||
)
|
||||
@GetMapping("/{eventId}/analytics/timeline")
|
||||
public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics(
|
||||
@@ -53,16 +51,6 @@ public class TimelineAnalyticsController {
|
||||
@RequestParam(required = false, defaultValue = "daily")
|
||||
String interval,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
|
||||
@RequestParam(required = false)
|
||||
String metrics
|
||||
@@ -74,7 +62,7 @@ public class TimelineAnalyticsController {
|
||||
: null;
|
||||
|
||||
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
|
||||
eventId, interval, startDate, endDate, metricList
|
||||
eventId, interval, metricList
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
|
||||
+6
-18
@@ -22,7 +22,7 @@ import java.time.LocalDateTime;
|
||||
@Tag(name = "User Analytics", description = "사용자 전체 이벤트 통합 성과 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequestMapping("/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserAnalyticsDashboardController {
|
||||
|
||||
@@ -31,31 +31,19 @@ public class UserAnalyticsDashboardController {
|
||||
/**
|
||||
* 사용자 전체 성과 대시보드 조회
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param startDate 조회 시작 날짜
|
||||
* @param endDate 조회 종료 날짜
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 전체 통합 성과 대시보드
|
||||
* @param userId 사용자 ID
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 전체 통합 성과 대시보드 (userId 기반 전체 이벤트 조회)
|
||||
*/
|
||||
@Operation(
|
||||
summary = "사용자 전체 성과 대시보드 조회",
|
||||
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다."
|
||||
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다. (userId 기반 전체 이벤트 조회)"
|
||||
)
|
||||
@GetMapping("/{userId}/analytics")
|
||||
public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics(
|
||||
@Parameter(description = "사용자 ID", required = true)
|
||||
@PathVariable String userId,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "캐시 갱신 여부")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean refresh
|
||||
@@ -63,7 +51,7 @@ public class UserAnalyticsDashboardController {
|
||||
log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
|
||||
|
||||
UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
|
||||
userId, startDate, endDate, refresh
|
||||
userId, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
|
||||
+3
-21
@@ -22,7 +22,7 @@ import java.util.List;
|
||||
@Tag(name = "User Channels", description = "사용자 전체 이벤트 채널별 성과 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequestMapping("/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserChannelAnalyticsController {
|
||||
|
||||
@@ -30,17 +30,13 @@ public class UserChannelAnalyticsController {
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 채널별 성과 분석",
|
||||
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
|
||||
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다. (전체 채널 무조건 표시)"
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/channels")
|
||||
public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics(
|
||||
@Parameter(description = "사용자 ID", required = true)
|
||||
@PathVariable String userId,
|
||||
|
||||
@Parameter(description = "조회할 채널 목록 (쉼표로 구분)")
|
||||
@RequestParam(required = false)
|
||||
String channels,
|
||||
|
||||
@Parameter(description = "정렬 기준")
|
||||
@RequestParam(required = false, defaultValue = "participants")
|
||||
String sortBy,
|
||||
@@ -49,28 +45,14 @@ public class UserChannelAnalyticsController {
|
||||
@RequestParam(required = false, defaultValue = "desc")
|
||||
String order,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "캐시 갱신 여부")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean refresh
|
||||
) {
|
||||
log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy);
|
||||
|
||||
List<String> channelList = channels != null && !channels.isBlank()
|
||||
? Arrays.asList(channels.split(","))
|
||||
: null;
|
||||
|
||||
UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
|
||||
userId, channelList, sortBy, order, startDate, endDate, refresh
|
||||
userId, sortBy, order, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
|
||||
+3
-13
@@ -20,7 +20,7 @@ import java.time.LocalDateTime;
|
||||
@Tag(name = "User ROI", description = "사용자 전체 이벤트 ROI 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequestMapping("/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserRoiAnalyticsController {
|
||||
|
||||
@@ -28,7 +28,7 @@ public class UserRoiAnalyticsController {
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 ROI 상세 분석",
|
||||
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
|
||||
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/roi")
|
||||
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics(
|
||||
@@ -39,16 +39,6 @@ public class UserRoiAnalyticsController {
|
||||
@RequestParam(required = false, defaultValue = "true")
|
||||
Boolean includeProjection,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "캐시 갱신 여부")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean refresh
|
||||
@@ -56,7 +46,7 @@ public class UserRoiAnalyticsController {
|
||||
log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
|
||||
|
||||
UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
|
||||
userId, includeProjection, startDate, endDate, refresh
|
||||
userId, includeProjection, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
|
||||
+3
-13
@@ -22,7 +22,7 @@ import java.util.List;
|
||||
@Tag(name = "User Timeline", description = "사용자 전체 이벤트 시간대별 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequestMapping("/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserTimelineAnalyticsController {
|
||||
|
||||
@@ -30,7 +30,7 @@ public class UserTimelineAnalyticsController {
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 시간대별 참여 추이",
|
||||
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
|
||||
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/timeline")
|
||||
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics(
|
||||
@@ -41,16 +41,6 @@ public class UserTimelineAnalyticsController {
|
||||
@RequestParam(required = false, defaultValue = "daily")
|
||||
String interval,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
|
||||
@RequestParam(required = false)
|
||||
String metrics,
|
||||
@@ -66,7 +56,7 @@ public class UserTimelineAnalyticsController {
|
||||
: null;
|
||||
|
||||
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
|
||||
userId, interval, startDate, endDate, metricList, refresh
|
||||
userId, interval, metricList, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
|
||||
+15
@@ -47,6 +47,21 @@ public class AnalyticsDashboardResponse {
|
||||
*/
|
||||
private RoiSummary roi;
|
||||
|
||||
/**
|
||||
* 투자 비용 상세
|
||||
*/
|
||||
private InvestmentDetails investment;
|
||||
|
||||
/**
|
||||
* 수익 상세
|
||||
*/
|
||||
private RevenueDetails revenue;
|
||||
|
||||
/**
|
||||
* 비용 효율성 분석
|
||||
*/
|
||||
private CostEfficiency costEfficiency;
|
||||
|
||||
/**
|
||||
* 마지막 업데이트 시간
|
||||
*/
|
||||
|
||||
+10
@@ -33,6 +33,16 @@ public class InvestmentDetails {
|
||||
*/
|
||||
private BigDecimal operation;
|
||||
|
||||
/**
|
||||
* 경품 비용 (원)
|
||||
*/
|
||||
private BigDecimal prizeCost;
|
||||
|
||||
/**
|
||||
* 채널 비용 (원) - distribution과 동일한 값
|
||||
*/
|
||||
private BigDecimal channelCost;
|
||||
|
||||
/**
|
||||
* 총 투자 비용 (원)
|
||||
*/
|
||||
|
||||
+10
@@ -26,6 +26,16 @@ public class RevenueDetails {
|
||||
*/
|
||||
private BigDecimal expectedSales;
|
||||
|
||||
/**
|
||||
* 신규 고객 매출 (원)
|
||||
*/
|
||||
private BigDecimal newCustomerRevenue;
|
||||
|
||||
/**
|
||||
* 기존 고객 매출 (원)
|
||||
*/
|
||||
private BigDecimal existingCustomerRevenue;
|
||||
|
||||
/**
|
||||
* 브랜드 가치 향상 추정액 (원)
|
||||
*/
|
||||
|
||||
@@ -125,4 +125,11 @@ public class ChannelStats extends BaseTimeEntity {
|
||||
@Column(name = "average_duration")
|
||||
@Builder.Default
|
||||
private Integer averageDuration = 0;
|
||||
|
||||
/**
|
||||
* 참여자 수 증가
|
||||
*/
|
||||
public void incrementParticipants() {
|
||||
this.participants++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,18 @@ public class EventStats extends BaseTimeEntity {
|
||||
@Column(length = 20)
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 이벤트 시작일
|
||||
*/
|
||||
@Column(name = "start_date")
|
||||
private java.time.LocalDateTime startDate;
|
||||
|
||||
/**
|
||||
* 이벤트 종료일 (null이면 진행중)
|
||||
*/
|
||||
@Column(name = "end_date")
|
||||
private java.time.LocalDateTime endDate;
|
||||
|
||||
/**
|
||||
* 참여자 수 증가
|
||||
*/
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -32,7 +32,7 @@ public class DistributionCompletedConsumer {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed";
|
||||
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed_v2";
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
|
||||
private static final long IDEMPOTENCY_TTL_DAYS = 7;
|
||||
|
||||
@@ -109,10 +109,15 @@ public class DistributionCompletedConsumer {
|
||||
channelStats.setImpressions(channel.getExpectedViews());
|
||||
}
|
||||
|
||||
// 배포 비용 저장
|
||||
if (channel.getDistributionCost() != null) {
|
||||
channelStats.setDistributionCost(channel.getDistributionCost());
|
||||
}
|
||||
|
||||
channelStatsRepository.save(channelStats);
|
||||
|
||||
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}",
|
||||
eventId, channelName, channel.getExpectedViews());
|
||||
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}, distributionCost={}",
|
||||
eventId, channelName, channel.getExpectedViews(), channel.getDistributionCost());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);
|
||||
|
||||
+7
-2
@@ -12,6 +12,7 @@ import org.springframework.kafka.annotation.KafkaListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
@@ -29,7 +30,7 @@ public class EventCreatedConsumer {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
private static final String PROCESSED_EVENTS_KEY = "processed_events";
|
||||
private static final String PROCESSED_EVENTS_KEY = "processed_events_v2";
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
|
||||
private static final long IDEMPOTENCY_TTL_DAYS = 7;
|
||||
|
||||
@@ -61,11 +62,15 @@ public class EventCreatedConsumer {
|
||||
.userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
|
||||
.totalParticipants(0)
|
||||
.totalInvestment(event.getTotalInvestment())
|
||||
.expectedRevenue(event.getExpectedRevenue() != null ? event.getExpectedRevenue() : BigDecimal.ZERO)
|
||||
.status(event.getStatus())
|
||||
.startDate(event.getStartDate())
|
||||
.endDate(event.getEndDate())
|
||||
.build();
|
||||
|
||||
eventStatsRepository.save(eventStats);
|
||||
log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId);
|
||||
log.info("✅ 이벤트 통계 초기화 완료: eventId={}, userId={}, startDate={}, endDate={}",
|
||||
eventId, eventStats.getUserId(), event.getStartDate(), event.getEndDate());
|
||||
|
||||
// 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영)
|
||||
String cacheKey = CACHE_KEY_PREFIX + eventId;
|
||||
|
||||
+27
-8
@@ -1,7 +1,9 @@
|
||||
package com.kt.event.analytics.messaging.consumer;
|
||||
|
||||
import com.kt.event.analytics.entity.ChannelStats;
|
||||
import com.kt.event.analytics.entity.EventStats;
|
||||
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
|
||||
import com.kt.event.analytics.repository.ChannelStatsRepository;
|
||||
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -26,10 +28,11 @@ import java.util.concurrent.TimeUnit;
|
||||
public class ParticipantRegisteredConsumer {
|
||||
|
||||
private final EventStatsRepository eventStatsRepository;
|
||||
private final ChannelStatsRepository channelStatsRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants";
|
||||
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants_v2";
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
|
||||
private static final long IDEMPOTENCY_TTL_DAYS = 7;
|
||||
|
||||
@@ -47,11 +50,13 @@ public class ParticipantRegisteredConsumer {
|
||||
ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class);
|
||||
String participantId = event.getParticipantId();
|
||||
String eventId = event.getEventId();
|
||||
String channel = event.getChannel();
|
||||
|
||||
// ✅ 1. 멱등성 체크 (중복 처리 방지)
|
||||
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId);
|
||||
// ✅ 1. 멱등성 체크 (중복 처리 방지) - eventId:participantId 조합으로 체크
|
||||
String idempotencyKey = eventId + ":" + participantId;
|
||||
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
|
||||
if (Boolean.TRUE.equals(isProcessed)) {
|
||||
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId);
|
||||
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}, participantId={}", eventId, participantId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,15 +72,29 @@ public class ParticipantRegisteredConsumer {
|
||||
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
|
||||
);
|
||||
|
||||
// 3. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
|
||||
// 3. 채널별 참여자 수 업데이트 - 비관적 락 적용
|
||||
if (channel != null && !channel.isEmpty()) {
|
||||
channelStatsRepository.findByEventIdAndChannelNameWithLock(eventId, channel)
|
||||
.ifPresentOrElse(
|
||||
channelStats -> {
|
||||
channelStats.incrementParticipants();
|
||||
channelStatsRepository.save(channelStats);
|
||||
log.info("✅ 채널별 참여자 수 업데이트: eventId={}, channel={}, participants={}",
|
||||
eventId, channel, channelStats.getParticipants());
|
||||
},
|
||||
() -> log.warn("⚠️ 채널 통계 없음: eventId={}, channel={}", eventId, channel)
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
|
||||
String cacheKey = CACHE_KEY_PREFIX + eventId;
|
||||
redisTemplate.delete(cacheKey);
|
||||
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
|
||||
|
||||
// 4. 멱등성 처리 완료 기록 (7일 TTL)
|
||||
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId);
|
||||
// 5. 멱등성 처리 완료 기록 (7일 TTL)
|
||||
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
|
||||
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
|
||||
log.debug("✅ 멱등성 기록: participantId={}", participantId);
|
||||
log.debug("✅ 멱등성 기록: eventId={}, participantId={}", eventId, participantId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);
|
||||
|
||||
+5
@@ -62,5 +62,10 @@ public class DistributionCompletedEvent {
|
||||
* 예상 노출 수
|
||||
*/
|
||||
private Integer expectedViews;
|
||||
|
||||
/**
|
||||
* 배포 비용 (원)
|
||||
*/
|
||||
private java.math.BigDecimal distributionCost;
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -6,6 +6,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 이벤트 생성 이벤트
|
||||
@@ -36,8 +37,23 @@ public class EventCreatedEvent {
|
||||
*/
|
||||
private BigDecimal totalInvestment;
|
||||
|
||||
/**
|
||||
* 예상 수익
|
||||
*/
|
||||
private BigDecimal expectedRevenue;
|
||||
|
||||
/**
|
||||
* 이벤트 상태
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 이벤트 시작일
|
||||
*/
|
||||
private LocalDateTime startDate;
|
||||
|
||||
/**
|
||||
* 이벤트 종료일 (null이면 진행중)
|
||||
*/
|
||||
private LocalDateTime endDate;
|
||||
}
|
||||
|
||||
+16
@@ -1,7 +1,11 @@
|
||||
package com.kt.event.analytics.repository;
|
||||
|
||||
import com.kt.event.analytics.entity.ChannelStats;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
@@ -30,6 +34,18 @@ public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long
|
||||
*/
|
||||
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
|
||||
|
||||
/**
|
||||
* 이벤트 ID와 채널명으로 통계 조회 (비관적 락)
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param channelName 채널명
|
||||
* @return 채널 통계
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT c FROM ChannelStats c WHERE c.eventId = :eventId AND c.channelName = :channelName")
|
||||
Optional<ChannelStats> findByEventIdAndChannelNameWithLock(@Param("eventId") String eventId,
|
||||
@Param("channelName") String channelName);
|
||||
|
||||
/**
|
||||
* 여러 이벤트 ID로 모든 채널 통계 조회
|
||||
*
|
||||
|
||||
+108
-14
@@ -47,12 +47,10 @@ public class AnalyticsService {
|
||||
* 대시보드 데이터 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param startDate 조회 시작 날짜 (선택)
|
||||
* @param endDate 조회 종료 날짜 (선택)
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 대시보드 응답
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 대시보드 응답 (이벤트 시작일 ~ 현재까지)
|
||||
*/
|
||||
public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||
public AnalyticsDashboardResponse getDashboardData(String eventId, boolean refresh) {
|
||||
log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + eventId;
|
||||
@@ -91,7 +89,7 @@ public class AnalyticsService {
|
||||
}
|
||||
|
||||
// 3. 대시보드 데이터 구성
|
||||
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate);
|
||||
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList);
|
||||
|
||||
// 4. Redis 캐싱 (1시간 TTL)
|
||||
try {
|
||||
@@ -110,10 +108,9 @@ public class AnalyticsService {
|
||||
/**
|
||||
* 대시보드 데이터 구성
|
||||
*/
|
||||
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
// 기간 정보
|
||||
PeriodInfo period = buildPeriodInfo(startDate, endDate);
|
||||
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList) {
|
||||
// 기간 정보 (이벤트 시작일 ~ 현재)
|
||||
PeriodInfo period = buildPeriodInfo(eventStats);
|
||||
|
||||
// 성과 요약
|
||||
AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList);
|
||||
@@ -124,6 +121,15 @@ public class AnalyticsService {
|
||||
// ROI 요약
|
||||
RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats);
|
||||
|
||||
// 투자 비용 상세
|
||||
InvestmentDetails investment = buildInvestmentDetails(eventStats, channelStatsList);
|
||||
|
||||
// 수익 상세
|
||||
RevenueDetails revenue = buildRevenueDetails(eventStats);
|
||||
|
||||
// 비용 효율성
|
||||
CostEfficiency costEfficiency = buildCostEfficiency(eventStats);
|
||||
|
||||
return AnalyticsDashboardResponse.builder()
|
||||
.eventId(eventStats.getEventId())
|
||||
.eventTitle(eventStats.getEventTitle())
|
||||
@@ -131,17 +137,21 @@ public class AnalyticsService {
|
||||
.summary(summary)
|
||||
.channelPerformance(channelPerformance)
|
||||
.roi(roiSummary)
|
||||
.investment(investment)
|
||||
.revenue(revenue)
|
||||
.costEfficiency(costEfficiency)
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("cached")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간 정보 구성
|
||||
* 기간 정보 구성 (이벤트 시작일 ~ 종료일 또는 현재)
|
||||
*/
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
private PeriodInfo buildPeriodInfo(EventStats eventStats) {
|
||||
LocalDateTime start = eventStats.getStartDate();
|
||||
LocalDateTime end = eventStats.getEndDate() != null ?
|
||||
eventStats.getEndDate() : LocalDateTime.now();
|
||||
|
||||
long durationDays = ChronoUnit.DAYS.between(start, end);
|
||||
|
||||
@@ -215,4 +225,88 @@ public class AnalyticsService {
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 투자 비용 상세 구성
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - 실제 채널 배포 비용 집계
|
||||
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
*/
|
||||
private InvestmentDetails buildInvestmentDetails(EventStats eventStats, List<ChannelStats> channelStatsList) {
|
||||
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
|
||||
|
||||
// ChannelStats에서 실제 배포 비용 집계
|
||||
java.math.BigDecimal actualDistribution = channelStatsList.stream()
|
||||
.map(ChannelStats::getDistributionCost)
|
||||
.reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add);
|
||||
|
||||
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
|
||||
java.math.BigDecimal remaining = totalInvestment.subtract(actualDistribution);
|
||||
|
||||
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
java.math.BigDecimal prizeCost = remaining.multiply(java.math.BigDecimal.valueOf(0.50));
|
||||
java.math.BigDecimal contentCreation = remaining.multiply(java.math.BigDecimal.valueOf(0.30));
|
||||
java.math.BigDecimal operation = remaining.multiply(java.math.BigDecimal.valueOf(0.20));
|
||||
|
||||
return InvestmentDetails.builder()
|
||||
.total(totalInvestment)
|
||||
.contentCreation(contentCreation)
|
||||
.operation(operation)
|
||||
.distribution(actualDistribution)
|
||||
.prizeCost(prizeCost)
|
||||
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수익 상세 구성
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - 직접 매출 70%, 예상 추가 매출 30%
|
||||
* - 신규 고객 40%, 기존 고객 60%
|
||||
*/
|
||||
private RevenueDetails buildRevenueDetails(EventStats eventStats) {
|
||||
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
|
||||
|
||||
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
|
||||
java.math.BigDecimal directSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.70));
|
||||
java.math.BigDecimal expectedSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.30));
|
||||
|
||||
// 신규 고객 40%, 기존 고객 60%
|
||||
java.math.BigDecimal newCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.40));
|
||||
java.math.BigDecimal existingCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.60));
|
||||
|
||||
return RevenueDetails.builder()
|
||||
.total(totalRevenue)
|
||||
.directSales(directSales)
|
||||
.expectedSales(expectedSales)
|
||||
.newCustomerRevenue(newCustomerRevenue)
|
||||
.existingCustomerRevenue(existingCustomerRevenue)
|
||||
.brandValue(java.math.BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 효율성 구성
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - 참여자당 비용 = 총투자 ÷ 총참여자수
|
||||
* - 참여자당 수익 = 총수익 ÷ 총참여자수
|
||||
*/
|
||||
private CostEfficiency buildCostEfficiency(EventStats eventStats) {
|
||||
int totalParticipants = eventStats.getTotalParticipants();
|
||||
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
|
||||
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
|
||||
|
||||
double costPerParticipant = totalParticipants > 0 ?
|
||||
totalInvestment.doubleValue() / totalParticipants : 0.0;
|
||||
double revenuePerParticipant = totalParticipants > 0 ?
|
||||
totalRevenue.doubleValue() / totalParticipants : 0.0;
|
||||
|
||||
return CostEfficiency.builder()
|
||||
.costPerParticipant(costPerParticipant)
|
||||
.revenuePerParticipant(revenuePerParticipant)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,43 +60,62 @@ public class ROICalculator {
|
||||
|
||||
/**
|
||||
* 투자 비용 계산
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - ChannelStats에서 실제 배포 비용 집계
|
||||
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
*/
|
||||
private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) {
|
||||
BigDecimal distributionCost = channelStats.stream()
|
||||
BigDecimal totalInvestment = eventStats.getTotalInvestment();
|
||||
|
||||
// ChannelStats에서 실제 배포 비용 집계
|
||||
BigDecimal actualDistribution = channelStats.stream()
|
||||
.map(ChannelStats::getDistributionCost)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
BigDecimal contentCreation = eventStats.getTotalInvestment()
|
||||
.multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40%를 콘텐츠 제작비로 가정
|
||||
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
|
||||
BigDecimal remaining = totalInvestment.subtract(actualDistribution);
|
||||
|
||||
BigDecimal operation = eventStats.getTotalInvestment()
|
||||
.multiply(BigDecimal.valueOf(0.1)); // 10%를 운영비로 가정
|
||||
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
|
||||
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
|
||||
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
|
||||
|
||||
return InvestmentDetails.builder()
|
||||
.total(totalInvestment)
|
||||
.contentCreation(contentCreation)
|
||||
.distribution(distributionCost)
|
||||
.operation(operation)
|
||||
.total(eventStats.getTotalInvestment())
|
||||
.distribution(actualDistribution)
|
||||
.prizeCost(prizeCost)
|
||||
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수익 계산
|
||||
*
|
||||
* UserRoiAnalyticsService와 동일한 로직:
|
||||
* - 직접 매출 70%, 예상 추가 매출 30%
|
||||
* - 신규 고객 40%, 기존 고객 60%
|
||||
*/
|
||||
private RevenueDetails calculateRevenue(EventStats eventStats) {
|
||||
BigDecimal directSales = eventStats.getExpectedRevenue()
|
||||
.multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66%를 직접 매출로 가정
|
||||
BigDecimal totalRevenue = eventStats.getExpectedRevenue();
|
||||
|
||||
BigDecimal expectedSales = eventStats.getExpectedRevenue()
|
||||
.multiply(BigDecimal.valueOf(0.34)); // 34%를 예상 추가 매출로 가정
|
||||
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
|
||||
BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
|
||||
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
|
||||
|
||||
BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요
|
||||
// 신규 고객 40%, 기존 고객 60%
|
||||
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
|
||||
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
|
||||
|
||||
return RevenueDetails.builder()
|
||||
.total(totalRevenue)
|
||||
.directSales(directSales)
|
||||
.expectedSales(expectedSales)
|
||||
.brandValue(brandValue)
|
||||
.total(eventStats.getExpectedRevenue())
|
||||
.newCustomerRevenue(newCustomerRevenue)
|
||||
.existingCustomerRevenue(existingCustomerRevenue)
|
||||
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
+4
-11
@@ -26,20 +26,13 @@ public class TimelineAnalyticsService {
|
||||
private final TimelineDataRepository timelineDataRepository;
|
||||
|
||||
/**
|
||||
* 시간대별 참여 추이 조회
|
||||
* 시간대별 참여 추이 조회 (이벤트 전체 기간)
|
||||
*/
|
||||
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval,
|
||||
LocalDateTime startDate, LocalDateTime endDate,
|
||||
List<String> metrics) {
|
||||
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, List<String> metrics) {
|
||||
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
|
||||
|
||||
// 시간대별 데이터 조회
|
||||
List<TimelineData> timelineDataList;
|
||||
if (startDate != null && endDate != null) {
|
||||
timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
|
||||
} else {
|
||||
timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
|
||||
}
|
||||
// 시간대별 데이터 조회 (이벤트 전체 기간)
|
||||
List<TimelineData> timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
|
||||
|
||||
// 시간대별 데이터 포인트 구성
|
||||
List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList);
|
||||
|
||||
+27
-19
@@ -44,13 +44,11 @@ public class UserAnalyticsService {
|
||||
/**
|
||||
* 사용자 전체 대시보드 데이터 조회
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param startDate 조회 시작 날짜 (선택)
|
||||
* @param endDate 조회 종료 날짜 (선택)
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 사용자 통합 대시보드 응답
|
||||
* @param userId 사용자 ID
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 사용자 통합 대시보드 응답 (userId 기반 전체 이벤트 조회)
|
||||
*/
|
||||
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, boolean refresh) {
|
||||
log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||
@@ -75,7 +73,7 @@ public class UserAnalyticsService {
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
log.warn("사용자에 이벤트가 없음: userId={}", userId);
|
||||
return buildEmptyResponse(userId, startDate, endDate);
|
||||
return buildEmptyResponse(userId);
|
||||
}
|
||||
|
||||
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
|
||||
@@ -87,7 +85,7 @@ public class UserAnalyticsService {
|
||||
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
|
||||
|
||||
// 3. 통합 대시보드 데이터 구성
|
||||
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate);
|
||||
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats);
|
||||
|
||||
// 4. Redis 캐싱 (30분 TTL)
|
||||
try {
|
||||
@@ -104,10 +102,15 @@ public class UserAnalyticsService {
|
||||
/**
|
||||
* 빈 응답 생성 (이벤트가 없는 경우)
|
||||
*/
|
||||
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return UserAnalyticsDashboardResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.period(PeriodInfo.builder()
|
||||
.startDate(now)
|
||||
.endDate(now)
|
||||
.durationDays(0)
|
||||
.build())
|
||||
.totalEvents(0)
|
||||
.activeEvents(0)
|
||||
.overallSummary(buildEmptyAnalyticsSummary())
|
||||
@@ -123,10 +126,9 @@ public class UserAnalyticsService {
|
||||
* 사용자 통합 대시보드 데이터 구성
|
||||
*/
|
||||
private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents,
|
||||
List<ChannelStats> allChannelStats,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
// 기간 정보
|
||||
PeriodInfo period = buildPeriodInfo(startDate, endDate);
|
||||
List<ChannelStats> allChannelStats) {
|
||||
// 기간 정보 (전체 이벤트의 최소/최대 날짜 기반)
|
||||
PeriodInfo period = buildPeriodFromEvents(allEvents);
|
||||
|
||||
// 전체 이벤트 수 및 활성 이벤트 수
|
||||
int totalEvents = allEvents.size();
|
||||
@@ -299,16 +301,22 @@ public class UserAnalyticsService {
|
||||
|
||||
/**
|
||||
* 기간 정보 구성
|
||||
*
|
||||
* 전체 이벤트 중 가장 빠른 시작일 ~ 현재까지의 기간 계산
|
||||
*/
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
long durationDays = ChronoUnit.DAYS.between(start, end);
|
||||
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
|
||||
LocalDateTime start = events.stream()
|
||||
.map(EventStats::getStartDate)
|
||||
.filter(Objects::nonNull)
|
||||
.min(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
LocalDateTime end = LocalDateTime.now();
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
.durationDays((int) durationDays)
|
||||
.durationDays((int) ChronoUnit.DAYS.between(start, end))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
+31
-23
@@ -42,10 +42,9 @@ public class UserChannelAnalyticsService {
|
||||
private static final long CACHE_TTL = 1800; // 30분
|
||||
|
||||
/**
|
||||
* 사용자 전체 채널 분석 데이터 조회
|
||||
* 사용자 전체 채널 분석 데이터 조회 (전체 채널 무조건 표시)
|
||||
*/
|
||||
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> channels, String sortBy, String order,
|
||||
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, String sortBy, String order, boolean refresh) {
|
||||
log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||
@@ -66,14 +65,14 @@ public class UserChannelAnalyticsService {
|
||||
// 2. 데이터 조회
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
return buildEmptyResponse(userId, startDate, endDate);
|
||||
return buildEmptyResponse(userId);
|
||||
}
|
||||
|
||||
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
|
||||
|
||||
// 3. 응답 구성
|
||||
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate);
|
||||
// 3. 응답 구성 (전체 채널)
|
||||
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, sortBy, order);
|
||||
|
||||
// 4. 캐싱
|
||||
try {
|
||||
@@ -87,10 +86,15 @@ public class UserChannelAnalyticsService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
private UserChannelAnalyticsResponse buildEmptyResponse(String userId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return UserChannelAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.period(PeriodInfo.builder()
|
||||
.startDate(now)
|
||||
.endDate(now)
|
||||
.durationDays(0)
|
||||
.build())
|
||||
.totalEvents(0)
|
||||
.channels(new ArrayList<>())
|
||||
.comparison(ChannelComparison.builder().build())
|
||||
@@ -100,15 +104,10 @@ public class UserChannelAnalyticsService {
|
||||
}
|
||||
|
||||
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents,
|
||||
List<ChannelStats> allChannelStats, List<String> channels,
|
||||
String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
// 채널 필터링
|
||||
List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty()
|
||||
? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
|
||||
: allChannelStats;
|
||||
|
||||
// 채널별 집계
|
||||
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
|
||||
List<ChannelStats> allChannelStats,
|
||||
String sortBy, String order) {
|
||||
// 채널별 집계 (전체 채널)
|
||||
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(allChannelStats);
|
||||
|
||||
// 정렬
|
||||
channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
|
||||
@@ -118,7 +117,7 @@ public class UserChannelAnalyticsService {
|
||||
|
||||
return UserChannelAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.period(buildPeriodFromEvents(allEvents))
|
||||
.totalEvents(allEvents.size())
|
||||
.channels(channelAnalyticsList)
|
||||
.comparison(comparison)
|
||||
@@ -246,15 +245,24 @@ public class UserChannelAnalyticsService {
|
||||
.build();
|
||||
}
|
||||
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
long durationDays = ChronoUnit.DAYS.between(start, end);
|
||||
/**
|
||||
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
|
||||
*/
|
||||
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
|
||||
LocalDateTime start = events.stream()
|
||||
.map(EventStats::getCreatedAt)
|
||||
.min(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
LocalDateTime end = events.stream()
|
||||
.map(EventStats::getUpdatedAt)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
.durationDays((int) durationDays)
|
||||
.durationDays((int) ChronoUnit.DAYS.between(start, end))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+80
-19
@@ -1,7 +1,9 @@
|
||||
package com.kt.event.analytics.service;
|
||||
|
||||
import com.kt.event.analytics.dto.response.*;
|
||||
import com.kt.event.analytics.entity.ChannelStats;
|
||||
import com.kt.event.analytics.entity.EventStats;
|
||||
import com.kt.event.analytics.repository.ChannelStatsRepository;
|
||||
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -31,14 +33,14 @@ import java.util.stream.Collectors;
|
||||
public class UserRoiAnalyticsService {
|
||||
|
||||
private final EventStatsRepository eventStatsRepository;
|
||||
private final ChannelStatsRepository channelStatsRepository;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:user:roi:";
|
||||
private static final long CACHE_TTL = 1800;
|
||||
|
||||
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection,
|
||||
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, boolean refresh) {
|
||||
log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||
@@ -56,10 +58,10 @@ public class UserRoiAnalyticsService {
|
||||
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
return buildEmptyResponse(userId, startDate, endDate);
|
||||
return buildEmptyResponse(userId);
|
||||
}
|
||||
|
||||
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate);
|
||||
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection);
|
||||
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(response);
|
||||
@@ -71,13 +73,32 @@ public class UserRoiAnalyticsService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
private UserRoiAnalyticsResponse buildEmptyResponse(String userId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return UserRoiAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.period(PeriodInfo.builder()
|
||||
.startDate(now)
|
||||
.endDate(now)
|
||||
.durationDays(0)
|
||||
.build())
|
||||
.totalEvents(0)
|
||||
.overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build())
|
||||
.overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build())
|
||||
.overallInvestment(InvestmentDetails.builder()
|
||||
.total(BigDecimal.ZERO)
|
||||
.contentCreation(BigDecimal.ZERO)
|
||||
.operation(BigDecimal.ZERO)
|
||||
.distribution(BigDecimal.ZERO)
|
||||
.prizeCost(BigDecimal.ZERO)
|
||||
.channelCost(BigDecimal.ZERO)
|
||||
.build())
|
||||
.overallRevenue(RevenueDetails.builder()
|
||||
.total(BigDecimal.ZERO)
|
||||
.directSales(BigDecimal.ZERO)
|
||||
.expectedSales(BigDecimal.ZERO)
|
||||
.newCustomerRevenue(BigDecimal.ZERO)
|
||||
.existingCustomerRevenue(BigDecimal.ZERO)
|
||||
.brandValue(BigDecimal.ZERO)
|
||||
.build())
|
||||
.overallRoi(RoiCalculation.builder()
|
||||
.netProfit(BigDecimal.ZERO)
|
||||
.roiPercentage(0.0)
|
||||
@@ -88,8 +109,7 @@ public class UserRoiAnalyticsService {
|
||||
.build();
|
||||
}
|
||||
|
||||
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection) {
|
||||
BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal totalProfit = totalRevenue.subtract(totalInvestment);
|
||||
@@ -98,17 +118,44 @@ public class UserRoiAnalyticsService {
|
||||
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
|
||||
: 0.0;
|
||||
|
||||
// ChannelStats에서 실제 배포 비용 집계
|
||||
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
|
||||
|
||||
BigDecimal actualDistribution = allChannelStats.stream()
|
||||
.map(ChannelStats::getDistributionCost)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
|
||||
BigDecimal remaining = totalInvestment.subtract(actualDistribution);
|
||||
|
||||
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
|
||||
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
|
||||
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
|
||||
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
|
||||
|
||||
InvestmentDetails investment = InvestmentDetails.builder()
|
||||
.total(totalInvestment)
|
||||
.contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6)))
|
||||
.operation(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
|
||||
.distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
|
||||
.contentCreation(contentCreation)
|
||||
.operation(operation)
|
||||
.distribution(actualDistribution)
|
||||
.prizeCost(prizeCost)
|
||||
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
|
||||
.build();
|
||||
|
||||
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30% / 신규 고객 40%, 기존 고객 60%
|
||||
BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
|
||||
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
|
||||
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
|
||||
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
|
||||
|
||||
RevenueDetails revenue = RevenueDetails.builder()
|
||||
.total(totalRevenue)
|
||||
.directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7)))
|
||||
.expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3)))
|
||||
.directSales(directSales)
|
||||
.expectedSales(expectedSales)
|
||||
.newCustomerRevenue(newCustomerRevenue)
|
||||
.existingCustomerRevenue(existingCustomerRevenue)
|
||||
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
|
||||
.build();
|
||||
|
||||
RoiCalculation roiCalc = RoiCalculation.builder()
|
||||
@@ -149,9 +196,12 @@ public class UserRoiAnalyticsService {
|
||||
.sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 전체 이벤트의 최소/최대 날짜로 period 계산
|
||||
PeriodInfo period = buildPeriodFromEvents(allEvents);
|
||||
|
||||
return UserRoiAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.period(period)
|
||||
.totalEvents(allEvents.size())
|
||||
.overallInvestment(investment)
|
||||
.overallRevenue(revenue)
|
||||
@@ -164,9 +214,20 @@ public class UserRoiAnalyticsService {
|
||||
.build();
|
||||
}
|
||||
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
/**
|
||||
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
|
||||
*/
|
||||
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
|
||||
LocalDateTime start = events.stream()
|
||||
.map(EventStats::getCreatedAt)
|
||||
.min(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
LocalDateTime end = events.stream()
|
||||
.map(EventStats::getUpdatedAt)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
|
||||
+26
-14
@@ -37,7 +37,6 @@ public class UserTimelineAnalyticsService {
|
||||
private static final long CACHE_TTL = 1800;
|
||||
|
||||
public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
|
||||
LocalDateTime startDate, LocalDateTime endDate,
|
||||
List<String> metrics, boolean refresh) {
|
||||
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
|
||||
|
||||
@@ -56,15 +55,13 @@ public class UserTimelineAnalyticsService {
|
||||
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
return buildEmptyResponse(userId, interval, startDate, endDate);
|
||||
return buildEmptyResponse(userId, interval);
|
||||
}
|
||||
|
||||
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||
List<TimelineData> allTimelineData = startDate != null && endDate != null
|
||||
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
|
||||
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
|
||||
List<TimelineData> allTimelineData = timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
|
||||
|
||||
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate);
|
||||
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval);
|
||||
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(response);
|
||||
@@ -76,10 +73,15 @@ public class UserTimelineAnalyticsService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return UserTimelineAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.period(PeriodInfo.builder()
|
||||
.startDate(now)
|
||||
.endDate(now)
|
||||
.durationDays(0)
|
||||
.build())
|
||||
.totalEvents(0)
|
||||
.interval(interval != null ? interval : "daily")
|
||||
.dataPoints(new ArrayList<>())
|
||||
@@ -91,8 +93,7 @@ public class UserTimelineAnalyticsService {
|
||||
}
|
||||
|
||||
private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents,
|
||||
List<TimelineData> allTimelineData, String interval,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
List<TimelineData> allTimelineData, String interval) {
|
||||
Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>();
|
||||
|
||||
for (TimelineData data : allTimelineData) {
|
||||
@@ -119,7 +120,7 @@ public class UserTimelineAnalyticsService {
|
||||
|
||||
return UserTimelineAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.period(buildPeriodFromEvents(allEvents))
|
||||
.totalEvents(allEvents.size())
|
||||
.interval(interval != null ? interval : "daily")
|
||||
.dataPoints(dataPoints)
|
||||
@@ -179,9 +180,20 @@ public class UserTimelineAnalyticsService {
|
||||
.build() : PeakTimeInfo.builder().build();
|
||||
}
|
||||
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
/**
|
||||
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
|
||||
*/
|
||||
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
|
||||
LocalDateTime start = events.stream()
|
||||
.map(EventStats::getCreatedAt)
|
||||
.min(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
LocalDateTime end = events.stream()
|
||||
.map(EventStats::getUpdatedAt)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.orElse(LocalDateTime.now());
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
|
||||
@@ -47,11 +47,13 @@ spring:
|
||||
enabled: ${KAFKA_ENABLED:true}
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
|
||||
consumer:
|
||||
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service}
|
||||
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service-consumers-v3}
|
||||
auto-offset-reset: earliest
|
||||
enable-auto-commit: true
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
properties:
|
||||
auto.offset.reset: earliest
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
@@ -75,6 +77,10 @@ server:
|
||||
port: ${SERVER_PORT:8086}
|
||||
servlet:
|
||||
context-path: /api/v1/analytics
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
enabled: true
|
||||
force: true
|
||||
|
||||
# JWT
|
||||
jwt:
|
||||
|
||||
@@ -19,7 +19,7 @@ spec:
|
||||
- name: kt-event-marketing
|
||||
containers:
|
||||
- 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
|
||||
ports:
|
||||
- containerPort: 8083
|
||||
@@ -42,21 +42,21 @@ spec:
|
||||
memory: "1024Mi"
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/ai-service/actuator/health
|
||||
path: /api/v1/ai/actuator/health
|
||||
port: 8083
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/ai-service/actuator/health/readiness
|
||||
path: /api/v1/ai/actuator/health/readiness
|
||||
port: 8083
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/ai-service/actuator/health/liveness
|
||||
path: /api/v1/ai/actuator/health/liveness
|
||||
port: 8083
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
|
||||
@@ -20,7 +20,7 @@ data:
|
||||
EXCLUDE_REDIS: ""
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ALLOWED_ORIGINS: "http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io"
|
||||
CORS_ALLOWED_ORIGINS: "http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io,http://kt-event-marketing-api.20.214.196.128.nip.io,http://*.20.214.196.128.nip.io,https://kt-event-marketing.20.214.196.128.nip.io,https://kt-event-marketing-api.20.214.196.128.nip.io,https://*.20.214.196.128.nip.io"
|
||||
CORS_ALLOWED_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH"
|
||||
CORS_ALLOWED_HEADERS: "*"
|
||||
CORS_ALLOW_CREDENTIALS: "true"
|
||||
|
||||
@@ -56,7 +56,7 @@ spec:
|
||||
number: 80
|
||||
|
||||
# AI Service
|
||||
- path: /api/v1/ai-service
|
||||
- path: /api/v1/ai
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
|
||||
@@ -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="LOG_FILE" value="logs/distribution-service.log" />
|
||||
<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="SERVER_PORT" value="8085" />
|
||||
<entry key="URIDONGNETV_API_URL" value="http://localhost:9001/api/uridongnetv" />
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
# Multi-stage build for Spring Boot application
|
||||
FROM eclipse-temurin:21-jre-alpine AS builder
|
||||
FROM eclipse-temurin:21-jre AS builder
|
||||
WORKDIR /app
|
||||
COPY build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=layertools -jar app.jar extract
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -S spring && adduser -S spring -G spring
|
||||
USER spring:spring
|
||||
# Install Playwright essential dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
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 --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/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
|
||||
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
|
||||
|
||||
@@ -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-bulkhead:${resilience4jVersion}"
|
||||
|
||||
// Playwright for browser automation
|
||||
implementation 'com.microsoft.playwright:playwright:1.41.0'
|
||||
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
}
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
package com.kt.distribution.adapter;
|
||||
|
||||
import com.kt.distribution.client.NaverBlogClient;
|
||||
import com.kt.distribution.dto.ChannelDistributionResult;
|
||||
import com.kt.distribution.dto.ChannelType;
|
||||
import com.kt.distribution.dto.DistributionRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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 java.util.UUID;
|
||||
|
||||
/**
|
||||
* Naver Blog Adapter
|
||||
* Naver Blog 포스팅 API 호출
|
||||
* Naver Blog 포스팅 (Playwright 기반)
|
||||
*
|
||||
* @author System Architect
|
||||
* @since 2025-10-23
|
||||
* @author Backend Developer
|
||||
* @since 2025-10-29
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "naver.blog.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class NaverAdapter extends AbstractChannelAdapter {
|
||||
|
||||
@Value("${channel.apis.naver.url}")
|
||||
private String apiUrl;
|
||||
private final NaverBlogClient naverBlogClient;
|
||||
|
||||
@Override
|
||||
public ChannelType getChannelType() {
|
||||
@@ -30,16 +33,35 @@ public class NaverAdapter extends AbstractChannelAdapter {
|
||||
|
||||
@Override
|
||||
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)
|
||||
String distributionId = "NAVER-" + UUID.randomUUID().toString();
|
||||
try {
|
||||
// 네이버 블로그에 포스팅
|
||||
String postUrl = naverBlogClient.postToBlog(request);
|
||||
String distributionId = "NAVER-" + UUID.randomUUID().toString();
|
||||
|
||||
return ChannelDistributionResult.builder()
|
||||
.channel(ChannelType.NAVER)
|
||||
.success(true)
|
||||
.distributionId(distributionId)
|
||||
.estimatedReach(2000) // 블로그 방문자 수 기반
|
||||
.build();
|
||||
log.info("Naver blog post created successfully: eventId={}, postUrl={}",
|
||||
request.getEventId(), postUrl);
|
||||
|
||||
return ChannelDistributionResult.builder()
|
||||
.channel(ChannelType.NAVER)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public class OpenApiConfig {
|
||||
.email("support@kt-event-marketing.com")))
|
||||
.servers(List.of(
|
||||
new Server()
|
||||
.url("http://localhost:8085")
|
||||
.url("http://localhost:8085/api/v1/distribution")
|
||||
.description("Local Development Server"),
|
||||
new Server()
|
||||
.url("https://dev-api.kt-event-marketing.com/distribution/v1")
|
||||
@@ -48,7 +48,7 @@ public class OpenApiConfig {
|
||||
.url("https://api.kt-event-marketing.com/distribution/v1")
|
||||
.description("Production Server"),
|
||||
new Server()
|
||||
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1")
|
||||
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/distribution")
|
||||
.description("VM Development Server")
|
||||
));
|
||||
}
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Distribution Controller
|
||||
* POST api/v1/distribution/distribute - 다중 채널 배포 실행
|
||||
* GET api/v1/distribution/{eventId}/status - 배포 상태 조회
|
||||
* POST /distribute - 다중 채널 배포 실행
|
||||
* GET /{eventId}/status - 배포 상태 조회
|
||||
*
|
||||
* @author System Architect
|
||||
* @since 2025-10-23
|
||||
|
||||
+5
@@ -32,6 +32,11 @@ public class ChannelDistributionResult {
|
||||
*/
|
||||
private String distributionId;
|
||||
|
||||
/**
|
||||
* 배포 URL (성공 시) - 실제 포스팅된 URL
|
||||
*/
|
||||
private String postUrl;
|
||||
|
||||
/**
|
||||
* 예상 노출 수 (성공 시)
|
||||
*/
|
||||
|
||||
+1
@@ -225,6 +225,7 @@ public class DistributionService {
|
||||
.channel(result.getChannel())
|
||||
.status(result.isSuccess() ? "COMPLETED" : "FAILED")
|
||||
.distributionId(result.getDistributionId())
|
||||
.postUrl(result.getPostUrl())
|
||||
.estimatedViews(result.getEstimatedReach())
|
||||
.eventId(eventId)
|
||||
.completedAt(completedAt)
|
||||
|
||||
@@ -123,6 +123,16 @@ channel:
|
||||
url: ${KAKAO_API_URL:http://localhost:9006/api/kakao}
|
||||
timeout: 10000
|
||||
|
||||
# Naver Blog Configuration (Playwright 기반)
|
||||
naver:
|
||||
blog:
|
||||
enabled: ${NAVER_BLOG_ENABLED:false}
|
||||
username: ${NAVER_BLOG_USERNAME:}
|
||||
password: ${NAVER_BLOG_PASSWORD:}
|
||||
blog-id: ${NAVER_BLOG_ID:}
|
||||
headless: ${NAVER_BLOG_HEADLESS:false}
|
||||
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
|
||||
|
||||
# Springdoc OpenAPI (Swagger)
|
||||
springdoc:
|
||||
api-docs:
|
||||
|
||||
+29
@@ -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.repository.EventRepository;
|
||||
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.dto.AIRecommendationResponse;
|
||||
import com.kt.event.eventservice.infrastructure.client.dto.ContentImageGenerationRequest;
|
||||
import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse;
|
||||
import com.kt.event.eventservice.infrastructure.kafka.AIJobKafkaProducer;
|
||||
@@ -43,6 +45,7 @@ public class EventService {
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final JobRepository jobRepository;
|
||||
private final AIServiceClient aiServiceClient;
|
||||
private final ContentServiceClient contentServiceClient;
|
||||
private final AIJobKafkaProducer aiJobKafkaProducer;
|
||||
private final ImageJobKafkaProducer imageJobKafkaProducer;
|
||||
@@ -611,4 +614,30 @@ public class EventService {
|
||||
.updatedAt(event.getUpdatedAt())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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);
|
||||
}
|
||||
+123
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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.response.*;
|
||||
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 io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -287,6 +288,30 @@ public class EventController {
|
||||
.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)
|
||||
*
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
+21
-37
@@ -1,17 +1,13 @@
|
||||
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.Configuration;
|
||||
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.WebSecurityCustomizer;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
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
|
||||
@@ -24,43 +20,31 @@ import java.util.Arrays;
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Value("${cors.allowed-origins:http://localhost:*}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Actuator endpoints
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
// CSRF 비활성화 (REST API는 CSRF 불필요)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
// 세션 사용 안 함 (JWT 기반 인증)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
|
||||
// 모든 요청 허용 (테스트용)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome DevTools 요청 등 정적 리소스 요청을 Spring Security에서 제외
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
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;
|
||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||
return (web) -> web.ignoring()
|
||||
.requestMatchers("/.well-known/**");
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
-104
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -25,9 +25,8 @@ import org.springframework.web.bind.annotation.*;
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Slf4j
|
||||
@CrossOrigin(origins = "http://localhost:3000")
|
||||
@RequestMapping
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class ParticipationController {
|
||||
|
||||
@@ -68,7 +67,7 @@ public class ParticipationController {
|
||||
description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
|
||||
"정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
|
||||
)
|
||||
@GetMapping("/events/{eventId}/participants")
|
||||
@GetMapping({"/events/{eventId}/participants"})
|
||||
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
|
||||
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
||||
@PathVariable String eventId,
|
||||
@@ -91,7 +90,7 @@ public class ParticipationController {
|
||||
* 참여자 상세 조회
|
||||
* GET /events/{eventId}/participants/{participantId}
|
||||
*/
|
||||
@GetMapping("/events/{eventId}/participants/{participantId}")
|
||||
@GetMapping({"/events/{eventId}/participants/{participantId}"})
|
||||
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String participantId) {
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
@Slf4j
|
||||
@CrossOrigin(origins = "http://localhost:3000")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@RequestMapping
|
||||
@RequiredArgsConstructor
|
||||
public class WinnerController {
|
||||
|
||||
@@ -50,7 +50,7 @@ public class WinnerController {
|
||||
|
||||
/**
|
||||
* 당첨자 목록 조회
|
||||
* GET /events/{eventId}/winners
|
||||
* GET /participations/{eventId}/winners
|
||||
*/
|
||||
@Operation(
|
||||
summary = "당첨자 목록 조회",
|
||||
|
||||
@@ -98,4 +98,14 @@ management:
|
||||
livenessState:
|
||||
enabled: true
|
||||
readinessState:
|
||||
enabled: true
|
||||
enabled: true
|
||||
|
||||
# OpenAPI Documentation
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
tags-sorter: alpha
|
||||
operations-sorter: alpha
|
||||
show-actuator: false
|
||||
@@ -0,0 +1,33 @@
|
||||
# Analytics Redis 초기화 스크립트
|
||||
|
||||
Write-Host "Analytics Redis 초기화 시작..." -ForegroundColor Cyan
|
||||
|
||||
# Redis 컨테이너 찾기
|
||||
$redisContainer = docker ps --filter "ancestor=redis" --format "{{.Names}}" | Select-Object -First 1
|
||||
|
||||
if ($redisContainer) {
|
||||
Write-Host "Redis 컨테이너 발견: $redisContainer" -ForegroundColor Green
|
||||
|
||||
# 멱등성 키 삭제
|
||||
Write-Host "멱등성 키 삭제 중..." -ForegroundColor Yellow
|
||||
docker exec $redisContainer redis-cli DEL processed_participants
|
||||
docker exec $redisContainer redis-cli DEL processed_events
|
||||
docker exec $redisContainer redis-cli DEL distribution_completed
|
||||
|
||||
# 캐시 삭제
|
||||
Write-Host "Analytics 캐시 삭제 중..." -ForegroundColor Yellow
|
||||
docker exec $redisContainer redis-cli --scan --pattern "analytics:*" | ForEach-Object {
|
||||
docker exec $redisContainer redis-cli DEL $_
|
||||
}
|
||||
|
||||
Write-Host "완료! 서버를 재시작해주세요." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Redis 컨테이너를 찾을 수 없습니다." -ForegroundColor Red
|
||||
Write-Host "로컬 Redis를 시도합니다..." -ForegroundColor Yellow
|
||||
|
||||
redis-cli DEL processed_participants
|
||||
redis-cli DEL processed_events
|
||||
redis-cli DEL distribution_completed
|
||||
|
||||
Write-Host "완료! 서버를 재시작해주세요." -ForegroundColor Green
|
||||
}
|
||||
@@ -12,6 +12,10 @@ dependencies {
|
||||
// OpenFeign for external API calls (사업자번호 검증)
|
||||
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
|
||||
|
||||
// Flyway for database migration
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||
|
||||
// H2 Database for development
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
|
||||
|
||||
@@ -38,6 +38,18 @@ public class SecurityConfig {
|
||||
@Value("${cors.allowed-origins:http://localhost:*}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Value("${cors.allowed-methods:GET,POST,PUT,DELETE,OPTIONS,PATCH}")
|
||||
private String allowedMethods;
|
||||
|
||||
@Value("${cors.allowed-headers:*}")
|
||||
private String allowedHeaders;
|
||||
|
||||
@Value("${cors.allow-credentials:true}")
|
||||
private boolean allowCredentials;
|
||||
|
||||
@Value("${cors.max-age:3600}")
|
||||
private long maxAge;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
@@ -45,8 +57,8 @@ public class SecurityConfig {
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Public endpoints
|
||||
.requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll()
|
||||
// Public endpoints (context-path가 /api/v1/users이므로 상대 경로 사용)
|
||||
.requestMatchers("/register", "/login").permitAll()
|
||||
// Actuator endpoints
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
// Swagger UI endpoints
|
||||
@@ -65,24 +77,23 @@ public class SecurityConfig {
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// 환경변수에서 허용할 Origin 패턴 설정
|
||||
String[] origins = allowedOrigins.split(",");
|
||||
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
|
||||
// application.yml에서 설정한 Origin 목록 사용
|
||||
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||
|
||||
// 허용할 HTTP 메소드
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
configuration.setAllowedMethods(Arrays.asList(allowedMethods.split(",")));
|
||||
|
||||
// 허용할 헤더
|
||||
configuration.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization", "Content-Type", "X-Requested-With", "Accept",
|
||||
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
|
||||
));
|
||||
configuration.setAllowedHeaders(Arrays.asList(allowedHeaders.split(",")));
|
||||
|
||||
// 자격 증명 허용
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setAllowCredentials(allowCredentials);
|
||||
|
||||
// Pre-flight 요청 캐시 시간
|
||||
configuration.setMaxAge(3600L);
|
||||
configuration.setMaxAge(maxAge);
|
||||
|
||||
// Exposed Headers 추가
|
||||
configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Total-Count"));
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
|
||||
@@ -26,10 +26,13 @@ public class SwaggerConfig {
|
||||
return new OpenAPI()
|
||||
.info(apiInfo())
|
||||
.addServersItem(new Server()
|
||||
.url("http://localhost:8081")
|
||||
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/users")
|
||||
.description("Production Server (AKS Ingress)"))
|
||||
.addServersItem(new Server()
|
||||
.url("http://localhost:8081/api/v1/users")
|
||||
.description("Local Development"))
|
||||
.addServersItem(new Server()
|
||||
.url("{protocol}://{host}:{port}")
|
||||
.url("{protocol}://{host}:{port}/api/v1/users")
|
||||
.description("Custom Server")
|
||||
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
|
||||
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||
|
||||
@@ -33,7 +33,7 @@ import java.util.UUID;
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequestMapping("") // context-path가 /api/v1/users이므로 빈 문자열 사용
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "User", description = "사용자 인증 및 프로필 관리 API")
|
||||
public class UserController {
|
||||
|
||||
@@ -31,7 +31,13 @@ spring:
|
||||
use_sql_comments: true
|
||||
dialect: ${JPA_DIALECT:org.hibernate.dialect.PostgreSQLDialect}
|
||||
hibernate:
|
||||
ddl-auto: ${DDL_AUTO:update}
|
||||
ddl-auto: ${DDL_AUTO:validate}
|
||||
|
||||
# Flyway Configuration
|
||||
flyway:
|
||||
enabled: ${FLYWAY_ENABLED:true}
|
||||
baseline-on-migrate: ${FLYWAY_BASELINE:true}
|
||||
locations: classpath:db/migration
|
||||
|
||||
# Auto-configuration exclusions for development without external services
|
||||
autoconfigure:
|
||||
@@ -76,7 +82,7 @@ jwt:
|
||||
|
||||
# CORS Configuration
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io}
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io,http://kt-event-marketing-api.20.214.196.128.nip.io,http://*.kt-event-marketing-api.20.214.196.128.nip.io,http://*.20.214.196.128.nip.io}
|
||||
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
|
||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Migration script to change user_id from BIGINT to UUID
|
||||
-- WARNING: This will delete all existing data in users and stores tables
|
||||
-- Make sure to backup your data before running this script!
|
||||
|
||||
-- Step 1: Drop dependent tables/constraints
|
||||
DROP TABLE IF EXISTS stores CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
|
||||
-- Step 2: Create users table with UUID
|
||||
CREATE TABLE users (
|
||||
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
phone_number VARCHAR(20) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'OWNER',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Step 3: Create indexes on users table
|
||||
CREATE UNIQUE INDEX idx_user_phone ON users(phone_number);
|
||||
CREATE UNIQUE INDEX idx_user_email ON users(email);
|
||||
|
||||
-- Step 4: Create stores table with UUID foreign key
|
||||
CREATE TABLE stores (
|
||||
store_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
industry VARCHAR(50),
|
||||
address VARCHAR(255) NOT NULL,
|
||||
business_hours VARCHAR(255),
|
||||
user_id UUID NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_stores_user FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Step 5: Create index on stores table
|
||||
CREATE INDEX idx_stores_user ON stores(user_id);
|
||||
|
||||
-- Enable UUID extension if not already enabled
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Migration script V002: Change user_id and store_id from BIGINT to UUID
|
||||
-- WARNING: This will delete all existing data in users and stores tables
|
||||
-- Make sure to backup your data before running this script!
|
||||
|
||||
-- Step 1: Drop dependent tables/constraints
|
||||
DROP TABLE IF EXISTS stores CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
|
||||
-- Step 2: Create users table with UUID
|
||||
CREATE TABLE users (
|
||||
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
phone_number VARCHAR(20) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'OWNER',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Step 3: Create indexes on users table
|
||||
CREATE UNIQUE INDEX idx_user_phone ON users(phone_number);
|
||||
CREATE UNIQUE INDEX idx_user_email ON users(email);
|
||||
|
||||
-- Step 4: Create stores table with UUID foreign key
|
||||
CREATE TABLE stores (
|
||||
store_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
industry VARCHAR(50),
|
||||
address VARCHAR(255) NOT NULL,
|
||||
business_hours VARCHAR(255),
|
||||
user_id UUID NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_stores_user FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Step 5: Create index on stores table
|
||||
CREATE INDEX idx_stores_user ON stores(user_id);
|
||||
|
||||
-- Enable UUID extension if not already enabled
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
Reference in New Issue
Block a user