Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea026d7fa3 | |||
| 019ac96daa | |||
| bc57b27852 | |||
| b9514257b0 | |||
| 977a287a91 | |||
| 3f0eccb69a | |||
| f30213d1a2 | |||
| 284278180c | |||
| 9438e0d285 | |||
| 02a4e966e8 | |||
| d36dc5be27 | |||
| 9305dfdb7f | |||
| d511140ecb | |||
| 4421f4447f | |||
| 5a82fe3610 | |||
| 02fd82e0af | |||
| 0c718c67f6 | |||
| ea4aa5d072 | |||
| e807bdbd59 | |||
| cf2689390d | |||
| 89a86c1301 | |||
| c768fff11e | |||
| f07002ac33 | |||
| 2ca453f89e | |||
| e2179daaf7 | |||
| de32a70f29 | |||
| 435ba1a86c | |||
| 16a91c85bf | |||
| 429f737066 | |||
| d56ff7684b | |||
| c152faff54 | |||
| ee664a6134 | |||
| 50043add5d | |||
| d89ee4edf7 | |||
| e0fc4286c7 | |||
| 060921e756 | |||
| b198c46d06 | |||
| 003b3843cc | |||
| 55e546e0b3 | |||
| e70f121db5 | |||
| 6465719b2c |
@@ -0,0 +1,17 @@
|
||||
---
|
||||
command: "/deploy-actions-cicd-guide-back"
|
||||
description: "백엔드 GitHub Actions CI/CD 파이프라인 가이드 작성"
|
||||
---
|
||||
|
||||
@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,18 @@
|
||||
---
|
||||
command: "/deploy-actions-cicd-guide-front"
|
||||
description: "프론트엔드 GitHub Actions CI/CD 파이프라인 가이드 작성"
|
||||
---
|
||||
|
||||
@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,7 @@
|
||||
---
|
||||
command: "/deploy-build-image-back"
|
||||
description: "백엔드 컨테이너 이미지 작성"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
command: "/deploy-build-image-front"
|
||||
description: "프론트엔드 컨테이너 이미지 작성"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'프론트엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
command: "/deploy-help"
|
||||
description: "배포 작업 순서 및 명령어 안내"
|
||||
---
|
||||
|
||||
# 배포 작업 순서
|
||||
|
||||
## 컨테이너 이미지 작성
|
||||
### 백엔드
|
||||
/deploy-build-image-back
|
||||
- 백엔드 서비스들의 컨테이너 이미지를 작성합니다
|
||||
|
||||
### 프론트엔드
|
||||
/deploy-build-image-front
|
||||
- 프론트엔드 서비스의 컨테이너 이미지를 작성합니다
|
||||
|
||||
## 컨테이너 실행 가이드 작성
|
||||
### 백엔드
|
||||
/deploy-run-container-guide-back
|
||||
- 백엔드 컨테이너 실행 가이드를 작성합니다
|
||||
- [실행정보] 섹션에 ACR명, VM 접속 정보 제공 필요
|
||||
|
||||
### 프론트엔드
|
||||
/deploy-run-container-guide-front
|
||||
- 프론트엔드 컨테이너 실행 가이드를 작성합니다
|
||||
- [실행정보] 섹션에 시스템명, ACR명, VM 접속 정보 제공 필요
|
||||
|
||||
## Kubernetes 배포 가이드 작성
|
||||
### 백엔드
|
||||
/deploy-k8s-guide-back
|
||||
- 백엔드 서비스 Kubernetes 배포 가이드를 작성합니다
|
||||
- [실행정보] 섹션에 ACR명, k8s명, 네임스페이스, 리소스 정보 제공 필요
|
||||
|
||||
### 프론트엔드
|
||||
/deploy-k8s-guide-front
|
||||
- 프론트엔드 서비스 Kubernetes 배포 가이드를 작성합니다
|
||||
- [실행정보] 섹션에 시스템명, ACR명, k8s명, 네임스페이스, Gateway Host 정보 제공 필요
|
||||
|
||||
## CI/CD 파이프라인 작성
|
||||
### Jenkins CI/CD
|
||||
#### 백엔드
|
||||
/deploy-jenkins-cicd-guide-back
|
||||
- Jenkins를 이용한 백엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||
- [실행정보] 섹션에 ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||
|
||||
#### 프론트엔드
|
||||
/deploy-jenkins-cicd-guide-front
|
||||
- Jenkins를 이용한 프론트엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||
- [실행정보] 섹션에 SYSTEM_NAME, ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||
|
||||
### GitHub Actions CI/CD
|
||||
#### 백엔드
|
||||
/deploy-actions-cicd-guide-back
|
||||
- GitHub Actions를 이용한 백엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||
- [실행정보] 섹션에 ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||
|
||||
#### 프론트엔드
|
||||
/deploy-actions-cicd-guide-front
|
||||
- GitHub Actions를 이용한 프론트엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||
- [실행정보] 섹션에 SYSTEM_NAME, ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||
|
||||
---
|
||||
|
||||
**참고**: 각 명령어 실행 시 [실행정보] 섹션에 필요한 정보를 함께 제공해야 합니다.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
command: "/deploy-jenkins-cicd-guide-back"
|
||||
description: "백엔드 Jenkins CI/CD 파이프라인 가이드 작성"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- ACR_NAME: acrdigitalgarage01
|
||||
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||
- AKS_CLUSTER: aks-digitalgarage-01
|
||||
- NAMESPACE: phonebill-dg0500
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
command: "/deploy-jenkins-cicd-guide-front"
|
||||
description: "프론트엔드 Jenkins CI/CD 파이프라인 가이드 작성"
|
||||
---
|
||||
|
||||
@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,19 @@
|
||||
---
|
||||
command: "/deploy-k8s-guide-back"
|
||||
description: "백엔드 Kubernetes 배포 가이드 작성"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드배포가이드'에 따라 백엔드 서비스 배포 방법을 작성해 주세요.
|
||||
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- ACR명: acrdigitalgarage01
|
||||
- k8s명: aks-digitalgarage-01
|
||||
- 네임스페이스: tripgen
|
||||
- 파드수: 2
|
||||
- 리소스(CPU): 256m/1024m
|
||||
- 리소스(메모리): 256Mi/1024Mi
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
command: "/deploy-k8s-guide-front"
|
||||
description: "프론트엔드 Kubernetes 배포 가이드 작성"
|
||||
---
|
||||
|
||||
@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,18 @@
|
||||
---
|
||||
command: "/deploy-run-container-guide-back"
|
||||
description: "백엔드 컨테이너 실행방법 가이드 작성"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
||||
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- ACR명: acrdigitalgarage01
|
||||
- VM
|
||||
- KEY파일: ~/home/bastion-dg0500
|
||||
- USERID: azureuser
|
||||
- IP: 4.230.5.6
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
command: "/deploy-run-container-guide-front"
|
||||
description: "프론트엔드 컨테이너 실행방법 가이드 작성"
|
||||
---
|
||||
|
||||
@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,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'에 생성
|
||||
|
||||
```
|
||||
|
||||
@@ -61,3 +61,5 @@ k8s/**/*-local.yaml
|
||||
|
||||
# Gradle (로컬 환경 설정)
|
||||
gradle.properties
|
||||
*.hprof
|
||||
test-data.json
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="EventServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" folderName="Event Service">
|
||||
<option name="ACTIVE_PROFILES" />
|
||||
<option name="ENABLE_LAUNCH_OPTIMIZATION" value="true" />
|
||||
<envs>
|
||||
<env name="DB_HOST" value="20.249.177.232" />
|
||||
<env name="DB_PORT" value="5432" />
|
||||
<env name="DB_NAME" value="eventdb" />
|
||||
<env name="DB_USERNAME" value="eventuser" />
|
||||
<env name="DB_PASSWORD" value="Hi5Jessica!" />
|
||||
<env name="REDIS_HOST" value="localhost" />
|
||||
<env name="REDIS_PORT" value="6379" />
|
||||
<env name="REDIS_PASSWORD" value="" />
|
||||
<env name="KAFKA_BOOTSTRAP_SERVERS" value="localhost:9092" />
|
||||
<env name="SERVER_PORT" value="8081" />
|
||||
<env name="DDL_AUTO" value="update" />
|
||||
<env name="LOG_LEVEL" value="DEBUG" />
|
||||
<env name="SQL_LOG_LEVEL" value="DEBUG" />
|
||||
<env name="DISTRIBUTION_SERVICE_URL" value="http://localhost:8084" />
|
||||
</envs>
|
||||
<module name="kt-event-marketing.event-service.main" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.event.eventservice.EventServiceApplication" />
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -3,7 +3,7 @@
|
||||
<ExternalSystemSettings>
|
||||
<option name="env">
|
||||
<map>
|
||||
<!-- Database Settings -->
|
||||
<!-- Database Configuration -->
|
||||
<entry key="DB_KIND" value="postgresql" />
|
||||
<entry key="DB_HOST" value="4.230.49.9" />
|
||||
<entry key="DB_PORT" value="5432" />
|
||||
@@ -11,47 +11,42 @@
|
||||
<entry key="DB_USERNAME" value="eventuser" />
|
||||
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||
|
||||
<!-- Redis Settings -->
|
||||
<!-- JPA Configuration -->
|
||||
<entry key="DDL_AUTO" value="create" />
|
||||
<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 Settings -->
|
||||
<!-- Kafka Configuration (원격 서버) -->
|
||||
<entry key="KAFKA_ENABLED" value="true" />
|
||||
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service" />
|
||||
<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 Settings (MVP Only) -->
|
||||
<!-- ⚠️ 실제 운영 환경에서는 false로 설정 (다른 서비스들이 이벤트 발행) -->
|
||||
<!-- Sample Data Configuration (MVP Only) -->
|
||||
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
|
||||
<entry key="SAMPLE_DATA_ENABLED" value="true" />
|
||||
|
||||
<!-- JPA Settings -->
|
||||
<entry key="SHOW_SQL" value="true" />
|
||||
<entry key="DDL_AUTO" value="update" />
|
||||
|
||||
<!-- Server Settings -->
|
||||
<!-- Server Configuration -->
|
||||
<entry key="SERVER_PORT" value="8086" />
|
||||
|
||||
<!-- JWT Settings -->
|
||||
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only-analytics-service-2024" />
|
||||
<!-- 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 Settings -->
|
||||
<!-- CORS Configuration -->
|
||||
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
|
||||
|
||||
<!-- Logging Settings -->
|
||||
<!-- 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" />
|
||||
<entry key="LOG_FILE" value="logs/analytics-service.log" />
|
||||
|
||||
<!-- Batch Settings -->
|
||||
<entry key="BATCH_ENABLED" value="true" />
|
||||
<entry key="BATCH_REFRESH_INTERVAL" value="300000" />
|
||||
<entry key="BATCH_INITIAL_DELAY" value="30000" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="executionName" />
|
||||
|
||||
@@ -5,11 +5,11 @@ spring:
|
||||
# Redis Configuration
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:redis-external} # Production: redis-external, Local: 20.214.210.71
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0} # AI Service uses database 3
|
||||
timeout: ${REDIS_TIMEOUT:3000}
|
||||
host: 20.214.210.71
|
||||
port: 6379
|
||||
password: Hi5Jessica!
|
||||
database: 3
|
||||
timeout: 3000
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
@@ -19,7 +19,7 @@ spring:
|
||||
|
||||
# Kafka Consumer Configuration
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
|
||||
bootstrap-servers: 4.230.50.63:9092
|
||||
consumer:
|
||||
group-id: ai-service-consumers
|
||||
auto-offset-reset: earliest
|
||||
@@ -28,14 +28,14 @@ spring:
|
||||
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||
properties:
|
||||
spring.json.trusted.packages: "*"
|
||||
max.poll.records: ${KAFKA_MAX_POLL_RECORDS:10}
|
||||
session.timeout.ms: ${KAFKA_SESSION_TIMEOUT:30000}
|
||||
max.poll.records: 10
|
||||
session.timeout.ms: 30000
|
||||
listener:
|
||||
ack-mode: manual
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
port: 8083
|
||||
servlet:
|
||||
context-path: /
|
||||
encoding:
|
||||
@@ -45,17 +45,17 @@ server:
|
||||
|
||||
# JWT Configuration
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
|
||||
secret: kt-event-marketing-secret-key-for-development-only-please-change-in-production
|
||||
access-token-validity: 604800000
|
||||
refresh-token-validity: 86400
|
||||
|
||||
# CORS Configuration
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
|
||||
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
|
||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||
max-age: ${CORS_MAX_AGE:3600}
|
||||
allowed-origins: http://localhost:*
|
||||
allowed-methods: GET,POST,PUT,DELETE,OPTIONS,PATCH
|
||||
allowed-headers: "*"
|
||||
allow-credentials: true
|
||||
max-age: 3600
|
||||
|
||||
# Actuator Configuration
|
||||
management:
|
||||
@@ -100,7 +100,7 @@ logging:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: ${LOG_FILE:logs/ai-service.log}
|
||||
name: logs/ai-service.log
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-file-size: 10MB
|
||||
@@ -110,26 +110,20 @@ logging:
|
||||
# Kafka Topics Configuration
|
||||
kafka:
|
||||
topics:
|
||||
ai-job: ${KAFKA_TOPIC_AI_JOB:ai-event-generation-job}
|
||||
ai-job-dlq: ${KAFKA_TOPIC_AI_JOB_DLQ:ai-event-generation-job-dlq}
|
||||
ai-job: ai-event-generation-job
|
||||
ai-job-dlq: ai-event-generation-job-dlq
|
||||
|
||||
# AI External API Configuration
|
||||
# AI API Configuration (실제 API 사용)
|
||||
ai:
|
||||
provider: CLAUDE
|
||||
claude:
|
||||
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
anthropic-version: ${CLAUDE_ANTHROPIC_VERSION:2023-06-01}
|
||||
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
|
||||
max-tokens: ${CLAUDE_MAX_TOKENS:4096}
|
||||
temperature: ${CLAUDE_TEMPERATURE:0.7}
|
||||
timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes
|
||||
gpt4:
|
||||
api-url: ${GPT4_API_URL:https://api.openai.com/v1/chat/completions}
|
||||
api-key: ${GPT4_API_KEY:}
|
||||
model: ${GPT4_MODEL:gpt-4-turbo-preview}
|
||||
max-tokens: ${GPT4_MAX_TOKENS:4096}
|
||||
timeout: ${GPT4_TIMEOUT:300000} # 5 minutes
|
||||
provider: ${AI_PROVIDER:CLAUDE} # CLAUDE or GPT4
|
||||
api-url: https://api.anthropic.com/v1/messages
|
||||
api-key: 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
|
||||
anthropic-version: 2023-06-01
|
||||
model: claude-sonnet-4-5-20250929
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
timeout: 300000
|
||||
|
||||
# Circuit Breaker Configuration
|
||||
resilience4j:
|
||||
@@ -168,7 +162,7 @@ resilience4j:
|
||||
# Redis Cache TTL Configuration (seconds)
|
||||
cache:
|
||||
ttl:
|
||||
recommendation: ${CACHE_TTL_RECOMMENDATION:86400} # 24 hours
|
||||
job-status: ${CACHE_TTL_JOB_STATUS:86400} # 24 hours
|
||||
trend: ${CACHE_TTL_TREND:3600} # 1 hour
|
||||
fallback: ${CACHE_TTL_FALLBACK:604800} # 7 days
|
||||
recommendation: 86400 # 24 hours
|
||||
job-status: 86400 # 24 hours
|
||||
trend: 3600 # 1 hour
|
||||
fallback: 604800 # 7 days
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||
|
||||
<!-- JPA Configuration -->
|
||||
<entry key="DDL_AUTO" value="update" />
|
||||
<entry key="DDL_AUTO" value="create" />
|
||||
<entry key="SHOW_SQL" value="true" />
|
||||
|
||||
<!-- Redis Configuration -->
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# 백엔드-프론트엔드 API 연동 검증 및 수정 결과
|
||||
|
||||
**작업일시**: 2025-10-28
|
||||
**브랜치**: feature/analytics
|
||||
**작업 범위**: Analytics Service 백엔드 DTO 및 Service 수정
|
||||
|
||||
---
|
||||
|
||||
## 📝 수정 요약
|
||||
|
||||
### 1️⃣ 필드명 통일 (프론트엔드 호환)
|
||||
|
||||
**목적**: 프론트엔드 Mock 데이터 필드명과 백엔드 Response DTO 필드명 일치
|
||||
|
||||
| 수정 전 (백엔드) | 수정 후 (백엔드) | 프론트엔드 |
|
||||
|-----------------|----------------|-----------|
|
||||
| `summary.totalParticipants` | `summary.participants` | `summary.participants` ✅ |
|
||||
| `channelPerformance[].channelName` | `channelPerformance[].channel` | `channelPerformance[].channel` ✅ |
|
||||
| `roi.totalInvestment` | `roi.totalCost` | `roiDetail.totalCost` ✅ |
|
||||
|
||||
### 2️⃣ 증감 데이터 추가
|
||||
|
||||
**목적**: 프론트엔드에서 요구하는 증감 표시 및 목표값 제공
|
||||
|
||||
| 필드 | 타입 | 설명 | 현재 값 |
|
||||
|-----|------|------|---------|
|
||||
| `summary.participantsDelta` | `Integer` | 참여자 증감 (이전 기간 대비) | `0` (TODO: 계산 로직 필요) |
|
||||
| `summary.targetRoi` | `Double` | 목표 ROI (%) | EventStats에서 가져옴 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 수정 파일 목록
|
||||
|
||||
### DTO (Response 구조 변경)
|
||||
|
||||
1. **AnalyticsSummary.java**
|
||||
- ✅ `totalParticipants` → `participants`
|
||||
- ✅ `participantsDelta` 필드 추가
|
||||
- ✅ `targetRoi` 필드 추가
|
||||
|
||||
2. **ChannelSummary.java**
|
||||
- ✅ `channelName` → `channel`
|
||||
|
||||
3. **RoiSummary.java**
|
||||
- ✅ `totalInvestment` → `totalCost`
|
||||
|
||||
### Entity (데이터베이스 스키마 변경)
|
||||
|
||||
4. **EventStats.java**
|
||||
- ✅ `targetRoi` 필드 추가 (`BigDecimal`, default: 0)
|
||||
|
||||
### Service (비즈니스 로직 수정)
|
||||
|
||||
5. **AnalyticsService.java**
|
||||
- ✅ `.participants()` 사용
|
||||
- ✅ `.participantsDelta(0)` 추가 (TODO 마킹)
|
||||
- ✅ `.targetRoi()` 추가
|
||||
- ✅ `.channel()` 사용
|
||||
|
||||
6. **ROICalculator.java**
|
||||
- ✅ `.totalCost()` 사용
|
||||
|
||||
7. **UserAnalyticsService.java**
|
||||
- ✅ `.participants()` 사용
|
||||
- ✅ `.participantsDelta(0)` 추가
|
||||
- ✅ `.channel()` 사용
|
||||
- ✅ `.totalCost()` 사용
|
||||
|
||||
---
|
||||
|
||||
## ✅ 검증 결과
|
||||
|
||||
### 컴파일 성공
|
||||
\`\`\`bash
|
||||
$ ./gradlew analytics-service:compileJava
|
||||
|
||||
BUILD SUCCESSFUL in 8s
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 📊 데이터베이스 스키마 변경
|
||||
|
||||
### EventStats 테이블
|
||||
|
||||
\`\`\`sql
|
||||
ALTER TABLE event_stats
|
||||
ADD COLUMN target_roi DECIMAL(10,2) DEFAULT 0.00;
|
||||
\`\`\`
|
||||
|
||||
**⚠️ 주의사항**
|
||||
- Spring Boot JPA `ddl-auto` 설정에 따라 자동 적용됨
|
||||
|
||||
---
|
||||
|
||||
## 📌 다음 단계
|
||||
|
||||
### 우선순위 HIGH
|
||||
|
||||
1. **프론트엔드 API 연동 테스트**
|
||||
2. **participantsDelta 계산 로직 구현**
|
||||
3. **targetRoi 데이터 입력** (Event Service 연동)
|
||||
|
||||
### 우선순위 MEDIUM
|
||||
|
||||
4. 시간대별 분석 구현
|
||||
5. 참여자 프로필 구현
|
||||
6. ROI 세분화 구현
|
||||
@@ -286,6 +286,11 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
publishEvent(PARTICIPANT_REGISTERED_TOPIC, event);
|
||||
totalPublished++;
|
||||
|
||||
// 동시성 충돌 방지: 10개마다 100ms 대기
|
||||
if ((j + 1) % 10 == 0) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.kt.event.analytics.controller;
|
||||
|
||||
import com.kt.event.analytics.dto.response.UserAnalyticsDashboardResponse;
|
||||
import com.kt.event.analytics.service.UserAnalyticsService;
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* User Analytics Dashboard Controller
|
||||
*
|
||||
* 사용자 전체 이벤트 통합 성과 대시보드 API
|
||||
*/
|
||||
@Tag(name = "User Analytics", description = "사용자 전체 이벤트 통합 성과 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserAnalyticsDashboardController {
|
||||
|
||||
private final UserAnalyticsService userAnalyticsService;
|
||||
|
||||
/**
|
||||
* 사용자 전체 성과 대시보드 조회
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param startDate 조회 시작 날짜
|
||||
* @param endDate 조회 종료 날짜
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 전체 통합 성과 대시보드
|
||||
*/
|
||||
@Operation(
|
||||
summary = "사용자 전체 성과 대시보드 조회",
|
||||
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다."
|
||||
)
|
||||
@GetMapping("/{userId}/analytics")
|
||||
public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics(
|
||||
@Parameter(description = "사용자 ID", required = true)
|
||||
@PathVariable String userId,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "캐시 갱신 여부")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean refresh
|
||||
) {
|
||||
log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
|
||||
|
||||
UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
|
||||
userId, startDate, endDate, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.kt.event.analytics.controller;
|
||||
|
||||
import com.kt.event.analytics.dto.response.UserChannelAnalyticsResponse;
|
||||
import com.kt.event.analytics.service.UserChannelAnalyticsService;
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* User Channel Analytics Controller
|
||||
*/
|
||||
@Tag(name = "User Channels", description = "사용자 전체 이벤트 채널별 성과 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserChannelAnalyticsController {
|
||||
|
||||
private final UserChannelAnalyticsService userChannelAnalyticsService;
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 채널별 성과 분석",
|
||||
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/channels")
|
||||
public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics(
|
||||
@Parameter(description = "사용자 ID", required = true)
|
||||
@PathVariable String userId,
|
||||
|
||||
@Parameter(description = "조회할 채널 목록 (쉼표로 구분)")
|
||||
@RequestParam(required = false)
|
||||
String channels,
|
||||
|
||||
@Parameter(description = "정렬 기준")
|
||||
@RequestParam(required = false, defaultValue = "participants")
|
||||
String sortBy,
|
||||
|
||||
@Parameter(description = "정렬 순서")
|
||||
@RequestParam(required = false, defaultValue = "desc")
|
||||
String order,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "캐시 갱신 여부")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean refresh
|
||||
) {
|
||||
log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy);
|
||||
|
||||
List<String> channelList = channels != null && !channels.isBlank()
|
||||
? Arrays.asList(channels.split(","))
|
||||
: null;
|
||||
|
||||
UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
|
||||
userId, channelList, sortBy, order, startDate, endDate, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.kt.event.analytics.controller;
|
||||
|
||||
import com.kt.event.analytics.dto.response.UserRoiAnalyticsResponse;
|
||||
import com.kt.event.analytics.service.UserRoiAnalyticsService;
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* User ROI Analytics Controller
|
||||
*/
|
||||
@Tag(name = "User ROI", description = "사용자 전체 이벤트 ROI 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserRoiAnalyticsController {
|
||||
|
||||
private final UserRoiAnalyticsService userRoiAnalyticsService;
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 ROI 상세 분석",
|
||||
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/roi")
|
||||
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics(
|
||||
@Parameter(description = "사용자 ID", required = true)
|
||||
@PathVariable String userId,
|
||||
|
||||
@Parameter(description = "예상 수익 포함 여부")
|
||||
@RequestParam(required = false, defaultValue = "true")
|
||||
Boolean includeProjection,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "캐시 갱신 여부")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean refresh
|
||||
) {
|
||||
log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
|
||||
|
||||
UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
|
||||
userId, includeProjection, startDate, endDate, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.kt.event.analytics.controller;
|
||||
|
||||
import com.kt.event.analytics.dto.response.UserTimelineAnalyticsResponse;
|
||||
import com.kt.event.analytics.service.UserTimelineAnalyticsService;
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* User Timeline Analytics Controller
|
||||
*/
|
||||
@Tag(name = "User Timeline", description = "사용자 전체 이벤트 시간대별 분석 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserTimelineAnalyticsController {
|
||||
|
||||
private final UserTimelineAnalyticsService userTimelineAnalyticsService;
|
||||
|
||||
@Operation(
|
||||
summary = "사용자 전체 시간대별 참여 추이",
|
||||
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
|
||||
)
|
||||
@GetMapping("/{userId}/analytics/timeline")
|
||||
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics(
|
||||
@Parameter(description = "사용자 ID", required = true)
|
||||
@PathVariable String userId,
|
||||
|
||||
@Parameter(description = "시간 간격 단위 (hourly, daily, weekly, monthly)")
|
||||
@RequestParam(required = false, defaultValue = "daily")
|
||||
String interval,
|
||||
|
||||
@Parameter(description = "조회 시작 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime startDate,
|
||||
|
||||
@Parameter(description = "조회 종료 날짜")
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||
LocalDateTime endDate,
|
||||
|
||||
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
|
||||
@RequestParam(required = false)
|
||||
String metrics,
|
||||
|
||||
@Parameter(description = "캐시 갱신 여부")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean refresh
|
||||
) {
|
||||
log.info("사용자 타임라인 분석 API 호출: userId={}, interval={}", userId, interval);
|
||||
|
||||
List<String> metricList = metrics != null && !metrics.isBlank()
|
||||
? Arrays.asList(metrics.split(","))
|
||||
: null;
|
||||
|
||||
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
|
||||
userId, interval, startDate, endDate, metricList, refresh
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -17,7 +17,12 @@ public class AnalyticsSummary {
|
||||
/**
|
||||
* 총 참여자 수
|
||||
*/
|
||||
private Integer totalParticipants;
|
||||
private Integer participants;
|
||||
|
||||
/**
|
||||
* 참여자 증감 (이전 기간 대비)
|
||||
*/
|
||||
private Integer participantsDelta;
|
||||
|
||||
/**
|
||||
* 총 조회수
|
||||
@@ -44,6 +49,11 @@ public class AnalyticsSummary {
|
||||
*/
|
||||
private Integer averageEngagementTime;
|
||||
|
||||
/**
|
||||
* 목표 ROI (%)
|
||||
*/
|
||||
private Double targetRoi;
|
||||
|
||||
/**
|
||||
* SNS 반응 통계
|
||||
*/
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ public class ChannelSummary {
|
||||
/**
|
||||
* 채널명
|
||||
*/
|
||||
private String channelName;
|
||||
private String channel;
|
||||
|
||||
/**
|
||||
* 조회수
|
||||
|
||||
@@ -19,7 +19,7 @@ public class RoiSummary {
|
||||
/**
|
||||
* 총 투자 비용 (원)
|
||||
*/
|
||||
private BigDecimal totalInvestment;
|
||||
private BigDecimal totalCost;
|
||||
|
||||
/**
|
||||
* 예상 매출 증대 (원)
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.kt.event.analytics.dto.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 사용자 전체 이벤트 통합 대시보드 응답
|
||||
*
|
||||
* 사용자 ID 기반으로 모든 이벤트의 성과를 통합하여 제공
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserAnalyticsDashboardResponse {
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 조회 기간 정보
|
||||
*/
|
||||
private PeriodInfo period;
|
||||
|
||||
/**
|
||||
* 전체 이벤트 수
|
||||
*/
|
||||
private Integer totalEvents;
|
||||
|
||||
/**
|
||||
* 활성 이벤트 수
|
||||
*/
|
||||
private Integer activeEvents;
|
||||
|
||||
/**
|
||||
* 전체 성과 요약 (모든 이벤트 통합)
|
||||
*/
|
||||
private AnalyticsSummary overallSummary;
|
||||
|
||||
/**
|
||||
* 채널별 성과 요약 (모든 이벤트 통합)
|
||||
*/
|
||||
private List<ChannelSummary> channelPerformance;
|
||||
|
||||
/**
|
||||
* 전체 ROI 요약
|
||||
*/
|
||||
private RoiSummary overallRoi;
|
||||
|
||||
/**
|
||||
* 이벤트별 성과 목록 (간략)
|
||||
*/
|
||||
private List<EventPerformanceSummary> eventPerformances;
|
||||
|
||||
/**
|
||||
* 마지막 업데이트 시간
|
||||
*/
|
||||
private LocalDateTime lastUpdatedAt;
|
||||
|
||||
/**
|
||||
* 데이터 출처 (real-time, cached, fallback)
|
||||
*/
|
||||
private String dataSource;
|
||||
|
||||
/**
|
||||
* 이벤트별 성과 요약
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class EventPerformanceSummary {
|
||||
private String eventId;
|
||||
private String eventTitle;
|
||||
private Integer participants;
|
||||
private Integer views;
|
||||
private Double roi;
|
||||
private String status;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.kt.event.analytics.dto.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 사용자 전체 이벤트의 채널별 성과 분석 응답
|
||||
*
|
||||
* 사용자 ID 기반으로 모든 이벤트의 채널 성과를 통합하여 제공
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserChannelAnalyticsResponse {
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 조회 기간 정보
|
||||
*/
|
||||
private PeriodInfo period;
|
||||
|
||||
/**
|
||||
* 전체 이벤트 수
|
||||
*/
|
||||
private Integer totalEvents;
|
||||
|
||||
/**
|
||||
* 채널별 통합 성과 목록
|
||||
*/
|
||||
private List<ChannelAnalytics> channels;
|
||||
|
||||
/**
|
||||
* 채널 간 비교 분석
|
||||
*/
|
||||
private ChannelComparison comparison;
|
||||
|
||||
/**
|
||||
* 마지막 업데이트 시간
|
||||
*/
|
||||
private LocalDateTime lastUpdatedAt;
|
||||
|
||||
/**
|
||||
* 데이터 출처
|
||||
*/
|
||||
private String dataSource;
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.kt.event.analytics.dto.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 사용자 전체 이벤트의 ROI 분석 응답
|
||||
*
|
||||
* 사용자 ID 기반으로 모든 이벤트의 ROI를 통합하여 제공
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserRoiAnalyticsResponse {
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 조회 기간 정보
|
||||
*/
|
||||
private PeriodInfo period;
|
||||
|
||||
/**
|
||||
* 전체 이벤트 수
|
||||
*/
|
||||
private Integer totalEvents;
|
||||
|
||||
/**
|
||||
* 전체 투자 정보 (모든 이벤트 합계)
|
||||
*/
|
||||
private InvestmentDetails overallInvestment;
|
||||
|
||||
/**
|
||||
* 전체 수익 정보 (모든 이벤트 합계)
|
||||
*/
|
||||
private RevenueDetails overallRevenue;
|
||||
|
||||
/**
|
||||
* 전체 ROI 계산 결과
|
||||
*/
|
||||
private RoiCalculation overallRoi;
|
||||
|
||||
/**
|
||||
* 비용 효율성 분석
|
||||
*/
|
||||
private CostEfficiency costEfficiency;
|
||||
|
||||
/**
|
||||
* 수익 예측 (포함 여부에 따라 nullable)
|
||||
*/
|
||||
private RevenueProjection projection;
|
||||
|
||||
/**
|
||||
* 이벤트별 ROI 목록
|
||||
*/
|
||||
private List<EventRoiSummary> eventRois;
|
||||
|
||||
/**
|
||||
* 마지막 업데이트 시간
|
||||
*/
|
||||
private LocalDateTime lastUpdatedAt;
|
||||
|
||||
/**
|
||||
* 데이터 출처
|
||||
*/
|
||||
private String dataSource;
|
||||
|
||||
/**
|
||||
* 이벤트별 ROI 요약
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class EventRoiSummary {
|
||||
private String eventId;
|
||||
private String eventTitle;
|
||||
private Double totalInvestment;
|
||||
private Double expectedRevenue;
|
||||
private Double roi;
|
||||
private String status;
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.kt.event.analytics.dto.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 사용자 전체 이벤트의 시간대별 분석 응답
|
||||
*
|
||||
* 사용자 ID 기반으로 모든 이벤트의 시간대별 데이터를 통합하여 제공
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserTimelineAnalyticsResponse {
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 조회 기간 정보
|
||||
*/
|
||||
private PeriodInfo period;
|
||||
|
||||
/**
|
||||
* 전체 이벤트 수
|
||||
*/
|
||||
private Integer totalEvents;
|
||||
|
||||
/**
|
||||
* 시간 간격 (hourly, daily, weekly, monthly)
|
||||
*/
|
||||
private String interval;
|
||||
|
||||
/**
|
||||
* 시간대별 데이터 포인트 (모든 이벤트 통합)
|
||||
*/
|
||||
private List<TimelineDataPoint> dataPoints;
|
||||
|
||||
/**
|
||||
* 트렌드 분석
|
||||
*/
|
||||
private TrendAnalysis trend;
|
||||
|
||||
/**
|
||||
* 피크 시간 정보
|
||||
*/
|
||||
private PeakTimeInfo peakTime;
|
||||
|
||||
/**
|
||||
* 마지막 업데이트 시간
|
||||
*/
|
||||
private LocalDateTime lastUpdatedAt;
|
||||
|
||||
/**
|
||||
* 데이터 출처
|
||||
*/
|
||||
private String dataSource;
|
||||
}
|
||||
@@ -37,10 +37,10 @@ public class EventStats extends BaseTimeEntity {
|
||||
private String eventTitle;
|
||||
|
||||
/**
|
||||
* 매장 ID (소유자)
|
||||
* 사용자 ID (소유자)
|
||||
*/
|
||||
@Column(nullable = false, length = 50)
|
||||
private String storeId;
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 총 참여자 수
|
||||
@@ -63,6 +63,13 @@ public class EventStats extends BaseTimeEntity {
|
||||
@Builder.Default
|
||||
private BigDecimal estimatedRoi = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 목표 ROI (%)
|
||||
*/
|
||||
@Column(precision = 10, scale = 2)
|
||||
@Builder.Default
|
||||
private BigDecimal targetRoi = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 매출 증가율 (%)
|
||||
*/
|
||||
|
||||
+6
-2
@@ -11,6 +11,7 @@ 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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -37,7 +38,10 @@ public class DistributionCompletedConsumer {
|
||||
|
||||
/**
|
||||
* DistributionCompleted 이벤트 처리 (설계서 기준 - 여러 채널 배열)
|
||||
*
|
||||
* @Transactional 필수: DB 저장 작업을 위해 트랜잭션 컨텍스트 필요
|
||||
*/
|
||||
@Transactional
|
||||
@KafkaListener(topics = "sample.distribution.completed", groupId = "${spring.kafka.consumer.group-id}")
|
||||
public void handleDistributionCompleted(String message) {
|
||||
try {
|
||||
@@ -128,8 +132,8 @@ public class DistributionCompletedConsumer {
|
||||
.mapToInt(ChannelStats::getImpressions)
|
||||
.sum();
|
||||
|
||||
// EventStats 업데이트
|
||||
eventStatsRepository.findByEventId(eventId)
|
||||
// EventStats 업데이트 - 비관적 락 적용
|
||||
eventStatsRepository.findByEventIdWithLock(eventId)
|
||||
.ifPresentOrElse(
|
||||
eventStats -> {
|
||||
eventStats.setTotalViews(totalViews);
|
||||
|
||||
+6
-2
@@ -10,6 +10,7 @@ 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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@@ -34,7 +35,10 @@ public class EventCreatedConsumer {
|
||||
|
||||
/**
|
||||
* EventCreated 이벤트 처리 (MVP용 샘플 토픽)
|
||||
*
|
||||
* @Transactional 필수: DB 저장 작업을 위해 트랜잭션 컨텍스트 필요
|
||||
*/
|
||||
@Transactional
|
||||
@KafkaListener(topics = "sample.event.created", groupId = "${spring.kafka.consumer.group-id}")
|
||||
public void handleEventCreated(String message) {
|
||||
try {
|
||||
@@ -50,11 +54,11 @@ public class EventCreatedConsumer {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 이벤트 통계 초기화
|
||||
// 2. 이벤트 통계 초기화 (1:1 관계: storeId → userId 매핑)
|
||||
EventStats eventStats = EventStats.builder()
|
||||
.eventId(eventId)
|
||||
.eventTitle(event.getEventTitle())
|
||||
.storeId(event.getStoreId())
|
||||
.userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
|
||||
.totalParticipants(0)
|
||||
.totalInvestment(event.getTotalInvestment())
|
||||
.status(event.getStatus())
|
||||
|
||||
+6
-2
@@ -10,6 +10,7 @@ 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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@@ -34,7 +35,10 @@ public class ParticipantRegisteredConsumer {
|
||||
|
||||
/**
|
||||
* ParticipantRegistered 이벤트 처리 (MVP용 샘플 토픽)
|
||||
*
|
||||
* @Transactional 필수: 비관적 락 사용을 위해 트랜잭션 컨텍스트 필요
|
||||
*/
|
||||
@Transactional
|
||||
@KafkaListener(topics = "sample.participant.registered", groupId = "${spring.kafka.consumer.group-id}")
|
||||
public void handleParticipantRegistered(String message) {
|
||||
try {
|
||||
@@ -51,8 +55,8 @@ public class ParticipantRegisteredConsumer {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 이벤트 통계 업데이트 (참여자 수 +1)
|
||||
eventStatsRepository.findByEventId(eventId)
|
||||
// 2. 이벤트 통계 업데이트 (참여자 수 +1) - 비관적 락 적용
|
||||
eventStatsRepository.findByEventIdWithLock(eventId)
|
||||
.ifPresentOrElse(
|
||||
eventStats -> {
|
||||
eventStats.incrementParticipants();
|
||||
|
||||
+8
@@ -29,4 +29,12 @@ public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long
|
||||
* @return 채널 통계
|
||||
*/
|
||||
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
|
||||
|
||||
/**
|
||||
* 여러 이벤트 ID로 모든 채널 통계 조회
|
||||
*
|
||||
* @param eventIds 이벤트 ID 목록
|
||||
* @return 채널 통계 목록
|
||||
*/
|
||||
List<ChannelStats> findByEventIdIn(List<String> eventIds);
|
||||
}
|
||||
|
||||
+29
-3
@@ -1,7 +1,11 @@
|
||||
package com.kt.event.analytics.repository;
|
||||
|
||||
import com.kt.event.analytics.entity.EventStats;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -21,11 +25,33 @@ public interface EventStatsRepository extends JpaRepository<EventStats, Long> {
|
||||
Optional<EventStats> findByEventId(String eventId);
|
||||
|
||||
/**
|
||||
* 매장 ID와 이벤트 ID로 통계 조회
|
||||
* 이벤트 ID로 통계 조회 (비관적 락 적용)
|
||||
*
|
||||
* 동시성 충돌 방지를 위해 PESSIMISTIC_WRITE 락 사용
|
||||
* - 읽는 순간부터 락을 걸어 다른 트랜잭션 차단
|
||||
* - ParticipantRegistered 이벤트 처리 시 사용
|
||||
*
|
||||
* @param storeId 매장 ID
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 이벤트 통계
|
||||
*/
|
||||
Optional<EventStats> findByStoreIdAndEventId(String storeId, String eventId);
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT e FROM EventStats e WHERE e.eventId = :eventId")
|
||||
Optional<EventStats> findByEventIdWithLock(@Param("eventId") String eventId);
|
||||
|
||||
/**
|
||||
* 사용자 ID와 이벤트 ID로 통계 조회
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 이벤트 통계
|
||||
*/
|
||||
Optional<EventStats> findByUserIdAndEventId(String userId, String eventId);
|
||||
|
||||
/**
|
||||
* 사용자 ID로 모든 이벤트 통계 조회
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @return 이벤트 통계 목록
|
||||
*/
|
||||
java.util.List<EventStats> findAllByUserId(String userId);
|
||||
}
|
||||
|
||||
+23
@@ -37,4 +37,27 @@ public interface TimelineDataRepository extends JpaRepository<TimelineData, Long
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate
|
||||
);
|
||||
|
||||
/**
|
||||
* 여러 이벤트 ID로 시간대별 데이터 조회 (시간 순 정렬)
|
||||
*
|
||||
* @param eventIds 이벤트 ID 목록
|
||||
* @return 시간대별 데이터 목록
|
||||
*/
|
||||
List<TimelineData> findByEventIdInOrderByTimestampAsc(List<String> eventIds);
|
||||
|
||||
/**
|
||||
* 여러 이벤트 ID와 기간으로 시간대별 데이터 조회
|
||||
*
|
||||
* @param eventIds 이벤트 ID 목록
|
||||
* @param startDate 시작 날짜
|
||||
* @param endDate 종료 날짜
|
||||
* @return 시간대별 데이터 목록
|
||||
*/
|
||||
@Query("SELECT t FROM TimelineData t WHERE t.eventId IN :eventIds AND t.timestamp BETWEEN :startDate AND :endDate ORDER BY t.timestamp ASC")
|
||||
List<TimelineData> findByEventIdInAndTimestampBetween(
|
||||
@Param("eventIds") List<String> eventIds,
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate
|
||||
);
|
||||
}
|
||||
|
||||
+4
-2
@@ -179,12 +179,14 @@ public class AnalyticsService {
|
||||
.build();
|
||||
|
||||
return AnalyticsSummary.builder()
|
||||
.totalParticipants(eventStats.getTotalParticipants())
|
||||
.participants(eventStats.getTotalParticipants())
|
||||
.participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산
|
||||
.totalViews(totalViews)
|
||||
.totalReach(totalReach)
|
||||
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
||||
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
|
||||
.averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 함)
|
||||
.targetRoi(eventStats.getTargetRoi() != null ? eventStats.getTargetRoi().doubleValue() : null)
|
||||
.socialInteractions(socialStats)
|
||||
.build();
|
||||
}
|
||||
@@ -202,7 +204,7 @@ public class AnalyticsService {
|
||||
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
|
||||
|
||||
summaries.add(ChannelSummary.builder()
|
||||
.channelName(stats.getChannelName())
|
||||
.channel(stats.getChannelName())
|
||||
.views(stats.getViews())
|
||||
.participants(stats.getParticipants())
|
||||
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
||||
|
||||
@@ -192,7 +192,7 @@ public class ROICalculator {
|
||||
}
|
||||
|
||||
return RoiSummary.builder()
|
||||
.totalInvestment(eventStats.getTotalInvestment())
|
||||
.totalCost(eventStats.getTotalInvestment())
|
||||
.expectedRevenue(eventStats.getExpectedRevenue())
|
||||
.netProfit(netProfit)
|
||||
.roi(roi)
|
||||
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
package com.kt.event.analytics.service;
|
||||
|
||||
import com.kt.event.analytics.dto.response.*;
|
||||
import com.kt.event.analytics.entity.ChannelStats;
|
||||
import com.kt.event.analytics.entity.EventStats;
|
||||
import com.kt.event.analytics.repository.ChannelStatsRepository;
|
||||
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* User Analytics Service
|
||||
*
|
||||
* 매장(사용자) 전체 이벤트의 통합 성과 대시보드를 제공하는 서비스
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class UserAnalyticsService {
|
||||
|
||||
private final EventStatsRepository eventStatsRepository;
|
||||
private final ChannelStatsRepository channelStatsRepository;
|
||||
private final ROICalculator roiCalculator;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:user:dashboard:";
|
||||
private static final long CACHE_TTL = 1800; // 30분 (여러 이벤트 통합이므로 짧게)
|
||||
|
||||
/**
|
||||
* 사용자 전체 대시보드 데이터 조회
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param startDate 조회 시작 날짜 (선택)
|
||||
* @param endDate 조회 종료 날짜 (선택)
|
||||
* @param refresh 캐시 갱신 여부
|
||||
* @return 사용자 통합 대시보드 응답
|
||||
*/
|
||||
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||
log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||
|
||||
// 1. Redis 캐시 조회 (refresh가 false일 때만)
|
||||
if (!refresh) {
|
||||
String cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cachedData != null) {
|
||||
try {
|
||||
log.info("✅ 캐시 HIT: {}", cacheKey);
|
||||
return objectMapper.readValue(cachedData, UserAnalyticsDashboardResponse.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("캐시 데이터 역직렬화 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 캐시 MISS: 데이터 조회 및 통합
|
||||
log.info("캐시 MISS 또는 refresh=true: PostgreSQL 조회");
|
||||
|
||||
// 2-1. 사용자의 모든 이벤트 조회
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
log.warn("사용자에 이벤트가 없음: userId={}", userId);
|
||||
return buildEmptyResponse(userId, startDate, endDate);
|
||||
}
|
||||
|
||||
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
|
||||
|
||||
// 2-2. 모든 이벤트의 채널 통계 조회
|
||||
List<String> eventIds = allEvents.stream()
|
||||
.map(EventStats::getEventId)
|
||||
.collect(Collectors.toList());
|
||||
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
|
||||
|
||||
// 3. 통합 대시보드 데이터 구성
|
||||
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate);
|
||||
|
||||
// 4. Redis 캐싱 (30분 TTL)
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(response);
|
||||
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
|
||||
log.info("✅ Redis 캐시 저장 완료: {} (TTL: 30분)", cacheKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("캐시 저장 실패 (무시하고 계속 진행): {}", e.getMessage());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 응답 생성 (이벤트가 없는 경우)
|
||||
*/
|
||||
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
return UserAnalyticsDashboardResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.totalEvents(0)
|
||||
.activeEvents(0)
|
||||
.overallSummary(buildEmptyAnalyticsSummary())
|
||||
.channelPerformance(new ArrayList<>())
|
||||
.overallRoi(buildEmptyRoiSummary())
|
||||
.eventPerformances(new ArrayList<>())
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("empty")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 통합 대시보드 데이터 구성
|
||||
*/
|
||||
private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents,
|
||||
List<ChannelStats> allChannelStats,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
// 기간 정보
|
||||
PeriodInfo period = buildPeriodInfo(startDate, endDate);
|
||||
|
||||
// 전체 이벤트 수 및 활성 이벤트 수
|
||||
int totalEvents = allEvents.size();
|
||||
long activeEvents = allEvents.stream()
|
||||
.filter(e -> "ACTIVE".equalsIgnoreCase(e.getStatus()) || "RUNNING".equalsIgnoreCase(e.getStatus()))
|
||||
.count();
|
||||
|
||||
// 전체 성과 요약 (모든 이벤트 통합)
|
||||
AnalyticsSummary overallSummary = buildOverallSummary(allEvents, allChannelStats);
|
||||
|
||||
// 채널별 성과 요약 (모든 이벤트 통합)
|
||||
List<ChannelSummary> channelPerformance = buildAggregatedChannelPerformance(allChannelStats, allEvents);
|
||||
|
||||
// 전체 ROI 요약
|
||||
RoiSummary overallRoi = calculateOverallRoi(allEvents);
|
||||
|
||||
// 이벤트별 성과 목록
|
||||
List<UserAnalyticsDashboardResponse.EventPerformanceSummary> eventPerformances = buildEventPerformances(allEvents);
|
||||
|
||||
return UserAnalyticsDashboardResponse.builder()
|
||||
.userId(userId)
|
||||
.period(period)
|
||||
.totalEvents(totalEvents)
|
||||
.activeEvents((int) activeEvents)
|
||||
.overallSummary(overallSummary)
|
||||
.channelPerformance(channelPerformance)
|
||||
.overallRoi(overallRoi)
|
||||
.eventPerformances(eventPerformances)
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("cached")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 성과 요약 계산 (모든 이벤트 통합)
|
||||
*/
|
||||
private AnalyticsSummary buildOverallSummary(List<EventStats> allEvents, List<ChannelStats> allChannelStats) {
|
||||
int totalParticipants = allEvents.stream()
|
||||
.mapToInt(EventStats::getTotalParticipants)
|
||||
.sum();
|
||||
|
||||
int totalViews = allEvents.stream()
|
||||
.mapToInt(EventStats::getTotalViews)
|
||||
.sum();
|
||||
|
||||
BigDecimal totalInvestment = allEvents.stream()
|
||||
.map(EventStats::getTotalInvestment)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
BigDecimal totalExpectedRevenue = allEvents.stream()
|
||||
.map(EventStats::getExpectedRevenue)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// 평균 참여율 계산
|
||||
double avgEngagementRate = totalViews > 0 ? (double) totalParticipants / totalViews * 100 : 0.0;
|
||||
|
||||
// 평균 전환율 계산 (채널 통계 기반)
|
||||
int totalConversions = allChannelStats.stream()
|
||||
.mapToInt(ChannelStats::getConversions)
|
||||
.sum();
|
||||
double avgConversionRate = totalParticipants > 0 ? (double) totalConversions / totalParticipants * 100 : 0.0;
|
||||
|
||||
return AnalyticsSummary.builder()
|
||||
.participants(totalParticipants)
|
||||
.participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산
|
||||
.totalViews(totalViews)
|
||||
.engagementRate(Math.round(avgEngagementRate * 10) / 10.0)
|
||||
.conversionRate(Math.round(avgConversionRate * 10) / 10.0)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널별 성과 통합 (모든 이벤트의 채널 데이터 집계)
|
||||
*/
|
||||
private List<ChannelSummary> buildAggregatedChannelPerformance(List<ChannelStats> allChannelStats, List<EventStats> allEvents) {
|
||||
if (allChannelStats.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
BigDecimal totalInvestment = allEvents.stream()
|
||||
.map(EventStats::getTotalInvestment)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// 채널명별로 그룹화하여 집계
|
||||
Map<String, List<ChannelStats>> channelGroups = allChannelStats.stream()
|
||||
.collect(Collectors.groupingBy(ChannelStats::getChannelName));
|
||||
|
||||
return channelGroups.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String channelName = entry.getKey();
|
||||
List<ChannelStats> channelList = entry.getValue();
|
||||
|
||||
int participants = channelList.stream().mapToInt(ChannelStats::getParticipants).sum();
|
||||
int views = channelList.stream().mapToInt(ChannelStats::getViews).sum();
|
||||
double engagementRate = views > 0 ? (double) participants / views * 100 : 0.0;
|
||||
|
||||
BigDecimal channelCost = channelList.stream()
|
||||
.map(ChannelStats::getDistributionCost)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
double channelRoi = channelCost.compareTo(BigDecimal.ZERO) > 0
|
||||
? (participants - channelCost.doubleValue()) / channelCost.doubleValue() * 100
|
||||
: 0.0;
|
||||
|
||||
return ChannelSummary.builder()
|
||||
.channel(channelName)
|
||||
.participants(participants)
|
||||
.views(views)
|
||||
.engagementRate(Math.round(engagementRate * 10) / 10.0)
|
||||
.roi(Math.round(channelRoi * 10) / 10.0)
|
||||
.build();
|
||||
})
|
||||
.sorted(Comparator.comparingInt(ChannelSummary::getParticipants).reversed())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 ROI 계산
|
||||
*/
|
||||
private RoiSummary calculateOverallRoi(List<EventStats> allEvents) {
|
||||
BigDecimal totalInvestment = allEvents.stream()
|
||||
.map(EventStats::getTotalInvestment)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
BigDecimal totalExpectedRevenue = allEvents.stream()
|
||||
.map(EventStats::getExpectedRevenue)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
BigDecimal totalProfit = totalExpectedRevenue.subtract(totalInvestment);
|
||||
|
||||
Double roi = totalInvestment.compareTo(BigDecimal.ZERO) > 0
|
||||
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.doubleValue()
|
||||
: 0.0;
|
||||
|
||||
return RoiSummary.builder()
|
||||
.totalCost(totalInvestment)
|
||||
.expectedRevenue(totalExpectedRevenue)
|
||||
.netProfit(totalProfit)
|
||||
.roi(Math.round(roi * 10) / 10.0)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트별 성과 목록 생성
|
||||
*/
|
||||
private List<UserAnalyticsDashboardResponse.EventPerformanceSummary> buildEventPerformances(List<EventStats> allEvents) {
|
||||
return allEvents.stream()
|
||||
.map(event -> {
|
||||
Double roi = event.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0
|
||||
? event.getExpectedRevenue().subtract(event.getTotalInvestment())
|
||||
.divide(event.getTotalInvestment(), 4, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.doubleValue()
|
||||
: 0.0;
|
||||
|
||||
return UserAnalyticsDashboardResponse.EventPerformanceSummary.builder()
|
||||
.eventId(event.getEventId())
|
||||
.eventTitle(event.getEventTitle())
|
||||
.participants(event.getTotalParticipants())
|
||||
.views(event.getTotalViews())
|
||||
.roi(Math.round(roi * 10) / 10.0)
|
||||
.status(event.getStatus())
|
||||
.build();
|
||||
})
|
||||
.sorted(Comparator.comparingInt(UserAnalyticsDashboardResponse.EventPerformanceSummary::getParticipants).reversed())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간 정보 구성
|
||||
*/
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
long durationDays = ChronoUnit.DAYS.between(start, end);
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
.durationDays((int) durationDays)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 성과 요약
|
||||
*/
|
||||
private AnalyticsSummary buildEmptyAnalyticsSummary() {
|
||||
return AnalyticsSummary.builder()
|
||||
.participants(0)
|
||||
.participantsDelta(0)
|
||||
.totalViews(0)
|
||||
.engagementRate(0.0)
|
||||
.conversionRate(0.0)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 ROI 요약
|
||||
*/
|
||||
private RoiSummary buildEmptyRoiSummary() {
|
||||
return RoiSummary.builder()
|
||||
.totalCost(BigDecimal.ZERO)
|
||||
.expectedRevenue(BigDecimal.ZERO)
|
||||
.netProfit(BigDecimal.ZERO)
|
||||
.roi(0.0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package com.kt.event.analytics.service;
|
||||
|
||||
import com.kt.event.analytics.dto.response.*;
|
||||
import com.kt.event.analytics.entity.ChannelStats;
|
||||
import com.kt.event.analytics.entity.EventStats;
|
||||
import com.kt.event.analytics.repository.ChannelStatsRepository;
|
||||
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* User Channel Analytics Service
|
||||
*
|
||||
* 매장(사용자) 전체 이벤트의 채널별 성과를 통합하여 제공하는 서비스
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class UserChannelAnalyticsService {
|
||||
|
||||
private final EventStatsRepository eventStatsRepository;
|
||||
private final ChannelStatsRepository channelStatsRepository;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:user:channels:";
|
||||
private static final long CACHE_TTL = 1800; // 30분
|
||||
|
||||
/**
|
||||
* 사용자 전체 채널 분석 데이터 조회
|
||||
*/
|
||||
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> channels, String sortBy, String order,
|
||||
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||
log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||
|
||||
// 1. 캐시 조회
|
||||
if (!refresh) {
|
||||
String cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cachedData != null) {
|
||||
try {
|
||||
log.info("✅ 캐시 HIT: {}", cacheKey);
|
||||
return objectMapper.readValue(cachedData, UserChannelAnalyticsResponse.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("캐시 역직렬화 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 데이터 조회
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
return buildEmptyResponse(userId, startDate, endDate);
|
||||
}
|
||||
|
||||
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
|
||||
|
||||
// 3. 응답 구성
|
||||
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate);
|
||||
|
||||
// 4. 캐싱
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(response);
|
||||
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
|
||||
log.info("✅ 캐시 저장 완료: {}", cacheKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("캐시 저장 실패: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
return UserChannelAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.totalEvents(0)
|
||||
.channels(new ArrayList<>())
|
||||
.comparison(ChannelComparison.builder().build())
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("empty")
|
||||
.build();
|
||||
}
|
||||
|
||||
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents,
|
||||
List<ChannelStats> allChannelStats, List<String> channels,
|
||||
String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
// 채널 필터링
|
||||
List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty()
|
||||
? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
|
||||
: allChannelStats;
|
||||
|
||||
// 채널별 집계
|
||||
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
|
||||
|
||||
// 정렬
|
||||
channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
|
||||
|
||||
// 채널 비교
|
||||
ChannelComparison comparison = buildChannelComparison(channelAnalyticsList);
|
||||
|
||||
return UserChannelAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.totalEvents(allEvents.size())
|
||||
.channels(channelAnalyticsList)
|
||||
.comparison(comparison)
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("cached")
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<ChannelAnalytics> aggregateChannelAnalytics(List<ChannelStats> allChannelStats) {
|
||||
Map<String, List<ChannelStats>> channelGroups = allChannelStats.stream()
|
||||
.collect(Collectors.groupingBy(ChannelStats::getChannelName));
|
||||
|
||||
return channelGroups.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String channelName = entry.getKey();
|
||||
List<ChannelStats> channelList = entry.getValue();
|
||||
|
||||
int views = channelList.stream().mapToInt(ChannelStats::getViews).sum();
|
||||
int participants = channelList.stream().mapToInt(ChannelStats::getParticipants).sum();
|
||||
int clicks = channelList.stream().mapToInt(ChannelStats::getClicks).sum();
|
||||
int conversions = channelList.stream().mapToInt(ChannelStats::getConversions).sum();
|
||||
|
||||
double engagementRate = views > 0 ? (double) participants / views * 100 : 0.0;
|
||||
double conversionRate = participants > 0 ? (double) conversions / participants * 100 : 0.0;
|
||||
|
||||
BigDecimal cost = channelList.stream()
|
||||
.map(ChannelStats::getDistributionCost)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
double roi = cost.compareTo(BigDecimal.ZERO) > 0
|
||||
? (participants - cost.doubleValue()) / cost.doubleValue() * 100
|
||||
: 0.0;
|
||||
|
||||
ChannelMetrics metrics = ChannelMetrics.builder()
|
||||
.impressions(channelList.stream().mapToInt(ChannelStats::getImpressions).sum())
|
||||
.views(views)
|
||||
.clicks(clicks)
|
||||
.participants(participants)
|
||||
.conversions(conversions)
|
||||
.build();
|
||||
|
||||
ChannelPerformance performance = ChannelPerformance.builder()
|
||||
.engagementRate(Math.round(engagementRate * 10) / 10.0)
|
||||
.conversionRate(Math.round(conversionRate * 10) / 10.0)
|
||||
.clickThroughRate(views > 0 ? Math.round((double) clicks / views * 1000) / 10.0 : 0.0)
|
||||
.build();
|
||||
|
||||
ChannelCosts costs = ChannelCosts.builder()
|
||||
.distributionCost(cost)
|
||||
.costPerView(views > 0 ? cost.doubleValue() / views : 0.0)
|
||||
.costPerClick(clicks > 0 ? cost.doubleValue() / clicks : 0.0)
|
||||
.costPerAcquisition(participants > 0 ? cost.doubleValue() / participants : 0.0)
|
||||
.roi(Math.round(roi * 10) / 10.0)
|
||||
.build();
|
||||
|
||||
return ChannelAnalytics.builder()
|
||||
.channelName(channelName)
|
||||
.channelType(channelList.get(0).getChannelType())
|
||||
.metrics(metrics)
|
||||
.performance(performance)
|
||||
.costs(costs)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<ChannelAnalytics> sortChannels(List<ChannelAnalytics> channels, String sortBy, String order) {
|
||||
Comparator<ChannelAnalytics> comparator;
|
||||
|
||||
switch (sortBy != null ? sortBy.toLowerCase() : "participants") {
|
||||
case "views":
|
||||
comparator = Comparator.comparingInt(c -> c.getMetrics().getViews());
|
||||
break;
|
||||
case "engagement_rate":
|
||||
comparator = Comparator.comparingDouble(c -> c.getPerformance().getEngagementRate());
|
||||
break;
|
||||
case "conversion_rate":
|
||||
comparator = Comparator.comparingDouble(c -> c.getPerformance().getConversionRate());
|
||||
break;
|
||||
case "roi":
|
||||
comparator = Comparator.comparingDouble(c -> c.getCosts().getRoi());
|
||||
break;
|
||||
case "participants":
|
||||
default:
|
||||
comparator = Comparator.comparingInt(c -> c.getMetrics().getParticipants());
|
||||
break;
|
||||
}
|
||||
|
||||
if ("desc".equalsIgnoreCase(order)) {
|
||||
comparator = comparator.reversed();
|
||||
}
|
||||
|
||||
return channels.stream().sorted(comparator).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ChannelComparison buildChannelComparison(List<ChannelAnalytics> channels) {
|
||||
if (channels.isEmpty()) {
|
||||
return ChannelComparison.builder().build();
|
||||
}
|
||||
|
||||
String bestPerformingChannel = channels.stream()
|
||||
.max(Comparator.comparingInt(c -> c.getMetrics().getParticipants()))
|
||||
.map(ChannelAnalytics::getChannelName)
|
||||
.orElse("N/A");
|
||||
|
||||
Map<String, String> bestPerforming = new HashMap<>();
|
||||
bestPerforming.put("channel", bestPerformingChannel);
|
||||
bestPerforming.put("metric", "participants");
|
||||
|
||||
Map<String, Double> averageMetrics = new HashMap<>();
|
||||
int totalChannels = channels.size();
|
||||
if (totalChannels > 0) {
|
||||
double avgParticipants = channels.stream().mapToInt(c -> c.getMetrics().getParticipants()).average().orElse(0.0);
|
||||
double avgEngagement = channels.stream().mapToDouble(c -> c.getPerformance().getEngagementRate()).average().orElse(0.0);
|
||||
double avgRoi = channels.stream().mapToDouble(c -> c.getCosts().getRoi()).average().orElse(0.0);
|
||||
|
||||
averageMetrics.put("participants", avgParticipants);
|
||||
averageMetrics.put("engagementRate", avgEngagement);
|
||||
averageMetrics.put("roi", avgRoi);
|
||||
}
|
||||
|
||||
return ChannelComparison.builder()
|
||||
.bestPerforming(bestPerforming)
|
||||
.averageMetrics(averageMetrics)
|
||||
.build();
|
||||
}
|
||||
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
long durationDays = ChronoUnit.DAYS.between(start, end);
|
||||
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
.durationDays((int) durationDays)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.kt.event.analytics.service;
|
||||
|
||||
import com.kt.event.analytics.dto.response.*;
|
||||
import com.kt.event.analytics.entity.EventStats;
|
||||
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* User ROI Analytics Service
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class UserRoiAnalyticsService {
|
||||
|
||||
private final EventStatsRepository eventStatsRepository;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:user:roi:";
|
||||
private static final long CACHE_TTL = 1800;
|
||||
|
||||
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection,
|
||||
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||
log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||
|
||||
if (!refresh) {
|
||||
String cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cachedData != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cachedData, UserRoiAnalyticsResponse.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("캐시 역직렬화 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
return buildEmptyResponse(userId, startDate, endDate);
|
||||
}
|
||||
|
||||
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate);
|
||||
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(response);
|
||||
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("캐시 저장 실패: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
return UserRoiAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.totalEvents(0)
|
||||
.overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build())
|
||||
.overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build())
|
||||
.overallRoi(RoiCalculation.builder()
|
||||
.netProfit(BigDecimal.ZERO)
|
||||
.roiPercentage(0.0)
|
||||
.build())
|
||||
.eventRois(new ArrayList<>())
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("empty")
|
||||
.build();
|
||||
}
|
||||
|
||||
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal totalProfit = totalRevenue.subtract(totalInvestment);
|
||||
|
||||
Double roiPercentage = totalInvestment.compareTo(BigDecimal.ZERO) > 0
|
||||
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
|
||||
: 0.0;
|
||||
|
||||
InvestmentDetails investment = InvestmentDetails.builder()
|
||||
.total(totalInvestment)
|
||||
.contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6)))
|
||||
.operation(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
|
||||
.distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
|
||||
.build();
|
||||
|
||||
RevenueDetails revenue = RevenueDetails.builder()
|
||||
.total(totalRevenue)
|
||||
.directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7)))
|
||||
.expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3)))
|
||||
.build();
|
||||
|
||||
RoiCalculation roiCalc = RoiCalculation.builder()
|
||||
.netProfit(totalProfit)
|
||||
.roiPercentage(Math.round(roiPercentage * 10) / 10.0)
|
||||
.build();
|
||||
|
||||
int totalParticipants = allEvents.stream().mapToInt(EventStats::getTotalParticipants).sum();
|
||||
CostEfficiency efficiency = CostEfficiency.builder()
|
||||
.costPerParticipant(totalParticipants > 0 ? totalInvestment.doubleValue() / totalParticipants : 0.0)
|
||||
.revenuePerParticipant(totalParticipants > 0 ? totalRevenue.doubleValue() / totalParticipants : 0.0)
|
||||
.build();
|
||||
|
||||
RevenueProjection projection = includeProjection ? RevenueProjection.builder()
|
||||
.currentRevenue(totalRevenue)
|
||||
.projectedFinalRevenue(totalRevenue.multiply(BigDecimal.valueOf(1.2)))
|
||||
.confidenceLevel(85.0)
|
||||
.basedOn("Historical trend analysis")
|
||||
.build() : null;
|
||||
|
||||
List<UserRoiAnalyticsResponse.EventRoiSummary> eventRois = allEvents.stream()
|
||||
.map(event -> {
|
||||
Double eventRoi = event.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0
|
||||
? event.getExpectedRevenue().subtract(event.getTotalInvestment())
|
||||
.divide(event.getTotalInvestment(), 4, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100)).doubleValue()
|
||||
: 0.0;
|
||||
|
||||
return UserRoiAnalyticsResponse.EventRoiSummary.builder()
|
||||
.eventId(event.getEventId())
|
||||
.eventTitle(event.getEventTitle())
|
||||
.totalInvestment(event.getTotalInvestment().doubleValue())
|
||||
.expectedRevenue(event.getExpectedRevenue().doubleValue())
|
||||
.roi(Math.round(eventRoi * 10) / 10.0)
|
||||
.status(event.getStatus())
|
||||
.build();
|
||||
})
|
||||
.sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return UserRoiAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.totalEvents(allEvents.size())
|
||||
.overallInvestment(investment)
|
||||
.overallRevenue(revenue)
|
||||
.overallRoi(roiCalc)
|
||||
.costEfficiency(efficiency)
|
||||
.projection(projection)
|
||||
.eventRois(eventRois)
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("cached")
|
||||
.build();
|
||||
}
|
||||
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
.durationDays((int) ChronoUnit.DAYS.between(start, end))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package com.kt.event.analytics.service;
|
||||
|
||||
import com.kt.event.analytics.dto.response.*;
|
||||
import com.kt.event.analytics.entity.EventStats;
|
||||
import com.kt.event.analytics.entity.TimelineData;
|
||||
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||
import com.kt.event.analytics.repository.TimelineDataRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* User Timeline Analytics Service
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class UserTimelineAnalyticsService {
|
||||
|
||||
private final EventStatsRepository eventStatsRepository;
|
||||
private final TimelineDataRepository timelineDataRepository;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "analytics:user:timeline:";
|
||||
private static final long CACHE_TTL = 1800;
|
||||
|
||||
public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
|
||||
LocalDateTime startDate, LocalDateTime endDate,
|
||||
List<String> metrics, boolean refresh) {
|
||||
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + userId + ":" + interval;
|
||||
|
||||
if (!refresh) {
|
||||
String cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cachedData != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cachedData, UserTimelineAnalyticsResponse.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("캐시 역직렬화 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||
if (allEvents.isEmpty()) {
|
||||
return buildEmptyResponse(userId, interval, startDate, endDate);
|
||||
}
|
||||
|
||||
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||
List<TimelineData> allTimelineData = startDate != null && endDate != null
|
||||
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
|
||||
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
|
||||
|
||||
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate);
|
||||
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(response);
|
||||
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("캐시 저장 실패: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) {
|
||||
return UserTimelineAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.totalEvents(0)
|
||||
.interval(interval != null ? interval : "daily")
|
||||
.dataPoints(new ArrayList<>())
|
||||
.trend(TrendAnalysis.builder().overallTrend("stable").build())
|
||||
.peakTime(PeakTimeInfo.builder().build())
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("empty")
|
||||
.build();
|
||||
}
|
||||
|
||||
private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents,
|
||||
List<TimelineData> allTimelineData, String interval,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>();
|
||||
|
||||
for (TimelineData data : allTimelineData) {
|
||||
LocalDateTime key = normalizeTimestamp(data.getTimestamp(), interval);
|
||||
aggregatedData.computeIfAbsent(key, k -> TimelineDataPoint.builder()
|
||||
.timestamp(k)
|
||||
.participants(0)
|
||||
.views(0)
|
||||
.engagement(0)
|
||||
.conversions(0)
|
||||
.build());
|
||||
|
||||
TimelineDataPoint point = aggregatedData.get(key);
|
||||
point.setParticipants(point.getParticipants() + data.getParticipants());
|
||||
point.setViews(point.getViews() + data.getViews());
|
||||
point.setEngagement(point.getEngagement() + data.getEngagement());
|
||||
point.setConversions(point.getConversions() + data.getConversions());
|
||||
}
|
||||
|
||||
List<TimelineDataPoint> dataPoints = new ArrayList<>(aggregatedData.values());
|
||||
|
||||
TrendAnalysis trend = analyzeTrend(dataPoints);
|
||||
PeakTimeInfo peakTime = findPeakTime(dataPoints);
|
||||
|
||||
return UserTimelineAnalyticsResponse.builder()
|
||||
.userId(userId)
|
||||
.period(buildPeriodInfo(startDate, endDate))
|
||||
.totalEvents(allEvents.size())
|
||||
.interval(interval != null ? interval : "daily")
|
||||
.dataPoints(dataPoints)
|
||||
.trend(trend)
|
||||
.peakTime(peakTime)
|
||||
.lastUpdatedAt(LocalDateTime.now())
|
||||
.dataSource("cached")
|
||||
.build();
|
||||
}
|
||||
|
||||
private LocalDateTime normalizeTimestamp(LocalDateTime timestamp, String interval) {
|
||||
switch (interval != null ? interval.toLowerCase() : "daily") {
|
||||
case "hourly":
|
||||
return timestamp.truncatedTo(ChronoUnit.HOURS);
|
||||
case "weekly":
|
||||
return timestamp.truncatedTo(ChronoUnit.DAYS).minusDays(timestamp.getDayOfWeek().getValue() - 1);
|
||||
case "monthly":
|
||||
return timestamp.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
|
||||
case "daily":
|
||||
default:
|
||||
return timestamp.truncatedTo(ChronoUnit.DAYS);
|
||||
}
|
||||
}
|
||||
|
||||
private TrendAnalysis analyzeTrend(List<TimelineDataPoint> dataPoints) {
|
||||
if (dataPoints.size() < 2) {
|
||||
return TrendAnalysis.builder().overallTrend("stable").build();
|
||||
}
|
||||
|
||||
int firstHalf = dataPoints.subList(0, dataPoints.size() / 2).stream()
|
||||
.mapToInt(TimelineDataPoint::getParticipants).sum();
|
||||
int secondHalf = dataPoints.subList(dataPoints.size() / 2, dataPoints.size()).stream()
|
||||
.mapToInt(TimelineDataPoint::getParticipants).sum();
|
||||
|
||||
double growthRate = firstHalf > 0 ? ((double) (secondHalf - firstHalf) / firstHalf) * 100 : 0.0;
|
||||
String trend = growthRate > 5 ? "increasing" : (growthRate < -5 ? "decreasing" : "stable");
|
||||
|
||||
return TrendAnalysis.builder()
|
||||
.overallTrend(trend)
|
||||
.build();
|
||||
}
|
||||
|
||||
private PeakTimeInfo findPeakTime(List<TimelineDataPoint> dataPoints) {
|
||||
if (dataPoints.isEmpty()) {
|
||||
return PeakTimeInfo.builder().build();
|
||||
}
|
||||
|
||||
TimelineDataPoint peak = dataPoints.stream()
|
||||
.max(Comparator.comparingInt(TimelineDataPoint::getParticipants))
|
||||
.orElse(null);
|
||||
|
||||
return peak != null ? PeakTimeInfo.builder()
|
||||
.timestamp(peak.getTimestamp())
|
||||
.metric("participants")
|
||||
.value(peak.getParticipants())
|
||||
.description(peak.getViews() + " views at peak time")
|
||||
.build() : PeakTimeInfo.builder().build();
|
||||
}
|
||||
|
||||
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||
return PeriodInfo.builder()
|
||||
.startDate(start)
|
||||
.endDate(end)
|
||||
.durationDays((int) ChronoUnit.DAYS.between(start, end))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
# Analytics Service 백엔드 테스트 결과서
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 테스트 목적
|
||||
- **userId 기반 통합 성과 분석 API 개발 및 검증**
|
||||
- 사용자 전체 이벤트를 통합하여 분석하는 4개 API 개발
|
||||
- 기존 eventId 기반 API와 독립적으로 동작하는 구조 검증
|
||||
- MVP 환경: 1:1 관계 (1 user = 1 store)
|
||||
|
||||
### 1.2 테스트 환경
|
||||
- **프로젝트**: kt-event-marketing
|
||||
- **서비스**: analytics-service
|
||||
- **브랜치**: feature/analytics
|
||||
- **빌드 도구**: Gradle 8.10
|
||||
- **프레임워크**: Spring Boot 3.3.0
|
||||
- **언어**: Java 21
|
||||
|
||||
### 1.3 테스트 일시
|
||||
- **작성일**: 2025-10-28
|
||||
- **컴파일 테스트**: 2025-10-28
|
||||
|
||||
---
|
||||
|
||||
## 2. 개발 범위
|
||||
|
||||
### 2.1 Repository 수정
|
||||
**파일**: 3개 Repository 인터페이스
|
||||
|
||||
#### EventStatsRepository
|
||||
```java
|
||||
// 추가된 메소드
|
||||
List<EventStats> findAllByUserId(String userId);
|
||||
```
|
||||
- **목적**: 특정 사용자의 모든 이벤트 통계 조회
|
||||
- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java`
|
||||
|
||||
#### ChannelStatsRepository
|
||||
```java
|
||||
// 추가된 메소드
|
||||
List<ChannelStats> findByEventIdIn(List<String> eventIds);
|
||||
```
|
||||
- **목적**: 여러 이벤트의 채널 통계 일괄 조회
|
||||
- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java`
|
||||
|
||||
#### TimelineDataRepository
|
||||
```java
|
||||
// 추가된 메소드
|
||||
List<TimelineData> findByEventIdInOrderByTimestampAsc(List<String> eventIds);
|
||||
|
||||
@Query("SELECT t FROM TimelineData t WHERE t.eventId IN :eventIds " +
|
||||
"AND t.timestamp BETWEEN :startDate AND :endDate " +
|
||||
"ORDER BY t.timestamp ASC")
|
||||
List<TimelineData> findByEventIdInAndTimestampBetween(
|
||||
@Param("eventIds") List<String> eventIds,
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate
|
||||
);
|
||||
```
|
||||
- **목적**: 여러 이벤트의 타임라인 데이터 조회
|
||||
- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Response DTO 작성
|
||||
**파일**: 4개 Response DTO
|
||||
|
||||
#### UserAnalyticsDashboardResponse
|
||||
- **경로**: `com.kt.event.analytics.dto.response.UserAnalyticsDashboardResponse`
|
||||
- **역할**: 사용자 전체 통합 성과 대시보드 응답
|
||||
- **주요 필드**:
|
||||
- `userId`: 사용자 ID
|
||||
- `totalEvents`: 총 이벤트 수
|
||||
- `activeEvents`: 활성 이벤트 수
|
||||
- `overallSummary`: 전체 성과 요약 (AnalyticsSummary)
|
||||
- `channelPerformance`: 채널별 성과 (List<ChannelSummary>)
|
||||
- `overallRoi`: 전체 ROI 요약 (RoiSummary)
|
||||
- `eventPerformances`: 이벤트별 성과 목록 (EventPerformanceSummary)
|
||||
- `period`: 조회 기간 (PeriodInfo)
|
||||
|
||||
#### UserChannelAnalyticsResponse
|
||||
- **경로**: `com.kt.event.analytics.dto.response.UserChannelAnalyticsResponse`
|
||||
- **역할**: 사용자 전체 채널별 성과 분석 응답
|
||||
- **주요 필드**:
|
||||
- `userId`: 사용자 ID
|
||||
- `totalEvents`: 총 이벤트 수
|
||||
- `channels`: 채널별 상세 분석 (List<ChannelAnalytics>)
|
||||
- `comparison`: 채널 간 비교 (ChannelComparison)
|
||||
- `period`: 조회 기간 (PeriodInfo)
|
||||
|
||||
#### UserRoiAnalyticsResponse
|
||||
- **경로**: `com.kt.event.analytics.dto.response.UserRoiAnalyticsResponse`
|
||||
- **역할**: 사용자 전체 ROI 상세 분석 응답
|
||||
- **주요 필드**:
|
||||
- `userId`: 사용자 ID
|
||||
- `totalEvents`: 총 이벤트 수
|
||||
- `overallInvestment`: 전체 투자 내역 (InvestmentDetails)
|
||||
- `overallRevenue`: 전체 수익 내역 (RevenueDetails)
|
||||
- `overallRoi`: ROI 계산 (RoiCalculation)
|
||||
- `costEfficiency`: 비용 효율성 (CostEfficiency)
|
||||
- `projection`: 수익 예측 (RevenueProjection)
|
||||
- `eventRois`: 이벤트별 ROI (EventRoiSummary)
|
||||
- `period`: 조회 기간 (PeriodInfo)
|
||||
|
||||
#### UserTimelineAnalyticsResponse
|
||||
- **경로**: `com.kt.event.analytics.dto.response.UserTimelineAnalyticsResponse`
|
||||
- **역할**: 사용자 전체 시간대별 참여 추이 분석 응답
|
||||
- **주요 필드**:
|
||||
- `userId`: 사용자 ID
|
||||
- `totalEvents`: 총 이벤트 수
|
||||
- `interval`: 시간 간격 단위 (hourly, daily, weekly, monthly)
|
||||
- `dataPoints`: 시간대별 데이터 포인트 (List<TimelineDataPoint>)
|
||||
- `trend`: 추세 분석 (TrendAnalysis)
|
||||
- `peakTime`: 피크 시간대 정보 (PeakTimeInfo)
|
||||
- `period`: 조회 기간 (PeriodInfo)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Service 개발
|
||||
**파일**: 4개 Service 클래스
|
||||
|
||||
#### UserAnalyticsService
|
||||
- **경로**: `com.kt.event.analytics.service.UserAnalyticsService`
|
||||
- **역할**: 사용자 전체 이벤트 통합 성과 대시보드 서비스
|
||||
- **주요 기능**:
|
||||
- `getUserDashboardData()`: 사용자 전체 대시보드 데이터 조회
|
||||
- Redis 캐싱 (TTL: 30분)
|
||||
- 전체 성과 요약 계산 (참여자, 조회수, 참여율, 전환율)
|
||||
- 채널별 성과 통합 집계
|
||||
- 전체 ROI 계산
|
||||
- 이벤트별 성과 목록 생성
|
||||
- **특징**:
|
||||
- 모든 이벤트의 메트릭을 합산하여 통합 분석
|
||||
- 채널명 기준으로 그룹화하여 채널 성과 집계
|
||||
- BigDecimal 타입으로 금액 정확도 보장
|
||||
|
||||
#### UserChannelAnalyticsService
|
||||
- **경로**: `com.kt.event.analytics.service.UserChannelAnalyticsService`
|
||||
- **역할**: 사용자 전체 이벤트의 채널별 성과 통합 서비스
|
||||
- **주요 기능**:
|
||||
- `getUserChannelAnalytics()`: 사용자 전체 채널 분석 데이터 조회
|
||||
- Redis 캐싱 (TTL: 30분)
|
||||
- 채널별 메트릭 집계 (조회수, 참여자, 클릭, 전환)
|
||||
- 채널 성과 지표 계산 (참여율, 전환율, CTR, ROI)
|
||||
- 채널 비용 분석 (조회당/클릭당/획득당 비용)
|
||||
- 채널 간 비교 분석 (최고 성과, 평균 지표)
|
||||
- **특징**:
|
||||
- 채널명 기준으로 그룹화하여 통합 집계
|
||||
- 다양한 정렬 옵션 지원 (participants, views, engagement_rate, conversion_rate, roi)
|
||||
- 채널 필터링 기능
|
||||
|
||||
#### UserRoiAnalyticsService
|
||||
- **경로**: `com.kt.event.analytics.service.UserRoiAnalyticsService`
|
||||
- **역할**: 사용자 전체 이벤트의 ROI 통합 분석 서비스
|
||||
- **주요 기능**:
|
||||
- `getUserRoiAnalytics()`: 사용자 전체 ROI 분석 데이터 조회
|
||||
- Redis 캐싱 (TTL: 30분)
|
||||
- 전체 투자 금액 집계 (콘텐츠 제작, 운영, 배포 비용)
|
||||
- 전체 수익 집계 (직접 판매, 예상 판매)
|
||||
- ROI 계산 (순이익, ROI %)
|
||||
- 비용 효율성 분석 (참여자당 비용/수익)
|
||||
- 수익 예측 (현재 수익 기반 최종 수익 예측)
|
||||
- **특징**:
|
||||
- BigDecimal로 금액 정밀 계산
|
||||
- 이벤트별 ROI 순위 제공
|
||||
- 선택적 수익 예측 기능
|
||||
|
||||
#### UserTimelineAnalyticsService
|
||||
- **경로**: `com.kt.event.analytics.service.UserTimelineAnalyticsService`
|
||||
- **역할**: 사용자 전체 이벤트의 시간대별 추이 통합 서비스
|
||||
- **주요 기능**:
|
||||
- `getUserTimelineAnalytics()`: 사용자 전체 타임라인 분석 데이터 조회
|
||||
- Redis 캐싱 (TTL: 30분)
|
||||
- 시간 간격별 데이터 집계 (hourly, daily, weekly, monthly)
|
||||
- 추세 분석 (증가/감소/안정)
|
||||
- 피크 시간대 식별 (최대 참여자 시점)
|
||||
- **특징**:
|
||||
- 시간대별로 정규화하여 데이터 집계
|
||||
- 전반부/후반부 비교를 통한 성장률 계산
|
||||
- 메트릭별 필터링 지원
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Controller 개발
|
||||
**파일**: 4개 Controller 클래스
|
||||
|
||||
#### UserAnalyticsDashboardController
|
||||
- **경로**: `com.kt.event.analytics.controller.UserAnalyticsDashboardController`
|
||||
- **엔드포인트**: `GET /api/v1/users/{userId}/analytics`
|
||||
- **역할**: 사용자 전체 성과 대시보드 API
|
||||
- **Request Parameters**:
|
||||
- `userId` (Path): 사용자 ID (필수)
|
||||
- `startDate` (Query): 조회 시작 날짜 (선택, ISO 8601 format)
|
||||
- `endDate` (Query): 조회 종료 날짜 (선택, ISO 8601 format)
|
||||
- `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
|
||||
- **Response**: `ApiResponse<UserAnalyticsDashboardResponse>`
|
||||
|
||||
#### UserChannelAnalyticsController
|
||||
- **경로**: `com.kt.event.analytics.controller.UserChannelAnalyticsController`
|
||||
- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/channels`
|
||||
- **역할**: 사용자 전체 채널별 성과 분석 API
|
||||
- **Request Parameters**:
|
||||
- `userId` (Path): 사용자 ID (필수)
|
||||
- `channels` (Query): 조회할 채널 목록 (쉼표 구분, 선택)
|
||||
- `sortBy` (Query): 정렬 기준 (선택, default: participants)
|
||||
- `order` (Query): 정렬 순서 (선택, default: desc)
|
||||
- `startDate` (Query): 조회 시작 날짜 (선택)
|
||||
- `endDate` (Query): 조회 종료 날짜 (선택)
|
||||
- `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
|
||||
- **Response**: `ApiResponse<UserChannelAnalyticsResponse>`
|
||||
|
||||
#### UserRoiAnalyticsController
|
||||
- **경로**: `com.kt.event.analytics.controller.UserRoiAnalyticsController`
|
||||
- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/roi`
|
||||
- **역할**: 사용자 전체 ROI 상세 분석 API
|
||||
- **Request Parameters**:
|
||||
- `userId` (Path): 사용자 ID (필수)
|
||||
- `includeProjection` (Query): 예상 수익 포함 여부 (선택, default: true)
|
||||
- `startDate` (Query): 조회 시작 날짜 (선택)
|
||||
- `endDate` (Query): 조회 종료 날짜 (선택)
|
||||
- `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
|
||||
- **Response**: `ApiResponse<UserRoiAnalyticsResponse>`
|
||||
|
||||
#### UserTimelineAnalyticsController
|
||||
- **경로**: `com.kt.event.analytics.controller.UserTimelineAnalyticsController`
|
||||
- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/timeline`
|
||||
- **역할**: 사용자 전체 시간대별 참여 추이 분석 API
|
||||
- **Request Parameters**:
|
||||
- `userId` (Path): 사용자 ID (필수)
|
||||
- `interval` (Query): 시간 간격 단위 (선택, default: daily)
|
||||
- 값: hourly, daily, weekly, monthly
|
||||
- `startDate` (Query): 조회 시작 날짜 (선택)
|
||||
- `endDate` (Query): 조회 종료 날짜 (선택)
|
||||
- `metrics` (Query): 조회할 지표 목록 (쉼표 구분, 선택)
|
||||
- `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
|
||||
- **Response**: `ApiResponse<UserTimelineAnalyticsResponse>`
|
||||
|
||||
---
|
||||
|
||||
## 3. 컴파일 테스트
|
||||
|
||||
### 3.1 테스트 명령
|
||||
```bash
|
||||
./gradlew.bat analytics-service:compileJava
|
||||
```
|
||||
|
||||
### 3.2 테스트 결과
|
||||
**상태**: ✅ **성공 (BUILD SUCCESSFUL)**
|
||||
|
||||
**출력**:
|
||||
```
|
||||
> Task :common:generateEffectiveLombokConfig UP-TO-DATE
|
||||
> Task :common:compileJava UP-TO-DATE
|
||||
> Task :analytics-service:generateEffectiveLombokConfig
|
||||
> Task :analytics-service:compileJava
|
||||
|
||||
BUILD SUCCESSFUL in 8s
|
||||
4 actionable tasks: 2 executed, 2 up-to-date
|
||||
```
|
||||
|
||||
### 3.3 오류 해결 과정
|
||||
|
||||
#### 3.3.1 초기 컴파일 오류 (19개)
|
||||
**문제**: 기존 DTO 구조와 Service 코드 간 필드명/타입 불일치
|
||||
|
||||
**해결**:
|
||||
1. **AnalyticsSummary**: totalInvestment, expectedRevenue 필드 제거
|
||||
2. **ChannelSummary**: cost 필드 제거
|
||||
3. **RoiSummary**: BigDecimal 타입 사용
|
||||
4. **InvestmentDetails**: totalAmount → total 변경, 필드명 수정 (contentCreation, operation, distribution)
|
||||
5. **RevenueDetails**: totalRevenue → total 변경, 필드명 수정 (directSales, expectedSales)
|
||||
6. **RoiCalculation**: totalInvestment, totalRevenue 필드 제거
|
||||
7. **TrendAnalysis**: direction → overallTrend 변경
|
||||
8. **PeakTimeInfo**: participants → value 변경, metric, description 추가
|
||||
9. **ChannelPerformance**: participationRate 필드 제거
|
||||
10. **ChannelCosts**: totalCost → distributionCost 변경, costPerParticipant → costPerAcquisition 변경
|
||||
11. **ChannelComparison**: mostEfficient, highestEngagement → averageMetrics로 통합
|
||||
12. **RevenueProjection**: projectedRevenue → projectedFinalRevenue 변경, basedOn 필드 추가
|
||||
|
||||
#### 3.3.2 수정된 파일
|
||||
- `UserAnalyticsService.java`: DTO 필드명 수정 (5곳)
|
||||
- `UserChannelAnalyticsService.java`: DTO 필드명 수정, HashMap import 추가 (3곳)
|
||||
- `UserRoiAnalyticsService.java`: DTO 필드명 수정, BigDecimal 타입 사용 (4곳)
|
||||
- `UserTimelineAnalyticsService.java`: DTO 필드명 수정 (3곳)
|
||||
|
||||
---
|
||||
|
||||
## 4. API 설계 요약
|
||||
|
||||
### 4.1 API 엔드포인트 구조
|
||||
```
|
||||
/api/v1/users/{userId}/analytics
|
||||
├─ GET / # 전체 통합 대시보드
|
||||
├─ GET /channels # 채널별 성과 분석
|
||||
├─ GET /roi # ROI 상세 분석
|
||||
└─ GET /timeline # 시간대별 참여 추이
|
||||
```
|
||||
|
||||
### 4.2 기존 API와의 비교
|
||||
| 구분 | 기존 API | 신규 API |
|
||||
|------|----------|----------|
|
||||
| **기준** | eventId (개별 이벤트) | userId (사용자 전체) |
|
||||
| **범위** | 단일 이벤트 | 사용자의 모든 이벤트 통합 |
|
||||
| **엔드포인트** | `/api/v1/events/{eventId}/...` | `/api/v1/users/{userId}/...` |
|
||||
| **캐시 TTL** | 3600초 (60분) | 1800초 (30분) |
|
||||
| **데이터 집계** | 개별 이벤트 데이터 | 여러 이벤트 합산/평균 |
|
||||
|
||||
### 4.3 캐싱 전략
|
||||
- **캐시 키 형식**: `analytics:user:{category}:{userId}`
|
||||
- **TTL**: 30분 (1800초)
|
||||
- 여러 이벤트 통합으로 데이터 변동성이 높아 기존보다 짧게 설정
|
||||
- **갱신 방식**: `refresh=true` 파라미터로 강제 갱신 가능
|
||||
- **구현**: RedisTemplate + Jackson ObjectMapper
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 기능
|
||||
|
||||
### 5.1 데이터 집계 로직
|
||||
#### 5.1.1 통합 성과 계산
|
||||
- **참여자 수**: 모든 이벤트의 totalParticipants 합산
|
||||
- **조회수**: 모든 이벤트의 totalViews 합산
|
||||
- **참여율**: 전체 참여자 / 전체 조회수 * 100
|
||||
- **전환율**: 전체 전환 / 전체 참여자 * 100
|
||||
|
||||
#### 5.1.2 채널 성과 집계
|
||||
- **그룹화**: 채널명(channelName) 기준
|
||||
- **메트릭 합산**: views, participants, clicks, conversions
|
||||
- **비용 집계**: distributionCost 합산
|
||||
- **ROI 계산**: (참여자 - 비용) / 비용 * 100
|
||||
|
||||
#### 5.1.3 ROI 계산
|
||||
- **투자 금액**: 모든 이벤트의 totalInvestment 합산
|
||||
- **수익**: 모든 이벤트의 expectedRevenue 합산
|
||||
- **순이익**: 수익 - 투자
|
||||
- **ROI**: (순이익 / 투자) * 100
|
||||
|
||||
#### 5.1.4 시간대별 집계
|
||||
- **정규화**: interval에 따라 timestamp 정규화
|
||||
- hourly: 시간 단위로 truncate
|
||||
- daily: 일 단위로 truncate
|
||||
- weekly: 주 시작일로 정규화
|
||||
- monthly: 월 시작일로 정규화
|
||||
- **데이터 포인트 합산**: 동일 시간대의 participants, views, engagement, conversions 합산
|
||||
|
||||
### 5.2 추세 분석
|
||||
- **전반부/후반부 비교**: 데이터 포인트를 반으로 나누어 성장률 계산
|
||||
- **추세 결정**:
|
||||
- 성장률 > 5%: "increasing"
|
||||
- 성장률 < -5%: "decreasing"
|
||||
- -5% ≤ 성장률 ≤ 5%: "stable"
|
||||
|
||||
### 5.3 피크 시간 식별
|
||||
- **기준**: 참여자 수(participants) 최대 시점
|
||||
- **정보**: timestamp, metric, value, description
|
||||
|
||||
---
|
||||
|
||||
## 6. 아키텍처 특징
|
||||
|
||||
### 6.1 계층 구조
|
||||
```
|
||||
Controller
|
||||
↓
|
||||
Service (비즈니스 로직)
|
||||
↓
|
||||
Repository (데이터 접근)
|
||||
↓
|
||||
Entity (JPA)
|
||||
```
|
||||
|
||||
### 6.2 독립성 보장
|
||||
- **기존 eventId 기반 API와 독립적 구조**
|
||||
- **별도의 Controller, Service 클래스**
|
||||
- **공통 Repository 재사용**
|
||||
- **기존 DTO 구조 준수**
|
||||
|
||||
### 6.3 확장성
|
||||
- **새로운 메트릭 추가 용이**: Service 레이어에서 계산 로직 추가
|
||||
- **캐싱 전략 개별 조정 가능**: 각 Service마다 독립적인 캐시 키
|
||||
- **채널/이벤트 필터링 지원**: 동적 쿼리 지원
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 결과
|
||||
|
||||
### 7.1 컴파일 검증
|
||||
- ✅ **Service 계층**: 4개 클래스 컴파일 성공
|
||||
- ✅ **Controller 계층**: 4개 클래스 컴파일 성공
|
||||
- ✅ **Repository 계층**: 3개 인터페이스 컴파일 성공
|
||||
- ✅ **DTO 계층**: 4개 Response 클래스 컴파일 성공
|
||||
|
||||
### 7.2 코드 품질
|
||||
- ✅ **Lombok 활용**: Builder 패턴, Data 클래스
|
||||
- ✅ **로깅**: Slf4j 적용
|
||||
- ✅ **트랜잭션**: @Transactional(readOnly = true)
|
||||
- ✅ **예외 처리**: try-catch로 캐시 오류 대응
|
||||
- ✅ **타입 안정성**: BigDecimal로 금액 처리
|
||||
|
||||
### 7.3 Swagger 문서화
|
||||
- ✅ **@Tag**: API 그룹 정의
|
||||
- ✅ **@Operation**: 엔드포인트 설명
|
||||
- ✅ **@Parameter**: 파라미터 설명
|
||||
|
||||
---
|
||||
|
||||
## 8. 다음 단계
|
||||
|
||||
### 8.1 백엔드 개발 완료 항목
|
||||
- ✅ Repository 쿼리 메소드 추가
|
||||
- ✅ Response DTO 작성
|
||||
- ✅ Service 로직 구현
|
||||
- ✅ Controller API 개발
|
||||
- ✅ 컴파일 검증
|
||||
|
||||
### 8.2 향후 작업
|
||||
1. **백엔드 서버 실행 테스트** (Phase 1 완료 후)
|
||||
- 애플리케이션 실행 확인
|
||||
- API 엔드포인트 접근 테스트
|
||||
- Swagger UI 확인
|
||||
|
||||
2. **API 통합 테스트** (Phase 1 완료 후)
|
||||
- Postman/curl로 API 호출 테스트
|
||||
- 실제 데이터로 응답 검증
|
||||
- 에러 핸들링 확인
|
||||
|
||||
3. **프론트엔드 연동** (Phase 2)
|
||||
- 프론트엔드에서 4개 API 호출
|
||||
- 응답 데이터 바인딩
|
||||
- UI 렌더링 검증
|
||||
|
||||
---
|
||||
|
||||
## 9. 결론
|
||||
|
||||
### 9.1 성과
|
||||
- ✅ **userId 기반 통합 분석 API 4개 개발 완료**
|
||||
- ✅ **컴파일 성공**
|
||||
- ✅ **기존 구조와 독립적인 설계**
|
||||
- ✅ **확장 가능한 아키텍처**
|
||||
- ✅ **MVP 환경 1:1 관계 (1 user = 1 store) 적용**
|
||||
|
||||
### 9.2 특이사항
|
||||
- **기존 DTO 구조 재사용**: 새로운 DTO 생성 최소화
|
||||
- **BigDecimal 타입 사용**: 금액 정확도 보장
|
||||
- **캐싱 전략**: Redis 캐싱으로 성능 최적화 (TTL: 30분)
|
||||
|
||||
### 9.3 개발 시간
|
||||
- **예상 개발 기간**: 3~4일
|
||||
- **실제 개발 완료**: 1일 (컴파일 테스트까지)
|
||||
|
||||
---
|
||||
|
||||
## 10. 첨부
|
||||
|
||||
### 10.1 주요 파일 목록
|
||||
```
|
||||
analytics-service/src/main/java/com/kt/event/analytics/
|
||||
├── repository/
|
||||
│ ├── EventStatsRepository.java (수정)
|
||||
│ ├── ChannelStatsRepository.java (수정)
|
||||
│ └── TimelineDataRepository.java (수정)
|
||||
├── dto/response/
|
||||
│ ├── UserAnalyticsDashboardResponse.java (신규)
|
||||
│ ├── UserChannelAnalyticsResponse.java (신규)
|
||||
│ ├── UserRoiAnalyticsResponse.java (신규)
|
||||
│ └── UserTimelineAnalyticsResponse.java (신규)
|
||||
├── service/
|
||||
│ ├── UserAnalyticsService.java (신규)
|
||||
│ ├── UserChannelAnalyticsService.java (신규)
|
||||
│ ├── UserRoiAnalyticsService.java (신규)
|
||||
│ └── UserTimelineAnalyticsService.java (신규)
|
||||
└── controller/
|
||||
├── UserAnalyticsDashboardController.java (신규)
|
||||
├── UserChannelAnalyticsController.java (신규)
|
||||
├── UserRoiAnalyticsController.java (신규)
|
||||
└── UserTimelineAnalyticsController.java (신규)
|
||||
```
|
||||
|
||||
### 10.2 API 목록
|
||||
| No | HTTP Method | Endpoint | 설명 |
|
||||
|----|-------------|----------|------|
|
||||
| 1 | GET | `/api/v1/users/{userId}/analytics` | 사용자 전체 성과 대시보드 |
|
||||
| 2 | GET | `/api/v1/users/{userId}/analytics/channels` | 사용자 전체 채널별 성과 분석 |
|
||||
| 3 | GET | `/api/v1/users/{userId}/analytics/roi` | 사용자 전체 ROI 상세 분석 |
|
||||
| 4 | GET | `/api/v1/users/{userId}/analytics/timeline` | 사용자 전체 시간대별 참여 추이 |
|
||||
|
||||
---
|
||||
|
||||
**작성자**: AI Backend Developer
|
||||
**검토자**: -
|
||||
**승인자**: -
|
||||
**버전**: 1.0
|
||||
**최종 수정일**: 2025-10-28
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,187 @@
|
||||
# 백엔드 컨테이너 실행방법 가이드
|
||||
|
||||
[요청사항]
|
||||
- 백엔드 각 서비스들의 컨테이너 이미지를 컨테이너로 실행하는 가이드 작성
|
||||
- 실제 컨테이너 실행은 하지 않음
|
||||
- '[결과파일]'에 수행할 명령어를 포함하여 컨테이너 실행 가이드 생성
|
||||
|
||||
[작업순서]
|
||||
- 실행정보 확인
|
||||
프롬프트의 '[실행정보]'섹션에서 아래정보를 확인
|
||||
- {ACR명}: 컨테이너 레지스트리 이름
|
||||
- {VM.KEY파일}: VM 접속하는 Private Key파일 경로
|
||||
- {VM.USERID}: VM 접속하는 OS 유저명
|
||||
- {VM.IP}: VM IP
|
||||
예시)
|
||||
```
|
||||
[실행정보]
|
||||
- ACR명: acrdigitalgarage01
|
||||
- VM
|
||||
- KEY파일: ~/home/bastion-dg0500
|
||||
- USERID: azureuser
|
||||
- IP: 4.230.5.6
|
||||
```
|
||||
|
||||
- 시스템명과 서비스명 확인
|
||||
settings.gradle에서 확인.
|
||||
- 시스템명: rootProject.name
|
||||
- 서비스명: include 'common'하위의 include문 뒤의 값임
|
||||
|
||||
예시) include 'common'하위의 4개가 서비스명임.
|
||||
```
|
||||
rootProject.name = 'tripgen'
|
||||
|
||||
include 'common'
|
||||
include 'user-service'
|
||||
include 'location-service'
|
||||
include 'ai-service'
|
||||
include 'trip-service'
|
||||
```
|
||||
|
||||
- VM 접속 방법 안내
|
||||
- Linux/Mac은 기본 터미널을 실행하고 Window는 Window Terminal을 실행하도록 안내
|
||||
- 터미널에서 아래 명령으로 VM에 접속하도록 안내
|
||||
최초 한번 Private key파일의 모드를 변경.
|
||||
```
|
||||
chmod 400 {VM.KEY파일}
|
||||
```
|
||||
|
||||
private key를 이용하여 접속.
|
||||
```
|
||||
ssh -i {VM.KEY파일} {VM.USERID}@{VM.IP}
|
||||
```
|
||||
- 접속 후 docker login 방법 안내
|
||||
```
|
||||
docker login {ACR명}.azurecr.io -u {ID} -p {암호}
|
||||
```
|
||||
|
||||
- Git Repository 클론 안내
|
||||
- workspace 디렉토리 생성 및 이동
|
||||
```
|
||||
mkdir -p ~/home/workspace
|
||||
cd ~/home/workspace
|
||||
```
|
||||
- 소스 Clone
|
||||
```
|
||||
git clone {원격 Git Repository 주소}
|
||||
```
|
||||
예)
|
||||
```
|
||||
git clone https://github.com/cna-bootcamp/phonebill.git
|
||||
```
|
||||
- 프로젝트 디렉토리로 이동
|
||||
```
|
||||
cd {시스템명}
|
||||
```
|
||||
|
||||
- 어플리케이션 빌드 및 컨테이너 이미지 생성 방법 안내
|
||||
'deployment/container/build-image.md' 파일을 열어 가이드대로 수행하도록 안내
|
||||
|
||||
- 컨테이너 레지스트리 로그인 방법 안내
|
||||
아래 명령으로 {ACR명}의 인증정보를 구합니다.
|
||||
'username'이 ID이고 'passwords[0].value'가 암호임.
|
||||
```
|
||||
az acr credential show --name {ACR명}
|
||||
```
|
||||
|
||||
예시) ID=dg0200cr, 암호={암호}
|
||||
```
|
||||
$ az acr credential show --name dg0200cr
|
||||
{
|
||||
"passwords": [
|
||||
{
|
||||
"name": "password",
|
||||
"value": "{암호}"
|
||||
},
|
||||
{
|
||||
"name": "password2",
|
||||
"value": "{암호2}"
|
||||
}
|
||||
],
|
||||
"username": "dg0200cr"
|
||||
}
|
||||
```
|
||||
|
||||
아래와 같이 로그인 명령을 작성합니다.
|
||||
```
|
||||
docker login {ACR명}.azurecr.io -u {ID} -p {암호}
|
||||
```
|
||||
|
||||
- 컨테이너 푸시 방법 안내
|
||||
Docker Tag 명령으로 이미지를 tag하는 명령을 작성합니다.
|
||||
```
|
||||
docker tag {서비스명}:latest {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest
|
||||
```
|
||||
이미지 푸시 명령을 작성합니다.
|
||||
```
|
||||
docker push {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest
|
||||
```
|
||||
|
||||
- 컨테이너 실행 명령 생성
|
||||
- 환경변수 확인
|
||||
'{서비스명}/.run/{서비스명}.run.xml' 을 읽어 각 서비스의 환경변수 찾음.
|
||||
"env.map"의 각 entry의 key와 value가 환경변수임.
|
||||
|
||||
예제) SERVER_PORT=8081, DB_HOST=20.249.137.175가 환경변수임
|
||||
```
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="ai-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="env">
|
||||
<map>
|
||||
<entry key="SERVER_PORT" value="8084" />
|
||||
<entry key="DB_HOST" value="20.249.137.175" />
|
||||
```
|
||||
|
||||
- 아래 명령으로 컨테이너를 실행하는 명령을 생성합니다.
|
||||
- shell 파일을 만들지 말고 command로 수행하는 방법 안내.
|
||||
- 모든 환경변수에 대해 '-e' 파라미터로 환경변수값을 넘깁니다.
|
||||
- 중요) CORS 설정 환경변수에 프론트엔드 주소 추가
|
||||
- 'ALLOWED_ORIGINS' 포함된 환경변수가 CORS 설정 환경변수임.
|
||||
- 이 환경변수의 값에 'http://{VM.IP}:3000'번 추가
|
||||
|
||||
```
|
||||
SERVER_PORT={환경변수의 SERVER_PORT값}
|
||||
|
||||
docker run -d --name {서비스명} --rm -p ${SERVER_PORT}:${SERVER_PORT} \
|
||||
-e {환경변수 KEY}={환경변수 VALUE}
|
||||
{ACR명}.azurecr.io/{시스템명}/{서비스명}:latest
|
||||
```
|
||||
|
||||
- 실행된 컨테이너 확인 방법 작성
|
||||
아래 명령으로 모든 서비스의 컨테이너가 실행 되었는지 확인하는 방법을 안내.
|
||||
```
|
||||
docker ps | grep {서비스명}
|
||||
```
|
||||
- 재배포 방법 작성
|
||||
- 로컬에서 수정된 소스 푸시
|
||||
- VM 접속
|
||||
- 디렉토리 이동 및 소스 내려받기
|
||||
```
|
||||
cd ~/home/workspace/{시스템명}
|
||||
```
|
||||
|
||||
```
|
||||
git pull
|
||||
```
|
||||
- 컨테이너 이미지 재생성
|
||||
'deployment/container/build-image.md' 파일을 열어 가이드대로 수행
|
||||
|
||||
- 컨테이너 이미지 푸시
|
||||
```
|
||||
docker tag {서비스명}:latest {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest
|
||||
docker push {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest
|
||||
```
|
||||
- 컨테이너 중지
|
||||
```
|
||||
docker stop {서비스명}
|
||||
```
|
||||
- 컨테이너 이미지 삭제
|
||||
```
|
||||
docker rmi {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest
|
||||
```
|
||||
- 컨테이너 재실행
|
||||
|
||||
[결과파일]
|
||||
deployment/container/run-container-guide.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'에 생성
|
||||
|
||||
```
|
||||
|
||||
@@ -32,4 +32,7 @@ dependencies {
|
||||
// Jackson for JSON
|
||||
api 'com.fasterxml.jackson.core:jackson-databind'
|
||||
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
// Swagger/OpenAPI
|
||||
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
|
||||
}
|
||||
|
||||
@@ -171,7 +171,11 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleDataIntegrityViolationException(DataIntegrityViolationException ex) {
|
||||
log.warn("Data integrity violation: {}", ex.getMessage());
|
||||
log.error("=== DataIntegrityViolationException 발생 ===");
|
||||
log.error("Exception type: {}", ex.getClass().getSimpleName());
|
||||
log.error("Exception message: {}", ex.getMessage());
|
||||
log.error("Root cause: {}", ex.getRootCause() != null ? ex.getRootCause().getMessage() : "null");
|
||||
log.error("Stack trace: ", ex);
|
||||
|
||||
String message = "데이터 중복 또는 무결성 제약 위반이 발생했습니다";
|
||||
String details = ex.getMessage();
|
||||
|
||||
@@ -113,9 +113,9 @@ public class JwtTokenProvider {
|
||||
public UserPrincipal getUserPrincipalFromToken(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
|
||||
Long userId = Long.parseLong(claims.getSubject());
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
String storeIdStr = claims.get("storeId", String.class);
|
||||
Long storeId = storeIdStr != null ? Long.parseLong(storeIdStr) : null;
|
||||
UUID storeId = storeIdStr != null ? UUID.fromString(storeIdStr) : null;
|
||||
String email = claims.get("email", String.class);
|
||||
String name = claims.get("name", String.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@@ -24,12 +24,12 @@ public class UserPrincipal implements UserDetails {
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private final Long userId;
|
||||
private final UUID userId;
|
||||
|
||||
/**
|
||||
* 매장 ID
|
||||
*/
|
||||
private final Long storeId;
|
||||
private final UUID storeId;
|
||||
|
||||
/**
|
||||
* 사용자 이메일
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: content-service
|
||||
namespace: kt-event-marketing
|
||||
labels:
|
||||
app: content-service
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: content-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: content-service
|
||||
spec:
|
||||
containers:
|
||||
- name: content-service
|
||||
image: acrdigitalgarage01.azurecr.io/content-service:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8084
|
||||
name: http
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: cm-common
|
||||
- configMapRef:
|
||||
name: cm-content-service
|
||||
- secretRef:
|
||||
name: secret-common
|
||||
- secretRef:
|
||||
name: secret-content-service
|
||||
resources:
|
||||
requests:
|
||||
cpu: 256m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1024m
|
||||
memory: 1024Mi
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/content/actuator/health
|
||||
port: 8084
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/content/actuator/health/liveness
|
||||
port: 8084
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/content/actuator/health/readiness
|
||||
port: 8084
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
failureThreshold: 3
|
||||
imagePullSecrets:
|
||||
- name: kt-event-marketing
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: content-service
|
||||
namespace: kt-event-marketing
|
||||
labels:
|
||||
app: content-service
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8084
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: content-service
|
||||
@@ -0,0 +1,24 @@
|
||||
# Multi-stage build for Spring Boot application
|
||||
FROM eclipse-temurin:21-jre-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=layertools -jar app.jar extract
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -S spring && adduser -S spring -G spring
|
||||
USER spring:spring
|
||||
|
||||
# Copy layers from builder
|
||||
COPY --from=builder /app/dependencies/ ./
|
||||
COPY --from=builder /app/spring-boot-loader/ ./
|
||||
COPY --from=builder /app/snapshot-dependencies/ ./
|
||||
COPY --from=builder /app/application/ ./
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8084/actuator/health || exit 1
|
||||
|
||||
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
|
||||
@@ -2,9 +2,13 @@ configurations {
|
||||
// Exclude JPA and PostgreSQL from inherited dependencies (Phase 3: Redis migration)
|
||||
implementation.exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa'
|
||||
implementation.exclude group: 'org.postgresql', module: 'postgresql'
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
|
||||
// Redis for AI data reading and image URL caching
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ public class Content {
|
||||
private final Long id;
|
||||
|
||||
/**
|
||||
* 이벤트 ID (이벤트 초안 ID)
|
||||
* 이벤트 ID
|
||||
*/
|
||||
private final Long eventDraftId;
|
||||
private final String eventId;
|
||||
|
||||
/**
|
||||
* 이벤트 제목
|
||||
|
||||
@@ -21,9 +21,9 @@ public class GeneratedImage {
|
||||
private final Long id;
|
||||
|
||||
/**
|
||||
* 이벤트 ID (이벤트 초안 ID)
|
||||
* 이벤트 ID
|
||||
*/
|
||||
private final Long eventDraftId;
|
||||
private final String eventId;
|
||||
|
||||
/**
|
||||
* 이미지 스타일
|
||||
|
||||
@@ -31,9 +31,9 @@ public class Job {
|
||||
private final String id;
|
||||
|
||||
/**
|
||||
* 이벤트 ID (이벤트 초안 ID)
|
||||
* 이벤트 ID
|
||||
*/
|
||||
private final Long eventDraftId;
|
||||
private final String eventId;
|
||||
|
||||
/**
|
||||
* Job 타입 (image-generation)
|
||||
|
||||
@@ -20,7 +20,7 @@ public class ContentCommand {
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
public static class GenerateImages {
|
||||
private Long eventDraftId;
|
||||
private String eventId;
|
||||
private String eventTitle;
|
||||
private String eventDescription;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.util.stream.Collectors;
|
||||
public class ContentInfo {
|
||||
|
||||
private Long id;
|
||||
private Long eventDraftId;
|
||||
private String eventId;
|
||||
private String eventTitle;
|
||||
private String eventDescription;
|
||||
private List<ImageInfo> images;
|
||||
@@ -34,7 +34,7 @@ public class ContentInfo {
|
||||
public static ContentInfo from(Content content) {
|
||||
return ContentInfo.builder()
|
||||
.id(content.getId())
|
||||
.eventDraftId(content.getEventDraftId())
|
||||
.eventId(content.getEventId())
|
||||
.eventTitle(content.getEventTitle())
|
||||
.eventDescription(content.getEventDescription())
|
||||
.images(content.getImages().stream()
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.time.LocalDateTime;
|
||||
public class ImageInfo {
|
||||
|
||||
private Long id;
|
||||
private Long eventDraftId;
|
||||
private String eventId;
|
||||
private ImageStyle style;
|
||||
private Platform platform;
|
||||
private String cdnUrl;
|
||||
@@ -36,7 +36,7 @@ public class ImageInfo {
|
||||
public static ImageInfo from(GeneratedImage image) {
|
||||
return ImageInfo.builder()
|
||||
.id(image.getId())
|
||||
.eventDraftId(image.getEventDraftId())
|
||||
.eventId(image.getEventId())
|
||||
.style(image.getStyle())
|
||||
.platform(image.getPlatform())
|
||||
.cdnUrl(image.getCdnUrl())
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.time.LocalDateTime;
|
||||
public class JobInfo {
|
||||
|
||||
private String id;
|
||||
private Long eventDraftId;
|
||||
private String eventId;
|
||||
private String jobType;
|
||||
private Job.Status status;
|
||||
private int progress;
|
||||
@@ -34,7 +34,7 @@ public class JobInfo {
|
||||
public static JobInfo from(Job job) {
|
||||
return JobInfo.builder()
|
||||
.id(job.getId())
|
||||
.eventDraftId(job.getEventDraftId())
|
||||
.eventId(job.getEventId())
|
||||
.jobType(job.getJobType())
|
||||
.status(job.getStatus())
|
||||
.progress(job.getProgress())
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.util.Map;
|
||||
/**
|
||||
* AI Service가 Redis에 저장한 이벤트 데이터 (읽기 전용)
|
||||
*
|
||||
* Key Pattern: ai:event:{eventDraftId}
|
||||
* Key Pattern: ai:event:{eventId}
|
||||
* Data Type: Hash
|
||||
* TTL: 24시간 (86400초)
|
||||
*
|
||||
@@ -25,9 +25,9 @@ import java.util.Map;
|
||||
@AllArgsConstructor
|
||||
public class RedisAIEventData {
|
||||
/**
|
||||
* 이벤트 초안 ID
|
||||
* 이벤트 ID
|
||||
*/
|
||||
private Long eventDraftId;
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* 이벤트 제목
|
||||
|
||||
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
|
||||
/**
|
||||
* Redis에 저장되는 이미지 데이터 구조
|
||||
*
|
||||
* Key Pattern: content:image:{eventDraftId}:{style}:{platform}
|
||||
* Key Pattern: content:image:{eventId}:{style}:{platform}
|
||||
* Data Type: String (JSON)
|
||||
* TTL: 7일 (604800초)
|
||||
*
|
||||
@@ -31,9 +31,9 @@ public class RedisImageData {
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 이벤트 초안 ID
|
||||
* 이벤트 ID
|
||||
*/
|
||||
private Long eventDraftId;
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* 이미지 스타일 (FANCY, SIMPLE, TRENDY)
|
||||
|
||||
@@ -29,9 +29,9 @@ public class RedisJobData {
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 이벤트 초안 ID
|
||||
* 이벤트 ID
|
||||
*/
|
||||
private Long eventDraftId;
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* Job 타입 (image-generation, image-regeneration)
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ public class GetEventContentService implements GetEventContentUseCase {
|
||||
private final ContentReader contentReader;
|
||||
|
||||
@Override
|
||||
public ContentInfo execute(Long eventDraftId) {
|
||||
Content content = contentReader.findByEventDraftIdWithImages(eventDraftId)
|
||||
public ContentInfo execute(String eventId) {
|
||||
Content content = contentReader.findByEventDraftIdWithImages(eventId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "콘텐츠를 찾을 수 없습니다"));
|
||||
|
||||
return ContentInfo.from(content);
|
||||
|
||||
+3
-3
@@ -26,10 +26,10 @@ public class GetImageListService implements GetImageListUseCase {
|
||||
private final ContentReader contentReader;
|
||||
|
||||
@Override
|
||||
public List<ImageInfo> execute(Long eventDraftId, ImageStyle style, Platform platform) {
|
||||
log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform);
|
||||
public List<ImageInfo> execute(String eventId, ImageStyle style, Platform platform) {
|
||||
log.info("이미지 목록 조회: eventId={}, style={}, platform={}", eventId, style, platform);
|
||||
|
||||
List<GeneratedImage> images = contentReader.findImagesByEventDraftId(eventDraftId);
|
||||
List<GeneratedImage> images = contentReader.findImagesByEventDraftId(eventId);
|
||||
|
||||
// 필터링 적용
|
||||
return images.stream()
|
||||
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
package com.kt.event.content.biz.service;
|
||||
|
||||
import com.kt.event.content.biz.domain.Content;
|
||||
import com.kt.event.content.biz.domain.GeneratedImage;
|
||||
import com.kt.event.content.biz.domain.ImageStyle;
|
||||
import com.kt.event.content.biz.domain.Job;
|
||||
import com.kt.event.content.biz.domain.Platform;
|
||||
import com.kt.event.content.biz.dto.ContentCommand;
|
||||
import com.kt.event.content.biz.dto.JobInfo;
|
||||
import com.kt.event.content.biz.dto.RedisJobData;
|
||||
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
|
||||
import com.kt.event.content.biz.usecase.out.CDNUploader;
|
||||
import com.kt.event.content.biz.usecase.out.ContentWriter;
|
||||
import com.kt.event.content.biz.usecase.out.JobWriter;
|
||||
import com.kt.event.content.infra.gateway.client.HuggingFaceApiClient;
|
||||
import com.kt.event.content.infra.gateway.client.dto.HuggingFaceRequest;
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Hugging Face Inference API 이미지 생성 서비스
|
||||
*
|
||||
* Hugging Face Inference API를 사용하여 Stable Diffusion으로 이미지 생성 (무료)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile({"prod", "dev"}) // production 및 dev 환경에서 활성화 (local은 Mock 사용)
|
||||
public class HuggingFaceImageGenerator implements GenerateImagesUseCase {
|
||||
|
||||
private final HuggingFaceApiClient huggingFaceClient;
|
||||
private final CDNUploader cdnUploader;
|
||||
private final JobWriter jobWriter;
|
||||
private final ContentWriter contentWriter;
|
||||
private final CircuitBreaker circuitBreaker;
|
||||
|
||||
public HuggingFaceImageGenerator(
|
||||
HuggingFaceApiClient huggingFaceClient,
|
||||
CDNUploader cdnUploader,
|
||||
JobWriter jobWriter,
|
||||
ContentWriter contentWriter,
|
||||
@Qualifier("huggingfaceCircuitBreaker") CircuitBreaker circuitBreaker) {
|
||||
this.huggingFaceClient = huggingFaceClient;
|
||||
this.cdnUploader = cdnUploader;
|
||||
this.jobWriter = jobWriter;
|
||||
this.contentWriter = contentWriter;
|
||||
this.circuitBreaker = circuitBreaker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JobInfo execute(ContentCommand.GenerateImages command) {
|
||||
log.info("Hugging Face 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
|
||||
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
|
||||
|
||||
// Job 생성
|
||||
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Job job = Job.builder()
|
||||
.id(jobId)
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.jobType("image-generation")
|
||||
.status(Job.Status.PENDING)
|
||||
.progress(0)
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Job 저장
|
||||
RedisJobData jobData = RedisJobData.builder()
|
||||
.id(job.getId())
|
||||
.eventDraftId(job.getEventDraftId())
|
||||
.jobType(job.getJobType())
|
||||
.status(job.getStatus().name())
|
||||
.progress(job.getProgress())
|
||||
.createdAt(job.getCreatedAt())
|
||||
.updatedAt(job.getUpdatedAt())
|
||||
.build();
|
||||
|
||||
jobWriter.saveJob(jobData, 3600); // TTL 1시간
|
||||
log.info("Job 생성 완료: jobId={}", jobId);
|
||||
|
||||
// 비동기로 이미지 생성
|
||||
processImageGeneration(jobId, command);
|
||||
|
||||
return JobInfo.from(job);
|
||||
}
|
||||
|
||||
@Async
|
||||
private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
|
||||
try {
|
||||
log.info("Hugging Face 이미지 생성 시작: jobId={}", jobId);
|
||||
|
||||
// Content 생성 또는 조회
|
||||
Content content = Content.builder()
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.eventTitle(command.getEventDraftId() + " 이벤트")
|
||||
.eventDescription("AI 생성 이벤트 이미지")
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
Content savedContent = contentWriter.save(content);
|
||||
log.info("Content 생성 완료: contentId={}", savedContent.getId());
|
||||
|
||||
// 스타일 x 플랫폼 조합으로 이미지 생성
|
||||
List<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
|
||||
? command.getStyles()
|
||||
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
|
||||
|
||||
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
|
||||
? command.getPlatforms()
|
||||
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
|
||||
|
||||
List<GeneratedImage> images = new ArrayList<>();
|
||||
int totalCount = styles.size() * platforms.size();
|
||||
int currentCount = 0;
|
||||
|
||||
for (ImageStyle style : styles) {
|
||||
for (Platform platform : platforms) {
|
||||
currentCount++;
|
||||
|
||||
// 진행률 업데이트
|
||||
int progress = (currentCount * 100) / totalCount;
|
||||
jobWriter.updateJobStatus(jobId, "IN_PROGRESS", progress);
|
||||
|
||||
// Hugging Face로 이미지 생성
|
||||
String prompt = buildPrompt(command, style, platform);
|
||||
String imageUrl = generateImage(prompt, platform);
|
||||
|
||||
// GeneratedImage 저장
|
||||
GeneratedImage image = GeneratedImage.builder()
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.style(style)
|
||||
.platform(platform)
|
||||
.cdnUrl(imageUrl)
|
||||
.prompt(prompt)
|
||||
.selected(currentCount == 1) // 첫 번째 이미지를 선택
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
if (currentCount == 1) {
|
||||
image.select();
|
||||
}
|
||||
|
||||
GeneratedImage savedImage = contentWriter.saveImage(image);
|
||||
images.add(savedImage);
|
||||
log.info("이미지 생성 완료: imageId={}, style={}, platform={}, url={}",
|
||||
savedImage.getId(), style, platform, imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Job 완료
|
||||
String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
|
||||
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
|
||||
jobWriter.updateJobResult(jobId, resultMessage);
|
||||
log.info("Hugging Face Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Hugging Face 이미지 생성 실패: jobId={}", jobId, e);
|
||||
jobWriter.updateJobError(jobId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hugging Face로 이미지 생성
|
||||
*
|
||||
* @param prompt 이미지 생성 프롬프트
|
||||
* @param platform 플랫폼 (이미지 크기 결정)
|
||||
* @return 생성된 이미지 URL
|
||||
*/
|
||||
private String generateImage(String prompt, Platform platform) {
|
||||
try {
|
||||
// 플랫폼별 이미지 크기 설정
|
||||
int width = platform.getWidth();
|
||||
int height = platform.getHeight();
|
||||
|
||||
// Hugging Face API 요청
|
||||
HuggingFaceRequest request = HuggingFaceRequest.builder()
|
||||
.inputs(prompt)
|
||||
.parameters(HuggingFaceRequest.Parameters.builder()
|
||||
.negative_prompt("blurry, bad quality, distorted, ugly, low resolution")
|
||||
.width(width)
|
||||
.height(height)
|
||||
.guidance_scale(7.5)
|
||||
.num_inference_steps(50)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
log.info("Hugging Face API 호출: prompt={}, size={}x{}", prompt, width, height);
|
||||
|
||||
// 이미지 생성 (동기 방식)
|
||||
byte[] imageData = generateImageWithCircuitBreaker(request);
|
||||
log.info("Hugging Face 이미지 생성 완료: size={} bytes", imageData.length);
|
||||
|
||||
// Azure Blob Storage에 업로드
|
||||
String fileName = String.format("event-%s-%s-%s.png",
|
||||
platform.name().toLowerCase(),
|
||||
UUID.randomUUID().toString().substring(0, 8),
|
||||
System.currentTimeMillis());
|
||||
String azureCdnUrl = cdnUploader.upload(imageData, fileName);
|
||||
log.info("Azure CDN 업로드 완료: fileName={}, url={}", fileName, azureCdnUrl);
|
||||
|
||||
return azureCdnUrl;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Hugging Face 이미지 생성 실패: prompt={}", prompt, e);
|
||||
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 생성 프롬프트 구성
|
||||
*/
|
||||
private String buildPrompt(ContentCommand.GenerateImages command, ImageStyle style, Platform platform) {
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
|
||||
// 업종 정보 추가
|
||||
if (command.getIndustry() != null && !command.getIndustry().trim().isEmpty()) {
|
||||
prompt.append(command.getIndustry()).append(" ");
|
||||
}
|
||||
|
||||
// 기본 프롬프트
|
||||
prompt.append("event promotion image");
|
||||
|
||||
// 지역 정보 추가
|
||||
if (command.getLocation() != null && !command.getLocation().trim().isEmpty()) {
|
||||
prompt.append(" in ").append(command.getLocation());
|
||||
}
|
||||
|
||||
// 트렌드 키워드 추가 (최대 3개)
|
||||
if (command.getTrends() != null && !command.getTrends().isEmpty()) {
|
||||
prompt.append(", featuring ");
|
||||
int count = Math.min(3, command.getTrends().size());
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i > 0) prompt.append(", ");
|
||||
prompt.append(command.getTrends().get(i));
|
||||
}
|
||||
}
|
||||
|
||||
prompt.append(", ");
|
||||
|
||||
// 스타일별 프롬프트
|
||||
switch (style) {
|
||||
case FANCY:
|
||||
prompt.append("elegant, luxurious, premium design, vibrant colors, ");
|
||||
break;
|
||||
case SIMPLE:
|
||||
prompt.append("minimalist, clean design, simple layout, modern, ");
|
||||
break;
|
||||
case TRENDY:
|
||||
prompt.append("trendy, contemporary, stylish, modern design, ");
|
||||
break;
|
||||
}
|
||||
|
||||
// 플랫폼별 특성 추가
|
||||
prompt.append("optimized for ").append(platform.name().toLowerCase()).append(" platform, ");
|
||||
prompt.append("high quality, detailed, 4k resolution");
|
||||
|
||||
return prompt.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker로 보호된 Hugging Face 이미지 생성
|
||||
*
|
||||
* @param request Hugging Face 요청
|
||||
* @return 생성된 이미지 바이트 데이터
|
||||
*/
|
||||
private byte[] generateImageWithCircuitBreaker(HuggingFaceRequest request) {
|
||||
try {
|
||||
return circuitBreaker.executeSupplier(() -> huggingFaceClient.generateImage(request));
|
||||
} catch (CallNotPermittedException e) {
|
||||
log.error("Hugging Face Circuit Breaker가 OPEN 상태입니다. 이미지 생성 차단");
|
||||
throw new RuntimeException("Hugging Face API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
|
||||
} catch (Exception e) {
|
||||
log.error("Hugging Face 이미지 생성 실패", e);
|
||||
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -32,7 +32,7 @@ public class JobManagementService implements GetJobStatusUseCase {
|
||||
// RedisJobData를 Job 도메인 객체로 변환
|
||||
Job job = Job.builder()
|
||||
.id(jobData.getId())
|
||||
.eventDraftId(jobData.getEventDraftId())
|
||||
.eventId(jobData.getEventId())
|
||||
.jobType(jobData.getJobType())
|
||||
.status(Job.Status.valueOf(jobData.getStatus()))
|
||||
.progress(jobData.getProgress())
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
package com.kt.event.content.biz.service;
|
||||
|
||||
import com.kt.event.content.biz.domain.GeneratedImage;
|
||||
import com.kt.event.content.biz.domain.Job;
|
||||
import com.kt.event.content.biz.dto.ContentCommand;
|
||||
import com.kt.event.content.biz.dto.JobInfo;
|
||||
import com.kt.event.content.biz.dto.RedisJobData;
|
||||
import com.kt.event.content.biz.usecase.in.RegenerateImageUseCase;
|
||||
import com.kt.event.content.biz.usecase.out.CDNUploader;
|
||||
import com.kt.event.content.biz.usecase.out.ContentWriter;
|
||||
import com.kt.event.content.biz.usecase.out.JobWriter;
|
||||
import com.kt.event.content.infra.gateway.client.ReplicateApiClient;
|
||||
import com.kt.event.content.infra.gateway.client.dto.ReplicateRequest;
|
||||
import com.kt.event.content.infra.gateway.client.dto.ReplicateResponse;
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이미지 재생성 서비스
|
||||
*
|
||||
* Stable Diffusion으로 기존 이미지를 새 프롬프트로 재생성
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RegenerateImageService implements RegenerateImageUseCase {
|
||||
|
||||
private final ReplicateApiClient replicateClient;
|
||||
private final CDNUploader cdnUploader;
|
||||
private final JobWriter jobWriter;
|
||||
private final ContentWriter contentWriter;
|
||||
private final CircuitBreaker circuitBreaker;
|
||||
|
||||
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
|
||||
private String modelVersion;
|
||||
|
||||
public RegenerateImageService(
|
||||
ReplicateApiClient replicateClient,
|
||||
CDNUploader cdnUploader,
|
||||
JobWriter jobWriter,
|
||||
ContentWriter contentWriter,
|
||||
@Qualifier("replicateCircuitBreaker") CircuitBreaker circuitBreaker) {
|
||||
this.replicateClient = replicateClient;
|
||||
this.cdnUploader = cdnUploader;
|
||||
this.jobWriter = jobWriter;
|
||||
this.contentWriter = contentWriter;
|
||||
this.circuitBreaker = circuitBreaker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JobInfo execute(ContentCommand.RegenerateImage command) {
|
||||
log.info("이미지 재생성 요청: imageId={}, newPrompt={}",
|
||||
command.getImageId(), command.getNewPrompt());
|
||||
|
||||
// Job 생성
|
||||
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Job job = Job.builder()
|
||||
.id(jobId)
|
||||
.eventId("regenerate-" + command.getImageId())
|
||||
.jobType("image-regeneration")
|
||||
.status(Job.Status.PENDING)
|
||||
.progress(0)
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Job 저장
|
||||
RedisJobData jobData = RedisJobData.builder()
|
||||
.id(job.getId())
|
||||
.eventId(job.getEventId())
|
||||
.jobType(job.getJobType())
|
||||
.status(job.getStatus().name())
|
||||
.progress(job.getProgress())
|
||||
.createdAt(job.getCreatedAt())
|
||||
.updatedAt(job.getUpdatedAt())
|
||||
.build();
|
||||
|
||||
jobWriter.saveJob(jobData, 3600); // TTL 1시간
|
||||
log.info("재생성 Job 생성 완료: jobId={}", jobId);
|
||||
|
||||
// 비동기로 이미지 재생성
|
||||
processImageRegeneration(jobId, command);
|
||||
|
||||
return JobInfo.from(job);
|
||||
}
|
||||
|
||||
@Async
|
||||
private void processImageRegeneration(String jobId, ContentCommand.RegenerateImage command) {
|
||||
try {
|
||||
log.info("이미지 재생성 시작: jobId={}, imageId={}", jobId, command.getImageId());
|
||||
|
||||
// 기존 이미지 조회
|
||||
GeneratedImage existingImage = contentWriter.getImageById(command.getImageId());
|
||||
if (existingImage == null) {
|
||||
throw new RuntimeException("이미지를 찾을 수 없습니다: imageId=" + command.getImageId());
|
||||
}
|
||||
|
||||
jobWriter.updateJobStatus(jobId, "IN_PROGRESS", 30);
|
||||
|
||||
// 새 프롬프트로 이미지 생성
|
||||
String newPrompt = command.getNewPrompt() != null && !command.getNewPrompt().trim().isEmpty()
|
||||
? command.getNewPrompt()
|
||||
: existingImage.getPrompt();
|
||||
|
||||
String imageUrl = generateImage(newPrompt, existingImage.getPlatform());
|
||||
|
||||
jobWriter.updateJobStatus(jobId, "IN_PROGRESS", 80);
|
||||
|
||||
// 기존 이미지를 기반으로 새 이미지 생성
|
||||
GeneratedImage updatedImage = GeneratedImage.builder()
|
||||
.id(existingImage.getId())
|
||||
.eventId(existingImage.getEventId())
|
||||
.style(existingImage.getStyle())
|
||||
.platform(existingImage.getPlatform())
|
||||
.cdnUrl(imageUrl) // 새 URL
|
||||
.prompt(newPrompt) // 새 프롬프트
|
||||
.selected(existingImage.isSelected())
|
||||
.createdAt(existingImage.getCreatedAt())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
contentWriter.saveImage(updatedImage);
|
||||
|
||||
log.info("이미지 재생성 완료: imageId={}, url={}", command.getImageId(), imageUrl);
|
||||
|
||||
// Job 완료
|
||||
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
|
||||
jobWriter.updateJobResult(jobId, "이미지가 성공적으로 재생성되었습니다.");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("이미지 재생성 실패: jobId={}", jobId, e);
|
||||
jobWriter.updateJobError(jobId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable Diffusion으로 이미지 생성
|
||||
*/
|
||||
private String generateImage(String prompt, com.kt.event.content.biz.domain.Platform platform) {
|
||||
try {
|
||||
int width = platform.getWidth();
|
||||
int height = platform.getHeight();
|
||||
|
||||
// Replicate API 요청
|
||||
ReplicateRequest request = ReplicateRequest.builder()
|
||||
.version(modelVersion)
|
||||
.input(ReplicateRequest.Input.builder()
|
||||
.prompt(prompt)
|
||||
.negativePrompt("blurry, bad quality, distorted, ugly, low resolution")
|
||||
.width(width)
|
||||
.height(height)
|
||||
.numOutputs(1)
|
||||
.guidanceScale(7.5)
|
||||
.numInferenceSteps(50)
|
||||
.seed(System.currentTimeMillis())
|
||||
.build())
|
||||
.build();
|
||||
|
||||
log.info("Replicate API 호출: prompt={}, size={}x{}", prompt, width, height);
|
||||
ReplicateResponse response = createPredictionWithCircuitBreaker(request);
|
||||
String predictionId = response.getId();
|
||||
|
||||
// 이미지 생성 완료까지 대기
|
||||
String replicateUrl = waitForCompletion(predictionId);
|
||||
log.info("이미지 생성 완료: url={}", replicateUrl);
|
||||
|
||||
// 이미지 다운로드
|
||||
byte[] imageData = downloadImage(replicateUrl);
|
||||
|
||||
// Azure Blob Storage에 업로드
|
||||
String fileName = String.format("regenerate-%s-%s.png",
|
||||
predictionId.substring(0, 8),
|
||||
System.currentTimeMillis());
|
||||
String azureCdnUrl = cdnUploader.upload(imageData, fileName);
|
||||
|
||||
return azureCdnUrl;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("이미지 생성 실패: prompt={}", prompt, e);
|
||||
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicate API 예측 완료 대기
|
||||
*/
|
||||
private String waitForCompletion(String predictionId) throws InterruptedException {
|
||||
int maxRetries = 60;
|
||||
int retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
ReplicateResponse response = getPredictionWithCircuitBreaker(predictionId);
|
||||
String status = response.getStatus();
|
||||
|
||||
if ("succeeded".equals(status)) {
|
||||
List<String> output = response.getOutput();
|
||||
if (output != null && !output.isEmpty()) {
|
||||
return output.get(0);
|
||||
}
|
||||
throw new RuntimeException("이미지 URL이 없습니다");
|
||||
} else if ("failed".equals(status) || "canceled".equals(status)) {
|
||||
String error = response.getError() != null ? response.getError() : "알 수 없는 오류";
|
||||
throw new RuntimeException("이미지 생성 실패: " + error);
|
||||
}
|
||||
|
||||
Thread.sleep(5000);
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
throw new RuntimeException("이미지 생성 타임아웃 (5분 초과)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 다운로드
|
||||
*/
|
||||
private byte[] downloadImage(String imageUrl) throws Exception {
|
||||
URL url = new URL(imageUrl);
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(30000);
|
||||
connection.setReadTimeout(30000);
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw new RuntimeException("이미지 다운로드 실패: HTTP " + responseCode);
|
||||
}
|
||||
|
||||
try (InputStream inputStream = connection.getInputStream();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker로 보호된 Replicate 예측 생성
|
||||
*/
|
||||
private ReplicateResponse createPredictionWithCircuitBreaker(ReplicateRequest request) {
|
||||
try {
|
||||
return circuitBreaker.executeSupplier(() -> replicateClient.createPrediction(request));
|
||||
} catch (CallNotPermittedException e) {
|
||||
log.error("Replicate Circuit Breaker가 OPEN 상태입니다");
|
||||
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker로 보호된 Replicate 예측 조회
|
||||
*/
|
||||
private ReplicateResponse getPredictionWithCircuitBreaker(String predictionId) {
|
||||
try {
|
||||
return circuitBreaker.executeSupplier(() -> replicateClient.getPrediction(predictionId));
|
||||
} catch (CallNotPermittedException e) {
|
||||
log.error("Replicate Circuit Breaker가 OPEN 상태입니다");
|
||||
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-9
@@ -22,7 +22,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -42,7 +41,6 @@ import java.util.UUID;
|
||||
@Slf4j
|
||||
@Service
|
||||
@Primary
|
||||
@Profile({"prod", "dev"}) // production 및 dev 환경에서 활성화 (local은 Mock 사용)
|
||||
public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||
|
||||
private final ReplicateApiClient replicateClient;
|
||||
@@ -69,15 +67,15 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||
|
||||
@Override
|
||||
public JobInfo execute(ContentCommand.GenerateImages command) {
|
||||
log.info("Stable Diffusion 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
|
||||
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
|
||||
log.info("Stable Diffusion 이미지 생성 요청: eventId={}, styles={}, platforms={}",
|
||||
command.getEventId(), command.getStyles(), command.getPlatforms());
|
||||
|
||||
// Job 생성
|
||||
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Job job = Job.builder()
|
||||
.id(jobId)
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.eventId(command.getEventId())
|
||||
.jobType("image-generation")
|
||||
.status(Job.Status.PENDING)
|
||||
.progress(0)
|
||||
@@ -88,7 +86,7 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||
// Job 저장
|
||||
RedisJobData jobData = RedisJobData.builder()
|
||||
.id(job.getId())
|
||||
.eventDraftId(job.getEventDraftId())
|
||||
.eventId(job.getEventId())
|
||||
.jobType(job.getJobType())
|
||||
.status(job.getStatus().name())
|
||||
.progress(job.getProgress())
|
||||
@@ -112,8 +110,8 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||
|
||||
// Content 생성 또는 조회
|
||||
Content content = Content.builder()
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.eventTitle(command.getEventDraftId() + " 이벤트")
|
||||
.eventId(command.getEventId())
|
||||
.eventTitle(command.getEventId() + " 이벤트")
|
||||
.eventDescription("AI 생성 이벤트 이미지")
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
@@ -148,7 +146,7 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||
|
||||
// GeneratedImage 저장
|
||||
GeneratedImage image = GeneratedImage.builder()
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.eventId(command.getEventId())
|
||||
.style(style)
|
||||
.platform(platform)
|
||||
.cdnUrl(imageUrl)
|
||||
|
||||
-154
@@ -1,154 +0,0 @@
|
||||
package com.kt.event.content.biz.service.mock;
|
||||
|
||||
import com.kt.event.content.biz.domain.Content;
|
||||
import com.kt.event.content.biz.domain.GeneratedImage;
|
||||
import com.kt.event.content.biz.domain.ImageStyle;
|
||||
import com.kt.event.content.biz.domain.Job;
|
||||
import com.kt.event.content.biz.domain.Platform;
|
||||
import com.kt.event.content.biz.dto.ContentCommand;
|
||||
import com.kt.event.content.biz.dto.JobInfo;
|
||||
import com.kt.event.content.biz.dto.RedisJobData;
|
||||
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
|
||||
import com.kt.event.content.biz.usecase.out.ContentWriter;
|
||||
import com.kt.event.content.biz.usecase.out.JobWriter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Mock 이미지 생성 서비스 (테스트용)
|
||||
* local 및 test 환경에서만 사용
|
||||
*
|
||||
* 테스트를 위해 실제로 Content와 GeneratedImage를 생성합니다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile({"local", "test"})
|
||||
@RequiredArgsConstructor
|
||||
public class MockGenerateImagesService implements GenerateImagesUseCase {
|
||||
|
||||
private final JobWriter jobWriter;
|
||||
private final ContentWriter contentWriter;
|
||||
|
||||
@Override
|
||||
public JobInfo execute(ContentCommand.GenerateImages command) {
|
||||
log.info("[MOCK] 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
|
||||
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
|
||||
|
||||
// Mock Job 생성
|
||||
String jobId = "job-mock-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Job job = Job.builder()
|
||||
.id(jobId)
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.jobType("image-generation")
|
||||
.status(Job.Status.PENDING)
|
||||
.progress(0)
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Job 저장 (Job 도메인을 RedisJobData로 변환)
|
||||
RedisJobData jobData = RedisJobData.builder()
|
||||
.id(job.getId())
|
||||
.eventDraftId(job.getEventDraftId())
|
||||
.jobType(job.getJobType())
|
||||
.status(job.getStatus().name())
|
||||
.progress(job.getProgress())
|
||||
.createdAt(job.getCreatedAt())
|
||||
.updatedAt(job.getUpdatedAt())
|
||||
.build();
|
||||
|
||||
jobWriter.saveJob(jobData, 3600); // TTL 1시간
|
||||
log.info("[MOCK] Job 생성 완료: jobId={}", jobId);
|
||||
|
||||
// 비동기로 이미지 생성 시뮬레이션
|
||||
processImageGeneration(jobId, command);
|
||||
|
||||
return JobInfo.from(job);
|
||||
}
|
||||
|
||||
@Async
|
||||
private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
|
||||
try {
|
||||
log.info("[MOCK] 이미지 생성 시작: jobId={}", jobId);
|
||||
|
||||
// 1초 대기 (이미지 생성 시뮬레이션)
|
||||
Thread.sleep(1000);
|
||||
|
||||
// Content 생성 또는 조회
|
||||
Content content = Content.builder()
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.eventTitle("Mock 이벤트 제목 " + command.getEventDraftId())
|
||||
.eventDescription("Mock 이벤트 설명입니다. 테스트를 위한 Mock 데이터입니다.")
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
Content savedContent = contentWriter.save(content);
|
||||
log.info("[MOCK] Content 생성 완료: contentId={}", savedContent.getId());
|
||||
|
||||
// 스타일 x 플랫폼 조합으로 이미지 생성
|
||||
List<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
|
||||
? command.getStyles()
|
||||
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
|
||||
|
||||
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
|
||||
? command.getPlatforms()
|
||||
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
|
||||
|
||||
List<GeneratedImage> images = new ArrayList<>();
|
||||
int count = 0;
|
||||
for (ImageStyle style : styles) {
|
||||
for (Platform platform : platforms) {
|
||||
count++;
|
||||
String mockCdnUrl = String.format(
|
||||
"https://mock-cdn.azure.com/images/%d/%s_%s_%s.png",
|
||||
command.getEventDraftId(),
|
||||
style.name().toLowerCase(),
|
||||
platform.name().toLowerCase(),
|
||||
UUID.randomUUID().toString().substring(0, 8)
|
||||
);
|
||||
|
||||
GeneratedImage image = GeneratedImage.builder()
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.style(style)
|
||||
.platform(platform)
|
||||
.cdnUrl(mockCdnUrl)
|
||||
.prompt(String.format("Mock prompt for %s style on %s platform", style, platform))
|
||||
.selected(false)
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 첫 번째 이미지를 선택된 이미지로 설정
|
||||
if (count == 1) {
|
||||
image.select();
|
||||
}
|
||||
|
||||
GeneratedImage savedImage = contentWriter.saveImage(image);
|
||||
images.add(savedImage);
|
||||
log.info("[MOCK] 이미지 생성: imageId={}, style={}, platform={}",
|
||||
savedImage.getId(), style, platform);
|
||||
}
|
||||
}
|
||||
|
||||
// Job 상태 업데이트: COMPLETED
|
||||
String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
|
||||
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
|
||||
jobWriter.updateJobResult(jobId, resultMessage);
|
||||
log.info("[MOCK] Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[MOCK] 이미지 생성 실패: jobId={}", jobId, e);
|
||||
|
||||
// Job 상태 업데이트: FAILED
|
||||
jobWriter.updateJobError(jobId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package com.kt.event.content.biz.service.mock;
|
||||
|
||||
import com.kt.event.content.biz.domain.Job;
|
||||
import com.kt.event.content.biz.dto.ContentCommand;
|
||||
import com.kt.event.content.biz.dto.JobInfo;
|
||||
import com.kt.event.content.biz.dto.RedisJobData;
|
||||
import com.kt.event.content.biz.usecase.in.RegenerateImageUseCase;
|
||||
import com.kt.event.content.biz.usecase.out.JobWriter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Mock 이미지 재생성 서비스 (테스트용)
|
||||
* 실제 구현 전까지 사용
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile({"local", "test", "dev"})
|
||||
@RequiredArgsConstructor
|
||||
public class MockRegenerateImageService implements RegenerateImageUseCase {
|
||||
|
||||
private final JobWriter jobWriter;
|
||||
|
||||
@Override
|
||||
public JobInfo execute(ContentCommand.RegenerateImage command) {
|
||||
log.info("[MOCK] 이미지 재생성 요청: imageId={}", command.getImageId());
|
||||
|
||||
// Mock Job 생성
|
||||
String jobId = "job-regen-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Job job = Job.builder()
|
||||
.id(jobId)
|
||||
.eventDraftId(999L) // Mock event ID
|
||||
.jobType("image-regeneration")
|
||||
.status(Job.Status.PENDING)
|
||||
.progress(0)
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Job 저장 (Job 도메인을 RedisJobData로 변환)
|
||||
RedisJobData jobData = RedisJobData.builder()
|
||||
.id(job.getId())
|
||||
.eventDraftId(job.getEventDraftId())
|
||||
.jobType(job.getJobType())
|
||||
.status(job.getStatus().name())
|
||||
.progress(job.getProgress())
|
||||
.createdAt(job.getCreatedAt())
|
||||
.updatedAt(job.getUpdatedAt())
|
||||
.build();
|
||||
|
||||
jobWriter.saveJob(jobData, 3600); // TTL 1시간
|
||||
|
||||
log.info("[MOCK] 재생성 Job 생성 완료: jobId={}", jobId);
|
||||
|
||||
return JobInfo.from(job);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -10,8 +10,8 @@ public interface GetEventContentUseCase {
|
||||
/**
|
||||
* 이벤트 전체 콘텐츠 조회 (이미지 목록 포함)
|
||||
*
|
||||
* @param eventDraftId 이벤트 초안 ID
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 콘텐츠 정보
|
||||
*/
|
||||
ContentInfo execute(Long eventDraftId);
|
||||
ContentInfo execute(String eventId);
|
||||
}
|
||||
|
||||
+2
-2
@@ -14,10 +14,10 @@ public interface GetImageListUseCase {
|
||||
/**
|
||||
* 이벤트의 이미지 목록 조회 (필터링 지원)
|
||||
*
|
||||
* @param eventDraftId 이벤트 초안 ID
|
||||
* @param eventId 이벤트 ID
|
||||
* @param style 이미지 스타일 필터 (null이면 전체)
|
||||
* @param platform 플랫폼 필터 (null이면 전체)
|
||||
* @return 이미지 정보 목록
|
||||
*/
|
||||
List<ImageInfo> execute(Long eventDraftId, ImageStyle style, Platform platform);
|
||||
List<ImageInfo> execute(String eventId, ImageStyle style, Platform platform);
|
||||
}
|
||||
|
||||
+4
-4
@@ -14,10 +14,10 @@ public interface ContentReader {
|
||||
/**
|
||||
* 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
|
||||
*
|
||||
* @param eventDraftId 이벤트 초안 ID
|
||||
* @param eventId 이벤트 초안 ID
|
||||
* @return 콘텐츠 도메인 모델
|
||||
*/
|
||||
Optional<Content> findByEventDraftIdWithImages(Long eventDraftId);
|
||||
Optional<Content> findByEventDraftIdWithImages(String eventId);
|
||||
|
||||
/**
|
||||
* 이미지 ID로 이미지 조회
|
||||
@@ -30,8 +30,8 @@ public interface ContentReader {
|
||||
/**
|
||||
* 이벤트 초안 ID로 이미지 목록 조회
|
||||
*
|
||||
* @param eventDraftId 이벤트 초안 ID
|
||||
* @param eventId 이벤트 초안 ID
|
||||
* @return 이미지 도메인 모델 목록
|
||||
*/
|
||||
List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId);
|
||||
List<GeneratedImage> findImagesByEventDraftId(String eventId);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ public interface ContentWriter {
|
||||
*/
|
||||
GeneratedImage saveImage(GeneratedImage image);
|
||||
|
||||
/**
|
||||
* 이미지 ID로 이미지 조회
|
||||
*
|
||||
* @param imageId 이미지 ID
|
||||
* @return 이미지 도메인 모델
|
||||
*/
|
||||
GeneratedImage getImageById(Long imageId);
|
||||
|
||||
/**
|
||||
* 이미지 ID로 이미지 삭제
|
||||
*
|
||||
|
||||
@@ -15,18 +15,18 @@ public interface ImageReader {
|
||||
/**
|
||||
* 특정 이미지 조회
|
||||
*
|
||||
* @param eventDraftId 이벤트 초안 ID
|
||||
* @param eventId 이벤트 초안 ID
|
||||
* @param style 이미지 스타일
|
||||
* @param platform 플랫폼
|
||||
* @return 이미지 데이터
|
||||
*/
|
||||
Optional<RedisImageData> getImage(Long eventDraftId, ImageStyle style, Platform platform);
|
||||
Optional<RedisImageData> getImage(String eventId, ImageStyle style, Platform platform);
|
||||
|
||||
/**
|
||||
* 이벤트의 모든 이미지 조회
|
||||
*
|
||||
* @param eventDraftId 이벤트 초안 ID
|
||||
* @param eventId 이벤트 초안 ID
|
||||
* @return 이미지 목록
|
||||
*/
|
||||
List<RedisImageData> getImagesByEventId(Long eventDraftId);
|
||||
List<RedisImageData> getImagesByEventId(String eventId);
|
||||
}
|
||||
|
||||
@@ -22,18 +22,18 @@ public interface ImageWriter {
|
||||
/**
|
||||
* 여러 이미지 저장
|
||||
*
|
||||
* @param eventDraftId 이벤트 초안 ID
|
||||
* @param eventId 이벤트 초안 ID
|
||||
* @param images 이미지 목록
|
||||
* @param ttlSeconds TTL (초 단위)
|
||||
*/
|
||||
void saveImages(Long eventDraftId, List<RedisImageData> images, long ttlSeconds);
|
||||
void saveImages(String eventId, List<RedisImageData> images, long ttlSeconds);
|
||||
|
||||
/**
|
||||
* 이미지 삭제
|
||||
*
|
||||
* @param eventDraftId 이벤트 초안 ID
|
||||
* @param eventId 이벤트 초안 ID
|
||||
* @param style 이미지 스타일
|
||||
* @param platform 플랫폼
|
||||
*/
|
||||
void deleteImage(Long eventDraftId, ImageStyle style, Platform platform);
|
||||
void deleteImage(String eventId, ImageStyle style, Platform platform);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user