Compare commits

...

47 Commits

Author SHA1 Message Date
doyeon 060921e756 백엔드 컨테이너 실행 가이드 문서 추가
- deployment/container/run-container-guide-back.md 파일 생성
- VM 접속 및 ACR 로그인 방법
- 컨테이너 실행 및 관리 방법
- 문제 해결 가이드
- 헬스체크 및 모니터링 방법
- 자동화 스크립트 예시
- 서비스별 실행 예시 포함

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:17:23 +09:00
doyeon b198c46d06 Analytics 서비스 및 보안 기능 업데이트
- Analytics 서비스 구현 추가 (API, 소스 코드)
- Event 서비스 소스 코드 추가
- 보안 관련 공통 컴포넌트 업데이트 (JWT, UserPrincipal, ErrorCode)
- API 컨벤션 및 명세서 업데이트
- 데이터베이스 SQL 스크립트 추가
- 백엔드 개발 문서 및 테스트 가이드 추가
- Kafka 메시지 체크 도구 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:11:00 +09:00
doyeon 003b3843cc Merge branch 'develop' into docker/participation
- 충돌 해결 완료
- settings.local.json 및 make-run-profile.md 병합

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:10:47 +09:00
hyeda2020 8323b795df Merge pull request #8 from ktds-dg0501/feature/user
UserPricipal 충돌 부분 조치
2025-10-27 15:28:05 +09:00
hyeda2020 ce3e01008a Merge branch 'develop' into feature/user 2025-10-27 15:27:57 +09:00
wonho ea807cf33e UserPricipal 충돌 부분 조치 2025-10-27 15:19:35 +09:00
Hyowon Yang 394c7a0029 Merge pull request #7 from ktds-dg0501/feature/analytics
Feature/analytics
2025-10-27 15:10:51 +09:00
Hyowon Yang 9b247ca058 Merge branch 'develop' into feature/analytics 2025-10-27 15:10:31 +09:00
Hyowon Yang 25ff21f684 analytics-백엔드테스트완료 2025-10-27 15:04:03 +09:00
doyeon e70f121db5 배포 가이드 및 명령어 추가
- 배포 관련 slash 명령어 추가 (컨테이너 이미지 빌드, 실행, K8s 배포, CI/CD)
- 백엔드/프론트엔드 각각에 대한 배포 가이드 문서 추가
- 프롬프트 파일 추가 (think, design, develop)
- deployment 디렉토리 생성
- 기존 명령어 파일 업데이트

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:03:36 +09:00
Hyowon Yang 180e5978a0 kafka 설정변경 2025-10-27 14:42:03 +09:00
doyeon 6465719b2c SecurityConfig와 application.yml 설정 업데이트
- SecurityConfig: CORS 설정 및 보안 필터 체인 구성
- application.yml: 환경 변수 플레이스홀더 방식으로 변경

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 14:06:02 +09:00
Hayoung Song 42f0665f5e Merge pull request #6 from ktds-dg0501/feature/event
feature/event
2025-10-27 13:43:44 +09:00
Hayoung Song 6728b98878 Merge branch 'develop' into feature/event 2025-10-27 13:43:15 +09:00
kkkd-max 6a31e5204b Merge pull request #3 from ktds-dg0501/feature/participation
Participation Service 백엔드 개발 완료
2025-10-27 13:41:22 +09:00
merrycoral 918e71cc35 API 경로를 /api/v1 접두사로 변경
- EventController: /api/events -> /api/v1/events
- JobController: /api/jobs -> /api/v1/jobs
- 모든 API 엔드포인트 테스트 완료

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 13:38:08 +09:00
Hyowon Yang 4197c72af5 Merge branch 'main' of https://github.com/ktds-dg0501/kt-event-marketing into feature/analytics 2025-10-27 11:25:08 +09:00
Hyowon Yang 97a3c41fff analytics 서비스 api개발 2025-10-27 11:23:56 +09:00
merrycoral 45f370a944 프로젝트 구성 개선 및 Kafka 인프라 추가
## 변경사항

### 보안 강화
- gradle.properties를 .gitignore에 추가 (로컬 환경 설정 제외)

### 공통 모듈
- ErrorCode에 Legacy 호환용 에러 코드 추가 (NOT_FOUND, INVALID_INPUT_VALUE)

### Event Service
- hibernate-types 라이브러리 제거 (Hibernate 6 네이티브 지원으로 대체)

### Kafka 인프라 추가
- Kafka 메시지 DTO 3개 추가
  * AIEventGenerationJobMessage: AI 이벤트 생성 작업 메시지
  * EventCreatedMessage: 이벤트 생성 완료 메시지
  * ImageGenerationJobMessage: 이미지 생성 작업 메시지
- Kafka Producer/Consumer 3개 추가
  * EventKafkaProducer: 이벤트 메시지 발행
  * AIJobKafkaConsumer: AI 작업 메시지 소비
  * ImageJobKafkaConsumer: 이미지 작업 메시지 소비

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 11:16:50 +09:00
Hyowon Yang a34037cdd1 TimelineData 샘플 데이터 생성 기능 추가
- 각 이벤트당 30일 치 daily 데이터 생성
- 참여자, 조회수, 참여행동, 전환수, 누적 참여자 수 포함
- 총 90건의 시간대별 데이터 생성 (3 이벤트 × 30일)
2025-10-27 11:14:54 +09:00
merrycoral 4d180c2a9f Event Service 개발 환경 구축 및 타입 시스템 개선
주요 변경사항:
- UserPrincipal 및 JWT 인증 시스템을 Long에서 UUID로 변경
- Event 엔티티 JPA 설정 최적화 (Lazy loading 및 fetch 전략 개선)
- 개발 환경용 DevAuthenticationFilter 추가 (User Service 구현 전까지 임시 사용)
- EventServiceApplication 문법 오류 수정
- Hibernate multiple bags 문제 해결 (List를 Set으로 변경)

기술 세부사항:
- common/UserPrincipal: Long → UUID 타입 변경, @Builder 어노테이션 추가
- common/JwtTokenProvider: UUID 지원 추가
- event-service/Event: Set 컬렉션 사용, Lazy loading 최적화
- event-service/EventService: Hibernate.initialize()로 컬렉션 초기화
- event-service/EventRepository: fetch join 쿼리 최적화
- event-service/SecurityConfig: DevAuthenticationFilter 통합

테스트 결과:
- 모든 Event CRUD API 정상 작동 확인
- PostgreSQL 연결 정상
- 비즈니스 로직 검증 정상 작동

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 11:04:03 +09:00
Hyowon Yang 884c964af6 Kafka Consumer 자동 시작 활성화 (autoStartup=true) 2025-10-27 10:59:31 +09:00
Hyowon Yang 704a4ae4d1 Kafka 활성화 설정 추가 (KAFKA_ENABLED=true) 2025-10-27 10:43:51 +09:00
Hyowon Yang 7fa1f8cc89 .gitignore에 Gradle 추가 및 빌드 파일 제거 2025-10-27 10:21:43 +09:00
Hyowon Yang 0ed0309e66 kafka 환경변수설정 2025-10-27 10:13:06 +09:00
Hyowon Yang f3901c8ef8 kafka 설정 변경
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 16:37:05 +09:00
Hyowon Yang 7735c8472b totalViews 필드 추가 및 배포완료 이벤트 개선
변경 사항:
1. EventStats에 totalViews 필드 추가 (모든 채널 노출 수 합계)
2. DistributionCompletedEvent에 expectedViews 필드 추가
3. DistributionCompletedConsumer 개선:
   - ChannelStats.impressions에 expectedViews 저장
   - updateTotalViews() 메서드로 전체 노출 수 집계
4. SampleDataLoader에 채널별 예상 노출 수 설정:
   - 이벤트1: 총 20,000 (우리동네TV 5K, 지니TV 10K, 링고비즈 3K, SNS 2K)
   - 이벤트2: 총 14,000
   - 이벤트3: 총 6,000

설계 다이어그램과 일치:
- 채널별 예상 노출 수 저장
- 총 노출 수 실시간 집계
- 멱등성 및 캐시 무효화 유지

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 16:08:32 +09:00
Hyowon Yang 7b3ca40e22 샘플 데이터 발행량 축소 (타임아웃 방지)
- ParticipantRegistered 이벤트: 27,610건 → 180건
- 이벤트별 참여자 수:
  - 이벤트1: 15,420명 → 100명
  - 이벤트2: 8,950명 → 50명
  - 이벤트3: 3,240명 → 30명
- Kafka 발행 지연 로직 제거 (불필요)
- MVP 테스트에 충분한 데이터 유지

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 15:27:30 +09:00
Hyowon Yang 4c8165bd20 샘플 토픽명으로 변경 (sample. 접두사 추가)
- 다른 서비스 개발자들의 운영 토픽과 충돌 방지
- MVP용 샘플 토픽: sample.event.created, sample.participant.registered, sample.distribution.completed
- KafkaTopicConfig, SampleDataLoader, 3개 Consumer 모두 업데이트

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 15:15:30 +09:00
Hyowon Yang 31fb1c541b kafka활성화
🚀 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 14:59:24 +09:00
Hyowon Yang 21b8fe5efb 배치-redis,db조회수정
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 14:26:11 +09:00
Hyowon Yang b0b0ba3263 배치서비스개발, redis설정
- Analytics 5분 단위 배치 스케줄러 추가
- 초기 데이터 로딩 기능 구현 (서버 시작 30초 후)
- Redis 설정 업데이트 (외부 Redis 서버 연결)
- Redis 읽기 전용 오류 처리 추가
- IntelliJ 실행 프로파일 생성
- @EnableScheduling 활성화

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 13:39:10 +09:00
merrycoral 55c7b838dd DDL 외래키 제약조건 주석처리
모든 테이블의 외래키 제약조건을 주석처리:
- event_channels.fk_event_channels_event
- generated_images.fk_generated_images_event
- ai_recommendations.fk_ai_recommendations_event
- jobs.fk_jobs_event

사유:
- JPA에서 연관관계 관리로 충분
- 개발 환경에서 유연성 확보
- 필요시 운영 환경에서 활성화 가능

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 13:28:08 +09:00
merrycoral 860293b2b9 Event Service DDL 최적화 작업 (Medium/Low 우선순위)
Medium 우선순위 수정:
1. created_at, updated_at 기본값 정책 정리
   - DEFAULT CURRENT_TIMESTAMP 제거
   - JPA @CreatedDate/@LastModifiedDate로 관리 명시
   - 주석으로 관리 주체 명확화

2. updated_at Trigger 비활성화
   - JPA 환경에서는 애플리케이션 레벨 관리
   - Trigger 코드는 주석으로 보존 (필요시 활성화 가능)
   - 이중 업데이트 메커니즘 제거로 성능 개선

Low 우선순위 추가:
3. 복합 인덱스 추가 (쿼리 성능 최적화)
   - events: (user_id, status, created_at DESC)
     → 사용자별 상태 필터링 + 최신순 정렬 최적화
   - generated_images: (event_id, is_selected)
     → 이벤트별 선택 이미지 조회 최적화
   - ai_recommendations: (event_id, is_selected)
     → 이벤트별 선택 추천 조회 최적화
   - jobs: (status, created_at DESC)
     → 상태별 최신 작업 조회 최적화

영향:
- JPA와 Database 역할 분담 명확화
- 불필요한 중복 메커니즘 제거
- 쿼리 성능 향상 (복합 인덱스)
- 유지보수성 개선

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 13:25:31 +09:00
merrycoral c63cf950eb Event Service 엔티티와 DDL 형상 일치화 작업
Critical 및 High 우선순위 이슈 수정:

1. Event 엔티티 nullable 필드 변경
   - eventName: nullable로 변경 (AI 추천 후 설정)
   - startDate, endDate: nullable로 변경 (AI 추천 후 설정)

2. Event.publish() 검증 로직 강화
   - eventName 필수 검증 추가
   - startDate, endDate 필수 검증 추가
   - 기간 유효성 검증 추가 (시작일 <= 종료일)

3. DDL 스키마 수정
   - event_name NOT NULL 제거
   - start_date, end_date NOT NULL 제거
   - chk_event_period 제약조건 수정 (NULL 허용)

4. jobs 테이블 외래키 추가
   - event_id에 대한 외래키 제약조건 추가
   - ON DELETE CASCADE 설정으로 데이터 무결성 보장

영향:
- 이벤트 생성 시 eventName, startDate, endDate를 NULL로 허용
- 배포(publish) 시점에 필수 필드 검증으로 데이터 무결성 보장
- 이벤트 삭제 시 관련 Job 자동 삭제로 고아 레코드 방지

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 13:22:14 +09:00
Hyowon Yang fb60c6f8a6 외부 API 호출 임시 비활성화 - 샘플 데이터 사용 2025-10-24 12:41:50 +09:00
Hyowon Yang db761cd7be Analytics API 엔드포인트 인증 제거 (테스트용) 2025-10-24 11:30:09 +09:00
merrycoral 7b76e573ed Event Service 데이터베이스 테이블 생성 SQL 추가
- events 테이블: 이벤트 마스터
- event_channels 테이블: 배포 채널
- generated_images 테이블: 생성된 이미지
- ai_recommendations 테이블: AI 추천 기획안
- jobs 테이블: 비동기 작업 관리
- updated_at 자동 업데이트 트리거 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 11:23:55 +09:00
Hyowon Yang ab99a26211 런타임에러해결 2025-10-24 11:00:02 +09:00
Hyowon Yang 9b10f915e3 Analytics 대시보드 샘플 데이터 자동 적재 기능 추가 2025-10-24 10:35:30 +09:00
Hyowon Yang 43e23eb7aa Kafka 조건부 활성화 설정 2025-10-24 10:31:58 +09:00
merrycoral 379ab0f1f9 Merge branch 'feature/event' of https://github.com/ktds-dg0501/kt-event-marketing into feature/event 2025-10-24 10:27:36 +09:00
Hayoung Song f3be6917b5 Merge pull request #1 from ktds-dg0501/main
되돌리기
2025-10-24 10:23:06 +09:00
merrycoral 5476fe9388 event-service 초기 구현 및 JWT 토큰 매장 ID 추가
- JWT 토큰에 매장 ID(storeId) 필드 추가
- event-service 구현 (이벤트 생성/조회 API)
- hibernate-types 의존성 추가 (UUID 지원)
- API 매핑 문서 추가
- IntelliJ 실행 프로파일 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 10:17:45 +09:00
Hyowon Yang 887b46ab46 실행프로파일, 로그설정 2025-10-24 10:04:28 +09:00
Hyowon Yang 46fc1663a5 analytics 서비스개발
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 09:44:02 +09:00
Hyowon Yang 25b1ec8b81 링고비즈api추가 2025-10-23 17:58:54 +09:00
149 changed files with 10793 additions and 40 deletions
@@ -0,0 +1,14 @@
---
command: "/deploy-actions-cicd-guide-back"
---
@cicd
'백엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- ACR_NAME: acrdigitalgarage01
- RESOURCE_GROUP: rg-digitalgarage-01
- AKS_CLUSTER: aks-digitalgarage-01
- NAMESPACE: phonebill-dg0500
@@ -0,0 +1,15 @@
---
command: "/deploy-actions-cicd-guide-front"
---
@cicd
'프론트엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- SYSTEM_NAME: phonebill
- ACR_NAME: acrdigitalgarage01
- RESOURCE_GROUP: rg-digitalgarage-01
- AKS_CLUSTER: aks-digitalgarage-01
- NAMESPACE: phonebill-dg0500
@@ -0,0 +1,6 @@
---
command: "/deploy-build-image-back"
---
@cicd
'백엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
@@ -0,0 +1,6 @@
---
command: "/deploy-build-image-front"
---
@cicd
'프론트엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
+81
View File
@@ -0,0 +1,81 @@
---
command: "/deploy-help"
---
# 배포 작업 순서
## 1단계: 컨테이너 이미지 작성
### 백엔드
```
/deploy-build-image-back
```
- 백엔드컨테이너이미지작성가이드를 참고하여 컨테이너 이미지를 빌드합니다
### 프론트엔드
```
/deploy-build-image-front
```
- 프론트엔드컨테이너이미지작성가이드를 참고하여 컨테이너 이미지를 빌드합니다
## 2단계: 컨테이너 실행 가이드 작성
### 백엔드
```
/deploy-run-container-guide-back
```
- 백엔드컨테이너실행방법가이드를 참고하여 컨테이너 실행 방법을 작성합니다
- 실행정보(ACR명, VM정보)가 필요합니다
### 프론트엔드
```
/deploy-run-container-guide-front
```
- 프론트엔드컨테이너실행방법가이드를 참고하여 컨테이너 실행 방법을 작성합니다
- 실행정보(시스템명, ACR명, VM정보)가 필요합니다
## 3단계: Kubernetes 배포 가이드 작성
### 백엔드
```
/deploy-k8s-guide-back
```
- 백엔드배포가이드를 참고하여 쿠버네티스 배포 방법을 작성합니다
- 실행정보(ACR명, k8s명, 네임스페이스, 리소스 설정)가 필요합니다
### 프론트엔드
```
/deploy-k8s-guide-front
```
- 프론트엔드배포가이드를 참고하여 쿠버네티스 배포 방법을 작성합니다
- 실행정보(시스템명, ACR명, k8s명, 네임스페이스, Gateway Host, 리소스 설정)가 필요합니다
## 4단계: CI/CD 파이프라인 구성
### Jenkins 사용 시
#### 백엔드
```
/deploy-jenkins-cicd-guide-back
```
- 백엔드Jenkins파이프라인작성가이드를 참고하여 Jenkins CI/CD 파이프라인을 구성합니다
#### 프론트엔드
```
/deploy-jenkins-cicd-guide-front
```
- 프론트엔드Jenkins파이프라인작성가이드를 참고하여 Jenkins CI/CD 파이프라인을 구성합니다
### GitHub Actions 사용 시
#### 백엔드
```
/deploy-actions-cicd-guide-back
```
- 백엔드GitHubActions파이프라인작성가이드를 참고하여 GitHub Actions CI/CD 파이프라인을 구성합니다
#### 프론트엔드
```
/deploy-actions-cicd-guide-front
```
- 프론트엔드GitHubActions파이프라인작성가이드를 참고하여 GitHub Actions CI/CD 파이프라인을 구성합니다
## 참고사항
- 각 명령 실행 전 필요한 실행정보를 프롬프트에 포함해야 합니다
- 실행정보가 없으면 안내 메시지가 표시되며 작업이 중단됩니다
- CI/CD 도구는 Jenkins 또는 GitHub Actions 중 선택하여 사용합니다
@@ -0,0 +1,14 @@
---
command: "/deploy-jenkins-cicd-guide-back"
---
@cicd
'백엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- ACR_NAME: acrdigitalgarage01
- RESOURCE_GROUP: rg-digitalgarage-01
- AKS_CLUSTER: aks-digitalgarage-01
- NAMESPACE: phonebill-dg0500
@@ -0,0 +1,15 @@
---
command: "/deploy-jenkins-cicd-guide-front"
---
@cicd
'프론트엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- SYSTEM_NAME: phonebill
- ACR_NAME: acrdigitalgarage01
- RESOURCE_GROUP: rg-digitalgarage-01
- AKS_CLUSTER: aks-digitalgarage-01
- NAMESPACE: phonebill-dg0500
+16
View File
@@ -0,0 +1,16 @@
---
command: "/deploy-k8s-guide-back"
---
@cicd
'백엔드배포가이드'에 따라 백엔드 서비스 배포 방법을 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- ACR명: acrdigitalgarage01
- k8s명: aks-digitalgarage-01
- 네임스페이스: tripgen
- 파드수: 2
- 리소스(CPU): 256m/1024m
- 리소스(메모리): 256Mi/1024Mi
@@ -0,0 +1,18 @@
---
command: "/deploy-k8s-guide-front"
---
@cicd
'프론트엔드배포가이드'에 따라 프론트엔드 서비스 배포 방법을 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- 시스템명: tripgen
- ACR명: acrdigitalgarage01
- k8s명: aks-digitalgarage-01
- 네임스페이스: tripgen
- 파드수: 2
- 리소스(CPU): 256m/1024m
- 리소스(메모리): 256Mi/1024Mi
- Gateway Host: http://tripgen-api.20.214.196.128.nip.io
@@ -0,0 +1,15 @@
---
command: "/deploy-run-container-guide-back"
---
@cicd
'백엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- ACR명: acrdigitalgarage01
- VM
- KEY파일: ~/home/bastion-dg0500
- USERID: azureuser
- IP: 4.230.5.6
@@ -0,0 +1,16 @@
---
command: "/deploy-run-container-guide-front"
---
@cicd
'프론트엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- 시스템명: tripgen
- ACR명: acrdigitalgarage01
- VM
- KEY파일: ~/home/bastion-dg0500
- USERID: azureuser
- IP: 4.230.5.6
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-api"
---
@architecture @architecture
API를 설계해 주세요: API를 설계해 주세요:
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계 - '공통설계원칙'과 'API설계가이드'를 준용하여 설계
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-class"
---
@architecture @architecture
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요. '공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다. 프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-data"
---
@architecture @architecture
데이터 설계를 해주세요: 데이터 설계를 해주세요:
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계 - '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-fix-prototype"
---
@fix as @front @fix as @front
'[오류내용]'섹션에 제공된 오류를 해결해 주세요. '[오류내용]'섹션에 제공된 오류를 해결해 주세요.
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시 프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-front"
---
@plan as @front @plan as @front
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요. '프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다. 프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-high-level"
---
@architecture @architecture
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요. 'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요. 'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
@@ -1,3 +1,6 @@
---
command: "/design-improve-prototype"
---
@improve as @front @improve as @front
'[개선내용]'섹션에 있는 내용을 개선해 주세요. '[개선내용]'섹션에 있는 내용을 개선해 주세요.
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시 프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
@@ -1,2 +1,5 @@
---
command: "/design-improve-userstory"
---
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후, @analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오. @document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-logical"
---
@architecture @architecture
논리 아키텍처를 설계해 주세요: 논리 아키텍처를 설계해 주세요:
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계 - '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-pattern"
---
@design-pattern @design-pattern
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요: 클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성 - '클라우드아키텍처패턴선정가이드'를 준용하여 작성
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-physical"
---
@architecture @architecture
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요. '물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요. 'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-prototype"
---
@prototype @prototype
프로토타입을 작성해 주세요: 프로토타입을 작성해 주세요:
- '프로토타입작성가이드'를 준용하여 작성 - '프로토타입작성가이드'를 준용하여 작성
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-seq-inner"
---
@architecture @architecture
내부 시퀀스 설계를 해 주세요: 내부 시퀀스 설계를 해 주세요:
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계 - '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-seq-outer"
---
@architecture @architecture
외부 시퀀스 설계를 해 주세요: 외부 시퀀스 설계를 해 주세요:
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계 - '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
@@ -1,2 +1,5 @@
---
command: "/design-test-prototype"
---
@test-front @test-front
프로토타입을 테스트 해 주세요. 프로토타입을 테스트 해 주세요.
+3
View File
@@ -1,3 +1,6 @@
---
command: "/design-uiux"
---
@uiux @uiux
UI/UX 설계를 해주세요: UI/UX 설계를 해주세요:
- 'UI/UX설계가이드'를 준용하여 작성 - 'UI/UX설계가이드'를 준용하여 작성
+3
View File
@@ -1,2 +1,5 @@
---
command: "/design-update-uiux"
---
@document @front @document @front
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요. 현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
+1 -1
View File
@@ -1,5 +1,5 @@
@test-backend @test-backend
'서비스실행파일작성가이드'에 따라 테스트를 해 주세요. '서비스실행프로파일작성가이드'에 따라 테스트를 해 주세요.
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. 프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요. DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
{안내메시지} {안내메시지}
+3
View File
@@ -1,3 +1,6 @@
---
command: "/think-help"
---
기획 작업 순서 기획 작업 순서
1단계: 서비스 기획 1단계: 서비스 기획
+3
View File
@@ -1,3 +1,6 @@
---
command: "/think-planning"
---
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다. 아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
``` ```
아래 가이드를 참고하여 서비스 기획을 수행합니다. 아래 가이드를 참고하여 서비스 기획을 수행합니다.
+6
View File
@@ -1,3 +1,7 @@
---
command: "/think-userstory"
---
```
@document @document
유저스토리를 작성하세요. 유저스토리를 작성하세요.
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다. 프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
@@ -16,3 +20,5 @@ Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을
2. 유저스토리 작성 2. 유저스토리 작성
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성 - '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
- 결과파일은 'design/userstory.md'에 생성 - 결과파일은 'design/userstory.md'에 생성
```
+5
View File
@@ -16,6 +16,11 @@
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(git push)", "Bash(git push)",
"Bash(git pull:*)", "Bash(git pull:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(./gradlew analytics-service:compileJava:*)",
"Bash(python -m json.tool:*)",
"Bash(powershell:*)"
"Bash(./gradlew participation-service:compileJava:*)", "Bash(./gradlew participation-service:compileJava:*)",
"Bash(find:*)", "Bash(find:*)",
"Bash(netstat:*)", "Bash(netstat:*)",
+20 -2
View File
@@ -23,6 +23,14 @@ build/
.gradle/ .gradle/
logs/ logs/
# Gradle
.gradle/
!gradle/wrapper/gradle-wrapper.jar
# Logs
logs/
*.log
# Environment # Environment
.env .env
.env.local .env.local
@@ -33,5 +41,15 @@ tmp/
temp/ temp/
*.tmp *.tmp
# Docker (로컬 개발용) # Kubernetes Secrets (민감한 정보 포함)
backing-service/docker-compose.yml k8s/**/secret.yaml
k8s/**/*-secret.yaml
k8s/**/*-prod.yaml
k8s/**/*-dev.yaml
k8s/**/*-local.yaml
# IntelliJ 실행 프로파일 (민감한 환경 변수 포함 가능)
.run/*.run.xml
# Gradle (로컬 환경 설정)
gradle.properties
@@ -0,0 +1,84 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="analytics-service" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Database Configuration -->
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_HOST" value="4.230.49.9" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="analyticdb" />
<entry key="DB_USERNAME" value="eventuser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
<!-- JPA Configuration -->
<entry key="DDL_AUTO" value="update" />
<entry key="SHOW_SQL" value="true" />
<!-- Redis Configuration -->
<entry key="REDIS_HOST" value="20.214.210.71" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="5" />
<!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
<!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
<entry key="SAMPLE_DATA_ENABLED" value="true" />
<!-- Server Configuration -->
<entry key="SERVER_PORT" value="8086" />
<!-- JWT Configuration -->
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only-kt-event-marketing" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="1800" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
<!-- CORS Configuration -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
<!-- Logging Configuration -->
<entry key="LOG_FILE" value="logs/analytics-service.log" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_WEB" value="INFO" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="analytics-service:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
</ENTRIES>
</extension>
</EXTENSION>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
@@ -0,0 +1,29 @@
package com.kt.event.analytics;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Analytics Service 애플리케이션 메인 클래스
*
* 실시간 효과 측정 및 통합 대시보드를 제공하는 Analytics Service
*/
@SpringBootApplication(scanBasePackages = {"com.kt.event.analytics", "com.kt.event.common"})
@EntityScan(basePackages = {"com.kt.event.analytics.entity", "com.kt.event.common.entity"})
@EnableJpaRepositories(basePackages = "com.kt.event.analytics.repository")
@EnableJpaAuditing
@EnableFeignClients
@EnableKafka
@EnableScheduling
public class AnalyticsServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AnalyticsServiceApplication.class, args);
}
}
@@ -0,0 +1,116 @@
package com.kt.event.analytics.batch;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.kt.event.analytics.service.AnalyticsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* Analytics 배치 스케줄러
*
* 5분 단위로 Analytics 대시보드 데이터를 갱신하는 배치 작업
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AnalyticsBatchScheduler {
private final AnalyticsService analyticsService;
private final EventStatsRepository eventStatsRepository;
private final RedisTemplate<String, String> redisTemplate;
/**
* 5분 단위 Analytics 데이터 갱신 배치
*
* - 각 이벤트마다 Redis 캐시 확인
* - 캐시 있음 → 건너뛰기 (1시간 유효)
* - 캐시 없음 → PostgreSQL + 외부 API → Redis 저장
*/
@Scheduled(fixedRate = 300000) // 5분 = 300,000ms
public void refreshAnalyticsDashboard() {
log.info("===== Analytics 배치 시작: {} =====", LocalDateTime.now());
try {
// 1. 모든 활성 이벤트 조회
List<EventStats> activeEvents = eventStatsRepository.findAll();
log.info("활성 이벤트 수: {}", activeEvents.size());
// 2. 각 이벤트별로 캐시 확인 및 갱신
int successCount = 0;
int skipCount = 0;
int failCount = 0;
for (EventStats event : activeEvents) {
String cacheKey = "analytics:dashboard:" + event.getEventId();
try {
// 2-1. Redis 캐시 확인
if (redisTemplate.hasKey(cacheKey)) {
log.debug("✅ 캐시 유효, 건너뜀: eventId={}", event.getEventId());
skipCount++;
continue;
}
// 2-2. 캐시 없음 → 데이터 갱신
log.info("캐시 만료, 갱신 시작: eventId={}, title={}",
event.getEventId(), event.getEventTitle());
// refresh=true로 호출하여 캐시 갱신 및 외부 API 호출
analyticsService.getDashboardData(event.getEventId(), null, null, true);
successCount++;
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
} catch (Exception e) {
failCount++;
log.error("❌ 배치 갱신 실패: eventId={}, error={}",
event.getEventId(), e.getMessage(), e);
}
}
log.info("===== Analytics 배치 완료: 성공={}, 건너뜀={}, 실패={}, 종료시각={} =====",
successCount, skipCount, failCount, LocalDateTime.now());
} catch (Exception e) {
log.error("Analytics 배치 실행 중 오류 발생: {}", e.getMessage(), e);
}
}
/**
* 초기 데이터 로딩 (애플리케이션 시작 후 30초 뒤 1회 실행)
*
* - 서버 시작 직후 캐시 워밍업
* - 첫 API 요청 시 응답 시간 단축
*/
@Scheduled(initialDelay = 30000, fixedDelay = Long.MAX_VALUE)
public void initialDataLoad() {
log.info("===== 초기 데이터 로딩 시작: {} =====", LocalDateTime.now());
try {
List<EventStats> allEvents = eventStatsRepository.findAll();
log.info("초기 로딩 대상 이벤트 수: {}", allEvents.size());
for (EventStats event : allEvents) {
try {
analyticsService.getDashboardData(event.getEventId(), null, null, true);
log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
} catch (Exception e) {
log.warn("초기 데이터 로딩 실패: eventId={}, error={}",
event.getEventId(), e.getMessage());
}
}
log.info("===== 초기 데이터 로딩 완료: {} =====", LocalDateTime.now());
} catch (Exception e) {
log.error("초기 데이터 로딩 중 오류 발생: {}", e.getMessage(), e);
}
}
}
@@ -0,0 +1,50 @@
package com.kt.event.analytics.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka Consumer 설정
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true)
public class KafkaConsumerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Value("${spring.kafka.consumer.group-id:analytics-service}")
private String groupId;
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
return new DefaultKafkaConsumerFactory<>(props);
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
// Kafka Consumer 자동 시작 활성화
factory.setAutoStartup(true);
return factory;
}
}
@@ -0,0 +1,53 @@
package com.kt.event.analytics.config;
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;
/**
* Kafka 토픽 자동 생성 설정
*
* ⚠️ MVP 전용: 샘플 데이터용 토픽을 생성합니다.
* 실제 운영 토픽(event.created 등)과 구분하기 위해 "sample." 접두사 사용
*
* 서비스 시작 시 필요한 Kafka 토픽을 자동으로 생성합니다.
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaTopicConfig {
/**
* sample.event.created 토픽 (MVP 샘플 데이터용)
*/
@Bean
public NewTopic eventCreatedTopic() {
return TopicBuilder.name("sample.event.created")
.partitions(3)
.replicas(1)
.build();
}
/**
* sample.participant.registered 토픽 (MVP 샘플 데이터용)
*/
@Bean
public NewTopic participantRegisteredTopic() {
return TopicBuilder.name("sample.participant.registered")
.partitions(3)
.replicas(1)
.build();
}
/**
* sample.distribution.completed 토픽 (MVP 샘플 데이터용)
*/
@Bean
public NewTopic distributionCompletedTopic() {
return TopicBuilder.name("sample.distribution.completed")
.partitions(3)
.replicas(1)
.build();
}
}
@@ -0,0 +1,35 @@
package com.kt.event.analytics.config;
import io.lettuce.core.ReadFrom;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 캐시 설정
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
// Read-only 오류 방지: 마스터 노드 우선 사용
if (connectionFactory instanceof LettuceConnectionFactory) {
LettuceConnectionFactory lettuceFactory = (LettuceConnectionFactory) connectionFactory;
lettuceFactory.setValidateConnection(true);
}
return template;
}
}
@@ -0,0 +1,27 @@
package com.kt.event.analytics.config;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Resilience4j Circuit Breaker 설정
*/
@Configuration
public class Resilience4jConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10)
.permittedNumberOfCallsInHalfOpenState(3)
.build();
return CircuitBreakerRegistry.of(config);
}
}
@@ -0,0 +1,361 @@
package com.kt.event.analytics.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.event.analytics.messaging.event.DistributionCompletedEvent;
import com.kt.event.analytics.messaging.event.EventCreatedEvent;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.kt.event.analytics.repository.TimelineDataRepository;
import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.UUID;
/**
* 샘플 데이터 로더 (Kafka Producer 방식)
*
* ⚠️ MVP 전용: 다른 마이크로서비스(Event, Participant, Distribution)가
* 없는 환경에서 해당 서비스들의 역할을 시뮬레이션합니다.
*
* ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행해야 하며,
* 이 클래스는 비활성화되어야 합니다.
* → SAMPLE_DATA_ENABLED=false 설정
*
* - 서비스 시작 시: Kafka 이벤트 발행하여 샘플 데이터 자동 생성
* - 서비스 종료 시: PostgreSQL 전체 데이터 삭제
*
* 활성화 조건: spring.sample-data.enabled=true (기본값: true)
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "spring.sample-data.enabled", havingValue = "true", matchIfMissing = true)
@RequiredArgsConstructor
public class SampleDataLoader implements ApplicationRunner {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final TimelineDataRepository timelineDataRepository;
private final EntityManager entityManager;
private final RedisTemplate<String, String> redisTemplate;
private final Random random = new Random();
// Kafka Topic Names (MVP용 샘플 토픽)
private static final String EVENT_CREATED_TOPIC = "sample.event.created";
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed";
@Override
@Transactional
public void run(ApplicationArguments args) {
log.info("========================================");
log.info("🚀 서비스 시작: Kafka 이벤트 발행하여 샘플 데이터 생성");
log.info("========================================");
// 항상 기존 데이터 삭제 후 새로 생성
long existingCount = eventStatsRepository.count();
if (existingCount > 0) {
log.info("기존 데이터 {} 건 삭제 중...", existingCount);
timelineDataRepository.deleteAll();
channelStatsRepository.deleteAll();
eventStatsRepository.deleteAll();
// 삭제 커밋 보장
entityManager.flush();
entityManager.clear();
log.info("✅ 기존 데이터 삭제 완료");
}
// Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events");
redisTemplate.delete("distribution_completed");
redisTemplate.delete("processed_participants");
log.info("✅ Redis 멱등성 키 삭제 완료");
try {
// 1. EventCreated 이벤트 발행 (3개 이벤트)
publishEventCreatedEvents();
log.info("⏳ EventStats 생성 대기 중... (5초)");
Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간
// 2. DistributionCompleted 이벤트 발행 (각 이벤트당 4개 채널)
publishDistributionCompletedEvents();
log.info("⏳ ChannelStats 생성 대기 중... (3초)");
Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간
// 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자)
publishParticipantRegisteredEvents();
log.info("========================================");
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
log.info("========================================");
log.info("발행된 이벤트:");
log.info(" - EventCreated: 3건");
log.info(" - DistributionCompleted: 3건 (각 이벤트당 4개 채널 배열)");
log.info(" - ParticipantRegistered: 180건 (MVP 테스트용)");
log.info("========================================");
// Consumer 처리 대기 (5초)
log.info("⏳ 참여자 수 업데이트 대기 중... (5초)");
Thread.sleep(5000);
// 4. TimelineData 생성 (시간대별 데이터)
createTimelineData();
log.info("✅ TimelineData 생성 완료");
} catch (Exception e) {
log.error("샘플 데이터 적재 중 오류 발생", e);
}
}
/**
* 서비스 종료 시 전체 데이터 삭제
*/
@PreDestroy
@Transactional
public void onShutdown() {
log.info("========================================");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
log.info("========================================");
try {
long timelineCount = timelineDataRepository.count();
long channelCount = channelStatsRepository.count();
long eventCount = eventStatsRepository.count();
log.info("삭제 대상: 이벤트={}, 채널={}, 타임라인={}",
eventCount, channelCount, timelineCount);
timelineDataRepository.deleteAll();
channelStatsRepository.deleteAll();
eventStatsRepository.deleteAll();
// 삭제 커밋 보장
entityManager.flush();
entityManager.clear();
log.info("✅ 모든 샘플 데이터 삭제 완료!");
log.info("========================================");
} catch (Exception e) {
log.error("샘플 데이터 삭제 중 오류 발생", e);
}
}
/**
* EventCreated 이벤트 발행
*/
private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과)
EventCreatedEvent event1 = EventCreatedEvent.builder()
.eventId("evt_2025012301")
.eventTitle("신년맞이 20% 할인 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("5000000"))
.status("ACTIVE")
.build();
publishEvent(EVENT_CREATED_TOPIC, event1);
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과)
EventCreatedEvent event2 = EventCreatedEvent.builder()
.eventId("evt_2025020101")
.eventTitle("설날 특가 선물세트 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("3500000"))
.status("ACTIVE")
.build();
publishEvent(EVENT_CREATED_TOPIC, event2);
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과)
EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("evt_2025011501")
.eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("2000000"))
.status("COMPLETED")
.build();
publishEvent(EVENT_CREATED_TOPIC, event3);
log.info("✅ EventCreated 이벤트 3건 발행 완료");
}
/**
* DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열)
*/
private void publishDistributionCompletedEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
int[][] expectedViews = {
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
{3500, 7000, 2000, 1500}, // 이벤트2
{1500, 3000, 1000, 500} // 이벤트3
};
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
// 4개 채널을 배열로 구성
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
// 1. 우리동네TV (TV)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("우리동네TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][0])
.build());
// 2. 지니TV (TV)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("지니TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][1])
.build());
// 3. 링고비즈 (CALL)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("링고비즈")
.channelType("CALL")
.status("SUCCESS")
.expectedViews(expectedViews[i][2])
.build());
// 4. SNS (SNS)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("SNS")
.channelType("SNS")
.status("SUCCESS")
.expectedViews(expectedViews[i][3])
.build());
// 이벤트 발행 (채널 배열 포함)
DistributionCompletedEvent event = DistributionCompletedEvent.builder()
.eventId(eventId)
.distributedChannels(channels)
.completedAt(java.time.LocalDateTime.now())
.build();
publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event);
}
log.info("✅ DistributionCompleted 이벤트 3건 발행 완료 (3 이벤트 × 4 채널 배열)");
}
/**
* ParticipantRegistered 이벤트 발행
*/
private void publishParticipantRegisteredEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
int[] totalParticipants = {100, 50, 30}; // MVP 테스트용 샘플 데이터 (총 180명)
String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"};
int totalPublished = 0;
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
int participants = totalParticipants[i];
// 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행
for (int j = 0; j < participants; j++) {
String participantId = UUID.randomUUID().toString();
String channel = channels[j % channels.length]; // 채널 순환 배정
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
.eventId(eventId)
.participantId(participantId)
.channel(channel)
.build();
publishEvent(PARTICIPANT_REGISTERED_TOPIC, event);
totalPublished++;
}
}
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
}
/**
* TimelineData 생성 (시간대별 샘플 데이터)
*
* - 각 이벤트마다 30일 치 daily 데이터 생성
* - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수
*/
private void createTimelineData() {
log.info("📊 TimelineData 생성 시작...");
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
// 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름)
int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
String eventId = eventIds[eventIndex];
int baseParticipant = baseParticipants[eventIndex];
int cumulativeParticipants = 0;
// 30일 치 데이터 생성 (2024-09-24부터)
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0);
for (int day = 0; day < 30; day++) {
java.time.LocalDateTime timestamp = startDate.plusDays(day);
// 랜덤한 참여자 수 생성 (기준값 ± 50%)
int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1);
cumulativeParticipants += dailyParticipants;
// 조회수는 참여자의 3~5배
int dailyViews = dailyParticipants * (3 + random.nextInt(3));
// 참여행동은 참여자의 1~2배
int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
// 전환수는 참여자의 50~80%
int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3));
// TimelineData 생성
com.kt.event.analytics.entity.TimelineData timelineData =
com.kt.event.analytics.entity.TimelineData.builder()
.eventId(eventId)
.timestamp(timestamp)
.participants(dailyParticipants)
.views(dailyViews)
.engagement(dailyEngagement)
.conversions(dailyConversions)
.cumulativeParticipants(cumulativeParticipants)
.build();
timelineDataRepository.save(timelineData);
}
log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
}
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건");
}
/**
* Kafka 이벤트 발행 공통 메서드
*/
private void publishEvent(String topic, Object event) throws Exception {
String jsonMessage = objectMapper.writeValueAsString(event);
kafkaTemplate.send(topic, jsonMessage);
}
}
@@ -0,0 +1,79 @@
package com.kt.event.analytics.config;
import com.kt.event.common.security.JwtAuthenticationFilter;
import com.kt.event.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정
* JWT 기반 인증 및 API 보안 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins:http://localhost:*}")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Actuator endpoints
.requestMatchers("/actuator/**").permitAll()
// Swagger UI endpoints
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
// Health check
.requestMatchers("/health").permitAll()
// Analytics API endpoints (테스트 및 개발 용도로 공개)
.requestMatchers("/api/**").permitAll()
// All other requests require authentication
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,63 @@
package com.kt.event.analytics.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger/OpenAPI 설정
* Analytics Service API 문서화를 위한 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(apiInfo())
.addServersItem(new Server()
.url("http://localhost:8086")
.description("Local Development"))
.addServersItem(new Server()
.url("{protocol}://{host}:{port}")
.description("Custom Server")
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("http")
.description("Protocol (http or https)")
.addEnumItem("http")
.addEnumItem("https"))
.addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("localhost")
.description("Server host"))
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("8086")
.description("Server port"))))
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
.components(new Components()
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
}
private Info apiInfo() {
return new Info()
.title("Analytics Service API")
.description("실시간 효과 측정 및 통합 대시보드를 제공하는 Analytics Service API")
.version("1.0.0")
.contact(new Contact()
.name("Digital Garage Team")
.email("support@kt-event-marketing.com"));
}
private SecurityScheme createAPIKeyScheme() {
return new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.bearerFormat("JWT")
.scheme("bearer");
}
}
@@ -0,0 +1,71 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.dto.response.AnalyticsDashboardResponse;
import com.kt.event.analytics.service.AnalyticsService;
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;
/**
* Analytics Dashboard Controller
*
* 이벤트 성과 대시보드 API
*/
@Tag(name = "Analytics", description = "이벤트 성과 분석 및 대시보드 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class AnalyticsDashboardController {
private final AnalyticsService analyticsService;
/**
* 성과 대시보드 조회
*
* @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부
* @return 성과 대시보드
*/
@Operation(
summary = "성과 대시보드 조회",
description = "이벤트의 전체 성과를 통합하여 조회합니다."
)
@GetMapping("/{eventId}/analytics")
public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> getEventAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
) {
log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
AnalyticsDashboardResponse response = analyticsService.getDashboardData(
eventId, startDate, endDate, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,73 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.dto.response.ChannelAnalyticsResponse;
import com.kt.event.analytics.service.ChannelAnalyticsService;
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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
/**
* Channel Analytics Controller
*
* 채널별 성과 분석 API
*/
@Tag(name = "Channels", description = "채널별 성과 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class ChannelAnalyticsController {
private final ChannelAnalyticsService channelAnalyticsService;
/**
* 채널별 성과 분석
*
* @param eventId 이벤트 ID
* @param channels 조회할 채널 목록 (쉼표로 구분)
* @param sortBy 정렬 기준
* @param order 정렬 순서
* @return 채널별 성과 분석
*/
@Operation(
summary = "채널별 성과 분석",
description = "각 배포 채널별 성과를 상세하게 분석합니다."
)
@GetMapping("/{eventId}/analytics/channels")
public ResponseEntity<ApiResponse<ChannelAnalyticsResponse>> getChannelAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "조회할 채널 목록 (쉼표로 구분, 미지정 시 전체)")
@RequestParam(required = false)
String channels,
@Parameter(description = "정렬 기준 (views, participants, engagement_rate, conversion_rate, roi)")
@RequestParam(required = false, defaultValue = "roi")
String sortBy,
@Parameter(description = "정렬 순서 (asc, desc)")
@RequestParam(required = false, defaultValue = "desc")
String order
) {
log.info("채널별 성과 분석 API 호출: eventId={}, sortBy={}", eventId, sortBy);
List<String> channelList = channels != null && !channels.isBlank()
? Arrays.asList(channels.split(","))
: null;
ChannelAnalyticsResponse response = channelAnalyticsService.getChannelAnalytics(
eventId, channelList, sortBy, order
);
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,54 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.dto.response.RoiAnalyticsResponse;
import com.kt.event.analytics.service.RoiAnalyticsService;
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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* ROI Analytics Controller
*
* 투자 대비 수익률 분석 API
*/
@Tag(name = "ROI", description = "투자 대비 수익률 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class RoiAnalyticsController {
private final RoiAnalyticsService roiAnalyticsService;
/**
* 투자 대비 수익률 상세
*
* @param eventId 이벤트 ID
* @param includeProjection 예상 수익 포함 여부
* @return ROI 상세 분석
*/
@Operation(
summary = "투자 대비 수익률 상세",
description = "이벤트의 투자 대비 수익률을 상세하게 분석합니다."
)
@GetMapping("/{eventId}/analytics/roi")
public ResponseEntity<ApiResponse<RoiAnalyticsResponse>> getRoiAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "예상 수익 포함 여부")
@RequestParam(required = false, defaultValue = "true")
Boolean includeProjection
) {
log.info("ROI 상세 분석 API 호출: eventId={}, includeProjection={}", eventId, includeProjection);
RoiAnalyticsResponse response = roiAnalyticsService.getRoiAnalytics(eventId, includeProjection);
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,82 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.dto.response.TimelineAnalyticsResponse;
import com.kt.event.analytics.service.TimelineAnalyticsService;
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;
/**
* Timeline Analytics Controller
*
* 시간대별 분석 API
*/
@Tag(name = "Timeline", description = "시간대별 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class TimelineAnalyticsController {
private final TimelineAnalyticsService timelineAnalyticsService;
/**
* 시간대별 참여 추이
*
* @param eventId 이벤트 ID
* @param interval 시간 간격 단위
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param metrics 조회할 지표 목록
* @return 시간대별 참여 추이
*/
@Operation(
summary = "시간대별 참여 추이",
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다."
)
@GetMapping("/{eventId}/analytics/timeline")
public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "시간 간격 단위 (hourly, daily, weekly)")
@RequestParam(required = false, defaultValue = "daily")
String interval,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false)
String metrics
) {
log.info("시간대별 참여 추이 API 호출: eventId={}, interval={}", eventId, interval);
List<String> metricList = metrics != null && !metrics.isBlank()
? Arrays.asList(metrics.split(","))
: null;
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
eventId, interval, startDate, endDate, metricList
);
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,59 @@
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;
/**
* 이벤트 성과 대시보드 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsDashboardResponse {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 이벤트 제목
*/
private String eventTitle;
/**
* 조회 기간 정보
*/
private PeriodInfo period;
/**
* 성과 요약
*/
private AnalyticsSummary summary;
/**
* 채널별 성과 요약
*/
private List<ChannelSummary> channelPerformance;
/**
* ROI 요약
*/
private RoiSummary roi;
/**
* 마지막 업데이트 시간
*/
private LocalDateTime lastUpdatedAt;
/**
* 데이터 출처 (real-time, cached, fallback)
*/
private String dataSource;
}
@@ -0,0 +1,51 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 성과 요약
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsSummary {
/**
* 총 참여자 수
*/
private Integer totalParticipants;
/**
* 총 조회수
*/
private Integer totalViews;
/**
* 총 도달 수
*/
private Integer totalReach;
/**
* 참여율 (%)
*/
private Double engagementRate;
/**
* 전환율 (%)
*/
private Double conversionRate;
/**
* 평균 참여 시간 (초)
*/
private Integer averageEngagementTime;
/**
* SNS 반응 통계
*/
private SocialInteractionStats socialInteractions;
}
@@ -0,0 +1,46 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채널별 상세 분석
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelAnalytics {
/**
* 채널명
*/
private String channelName;
/**
* 채널 유형
*/
private String channelType;
/**
* 채널 지표
*/
private ChannelMetrics metrics;
/**
* 성과 지표
*/
private ChannelPerformance performance;
/**
* 비용 정보
*/
private ChannelCosts costs;
/**
* 외부 API 연동 상태 (success, fallback, failed)
*/
private String externalApiStatus;
}
@@ -0,0 +1,39 @@
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;
/**
* 채널별 성과 분석 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelAnalyticsResponse {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 채널별 상세 분석
*/
private List<ChannelAnalytics> channels;
/**
* 채널 간 비교 분석
*/
private ChannelComparison comparison;
/**
* 마지막 업데이트 시간
*/
private LocalDateTime lastUpdatedAt;
}
@@ -0,0 +1,28 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 채널 간 비교 분석
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelComparison {
/**
* 최고 성과 채널
*/
private Map<String, String> bestPerforming;
/**
* 전체 채널 평균 지표
*/
private Map<String, Double> averageMetrics;
}
@@ -0,0 +1,43 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 채널별 비용
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelCosts {
/**
* 배포 비용 (원)
*/
private BigDecimal distributionCost;
/**
* 조회당 비용 (CPV, 원)
*/
private Double costPerView;
/**
* 클릭당 비용 (CPC, 원)
*/
private Double costPerClick;
/**
* 고객 획득 비용 (CPA, 원)
*/
private Double costPerAcquisition;
/**
* ROI (%)
*/
private Double roi;
}
@@ -0,0 +1,51 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채널 지표
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelMetrics {
/**
* 노출 수
*/
private Integer impressions;
/**
* 조회수
*/
private Integer views;
/**
* 클릭 수
*/
private Integer clicks;
/**
* 참여자 수
*/
private Integer participants;
/**
* 전환 수
*/
private Integer conversions;
/**
* SNS 반응 통계
*/
private SocialInteractionStats socialInteractions;
/**
* 링고비즈 통화 통계
*/
private VoiceCallStats voiceCallStats;
}
@@ -0,0 +1,41 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채널 성과 지표
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelPerformance {
/**
* 클릭률 (CTR, %)
*/
private Double clickThroughRate;
/**
* 참여율 (%)
*/
private Double engagementRate;
/**
* 전환율 (%)
*/
private Double conversionRate;
/**
* 평균 참여 시간 (초)
*/
private Integer averageEngagementTime;
/**
* 이탈율 (%)
*/
private Double bounceRate;
}
@@ -0,0 +1,46 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채널별 성과 요약
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelSummary {
/**
* 채널명
*/
private String channelName;
/**
* 조회수
*/
private Integer views;
/**
* 참여자 수
*/
private Integer participants;
/**
* 참여율 (%)
*/
private Double engagementRate;
/**
* 전환율 (%)
*/
private Double conversionRate;
/**
* ROI (%)
*/
private Double roi;
}
@@ -0,0 +1,36 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 비용 효율성
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CostEfficiency {
/**
* 참여자당 비용 (원)
*/
private Double costPerParticipant;
/**
* 전환당 비용 (원)
*/
private Double costPerConversion;
/**
* 조회당 비용 (원)
*/
private Double costPerView;
/**
* 참여자당 수익 (원)
*/
private Double revenuePerParticipant;
}
@@ -0,0 +1,45 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 투자 비용 상세
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InvestmentDetails {
/**
* 콘텐츠 제작비 (원)
*/
private BigDecimal contentCreation;
/**
* 배포 비용 (원)
*/
private BigDecimal distribution;
/**
* 운영 비용 (원)
*/
private BigDecimal operation;
/**
* 총 투자 비용 (원)
*/
private BigDecimal total;
/**
* 채널별 비용 상세
*/
private List<Map<String, Object>> breakdown;
}
@@ -0,0 +1,38 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 피크 타임 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PeakTimeInfo {
/**
* 피크 시간
*/
private LocalDateTime timestamp;
/**
* 피크 지표 (participants, views, engagement, conversions)
*/
private String metric;
/**
* 피크 값
*/
private Integer value;
/**
* 피크 설명
*/
private String description;
}
@@ -0,0 +1,33 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 조회 기간 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PeriodInfo {
/**
* 조회 시작 날짜
*/
private LocalDateTime startDate;
/**
* 조회 종료 날짜
*/
private LocalDateTime endDate;
/**
* 기간 (일)
*/
private Integer durationDays;
}
@@ -0,0 +1,38 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 수익 상세
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RevenueDetails {
/**
* 직접 매출 (원)
*/
private BigDecimal directSales;
/**
* 예상 추가 매출 (원)
*/
private BigDecimal expectedSales;
/**
* 브랜드 가치 향상 추정액 (원)
*/
private BigDecimal brandValue;
/**
* 총 수익 (원)
*/
private BigDecimal total;
}
@@ -0,0 +1,38 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 수익 예측
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RevenueProjection {
/**
* 현재 누적 수익 (원)
*/
private BigDecimal currentRevenue;
/**
* 예상 최종 수익 (원)
*/
private BigDecimal projectedFinalRevenue;
/**
* 예측 신뢰도 (%)
*/
private Double confidenceLevel;
/**
* 예측 기반
*/
private String basedOn;
}
@@ -0,0 +1,53 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* ROI 상세 분석 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RoiAnalyticsResponse {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 투자 비용 상세
*/
private InvestmentDetails investment;
/**
* 수익 상세
*/
private RevenueDetails revenue;
/**
* ROI 계산
*/
private RoiCalculation roi;
/**
* 비용 효율성
*/
private CostEfficiency costEfficiency;
/**
* 수익 예측
*/
private RevenueProjection projection;
/**
* 마지막 업데이트 시간
*/
private LocalDateTime lastUpdatedAt;
}
@@ -0,0 +1,39 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* ROI 계산
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RoiCalculation {
/**
* 순이익 (원)
*/
private BigDecimal netProfit;
/**
* ROI (%)
*/
private Double roiPercentage;
/**
* 손익분기점 도달 시점
*/
private LocalDateTime breakEvenPoint;
/**
* 투자 회수 기간 (일)
*/
private Integer paybackPeriod;
}
@@ -0,0 +1,43 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* ROI 요약
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RoiSummary {
/**
* 총 투자 비용 (원)
*/
private BigDecimal totalInvestment;
/**
* 예상 매출 증대 (원)
*/
private BigDecimal expectedRevenue;
/**
* 순이익 (원)
*/
private BigDecimal netProfit;
/**
* ROI (%)
*/
private Double roi;
/**
* 고객 획득 비용 (CPA, 원)
*/
private Double costPerAcquisition;
}
@@ -0,0 +1,31 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* SNS 반응 통계
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SocialInteractionStats {
/**
* 좋아요 수
*/
private Integer likes;
/**
* 댓글 수
*/
private Integer comments;
/**
* 공유 수
*/
private Integer shares;
}
@@ -0,0 +1,49 @@
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;
/**
* 시간대별 참여 추이 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TimelineAnalyticsResponse {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 시간 간격 (hourly, daily, weekly)
*/
private String interval;
/**
* 시간대별 데이터
*/
private List<TimelineDataPoint> dataPoints;
/**
* 추세 분석
*/
private TrendAnalysis trends;
/**
* 피크 타임 정보
*/
private List<PeakTimeInfo> peakTimes;
/**
* 마지막 업데이트 시간
*/
private LocalDateTime lastUpdatedAt;
}
@@ -0,0 +1,48 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 시간대별 데이터 포인트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TimelineDataPoint {
/**
* 시간
*/
private LocalDateTime timestamp;
/**
* 참여자 수
*/
private Integer participants;
/**
* 조회수
*/
private Integer views;
/**
* 참여 행동 수
*/
private Integer engagement;
/**
* 전환 수
*/
private Integer conversions;
/**
* 누적 참여자 수
*/
private Integer cumulativeParticipants;
}
@@ -0,0 +1,36 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 추세 분석
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TrendAnalysis {
/**
* 전체 추세 (increasing, stable, decreasing)
*/
private String overallTrend;
/**
* 증가율 (%)
*/
private Double growthRate;
/**
* 예상 참여자 수 (기간 종료 시점)
*/
private Integer projectedParticipants;
/**
* 피크 기간
*/
private String peakPeriod;
}
@@ -0,0 +1,36 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 링고비즈 음성 통화 통계
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VoiceCallStats {
/**
* 총 통화 수
*/
private Integer totalCalls;
/**
* 완료된 통화 수
*/
private Integer completedCalls;
/**
* 평균 통화 시간 (초)
*/
private Integer averageDuration;
/**
* 통화 완료율 (%)
*/
private Double completionRate;
}
@@ -0,0 +1,128 @@
package com.kt.event.analytics.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
/**
* 채널별 통계 엔티티
*
* 각 배포 채널별 성과 데이터를 저장
*/
@Entity
@Table(name = "channel_stats", indexes = {
@Index(name = "idx_event_id", columnList = "event_id"),
@Index(name = "idx_event_channel", columnList = "event_id, channel_name")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChannelStats extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID
*/
@Column(name = "event_id", nullable = false, length = 50)
private String eventId;
/**
* 채널명 (우리동네TV, 지니TV, 링고비즈, SNS)
*/
@Column(name = "channel_name", nullable = false, length = 50)
private String channelName;
/**
* 채널 유형
*/
@Column(name = "channel_type", length = 30)
private String channelType;
/**
* 노출 수
*/
@Column(nullable = false)
@Builder.Default
private Integer impressions = 0;
/**
* 조회수
*/
@Column(nullable = false)
@Builder.Default
private Integer views = 0;
/**
* 클릭 수
*/
@Column(nullable = false)
@Builder.Default
private Integer clicks = 0;
/**
* 참여자 수
*/
@Column(nullable = false)
@Builder.Default
private Integer participants = 0;
/**
* 전환 수
*/
@Column(nullable = false)
@Builder.Default
private Integer conversions = 0;
/**
* 배포 비용 (원)
*/
@Column(name = "distribution_cost", precision = 15, scale = 2)
@Builder.Default
private BigDecimal distributionCost = BigDecimal.ZERO;
/**
* 좋아요 수 (SNS 전용)
*/
@Builder.Default
private Integer likes = 0;
/**
* 댓글 수 (SNS 전용)
*/
@Builder.Default
private Integer comments = 0;
/**
* 공유 수 (SNS 전용)
*/
@Builder.Default
private Integer shares = 0;
/**
* 통화 수 (링고비즈 전용)
*/
@Column(name = "total_calls")
@Builder.Default
private Integer totalCalls = 0;
/**
* 완료된 통화 수 (링고비즈 전용)
*/
@Column(name = "completed_calls")
@Builder.Default
private Integer completedCalls = 0;
/**
* 평균 통화 시간 (초) (링고비즈 전용)
*/
@Column(name = "average_duration")
@Builder.Default
private Integer averageDuration = 0;
}
@@ -0,0 +1,106 @@
package com.kt.event.analytics.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
/**
* 이벤트 통계 엔티티
*
* Kafka Event Subscription을 통해 실시간으로 업데이트되는 이벤트 통계 정보
*/
@Entity
@Table(name = "event_stats")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EventStats extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID
*/
@Column(nullable = false, unique = true, length = 50)
private String eventId;
/**
* 이벤트 제목
*/
@Column(nullable = false, length = 200)
private String eventTitle;
/**
* 매장 ID (소유자)
*/
@Column(nullable = false, length = 50)
private String storeId;
/**
* 총 참여자 수
*/
@Column(nullable = false)
@Builder.Default
private Integer totalParticipants = 0;
/**
* 총 노출 수 (모든 채널의 노출 수 합계)
*/
@Column(nullable = false)
@Builder.Default
private Integer totalViews = 0;
/**
* 예상 ROI (%)
*/
@Column(precision = 10, scale = 2)
@Builder.Default
private BigDecimal estimatedRoi = BigDecimal.ZERO;
/**
* 매출 증가율 (%)
*/
@Column(precision = 10, scale = 2)
@Builder.Default
private BigDecimal salesGrowthRate = BigDecimal.ZERO;
/**
* 총 투자 비용 (원)
*/
@Column(precision = 15, scale = 2)
@Builder.Default
private BigDecimal totalInvestment = BigDecimal.ZERO;
/**
* 예상 수익 (원)
*/
@Column(precision = 15, scale = 2)
@Builder.Default
private BigDecimal expectedRevenue = BigDecimal.ZERO;
/**
* 이벤트 상태
*/
@Column(length = 20)
private String status;
/**
* 참여자 수 증가
*/
public void incrementParticipants() {
this.totalParticipants++;
}
/**
* 참여자 수 증가 (특정 수)
*/
public void incrementParticipants(int count) {
this.totalParticipants += count;
}
}
@@ -0,0 +1,75 @@
package com.kt.event.analytics.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 시간대별 데이터 엔티티
*
* 이벤트 기간 동안의 시간대별 참여 추이 데이터
*/
@Entity
@Table(name = "timeline_data", indexes = {
@Index(name = "idx_event_timestamp", columnList = "event_id, timestamp")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TimelineData extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID
*/
@Column(name = "event_id", nullable = false, length = 50)
private String eventId;
/**
* 시간 (집계 기준 시간)
*/
@Column(nullable = false)
private LocalDateTime timestamp;
/**
* 참여자 수
*/
@Column(nullable = false)
@Builder.Default
private Integer participants = 0;
/**
* 조회수
*/
@Column(nullable = false)
@Builder.Default
private Integer views = 0;
/**
* 참여 행동 수
*/
@Column(nullable = false)
@Builder.Default
private Integer engagement = 0;
/**
* 전환 수
*/
@Column(nullable = false)
@Builder.Default
private Integer conversions = 0;
/**
* 누적 참여자 수
*/
@Column(name = "cumulative_participants", nullable = false)
@Builder.Default
private Integer cumulativeParticipants = 0;
}
@@ -0,0 +1,145 @@
package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.messaging.event.DistributionCompletedEvent;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 배포 완료 Consumer
*
* 배포 완료 시 채널 통계 업데이트
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
@RequiredArgsConstructor
public class DistributionCompletedConsumer {
private final ChannelStatsRepository channelStatsRepository;
private final EventStatsRepository eventStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
/**
* DistributionCompleted 이벤트 처리 (설계서 기준 - 여러 채널 배열)
*/
@KafkaListener(topics = "sample.distribution.completed", groupId = "${spring.kafka.consumer.group-id}")
public void handleDistributionCompleted(String message) {
try {
log.info("📩 DistributionCompleted 이벤트 수신: {}", message);
DistributionCompletedEvent event = objectMapper.readValue(message, DistributionCompletedEvent.class);
String eventId = event.getEventId();
// ✅ 1. 멱등성 체크 (중복 처리 방지) - eventId 기반
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_DISTRIBUTIONS_KEY, eventId);
if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}", eventId);
return;
}
// 2. 채널 배열 루프 처리 (설계서: distributedChannels 배열)
if (event.getDistributedChannels() != null && !event.getDistributedChannels().isEmpty()) {
for (DistributionCompletedEvent.ChannelDistribution channel : event.getDistributedChannels()) {
processChannelStats(eventId, channel);
}
log.info("✅ 채널 통계 일괄 업데이트 완료: eventId={}, channelCount={}",
eventId, event.getDistributedChannels().size());
} else {
log.warn("⚠️ 배포된 채널 없음: eventId={}", eventId);
}
// 3. EventStats의 totalViews 업데이트 (모든 채널 노출 수 합계)
updateTotalViews(eventId);
// 4. 캐시 무효화 (다음 조회 시 최신 배포 통계 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 5. 멱등성 처리 완료 기록 (7일 TTL) - eventId 기반
redisTemplate.opsForSet().add(PROCESSED_DISTRIBUTIONS_KEY, eventId);
redisTemplate.expire(PROCESSED_DISTRIBUTIONS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: eventId={}", eventId);
} catch (Exception e) {
log.error("❌ DistributionCompleted 이벤트 처리 실패: {}", e.getMessage(), e);
throw new RuntimeException("DistributionCompleted 처리 실패", e);
}
}
/**
* 개별 채널 통계 처리
*/
private void processChannelStats(String eventId, DistributionCompletedEvent.ChannelDistribution channel) {
try {
String channelName = channel.getChannel();
// 채널 통계 생성 또는 업데이트
ChannelStats channelStats = channelStatsRepository
.findByEventIdAndChannelName(eventId, channelName)
.orElse(ChannelStats.builder()
.eventId(eventId)
.channelName(channelName)
.channelType(channel.getChannelType())
.build());
// 예상 노출 수 저장
if (channel.getExpectedViews() != null) {
channelStats.setImpressions(channel.getExpectedViews());
}
channelStatsRepository.save(channelStats);
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}",
eventId, channelName, channel.getExpectedViews());
} catch (Exception e) {
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);
}
}
/**
* 모든 채널의 예상 노출 수를 합산하여 EventStats.totalViews 업데이트
*/
private void updateTotalViews(String eventId) {
try {
// 모든 채널 통계 조회
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
// 총 노출 수 계산
int totalViews = channelStatsList.stream()
.mapToInt(ChannelStats::getImpressions)
.sum();
// EventStats 업데이트
eventStatsRepository.findByEventId(eventId)
.ifPresentOrElse(
eventStats -> {
eventStats.setTotalViews(totalViews);
eventStatsRepository.save(eventStats);
log.info("✅ 총 노출 수 업데이트: eventId={}, totalViews={}", eventId, totalViews);
},
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
);
} catch (Exception e) {
log.error("❌ totalViews 업데이트 실패: eventId={}", eventId, e);
}
}
}
@@ -0,0 +1,81 @@
package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.messaging.event.EventCreatedEvent;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 이벤트 생성 Consumer
*
* 이벤트 생성 시 Analytics 통계 초기화
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
@RequiredArgsConstructor
public class EventCreatedConsumer {
private final EventStatsRepository eventStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_EVENTS_KEY = "processed_events";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
/**
* EventCreated 이벤트 처리 (MVP용 샘플 토픽)
*/
@KafkaListener(topics = "sample.event.created", groupId = "${spring.kafka.consumer.group-id}")
public void handleEventCreated(String message) {
try {
log.info("📩 EventCreated 이벤트 수신: {}", message);
EventCreatedEvent event = objectMapper.readValue(message, EventCreatedEvent.class);
String eventId = event.getEventId();
// ✅ 1. 멱등성 체크 (중복 처리 방지)
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_EVENTS_KEY, eventId);
if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}", eventId);
return;
}
// 2. 이벤트 통계 초기화
EventStats eventStats = EventStats.builder()
.eventId(eventId)
.eventTitle(event.getEventTitle())
.storeId(event.getStoreId())
.totalParticipants(0)
.totalInvestment(event.getTotalInvestment())
.status(event.getStatus())
.build();
eventStatsRepository.save(eventStats);
log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId);
// 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 4. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_EVENTS_KEY, eventId);
redisTemplate.expire(PROCESSED_EVENTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: eventId={}", eventId);
} catch (Exception e) {
log.error("❌ EventCreated 이벤트 처리 실패: {}", e.getMessage(), e);
throw new RuntimeException("EventCreated 처리 실패", e);
}
}
}
@@ -0,0 +1,81 @@
package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 참여자 등록 Consumer
*
* 참여자 등록 시 실시간 참여자 수 업데이트
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
@RequiredArgsConstructor
public class ParticipantRegisteredConsumer {
private final EventStatsRepository eventStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
/**
* ParticipantRegistered 이벤트 처리 (MVP용 샘플 토픽)
*/
@KafkaListener(topics = "sample.participant.registered", groupId = "${spring.kafka.consumer.group-id}")
public void handleParticipantRegistered(String message) {
try {
log.info("📩 ParticipantRegistered 이벤트 수신: {}", message);
ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class);
String participantId = event.getParticipantId();
String eventId = event.getEventId();
// ✅ 1. 멱등성 체크 (중복 처리 방지)
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId);
if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId);
return;
}
// 2. 이벤트 통계 업데이트 (참여자 수 +1)
eventStatsRepository.findByEventId(eventId)
.ifPresentOrElse(
eventStats -> {
eventStats.incrementParticipants();
eventStatsRepository.save(eventStats);
log.info("✅ 참여자 수 업데이트: eventId={}, totalParticipants={}",
eventId, eventStats.getTotalParticipants());
},
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
);
// 3. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 4. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId);
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: participantId={}", participantId);
} catch (Exception e) {
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);
throw new RuntimeException("ParticipantRegistered 처리 실패", e);
}
}
}
@@ -0,0 +1,66 @@
package com.kt.event.analytics.messaging.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 배포 완료 이벤트 (설계서 기준)
*
* Distribution Service가 한 이벤트의 모든 채널 배포 완료 시 발행
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DistributionCompletedEvent {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 배포된 채널 목록 (여러 채널을 배열로 포함)
*/
private List<ChannelDistribution> distributedChannels;
/**
* 배포 완료 시각
*/
private LocalDateTime completedAt;
/**
* 개별 채널 배포 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ChannelDistribution {
/**
* 채널명 (우리동네TV, 지니TV, 링고비즈, SNS)
*/
private String channel;
/**
* 채널 유형 (TV, CALL, SNS)
*/
private String channelType;
/**
* 배포 상태 (SUCCESS, FAILURE)
*/
private String status;
/**
* 예상 노출 수
*/
private Integer expectedViews;
}
}
@@ -0,0 +1,43 @@
package com.kt.event.analytics.messaging.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 이벤트 생성 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventCreatedEvent {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 이벤트 제목
*/
private String eventTitle;
/**
* 매장 ID
*/
private String storeId;
/**
* 총 투자 비용
*/
private BigDecimal totalInvestment;
/**
* 이벤트 상태
*/
private String status;
}
@@ -0,0 +1,31 @@
package com.kt.event.analytics.messaging.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 참여자 등록 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantRegisteredEvent {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 참여자 ID
*/
private String participantId;
/**
* 참여 채널
*/
private String channel;
}
@@ -0,0 +1,32 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.ChannelStats;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 채널 통계 Repository
*/
@Repository
public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long> {
/**
* 이벤트 ID로 모든 채널 통계 조회
*
* @param eventId 이벤트 ID
* @return 채널 통계 목록
*/
List<ChannelStats> findByEventId(String eventId);
/**
* 이벤트 ID와 채널명으로 통계 조회
*
* @param eventId 이벤트 ID
* @param channelName 채널명
* @return 채널 통계
*/
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
}
@@ -0,0 +1,31 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.EventStats;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 이벤트 통계 Repository
*/
@Repository
public interface EventStatsRepository extends JpaRepository<EventStats, Long> {
/**
* 이벤트 ID로 통계 조회
*
* @param eventId 이벤트 ID
* @return 이벤트 통계
*/
Optional<EventStats> findByEventId(String eventId);
/**
* 매장 ID와 이벤트 ID로 통계 조회
*
* @param storeId 매장 ID
* @param eventId 이벤트 ID
* @return 이벤트 통계
*/
Optional<EventStats> findByStoreIdAndEventId(String storeId, String eventId);
}
@@ -0,0 +1,40 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.TimelineData;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/**
* 시간대별 데이터 Repository
*/
@Repository
public interface TimelineDataRepository extends JpaRepository<TimelineData, Long> {
/**
* 이벤트 ID로 시간대별 데이터 조회 (시간 순 정렬)
*
* @param eventId 이벤트 ID
* @return 시간대별 데이터 목록
*/
List<TimelineData> findByEventIdOrderByTimestampAsc(String eventId);
/**
* 이벤트 ID와 기간으로 시간대별 데이터 조회
*
* @param eventId 이벤트 ID
* @param startDate 시작 날짜
* @param endDate 종료 날짜
* @return 시간대별 데이터 목록
*/
@Query("SELECT t FROM TimelineData t WHERE t.eventId = :eventId AND t.timestamp BETWEEN :startDate AND :endDate ORDER BY t.timestamp ASC")
List<TimelineData> findByEventIdAndTimestampBetween(
@Param("eventId") String eventId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
}
@@ -0,0 +1,216 @@
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.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
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.Duration;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Analytics Service
*
* 이벤트 성과 대시보드 데이터를 제공하는 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AnalyticsService {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final ExternalChannelService externalChannelService;
private final ROICalculator roiCalculator;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long CACHE_TTL = 3600; // 1시간 (단일 캐시)
/**
* 대시보드 데이터 조회
*
* @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜 (선택)
* @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부
* @return 대시보드 응답
*/
public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
String cacheKey = CACHE_KEY_PREFIX + eventId;
// 1. Redis 캐시 조회 (refresh가 false일 때만)
if (!refresh) {
String cachedData = redisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
try {
log.info("✅ 캐시 HIT: {} (1시간 캐시)", cacheKey);
return objectMapper.readValue(cachedData, AnalyticsDashboardResponse.class);
} catch (JsonProcessingException e) {
log.warn("캐시 데이터 역직렬화 실패: {}", e.getMessage());
}
}
}
// 2. 캐시 MISS: 데이터 통합 작업
log.info("캐시 MISS 또는 refresh=true: PostgreSQL + 외부 API 호출");
// 2-1. Analytics DB 조회 (PostgreSQL)
EventStats eventStats = eventStatsRepository.findByEventId(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
log.debug("PostgreSQL 조회 완료: eventId={}, 채널 수={}", eventId, channelStatsList.size());
// 2-2. 외부 채널 API 병렬 호출 (Circuit Breaker 적용)
try {
externalChannelService.updateChannelStatsFromExternalAPIs(eventId, channelStatsList);
log.info("외부 API 호출 성공: eventId={}", eventId);
} catch (Exception e) {
log.warn("외부 API 호출 실패, PostgreSQL 샘플 데이터 사용: eventId={}, error={}",
eventId, e.getMessage());
// Fallback: PostgreSQL 샘플 데이터만 사용
}
// 3. 대시보드 데이터 구성
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate);
// 4. Redis 캐싱 (1시간 TTL)
try {
String jsonData = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
log.info("✅ Redis 캐시 저장 완료: {} (TTL: 1시간)", cacheKey);
} catch (JsonProcessingException e) {
log.warn("캐시 데이터 직렬화 실패: {}", e.getMessage());
} catch (Exception e) {
log.warn("캐시 저장 실패 (무시하고 계속 진행): {}", e.getMessage());
}
return response;
}
/**
* 대시보드 데이터 구성
*/
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList,
LocalDateTime startDate, LocalDateTime endDate) {
// 기간 정보
PeriodInfo period = buildPeriodInfo(startDate, endDate);
// 성과 요약
AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList);
// 채널별 성과 요약
List<ChannelSummary> channelPerformance = buildChannelPerformance(channelStatsList, eventStats.getTotalInvestment());
// ROI 요약
RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats);
return AnalyticsDashboardResponse.builder()
.eventId(eventStats.getEventId())
.eventTitle(eventStats.getEventTitle())
.period(period)
.summary(summary)
.channelPerformance(channelPerformance)
.roi(roiSummary)
.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();
long durationDays = ChronoUnit.DAYS.between(start, end);
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
.durationDays((int) durationDays)
.build();
}
/**
* 성과 요약 구성
*/
private AnalyticsSummary buildAnalyticsSummary(EventStats eventStats, List<ChannelStats> channelStatsList) {
int totalViews = channelStatsList.stream()
.mapToInt(ChannelStats::getViews)
.sum();
int totalReach = channelStatsList.stream()
.mapToInt(ChannelStats::getImpressions)
.sum();
double engagementRate = totalViews > 0 ? (eventStats.getTotalParticipants() * 100.0 / totalViews) : 0.0;
double conversionRate = totalViews > 0 ? (eventStats.getTotalParticipants() * 100.0 / totalViews) : 0.0;
// SNS 반응 통계 집계
int totalLikes = channelStatsList.stream().mapToInt(ChannelStats::getLikes).sum();
int totalComments = channelStatsList.stream().mapToInt(ChannelStats::getComments).sum();
int totalShares = channelStatsList.stream().mapToInt(ChannelStats::getShares).sum();
SocialInteractionStats socialStats = SocialInteractionStats.builder()
.likes(totalLikes)
.comments(totalComments)
.shares(totalShares)
.build();
return AnalyticsSummary.builder()
.totalParticipants(eventStats.getTotalParticipants())
.totalViews(totalViews)
.totalReach(totalReach)
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
.averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 함)
.socialInteractions(socialStats)
.build();
}
/**
* 채널별 성과 구성
*/
private List<ChannelSummary> buildChannelPerformance(List<ChannelStats> channelStatsList, java.math.BigDecimal totalInvestment) {
List<ChannelSummary> summaries = new ArrayList<>();
for (ChannelStats stats : channelStatsList) {
double engagementRate = stats.getViews() > 0 ? (stats.getParticipants() * 100.0 / stats.getViews()) : 0.0;
double conversionRate = stats.getViews() > 0 ? (stats.getConversions() * 100.0 / stats.getViews()) : 0.0;
double roi = stats.getDistributionCost().compareTo(java.math.BigDecimal.ZERO) > 0 ?
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
summaries.add(ChannelSummary.builder()
.channelName(stats.getChannelName())
.views(stats.getViews())
.participants(stats.getParticipants())
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
.roi(Math.round(roi * 10.0) / 10.0)
.build());
}
return summaries;
}
}
@@ -0,0 +1,241 @@
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.repository.ChannelStatsRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.util.*;
import java.util.stream.Collectors;
/**
* 채널별 분석 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChannelAnalyticsService {
private final ChannelStatsRepository channelStatsRepository;
private final ExternalChannelService externalChannelService;
/**
* 채널별 성과 분석
*/
public ChannelAnalyticsResponse getChannelAnalytics(String eventId, List<String> channels, String sortBy, String order) {
log.info("채널별 성과 분석 조회: eventId={}", eventId);
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
// 외부 API 호출하여 최신 데이터 반영
externalChannelService.updateChannelStatsFromExternalAPIs(eventId, channelStatsList);
// 필터링 (특정 채널만 조회)
if (channels != null && !channels.isEmpty()) {
channelStatsList = channelStatsList.stream()
.filter(stats -> channels.contains(stats.getChannelName()))
.collect(Collectors.toList());
}
// 채널별 상세 분석 구성
List<ChannelAnalytics> channelAnalytics = buildChannelAnalytics(channelStatsList);
// 정렬
channelAnalytics = sortChannelAnalytics(channelAnalytics, sortBy, order);
// 채널 간 비교 분석
ChannelComparison comparison = buildChannelComparison(channelAnalytics);
return ChannelAnalyticsResponse.builder()
.eventId(eventId)
.channels(channelAnalytics)
.comparison(comparison)
.lastUpdatedAt(LocalDateTime.now())
.build();
}
/**
* 채널별 상세 분석 구성
*/
private List<ChannelAnalytics> buildChannelAnalytics(List<ChannelStats> channelStatsList) {
return channelStatsList.stream()
.map(this::buildChannelAnalytics)
.collect(Collectors.toList());
}
private ChannelAnalytics buildChannelAnalytics(ChannelStats stats) {
ChannelMetrics metrics = buildChannelMetrics(stats);
ChannelPerformance performance = buildChannelPerformance(stats);
ChannelCosts costs = buildChannelCosts(stats);
return ChannelAnalytics.builder()
.channelName(stats.getChannelName())
.channelType(stats.getChannelType())
.metrics(metrics)
.performance(performance)
.costs(costs)
.externalApiStatus("success")
.build();
}
/**
* 채널 지표 구성
*/
private ChannelMetrics buildChannelMetrics(ChannelStats stats) {
SocialInteractionStats socialStats = null;
if (stats.getLikes() > 0 || stats.getComments() > 0 || stats.getShares() > 0) {
socialStats = SocialInteractionStats.builder()
.likes(stats.getLikes())
.comments(stats.getComments())
.shares(stats.getShares())
.build();
}
VoiceCallStats voiceStats = null;
if (stats.getTotalCalls() > 0) {
double completionRate = stats.getTotalCalls() > 0 ?
(stats.getCompletedCalls() * 100.0 / stats.getTotalCalls()) : 0.0;
voiceStats = VoiceCallStats.builder()
.totalCalls(stats.getTotalCalls())
.completedCalls(stats.getCompletedCalls())
.averageDuration(stats.getAverageDuration())
.completionRate(Math.round(completionRate * 10.0) / 10.0)
.build();
}
return ChannelMetrics.builder()
.impressions(stats.getImpressions())
.views(stats.getViews())
.clicks(stats.getClicks())
.participants(stats.getParticipants())
.conversions(stats.getConversions())
.socialInteractions(socialStats)
.voiceCallStats(voiceStats)
.build();
}
/**
* 채널 성과 지표 구성
*/
private ChannelPerformance buildChannelPerformance(ChannelStats stats) {
double ctr = stats.getImpressions() > 0 ? (stats.getClicks() * 100.0 / stats.getImpressions()) : 0.0;
double engagementRate = stats.getViews() > 0 ? (stats.getParticipants() * 100.0 / stats.getViews()) : 0.0;
double conversionRate = stats.getViews() > 0 ? (stats.getConversions() * 100.0 / stats.getViews()) : 0.0;
return ChannelPerformance.builder()
.clickThroughRate(Math.round(ctr * 10.0) / 10.0)
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
.averageEngagementTime(165)
.bounceRate(35.8)
.build();
}
/**
* 채널 비용 구성
*/
private ChannelCosts buildChannelCosts(ChannelStats stats) {
double cpv = stats.getViews() > 0 ?
stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getViews()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0;
double cpc = stats.getClicks() > 0 ?
stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getClicks()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0;
double cpa = stats.getParticipants() > 0 ?
stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getParticipants()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0;
double roi = stats.getDistributionCost().compareTo(BigDecimal.ZERO) > 0 ?
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
return ChannelCosts.builder()
.distributionCost(stats.getDistributionCost())
.costPerView(Math.round(cpv * 100.0) / 100.0)
.costPerClick(Math.round(cpc * 100.0) / 100.0)
.costPerAcquisition(Math.round(cpa * 100.0) / 100.0)
.roi(Math.round(roi * 10.0) / 10.0)
.build();
}
/**
* 채널 정렬
*/
private List<ChannelAnalytics> sortChannelAnalytics(List<ChannelAnalytics> channelAnalytics, String sortBy, String order) {
Comparator<ChannelAnalytics> comparator = switch (sortBy != null ? sortBy : "roi") {
case "views" -> Comparator.comparing(c -> c.getMetrics().getViews());
case "participants" -> Comparator.comparing(c -> c.getMetrics().getParticipants());
case "engagement_rate" -> Comparator.comparing(c -> c.getPerformance().getEngagementRate());
case "conversion_rate" -> Comparator.comparing(c -> c.getPerformance().getConversionRate());
default -> Comparator.comparing(c -> c.getCosts().getRoi());
};
if ("asc".equals(order)) {
channelAnalytics.sort(comparator);
} else {
channelAnalytics.sort(comparator.reversed());
}
return channelAnalytics;
}
/**
* 채널 간 비교 분석 구성
*/
private ChannelComparison buildChannelComparison(List<ChannelAnalytics> channelAnalytics) {
if (channelAnalytics.isEmpty()) {
return null;
}
// 최고 성과 채널 찾기
String bestByViews = channelAnalytics.stream()
.max(Comparator.comparing(c -> c.getMetrics().getViews()))
.map(ChannelAnalytics::getChannelName)
.orElse(null);
String bestByEngagement = channelAnalytics.stream()
.max(Comparator.comparing(c -> c.getPerformance().getEngagementRate()))
.map(ChannelAnalytics::getChannelName)
.orElse(null);
String bestByRoi = channelAnalytics.stream()
.max(Comparator.comparing(c -> c.getCosts().getRoi()))
.map(ChannelAnalytics::getChannelName)
.orElse(null);
Map<String, String> bestPerforming = new HashMap<>();
bestPerforming.put("byViews", bestByViews);
bestPerforming.put("byEngagement", bestByEngagement);
bestPerforming.put("byRoi", bestByRoi);
// 평균 지표 계산
double avgEngagementRate = channelAnalytics.stream()
.mapToDouble(c -> c.getPerformance().getEngagementRate())
.average()
.orElse(0.0);
double avgConversionRate = channelAnalytics.stream()
.mapToDouble(c -> c.getPerformance().getConversionRate())
.average()
.orElse(0.0);
double avgRoi = channelAnalytics.stream()
.mapToDouble(c -> c.getCosts().getRoi())
.average()
.orElse(0.0);
Map<String, Double> averageMetrics = new HashMap<>();
averageMetrics.put("engagementRate", Math.round(avgEngagementRate * 10.0) / 10.0);
averageMetrics.put("conversionRate", Math.round(avgConversionRate * 10.0) / 10.0);
averageMetrics.put("roi", Math.round(avgRoi * 10.0) / 10.0);
return ChannelComparison.builder()
.bestPerforming(bestPerforming)
.averageMetrics(averageMetrics)
.build();
}
}
@@ -0,0 +1,142 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.entity.ChannelStats;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 외부 채널 Service
*
* 외부 API 호출 및 Circuit Breaker 적용
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExternalChannelService {
/**
* 외부 채널 API에서 통계 업데이트
*
* @param eventId 이벤트 ID
* @param channelStatsList 채널 통계 목록
*/
public void updateChannelStatsFromExternalAPIs(String eventId, List<ChannelStats> channelStatsList) {
log.info("외부 채널 API 병렬 호출 시작: eventId={}", eventId);
List<CompletableFuture<Void>> futures = channelStatsList.stream()
.map(channelStats -> CompletableFuture.runAsync(() ->
updateChannelStatsFromAPI(eventId, channelStats)))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
log.info("외부 채널 API 병렬 호출 완료: eventId={}", eventId);
}
/**
* 개별 채널 통계 업데이트
*/
private void updateChannelStatsFromAPI(String eventId, ChannelStats channelStats) {
String channelName = channelStats.getChannelName();
log.debug("채널 통계 업데이트: eventId={}, channel={}", eventId, channelName);
switch (channelName) {
case "우리동네TV" -> updateWooriTVStats(eventId, channelStats);
case "지니TV" -> updateGenieTVStats(eventId, channelStats);
case "링고비즈" -> updateRingoBizStats(eventId, channelStats);
case "SNS" -> updateSNSStats(eventId, channelStats);
default -> log.warn("알 수 없는 채널: {}", channelName);
}
}
/**
* 우리동네TV 통계 업데이트
*/
@CircuitBreaker(name = "wooriTV", fallbackMethod = "wooriTVFallback")
private void updateWooriTVStats(String eventId, ChannelStats channelStats) {
log.debug("우리동네TV API 호출: eventId={}", eventId);
// 실제 API 호출 로직 (Feign Client 사용)
// 예시 데이터 설정
channelStats.setViews(45000);
channelStats.setClicks(5500);
channelStats.setImpressions(120000);
}
/**
* 우리동네TV Fallback
*/
private void wooriTVFallback(String eventId, ChannelStats channelStats, Exception e) {
log.warn("우리동네TV API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage());
// Fallback 데이터 (캐시 또는 기본값)
channelStats.setViews(0);
channelStats.setClicks(0);
}
/**
* 지니TV 통계 업데이트
*/
@CircuitBreaker(name = "genieTV", fallbackMethod = "genieTVFallback")
private void updateGenieTVStats(String eventId, ChannelStats channelStats) {
log.debug("지니TV API 호출: eventId={}", eventId);
// 예시 데이터 설정
channelStats.setViews(30000);
channelStats.setClicks(3000);
channelStats.setImpressions(80000);
}
/**
* 지니TV Fallback
*/
private void genieTVFallback(String eventId, ChannelStats channelStats, Exception e) {
log.warn("지니TV API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage());
channelStats.setViews(0);
channelStats.setClicks(0);
}
/**
* 링고비즈 통계 업데이트
*/
@CircuitBreaker(name = "ringoBiz", fallbackMethod = "ringoBizFallback")
private void updateRingoBizStats(String eventId, ChannelStats channelStats) {
log.debug("링고비즈 API 호출: eventId={}", eventId);
// 예시 데이터 설정
channelStats.setTotalCalls(3000);
channelStats.setCompletedCalls(2500);
channelStats.setAverageDuration(45);
}
/**
* 링고비즈 Fallback
*/
private void ringoBizFallback(String eventId, ChannelStats channelStats, Exception e) {
log.warn("링고비즈 API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage());
channelStats.setTotalCalls(0);
channelStats.setCompletedCalls(0);
}
/**
* SNS 통계 업데이트
*/
@CircuitBreaker(name = "sns", fallbackMethod = "snsFallback")
private void updateSNSStats(String eventId, ChannelStats channelStats) {
log.debug("SNS API 호출: eventId={}", eventId);
// 예시 데이터 설정
channelStats.setLikes(3450);
channelStats.setComments(890);
channelStats.setShares(1250);
}
/**
* SNS Fallback
*/
private void snsFallback(String eventId, ChannelStats channelStats, Exception e) {
log.warn("SNS API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage());
channelStats.setLikes(0);
channelStats.setComments(0);
channelStats.setShares(0);
}
}
@@ -0,0 +1,202 @@
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
/**
* ROI 계산 유틸리티
*
* 이벤트의 투자 대비 수익률을 계산하는 비즈니스 로직
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ROICalculator {
/**
* ROI 상세 계산
*
* @param eventStats 이벤트 통계
* @param channelStats 채널별 통계
* @return ROI 상세 분석 결과
*/
public RoiAnalyticsResponse calculateDetailedRoi(EventStats eventStats, List<ChannelStats> channelStats) {
log.debug("ROI 상세 계산 시작: eventId={}", eventStats.getEventId());
// 투자 비용 계산
InvestmentDetails investment = calculateInvestment(eventStats, channelStats);
// 수익 계산
RevenueDetails revenue = calculateRevenue(eventStats);
// ROI 계산
RoiCalculation roiCalc = calculateRoi(investment, revenue);
// 비용 효율성 계산
CostEfficiency costEfficiency = calculateCostEfficiency(investment, revenue, eventStats);
// 수익 예측
RevenueProjection projection = projectRevenue(revenue, eventStats);
return RoiAnalyticsResponse.builder()
.eventId(eventStats.getEventId())
.investment(investment)
.revenue(revenue)
.roi(roiCalc)
.costEfficiency(costEfficiency)
.projection(projection)
.lastUpdatedAt(LocalDateTime.now())
.build();
}
/**
* 투자 비용 계산
*/
private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) {
BigDecimal distributionCost = channelStats.stream()
.map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal contentCreation = eventStats.getTotalInvestment()
.multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40%를 콘텐츠 제작비로 가정
BigDecimal operation = eventStats.getTotalInvestment()
.multiply(BigDecimal.valueOf(0.1)); // 10%를 운영비로 가정
return InvestmentDetails.builder()
.contentCreation(contentCreation)
.distribution(distributionCost)
.operation(operation)
.total(eventStats.getTotalInvestment())
.build();
}
/**
* 수익 계산
*/
private RevenueDetails calculateRevenue(EventStats eventStats) {
BigDecimal directSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66%를 직접 매출로 가정
BigDecimal expectedSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.34)); // 34%를 예상 추가 매출로 가정
BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요
return RevenueDetails.builder()
.directSales(directSales)
.expectedSales(expectedSales)
.brandValue(brandValue)
.total(eventStats.getExpectedRevenue())
.build();
}
/**
* ROI 계산
*/
private RoiCalculation calculateRoi(InvestmentDetails investment, RevenueDetails revenue) {
BigDecimal netProfit = revenue.getTotal().subtract(investment.getTotal());
double roiPercentage = 0.0;
if (investment.getTotal().compareTo(BigDecimal.ZERO) > 0) {
roiPercentage = netProfit.divide(investment.getTotal(), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.doubleValue();
}
// 손익분기점 계산 (간단한 선형 모델)
LocalDateTime breakEvenPoint = null;
if (roiPercentage > 0) {
breakEvenPoint = LocalDateTime.now().minusDays(5); // 예시
}
Integer paybackPeriod = roiPercentage > 0 ? 10 : null; // 예시
return RoiCalculation.builder()
.netProfit(netProfit)
.roiPercentage(roiPercentage)
.breakEvenPoint(breakEvenPoint)
.paybackPeriod(paybackPeriod)
.build();
}
/**
* 비용 효율성 계산
*/
private CostEfficiency calculateCostEfficiency(InvestmentDetails investment, RevenueDetails revenue, EventStats eventStats) {
double costPerParticipant = 0.0;
double costPerConversion = 0.0;
double costPerView = 0.0;
double revenuePerParticipant = 0.0;
if (eventStats.getTotalParticipants() > 0) {
costPerParticipant = investment.getTotal()
.divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP)
.doubleValue();
revenuePerParticipant = revenue.getTotal()
.divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP)
.doubleValue();
}
return CostEfficiency.builder()
.costPerParticipant(costPerParticipant)
.costPerConversion(costPerConversion)
.costPerView(costPerView)
.revenuePerParticipant(revenuePerParticipant)
.build();
}
/**
* 수익 예측
*/
private RevenueProjection projectRevenue(RevenueDetails revenue, EventStats eventStats) {
BigDecimal projectedFinal = revenue.getTotal()
.multiply(BigDecimal.valueOf(1.1)); // 현재 수익의 110%로 예측
return RevenueProjection.builder()
.currentRevenue(revenue.getTotal())
.projectedFinalRevenue(projectedFinal)
.confidenceLevel(85.5)
.basedOn("현재 추세 및 과거 유사 이벤트 데이터")
.build();
}
/**
* ROI 요약 계산
*/
public RoiSummary calculateRoiSummary(EventStats eventStats) {
BigDecimal netProfit = eventStats.getExpectedRevenue().subtract(eventStats.getTotalInvestment());
double roi = 0.0;
if (eventStats.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0) {
roi = netProfit.divide(eventStats.getTotalInvestment(), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.doubleValue();
}
double cpa = 0.0;
if (eventStats.getTotalParticipants() > 0) {
cpa = eventStats.getTotalInvestment()
.divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP)
.doubleValue();
}
return RoiSummary.builder()
.totalInvestment(eventStats.getTotalInvestment())
.expectedRevenue(eventStats.getExpectedRevenue())
.netProfit(netProfit)
.roi(roi)
.costPerAcquisition(cpa)
.build();
}
}
@@ -0,0 +1,53 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.RoiAnalyticsResponse;
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.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* ROI 분석 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RoiAnalyticsService {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final ROICalculator roiCalculator;
/**
* ROI 상세 분석 조회
*/
public RoiAnalyticsResponse getRoiAnalytics(String eventId, boolean includeProjection) {
log.info("ROI 상세 분석 조회: eventId={}, includeProjection={}", eventId, includeProjection);
// 이벤트 통계 조회
EventStats eventStats = eventStatsRepository.findByEventId(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
// 채널별 통계 조회
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
// ROI 상세 계산
RoiAnalyticsResponse response = roiCalculator.calculateDetailedRoi(eventStats, channelStatsList);
// 예측 데이터 제외 옵션
if (!includeProjection) {
response.setProjection(null);
}
return response;
}
}
@@ -0,0 +1,206 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*;
import com.kt.event.analytics.entity.TimelineData;
import com.kt.event.analytics.repository.TimelineDataRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* 시간대별 분석 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TimelineAnalyticsService {
private final TimelineDataRepository timelineDataRepository;
/**
* 시간대별 참여 추이 조회
*/
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics) {
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
// 시간대별 데이터 조회
List<TimelineData> timelineDataList;
if (startDate != null && endDate != null) {
timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
} else {
timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
}
// 시간대별 데이터 포인트 구성
List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList);
// 추세 분석
TrendAnalysis trends = buildTrendAnalysis(dataPoints);
// 피크 타임 분석
List<PeakTimeInfo> peakTimes = buildPeakTimes(dataPoints);
return TimelineAnalyticsResponse.builder()
.eventId(eventId)
.interval(interval != null ? interval : "daily")
.dataPoints(dataPoints)
.trends(trends)
.peakTimes(peakTimes)
.lastUpdatedAt(LocalDateTime.now())
.build();
}
/**
* 시간대별 데이터 포인트 구성
*/
private List<TimelineDataPoint> buildTimelineDataPoints(List<TimelineData> timelineDataList) {
return timelineDataList.stream()
.map(data -> TimelineDataPoint.builder()
.timestamp(data.getTimestamp())
.participants(data.getParticipants())
.views(data.getViews())
.engagement(data.getEngagement())
.conversions(data.getConversions())
.cumulativeParticipants(data.getCumulativeParticipants())
.build())
.collect(Collectors.toList());
}
/**
* 추세 분석 구성
*/
private TrendAnalysis buildTrendAnalysis(List<TimelineDataPoint> dataPoints) {
if (dataPoints.isEmpty()) {
return null;
}
// 전체 추세 계산
String overallTrend = calculateOverallTrend(dataPoints);
// 증가율 계산
double growthRate = calculateGrowthRate(dataPoints);
// 예상 참여자 수
int projectedParticipants = calculateProjectedParticipants(dataPoints);
// 피크 기간 계산
String peakPeriod = calculatePeakPeriod(dataPoints);
return TrendAnalysis.builder()
.overallTrend(overallTrend)
.growthRate(Math.round(growthRate * 10.0) / 10.0)
.projectedParticipants(projectedParticipants)
.peakPeriod(peakPeriod)
.build();
}
/**
* 전체 추세 계산
*/
private String calculateOverallTrend(List<TimelineDataPoint> dataPoints) {
if (dataPoints.size() < 2) {
return "stable";
}
int firstHalfParticipants = dataPoints.stream()
.limit(dataPoints.size() / 2)
.mapToInt(TimelineDataPoint::getParticipants)
.sum();
int secondHalfParticipants = dataPoints.stream()
.skip(dataPoints.size() / 2)
.mapToInt(TimelineDataPoint::getParticipants)
.sum();
if (secondHalfParticipants > firstHalfParticipants * 1.1) {
return "increasing";
} else if (secondHalfParticipants < firstHalfParticipants * 0.9) {
return "decreasing";
} else {
return "stable";
}
}
/**
* 증가율 계산
*/
private double calculateGrowthRate(List<TimelineDataPoint> dataPoints) {
if (dataPoints.size() < 2) {
return 0.0;
}
int firstParticipants = dataPoints.get(0).getParticipants();
int lastParticipants = dataPoints.get(dataPoints.size() - 1).getParticipants();
if (firstParticipants == 0) {
return 0.0;
}
return ((lastParticipants - firstParticipants) * 100.0 / firstParticipants);
}
/**
* 예상 참여자 수 계산
*/
private int calculateProjectedParticipants(List<TimelineDataPoint> dataPoints) {
if (dataPoints.isEmpty()) {
return 0;
}
return dataPoints.get(dataPoints.size() - 1).getCumulativeParticipants();
}
/**
* 피크 기간 계산
*/
private String calculatePeakPeriod(List<TimelineDataPoint> dataPoints) {
TimelineDataPoint peakPoint = dataPoints.stream()
.max(Comparator.comparing(TimelineDataPoint::getParticipants))
.orElse(null);
if (peakPoint == null) {
return "";
}
return peakPoint.getTimestamp().toLocalDate().toString();
}
/**
* 피크 타임 구성
*/
private List<PeakTimeInfo> buildPeakTimes(List<TimelineDataPoint> dataPoints) {
List<PeakTimeInfo> peakTimes = new ArrayList<>();
// 참여자 수 피크
dataPoints.stream()
.max(Comparator.comparing(TimelineDataPoint::getParticipants))
.ifPresent(point -> peakTimes.add(PeakTimeInfo.builder()
.timestamp(point.getTimestamp())
.metric("participants")
.value(point.getParticipants())
.description("최대 참여자 수")
.build()));
// 조회수 피크
dataPoints.stream()
.max(Comparator.comparing(TimelineDataPoint::getViews))
.ifPresent(point -> peakTimes.add(PeakTimeInfo.builder()
.timestamp(point.getTimestamp())
.metric("views")
.value(point.getViews())
.description("최대 조회수")
.build()));
return peakTimes;
}
}
@@ -0,0 +1,158 @@
spring:
application:
name: analytics-service
# Database
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:analytics_db}
username: ${DB_USERNAME:analytics_user}
password: ${DB_PASSWORD:analytics_pass}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis
data:
redis:
host: ${REDIS_HOST:20.214.210.71}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Hi5Jessica!}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:5}
# Kafka (원격 서버 사용)
kafka:
enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer:
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service}
auto-offset-reset: earliest
enable-auto-commit: true
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
retries: 3
properties:
connections.max.idle.ms: 540000
request.timeout.ms: 30000
session.timeout.ms: 30000
heartbeat.interval.ms: 3000
max.poll.interval.ms: 300000
# Sample Data (MVP Only)
# ⚠️ 실제 운영: false로 설정 (다른 서비스들이 이벤트 발행)
# ⚠️ MVP 환경: true로 설정 (SampleDataLoader가 이벤트 발행)
sample-data:
enabled: ${SAMPLE_DATA_ENABLED:true}
# Server
server:
port: ${SERVER_PORT:8086}
# JWT
jwt:
secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
# 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
# OpenAPI Documentation
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: false
# Logging
logging:
level:
com.kt.event.analytics: ${LOG_LEVEL_APP:DEBUG}
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE}
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: ${LOG_FILE:logs/analytics-service.log}
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 7
total-size-cap: 100MB
# Resilience4j Circuit Breaker
resilience4j:
circuitbreaker:
instances:
wooriTV:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
permitted-number-of-calls-in-half-open-state: 3
genieTV:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
ringoBiz:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
sns:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
# Batch Scheduler
batch:
analytics:
refresh-interval: ${BATCH_REFRESH_INTERVAL:300000} # 5분 (밀리초)
initial-delay: ${BATCH_INITIAL_DELAY:30000} # 30초 (밀리초)
enabled: ${BATCH_ENABLED:true} # 배치 활성화 여부
+82
View File
@@ -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
+220
View File
@@ -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 아이콘 버튼과 화면 타이틀 표시
- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
```
+180
View File
@@ -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
```
+3 -1
View File
@@ -1,5 +1,6 @@
% Total % Received % Xferd Average Speed Time Time Time Current % Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드
[요청사항] [요청사항]
@@ -150,7 +151,8 @@
<option name="IS_ENABLED" value="false" /> <option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" /> <option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" /> <option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" /> <option name="IS_IGNORE_MISSING_FILES" value="false
100 9115 100 9115 0 0 28105 0 --:--:-- --:--:-- --:--:-- 28219" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" /> <option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES> <ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" /> <ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
+48
View File
@@ -0,0 +1,48 @@
# 백엔드 테스트 가이드
[요청사항]
- <테스트원칙>을 준용하여 수행
- <테스트순서>에 따라 수행
- [결과파일] 안내에 따라 파일 작성
[가이드]
<테스트원칙>
- 설정 Manifest(src/main/resources/application*.yml)의 각 항목의 값은 하드코딩하지 않고 환경변수 처리
- Kubernetes에 배포된 데이터베이스는 LoadBalacer유형의 Service를 만들어 연결
<테스트순서>
- 준비:
- 설정 Manifest(src/main/resources/application*.yml)와 실행 프로파일({service-name}.run.xml 내부에 있음)의 일치여부 검사 및 수정
- 실행:
- 'curl'명령을 이용한 테스트 및 오류 수정
- 서비스 의존관계를 고려하여 테스트 순서 결정
- 순서에 따라 순차적으로 각 서비스의 Controller에서 API 스펙 확인 후 API 테스트
- API경로와 DTO클래스를 확인하여 정확한 request data 구성
- 소스 수정 후 테스트 절차
- 컴파일 및 오류 수정: {프로젝트 루트}/gradlew {service-name}:compileJava
- 컴파일 성공 후 서비스 재시작 요청: 서비스 시작은 인간에게 요청
- 만약 직접 서비스를 실행하려면 '<서비스 시작 방법>'으로 수행
- 서비스 중지는 '<서비스 중지 방법>'을 참조 수행
- 설정 Manifest 수정 시 민감 정보는 기본값으로 지정하지 않고 '<실행프로파일 작성 가이드>'를 참조하여 실행 프로파일에 값을 지정함
- 실행 결과 로그는 'logs' 디렉토리 하위에 생성
- 결과: test-backend.md
<실행프로파일 작성 가이드>
- {service-name}/.run/{service-name}.run.xml 파일로 작성
- Kubernetes에 배포된 데이터베이스의 LoadBalancer Service 확인:
- kubectl get svc -n {namespace} | grep LoadBalancer 명령으로 LoadBalancer IP 확인
- 각 서비스별 데이터베이스의 LoadBalancer External IP를 DB_HOST로 사용
- 캐시(Redis)의 LoadBalancer External IP를 REDIS_HOST로 사용
<서비스 시작 방법>
- 'IntelliJ서비스실행기'를 'tools' 디렉토리에 다운로드
- python 또는 python3 명령으로 백그라우드로 실행하고 결과 로그를 분석
nohup python3 tools/run-intellij-service-profile.py {service-name} > logs/{service-name}.log 2>&1 & echo "Started {service-name} with PID: $!"
- 서비스 실행은 다른 방법 사용하지 말고 **반드시 python 프로그램 이용**
<서비스 중지 방법>
- Window
- netstat -ano | findstr :{PORT}
- powershell "Stop-Process -Id {Process number} -Force"
- Linux/Mac
- netstat -ano | grep {PORT}
- kill -9 {Process number}
[결과파일]
- develop/dev/test-backend.md
+41
View File
@@ -0,0 +1,41 @@
# 서비스 기획 프롬프트
## 서비스 기획
command: "/think-planning"
prompt:
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
```
아래 가이드를 참고하여 서비스 기획을 수행합니다.
https://github.com/cna-bootcamp/aiguide/blob/main/AI%ED%99%9C%EC%9A%A9%20%EC%84%9C%EB%B9%84%EC%8A%A4%20%EA%B8%B0%ED%9A%8D%20%EA%B0%80%EC%9D%B4%EB%93%9C.md
```
---
## 유저스토리 작성
command: "/think-userstory"
prompt:
```
@document
유저스토리를 작성하세요.
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
{안내메시지}
'[요구사항]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
[요구사항]
Case 1) 이벤트스토밍을 피그마로 수행한 경우는 피그마 채널ID를 제공
예) 피그마 채널ID 'abcde'에 접속하여 분석
Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을 정리한 파일 경로를 제공
예) 요구사항문서 'design/requirement.md'를 읽어 분석
프롬프트에 '[요구사항]'섹션이 있으면 아래와 같이 수행합니다.
1. 요구사항 분석
- 피그마 채널ID가 제공된 경우 figma MCP를 이용하여 해당 채널에 접속하여 분석
- 요구사항문서 경로가 제공된 경우 해당 문서를 읽어 요구사항을 분석
2. 유저스토리 작성
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
- 결과파일은 'design/userstory.md'에 생성
```
@@ -18,6 +18,10 @@ public enum ErrorCode {
COMMON_004("COMMON_004", "서버 내부 오류가 발생했습니다"), COMMON_004("COMMON_004", "서버 내부 오류가 발생했습니다"),
COMMON_005("COMMON_005", "지원하지 않는 작업입니다"), COMMON_005("COMMON_005", "지원하지 않는 작업입니다"),
// 일반 에러 상수 (Legacy 호환용)
NOT_FOUND("NOT_FOUND", "요청한 리소스를 찾을 수 없습니다"),
INVALID_INPUT_VALUE("INVALID_INPUT_VALUE", "유효하지 않은 입력값입니다"),
// 인증/인가 에러 (AUTH_XXX) // 인증/인가 에러 (AUTH_XXX)
AUTH_001("AUTH_001", "인증에 실패했습니다"), AUTH_001("AUTH_001", "인증에 실패했습니다"),
AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"), AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"),
@@ -12,6 +12,7 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* JWT 토큰 생성 및 검증 제공자 * JWT 토큰 생성 및 검증 제공자
@@ -49,17 +50,20 @@ public class JwtTokenProvider {
* Access Token 생성 * Access Token 생성
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @param storeId 매장 ID
* @param email 이메일 * @param email 이메일
* @param name 이름 * @param name 이름
* @param roles 역할 목록 * @param roles 역할 목록
* @return Access Token * @return Access Token
*/ */
public String createAccessToken(Long userId, String email, String name, List<String> roles) {
public String createAccessToken(Long userId, Long storeId, String email, String name, List<String> roles) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs); Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
return Jwts.builder() return Jwts.builder()
.subject(userId.toString()) .subject(userId.toString())
.claim("storeId", storeId != null ? storeId.toString() : null)
.claim("email", email) .claim("email", email)
.claim("name", name) .claim("name", name)
.claim("roles", roles) .claim("roles", roles)
@@ -76,7 +80,7 @@ public class JwtTokenProvider {
* @param userId 사용자 ID * @param userId 사용자 ID
* @return Refresh Token * @return Refresh Token
*/ */
public String createRefreshToken(Long userId) { public String createRefreshToken(UUID userId) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs); Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
@@ -95,9 +99,9 @@ public class JwtTokenProvider {
* @param token JWT 토큰 * @param token JWT 토큰
* @return 사용자 ID * @return 사용자 ID
*/ */
public Long getUserIdFromToken(String token) { public UUID getUserIdFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
return Long.parseLong(claims.getSubject()); return UUID.fromString(claims.getSubject());
} }
/** /**
@@ -110,12 +114,14 @@ public class JwtTokenProvider {
Claims claims = parseToken(token); Claims claims = parseToken(token);
Long userId = Long.parseLong(claims.getSubject()); Long userId = Long.parseLong(claims.getSubject());
String storeIdStr = claims.get("storeId", String.class);
Long storeId = storeIdStr != null ? Long.parseLong(storeIdStr) : null;
String email = claims.get("email", String.class); String email = claims.get("email", String.class);
String name = claims.get("name", String.class); String name = claims.get("name", String.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<String> roles = claims.get("roles", List.class); List<String> roles = claims.get("roles", List.class);
return new UserPrincipal(userId, email, name, roles); return new UserPrincipal(userId, storeId, email, name, roles);
} }
/** /**
@@ -1,6 +1,7 @@
package com.kt.event.common.security; package com.kt.event.common.security;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
@@ -8,6 +9,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -15,13 +17,24 @@ import java.util.stream.Collectors;
* JWT 토큰에서 추출한 사용자 정보를 담는 객체 * JWT 토큰에서 추출한 사용자 정보를 담는 객체
*/ */
@Getter @Getter
@Builder
@AllArgsConstructor @AllArgsConstructor
public class UserPrincipal implements UserDetails { public class UserPrincipal implements UserDetails {
/** /**
* 사용자 ID * 사용자 ID
*/ */
private final Long userId; private final UUID userId;
/**
* 매장 ID
*/
private final UUID storeId;
/**
* 매장 ID
*/
private final Long storeId;
/** /**
* 사용자 이메일 * 사용자 이메일
+25
View File
@@ -0,0 +1,25 @@
# 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"]

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