Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 060921e756 | |||
| b198c46d06 | |||
| 003b3843cc | |||
| 8323b795df | |||
| ce3e01008a | |||
| ea807cf33e | |||
| 394c7a0029 | |||
| 9b247ca058 | |||
| 25ff21f684 | |||
| e70f121db5 | |||
| 180e5978a0 | |||
| 6465719b2c | |||
| 42f0665f5e | |||
| 6728b98878 | |||
| 6a31e5204b | |||
| 918e71cc35 | |||
| 4197c72af5 | |||
| 97a3c41fff | |||
| 45f370a944 | |||
| a34037cdd1 | |||
| 4d180c2a9f | |||
| 884c964af6 | |||
| 704a4ae4d1 | |||
| 7fa1f8cc89 | |||
| 0ed0309e66 | |||
| f3901c8ef8 | |||
| 7735c8472b | |||
| 7b3ca40e22 | |||
| 4c8165bd20 | |||
| 31fb1c541b | |||
| 21b8fe5efb | |||
| b0b0ba3263 | |||
| 55c7b838dd | |||
| 860293b2b9 | |||
| c63cf950eb | |||
| fb60c6f8a6 | |||
| db761cd7be | |||
| 7b76e573ed | |||
| ab99a26211 | |||
| 9b10f915e3 | |||
| 43e23eb7aa | |||
| 379ab0f1f9 | |||
| f3be6917b5 | |||
| 5476fe9388 | |||
| 887b46ab46 | |||
| 46fc1663a5 | |||
| 25b1ec8b81 |
@@ -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
|
||||
'프론트엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-api"
|
||||
---
|
||||
@architecture
|
||||
API를 설계해 주세요:
|
||||
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
|
||||
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-class"
|
||||
---
|
||||
@architecture
|
||||
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
|
||||
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
@@ -9,4 +12,4 @@
|
||||
- User: Layered
|
||||
- Trip: Clean
|
||||
- Location: Layered
|
||||
- AI: Layered
|
||||
- AI: Layered
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-data"
|
||||
---
|
||||
@architecture
|
||||
데이터 설계를 해주세요:
|
||||
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
|
||||
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
|
||||
@@ -1,5 +1,8 @@
|
||||
---
|
||||
command: "/design-fix-prototype"
|
||||
---
|
||||
@fix as @front
|
||||
'[오류내용]'섹션에 제공된 오류를 해결해 주세요.
|
||||
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
|
||||
{안내메시지}
|
||||
'[오류내용]'섹션 하위에 오류 내용을 제공
|
||||
'[오류내용]'섹션 하위에 오류 내용을 제공
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-front"
|
||||
---
|
||||
@plan as @front
|
||||
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
|
||||
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
@@ -13,4 +16,4 @@
|
||||
- ai service: http://localhost:8084/v3/api-docs
|
||||
[요구사항]
|
||||
- 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시
|
||||
- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
|
||||
- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
command: "/design-high-level"
|
||||
---
|
||||
@architecture
|
||||
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
|
||||
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
||||
{안내메시지}
|
||||
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
|
||||
- CLOUD: Azure
|
||||
- CLOUD: Azure
|
||||
@@ -1,5 +1,8 @@
|
||||
---
|
||||
command: "/design-improve-prototype"
|
||||
---
|
||||
@improve as @front
|
||||
'[개선내용]'섹션에 있는 내용을 개선해 주세요.
|
||||
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
|
||||
{안내메시지}
|
||||
'[개선내용]'섹션 하위에 개선할 내용을 제공
|
||||
'[개선내용]'섹션 하위에 개선할 내용을 제공
|
||||
@@ -1,2 +1,5 @@
|
||||
---
|
||||
command: "/design-improve-userstory"
|
||||
---
|
||||
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
|
||||
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
|
||||
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-logical"
|
||||
---
|
||||
@architecture
|
||||
논리 아키텍처를 설계해 주세요:
|
||||
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
|
||||
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-pattern"
|
||||
---
|
||||
@design-pattern
|
||||
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
|
||||
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
|
||||
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
command: "/design-physical"
|
||||
---
|
||||
@architecture
|
||||
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
|
||||
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
||||
{안내메시지}
|
||||
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
|
||||
- CLOUD: Azure
|
||||
- CLOUD: Azure
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-prototype"
|
||||
---
|
||||
@prototype
|
||||
프로토타입을 작성해 주세요:
|
||||
- '프로토타입작성가이드'를 준용하여 작성
|
||||
- '프로토타입작성가이드'를 준용하여 작성
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-seq-inner"
|
||||
---
|
||||
@architecture
|
||||
내부 시퀀스 설계를 해 주세요:
|
||||
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
|
||||
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-seq-outer"
|
||||
---
|
||||
@architecture
|
||||
외부 시퀀스 설계를 해 주세요:
|
||||
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
|
||||
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
|
||||
@@ -1,2 +1,5 @@
|
||||
---
|
||||
command: "/design-test-prototype"
|
||||
---
|
||||
@test-front
|
||||
프로토타입을 테스트 해 주세요.
|
||||
프로토타입을 테스트 해 주세요.
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-uiux"
|
||||
---
|
||||
@uiux
|
||||
UI/UX 설계를 해주세요:
|
||||
- 'UI/UX설계가이드'를 준용하여 작성
|
||||
- 'UI/UX설계가이드'를 준용하여 작성
|
||||
@@ -1,2 +1,5 @@
|
||||
---
|
||||
command: "/design-update-uiux"
|
||||
---
|
||||
@document @front
|
||||
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
|
||||
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
|
||||
@@ -1,5 +1,5 @@
|
||||
@test-backend
|
||||
'서비스실행파일작성가이드'에 따라 테스트를 해 주세요.
|
||||
'서비스실행프로파일작성가이드'에 따라 테스트를 해 주세요.
|
||||
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
|
||||
{안내메시지}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/think-help"
|
||||
---
|
||||
기획 작업 순서
|
||||
|
||||
1단계: 서비스 기획
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/think-planning"
|
||||
---
|
||||
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
|
||||
```
|
||||
아래 가이드를 참고하여 서비스 기획을 수행합니다.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
command: "/think-userstory"
|
||||
---
|
||||
```
|
||||
@document
|
||||
유저스토리를 작성하세요.
|
||||
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
@@ -16,3 +20,5 @@ Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을
|
||||
2. 유저스토리 작성
|
||||
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
|
||||
- 결과파일은 'design/userstory.md'에 생성
|
||||
|
||||
```
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)",
|
||||
"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(find:*)",
|
||||
"Bash(netstat:*)",
|
||||
|
||||
+20
-2
@@ -23,6 +23,14 @@ build/
|
||||
.gradle/
|
||||
logs/
|
||||
|
||||
# Gradle
|
||||
.gradle/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
@@ -33,5 +41,15 @@ tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Docker (로컬 개발용)
|
||||
backing-service/docker-compose.yml
|
||||
# Kubernetes Secrets (민감한 정보 포함)
|
||||
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>
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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");
|
||||
}
|
||||
}
|
||||
+71
@@ -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));
|
||||
}
|
||||
}
|
||||
+73
@@ -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));
|
||||
}
|
||||
}
|
||||
+54
@@ -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));
|
||||
}
|
||||
}
|
||||
+82
@@ -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));
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+145
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+241
@@ -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();
|
||||
}
|
||||
}
|
||||
+142
@@ -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();
|
||||
}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+206
@@ -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} # 배치 활성화 여부
|
||||
@@ -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
|
||||
@@ -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 아이콘 버튼과 화면 타이틀 표시
|
||||
- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,6 +1,7 @@
|
||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||
Dload Upload Total Spent Left Speed
|
||||
|
||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||
Dload Upload Total Spent Left Speed
|
||||
|
||||
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드
|
||||
|
||||
[요청사항]
|
||||
- <수행원칙>을 준용하여 수행
|
||||
@@ -150,7 +151,8 @@
|
||||
<option name="IS_ENABLED" 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_IGNORE_MISSING_FILES" value="false
|
||||
100 9115 100 9115 0 0 28105 0 --:--:-- --:--:-- --:--:-- 28219" />
|
||||
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
|
||||
<ENTRIES>
|
||||
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
|
||||
@@ -175,4 +177,4 @@
|
||||
- MQ 유형 및 연결 정보
|
||||
- 연결에 필요한 호스트, 포트, 인증 정보
|
||||
- LoadBalancer Service External IP (해당하는 경우)
|
||||
- 연결에 필요한 호스트, 포트, 인증 정보
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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_005("COMMON_005", "지원하지 않는 작업입니다"),
|
||||
|
||||
// 일반 에러 상수 (Legacy 호환용)
|
||||
NOT_FOUND("NOT_FOUND", "요청한 리소스를 찾을 수 없습니다"),
|
||||
INVALID_INPUT_VALUE("INVALID_INPUT_VALUE", "유효하지 않은 입력값입니다"),
|
||||
|
||||
// 인증/인가 에러 (AUTH_XXX)
|
||||
AUTH_001("AUTH_001", "인증에 실패했습니다"),
|
||||
AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"),
|
||||
|
||||
@@ -12,6 +12,7 @@ import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* JWT 토큰 생성 및 검증 제공자
|
||||
@@ -49,17 +50,20 @@ public class JwtTokenProvider {
|
||||
* Access Token 생성
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param storeId 매장 ID
|
||||
* @param email 이메일
|
||||
* @param name 이름
|
||||
* @param roles 역할 목록
|
||||
* @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 expiryDate = new Date(now.getTime() + accessTokenValidityMs);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("storeId", storeId != null ? storeId.toString() : null)
|
||||
.claim("email", email)
|
||||
.claim("name", name)
|
||||
.claim("roles", roles)
|
||||
@@ -76,7 +80,7 @@ public class JwtTokenProvider {
|
||||
* @param userId 사용자 ID
|
||||
* @return Refresh Token
|
||||
*/
|
||||
public String createRefreshToken(Long userId) {
|
||||
public String createRefreshToken(UUID userId) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
|
||||
|
||||
@@ -95,9 +99,9 @@ public class JwtTokenProvider {
|
||||
* @param token JWT 토큰
|
||||
* @return 사용자 ID
|
||||
*/
|
||||
public Long getUserIdFromToken(String token) {
|
||||
public UUID getUserIdFromToken(String 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);
|
||||
|
||||
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 name = claims.get("name", String.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
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;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
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.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -15,13 +17,24 @@ import java.util.stream.Collectors;
|
||||
* JWT 토큰에서 추출한 사용자 정보를 담는 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
public class UserPrincipal implements UserDetails {
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private final Long userId;
|
||||
private final UUID userId;
|
||||
|
||||
/**
|
||||
* 매장 ID
|
||||
*/
|
||||
private final UUID storeId;
|
||||
|
||||
/**
|
||||
* 매장 ID
|
||||
*/
|
||||
private final Long storeId;
|
||||
|
||||
/**
|
||||
* 사용자 이메일
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user