diff --git a/.claude/commands/design-api.md b/.claude/commands/design-api.md
index 5375bf7..750eae3 100644
--- a/.claude/commands/design-api.md
+++ b/.claude/commands/design-api.md
@@ -1,3 +1,6 @@
+---
+command: "/design-api"
+---
@architecture
API를 설계해 주세요:
-- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
+- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
\ No newline at end of file
diff --git a/.claude/commands/design-class.md b/.claude/commands/design-class.md
index dc76da9..178bdb1 100644
--- a/.claude/commands/design-class.md
+++ b/.claude/commands/design-class.md
@@ -1,3 +1,6 @@
+---
+command: "/design-class"
+---
@architecture
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
@@ -9,4 +12,4 @@
- User: Layered
- Trip: Clean
- Location: Layered
- - AI: Layered
+ - AI: Layered
\ No newline at end of file
diff --git a/.claude/commands/design-data.md b/.claude/commands/design-data.md
index 8d9fd77..b5ff1dd 100644
--- a/.claude/commands/design-data.md
+++ b/.claude/commands/design-data.md
@@ -1,3 +1,6 @@
+---
+command: "/design-data"
+---
@architecture
데이터 설계를 해주세요:
-- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
+- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
\ No newline at end of file
diff --git a/.claude/commands/design-fix-prototype.md b/.claude/commands/design-fix-prototype.md
index d1ddb8a..5cc1890 100644
--- a/.claude/commands/design-fix-prototype.md
+++ b/.claude/commands/design-fix-prototype.md
@@ -1,5 +1,8 @@
+---
+command: "/design-fix-prototype"
+---
@fix as @front
'[오류내용]'섹션에 제공된 오류를 해결해 주세요.
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
{안내메시지}
-'[오류내용]'섹션 하위에 오류 내용을 제공
+'[오류내용]'섹션 하위에 오류 내용을 제공
\ No newline at end of file
diff --git a/.claude/commands/design-front.md b/.claude/commands/design-front.md
index 67bc0a5..8dd99c9 100644
--- a/.claude/commands/design-front.md
+++ b/.claude/commands/design-front.md
@@ -1,3 +1,6 @@
+---
+command: "/design-front"
+---
@plan as @front
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
@@ -13,4 +16,4 @@
- ai service: http://localhost:8084/v3/api-docs
[요구사항]
- 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시
-- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
+- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
\ No newline at end of file
diff --git a/.claude/commands/design-high-level.md b/.claude/commands/design-high-level.md
index d7028b1..0debc5e 100644
--- a/.claude/commands/design-high-level.md
+++ b/.claude/commands/design-high-level.md
@@ -1,6 +1,9 @@
+---
+command: "/design-high-level"
+---
@architecture
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
{안내메시지}
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
-- CLOUD: Azure
+- CLOUD: Azure
\ No newline at end of file
diff --git a/.claude/commands/design-improve-prototype.md b/.claude/commands/design-improve-prototype.md
index 0d1b31b..22bc079 100644
--- a/.claude/commands/design-improve-prototype.md
+++ b/.claude/commands/design-improve-prototype.md
@@ -1,5 +1,8 @@
+---
+command: "/design-improve-prototype"
+---
@improve as @front
'[개선내용]'섹션에 있는 내용을 개선해 주세요.
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
{안내메시지}
-'[개선내용]'섹션 하위에 개선할 내용을 제공
+'[개선내용]'섹션 하위에 개선할 내용을 제공
\ No newline at end of file
diff --git a/.claude/commands/design-improve-userstory.md b/.claude/commands/design-improve-userstory.md
index a1055f2..73fd453 100644
--- a/.claude/commands/design-improve-userstory.md
+++ b/.claude/commands/design-improve-userstory.md
@@ -1,2 +1,5 @@
+---
+command: "/design-improve-userstory"
+---
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
-@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
+@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
\ No newline at end of file
diff --git a/.claude/commands/design-logical.md b/.claude/commands/design-logical.md
index 28f15e9..3d50c8f 100644
--- a/.claude/commands/design-logical.md
+++ b/.claude/commands/design-logical.md
@@ -1,3 +1,6 @@
+---
+command: "/design-logical"
+---
@architecture
논리 아키텍처를 설계해 주세요:
-- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
+- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
\ No newline at end of file
diff --git a/.claude/commands/design-pattern.md b/.claude/commands/design-pattern.md
index 06ed88d..decb145 100644
--- a/.claude/commands/design-pattern.md
+++ b/.claude/commands/design-pattern.md
@@ -1,3 +1,6 @@
+---
+command: "/design-pattern"
+---
@design-pattern
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
-- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
+- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
\ No newline at end of file
diff --git a/.claude/commands/design-physical.md b/.claude/commands/design-physical.md
index 2dc8a51..7df5bca 100644
--- a/.claude/commands/design-physical.md
+++ b/.claude/commands/design-physical.md
@@ -1,6 +1,9 @@
+---
+command: "/design-physical"
+---
@architecture
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
{안내메시지}
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
-- CLOUD: Azure
+- CLOUD: Azure
\ No newline at end of file
diff --git a/.claude/commands/design-prototype.md b/.claude/commands/design-prototype.md
index f43547f..dbd24a0 100644
--- a/.claude/commands/design-prototype.md
+++ b/.claude/commands/design-prototype.md
@@ -1,3 +1,6 @@
+---
+command: "/design-prototype"
+---
@prototype
프로토타입을 작성해 주세요:
-- '프로토타입작성가이드'를 준용하여 작성
+- '프로토타입작성가이드'를 준용하여 작성
\ No newline at end of file
diff --git a/.claude/commands/design-seq-inner.md b/.claude/commands/design-seq-inner.md
index 5583610..d2bc4ac 100644
--- a/.claude/commands/design-seq-inner.md
+++ b/.claude/commands/design-seq-inner.md
@@ -1,3 +1,6 @@
+---
+command: "/design-seq-inner"
+---
@architecture
내부 시퀀스 설계를 해 주세요:
-- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
+- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
\ No newline at end of file
diff --git a/.claude/commands/design-seq-outer.md b/.claude/commands/design-seq-outer.md
index 0546370..8e05435 100644
--- a/.claude/commands/design-seq-outer.md
+++ b/.claude/commands/design-seq-outer.md
@@ -1,3 +1,6 @@
+---
+command: "/design-seq-outer"
+---
@architecture
외부 시퀀스 설계를 해 주세요:
-- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
+- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
\ No newline at end of file
diff --git a/.claude/commands/design-test-prototype.md b/.claude/commands/design-test-prototype.md
index bd45346..350788a 100644
--- a/.claude/commands/design-test-prototype.md
+++ b/.claude/commands/design-test-prototype.md
@@ -1,2 +1,5 @@
+---
+command: "/design-test-prototype"
+---
@test-front
-프로토타입을 테스트 해 주세요.
+프로토타입을 테스트 해 주세요.
\ No newline at end of file
diff --git a/.claude/commands/design-uiux.md b/.claude/commands/design-uiux.md
index 2b1c387..d68d857 100644
--- a/.claude/commands/design-uiux.md
+++ b/.claude/commands/design-uiux.md
@@ -1,3 +1,6 @@
+---
+command: "/design-uiux"
+---
@uiux
UI/UX 설계를 해주세요:
-- 'UI/UX설계가이드'를 준용하여 작성
+- 'UI/UX설계가이드'를 준용하여 작성
\ No newline at end of file
diff --git a/.claude/commands/design-update-uiux.md b/.claude/commands/design-update-uiux.md
index 6994cd9..afd7cf9 100644
--- a/.claude/commands/design-update-uiux.md
+++ b/.claude/commands/design-update-uiux.md
@@ -1,2 +1,5 @@
+---
+command: "/design-update-uiux"
+---
@document @front
-현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
+현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
\ No newline at end of file
diff --git a/.claude/commands/think-help.md b/.claude/commands/think-help.md
index 49bc697..17ad05a 100644
--- a/.claude/commands/think-help.md
+++ b/.claude/commands/think-help.md
@@ -1,3 +1,6 @@
+---
+command: "/think-help"
+---
기획 작업 순서
1단계: 서비스 기획
diff --git a/.claude/commands/think-planning.md b/.claude/commands/think-planning.md
index c40eaec..beec938 100644
--- a/.claude/commands/think-planning.md
+++ b/.claude/commands/think-planning.md
@@ -1,3 +1,6 @@
+---
+command: "/think-planning"
+---
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
```
아래 가이드를 참고하여 서비스 기획을 수행합니다.
diff --git a/.claude/commands/think-userstory.md b/.claude/commands/think-userstory.md
index abdcb97..a002c30 100644
--- a/.claude/commands/think-userstory.md
+++ b/.claude/commands/think-userstory.md
@@ -1,3 +1,7 @@
+---
+command: "/think-userstory"
+---
+```
@document
유저스토리를 작성하세요.
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
@@ -16,3 +20,5 @@ Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을
2. 유저스토리 작성
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
- 결과파일은 'design/userstory.md'에 생성
+
+```
diff --git a/.gitignore b/.gitignore
index 74a08c5..9f987d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,3 +61,5 @@ k8s/**/*-local.yaml
# Gradle (로컬 환경 설정)
gradle.properties
+*.hprof
+test-data.json
diff --git a/.run/EventServiceApplication.run.xml b/.run/EventServiceApplication.run.xml
deleted file mode 100644
index 38d1691..0000000
--- a/.run/EventServiceApplication.run.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml
index a323100..8102290 100644
--- a/.run/ParticipationServiceApplication.run.xml
+++ b/.run/ParticipationServiceApplication.run.xml
@@ -43,7 +43,7 @@
diff --git a/.run/analytics-service.run.xml b/.run/analytics-service.run.xml
index ade144d..15941a1 100644
--- a/.run/analytics-service.run.xml
+++ b/.run/analytics-service.run.xml
@@ -3,7 +3,7 @@
diff --git a/ai-service/src/main/resources/application.yml b/ai-service/src/main/resources/application.yml
index d9b615a..0da6277 100644
--- a/ai-service/src/main/resources/application.yml
+++ b/ai-service/src/main/resources/application.yml
@@ -5,11 +5,11 @@ spring:
# Redis Configuration
data:
redis:
- host: ${REDIS_HOST:redis-external} # Production: redis-external, Local: 20.214.210.71
- port: ${REDIS_PORT:6379}
- password: ${REDIS_PASSWORD:}
- database: ${REDIS_DATABASE:0} # AI Service uses database 3
- timeout: ${REDIS_TIMEOUT:3000}
+ host: 20.214.210.71
+ port: 6379
+ password: Hi5Jessica!
+ database: 3
+ timeout: 3000
lettuce:
pool:
max-active: 8
@@ -19,7 +19,7 @@ spring:
# Kafka Consumer Configuration
kafka:
- bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ bootstrap-servers: 4.230.50.63:9092
consumer:
group-id: ai-service-consumers
auto-offset-reset: earliest
@@ -28,14 +28,14 @@ spring:
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "*"
- max.poll.records: ${KAFKA_MAX_POLL_RECORDS:10}
- session.timeout.ms: ${KAFKA_SESSION_TIMEOUT:30000}
+ max.poll.records: 10
+ session.timeout.ms: 30000
listener:
ack-mode: manual
# Server Configuration
server:
- port: ${SERVER_PORT:8083}
+ port: 8083
servlet:
context-path: /
encoding:
@@ -45,17 +45,17 @@ server:
# JWT Configuration
jwt:
- secret: ${JWT_SECRET:}
- access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
- refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
+ secret: kt-event-marketing-secret-key-for-development-only-please-change-in-production
+ access-token-validity: 604800000
+ refresh-token-validity: 86400
# CORS Configuration
cors:
- allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
- allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
- allowed-headers: ${CORS_ALLOWED_HEADERS:*}
- allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
- max-age: ${CORS_MAX_AGE:3600}
+ allowed-origins: http://localhost:*
+ allowed-methods: GET,POST,PUT,DELETE,OPTIONS,PATCH
+ allowed-headers: "*"
+ allow-credentials: true
+ max-age: 3600
# Actuator Configuration
management:
@@ -100,7 +100,7 @@ logging:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
- name: ${LOG_FILE:logs/ai-service.log}
+ name: logs/ai-service.log
logback:
rollingpolicy:
max-file-size: 10MB
@@ -110,26 +110,20 @@ logging:
# Kafka Topics Configuration
kafka:
topics:
- ai-job: ${KAFKA_TOPIC_AI_JOB:ai-event-generation-job}
- ai-job-dlq: ${KAFKA_TOPIC_AI_JOB_DLQ:ai-event-generation-job-dlq}
+ ai-job: ai-event-generation-job
+ ai-job-dlq: ai-event-generation-job-dlq
-# AI External API Configuration
+# AI API Configuration (실제 API 사용)
ai:
+ provider: CLAUDE
claude:
- api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
- api-key: ${CLAUDE_API_KEY:}
- anthropic-version: ${CLAUDE_ANTHROPIC_VERSION:2023-06-01}
- model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
- max-tokens: ${CLAUDE_MAX_TOKENS:4096}
- temperature: ${CLAUDE_TEMPERATURE:0.7}
- timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes
- gpt4:
- api-url: ${GPT4_API_URL:https://api.openai.com/v1/chat/completions}
- api-key: ${GPT4_API_KEY:}
- model: ${GPT4_MODEL:gpt-4-turbo-preview}
- max-tokens: ${GPT4_MAX_TOKENS:4096}
- timeout: ${GPT4_TIMEOUT:300000} # 5 minutes
- provider: ${AI_PROVIDER:CLAUDE} # CLAUDE or GPT4
+ api-url: https://api.anthropic.com/v1/messages
+ api-key: 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
+ anthropic-version: 2023-06-01
+ model: claude-sonnet-4-5-20250929
+ max-tokens: 4096
+ temperature: 0.7
+ timeout: 300000
# Circuit Breaker Configuration
resilience4j:
@@ -168,7 +162,7 @@ resilience4j:
# Redis Cache TTL Configuration (seconds)
cache:
ttl:
- recommendation: ${CACHE_TTL_RECOMMENDATION:86400} # 24 hours
- job-status: ${CACHE_TTL_JOB_STATUS:86400} # 24 hours
- trend: ${CACHE_TTL_TREND:3600} # 1 hour
- fallback: ${CACHE_TTL_FALLBACK:604800} # 7 days
+ recommendation: 86400 # 24 hours
+ job-status: 86400 # 24 hours
+ trend: 3600 # 1 hour
+ fallback: 604800 # 7 days
diff --git a/analytics-service/.run/analytics-service.run.xml b/analytics-service/.run/analytics-service.run.xml
index 44dfb98..15941a1 100644
--- a/analytics-service/.run/analytics-service.run.xml
+++ b/analytics-service/.run/analytics-service.run.xml
@@ -12,7 +12,7 @@
-
+
diff --git a/analytics-service/frontend-backend-validation.md b/analytics-service/frontend-backend-validation.md
new file mode 100644
index 0000000..8f36b9a
--- /dev/null
+++ b/analytics-service/frontend-backend-validation.md
@@ -0,0 +1,108 @@
+# 백엔드-프론트엔드 API 연동 검증 및 수정 결과
+
+**작업일시**: 2025-10-28
+**브랜치**: feature/analytics
+**작업 범위**: Analytics Service 백엔드 DTO 및 Service 수정
+
+---
+
+## 📝 수정 요약
+
+### 1️⃣ 필드명 통일 (프론트엔드 호환)
+
+**목적**: 프론트엔드 Mock 데이터 필드명과 백엔드 Response DTO 필드명 일치
+
+| 수정 전 (백엔드) | 수정 후 (백엔드) | 프론트엔드 |
+|-----------------|----------------|-----------|
+| `summary.totalParticipants` | `summary.participants` | `summary.participants` ✅ |
+| `channelPerformance[].channelName` | `channelPerformance[].channel` | `channelPerformance[].channel` ✅ |
+| `roi.totalInvestment` | `roi.totalCost` | `roiDetail.totalCost` ✅ |
+
+### 2️⃣ 증감 데이터 추가
+
+**목적**: 프론트엔드에서 요구하는 증감 표시 및 목표값 제공
+
+| 필드 | 타입 | 설명 | 현재 값 |
+|-----|------|------|---------|
+| `summary.participantsDelta` | `Integer` | 참여자 증감 (이전 기간 대비) | `0` (TODO: 계산 로직 필요) |
+| `summary.targetRoi` | `Double` | 목표 ROI (%) | EventStats에서 가져옴 |
+
+---
+
+## 🔧 수정 파일 목록
+
+### DTO (Response 구조 변경)
+
+1. **AnalyticsSummary.java**
+ - ✅ `totalParticipants` → `participants`
+ - ✅ `participantsDelta` 필드 추가
+ - ✅ `targetRoi` 필드 추가
+
+2. **ChannelSummary.java**
+ - ✅ `channelName` → `channel`
+
+3. **RoiSummary.java**
+ - ✅ `totalInvestment` → `totalCost`
+
+### Entity (데이터베이스 스키마 변경)
+
+4. **EventStats.java**
+ - ✅ `targetRoi` 필드 추가 (`BigDecimal`, default: 0)
+
+### Service (비즈니스 로직 수정)
+
+5. **AnalyticsService.java**
+ - ✅ `.participants()` 사용
+ - ✅ `.participantsDelta(0)` 추가 (TODO 마킹)
+ - ✅ `.targetRoi()` 추가
+ - ✅ `.channel()` 사용
+
+6. **ROICalculator.java**
+ - ✅ `.totalCost()` 사용
+
+7. **UserAnalyticsService.java**
+ - ✅ `.participants()` 사용
+ - ✅ `.participantsDelta(0)` 추가
+ - ✅ `.channel()` 사용
+ - ✅ `.totalCost()` 사용
+
+---
+
+## ✅ 검증 결과
+
+### 컴파일 성공
+\`\`\`bash
+$ ./gradlew analytics-service:compileJava
+
+BUILD SUCCESSFUL in 8s
+\`\`\`
+
+---
+
+## 📊 데이터베이스 스키마 변경
+
+### EventStats 테이블
+
+\`\`\`sql
+ALTER TABLE event_stats
+ADD COLUMN target_roi DECIMAL(10,2) DEFAULT 0.00;
+\`\`\`
+
+**⚠️ 주의사항**
+- Spring Boot JPA `ddl-auto` 설정에 따라 자동 적용됨
+
+---
+
+## 📌 다음 단계
+
+### 우선순위 HIGH
+
+1. **프론트엔드 API 연동 테스트**
+2. **participantsDelta 계산 로직 구현**
+3. **targetRoi 데이터 입력** (Event Service 연동)
+
+### 우선순위 MEDIUM
+
+4. 시간대별 분석 구현
+5. 참여자 프로필 구현
+6. ROI 세분화 구현
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java
new file mode 100644
index 0000000..1822fde
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java
@@ -0,0 +1,71 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.dto.response.UserAnalyticsDashboardResponse;
+import com.kt.event.analytics.service.UserAnalyticsService;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * User Analytics Dashboard Controller
+ *
+ * 사용자 전체 이벤트 통합 성과 대시보드 API
+ */
+@Tag(name = "User Analytics", description = "사용자 전체 이벤트 통합 성과 분석 API")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/users")
+@RequiredArgsConstructor
+public class UserAnalyticsDashboardController {
+
+ private final UserAnalyticsService userAnalyticsService;
+
+ /**
+ * 사용자 전체 성과 대시보드 조회
+ *
+ * @param userId 사용자 ID
+ * @param startDate 조회 시작 날짜
+ * @param endDate 조회 종료 날짜
+ * @param refresh 캐시 갱신 여부
+ * @return 전체 통합 성과 대시보드
+ */
+ @Operation(
+ summary = "사용자 전체 성과 대시보드 조회",
+ description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다."
+ )
+ @GetMapping("/{userId}/analytics")
+ public ResponseEntity> 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
+ ) {
+ log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
+
+ UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
+ userId, startDate, endDate, refresh
+ );
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java
new file mode 100644
index 0000000..2b68cb6
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java
@@ -0,0 +1,78 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.dto.response.UserChannelAnalyticsResponse;
+import com.kt.event.analytics.service.UserChannelAnalyticsService;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * User Channel Analytics Controller
+ */
+@Tag(name = "User Channels", description = "사용자 전체 이벤트 채널별 성과 분석 API")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/users")
+@RequiredArgsConstructor
+public class UserChannelAnalyticsController {
+
+ private final UserChannelAnalyticsService userChannelAnalyticsService;
+
+ @Operation(
+ summary = "사용자 전체 채널별 성과 분석",
+ description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
+ )
+ @GetMapping("/{userId}/analytics/channels")
+ public ResponseEntity> 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,
+
+ @Parameter(description = "정렬 순서")
+ @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 channelList = channels != null && !channels.isBlank()
+ ? Arrays.asList(channels.split(","))
+ : null;
+
+ UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
+ userId, channelList, sortBy, order, startDate, endDate, refresh
+ );
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java
new file mode 100644
index 0000000..58a098f
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java
@@ -0,0 +1,64 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.dto.response.UserRoiAnalyticsResponse;
+import com.kt.event.analytics.service.UserRoiAnalyticsService;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * User ROI Analytics Controller
+ */
+@Tag(name = "User ROI", description = "사용자 전체 이벤트 ROI 분석 API")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/users")
+@RequiredArgsConstructor
+public class UserRoiAnalyticsController {
+
+ private final UserRoiAnalyticsService userRoiAnalyticsService;
+
+ @Operation(
+ summary = "사용자 전체 ROI 상세 분석",
+ description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
+ )
+ @GetMapping("/{userId}/analytics/roi")
+ public ResponseEntity> getUserRoiAnalytics(
+ @Parameter(description = "사용자 ID", required = true)
+ @PathVariable String userId,
+
+ @Parameter(description = "예상 수익 포함 여부")
+ @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
+ ) {
+ log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
+
+ UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
+ userId, includeProjection, startDate, endDate, refresh
+ );
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java
new file mode 100644
index 0000000..40fe700
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java
@@ -0,0 +1,74 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.dto.response.UserTimelineAnalyticsResponse;
+import com.kt.event.analytics.service.UserTimelineAnalyticsService;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * User Timeline Analytics Controller
+ */
+@Tag(name = "User Timeline", description = "사용자 전체 이벤트 시간대별 분석 API")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/users")
+@RequiredArgsConstructor
+public class UserTimelineAnalyticsController {
+
+ private final UserTimelineAnalyticsService userTimelineAnalyticsService;
+
+ @Operation(
+ summary = "사용자 전체 시간대별 참여 추이",
+ description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
+ )
+ @GetMapping("/{userId}/analytics/timeline")
+ public ResponseEntity> getUserTimelineAnalytics(
+ @Parameter(description = "사용자 ID", required = true)
+ @PathVariable String userId,
+
+ @Parameter(description = "시간 간격 단위 (hourly, daily, weekly, monthly)")
+ @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,
+
+ @Parameter(description = "캐시 갱신 여부")
+ @RequestParam(required = false, defaultValue = "false")
+ Boolean refresh
+ ) {
+ log.info("사용자 타임라인 분석 API 호출: userId={}, interval={}", userId, interval);
+
+ List metricList = metrics != null && !metrics.isBlank()
+ ? Arrays.asList(metrics.split(","))
+ : null;
+
+ UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
+ userId, interval, startDate, endDate, metricList, refresh
+ );
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java
index e4fb561..2aafc74 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java
@@ -17,7 +17,12 @@ public class AnalyticsSummary {
/**
* 총 참여자 수
*/
- private Integer totalParticipants;
+ private Integer participants;
+
+ /**
+ * 참여자 증감 (이전 기간 대비)
+ */
+ private Integer participantsDelta;
/**
* 총 조회수
@@ -44,6 +49,11 @@ public class AnalyticsSummary {
*/
private Integer averageEngagementTime;
+ /**
+ * 목표 ROI (%)
+ */
+ private Double targetRoi;
+
/**
* SNS 반응 통계
*/
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java
index 49e99da..65abb37 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java
@@ -17,7 +17,7 @@ public class ChannelSummary {
/**
* 채널명
*/
- private String channelName;
+ private String channel;
/**
* 조회수
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiSummary.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiSummary.java
index ae2e504..9a995f3 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiSummary.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiSummary.java
@@ -19,7 +19,7 @@ public class RoiSummary {
/**
* 총 투자 비용 (원)
*/
- private BigDecimal totalInvestment;
+ private BigDecimal totalCost;
/**
* 예상 매출 증대 (원)
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserAnalyticsDashboardResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserAnalyticsDashboardResponse.java
new file mode 100644
index 0000000..ebe2f82
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserAnalyticsDashboardResponse.java
@@ -0,0 +1,87 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 사용자 전체 이벤트 통합 대시보드 응답
+ *
+ * 사용자 ID 기반으로 모든 이벤트의 성과를 통합하여 제공
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserAnalyticsDashboardResponse {
+
+ /**
+ * 사용자 ID
+ */
+ private String userId;
+
+ /**
+ * 조회 기간 정보
+ */
+ private PeriodInfo period;
+
+ /**
+ * 전체 이벤트 수
+ */
+ private Integer totalEvents;
+
+ /**
+ * 활성 이벤트 수
+ */
+ private Integer activeEvents;
+
+ /**
+ * 전체 성과 요약 (모든 이벤트 통합)
+ */
+ private AnalyticsSummary overallSummary;
+
+ /**
+ * 채널별 성과 요약 (모든 이벤트 통합)
+ */
+ private List channelPerformance;
+
+ /**
+ * 전체 ROI 요약
+ */
+ private RoiSummary overallRoi;
+
+ /**
+ * 이벤트별 성과 목록 (간략)
+ */
+ private List eventPerformances;
+
+ /**
+ * 마지막 업데이트 시간
+ */
+ private LocalDateTime lastUpdatedAt;
+
+ /**
+ * 데이터 출처 (real-time, cached, fallback)
+ */
+ private String dataSource;
+
+ /**
+ * 이벤트별 성과 요약
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class EventPerformanceSummary {
+ private String eventId;
+ private String eventTitle;
+ private Integer participants;
+ private Integer views;
+ private Double roi;
+ private String status;
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserChannelAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserChannelAnalyticsResponse.java
new file mode 100644
index 0000000..f20e5d8
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserChannelAnalyticsResponse.java
@@ -0,0 +1,56 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 사용자 전체 이벤트의 채널별 성과 분석 응답
+ *
+ * 사용자 ID 기반으로 모든 이벤트의 채널 성과를 통합하여 제공
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserChannelAnalyticsResponse {
+
+ /**
+ * 사용자 ID
+ */
+ private String userId;
+
+ /**
+ * 조회 기간 정보
+ */
+ private PeriodInfo period;
+
+ /**
+ * 전체 이벤트 수
+ */
+ private Integer totalEvents;
+
+ /**
+ * 채널별 통합 성과 목록
+ */
+ private List channels;
+
+ /**
+ * 채널 간 비교 분석
+ */
+ private ChannelComparison comparison;
+
+ /**
+ * 마지막 업데이트 시간
+ */
+ private LocalDateTime lastUpdatedAt;
+
+ /**
+ * 데이터 출처
+ */
+ private String dataSource;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserRoiAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserRoiAnalyticsResponse.java
new file mode 100644
index 0000000..dcda8f2
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserRoiAnalyticsResponse.java
@@ -0,0 +1,92 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 사용자 전체 이벤트의 ROI 분석 응답
+ *
+ * 사용자 ID 기반으로 모든 이벤트의 ROI를 통합하여 제공
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserRoiAnalyticsResponse {
+
+ /**
+ * 사용자 ID
+ */
+ private String userId;
+
+ /**
+ * 조회 기간 정보
+ */
+ private PeriodInfo period;
+
+ /**
+ * 전체 이벤트 수
+ */
+ private Integer totalEvents;
+
+ /**
+ * 전체 투자 정보 (모든 이벤트 합계)
+ */
+ private InvestmentDetails overallInvestment;
+
+ /**
+ * 전체 수익 정보 (모든 이벤트 합계)
+ */
+ private RevenueDetails overallRevenue;
+
+ /**
+ * 전체 ROI 계산 결과
+ */
+ private RoiCalculation overallRoi;
+
+ /**
+ * 비용 효율성 분석
+ */
+ private CostEfficiency costEfficiency;
+
+ /**
+ * 수익 예측 (포함 여부에 따라 nullable)
+ */
+ private RevenueProjection projection;
+
+ /**
+ * 이벤트별 ROI 목록
+ */
+ private List eventRois;
+
+ /**
+ * 마지막 업데이트 시간
+ */
+ private LocalDateTime lastUpdatedAt;
+
+ /**
+ * 데이터 출처
+ */
+ private String dataSource;
+
+ /**
+ * 이벤트별 ROI 요약
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class EventRoiSummary {
+ private String eventId;
+ private String eventTitle;
+ private Double totalInvestment;
+ private Double expectedRevenue;
+ private Double roi;
+ private String status;
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserTimelineAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserTimelineAnalyticsResponse.java
new file mode 100644
index 0000000..7a41d13
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserTimelineAnalyticsResponse.java
@@ -0,0 +1,66 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 사용자 전체 이벤트의 시간대별 분석 응답
+ *
+ * 사용자 ID 기반으로 모든 이벤트의 시간대별 데이터를 통합하여 제공
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserTimelineAnalyticsResponse {
+
+ /**
+ * 사용자 ID
+ */
+ private String userId;
+
+ /**
+ * 조회 기간 정보
+ */
+ private PeriodInfo period;
+
+ /**
+ * 전체 이벤트 수
+ */
+ private Integer totalEvents;
+
+ /**
+ * 시간 간격 (hourly, daily, weekly, monthly)
+ */
+ private String interval;
+
+ /**
+ * 시간대별 데이터 포인트 (모든 이벤트 통합)
+ */
+ private List dataPoints;
+
+ /**
+ * 트렌드 분석
+ */
+ private TrendAnalysis trend;
+
+ /**
+ * 피크 시간 정보
+ */
+ private PeakTimeInfo peakTime;
+
+ /**
+ * 마지막 업데이트 시간
+ */
+ private LocalDateTime lastUpdatedAt;
+
+ /**
+ * 데이터 출처
+ */
+ private String dataSource;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/entity/EventStats.java b/analytics-service/src/main/java/com/kt/event/analytics/entity/EventStats.java
index 4c48a67..e3b4464 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/entity/EventStats.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/entity/EventStats.java
@@ -37,10 +37,10 @@ public class EventStats extends BaseTimeEntity {
private String eventTitle;
/**
- * 매장 ID (소유자)
+ * 사용자 ID (소유자)
*/
@Column(nullable = false, length = 50)
- private String storeId;
+ private String userId;
/**
* 총 참여자 수
@@ -63,6 +63,13 @@ public class EventStats extends BaseTimeEntity {
@Builder.Default
private BigDecimal estimatedRoi = BigDecimal.ZERO;
+ /**
+ * 목표 ROI (%)
+ */
+ @Column(precision = 10, scale = 2)
+ @Builder.Default
+ private BigDecimal targetRoi = BigDecimal.ZERO;
+
/**
* 매출 증가율 (%)
*/
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java
index 5f8cb84..f4be5ef 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java
@@ -54,11 +54,11 @@ public class EventCreatedConsumer {
return;
}
- // 2. 이벤트 통계 초기화
+ // 2. 이벤트 통계 초기화 (1:1 관계: storeId → userId 매핑)
EventStats eventStats = EventStats.builder()
.eventId(eventId)
.eventTitle(event.getEventTitle())
- .storeId(event.getStoreId())
+ .userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
.totalParticipants(0)
.totalInvestment(event.getTotalInvestment())
.status(event.getStatus())
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java b/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java
index d73541d..a049da6 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java
@@ -29,4 +29,12 @@ public interface ChannelStatsRepository extends JpaRepository findByEventIdAndChannelName(String eventId, String channelName);
+
+ /**
+ * 여러 이벤트 ID로 모든 채널 통계 조회
+ *
+ * @param eventIds 이벤트 ID 목록
+ * @return 채널 통계 목록
+ */
+ List findByEventIdIn(List eventIds);
}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java b/analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java
index 02688a9..ac36dd2 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java
@@ -39,11 +39,19 @@ public interface EventStatsRepository extends JpaRepository {
Optional findByEventIdWithLock(@Param("eventId") String eventId);
/**
- * 매장 ID와 이벤트 ID로 통계 조회
+ * 사용자 ID와 이벤트 ID로 통계 조회
*
- * @param storeId 매장 ID
+ * @param userId 사용자 ID
* @param eventId 이벤트 ID
* @return 이벤트 통계
*/
- Optional findByStoreIdAndEventId(String storeId, String eventId);
+ Optional findByUserIdAndEventId(String userId, String eventId);
+
+ /**
+ * 사용자 ID로 모든 이벤트 통계 조회
+ *
+ * @param userId 사용자 ID
+ * @return 이벤트 통계 목록
+ */
+ java.util.List findAllByUserId(String userId);
}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java b/analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java
index b2e8562..78c63c1 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java
@@ -37,4 +37,27 @@ public interface TimelineDataRepository extends JpaRepository findByEventIdInOrderByTimestampAsc(List eventIds);
+
+ /**
+ * 여러 이벤트 ID와 기간으로 시간대별 데이터 조회
+ *
+ * @param eventIds 이벤트 ID 목록
+ * @param startDate 시작 날짜
+ * @param endDate 종료 날짜
+ * @return 시간대별 데이터 목록
+ */
+ @Query("SELECT t FROM TimelineData t WHERE t.eventId IN :eventIds AND t.timestamp BETWEEN :startDate AND :endDate ORDER BY t.timestamp ASC")
+ List findByEventIdInAndTimestampBetween(
+ @Param("eventIds") List eventIds,
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate
+ );
}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java
index 0969741..4402e06 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java
@@ -179,12 +179,14 @@ public class AnalyticsService {
.build();
return AnalyticsSummary.builder()
- .totalParticipants(eventStats.getTotalParticipants())
+ .participants(eventStats.getTotalParticipants())
+ .participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산
.totalViews(totalViews)
.totalReach(totalReach)
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
.averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 함)
+ .targetRoi(eventStats.getTargetRoi() != null ? eventStats.getTargetRoi().doubleValue() : null)
.socialInteractions(socialStats)
.build();
}
@@ -202,7 +204,7 @@ public class AnalyticsService {
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
summaries.add(ChannelSummary.builder()
- .channelName(stats.getChannelName())
+ .channel(stats.getChannelName())
.views(stats.getViews())
.participants(stats.getParticipants())
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java b/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java
index b802ea6..29196e4 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java
@@ -192,7 +192,7 @@ public class ROICalculator {
}
return RoiSummary.builder()
- .totalInvestment(eventStats.getTotalInvestment())
+ .totalCost(eventStats.getTotalInvestment())
.expectedRevenue(eventStats.getExpectedRevenue())
.netProfit(netProfit)
.roi(roi)
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java
new file mode 100644
index 0000000..98a7b51
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java
@@ -0,0 +1,339 @@
+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;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * User Analytics Service
+ *
+ * 매장(사용자) 전체 이벤트의 통합 성과 대시보드를 제공하는 서비스
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class UserAnalyticsService {
+
+ private final EventStatsRepository eventStatsRepository;
+ private final ChannelStatsRepository channelStatsRepository;
+ private final ROICalculator roiCalculator;
+ private final RedisTemplate redisTemplate;
+ private final ObjectMapper objectMapper;
+
+ private static final String CACHE_KEY_PREFIX = "analytics:user:dashboard:";
+ private static final long CACHE_TTL = 1800; // 30분 (여러 이벤트 통합이므로 짧게)
+
+ /**
+ * 사용자 전체 대시보드 데이터 조회
+ *
+ * @param userId 사용자 ID
+ * @param startDate 조회 시작 날짜 (선택)
+ * @param endDate 조회 종료 날짜 (선택)
+ * @param refresh 캐시 갱신 여부
+ * @return 사용자 통합 대시보드 응답
+ */
+ public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
+ log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
+
+ String cacheKey = CACHE_KEY_PREFIX + userId;
+
+ // 1. Redis 캐시 조회 (refresh가 false일 때만)
+ if (!refresh) {
+ String cachedData = redisTemplate.opsForValue().get(cacheKey);
+ if (cachedData != null) {
+ try {
+ log.info("✅ 캐시 HIT: {}", cacheKey);
+ return objectMapper.readValue(cachedData, UserAnalyticsDashboardResponse.class);
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 데이터 역직렬화 실패: {}", e.getMessage());
+ }
+ }
+ }
+
+ // 2. 캐시 MISS: 데이터 조회 및 통합
+ log.info("캐시 MISS 또는 refresh=true: PostgreSQL 조회");
+
+ // 2-1. 사용자의 모든 이벤트 조회
+ List allEvents = eventStatsRepository.findAllByUserId(userId);
+ if (allEvents.isEmpty()) {
+ log.warn("사용자에 이벤트가 없음: userId={}", userId);
+ return buildEmptyResponse(userId, startDate, endDate);
+ }
+
+ log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
+
+ // 2-2. 모든 이벤트의 채널 통계 조회
+ List eventIds = allEvents.stream()
+ .map(EventStats::getEventId)
+ .collect(Collectors.toList());
+ List allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
+
+ // 3. 통합 대시보드 데이터 구성
+ UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate);
+
+ // 4. Redis 캐싱 (30분 TTL)
+ try {
+ String jsonData = objectMapper.writeValueAsString(response);
+ redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
+ log.info("✅ Redis 캐시 저장 완료: {} (TTL: 30분)", cacheKey);
+ } catch (Exception e) {
+ log.warn("캐시 저장 실패 (무시하고 계속 진행): {}", e.getMessage());
+ }
+
+ return response;
+ }
+
+ /**
+ * 빈 응답 생성 (이벤트가 없는 경우)
+ */
+ private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
+ return UserAnalyticsDashboardResponse.builder()
+ .userId(userId)
+ .period(buildPeriodInfo(startDate, endDate))
+ .totalEvents(0)
+ .activeEvents(0)
+ .overallSummary(buildEmptyAnalyticsSummary())
+ .channelPerformance(new ArrayList<>())
+ .overallRoi(buildEmptyRoiSummary())
+ .eventPerformances(new ArrayList<>())
+ .lastUpdatedAt(LocalDateTime.now())
+ .dataSource("empty")
+ .build();
+ }
+
+ /**
+ * 사용자 통합 대시보드 데이터 구성
+ */
+ private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List allEvents,
+ List allChannelStats,
+ LocalDateTime startDate, LocalDateTime endDate) {
+ // 기간 정보
+ PeriodInfo period = buildPeriodInfo(startDate, endDate);
+
+ // 전체 이벤트 수 및 활성 이벤트 수
+ int totalEvents = allEvents.size();
+ long activeEvents = allEvents.stream()
+ .filter(e -> "ACTIVE".equalsIgnoreCase(e.getStatus()) || "RUNNING".equalsIgnoreCase(e.getStatus()))
+ .count();
+
+ // 전체 성과 요약 (모든 이벤트 통합)
+ AnalyticsSummary overallSummary = buildOverallSummary(allEvents, allChannelStats);
+
+ // 채널별 성과 요약 (모든 이벤트 통합)
+ List channelPerformance = buildAggregatedChannelPerformance(allChannelStats, allEvents);
+
+ // 전체 ROI 요약
+ RoiSummary overallRoi = calculateOverallRoi(allEvents);
+
+ // 이벤트별 성과 목록
+ List eventPerformances = buildEventPerformances(allEvents);
+
+ return UserAnalyticsDashboardResponse.builder()
+ .userId(userId)
+ .period(period)
+ .totalEvents(totalEvents)
+ .activeEvents((int) activeEvents)
+ .overallSummary(overallSummary)
+ .channelPerformance(channelPerformance)
+ .overallRoi(overallRoi)
+ .eventPerformances(eventPerformances)
+ .lastUpdatedAt(LocalDateTime.now())
+ .dataSource("cached")
+ .build();
+ }
+
+ /**
+ * 전체 성과 요약 계산 (모든 이벤트 통합)
+ */
+ private AnalyticsSummary buildOverallSummary(List allEvents, List allChannelStats) {
+ int totalParticipants = allEvents.stream()
+ .mapToInt(EventStats::getTotalParticipants)
+ .sum();
+
+ int totalViews = allEvents.stream()
+ .mapToInt(EventStats::getTotalViews)
+ .sum();
+
+ BigDecimal totalInvestment = allEvents.stream()
+ .map(EventStats::getTotalInvestment)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ BigDecimal totalExpectedRevenue = allEvents.stream()
+ .map(EventStats::getExpectedRevenue)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ // 평균 참여율 계산
+ double avgEngagementRate = totalViews > 0 ? (double) totalParticipants / totalViews * 100 : 0.0;
+
+ // 평균 전환율 계산 (채널 통계 기반)
+ int totalConversions = allChannelStats.stream()
+ .mapToInt(ChannelStats::getConversions)
+ .sum();
+ double avgConversionRate = totalParticipants > 0 ? (double) totalConversions / totalParticipants * 100 : 0.0;
+
+ return AnalyticsSummary.builder()
+ .participants(totalParticipants)
+ .participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산
+ .totalViews(totalViews)
+ .engagementRate(Math.round(avgEngagementRate * 10) / 10.0)
+ .conversionRate(Math.round(avgConversionRate * 10) / 10.0)
+ .build();
+ }
+
+ /**
+ * 채널별 성과 통합 (모든 이벤트의 채널 데이터 집계)
+ */
+ private List buildAggregatedChannelPerformance(List allChannelStats, List allEvents) {
+ if (allChannelStats.isEmpty()) {
+ return new ArrayList<>();
+ }
+
+ BigDecimal totalInvestment = allEvents.stream()
+ .map(EventStats::getTotalInvestment)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ // 채널명별로 그룹화하여 집계
+ Map> channelGroups = allChannelStats.stream()
+ .collect(Collectors.groupingBy(ChannelStats::getChannelName));
+
+ return channelGroups.entrySet().stream()
+ .map(entry -> {
+ String channelName = entry.getKey();
+ List channelList = entry.getValue();
+
+ int participants = channelList.stream().mapToInt(ChannelStats::getParticipants).sum();
+ int views = channelList.stream().mapToInt(ChannelStats::getViews).sum();
+ double engagementRate = views > 0 ? (double) participants / views * 100 : 0.0;
+
+ BigDecimal channelCost = channelList.stream()
+ .map(ChannelStats::getDistributionCost)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ double channelRoi = channelCost.compareTo(BigDecimal.ZERO) > 0
+ ? (participants - channelCost.doubleValue()) / channelCost.doubleValue() * 100
+ : 0.0;
+
+ return ChannelSummary.builder()
+ .channel(channelName)
+ .participants(participants)
+ .views(views)
+ .engagementRate(Math.round(engagementRate * 10) / 10.0)
+ .roi(Math.round(channelRoi * 10) / 10.0)
+ .build();
+ })
+ .sorted(Comparator.comparingInt(ChannelSummary::getParticipants).reversed())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 전체 ROI 계산
+ */
+ private RoiSummary calculateOverallRoi(List allEvents) {
+ BigDecimal totalInvestment = allEvents.stream()
+ .map(EventStats::getTotalInvestment)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ BigDecimal totalExpectedRevenue = allEvents.stream()
+ .map(EventStats::getExpectedRevenue)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ BigDecimal totalProfit = totalExpectedRevenue.subtract(totalInvestment);
+
+ Double roi = totalInvestment.compareTo(BigDecimal.ZERO) > 0
+ ? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100))
+ .doubleValue()
+ : 0.0;
+
+ return RoiSummary.builder()
+ .totalCost(totalInvestment)
+ .expectedRevenue(totalExpectedRevenue)
+ .netProfit(totalProfit)
+ .roi(Math.round(roi * 10) / 10.0)
+ .build();
+ }
+
+ /**
+ * 이벤트별 성과 목록 생성
+ */
+ private List buildEventPerformances(List allEvents) {
+ return allEvents.stream()
+ .map(event -> {
+ Double roi = event.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0
+ ? event.getExpectedRevenue().subtract(event.getTotalInvestment())
+ .divide(event.getTotalInvestment(), 4, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100))
+ .doubleValue()
+ : 0.0;
+
+ return UserAnalyticsDashboardResponse.EventPerformanceSummary.builder()
+ .eventId(event.getEventId())
+ .eventTitle(event.getEventTitle())
+ .participants(event.getTotalParticipants())
+ .views(event.getTotalViews())
+ .roi(Math.round(roi * 10) / 10.0)
+ .status(event.getStatus())
+ .build();
+ })
+ .sorted(Comparator.comparingInt(UserAnalyticsDashboardResponse.EventPerformanceSummary::getParticipants).reversed())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 기간 정보 구성
+ */
+ 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);
+
+ return PeriodInfo.builder()
+ .startDate(start)
+ .endDate(end)
+ .durationDays((int) durationDays)
+ .build();
+ }
+
+ /**
+ * 빈 성과 요약
+ */
+ private AnalyticsSummary buildEmptyAnalyticsSummary() {
+ return AnalyticsSummary.builder()
+ .participants(0)
+ .participantsDelta(0)
+ .totalViews(0)
+ .engagementRate(0.0)
+ .conversionRate(0.0)
+ .build();
+ }
+
+ /**
+ * 빈 ROI 요약
+ */
+ private RoiSummary buildEmptyRoiSummary() {
+ return RoiSummary.builder()
+ .totalCost(BigDecimal.ZERO)
+ .expectedRevenue(BigDecimal.ZERO)
+ .netProfit(BigDecimal.ZERO)
+ .roi(0.0)
+ .build();
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java
new file mode 100644
index 0000000..057b10e
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java
@@ -0,0 +1,260 @@
+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;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.HashMap;
+
+/**
+ * User Channel Analytics Service
+ *
+ * 매장(사용자) 전체 이벤트의 채널별 성과를 통합하여 제공하는 서비스
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class UserChannelAnalyticsService {
+
+ private final EventStatsRepository eventStatsRepository;
+ private final ChannelStatsRepository channelStatsRepository;
+ private final RedisTemplate redisTemplate;
+ private final ObjectMapper objectMapper;
+
+ private static final String CACHE_KEY_PREFIX = "analytics:user:channels:";
+ private static final long CACHE_TTL = 1800; // 30분
+
+ /**
+ * 사용자 전체 채널 분석 데이터 조회
+ */
+ public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List channels, String sortBy, String order,
+ LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
+ log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
+
+ String cacheKey = CACHE_KEY_PREFIX + userId;
+
+ // 1. 캐시 조회
+ if (!refresh) {
+ String cachedData = redisTemplate.opsForValue().get(cacheKey);
+ if (cachedData != null) {
+ try {
+ log.info("✅ 캐시 HIT: {}", cacheKey);
+ return objectMapper.readValue(cachedData, UserChannelAnalyticsResponse.class);
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 역직렬화 실패: {}", e.getMessage());
+ }
+ }
+ }
+
+ // 2. 데이터 조회
+ List allEvents = eventStatsRepository.findAllByUserId(userId);
+ if (allEvents.isEmpty()) {
+ return buildEmptyResponse(userId, startDate, endDate);
+ }
+
+ List eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
+ List allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
+
+ // 3. 응답 구성
+ UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate);
+
+ // 4. 캐싱
+ try {
+ String jsonData = objectMapper.writeValueAsString(response);
+ redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
+ log.info("✅ 캐시 저장 완료: {}", cacheKey);
+ } catch (Exception e) {
+ log.warn("캐시 저장 실패: {}", e.getMessage());
+ }
+
+ return response;
+ }
+
+ private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
+ return UserChannelAnalyticsResponse.builder()
+ .userId(userId)
+ .period(buildPeriodInfo(startDate, endDate))
+ .totalEvents(0)
+ .channels(new ArrayList<>())
+ .comparison(ChannelComparison.builder().build())
+ .lastUpdatedAt(LocalDateTime.now())
+ .dataSource("empty")
+ .build();
+ }
+
+ private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List allEvents,
+ List allChannelStats, List channels,
+ String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
+ // 채널 필터링
+ List filteredChannels = channels != null && !channels.isEmpty()
+ ? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
+ : allChannelStats;
+
+ // 채널별 집계
+ List channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
+
+ // 정렬
+ channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
+
+ // 채널 비교
+ ChannelComparison comparison = buildChannelComparison(channelAnalyticsList);
+
+ return UserChannelAnalyticsResponse.builder()
+ .userId(userId)
+ .period(buildPeriodInfo(startDate, endDate))
+ .totalEvents(allEvents.size())
+ .channels(channelAnalyticsList)
+ .comparison(comparison)
+ .lastUpdatedAt(LocalDateTime.now())
+ .dataSource("cached")
+ .build();
+ }
+
+ private List aggregateChannelAnalytics(List allChannelStats) {
+ Map> channelGroups = allChannelStats.stream()
+ .collect(Collectors.groupingBy(ChannelStats::getChannelName));
+
+ return channelGroups.entrySet().stream()
+ .map(entry -> {
+ String channelName = entry.getKey();
+ List channelList = entry.getValue();
+
+ int views = channelList.stream().mapToInt(ChannelStats::getViews).sum();
+ int participants = channelList.stream().mapToInt(ChannelStats::getParticipants).sum();
+ int clicks = channelList.stream().mapToInt(ChannelStats::getClicks).sum();
+ int conversions = channelList.stream().mapToInt(ChannelStats::getConversions).sum();
+
+ double engagementRate = views > 0 ? (double) participants / views * 100 : 0.0;
+ double conversionRate = participants > 0 ? (double) conversions / participants * 100 : 0.0;
+
+ BigDecimal cost = channelList.stream()
+ .map(ChannelStats::getDistributionCost)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ double roi = cost.compareTo(BigDecimal.ZERO) > 0
+ ? (participants - cost.doubleValue()) / cost.doubleValue() * 100
+ : 0.0;
+
+ ChannelMetrics metrics = ChannelMetrics.builder()
+ .impressions(channelList.stream().mapToInt(ChannelStats::getImpressions).sum())
+ .views(views)
+ .clicks(clicks)
+ .participants(participants)
+ .conversions(conversions)
+ .build();
+
+ ChannelPerformance performance = ChannelPerformance.builder()
+ .engagementRate(Math.round(engagementRate * 10) / 10.0)
+ .conversionRate(Math.round(conversionRate * 10) / 10.0)
+ .clickThroughRate(views > 0 ? Math.round((double) clicks / views * 1000) / 10.0 : 0.0)
+ .build();
+
+ ChannelCosts costs = ChannelCosts.builder()
+ .distributionCost(cost)
+ .costPerView(views > 0 ? cost.doubleValue() / views : 0.0)
+ .costPerClick(clicks > 0 ? cost.doubleValue() / clicks : 0.0)
+ .costPerAcquisition(participants > 0 ? cost.doubleValue() / participants : 0.0)
+ .roi(Math.round(roi * 10) / 10.0)
+ .build();
+
+ return ChannelAnalytics.builder()
+ .channelName(channelName)
+ .channelType(channelList.get(0).getChannelType())
+ .metrics(metrics)
+ .performance(performance)
+ .costs(costs)
+ .build();
+ })
+ .collect(Collectors.toList());
+ }
+
+ private List sortChannels(List channels, String sortBy, String order) {
+ Comparator comparator;
+
+ switch (sortBy != null ? sortBy.toLowerCase() : "participants") {
+ case "views":
+ comparator = Comparator.comparingInt(c -> c.getMetrics().getViews());
+ break;
+ case "engagement_rate":
+ comparator = Comparator.comparingDouble(c -> c.getPerformance().getEngagementRate());
+ break;
+ case "conversion_rate":
+ comparator = Comparator.comparingDouble(c -> c.getPerformance().getConversionRate());
+ break;
+ case "roi":
+ comparator = Comparator.comparingDouble(c -> c.getCosts().getRoi());
+ break;
+ case "participants":
+ default:
+ comparator = Comparator.comparingInt(c -> c.getMetrics().getParticipants());
+ break;
+ }
+
+ if ("desc".equalsIgnoreCase(order)) {
+ comparator = comparator.reversed();
+ }
+
+ return channels.stream().sorted(comparator).collect(Collectors.toList());
+ }
+
+ private ChannelComparison buildChannelComparison(List channels) {
+ if (channels.isEmpty()) {
+ return ChannelComparison.builder().build();
+ }
+
+ String bestPerformingChannel = channels.stream()
+ .max(Comparator.comparingInt(c -> c.getMetrics().getParticipants()))
+ .map(ChannelAnalytics::getChannelName)
+ .orElse("N/A");
+
+ Map bestPerforming = new HashMap<>();
+ bestPerforming.put("channel", bestPerformingChannel);
+ bestPerforming.put("metric", "participants");
+
+ Map averageMetrics = new HashMap<>();
+ int totalChannels = channels.size();
+ if (totalChannels > 0) {
+ double avgParticipants = channels.stream().mapToInt(c -> c.getMetrics().getParticipants()).average().orElse(0.0);
+ double avgEngagement = channels.stream().mapToDouble(c -> c.getPerformance().getEngagementRate()).average().orElse(0.0);
+ double avgRoi = channels.stream().mapToDouble(c -> c.getCosts().getRoi()).average().orElse(0.0);
+
+ averageMetrics.put("participants", avgParticipants);
+ averageMetrics.put("engagementRate", avgEngagement);
+ averageMetrics.put("roi", avgRoi);
+ }
+
+ return ChannelComparison.builder()
+ .bestPerforming(bestPerforming)
+ .averageMetrics(averageMetrics)
+ .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);
+
+ return PeriodInfo.builder()
+ .startDate(start)
+ .endDate(end)
+ .durationDays((int) durationDays)
+ .build();
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java
new file mode 100644
index 0000000..44ea2eb
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java
@@ -0,0 +1,176 @@
+package com.kt.event.analytics.service;
+
+import com.kt.event.analytics.dto.response.*;
+import com.kt.event.analytics.entity.EventStats;
+import com.kt.event.analytics.repository.EventStatsRepository;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * User ROI Analytics Service
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class UserRoiAnalyticsService {
+
+ private final EventStatsRepository eventStatsRepository;
+ private final RedisTemplate 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) {
+ log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
+
+ String cacheKey = CACHE_KEY_PREFIX + userId;
+
+ if (!refresh) {
+ String cachedData = redisTemplate.opsForValue().get(cacheKey);
+ if (cachedData != null) {
+ try {
+ return objectMapper.readValue(cachedData, UserRoiAnalyticsResponse.class);
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 역직렬화 실패: {}", e.getMessage());
+ }
+ }
+ }
+
+ List allEvents = eventStatsRepository.findAllByUserId(userId);
+ if (allEvents.isEmpty()) {
+ return buildEmptyResponse(userId, startDate, endDate);
+ }
+
+ UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate);
+
+ try {
+ String jsonData = objectMapper.writeValueAsString(response);
+ redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ log.warn("캐시 저장 실패: {}", e.getMessage());
+ }
+
+ return response;
+ }
+
+ private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
+ return UserRoiAnalyticsResponse.builder()
+ .userId(userId)
+ .period(buildPeriodInfo(startDate, endDate))
+ .totalEvents(0)
+ .overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build())
+ .overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build())
+ .overallRoi(RoiCalculation.builder()
+ .netProfit(BigDecimal.ZERO)
+ .roiPercentage(0.0)
+ .build())
+ .eventRois(new ArrayList<>())
+ .lastUpdatedAt(LocalDateTime.now())
+ .dataSource("empty")
+ .build();
+ }
+
+ private UserRoiAnalyticsResponse buildRoiResponse(String userId, List allEvents, boolean includeProjection,
+ LocalDateTime startDate, LocalDateTime endDate) {
+ 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);
+
+ Double roiPercentage = totalInvestment.compareTo(BigDecimal.ZERO) > 0
+ ? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
+ : 0.0;
+
+ 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)))
+ .build();
+
+ RevenueDetails revenue = RevenueDetails.builder()
+ .total(totalRevenue)
+ .directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7)))
+ .expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3)))
+ .build();
+
+ RoiCalculation roiCalc = RoiCalculation.builder()
+ .netProfit(totalProfit)
+ .roiPercentage(Math.round(roiPercentage * 10) / 10.0)
+ .build();
+
+ int totalParticipants = allEvents.stream().mapToInt(EventStats::getTotalParticipants).sum();
+ CostEfficiency efficiency = CostEfficiency.builder()
+ .costPerParticipant(totalParticipants > 0 ? totalInvestment.doubleValue() / totalParticipants : 0.0)
+ .revenuePerParticipant(totalParticipants > 0 ? totalRevenue.doubleValue() / totalParticipants : 0.0)
+ .build();
+
+ RevenueProjection projection = includeProjection ? RevenueProjection.builder()
+ .currentRevenue(totalRevenue)
+ .projectedFinalRevenue(totalRevenue.multiply(BigDecimal.valueOf(1.2)))
+ .confidenceLevel(85.0)
+ .basedOn("Historical trend analysis")
+ .build() : null;
+
+ List eventRois = allEvents.stream()
+ .map(event -> {
+ Double eventRoi = event.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0
+ ? event.getExpectedRevenue().subtract(event.getTotalInvestment())
+ .divide(event.getTotalInvestment(), 4, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100)).doubleValue()
+ : 0.0;
+
+ return UserRoiAnalyticsResponse.EventRoiSummary.builder()
+ .eventId(event.getEventId())
+ .eventTitle(event.getEventTitle())
+ .totalInvestment(event.getTotalInvestment().doubleValue())
+ .expectedRevenue(event.getExpectedRevenue().doubleValue())
+ .roi(Math.round(eventRoi * 10) / 10.0)
+ .status(event.getStatus())
+ .build();
+ })
+ .sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
+ .collect(Collectors.toList());
+
+ return UserRoiAnalyticsResponse.builder()
+ .userId(userId)
+ .period(buildPeriodInfo(startDate, endDate))
+ .totalEvents(allEvents.size())
+ .overallInvestment(investment)
+ .overallRevenue(revenue)
+ .overallRoi(roiCalc)
+ .costEfficiency(efficiency)
+ .projection(projection)
+ .eventRois(eventRois)
+ .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();
+ return PeriodInfo.builder()
+ .startDate(start)
+ .endDate(end)
+ .durationDays((int) ChronoUnit.DAYS.between(start, end))
+ .build();
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java
new file mode 100644
index 0000000..abee9b8
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java
@@ -0,0 +1,191 @@
+package com.kt.event.analytics.service;
+
+import com.kt.event.analytics.dto.response.*;
+import com.kt.event.analytics.entity.EventStats;
+import com.kt.event.analytics.entity.TimelineData;
+import com.kt.event.analytics.repository.EventStatsRepository;
+import com.kt.event.analytics.repository.TimelineDataRepository;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * User Timeline Analytics Service
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class UserTimelineAnalyticsService {
+
+ private final EventStatsRepository eventStatsRepository;
+ private final TimelineDataRepository timelineDataRepository;
+ private final RedisTemplate redisTemplate;
+ private final ObjectMapper objectMapper;
+
+ private static final String CACHE_KEY_PREFIX = "analytics:user:timeline:";
+ private static final long CACHE_TTL = 1800;
+
+ public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
+ LocalDateTime startDate, LocalDateTime endDate,
+ List metrics, boolean refresh) {
+ log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
+
+ String cacheKey = CACHE_KEY_PREFIX + userId + ":" + interval;
+
+ if (!refresh) {
+ String cachedData = redisTemplate.opsForValue().get(cacheKey);
+ if (cachedData != null) {
+ try {
+ return objectMapper.readValue(cachedData, UserTimelineAnalyticsResponse.class);
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 역직렬화 실패: {}", e.getMessage());
+ }
+ }
+ }
+
+ List allEvents = eventStatsRepository.findAllByUserId(userId);
+ if (allEvents.isEmpty()) {
+ return buildEmptyResponse(userId, interval, startDate, endDate);
+ }
+
+ List eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
+ List allTimelineData = startDate != null && endDate != null
+ ? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
+ : timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
+
+ UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate);
+
+ try {
+ String jsonData = objectMapper.writeValueAsString(response);
+ redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ log.warn("캐시 저장 실패: {}", e.getMessage());
+ }
+
+ return response;
+ }
+
+ private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) {
+ return UserTimelineAnalyticsResponse.builder()
+ .userId(userId)
+ .period(buildPeriodInfo(startDate, endDate))
+ .totalEvents(0)
+ .interval(interval != null ? interval : "daily")
+ .dataPoints(new ArrayList<>())
+ .trend(TrendAnalysis.builder().overallTrend("stable").build())
+ .peakTime(PeakTimeInfo.builder().build())
+ .lastUpdatedAt(LocalDateTime.now())
+ .dataSource("empty")
+ .build();
+ }
+
+ private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List allEvents,
+ List allTimelineData, String interval,
+ LocalDateTime startDate, LocalDateTime endDate) {
+ Map aggregatedData = new LinkedHashMap<>();
+
+ for (TimelineData data : allTimelineData) {
+ LocalDateTime key = normalizeTimestamp(data.getTimestamp(), interval);
+ aggregatedData.computeIfAbsent(key, k -> TimelineDataPoint.builder()
+ .timestamp(k)
+ .participants(0)
+ .views(0)
+ .engagement(0)
+ .conversions(0)
+ .build());
+
+ TimelineDataPoint point = aggregatedData.get(key);
+ point.setParticipants(point.getParticipants() + data.getParticipants());
+ point.setViews(point.getViews() + data.getViews());
+ point.setEngagement(point.getEngagement() + data.getEngagement());
+ point.setConversions(point.getConversions() + data.getConversions());
+ }
+
+ List dataPoints = new ArrayList<>(aggregatedData.values());
+
+ TrendAnalysis trend = analyzeTrend(dataPoints);
+ PeakTimeInfo peakTime = findPeakTime(dataPoints);
+
+ return UserTimelineAnalyticsResponse.builder()
+ .userId(userId)
+ .period(buildPeriodInfo(startDate, endDate))
+ .totalEvents(allEvents.size())
+ .interval(interval != null ? interval : "daily")
+ .dataPoints(dataPoints)
+ .trend(trend)
+ .peakTime(peakTime)
+ .lastUpdatedAt(LocalDateTime.now())
+ .dataSource("cached")
+ .build();
+ }
+
+ private LocalDateTime normalizeTimestamp(LocalDateTime timestamp, String interval) {
+ switch (interval != null ? interval.toLowerCase() : "daily") {
+ case "hourly":
+ return timestamp.truncatedTo(ChronoUnit.HOURS);
+ case "weekly":
+ return timestamp.truncatedTo(ChronoUnit.DAYS).minusDays(timestamp.getDayOfWeek().getValue() - 1);
+ case "monthly":
+ return timestamp.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
+ case "daily":
+ default:
+ return timestamp.truncatedTo(ChronoUnit.DAYS);
+ }
+ }
+
+ private TrendAnalysis analyzeTrend(List dataPoints) {
+ if (dataPoints.size() < 2) {
+ return TrendAnalysis.builder().overallTrend("stable").build();
+ }
+
+ int firstHalf = dataPoints.subList(0, dataPoints.size() / 2).stream()
+ .mapToInt(TimelineDataPoint::getParticipants).sum();
+ int secondHalf = dataPoints.subList(dataPoints.size() / 2, dataPoints.size()).stream()
+ .mapToInt(TimelineDataPoint::getParticipants).sum();
+
+ double growthRate = firstHalf > 0 ? ((double) (secondHalf - firstHalf) / firstHalf) * 100 : 0.0;
+ String trend = growthRate > 5 ? "increasing" : (growthRate < -5 ? "decreasing" : "stable");
+
+ return TrendAnalysis.builder()
+ .overallTrend(trend)
+ .build();
+ }
+
+ private PeakTimeInfo findPeakTime(List dataPoints) {
+ if (dataPoints.isEmpty()) {
+ return PeakTimeInfo.builder().build();
+ }
+
+ TimelineDataPoint peak = dataPoints.stream()
+ .max(Comparator.comparingInt(TimelineDataPoint::getParticipants))
+ .orElse(null);
+
+ return peak != null ? PeakTimeInfo.builder()
+ .timestamp(peak.getTimestamp())
+ .metric("participants")
+ .value(peak.getParticipants())
+ .description(peak.getViews() + " views at peak time")
+ .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();
+ return PeriodInfo.builder()
+ .startDate(start)
+ .endDate(end)
+ .durationDays((int) ChronoUnit.DAYS.between(start, end))
+ .build();
+ }
+}
diff --git a/analytics-service/test-backend.md b/analytics-service/test-backend.md
new file mode 100644
index 0000000..a7f0347
--- /dev/null
+++ b/analytics-service/test-backend.md
@@ -0,0 +1,494 @@
+# Analytics Service 백엔드 테스트 결과서
+
+## 1. 개요
+
+### 1.1 테스트 목적
+- **userId 기반 통합 성과 분석 API 개발 및 검증**
+- 사용자 전체 이벤트를 통합하여 분석하는 4개 API 개발
+- 기존 eventId 기반 API와 독립적으로 동작하는 구조 검증
+- MVP 환경: 1:1 관계 (1 user = 1 store)
+
+### 1.2 테스트 환경
+- **프로젝트**: kt-event-marketing
+- **서비스**: analytics-service
+- **브랜치**: feature/analytics
+- **빌드 도구**: Gradle 8.10
+- **프레임워크**: Spring Boot 3.3.0
+- **언어**: Java 21
+
+### 1.3 테스트 일시
+- **작성일**: 2025-10-28
+- **컴파일 테스트**: 2025-10-28
+
+---
+
+## 2. 개발 범위
+
+### 2.1 Repository 수정
+**파일**: 3개 Repository 인터페이스
+
+#### EventStatsRepository
+```java
+// 추가된 메소드
+List findAllByUserId(String userId);
+```
+- **목적**: 특정 사용자의 모든 이벤트 통계 조회
+- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java`
+
+#### ChannelStatsRepository
+```java
+// 추가된 메소드
+List findByEventIdIn(List eventIds);
+```
+- **목적**: 여러 이벤트의 채널 통계 일괄 조회
+- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java`
+
+#### TimelineDataRepository
+```java
+// 추가된 메소드
+List findByEventIdInOrderByTimestampAsc(List eventIds);
+
+@Query("SELECT t FROM TimelineData t WHERE t.eventId IN :eventIds " +
+ "AND t.timestamp BETWEEN :startDate AND :endDate " +
+ "ORDER BY t.timestamp ASC")
+List findByEventIdInAndTimestampBetween(
+ @Param("eventIds") List eventIds,
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate
+);
+```
+- **목적**: 여러 이벤트의 타임라인 데이터 조회
+- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java`
+
+---
+
+### 2.2 Response DTO 작성
+**파일**: 4개 Response DTO
+
+#### UserAnalyticsDashboardResponse
+- **경로**: `com.kt.event.analytics.dto.response.UserAnalyticsDashboardResponse`
+- **역할**: 사용자 전체 통합 성과 대시보드 응답
+- **주요 필드**:
+ - `userId`: 사용자 ID
+ - `totalEvents`: 총 이벤트 수
+ - `activeEvents`: 활성 이벤트 수
+ - `overallSummary`: 전체 성과 요약 (AnalyticsSummary)
+ - `channelPerformance`: 채널별 성과 (List)
+ - `overallRoi`: 전체 ROI 요약 (RoiSummary)
+ - `eventPerformances`: 이벤트별 성과 목록 (EventPerformanceSummary)
+ - `period`: 조회 기간 (PeriodInfo)
+
+#### UserChannelAnalyticsResponse
+- **경로**: `com.kt.event.analytics.dto.response.UserChannelAnalyticsResponse`
+- **역할**: 사용자 전체 채널별 성과 분석 응답
+- **주요 필드**:
+ - `userId`: 사용자 ID
+ - `totalEvents`: 총 이벤트 수
+ - `channels`: 채널별 상세 분석 (List)
+ - `comparison`: 채널 간 비교 (ChannelComparison)
+ - `period`: 조회 기간 (PeriodInfo)
+
+#### UserRoiAnalyticsResponse
+- **경로**: `com.kt.event.analytics.dto.response.UserRoiAnalyticsResponse`
+- **역할**: 사용자 전체 ROI 상세 분석 응답
+- **주요 필드**:
+ - `userId`: 사용자 ID
+ - `totalEvents`: 총 이벤트 수
+ - `overallInvestment`: 전체 투자 내역 (InvestmentDetails)
+ - `overallRevenue`: 전체 수익 내역 (RevenueDetails)
+ - `overallRoi`: ROI 계산 (RoiCalculation)
+ - `costEfficiency`: 비용 효율성 (CostEfficiency)
+ - `projection`: 수익 예측 (RevenueProjection)
+ - `eventRois`: 이벤트별 ROI (EventRoiSummary)
+ - `period`: 조회 기간 (PeriodInfo)
+
+#### UserTimelineAnalyticsResponse
+- **경로**: `com.kt.event.analytics.dto.response.UserTimelineAnalyticsResponse`
+- **역할**: 사용자 전체 시간대별 참여 추이 분석 응답
+- **주요 필드**:
+ - `userId`: 사용자 ID
+ - `totalEvents`: 총 이벤트 수
+ - `interval`: 시간 간격 단위 (hourly, daily, weekly, monthly)
+ - `dataPoints`: 시간대별 데이터 포인트 (List)
+ - `trend`: 추세 분석 (TrendAnalysis)
+ - `peakTime`: 피크 시간대 정보 (PeakTimeInfo)
+ - `period`: 조회 기간 (PeriodInfo)
+
+---
+
+### 2.3 Service 개발
+**파일**: 4개 Service 클래스
+
+#### UserAnalyticsService
+- **경로**: `com.kt.event.analytics.service.UserAnalyticsService`
+- **역할**: 사용자 전체 이벤트 통합 성과 대시보드 서비스
+- **주요 기능**:
+ - `getUserDashboardData()`: 사용자 전체 대시보드 데이터 조회
+ - Redis 캐싱 (TTL: 30분)
+ - 전체 성과 요약 계산 (참여자, 조회수, 참여율, 전환율)
+ - 채널별 성과 통합 집계
+ - 전체 ROI 계산
+ - 이벤트별 성과 목록 생성
+- **특징**:
+ - 모든 이벤트의 메트릭을 합산하여 통합 분석
+ - 채널명 기준으로 그룹화하여 채널 성과 집계
+ - BigDecimal 타입으로 금액 정확도 보장
+
+#### UserChannelAnalyticsService
+- **경로**: `com.kt.event.analytics.service.UserChannelAnalyticsService`
+- **역할**: 사용자 전체 이벤트의 채널별 성과 통합 서비스
+- **주요 기능**:
+ - `getUserChannelAnalytics()`: 사용자 전체 채널 분석 데이터 조회
+ - Redis 캐싱 (TTL: 30분)
+ - 채널별 메트릭 집계 (조회수, 참여자, 클릭, 전환)
+ - 채널 성과 지표 계산 (참여율, 전환율, CTR, ROI)
+ - 채널 비용 분석 (조회당/클릭당/획득당 비용)
+ - 채널 간 비교 분석 (최고 성과, 평균 지표)
+- **특징**:
+ - 채널명 기준으로 그룹화하여 통합 집계
+ - 다양한 정렬 옵션 지원 (participants, views, engagement_rate, conversion_rate, roi)
+ - 채널 필터링 기능
+
+#### UserRoiAnalyticsService
+- **경로**: `com.kt.event.analytics.service.UserRoiAnalyticsService`
+- **역할**: 사용자 전체 이벤트의 ROI 통합 분석 서비스
+- **주요 기능**:
+ - `getUserRoiAnalytics()`: 사용자 전체 ROI 분석 데이터 조회
+ - Redis 캐싱 (TTL: 30분)
+ - 전체 투자 금액 집계 (콘텐츠 제작, 운영, 배포 비용)
+ - 전체 수익 집계 (직접 판매, 예상 판매)
+ - ROI 계산 (순이익, ROI %)
+ - 비용 효율성 분석 (참여자당 비용/수익)
+ - 수익 예측 (현재 수익 기반 최종 수익 예측)
+- **특징**:
+ - BigDecimal로 금액 정밀 계산
+ - 이벤트별 ROI 순위 제공
+ - 선택적 수익 예측 기능
+
+#### UserTimelineAnalyticsService
+- **경로**: `com.kt.event.analytics.service.UserTimelineAnalyticsService`
+- **역할**: 사용자 전체 이벤트의 시간대별 추이 통합 서비스
+- **주요 기능**:
+ - `getUserTimelineAnalytics()`: 사용자 전체 타임라인 분석 데이터 조회
+ - Redis 캐싱 (TTL: 30분)
+ - 시간 간격별 데이터 집계 (hourly, daily, weekly, monthly)
+ - 추세 분석 (증가/감소/안정)
+ - 피크 시간대 식별 (최대 참여자 시점)
+- **특징**:
+ - 시간대별로 정규화하여 데이터 집계
+ - 전반부/후반부 비교를 통한 성장률 계산
+ - 메트릭별 필터링 지원
+
+---
+
+### 2.4 Controller 개발
+**파일**: 4개 Controller 클래스
+
+#### UserAnalyticsDashboardController
+- **경로**: `com.kt.event.analytics.controller.UserAnalyticsDashboardController`
+- **엔드포인트**: `GET /api/v1/users/{userId}/analytics`
+- **역할**: 사용자 전체 성과 대시보드 API
+- **Request Parameters**:
+ - `userId` (Path): 사용자 ID (필수)
+ - `startDate` (Query): 조회 시작 날짜 (선택, ISO 8601 format)
+ - `endDate` (Query): 조회 종료 날짜 (선택, ISO 8601 format)
+ - `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
+- **Response**: `ApiResponse`
+
+#### UserChannelAnalyticsController
+- **경로**: `com.kt.event.analytics.controller.UserChannelAnalyticsController`
+- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/channels`
+- **역할**: 사용자 전체 채널별 성과 분석 API
+- **Request Parameters**:
+ - `userId` (Path): 사용자 ID (필수)
+ - `channels` (Query): 조회할 채널 목록 (쉼표 구분, 선택)
+ - `sortBy` (Query): 정렬 기준 (선택, default: participants)
+ - `order` (Query): 정렬 순서 (선택, default: desc)
+ - `startDate` (Query): 조회 시작 날짜 (선택)
+ - `endDate` (Query): 조회 종료 날짜 (선택)
+ - `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
+- **Response**: `ApiResponse`
+
+#### UserRoiAnalyticsController
+- **경로**: `com.kt.event.analytics.controller.UserRoiAnalyticsController`
+- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/roi`
+- **역할**: 사용자 전체 ROI 상세 분석 API
+- **Request Parameters**:
+ - `userId` (Path): 사용자 ID (필수)
+ - `includeProjection` (Query): 예상 수익 포함 여부 (선택, default: true)
+ - `startDate` (Query): 조회 시작 날짜 (선택)
+ - `endDate` (Query): 조회 종료 날짜 (선택)
+ - `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
+- **Response**: `ApiResponse`
+
+#### UserTimelineAnalyticsController
+- **경로**: `com.kt.event.analytics.controller.UserTimelineAnalyticsController`
+- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/timeline`
+- **역할**: 사용자 전체 시간대별 참여 추이 분석 API
+- **Request Parameters**:
+ - `userId` (Path): 사용자 ID (필수)
+ - `interval` (Query): 시간 간격 단위 (선택, default: daily)
+ - 값: hourly, daily, weekly, monthly
+ - `startDate` (Query): 조회 시작 날짜 (선택)
+ - `endDate` (Query): 조회 종료 날짜 (선택)
+ - `metrics` (Query): 조회할 지표 목록 (쉼표 구분, 선택)
+ - `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
+- **Response**: `ApiResponse`
+
+---
+
+## 3. 컴파일 테스트
+
+### 3.1 테스트 명령
+```bash
+./gradlew.bat analytics-service:compileJava
+```
+
+### 3.2 테스트 결과
+**상태**: ✅ **성공 (BUILD SUCCESSFUL)**
+
+**출력**:
+```
+> Task :common:generateEffectiveLombokConfig UP-TO-DATE
+> Task :common:compileJava UP-TO-DATE
+> Task :analytics-service:generateEffectiveLombokConfig
+> Task :analytics-service:compileJava
+
+BUILD SUCCESSFUL in 8s
+4 actionable tasks: 2 executed, 2 up-to-date
+```
+
+### 3.3 오류 해결 과정
+
+#### 3.3.1 초기 컴파일 오류 (19개)
+**문제**: 기존 DTO 구조와 Service 코드 간 필드명/타입 불일치
+
+**해결**:
+1. **AnalyticsSummary**: totalInvestment, expectedRevenue 필드 제거
+2. **ChannelSummary**: cost 필드 제거
+3. **RoiSummary**: BigDecimal 타입 사용
+4. **InvestmentDetails**: totalAmount → total 변경, 필드명 수정 (contentCreation, operation, distribution)
+5. **RevenueDetails**: totalRevenue → total 변경, 필드명 수정 (directSales, expectedSales)
+6. **RoiCalculation**: totalInvestment, totalRevenue 필드 제거
+7. **TrendAnalysis**: direction → overallTrend 변경
+8. **PeakTimeInfo**: participants → value 변경, metric, description 추가
+9. **ChannelPerformance**: participationRate 필드 제거
+10. **ChannelCosts**: totalCost → distributionCost 변경, costPerParticipant → costPerAcquisition 변경
+11. **ChannelComparison**: mostEfficient, highestEngagement → averageMetrics로 통합
+12. **RevenueProjection**: projectedRevenue → projectedFinalRevenue 변경, basedOn 필드 추가
+
+#### 3.3.2 수정된 파일
+- `UserAnalyticsService.java`: DTO 필드명 수정 (5곳)
+- `UserChannelAnalyticsService.java`: DTO 필드명 수정, HashMap import 추가 (3곳)
+- `UserRoiAnalyticsService.java`: DTO 필드명 수정, BigDecimal 타입 사용 (4곳)
+- `UserTimelineAnalyticsService.java`: DTO 필드명 수정 (3곳)
+
+---
+
+## 4. API 설계 요약
+
+### 4.1 API 엔드포인트 구조
+```
+/api/v1/users/{userId}/analytics
+├─ GET / # 전체 통합 대시보드
+├─ GET /channels # 채널별 성과 분석
+├─ GET /roi # ROI 상세 분석
+└─ GET /timeline # 시간대별 참여 추이
+```
+
+### 4.2 기존 API와의 비교
+| 구분 | 기존 API | 신규 API |
+|------|----------|----------|
+| **기준** | eventId (개별 이벤트) | userId (사용자 전체) |
+| **범위** | 단일 이벤트 | 사용자의 모든 이벤트 통합 |
+| **엔드포인트** | `/api/v1/events/{eventId}/...` | `/api/v1/users/{userId}/...` |
+| **캐시 TTL** | 3600초 (60분) | 1800초 (30분) |
+| **데이터 집계** | 개별 이벤트 데이터 | 여러 이벤트 합산/평균 |
+
+### 4.3 캐싱 전략
+- **캐시 키 형식**: `analytics:user:{category}:{userId}`
+- **TTL**: 30분 (1800초)
+ - 여러 이벤트 통합으로 데이터 변동성이 높아 기존보다 짧게 설정
+- **갱신 방식**: `refresh=true` 파라미터로 강제 갱신 가능
+- **구현**: RedisTemplate + Jackson ObjectMapper
+
+---
+
+## 5. 주요 기능
+
+### 5.1 데이터 집계 로직
+#### 5.1.1 통합 성과 계산
+- **참여자 수**: 모든 이벤트의 totalParticipants 합산
+- **조회수**: 모든 이벤트의 totalViews 합산
+- **참여율**: 전체 참여자 / 전체 조회수 * 100
+- **전환율**: 전체 전환 / 전체 참여자 * 100
+
+#### 5.1.2 채널 성과 집계
+- **그룹화**: 채널명(channelName) 기준
+- **메트릭 합산**: views, participants, clicks, conversions
+- **비용 집계**: distributionCost 합산
+- **ROI 계산**: (참여자 - 비용) / 비용 * 100
+
+#### 5.1.3 ROI 계산
+- **투자 금액**: 모든 이벤트의 totalInvestment 합산
+- **수익**: 모든 이벤트의 expectedRevenue 합산
+- **순이익**: 수익 - 투자
+- **ROI**: (순이익 / 투자) * 100
+
+#### 5.1.4 시간대별 집계
+- **정규화**: interval에 따라 timestamp 정규화
+ - hourly: 시간 단위로 truncate
+ - daily: 일 단위로 truncate
+ - weekly: 주 시작일로 정규화
+ - monthly: 월 시작일로 정규화
+- **데이터 포인트 합산**: 동일 시간대의 participants, views, engagement, conversions 합산
+
+### 5.2 추세 분석
+- **전반부/후반부 비교**: 데이터 포인트를 반으로 나누어 성장률 계산
+- **추세 결정**:
+ - 성장률 > 5%: "increasing"
+ - 성장률 < -5%: "decreasing"
+ - -5% ≤ 성장률 ≤ 5%: "stable"
+
+### 5.3 피크 시간 식별
+- **기준**: 참여자 수(participants) 최대 시점
+- **정보**: timestamp, metric, value, description
+
+---
+
+## 6. 아키텍처 특징
+
+### 6.1 계층 구조
+```
+Controller
+ ↓
+Service (비즈니스 로직)
+ ↓
+Repository (데이터 접근)
+ ↓
+Entity (JPA)
+```
+
+### 6.2 독립성 보장
+- **기존 eventId 기반 API와 독립적 구조**
+- **별도의 Controller, Service 클래스**
+- **공통 Repository 재사용**
+- **기존 DTO 구조 준수**
+
+### 6.3 확장성
+- **새로운 메트릭 추가 용이**: Service 레이어에서 계산 로직 추가
+- **캐싱 전략 개별 조정 가능**: 각 Service마다 독립적인 캐시 키
+- **채널/이벤트 필터링 지원**: 동적 쿼리 지원
+
+---
+
+## 7. 검증 결과
+
+### 7.1 컴파일 검증
+- ✅ **Service 계층**: 4개 클래스 컴파일 성공
+- ✅ **Controller 계층**: 4개 클래스 컴파일 성공
+- ✅ **Repository 계층**: 3개 인터페이스 컴파일 성공
+- ✅ **DTO 계층**: 4개 Response 클래스 컴파일 성공
+
+### 7.2 코드 품질
+- ✅ **Lombok 활용**: Builder 패턴, Data 클래스
+- ✅ **로깅**: Slf4j 적용
+- ✅ **트랜잭션**: @Transactional(readOnly = true)
+- ✅ **예외 처리**: try-catch로 캐시 오류 대응
+- ✅ **타입 안정성**: BigDecimal로 금액 처리
+
+### 7.3 Swagger 문서화
+- ✅ **@Tag**: API 그룹 정의
+- ✅ **@Operation**: 엔드포인트 설명
+- ✅ **@Parameter**: 파라미터 설명
+
+---
+
+## 8. 다음 단계
+
+### 8.1 백엔드 개발 완료 항목
+- ✅ Repository 쿼리 메소드 추가
+- ✅ Response DTO 작성
+- ✅ Service 로직 구현
+- ✅ Controller API 개발
+- ✅ 컴파일 검증
+
+### 8.2 향후 작업
+1. **백엔드 서버 실행 테스트** (Phase 1 완료 후)
+ - 애플리케이션 실행 확인
+ - API 엔드포인트 접근 테스트
+ - Swagger UI 확인
+
+2. **API 통합 테스트** (Phase 1 완료 후)
+ - Postman/curl로 API 호출 테스트
+ - 실제 데이터로 응답 검증
+ - 에러 핸들링 확인
+
+3. **프론트엔드 연동** (Phase 2)
+ - 프론트엔드에서 4개 API 호출
+ - 응답 데이터 바인딩
+ - UI 렌더링 검증
+
+---
+
+## 9. 결론
+
+### 9.1 성과
+- ✅ **userId 기반 통합 분석 API 4개 개발 완료**
+- ✅ **컴파일 성공**
+- ✅ **기존 구조와 독립적인 설계**
+- ✅ **확장 가능한 아키텍처**
+- ✅ **MVP 환경 1:1 관계 (1 user = 1 store) 적용**
+
+### 9.2 특이사항
+- **기존 DTO 구조 재사용**: 새로운 DTO 생성 최소화
+- **BigDecimal 타입 사용**: 금액 정확도 보장
+- **캐싱 전략**: Redis 캐싱으로 성능 최적화 (TTL: 30분)
+
+### 9.3 개발 시간
+- **예상 개발 기간**: 3~4일
+- **실제 개발 완료**: 1일 (컴파일 테스트까지)
+
+---
+
+## 10. 첨부
+
+### 10.1 주요 파일 목록
+```
+analytics-service/src/main/java/com/kt/event/analytics/
+├── repository/
+│ ├── EventStatsRepository.java (수정)
+│ ├── ChannelStatsRepository.java (수정)
+│ └── TimelineDataRepository.java (수정)
+├── dto/response/
+│ ├── UserAnalyticsDashboardResponse.java (신규)
+│ ├── UserChannelAnalyticsResponse.java (신규)
+│ ├── UserRoiAnalyticsResponse.java (신규)
+│ └── UserTimelineAnalyticsResponse.java (신규)
+├── service/
+│ ├── UserAnalyticsService.java (신규)
+│ ├── UserChannelAnalyticsService.java (신규)
+│ ├── UserRoiAnalyticsService.java (신규)
+│ └── UserTimelineAnalyticsService.java (신규)
+└── controller/
+ ├── UserAnalyticsDashboardController.java (신규)
+ ├── UserChannelAnalyticsController.java (신규)
+ ├── UserRoiAnalyticsController.java (신규)
+ └── UserTimelineAnalyticsController.java (신규)
+```
+
+### 10.2 API 목록
+| No | HTTP Method | Endpoint | 설명 |
+|----|-------------|----------|------|
+| 1 | GET | `/api/v1/users/{userId}/analytics` | 사용자 전체 성과 대시보드 |
+| 2 | GET | `/api/v1/users/{userId}/analytics/channels` | 사용자 전체 채널별 성과 분석 |
+| 3 | GET | `/api/v1/users/{userId}/analytics/roi` | 사용자 전체 ROI 상세 분석 |
+| 4 | GET | `/api/v1/users/{userId}/analytics/timeline` | 사용자 전체 시간대별 참여 추이 |
+
+---
+
+**작성자**: AI Backend Developer
+**검토자**: -
+**승인자**: -
+**버전**: 1.0
+**최종 수정일**: 2025-10-28
diff --git a/claude/build-image-back.md b/claude/build-image-back.md
new file mode 100644
index 0000000..d7b822f
--- /dev/null
+++ b/claude/build-image-back.md
@@ -0,0 +1,82 @@
+# 백엔드 컨테이너이미지 작성가이드
+
+[요청사항]
+- 백엔드 각 서비스를의 컨테이너 이미지 생성
+- 실제 빌드 수행 및 검증까지 완료
+- '[결과파일]'에 수행한 명령어를 포함하여 컨테이너 이미지 작성 과정 생성
+
+[작업순서]
+- 서비스명 확인
+ 서비스명은 settings.gradle에서 확인
+
+ 예시) include 'common'하위의 4개가 서비스명임.
+ ```
+ rootProject.name = 'tripgen'
+
+ include 'common'
+ include 'user-service'
+ include 'location-service'
+ include 'ai-service'
+ include 'trip-service'
+ ```
+
+- 실행Jar 파일 설정
+ 실행Jar 파일명을 서비스명과 일치하도록 build.gradle에 설정 합니다.
+ ```
+ bootJar {
+ archiveFileName = '{서비스명}.jar'
+ }
+ ```
+
+- Dockerfile 생성
+ 아래 내용으로 deployment/container/Dockerfile-backend 생성
+ ```
+ # Build stage
+ FROM openjdk:23-oraclelinux8 AS builder
+ ARG BUILD_LIB_DIR
+ ARG ARTIFACTORY_FILE
+ COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
+
+ # Run stage
+ FROM openjdk:23-slim
+ ENV USERNAME=k8s
+ ENV ARTIFACTORY_HOME=/home/${USERNAME}
+ ENV JAVA_OPTS=""
+
+ # Add a non-root user
+ RUN adduser --system --group ${USERNAME} && \
+ mkdir -p ${ARTIFACTORY_HOME} && \
+ chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
+
+ WORKDIR ${ARTIFACTORY_HOME}
+ COPY --from=builder app.jar app.jar
+ RUN chown ${USERNAME}:${USERNAME} app.jar
+
+ USER ${USERNAME}
+
+ ENTRYPOINT [ "sh", "-c" ]
+ CMD ["java ${JAVA_OPTS} -jar app.jar"]
+ ```
+
+- 컨테이너 이미지 생성
+ 아래 명령으로 각 서비스 빌드. shell 파일을 생성하지 말고 command로 수행.
+ 서브에이젼트를 생성하여 병렬로 수행.
+ ```
+ DOCKER_FILE=deployment/container/Dockerfile-backend
+ service={서비스명}
+
+ docker build \
+ --platform linux/amd64 \
+ --build-arg BUILD_LIB_DIR="${서비스명}/build/libs" \
+ --build-arg ARTIFACTORY_FILE="${서비스명}.jar" \
+ -f ${DOCKER_FILE} \
+ -t ${서비스명}:latest .
+ ```
+- 생성된 이미지 확인
+ 아래 명령으로 모든 서비스의 이미지가 빌드되었는지 확인
+ ```
+ docker images | grep {서비스명}
+ ```
+
+[결과파일]
+deployment/container/build-image.md
diff --git a/claude/design-prompt.md b/claude/design-prompt.md
new file mode 100644
index 0000000..ea4ce28
--- /dev/null
+++ b/claude/design-prompt.md
@@ -0,0 +1,220 @@
+# 설계 프롬프트
+아래 순서대로 설계합니다.
+
+## UI/UX 설계
+command: "/design-uiux"
+prompt:
+```
+@uiux
+UI/UX 설계를 해주세요:
+- 'UI/UX설계가이드'를 준용하여 작성
+```
+
+---
+
+# 프로토타입 작성
+command: "/design-prototype"
+prompt:
+**1.작성**
+```
+@prototype
+프로토타입을 작성해 주세요:
+- '프로토타입작성가이드'를 준용하여 작성
+```
+
+---
+
+**2.검증**
+command: "/design-test-prototype"
+prompt:
+```
+@test-front
+프로토타입을 테스트 해 주세요.
+```
+
+---
+
+**3.오류수정**
+command: "/design-fix-prototype"
+prompt:
+```
+@fix as @front
+'[오류내용]'섹션에 제공된 오류를 해결해 주세요.
+프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
+{안내메시지}
+'[오류내용]'섹션 하위에 오류 내용을 제공
+```
+
+---
+
+**4.개선**
+command: "/design-improve-prototype"
+prompt:
+```
+@improve as @front
+'[개선내용]'섹션에 있는 내용을 개선해 주세요.
+프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
+{안내메시지}
+'[개선내용]'섹션 하위에 개선할 내용을 제공
+```
+
+---
+
+**5.유저스토리 품질 높이기**
+command: "/design-improve-userstory"
+prompt:
+```
+@analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
+@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
+```
+
+---
+
+**6.설계서 다시 업데이트**
+command: "/design-update-uiux"
+prompt:
+```
+@document @front
+현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
+```
+
+---
+
+## 클라우드 아키텍처 패턴 선정
+command: "/design-pattern"
+prompt:
+```
+@design-pattern
+클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
+- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
+```
+
+---
+
+## 논리아키텍처 설계
+command: "/design-logical"
+prompt:
+```
+@architecture
+논리 아키텍처를 설계해 주세요:
+- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
+
+```
+
+---
+
+## 외부 시퀀스 설계
+command: "/design-seq-outer"
+prompt:
+```
+@architecture
+외부 시퀀스 설계를 해 주세요:
+- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
+
+```
+
+---
+
+## 내부 시퀀스 설계
+command: "/design-seq-inner"
+prompt:
+```
+@architecture
+내부 시퀀스 설계를 해 주세요:
+- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
+
+```
+
+---
+
+## API 설계
+command: "/design-api"
+prompt:
+```
+@architecture
+API를 설계해 주세요:
+- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
+
+```
+
+---
+
+## 클래스 설계
+command: "/design-class"
+prompt:
+```
+@architecture
+'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
+프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
+{안내메시지}
+'[클래스설계 정보]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
+[클래스설계 정보]
+- 패키지 그룹: com.unicorn.tripgen
+- 설계 아키텍처 패턴
+ - User: Layered
+ - Trip: Clean
+ - Location: Layered
+ - AI: Layered
+```
+
+---
+
+## 데이터 설계
+command: "/design-data"
+prompt:
+```
+@architecture
+데이터 설계를 해주세요:
+- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
+```
+
+---
+
+## High Level 아키텍처 정의서 작성
+command: "/design-high-level"
+prompt:
+```
+@architecture
+'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
+'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
+{안내메시지}
+아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
+- CLOUD: Azure
+```
+
+---
+
+## 물리 아키텍처 설계
+command: "/design-physical"
+prompt:
+```
+@architecture
+'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
+'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
+{안내메시지}
+아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
+- CLOUD: Azure
+```
+
+## 프론트엔드 설계
+command: "/design-front"
+prompt:
+```
+@plan as @front
+'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
+프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
+{안내메시지}
+'[백엔드시스템]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
+[백엔드시스템]
+- 시스템: tripgen
+- 마이크로서비스: user-service, location-service, trip-service, ai-service
+- API문서
+ - user service: http://localhost:8081/v3/api-docs
+ - location service: http://localhost:8082/v3/api-docs
+ - trip service: http://localhost:8083/v3/api-docs
+ - ai service: http://localhost:8084/v3/api-docs
+[요구사항]
+- 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시
+- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
+```
+
diff --git a/claude/develop-prompt.md b/claude/develop-prompt.md
new file mode 100644
index 0000000..57c5a06
--- /dev/null
+++ b/claude/develop-prompt.md
@@ -0,0 +1,180 @@
+# 개발 프롬프트
+
+## 데이터베이스 설치계획서 작성 요청
+command: "/develop-db-guide"
+prompt:
+```
+@backing-service
+"데이터베이스설치계획서가이드"에 따라 데이터베이스 설치계획서를 작성해 주십시오.
+```
+
+---
+
+## 데이터베이스 설치 수행 요청
+command: "/develop-db-install"
+prompt:
+```
+@backing-service
+[요구사항]
+'데이터베이스설치가이드'에 따라 설치해 주세요.
+'[설치정보]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시하세요.
+{안내메시지}
+'[설치정보]'섹션 하위에 아래 예와 같이 설치에 필요한 정보를 추가해 주세요.
+- 설치대상환경: 개발환경
+- AKS Resource Group: rg-digitalgarage-01
+- AKS Name: aks-digitalgarage-01
+- Namespace: tripgen-dev
+```
+
+---
+
+## 데이터베이스 설치 제거 요청 (필요시)
+command: "/develop-db-remove"
+prompt:
+```
+@backing-service
+[요구사항]
+- "데이터베이스설치결과서"를 보고 관련된 모든 리소스를 삭제
+- "캐시설치결과서"를 보고 관련된 모든 리소스를 삭제
+- 현재 OS에 맞게 수행
+- 서브 에이젼트를 병렬로 수행하여 삭제
+- 결과파일은 생성할 필요 없고 화면에만 결과 표시
+[참고자료]
+- 데이터베이스설치결과서
+- 캐시설치결과서
+```
+
+---
+
+## Message Queue 설치 계획서 작성 요청
+command: "/develop-mq-guide"
+prompt:
+```
+@backing-service
+"MQ설치게획서가이드"에 따라 Message Queue 설치계획서를 작성해 주세요.
+```
+
+---
+
+## Message Queue 설치 수행 요청(필요시)
+command: "/develop-mq-install"
+prompt:
+```
+@backing-service
+[요구사항]
+'MQ설치가이드'에 따라 설치해 주세요.
+'[설치정보]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시하세요.
+{안내메시지}
+'[설치정보]'섹션 하위에 아래 예와 같이 설치에 필요한 정보를 추가해 주세요.
+- 설치대상환경: 개발환경
+- Resource Group: rg-digitalgarage-01
+- Namespace: tripgen-dev
+```
+
+---
+
+## Message Queue 설치 제거 요청
+command: "/develop-mq-remove"
+prompt:
+```
+@backing-service
+[요구사항]
+- "MQ설치결과서"를 보고 관련된 모든 리소스를 삭제
+- 현재 OS에 맞게 수행
+- 서브 에이젼트를 병렬로 수행하여 삭제
+- 결과파일은 생성할 필요 없고 화면에만 결과 표시
+[참고자료]
+- MQ설치결과서
+```
+
+---
+
+## 백엔드 개발 요청
+command: "/develop-dev-backend"
+prompt:
+```
+@dev-backend
+"백엔드개발가이드"에 따라 개발해 주세요.
+프롬프트에 '[개발정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
+{안내메시지}
+[개발정보]
+- 개발 아키텍처패턴
+ - auth: Layered
+ - bill-inquiry: Clean
+ - product-change: Layered
+ - kos-mock: Layered
+```
+
+---
+
+## 백엔드 오류 해결 요청
+command: "/develop-fix-backend"
+prompt:
+```
+@fix as @back
+개발된 각 서비스와 common 모듈을 컴파일하고 에러를 해결해 주세요.
+- common 모듈 우선 수행
+- 각 서비스별로 서브 에이젠트를 병렬로 수행
+- 컴파일이 모두 성공할때까지 계속 수행
+```
+
+---
+
+## 서비스 실행파일 작성 요청
+command: "/develop-make-run-profile"
+prompt:
+```
+@test-backend
+'서비스실행파일작성가이드'에 따라 테스트를 해 주세요.
+프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
+DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
+{안내메시지}
+[작성정보]
+- API Key
+ - Claude: sk-ant-ap...
+ - OpenAI: sk-proj-An4Q...
+ - Open Weather Map: 1aa5b...
+ - Kakao API Key: 5cdc24....
+```
+
+---
+
+## 백엔드 테스트 요청
+command: "/develop-test-backend"
+prompt:
+```
+@test-backend
+'백엔드테스트가이드'에 따라 테스트를 해 주세요.
+프롬프트에 '[테스트정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
+테스트 대상 서비스를 지정안하면 모든 서비스를 테스트 합니다.
+{안내메시지}
+'[테스트정보]'섹션 하위에 아래 예와 같이 테스트에 필요한 정보를 제시해 주세요.
+테스트 대상 서비스를 콤마로 구분하여 입력할 수 있으며 전체를 테스트 할 때는 '전체'라고 입력하세요.
+- 서비스: user-service
+- API Key
+ - Claude: sk-ant-ap...
+ - OpenAI: sk-proj-An4Q...
+ - Open Weather Map: 1aa5b...
+ - Kakao API Key: 5cdc24....
+```
+
+---
+
+## 프론트엔드 개발 요청
+command: "/develop-dev-front"
+prompt:
+```
+@dev-front
+"프론트엔드개발가이드"에 따라 개발해 주세요.
+프롬프트에 '[개발정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
+{안내메시지}
+'[개발정보]'섹션 하위에 아래 예와 같이 개발에 필요한 정보를 제시해 주세요.
+[개발정보]
+- 개발프레임워크: Typescript + React 18
+- UI프레임워크: MUI v5
+- 상태관리: Redux Toolkit
+- 라우팅: React Router v6
+- API통신: Axios
+- 스타일링: MUI + styled-components
+- 빌드도구: Vite
+```
\ No newline at end of file
diff --git a/claude/run-container-guide-back.md b/claude/run-container-guide-back.md
new file mode 100644
index 0000000..4e41684
--- /dev/null
+++ b/claude/run-container-guide-back.md
@@ -0,0 +1,187 @@
+# 백엔드 컨테이너 실행방법 가이드
+
+[요청사항]
+- 백엔드 각 서비스들의 컨테이너 이미지를 컨테이너로 실행하는 가이드 작성
+- 실제 컨테이너 실행은 하지 않음
+- '[결과파일]'에 수행할 명령어를 포함하여 컨테이너 실행 가이드 생성
+
+[작업순서]
+- 실행정보 확인
+ 프롬프트의 '[실행정보]'섹션에서 아래정보를 확인
+ - {ACR명}: 컨테이너 레지스트리 이름
+ - {VM.KEY파일}: VM 접속하는 Private Key파일 경로
+ - {VM.USERID}: VM 접속하는 OS 유저명
+ - {VM.IP}: VM IP
+ 예시)
+ ```
+ [실행정보]
+ - ACR명: acrdigitalgarage01
+ - VM
+ - KEY파일: ~/home/bastion-dg0500
+ - USERID: azureuser
+ - IP: 4.230.5.6
+ ```
+
+- 시스템명과 서비스명 확인
+ settings.gradle에서 확인.
+ - 시스템명: rootProject.name
+ - 서비스명: include 'common'하위의 include문 뒤의 값임
+
+ 예시) include 'common'하위의 4개가 서비스명임.
+ ```
+ rootProject.name = 'tripgen'
+
+ include 'common'
+ include 'user-service'
+ include 'location-service'
+ include 'ai-service'
+ include 'trip-service'
+ ```
+
+- VM 접속 방법 안내
+ - Linux/Mac은 기본 터미널을 실행하고 Window는 Window Terminal을 실행하도록 안내
+ - 터미널에서 아래 명령으로 VM에 접속하도록 안내
+ 최초 한번 Private key파일의 모드를 변경.
+ ```
+ chmod 400 {VM.KEY파일}
+ ```
+
+ private key를 이용하여 접속.
+ ```
+ ssh -i {VM.KEY파일} {VM.USERID}@{VM.IP}
+ ```
+ - 접속 후 docker login 방법 안내
+ ```
+ docker login {ACR명}.azurecr.io -u {ID} -p {암호}
+ ```
+
+- Git Repository 클론 안내
+ - workspace 디렉토리 생성 및 이동
+ ```
+ mkdir -p ~/home/workspace
+ cd ~/home/workspace
+ ```
+ - 소스 Clone
+ ```
+ git clone {원격 Git Repository 주소}
+ ```
+ 예)
+ ```
+ git clone https://github.com/cna-bootcamp/phonebill.git
+ ```
+ - 프로젝트 디렉토리로 이동
+ ```
+ cd {시스템명}
+ ```
+
+- 어플리케이션 빌드 및 컨테이너 이미지 생성 방법 안내
+ 'deployment/container/build-image.md' 파일을 열어 가이드대로 수행하도록 안내
+
+- 컨테이너 레지스트리 로그인 방법 안내
+ 아래 명령으로 {ACR명}의 인증정보를 구합니다.
+ 'username'이 ID이고 'passwords[0].value'가 암호임.
+ ```
+ az acr credential show --name {ACR명}
+ ```
+
+ 예시) ID=dg0200cr, 암호={암호}
+ ```
+ $ az acr credential show --name dg0200cr
+ {
+ "passwords": [
+ {
+ "name": "password",
+ "value": "{암호}"
+ },
+ {
+ "name": "password2",
+ "value": "{암호2}"
+ }
+ ],
+ "username": "dg0200cr"
+ }
+ ```
+
+ 아래와 같이 로그인 명령을 작성합니다.
+ ```
+ docker login {ACR명}.azurecr.io -u {ID} -p {암호}
+ ```
+
+- 컨테이너 푸시 방법 안내
+ Docker Tag 명령으로 이미지를 tag하는 명령을 작성합니다.
+ ```
+ docker tag {서비스명}:latest {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest
+ ```
+ 이미지 푸시 명령을 작성합니다.
+ ```
+ docker push {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest
+ ```
+
+- 컨테이너 실행 명령 생성
+ - 환경변수 확인
+ '{서비스명}/.run/{서비스명}.run.xml' 을 읽어 각 서비스의 환경변수 찾음.
+ "env.map"의 각 entry의 key와 value가 환경변수임.
+
+ 예제) SERVER_PORT=8081, DB_HOST=20.249.137.175가 환경변수임
+ ```
+
+
+
+
+
-
+
@@ -30,7 +30,7 @@
-
+
diff --git a/participation-service/add-channel-column.sql b/participation-service/add-channel-column.sql
new file mode 100644
index 0000000..25612a9
--- /dev/null
+++ b/participation-service/add-channel-column.sql
@@ -0,0 +1,14 @@
+-- participation-service channel 컬럼 추가 스크립트
+-- 실행 방법: psql -h 4.230.72.147 -U eventuser -d participationdb -f add-channel-column.sql
+
+-- channel 컬럼 추가
+ALTER TABLE participants
+ADD COLUMN IF NOT EXISTS channel VARCHAR(20);
+
+-- 기존 데이터에 기본값 설정
+UPDATE participants
+SET channel = 'SNS'
+WHERE channel IS NULL;
+
+-- 커밋
+COMMIT;
diff --git a/participation-service/fix-indexes.sql b/participation-service/fix-indexes.sql
new file mode 100644
index 0000000..136b256
--- /dev/null
+++ b/participation-service/fix-indexes.sql
@@ -0,0 +1,14 @@
+-- participation-service 인덱스 중복 문제 해결 스크립트
+-- 실행 방법: psql -h 4.230.72.147 -U eventuser -d participationdb -f fix-indexes.sql
+
+-- 기존 중복 인덱스 삭제 (존재하는 경우만)
+DROP INDEX IF EXISTS idx_event_id;
+DROP INDEX IF EXISTS idx_event_phone;
+
+-- 새로운 고유 인덱스는 Hibernate가 자동 생성하므로 별도 생성 불필요
+-- 다음 서비스 시작 시 자동으로 생성됩니다:
+-- - idx_draw_log_event_id (draw_logs 테이블)
+-- - idx_participant_event_id (participants 테이블)
+-- - idx_participant_event_phone (participants 테이블)
+
+COMMIT;
diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java
index 27b5acc..2cfe768 100644
--- a/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java
+++ b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java
@@ -44,14 +44,31 @@ public class ParticipationService {
public ParticipationResponse participate(String eventId, ParticipationRequest request) {
log.info("이벤트 참여 시작 - eventId: {}, phoneNumber: {}", eventId, request.getPhoneNumber());
- // 중복 참여 체크
- if (participantRepository.existsByEventIdAndPhoneNumber(eventId, request.getPhoneNumber())) {
+ // 중복 참여 체크 - 상세 디버깅
+ log.info("중복 참여 체크 시작 - eventId: '{}', phoneNumber: '{}'", eventId, request.getPhoneNumber());
+
+ boolean isDuplicate = participantRepository.existsByEventIdAndPhoneNumber(eventId, request.getPhoneNumber());
+ log.info("중복 참여 체크 결과 - isDuplicate: {}", isDuplicate);
+
+ if (isDuplicate) {
+ log.warn("중복 참여 감지! eventId: '{}', phoneNumber: '{}'", eventId, request.getPhoneNumber());
throw new DuplicateParticipationException();
}
- // 참여자 ID 생성
- Long maxId = participantRepository.findMaxIdByEventId(eventId).orElse(0L);
- String participantId = Participant.generateParticipantId(eventId, maxId + 1);
+ log.info("중복 참여 체크 통과 - 참여 진행");
+
+ // 참여자 ID 생성 - 날짜별 최대 순번 기반
+ String dateTime;
+ if (eventId != null && eventId.length() >= 16 && eventId.startsWith("evt_")) {
+ dateTime = eventId.substring(4, 12); // "20250124"
+ } else {
+ dateTime = java.time.LocalDate.now().format(
+ java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
+ }
+
+ String datePrefix = "prt_" + dateTime + "_";
+ Integer maxSequence = participantRepository.findMaxSequenceByDatePrefix(datePrefix);
+ String participantId = String.format("prt_%s_%03d", dateTime, maxSequence + 1);
// 참여자 저장
Participant participant = Participant.builder()
diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java
index 748f68c..fb0fad9 100644
--- a/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java
+++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java
@@ -14,7 +14,7 @@ import lombok.*;
@Entity
@Table(name = "draw_logs",
indexes = {
- @Index(name = "idx_event_id", columnList = "event_id")
+ @Index(name = "idx_draw_log_event_id", columnList = "event_id")
}
)
@Getter
diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java
index 0aac1f8..5ee4fa5 100644
--- a/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java
+++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java
@@ -13,8 +13,9 @@ import lombok.*;
@Entity
@Table(name = "participants",
indexes = {
- @Index(name = "idx_event_id", columnList = "event_id"),
- @Index(name = "idx_event_phone", columnList = "event_id, phone_number")
+ @Index(name = "idx_participant_event_id", columnList = "event_id"),
+ @Index(name = "idx_participant_event_phone", columnList = "event_id, phone_number")
+
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_event_phone", columnNames = {"event_id", "phone_number"})
diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java
index d7563dd..e03560f 100644
--- a/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java
+++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java
@@ -106,4 +106,16 @@ public interface ParticipantRepository extends JpaRepository
* @return 참여자 Optional
*/
Optional findByEventIdAndParticipantId(String eventId, String participantId);
+
+ /**
+ * 특정 날짜 패턴의 참여자 ID 중 최대 순번 조회
+ *
+ * @param datePrefix 날짜 접두사 (예: "prt_20251028_")
+ * @return 최대 순번
+ */
+ @Query(value = "SELECT COALESCE(MAX(CAST(SUBSTRING(participant_id FROM LENGTH(?1) + 1) AS INTEGER)), 0) " +
+ "FROM participants " +
+ "WHERE participant_id LIKE CONCAT(?1, '%')",
+ nativeQuery = true)
+ Integer findMaxSequenceByDatePrefix(@Param("datePrefix") String datePrefix);
}
diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java
index b43fdfc..855ba0f 100644
--- a/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java
+++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java
@@ -24,6 +24,8 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
+ // Actuator endpoints
+ .requestMatchers("/actuator/**").permitAll()
.anyRequest().permitAll()
);
diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/DebugController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/DebugController.java
new file mode 100644
index 0000000..a186d0f
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/DebugController.java
@@ -0,0 +1,104 @@
+package com.kt.event.participation.presentation.controller;
+
+import com.kt.event.participation.domain.participant.Participant;
+import com.kt.event.participation.domain.participant.ParticipantRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 디버깅용 컨트롤러
+ */
+@Slf4j
+@CrossOrigin(origins = "http://localhost:3000")
+@RestController
+@RequestMapping("/debug")
+@RequiredArgsConstructor
+public class DebugController {
+
+ private final ParticipantRepository participantRepository;
+
+ /**
+ * 중복 참여 체크 테스트
+ */
+ @GetMapping("/exists/{eventId}/{phoneNumber}")
+ public String testExists(@PathVariable String eventId, @PathVariable String phoneNumber) {
+ try {
+ log.info("디버그: 중복 체크 시작 - eventId: {}, phoneNumber: {}", eventId, phoneNumber);
+
+ boolean exists = participantRepository.existsByEventIdAndPhoneNumber(eventId, phoneNumber);
+
+ log.info("디버그: 중복 체크 결과 - exists: {}", exists);
+
+ long totalCount = participantRepository.count();
+ long eventCount = participantRepository.countByEventId(eventId);
+
+ return String.format(
+ "eventId: %s, phoneNumber: %s, exists: %s, totalCount: %d, eventCount: %d",
+ eventId, phoneNumber, exists, totalCount, eventCount
+ );
+
+ } catch (Exception e) {
+ log.error("디버그: 예외 발생", e);
+ return "ERROR: " + e.getMessage();
+ }
+ }
+
+ /**
+ * 모든 참여자 데이터 조회
+ */
+ @GetMapping("/participants")
+ public String getAllParticipants() {
+ try {
+ List 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 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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java
index 0643fb9..078f913 100644
--- a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java
+++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java
@@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.*;
* @since 2025-01-24
*/
@Slf4j
+@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
@@ -41,12 +42,21 @@ public class ParticipationController {
@PathVariable String eventId,
@Valid @RequestBody ParticipationRequest request) {
- log.info("이벤트 참여 요청 - eventId: {}", eventId);
- ParticipationResponse response = participationService.participate(eventId, request);
+ log.info("컨트롤러: 이벤트 참여 요청 시작 - eventId: '{}', phoneNumber: '{}'", eventId, request.getPhoneNumber());
- return ResponseEntity
- .status(HttpStatus.CREATED)
- .body(ApiResponse.success(response));
+ try {
+ log.info("컨트롤러: 서비스 호출 전");
+ ParticipationResponse response = participationService.participate(eventId, request);
+ log.info("컨트롤러: 서비스 호출 완료 - participantId: {}", response.getParticipantId());
+
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(ApiResponse.success(response));
+
+ } catch (Exception e) {
+ log.error("컨트롤러: 예외 발생 - type: {}, message: {}", e.getClass().getSimpleName(), e.getMessage());
+ throw e;
+ }
}
/**
diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java
index f7fbc83..3adf1fe 100644
--- a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java
+++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java
@@ -8,11 +8,10 @@ import com.kt.event.participation.application.dto.ParticipationResponse;
import com.kt.event.participation.application.service.WinnerDrawService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import org.springdoc.core.annotations.ParameterObject;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
@@ -26,6 +25,7 @@ import org.springframework.web.bind.annotation.*;
* @since 2025-01-24
*/
@Slf4j
+@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
diff --git a/participation-service/src/main/resources/application.yml b/participation-service/src/main/resources/application.yml
index fa3a8c3..dcc2575 100644
--- a/participation-service/src/main/resources/application.yml
+++ b/participation-service/src/main/resources/application.yml
@@ -18,7 +18,7 @@ spring:
# JPA 설정
jpa:
hibernate:
- ddl-auto: ${DDL_AUTO:validate}
+ ddl-auto: ${DDL_AUTO:update}
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
@@ -51,7 +51,7 @@ spring:
# JWT 설정
jwt:
- secret: ${JWT_SECRET:kt-event-marketing-secret-key-for-development-only-change-in-production}
+ secret: ${JWT_SECRET:dev-jwt-secret-key-for-development-only}
expiration: ${JWT_EXPIRATION:86400000}
# 서버 설정
@@ -73,3 +73,19 @@ logging:
max-file-size: 10MB
max-history: 7
total-size-cap: 100MB
+# Actuator
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics,prometheus
+ base-path: /actuator
+ endpoint:
+ health:
+ show-details: always
+ show-components: always
+ health:
+ livenessState:
+ enabled: true
+ readinessState:
+ enabled: true
\ No newline at end of file
diff --git a/run-event-service.ps1 b/run-event-service.ps1
new file mode 100644
index 0000000..087d337
--- /dev/null
+++ b/run-event-service.ps1
@@ -0,0 +1,22 @@
+# Event Service 실행 스크립트
+
+$env:SERVER_PORT="8081"
+$env:DB_HOST="20.249.177.232"
+$env:DB_PORT="5432"
+$env:DB_NAME="eventdb"
+$env:DB_USERNAME="eventuser"
+$env:DB_PASSWORD="Hi5Jessica!"
+$env:REDIS_HOST="localhost"
+$env:REDIS_PORT="6379"
+$env:REDIS_PASSWORD=""
+$env:KAFKA_BOOTSTRAP_SERVERS="20.249.182.13:9095,4.217.131.59:9095"
+$env:DDL_AUTO="update"
+$env:LOG_LEVEL="DEBUG"
+$env:SQL_LOG_LEVEL="DEBUG"
+$env:CONTENT_SERVICE_URL="http://localhost:8082"
+$env:DISTRIBUTION_SERVICE_URL="http://localhost:8084"
+$env:JWT_SECRET="kt-event-marketing-jwt-secret-key-for-development-only-minimum-256-bits-required"
+
+Write-Host "Starting Event Service on port 8081..." -ForegroundColor Green
+Write-Host "Logs will be saved to logs/event-service.log" -ForegroundColor Yellow
+./gradlew event-service:bootRun 2>&1 | Tee-Object -FilePath logs/event-service.log
diff --git a/test-existing-phone-other-event.json b/test-existing-phone-other-event.json
new file mode 100644
index 0000000..92c9a0a
--- /dev/null
+++ b/test-existing-phone-other-event.json
@@ -0,0 +1,9 @@
+{
+ "name": "기존전화번호테스트",
+ "phoneNumber": "010-2044-4103",
+ "email": "test@example.com",
+ "channel": "SNS",
+ "storeVisited": false,
+ "agreeMarketing": true,
+ "agreePrivacy": true
+}
\ No newline at end of file
diff --git a/test-new-phone.json b/test-new-phone.json
new file mode 100644
index 0000000..53efd85
--- /dev/null
+++ b/test-new-phone.json
@@ -0,0 +1,9 @@
+{
+ "name": "새로운테스트",
+ "phoneNumber": "010-8888-8888",
+ "email": "newtest@example.com",
+ "channel": "SNS",
+ "storeVisited": false,
+ "agreeMarketing": true,
+ "agreePrivacy": true
+}
\ No newline at end of file
diff --git a/test-participate-new.json b/test-participate-new.json
new file mode 100644
index 0000000..eea92a6
--- /dev/null
+++ b/test-participate-new.json
@@ -0,0 +1,9 @@
+{
+ "name": "새로운테스트",
+ "phoneNumber": "010-9999-9999",
+ "email": "newtest@example.com",
+ "channel": "SNS",
+ "storeVisited": false,
+ "agreeMarketing": true,
+ "agreePrivacy": true
+}
\ No newline at end of file
diff --git a/test-participate.json b/test-participate.json
new file mode 100644
index 0000000..db17851
--- /dev/null
+++ b/test-participate.json
@@ -0,0 +1,9 @@
+{
+ "name": "테스트",
+ "phoneNumber": "010-2044-4103",
+ "email": "test@example.com",
+ "channel": "SNS",
+ "storeVisited": false,
+ "agreeMarketing": true,
+ "agreePrivacy": true
+}
\ No newline at end of file
diff --git a/test-token.txt b/test-token.txt
new file mode 100644
index 0000000..9e5b876
--- /dev/null
+++ b/test-token.txt
@@ -0,0 +1,22 @@
+C:\Users\KTDS\home\workspace\kt-event-marketing\generate-test-token.py:26: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
+ now = datetime.datetime.utcnow()
+================================================================================
+JWT Ʈ ū
+================================================================================
+
+User ID: 6db043d0-b303-4577-b9dd-6d366cc59fa0
+Store ID: 34000028-01fd-4ed1-975c-35f7c88b6547
+Email: test@example.com
+Name: Test User
+Roles: ['ROLE_USER']
+
+================================================================================
+Access Token:
+================================================================================
+eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZGIwNDNkMC1iMzAzLTQ1NzctYjlkZC02ZDM2NmNjNTlmYTAiLCJzdG9yZUlkIjoiMzQwMDAwMjgtMDFmZC00ZWQxLTk3NWMtMzVmN2M4OGI2NTQ3IiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNTQ5MjkxLCJleHAiOjE3OTMwODUyOTF9.PfQ_NhXRjdfsmQn0NcAKgxcje2XaIL-TlQk_f_DVU38
+
+================================================================================
+ :
+================================================================================
+curl -H "Authorization: Bearer " http://localhost:8081/api/v1/events
+