Compare commits
29 Commits
main
...
feature/co
| 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 | ||
|
|
435ba1a86c | ||
|
|
16a91c85bf | ||
|
|
d89ee4edf7 | ||
|
|
55e546e0b3 |
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-actions-cicd-guide-back"
|
command: "/deploy-actions-cicd-guide-back"
|
||||||
|
description: "백엔드 GitHub Actions CI/CD 파이프라인 가이드 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
'백엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
|
'백엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||||
|
|
||||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
[실행정보]
|
[실행정보]
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-actions-cicd-guide-front"
|
command: "/deploy-actions-cicd-guide-front"
|
||||||
|
description: "프론트엔드 GitHub Actions CI/CD 파이프라인 가이드 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
'프론트엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
|
'프론트엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||||
|
|
||||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
[실행정보]
|
[실행정보]
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-build-image-back"
|
command: "/deploy-build-image-back"
|
||||||
|
description: "백엔드 컨테이너 이미지 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-build-image-front"
|
command: "/deploy-build-image-front"
|
||||||
|
description: "프론트엔드 컨테이너 이미지 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
|
|||||||
@ -1,81 +1,64 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-help"
|
command: "/deploy-help"
|
||||||
|
description: "배포 작업 순서 및 명령어 안내"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 배포 작업 순서
|
# 배포 작업 순서
|
||||||
|
|
||||||
## 1단계: 컨테이너 이미지 작성
|
## 컨테이너 이미지 작성
|
||||||
### 백엔드
|
### 백엔드
|
||||||
```
|
|
||||||
/deploy-build-image-back
|
/deploy-build-image-back
|
||||||
```
|
- 백엔드 서비스들의 컨테이너 이미지를 작성합니다
|
||||||
- 백엔드컨테이너이미지작성가이드를 참고하여 컨테이너 이미지를 빌드합니다
|
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
```
|
|
||||||
/deploy-build-image-front
|
/deploy-build-image-front
|
||||||
```
|
- 프론트엔드 서비스의 컨테이너 이미지를 작성합니다
|
||||||
- 프론트엔드컨테이너이미지작성가이드를 참고하여 컨테이너 이미지를 빌드합니다
|
|
||||||
|
|
||||||
## 2단계: 컨테이너 실행 가이드 작성
|
## 컨테이너 실행 가이드 작성
|
||||||
### 백엔드
|
### 백엔드
|
||||||
```
|
|
||||||
/deploy-run-container-guide-back
|
/deploy-run-container-guide-back
|
||||||
```
|
- 백엔드 컨테이너 실행 가이드를 작성합니다
|
||||||
- 백엔드컨테이너실행방법가이드를 참고하여 컨테이너 실행 방법을 작성합니다
|
- [실행정보] 섹션에 ACR명, VM 접속 정보 제공 필요
|
||||||
- 실행정보(ACR명, VM정보)가 필요합니다
|
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
```
|
|
||||||
/deploy-run-container-guide-front
|
/deploy-run-container-guide-front
|
||||||
```
|
- 프론트엔드 컨테이너 실행 가이드를 작성합니다
|
||||||
- 프론트엔드컨테이너실행방법가이드를 참고하여 컨테이너 실행 방법을 작성합니다
|
- [실행정보] 섹션에 시스템명, ACR명, VM 접속 정보 제공 필요
|
||||||
- 실행정보(시스템명, ACR명, VM정보)가 필요합니다
|
|
||||||
|
|
||||||
## 3단계: Kubernetes 배포 가이드 작성
|
## Kubernetes 배포 가이드 작성
|
||||||
### 백엔드
|
### 백엔드
|
||||||
```
|
|
||||||
/deploy-k8s-guide-back
|
/deploy-k8s-guide-back
|
||||||
```
|
- 백엔드 서비스 Kubernetes 배포 가이드를 작성합니다
|
||||||
- 백엔드배포가이드를 참고하여 쿠버네티스 배포 방법을 작성합니다
|
- [실행정보] 섹션에 ACR명, k8s명, 네임스페이스, 리소스 정보 제공 필요
|
||||||
- 실행정보(ACR명, k8s명, 네임스페이스, 리소스 설정)가 필요합니다
|
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
```
|
|
||||||
/deploy-k8s-guide-front
|
/deploy-k8s-guide-front
|
||||||
```
|
- 프론트엔드 서비스 Kubernetes 배포 가이드를 작성합니다
|
||||||
- 프론트엔드배포가이드를 참고하여 쿠버네티스 배포 방법을 작성합니다
|
- [실행정보] 섹션에 시스템명, ACR명, k8s명, 네임스페이스, Gateway Host 정보 제공 필요
|
||||||
- 실행정보(시스템명, ACR명, k8s명, 네임스페이스, Gateway Host, 리소스 설정)가 필요합니다
|
|
||||||
|
|
||||||
## 4단계: CI/CD 파이프라인 구성
|
## CI/CD 파이프라인 작성
|
||||||
|
### Jenkins CI/CD
|
||||||
### Jenkins 사용 시
|
|
||||||
#### 백엔드
|
#### 백엔드
|
||||||
```
|
|
||||||
/deploy-jenkins-cicd-guide-back
|
/deploy-jenkins-cicd-guide-back
|
||||||
```
|
- Jenkins를 이용한 백엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||||
- 백엔드Jenkins파이프라인작성가이드를 참고하여 Jenkins CI/CD 파이프라인을 구성합니다
|
- [실행정보] 섹션에 ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||||
|
|
||||||
#### 프론트엔드
|
#### 프론트엔드
|
||||||
```
|
|
||||||
/deploy-jenkins-cicd-guide-front
|
/deploy-jenkins-cicd-guide-front
|
||||||
```
|
- Jenkins를 이용한 프론트엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||||
- 프론트엔드Jenkins파이프라인작성가이드를 참고하여 Jenkins CI/CD 파이프라인을 구성합니다
|
- [실행정보] 섹션에 SYSTEM_NAME, ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||||
|
|
||||||
### GitHub Actions 사용 시
|
### GitHub Actions CI/CD
|
||||||
#### 백엔드
|
#### 백엔드
|
||||||
```
|
|
||||||
/deploy-actions-cicd-guide-back
|
/deploy-actions-cicd-guide-back
|
||||||
```
|
- GitHub Actions를 이용한 백엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||||
- 백엔드GitHubActions파이프라인작성가이드를 참고하여 GitHub Actions CI/CD 파이프라인을 구성합니다
|
- [실행정보] 섹션에 ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||||
|
|
||||||
#### 프론트엔드
|
#### 프론트엔드
|
||||||
```
|
|
||||||
/deploy-actions-cicd-guide-front
|
/deploy-actions-cicd-guide-front
|
||||||
```
|
- GitHub Actions를 이용한 프론트엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||||
- 프론트엔드GitHubActions파이프라인작성가이드를 참고하여 GitHub Actions CI/CD 파이프라인을 구성합니다
|
- [실행정보] 섹션에 SYSTEM_NAME, ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||||
|
|
||||||
## 참고사항
|
---
|
||||||
- 각 명령 실행 전 필요한 실행정보를 프롬프트에 포함해야 합니다
|
|
||||||
- 실행정보가 없으면 안내 메시지가 표시되며 작업이 중단됩니다
|
**참고**: 각 명령어 실행 시 [실행정보] 섹션에 필요한 정보를 함께 제공해야 합니다.
|
||||||
- CI/CD 도구는 Jenkins 또는 GitHub Actions 중 선택하여 사용합니다
|
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-jenkins-cicd-guide-back"
|
command: "/deploy-jenkins-cicd-guide-back"
|
||||||
|
description: "백엔드 Jenkins CI/CD 파이프라인 가이드 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
'백엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
'백엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||||
|
|
||||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
[실행정보]
|
[실행정보]
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-jenkins-cicd-guide-front"
|
command: "/deploy-jenkins-cicd-guide-front"
|
||||||
|
description: "프론트엔드 Jenkins CI/CD 파이프라인 가이드 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
'프론트엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
'프론트엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||||
|
|
||||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
[실행정보]
|
[실행정보]
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-k8s-guide-back"
|
command: "/deploy-k8s-guide-back"
|
||||||
|
description: "백엔드 Kubernetes 배포 가이드 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
'백엔드배포가이드'에 따라 백엔드 서비스 배포 방법을 작성해 주세요.
|
'백엔드배포가이드'에 따라 백엔드 서비스 배포 방법을 작성해 주세요.
|
||||||
|
|
||||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
[실행정보]
|
[실행정보]
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-k8s-guide-front"
|
command: "/deploy-k8s-guide-front"
|
||||||
|
description: "프론트엔드 Kubernetes 배포 가이드 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
'프론트엔드배포가이드'에 따라 프론트엔드 서비스 배포 방법을 작성해 주세요.
|
'프론트엔드배포가이드'에 따라 프론트엔드 서비스 배포 방법을 작성해 주세요.
|
||||||
|
|
||||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
[실행정보]
|
[실행정보]
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-run-container-guide-back"
|
command: "/deploy-run-container-guide-back"
|
||||||
|
description: "백엔드 컨테이너 실행방법 가이드 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
'백엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
'백엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
||||||
|
|
||||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
[실행정보]
|
[실행정보]
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
command: "/deploy-run-container-guide-front"
|
command: "/deploy-run-container-guide-front"
|
||||||
|
description: "프론트엔드 컨테이너 실행방법 가이드 작성"
|
||||||
---
|
---
|
||||||
|
|
||||||
@cicd
|
@cicd
|
||||||
'프론트엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
'프론트엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
||||||
|
|
||||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
[실행정보]
|
[실행정보]
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -61,3 +61,5 @@ k8s/**/*-local.yaml
|
|||||||
|
|
||||||
# Gradle (로컬 환경 설정)
|
# Gradle (로컬 환경 설정)
|
||||||
gradle.properties
|
gradle.properties
|
||||||
|
*.hprof
|
||||||
|
test-data.json
|
||||||
|
|||||||
84
.run/analytics-service.run.xml
Normal file
84
.run/analytics-service.run.xml
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="analytics-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<!-- Database Configuration -->
|
||||||
|
<entry key="DB_KIND" value="postgresql" />
|
||||||
|
<entry key="DB_HOST" value="4.230.49.9" />
|
||||||
|
<entry key="DB_PORT" value="5432" />
|
||||||
|
<entry key="DB_NAME" value="analyticdb" />
|
||||||
|
<entry key="DB_USERNAME" value="eventuser" />
|
||||||
|
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||||
|
|
||||||
|
<!-- JPA Configuration -->
|
||||||
|
<entry key="DDL_AUTO" value="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 Configuration (원격 서버) -->
|
||||||
|
<entry key="KAFKA_ENABLED" value="true" />
|
||||||
|
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||||
|
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
|
||||||
|
|
||||||
|
<!-- Sample Data Configuration (MVP Only) -->
|
||||||
|
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
|
||||||
|
<entry key="SAMPLE_DATA_ENABLED" value="true" />
|
||||||
|
|
||||||
|
<!-- Server Configuration -->
|
||||||
|
<entry key="SERVER_PORT" value="8086" />
|
||||||
|
|
||||||
|
<!-- JWT Configuration -->
|
||||||
|
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only-kt-event-marketing" />
|
||||||
|
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="1800" />
|
||||||
|
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
|
||||||
|
|
||||||
|
<!-- CORS Configuration -->
|
||||||
|
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
|
||||||
|
|
||||||
|
<!-- Logging Configuration -->
|
||||||
|
<entry key="LOG_FILE" value="logs/analytics-service.log" />
|
||||||
|
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||||
|
<entry key="LOG_LEVEL_WEB" value="INFO" />
|
||||||
|
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
|
||||||
|
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="analytics-service:bootRun" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="net.ashald.envfile">
|
||||||
|
<option name="IS_ENABLED" value="false" />
|
||||||
|
<option name="IS_SUBST" value="false" />
|
||||||
|
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
|
||||||
|
<option name="IS_IGNORE_MISSING_FILES" value="false" />
|
||||||
|
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
|
||||||
|
<ENTRIES>
|
||||||
|
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
|
||||||
|
</ENTRIES>
|
||||||
|
</extension>
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@ -5,11 +5,11 @@ spring:
|
|||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:redis-external} # Production: redis-external, Local: 20.214.210.71
|
host: 20.214.210.71
|
||||||
port: ${REDIS_PORT:6379}
|
port: 6379
|
||||||
password: ${REDIS_PASSWORD:}
|
password: Hi5Jessica!
|
||||||
database: ${REDIS_DATABASE:0} # AI Service uses database 3
|
database: 3
|
||||||
timeout: ${REDIS_TIMEOUT:3000}
|
timeout: 3000
|
||||||
lettuce:
|
lettuce:
|
||||||
pool:
|
pool:
|
||||||
max-active: 8
|
max-active: 8
|
||||||
@ -19,7 +19,7 @@ spring:
|
|||||||
|
|
||||||
# Kafka Consumer Configuration
|
# Kafka Consumer Configuration
|
||||||
kafka:
|
kafka:
|
||||||
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
|
bootstrap-servers: 4.230.50.63:9092
|
||||||
consumer:
|
consumer:
|
||||||
group-id: ai-service-consumers
|
group-id: ai-service-consumers
|
||||||
auto-offset-reset: earliest
|
auto-offset-reset: earliest
|
||||||
@ -28,14 +28,14 @@ spring:
|
|||||||
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||||
properties:
|
properties:
|
||||||
spring.json.trusted.packages: "*"
|
spring.json.trusted.packages: "*"
|
||||||
max.poll.records: ${KAFKA_MAX_POLL_RECORDS:10}
|
max.poll.records: 10
|
||||||
session.timeout.ms: ${KAFKA_SESSION_TIMEOUT:30000}
|
session.timeout.ms: 30000
|
||||||
listener:
|
listener:
|
||||||
ack-mode: manual
|
ack-mode: manual
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8083}
|
port: 8083
|
||||||
servlet:
|
servlet:
|
||||||
context-path: /
|
context-path: /
|
||||||
encoding:
|
encoding:
|
||||||
@ -45,17 +45,17 @@ server:
|
|||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:}
|
secret: kt-event-marketing-secret-key-for-development-only-please-change-in-production
|
||||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
|
access-token-validity: 604800000
|
||||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
|
refresh-token-validity: 86400
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
|
allowed-origins: http://localhost:*
|
||||||
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
|
allowed-methods: GET,POST,PUT,DELETE,OPTIONS,PATCH
|
||||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
allowed-headers: "*"
|
||||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
allow-credentials: true
|
||||||
max-age: ${CORS_MAX_AGE:3600}
|
max-age: 3600
|
||||||
|
|
||||||
# Actuator Configuration
|
# Actuator Configuration
|
||||||
management:
|
management:
|
||||||
@ -100,7 +100,7 @@ logging:
|
|||||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
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: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
file:
|
file:
|
||||||
name: ${LOG_FILE:logs/ai-service.log}
|
name: logs/ai-service.log
|
||||||
logback:
|
logback:
|
||||||
rollingpolicy:
|
rollingpolicy:
|
||||||
max-file-size: 10MB
|
max-file-size: 10MB
|
||||||
@ -110,26 +110,20 @@ logging:
|
|||||||
# Kafka Topics Configuration
|
# Kafka Topics Configuration
|
||||||
kafka:
|
kafka:
|
||||||
topics:
|
topics:
|
||||||
ai-job: ${KAFKA_TOPIC_AI_JOB:ai-event-generation-job}
|
ai-job: ai-event-generation-job
|
||||||
ai-job-dlq: ${KAFKA_TOPIC_AI_JOB_DLQ:ai-event-generation-job-dlq}
|
ai-job-dlq: ai-event-generation-job-dlq
|
||||||
|
|
||||||
# AI External API Configuration
|
# AI API Configuration (실제 API 사용)
|
||||||
ai:
|
ai:
|
||||||
|
provider: CLAUDE
|
||||||
claude:
|
claude:
|
||||||
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
api-url: https://api.anthropic.com/v1/messages
|
||||||
api-key: ${CLAUDE_API_KEY:}
|
api-key: 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
|
||||||
anthropic-version: ${CLAUDE_ANTHROPIC_VERSION:2023-06-01}
|
anthropic-version: 2023-06-01
|
||||||
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
|
model: claude-sonnet-4-5-20250929
|
||||||
max-tokens: ${CLAUDE_MAX_TOKENS:4096}
|
max-tokens: 4096
|
||||||
temperature: ${CLAUDE_TEMPERATURE:0.7}
|
temperature: 0.7
|
||||||
timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes
|
timeout: 300000
|
||||||
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
|
|
||||||
|
|
||||||
# Circuit Breaker Configuration
|
# Circuit Breaker Configuration
|
||||||
resilience4j:
|
resilience4j:
|
||||||
@ -168,7 +162,7 @@ resilience4j:
|
|||||||
# Redis Cache TTL Configuration (seconds)
|
# Redis Cache TTL Configuration (seconds)
|
||||||
cache:
|
cache:
|
||||||
ttl:
|
ttl:
|
||||||
recommendation: ${CACHE_TTL_RECOMMENDATION:86400} # 24 hours
|
recommendation: 86400 # 24 hours
|
||||||
job-status: ${CACHE_TTL_JOB_STATUS:86400} # 24 hours
|
job-status: 86400 # 24 hours
|
||||||
trend: ${CACHE_TTL_TREND:3600} # 1 hour
|
trend: 3600 # 1 hour
|
||||||
fallback: ${CACHE_TTL_FALLBACK:604800} # 7 days
|
fallback: 604800 # 7 days
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||||
|
|
||||||
<!-- JPA Configuration -->
|
<!-- JPA Configuration -->
|
||||||
<entry key="DDL_AUTO" value="update" />
|
<entry key="DDL_AUTO" value="create" />
|
||||||
<entry key="SHOW_SQL" value="true" />
|
<entry key="SHOW_SQL" value="true" />
|
||||||
|
|
||||||
<!-- Redis Configuration -->
|
<!-- Redis Configuration -->
|
||||||
|
|||||||
108
analytics-service/frontend-backend-validation.md
Normal file
108
analytics-service/frontend-backend-validation.md
Normal file
@ -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 세분화 구현
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
private Integer averageEngagementTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목표 ROI (%)
|
||||||
|
*/
|
||||||
|
private Double targetRoi;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SNS 반응 통계
|
* SNS 반응 통계
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예상 매출 증대 (원)
|
* 예상 매출 증대 (원)
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
private String eventTitle;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 ID (소유자)
|
* 사용자 ID (소유자)
|
||||||
*/
|
*/
|
||||||
@Column(nullable = false, length = 50)
|
@Column(nullable = false, length = 50)
|
||||||
private String storeId;
|
private String userId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 총 참여자 수
|
* 총 참여자 수
|
||||||
@ -63,6 +63,13 @@ public class EventStats extends BaseTimeEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private BigDecimal estimatedRoi = BigDecimal.ZERO;
|
private BigDecimal estimatedRoi = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목표 ROI (%)
|
||||||
|
*/
|
||||||
|
@Column(precision = 10, scale = 2)
|
||||||
|
@Builder.Default
|
||||||
|
private BigDecimal targetRoi = BigDecimal.ZERO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매출 증가율 (%)
|
* 매출 증가율 (%)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -54,11 +54,11 @@ public class EventCreatedConsumer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 이벤트 통계 초기화
|
// 2. 이벤트 통계 초기화 (1:1 관계: storeId → userId 매핑)
|
||||||
EventStats eventStats = EventStats.builder()
|
EventStats eventStats = EventStats.builder()
|
||||||
.eventId(eventId)
|
.eventId(eventId)
|
||||||
.eventTitle(event.getEventTitle())
|
.eventTitle(event.getEventTitle())
|
||||||
.storeId(event.getStoreId())
|
.userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
|
||||||
.totalParticipants(0)
|
.totalParticipants(0)
|
||||||
.totalInvestment(event.getTotalInvestment())
|
.totalInvestment(event.getTotalInvestment())
|
||||||
.status(event.getStatus())
|
.status(event.getStatus())
|
||||||
|
|||||||
@ -29,4 +29,12 @@ public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long
|
|||||||
* @return 채널 통계
|
* @return 채널 통계
|
||||||
*/
|
*/
|
||||||
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
|
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 이벤트 ID로 모든 채널 통계 조회
|
||||||
|
*
|
||||||
|
* @param eventIds 이벤트 ID 목록
|
||||||
|
* @return 채널 통계 목록
|
||||||
|
*/
|
||||||
|
List<ChannelStats> findByEventIdIn(List<String> eventIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,11 +39,19 @@ public interface EventStatsRepository extends JpaRepository<EventStats, Long> {
|
|||||||
Optional<EventStats> findByEventIdWithLock(@Param("eventId") String eventId);
|
Optional<EventStats> findByEventIdWithLock(@Param("eventId") String eventId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 ID와 이벤트 ID로 통계 조회
|
* 사용자 ID와 이벤트 ID로 통계 조회
|
||||||
*
|
*
|
||||||
* @param storeId 매장 ID
|
* @param userId 사용자 ID
|
||||||
* @param eventId 이벤트 ID
|
* @param eventId 이벤트 ID
|
||||||
* @return 이벤트 통계
|
* @return 이벤트 통계
|
||||||
*/
|
*/
|
||||||
Optional<EventStats> findByStoreIdAndEventId(String storeId, String eventId);
|
Optional<EventStats> findByUserIdAndEventId(String userId, String eventId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID로 모든 이벤트 통계 조회
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @return 이벤트 통계 목록
|
||||||
|
*/
|
||||||
|
java.util.List<EventStats> findAllByUserId(String userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,4 +37,27 @@ public interface TimelineDataRepository extends JpaRepository<TimelineData, Long
|
|||||||
@Param("startDate") LocalDateTime startDate,
|
@Param("startDate") LocalDateTime startDate,
|
||||||
@Param("endDate") LocalDateTime endDate
|
@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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,12 +179,14 @@ public class AnalyticsService {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
return AnalyticsSummary.builder()
|
return AnalyticsSummary.builder()
|
||||||
.totalParticipants(eventStats.getTotalParticipants())
|
.participants(eventStats.getTotalParticipants())
|
||||||
|
.participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산
|
||||||
.totalViews(totalViews)
|
.totalViews(totalViews)
|
||||||
.totalReach(totalReach)
|
.totalReach(totalReach)
|
||||||
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
||||||
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
|
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
|
||||||
.averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 함)
|
.averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 함)
|
||||||
|
.targetRoi(eventStats.getTargetRoi() != null ? eventStats.getTargetRoi().doubleValue() : null)
|
||||||
.socialInteractions(socialStats)
|
.socialInteractions(socialStats)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@ -202,7 +204,7 @@ public class AnalyticsService {
|
|||||||
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
|
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
|
||||||
|
|
||||||
summaries.add(ChannelSummary.builder()
|
summaries.add(ChannelSummary.builder()
|
||||||
.channelName(stats.getChannelName())
|
.channel(stats.getChannelName())
|
||||||
.views(stats.getViews())
|
.views(stats.getViews())
|
||||||
.participants(stats.getParticipants())
|
.participants(stats.getParticipants())
|
||||||
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
||||||
|
|||||||
@ -192,7 +192,7 @@ public class ROICalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return RoiSummary.builder()
|
return RoiSummary.builder()
|
||||||
.totalInvestment(eventStats.getTotalInvestment())
|
.totalCost(eventStats.getTotalInvestment())
|
||||||
.expectedRevenue(eventStats.getExpectedRevenue())
|
.expectedRevenue(eventStats.getExpectedRevenue())
|
||||||
.netProfit(netProfit)
|
.netProfit(netProfit)
|
||||||
.roi(roi)
|
.roi(roi)
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
494
analytics-service/test-backend.md
Normal file
494
analytics-service/test-backend.md
Normal file
@ -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
|
||||||
187
claude/run-container-guide-back.md
Normal file
187
claude/run-container-guide-back.md
Normal file
@ -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
|
||||||
|
|
||||||
@ -171,7 +171,11 @@ public class GlobalExceptionHandler {
|
|||||||
*/
|
*/
|
||||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleDataIntegrityViolationException(DataIntegrityViolationException ex) {
|
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 message = "데이터 중복 또는 무결성 제약 위반이 발생했습니다";
|
||||||
String details = ex.getMessage();
|
String details = ex.getMessage();
|
||||||
|
|||||||
@ -113,9 +113,9 @@ public class JwtTokenProvider {
|
|||||||
public UserPrincipal getUserPrincipalFromToken(String token) {
|
public UserPrincipal getUserPrincipalFromToken(String token) {
|
||||||
Claims claims = parseToken(token);
|
Claims claims = parseToken(token);
|
||||||
|
|
||||||
Long userId = Long.parseLong(claims.getSubject());
|
UUID userId = UUID.fromString(claims.getSubject());
|
||||||
String storeIdStr = claims.get("storeId", String.class);
|
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 email = claims.get("email", String.class);
|
||||||
String name = claims.get("name", String.class);
|
String name = claims.get("name", String.class);
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@ -24,12 +24,12 @@ public class UserPrincipal implements UserDetails {
|
|||||||
/**
|
/**
|
||||||
* 사용자 ID
|
* 사용자 ID
|
||||||
*/
|
*/
|
||||||
private final Long userId;
|
private final UUID userId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 ID
|
* 매장 ID
|
||||||
*/
|
*/
|
||||||
private final Long storeId;
|
private final UUID storeId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 이메일
|
* 사용자 이메일
|
||||||
|
|||||||
64
content-service-deployment.yaml
Normal file
64
content-service-deployment.yaml
Normal file
@ -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
|
||||||
16
content-service-service.yaml
Normal file
16
content-service-service.yaml
Normal file
@ -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
|
||||||
24
content-service/Dockerfile
Normal file
24
content-service/Dockerfile
Normal file
@ -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)
|
// 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.springframework.boot', module: 'spring-boot-starter-data-jpa'
|
||||||
implementation.exclude group: 'org.postgresql', module: 'postgresql'
|
implementation.exclude group: 'org.postgresql', module: 'postgresql'
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
|
||||||
// Redis for AI data reading and image URL caching
|
// Redis for AI data reading and image URL caching
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
|
||||||
|
|||||||
@ -23,9 +23,9 @@ public class Content {
|
|||||||
private final Long id;
|
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;
|
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;
|
private final String id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 ID (이벤트 초안 ID)
|
* 이벤트 ID
|
||||||
*/
|
*/
|
||||||
private final Long eventDraftId;
|
private final String eventId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 타입 (image-generation)
|
* Job 타입 (image-generation)
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class ContentCommand {
|
|||||||
@Builder
|
@Builder
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public static class GenerateImages {
|
public static class GenerateImages {
|
||||||
private Long eventDraftId;
|
private String eventId;
|
||||||
private String eventTitle;
|
private String eventTitle;
|
||||||
private String eventDescription;
|
private String eventDescription;
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import java.util.stream.Collectors;
|
|||||||
public class ContentInfo {
|
public class ContentInfo {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private Long eventDraftId;
|
private String eventId;
|
||||||
private String eventTitle;
|
private String eventTitle;
|
||||||
private String eventDescription;
|
private String eventDescription;
|
||||||
private List<ImageInfo> images;
|
private List<ImageInfo> images;
|
||||||
@ -34,7 +34,7 @@ public class ContentInfo {
|
|||||||
public static ContentInfo from(Content content) {
|
public static ContentInfo from(Content content) {
|
||||||
return ContentInfo.builder()
|
return ContentInfo.builder()
|
||||||
.id(content.getId())
|
.id(content.getId())
|
||||||
.eventDraftId(content.getEventDraftId())
|
.eventId(content.getEventId())
|
||||||
.eventTitle(content.getEventTitle())
|
.eventTitle(content.getEventTitle())
|
||||||
.eventDescription(content.getEventDescription())
|
.eventDescription(content.getEventDescription())
|
||||||
.images(content.getImages().stream()
|
.images(content.getImages().stream()
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import java.time.LocalDateTime;
|
|||||||
public class ImageInfo {
|
public class ImageInfo {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private Long eventDraftId;
|
private String eventId;
|
||||||
private ImageStyle style;
|
private ImageStyle style;
|
||||||
private Platform platform;
|
private Platform platform;
|
||||||
private String cdnUrl;
|
private String cdnUrl;
|
||||||
@ -36,7 +36,7 @@ public class ImageInfo {
|
|||||||
public static ImageInfo from(GeneratedImage image) {
|
public static ImageInfo from(GeneratedImage image) {
|
||||||
return ImageInfo.builder()
|
return ImageInfo.builder()
|
||||||
.id(image.getId())
|
.id(image.getId())
|
||||||
.eventDraftId(image.getEventDraftId())
|
.eventId(image.getEventId())
|
||||||
.style(image.getStyle())
|
.style(image.getStyle())
|
||||||
.platform(image.getPlatform())
|
.platform(image.getPlatform())
|
||||||
.cdnUrl(image.getCdnUrl())
|
.cdnUrl(image.getCdnUrl())
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import java.time.LocalDateTime;
|
|||||||
public class JobInfo {
|
public class JobInfo {
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private Long eventDraftId;
|
private String eventId;
|
||||||
private String jobType;
|
private String jobType;
|
||||||
private Job.Status status;
|
private Job.Status status;
|
||||||
private int progress;
|
private int progress;
|
||||||
@ -34,7 +34,7 @@ public class JobInfo {
|
|||||||
public static JobInfo from(Job job) {
|
public static JobInfo from(Job job) {
|
||||||
return JobInfo.builder()
|
return JobInfo.builder()
|
||||||
.id(job.getId())
|
.id(job.getId())
|
||||||
.eventDraftId(job.getEventDraftId())
|
.eventId(job.getEventId())
|
||||||
.jobType(job.getJobType())
|
.jobType(job.getJobType())
|
||||||
.status(job.getStatus())
|
.status(job.getStatus())
|
||||||
.progress(job.getProgress())
|
.progress(job.getProgress())
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* AI Service가 Redis에 저장한 이벤트 데이터 (읽기 전용)
|
* AI Service가 Redis에 저장한 이벤트 데이터 (읽기 전용)
|
||||||
*
|
*
|
||||||
* Key Pattern: ai:event:{eventDraftId}
|
* Key Pattern: ai:event:{eventId}
|
||||||
* Data Type: Hash
|
* Data Type: Hash
|
||||||
* TTL: 24시간 (86400초)
|
* TTL: 24시간 (86400초)
|
||||||
*
|
*
|
||||||
@ -25,9 +25,9 @@ import java.util.Map;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class RedisAIEventData {
|
public class RedisAIEventData {
|
||||||
/**
|
/**
|
||||||
* 이벤트 초안 ID
|
* 이벤트 ID
|
||||||
*/
|
*/
|
||||||
private Long eventDraftId;
|
private String eventId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 제목
|
* 이벤트 제목
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import java.time.LocalDateTime;
|
|||||||
/**
|
/**
|
||||||
* Redis에 저장되는 이미지 데이터 구조
|
* Redis에 저장되는 이미지 데이터 구조
|
||||||
*
|
*
|
||||||
* Key Pattern: content:image:{eventDraftId}:{style}:{platform}
|
* Key Pattern: content:image:{eventId}:{style}:{platform}
|
||||||
* Data Type: String (JSON)
|
* Data Type: String (JSON)
|
||||||
* TTL: 7일 (604800초)
|
* TTL: 7일 (604800초)
|
||||||
*
|
*
|
||||||
@ -31,9 +31,9 @@ public class RedisImageData {
|
|||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 초안 ID
|
* 이벤트 ID
|
||||||
*/
|
*/
|
||||||
private Long eventDraftId;
|
private String eventId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 스타일 (FANCY, SIMPLE, TRENDY)
|
* 이미지 스타일 (FANCY, SIMPLE, TRENDY)
|
||||||
|
|||||||
@ -29,9 +29,9 @@ public class RedisJobData {
|
|||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 초안 ID
|
* 이벤트 ID
|
||||||
*/
|
*/
|
||||||
private Long eventDraftId;
|
private String eventId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 타입 (image-generation, image-regeneration)
|
* Job 타입 (image-generation, image-regeneration)
|
||||||
|
|||||||
@ -23,8 +23,8 @@ public class GetEventContentService implements GetEventContentUseCase {
|
|||||||
private final ContentReader contentReader;
|
private final ContentReader contentReader;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ContentInfo execute(Long eventDraftId) {
|
public ContentInfo execute(String eventId) {
|
||||||
Content content = contentReader.findByEventDraftIdWithImages(eventDraftId)
|
Content content = contentReader.findByEventDraftIdWithImages(eventId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "콘텐츠를 찾을 수 없습니다"));
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "콘텐츠를 찾을 수 없습니다"));
|
||||||
|
|
||||||
return ContentInfo.from(content);
|
return ContentInfo.from(content);
|
||||||
|
|||||||
@ -26,10 +26,10 @@ public class GetImageListService implements GetImageListUseCase {
|
|||||||
private final ContentReader contentReader;
|
private final ContentReader contentReader;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ImageInfo> execute(Long eventDraftId, ImageStyle style, Platform platform) {
|
public List<ImageInfo> execute(String eventId, ImageStyle style, Platform platform) {
|
||||||
log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform);
|
log.info("이미지 목록 조회: eventId={}, style={}, platform={}", eventId, style, platform);
|
||||||
|
|
||||||
List<GeneratedImage> images = contentReader.findImagesByEventDraftId(eventDraftId);
|
List<GeneratedImage> images = contentReader.findImagesByEventDraftId(eventId);
|
||||||
|
|
||||||
// 필터링 적용
|
// 필터링 적용
|
||||||
return images.stream()
|
return images.stream()
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -32,7 +32,7 @@ public class JobManagementService implements GetJobStatusUseCase {
|
|||||||
// RedisJobData를 Job 도메인 객체로 변환
|
// RedisJobData를 Job 도메인 객체로 변환
|
||||||
Job job = Job.builder()
|
Job job = Job.builder()
|
||||||
.id(jobData.getId())
|
.id(jobData.getId())
|
||||||
.eventDraftId(jobData.getEventDraftId())
|
.eventId(jobData.getEventId())
|
||||||
.jobType(jobData.getJobType())
|
.jobType(jobData.getJobType())
|
||||||
.status(Job.Status.valueOf(jobData.getStatus()))
|
.status(Job.Status.valueOf(jobData.getStatus()))
|
||||||
.progress(jobData.getProgress())
|
.progress(jobData.getProgress())
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,7 +22,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@ -42,7 +41,6 @@ import java.util.UUID;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@Primary
|
@Primary
|
||||||
@Profile({"prod", "dev"}) // production 및 dev 환경에서 활성화 (local은 Mock 사용)
|
|
||||||
public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
||||||
|
|
||||||
private final ReplicateApiClient replicateClient;
|
private final ReplicateApiClient replicateClient;
|
||||||
@ -69,15 +67,15 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JobInfo execute(ContentCommand.GenerateImages command) {
|
public JobInfo execute(ContentCommand.GenerateImages command) {
|
||||||
log.info("Stable Diffusion 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
|
log.info("Stable Diffusion 이미지 생성 요청: eventId={}, styles={}, platforms={}",
|
||||||
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
|
command.getEventId(), command.getStyles(), command.getPlatforms());
|
||||||
|
|
||||||
// Job 생성
|
// Job 생성
|
||||||
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
|
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
|
||||||
Job job = Job.builder()
|
Job job = Job.builder()
|
||||||
.id(jobId)
|
.id(jobId)
|
||||||
.eventDraftId(command.getEventDraftId())
|
.eventId(command.getEventId())
|
||||||
.jobType("image-generation")
|
.jobType("image-generation")
|
||||||
.status(Job.Status.PENDING)
|
.status(Job.Status.PENDING)
|
||||||
.progress(0)
|
.progress(0)
|
||||||
@ -88,7 +86,7 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
|||||||
// Job 저장
|
// Job 저장
|
||||||
RedisJobData jobData = RedisJobData.builder()
|
RedisJobData jobData = RedisJobData.builder()
|
||||||
.id(job.getId())
|
.id(job.getId())
|
||||||
.eventDraftId(job.getEventDraftId())
|
.eventId(job.getEventId())
|
||||||
.jobType(job.getJobType())
|
.jobType(job.getJobType())
|
||||||
.status(job.getStatus().name())
|
.status(job.getStatus().name())
|
||||||
.progress(job.getProgress())
|
.progress(job.getProgress())
|
||||||
@ -112,8 +110,8 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
|||||||
|
|
||||||
// Content 생성 또는 조회
|
// Content 생성 또는 조회
|
||||||
Content content = Content.builder()
|
Content content = Content.builder()
|
||||||
.eventDraftId(command.getEventDraftId())
|
.eventId(command.getEventId())
|
||||||
.eventTitle(command.getEventDraftId() + " 이벤트")
|
.eventTitle(command.getEventId() + " 이벤트")
|
||||||
.eventDescription("AI 생성 이벤트 이미지")
|
.eventDescription("AI 생성 이벤트 이미지")
|
||||||
.createdAt(java.time.LocalDateTime.now())
|
.createdAt(java.time.LocalDateTime.now())
|
||||||
.updatedAt(java.time.LocalDateTime.now())
|
.updatedAt(java.time.LocalDateTime.now())
|
||||||
@ -148,7 +146,7 @@ public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
|
|||||||
|
|
||||||
// GeneratedImage 저장
|
// GeneratedImage 저장
|
||||||
GeneratedImage image = GeneratedImage.builder()
|
GeneratedImage image = GeneratedImage.builder()
|
||||||
.eventDraftId(command.getEventDraftId())
|
.eventId(command.getEventId())
|
||||||
.style(style)
|
.style(style)
|
||||||
.platform(platform)
|
.platform(platform)
|
||||||
.cdnUrl(imageUrl)
|
.cdnUrl(imageUrl)
|
||||||
|
|||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,8 +10,8 @@ public interface GetEventContentUseCase {
|
|||||||
/**
|
/**
|
||||||
* 이벤트 전체 콘텐츠 조회 (이미지 목록 포함)
|
* 이벤트 전체 콘텐츠 조회 (이미지 목록 포함)
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 ID
|
||||||
* @return 콘텐츠 정보
|
* @return 콘텐츠 정보
|
||||||
*/
|
*/
|
||||||
ContentInfo execute(Long eventDraftId);
|
ContentInfo execute(String eventId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,10 +14,10 @@ public interface GetImageListUseCase {
|
|||||||
/**
|
/**
|
||||||
* 이벤트의 이미지 목록 조회 (필터링 지원)
|
* 이벤트의 이미지 목록 조회 (필터링 지원)
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 ID
|
||||||
* @param style 이미지 스타일 필터 (null이면 전체)
|
* @param style 이미지 스타일 필터 (null이면 전체)
|
||||||
* @param platform 플랫폼 필터 (null이면 전체)
|
* @param platform 플랫폼 필터 (null이면 전체)
|
||||||
* @return 이미지 정보 목록
|
* @return 이미지 정보 목록
|
||||||
*/
|
*/
|
||||||
List<ImageInfo> execute(Long eventDraftId, ImageStyle style, Platform platform);
|
List<ImageInfo> execute(String eventId, ImageStyle style, Platform platform);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,10 +14,10 @@ public interface ContentReader {
|
|||||||
/**
|
/**
|
||||||
* 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
|
* 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 초안 ID
|
||||||
* @return 콘텐츠 도메인 모델
|
* @return 콘텐츠 도메인 모델
|
||||||
*/
|
*/
|
||||||
Optional<Content> findByEventDraftIdWithImages(Long eventDraftId);
|
Optional<Content> findByEventDraftIdWithImages(String eventId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 ID로 이미지 조회
|
* 이미지 ID로 이미지 조회
|
||||||
@ -30,8 +30,8 @@ public interface ContentReader {
|
|||||||
/**
|
/**
|
||||||
* 이벤트 초안 ID로 이미지 목록 조회
|
* 이벤트 초안 ID로 이미지 목록 조회
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 초안 ID
|
||||||
* @return 이미지 도메인 모델 목록
|
* @return 이미지 도메인 모델 목록
|
||||||
*/
|
*/
|
||||||
List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId);
|
List<GeneratedImage> findImagesByEventDraftId(String eventId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,14 @@ public interface ContentWriter {
|
|||||||
*/
|
*/
|
||||||
GeneratedImage saveImage(GeneratedImage image);
|
GeneratedImage saveImage(GeneratedImage image);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 ID로 이미지 조회
|
||||||
|
*
|
||||||
|
* @param imageId 이미지 ID
|
||||||
|
* @return 이미지 도메인 모델
|
||||||
|
*/
|
||||||
|
GeneratedImage getImageById(Long imageId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 ID로 이미지 삭제
|
* 이미지 ID로 이미지 삭제
|
||||||
*
|
*
|
||||||
|
|||||||
@ -15,18 +15,18 @@ public interface ImageReader {
|
|||||||
/**
|
/**
|
||||||
* 특정 이미지 조회
|
* 특정 이미지 조회
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 초안 ID
|
||||||
* @param style 이미지 스타일
|
* @param style 이미지 스타일
|
||||||
* @param platform 플랫폼
|
* @param platform 플랫폼
|
||||||
* @return 이미지 데이터
|
* @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 이미지 목록
|
* @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 images 이미지 목록
|
||||||
* @param ttlSeconds TTL (초 단위)
|
* @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 style 이미지 스타일
|
||||||
* @param platform 플랫폼
|
* @param platform 플랫폼
|
||||||
*/
|
*/
|
||||||
void deleteImage(Long eventDraftId, ImageStyle style, Platform platform);
|
void deleteImage(String eventId, ImageStyle style, Platform platform);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,8 @@ public interface RedisAIDataReader {
|
|||||||
/**
|
/**
|
||||||
* AI 추천 데이터 조회
|
* AI 추천 데이터 조회
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 초안 ID
|
||||||
* @return AI 추천 데이터 (JSON 형태의 Map)
|
* @return AI 추천 데이터 (JSON 형태의 Map)
|
||||||
*/
|
*/
|
||||||
Optional<Map<String, Object>> getAIRecommendation(Long eventDraftId);
|
Optional<Map<String, Object>> getAIRecommendation(String eventId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,9 @@ public interface RedisImageWriter {
|
|||||||
/**
|
/**
|
||||||
* 이미지 목록 캐싱
|
* 이미지 목록 캐싱
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 초안 ID
|
||||||
* @param images 이미지 목록
|
* @param images 이미지 목록
|
||||||
* @param ttlSeconds TTL (초)
|
* @param ttlSeconds TTL (초)
|
||||||
*/
|
*/
|
||||||
void cacheImages(Long eventDraftId, List<GeneratedImage> images, long ttlSeconds);
|
void cacheImages(String eventId, List<GeneratedImage> images, long ttlSeconds);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
package com.kt.event.content.infra;
|
package com.kt.event.content.infra;
|
||||||
|
|
||||||
|
import com.kt.event.common.security.JwtAuthenticationFilter;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.context.annotation.FilterType;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,6 +18,16 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
|||||||
"com.kt.event.content",
|
"com.kt.event.content",
|
||||||
"com.kt.event.common"
|
"com.kt.event.common"
|
||||||
})
|
})
|
||||||
|
@ComponentScan(
|
||||||
|
basePackages = {
|
||||||
|
"com.kt.event.content",
|
||||||
|
"com.kt.event.common"
|
||||||
|
},
|
||||||
|
excludeFilters = @ComponentScan.Filter(
|
||||||
|
type = FilterType.ASSIGNABLE_TYPE,
|
||||||
|
classes = JwtAuthenticationFilter.class
|
||||||
|
)
|
||||||
|
)
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableFeignClients(basePackages = "com.kt.event.content.infra.gateway.client")
|
@EnableFeignClients(basePackages = "com.kt.event.content.infra.gateway.client")
|
||||||
public class ContentApplication {
|
public class ContentApplication {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package com.kt.event.content.infra.config;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||||
@ -12,11 +11,9 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSeriali
|
|||||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis 설정 (Production 환경용)
|
* Redis 설정
|
||||||
* Local/Test 환경에서는 Mock Gateway 사용
|
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Profile({"!local", "!test"})
|
|
||||||
public class RedisConfig {
|
public class RedisConfig {
|
||||||
|
|
||||||
@Value("${spring.data.redis.host}")
|
@Value("${spring.data.redis.host}")
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import java.time.Duration;
|
|||||||
/**
|
/**
|
||||||
* Resilience4j Circuit Breaker 설정
|
* Resilience4j Circuit Breaker 설정
|
||||||
*
|
*
|
||||||
* Hugging Face API, Replicate API 및 Azure Blob Storage에 대한 Circuit Breaker 패턴 적용
|
* Replicate API 및 Azure Blob Storage에 대한 Circuit Breaker 패턴 적용
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
@ -89,40 +89,4 @@ public class Resilience4jConfig {
|
|||||||
|
|
||||||
return circuitBreaker;
|
return circuitBreaker;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hugging Face API Circuit Breaker
|
|
||||||
*
|
|
||||||
* - 실패율 50% 이상 시 Open
|
|
||||||
* - 최소 3개 요청 후 평가
|
|
||||||
* - Open 후 30초 대기 (Half-Open 전환)
|
|
||||||
* - Half-Open 상태에서 2개 요청으로 평가
|
|
||||||
*/
|
|
||||||
@Bean
|
|
||||||
public CircuitBreaker huggingfaceCircuitBreaker() {
|
|
||||||
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
|
|
||||||
.failureRateThreshold(50) // 실패율 50% 초과 시 Open
|
|
||||||
.slowCallRateThreshold(50) // 느린 호출 50% 초과 시 Open
|
|
||||||
.slowCallDurationThreshold(Duration.ofSeconds(60)) // 60초 이상 걸리면 느린 호출로 판단
|
|
||||||
.waitDurationInOpenState(Duration.ofSeconds(30)) // Open 후 30초 대기
|
|
||||||
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 횟수 기반 평가
|
|
||||||
.slidingWindowSize(10) // 최근 10개 요청 평가
|
|
||||||
.minimumNumberOfCalls(3) // 최소 3개 요청 후 평가
|
|
||||||
.permittedNumberOfCallsInHalfOpenState(2) // Half-Open에서 2개 요청으로 평가
|
|
||||||
.automaticTransitionFromOpenToHalfOpenEnabled(true) // 자동 Half-Open 전환
|
|
||||||
.build();
|
|
||||||
|
|
||||||
CircuitBreaker circuitBreaker = CircuitBreakerRegistry.of(config).circuitBreaker("huggingface");
|
|
||||||
|
|
||||||
// Circuit Breaker 이벤트 로깅
|
|
||||||
circuitBreaker.getEventPublisher()
|
|
||||||
.onSuccess(event -> log.debug("Hugging Face Circuit Breaker: Success"))
|
|
||||||
.onError(event -> log.warn("Hugging Face Circuit Breaker: Error - {}", event.getThrowable().getMessage()))
|
|
||||||
.onStateTransition(event -> log.warn("Hugging Face Circuit Breaker: State transition from {} to {}",
|
|
||||||
event.getStateTransition().getFromState(), event.getStateTransition().getToState()))
|
|
||||||
.onSlowCallRateExceeded(event -> log.warn("Hugging Face Circuit Breaker: Slow call rate exceeded"))
|
|
||||||
.onFailureRateExceeded(event -> log.warn("Hugging Face Circuit Breaker: Failure rate exceeded"));
|
|
||||||
|
|
||||||
return circuitBreaker;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,14 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Security 설정
|
* Spring Security 설정
|
||||||
* API 테스트를 위해 일단 모든 요청 허용 (추후 JWT 인증 추가)
|
* API 테스트를 위해 일단 모든 요청 허용
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@ -27,13 +28,20 @@ public class SecurityConfig {
|
|||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 모든 요청 허용 (테스트용, 추후 JWT 필터 추가 필요)
|
// 모든 요청 허용 (테스트용)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
.anyRequest().permitAll()
|
||||||
.requestMatchers("/actuator/**").permitAll()
|
|
||||||
.anyRequest().permitAll() // TODO: 추후 authenticated()로 변경
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome DevTools 요청 등 정적 리소스 요청을 Spring Security에서 제외
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||||
|
return (web) -> web.ignoring()
|
||||||
|
.requestMatchers("/.well-known/**");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,9 @@ public class SwaggerConfig {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.servers(List.of(
|
.servers(List.of(
|
||||||
|
new Server()
|
||||||
|
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/content")
|
||||||
|
.description("VM Development Server"),
|
||||||
new Server()
|
new Server()
|
||||||
.url("http://localhost:8084")
|
.url("http://localhost:8084")
|
||||||
.description("Local Development Server"),
|
.description("Local Development Server"),
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import com.kt.event.content.biz.usecase.out.RedisAIDataReader;
|
|||||||
import com.kt.event.content.biz.usecase.out.RedisImageWriter;
|
import com.kt.event.content.biz.usecase.out.RedisImageWriter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@ -31,13 +30,10 @@ import java.util.Optional;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis Gateway 구현체 (Production 환경용)
|
* Redis Gateway 구현체
|
||||||
*
|
|
||||||
* Local/Test 환경에서는 MockRedisGateway 사용
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@Profile({"!local", "!test"})
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader, ContentReader, ContentWriter {
|
public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader, ContentReader, ContentWriter {
|
||||||
|
|
||||||
@ -49,13 +45,13 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
private static final Duration DEFAULT_TTL = Duration.ofHours(24);
|
private static final Duration DEFAULT_TTL = Duration.ofHours(24);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Map<String, Object>> getAIRecommendation(Long eventDraftId) {
|
public Optional<Map<String, Object>> getAIRecommendation(String eventId) {
|
||||||
try {
|
try {
|
||||||
String key = AI_DATA_KEY_PREFIX + eventDraftId;
|
String key = AI_DATA_KEY_PREFIX + eventId;
|
||||||
Object data = redisTemplate.opsForValue().get(key);
|
Object data = redisTemplate.opsForValue().get(key);
|
||||||
|
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
log.warn("AI 이벤트 데이터를 찾을 수 없음: eventDraftId={}", eventDraftId);
|
log.warn("AI 이벤트 데이터를 찾을 수 없음: eventId={}", eventId);
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,48 +59,48 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
Map<String, Object> aiData = objectMapper.convertValue(data, Map.class);
|
Map<String, Object> aiData = objectMapper.convertValue(data, Map.class);
|
||||||
return Optional.of(aiData);
|
return Optional.of(aiData);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("AI 이벤트 데이터 조회 실패: eventDraftId={}", eventDraftId, e);
|
log.error("AI 이벤트 데이터 조회 실패: eventId={}", eventId, e);
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void cacheImages(Long eventDraftId, List<GeneratedImage> images, long ttlSeconds) {
|
public void cacheImages(String eventId, List<GeneratedImage> images, long ttlSeconds) {
|
||||||
try {
|
try {
|
||||||
String key = IMAGE_URL_KEY_PREFIX + eventDraftId;
|
String key = IMAGE_URL_KEY_PREFIX + eventId;
|
||||||
|
|
||||||
// 이미지 목록을 캐싱
|
// 이미지 목록을 캐싱
|
||||||
redisTemplate.opsForValue().set(key, images, Duration.ofSeconds(ttlSeconds));
|
redisTemplate.opsForValue().set(key, images, Duration.ofSeconds(ttlSeconds));
|
||||||
log.info("이미지 목록 캐싱 완료: eventDraftId={}, count={}, ttl={}초",
|
log.info("이미지 목록 캐싱 완료: eventId={}, count={}, ttl={}초",
|
||||||
eventDraftId, images.size(), ttlSeconds);
|
eventId, images.size(), ttlSeconds);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 목록 캐싱 실패: eventDraftId={}", eventDraftId, e);
|
log.error("이미지 목록 캐싱 실패: eventId={}", eventId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 URL 캐시 삭제
|
* 이미지 URL 캐시 삭제
|
||||||
*/
|
*/
|
||||||
public void deleteImageUrl(Long eventDraftId) {
|
public void deleteImageUrl(String eventId) {
|
||||||
try {
|
try {
|
||||||
String key = IMAGE_URL_KEY_PREFIX + eventDraftId;
|
String key = IMAGE_URL_KEY_PREFIX + eventId;
|
||||||
redisTemplate.delete(key);
|
redisTemplate.delete(key);
|
||||||
log.info("이미지 URL 캐시 삭제: eventDraftId={}", eventDraftId);
|
log.info("이미지 URL 캐시 삭제: eventId={}", eventId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 URL 캐시 삭제 실패: eventDraftId={}", eventDraftId, e);
|
log.error("이미지 URL 캐시 삭제 실패: eventId={}", eventId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 이벤트 데이터 캐시 삭제
|
* AI 이벤트 데이터 캐시 삭제
|
||||||
*/
|
*/
|
||||||
public void deleteAIEventData(Long eventDraftId) {
|
public void deleteAIEventData(String eventId) {
|
||||||
try {
|
try {
|
||||||
String key = AI_DATA_KEY_PREFIX + eventDraftId;
|
String key = AI_DATA_KEY_PREFIX + eventId;
|
||||||
redisTemplate.delete(key);
|
redisTemplate.delete(key);
|
||||||
log.info("AI 이벤트 데이터 캐시 삭제: eventDraftId={}", eventDraftId);
|
log.info("AI 이벤트 데이터 캐시 삭제: eventId={}", eventId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("AI 이벤트 데이터 캐시 삭제 실패: eventDraftId={}", eventDraftId, e);
|
log.error("AI 이벤트 데이터 캐시 삭제 실패: eventId={}", eventId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,26 +110,26 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 저장
|
* 이미지 저장
|
||||||
* Key: content:image:{eventDraftId}:{style}:{platform}
|
* Key: content:image:{eventId}:{style}:{platform}
|
||||||
*/
|
*/
|
||||||
public void saveImage(RedisImageData imageData, long ttlSeconds) {
|
public void saveImage(RedisImageData imageData, long ttlSeconds) {
|
||||||
try {
|
try {
|
||||||
String key = buildImageKey(imageData.getEventDraftId(), imageData.getStyle(), imageData.getPlatform());
|
String key = buildImageKey(imageData.getEventId(), imageData.getStyle(), imageData.getPlatform());
|
||||||
String json = objectMapper.writeValueAsString(imageData);
|
String json = objectMapper.writeValueAsString(imageData);
|
||||||
redisTemplate.opsForValue().set(key, json, Duration.ofSeconds(ttlSeconds));
|
redisTemplate.opsForValue().set(key, json, Duration.ofSeconds(ttlSeconds));
|
||||||
log.info("이미지 저장 완료: key={}, ttl={}초", key, ttlSeconds);
|
log.info("이미지 저장 완료: key={}, ttl={}초", key, ttlSeconds);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 저장 실패: eventDraftId={}, style={}, platform={}",
|
log.error("이미지 저장 실패: eventId={}, style={}, platform={}",
|
||||||
imageData.getEventDraftId(), imageData.getStyle(), imageData.getPlatform(), e);
|
imageData.getEventId(), imageData.getStyle(), imageData.getPlatform(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 이미지 조회
|
* 특정 이미지 조회
|
||||||
*/
|
*/
|
||||||
public Optional<RedisImageData> getImage(Long eventDraftId, ImageStyle style, Platform platform) {
|
public Optional<RedisImageData> getImage(String eventId, ImageStyle style, Platform platform) {
|
||||||
try {
|
try {
|
||||||
String key = buildImageKey(eventDraftId, style, platform);
|
String key = buildImageKey(eventId, style, platform);
|
||||||
Object data = redisTemplate.opsForValue().get(key);
|
Object data = redisTemplate.opsForValue().get(key);
|
||||||
|
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
@ -144,7 +140,7 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
RedisImageData imageData = objectMapper.readValue(data.toString(), RedisImageData.class);
|
RedisImageData imageData = objectMapper.readValue(data.toString(), RedisImageData.class);
|
||||||
return Optional.of(imageData);
|
return Optional.of(imageData);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 조회 실패: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform, e);
|
log.error("이미지 조회 실패: eventId={}, style={}, platform={}", eventId, style, platform, e);
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,13 +148,13 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
/**
|
/**
|
||||||
* 이벤트의 모든 이미지 조회
|
* 이벤트의 모든 이미지 조회
|
||||||
*/
|
*/
|
||||||
public List<RedisImageData> getImagesByEventId(Long eventDraftId) {
|
public List<RedisImageData> getImagesByEventId(String eventId) {
|
||||||
try {
|
try {
|
||||||
String pattern = IMAGE_KEY_PREFIX + eventDraftId + ":*";
|
String pattern = IMAGE_KEY_PREFIX + eventId + ":*";
|
||||||
var keys = redisTemplate.keys(pattern);
|
var keys = redisTemplate.keys(pattern);
|
||||||
|
|
||||||
if (keys == null || keys.isEmpty()) {
|
if (keys == null || keys.isEmpty()) {
|
||||||
log.warn("이벤트 이미지를 찾을 수 없음: eventDraftId={}", eventDraftId);
|
log.warn("이벤트 이미지를 찾을 수 없음: eventId={}", eventId);
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,10 +167,10 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("이벤트 이미지 조회 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
log.info("이벤트 이미지 조회 완료: eventId={}, count={}", eventId, images.size());
|
||||||
return images;
|
return images;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이벤트 이미지 조회 실패: eventDraftId={}", eventDraftId, e);
|
log.error("이벤트 이미지 조회 실패: eventId={}", eventId, e);
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -182,29 +178,29 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
/**
|
/**
|
||||||
* 이미지 삭제
|
* 이미지 삭제
|
||||||
*/
|
*/
|
||||||
public void deleteImage(Long eventDraftId, ImageStyle style, Platform platform) {
|
public void deleteImage(String eventId, ImageStyle style, Platform platform) {
|
||||||
try {
|
try {
|
||||||
String key = buildImageKey(eventDraftId, style, platform);
|
String key = buildImageKey(eventId, style, platform);
|
||||||
redisTemplate.delete(key);
|
redisTemplate.delete(key);
|
||||||
log.info("이미지 삭제 완료: key={}", key);
|
log.info("이미지 삭제 완료: key={}", key);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 삭제 실패: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform, e);
|
log.error("이미지 삭제 실패: eventId={}, style={}, platform={}", eventId, style, platform, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 여러 이미지 저장
|
* 여러 이미지 저장
|
||||||
*/
|
*/
|
||||||
public void saveImages(Long eventDraftId, List<RedisImageData> images, long ttlSeconds) {
|
public void saveImages(String eventId, List<RedisImageData> images, long ttlSeconds) {
|
||||||
images.forEach(image -> saveImage(image, ttlSeconds));
|
images.forEach(image -> saveImage(image, ttlSeconds));
|
||||||
log.info("여러 이미지 저장 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
log.info("여러 이미지 저장 완료: eventId={}, count={}", eventId, images.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 Key 생성
|
* 이미지 Key 생성
|
||||||
*/
|
*/
|
||||||
private String buildImageKey(Long eventDraftId, ImageStyle style, Platform platform) {
|
private String buildImageKey(String eventId, ImageStyle style, Platform platform) {
|
||||||
return IMAGE_KEY_PREFIX + eventDraftId + ":" + style.name() + ":" + platform.name();
|
return IMAGE_KEY_PREFIX + eventId + ":" + style.name() + ":" + platform.name();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Job 상태 관리 ====================
|
// ==================== Job 상태 관리 ====================
|
||||||
@ -222,7 +218,7 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
// Hash 형태로 저장
|
// Hash 형태로 저장
|
||||||
Map<String, String> jobFields = Map.of(
|
Map<String, String> jobFields = Map.of(
|
||||||
"id", jobData.getId(),
|
"id", jobData.getId(),
|
||||||
"eventDraftId", String.valueOf(jobData.getEventDraftId()),
|
"eventId", jobData.getEventId(),
|
||||||
"jobType", jobData.getJobType(),
|
"jobType", jobData.getJobType(),
|
||||||
"status", jobData.getStatus(),
|
"status", jobData.getStatus(),
|
||||||
"progress", String.valueOf(jobData.getProgress()),
|
"progress", String.valueOf(jobData.getProgress()),
|
||||||
@ -256,7 +252,7 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
|
|
||||||
RedisJobData jobData = RedisJobData.builder()
|
RedisJobData jobData = RedisJobData.builder()
|
||||||
.id(getString(jobFields, "id"))
|
.id(getString(jobFields, "id"))
|
||||||
.eventDraftId(getLong(jobFields, "eventDraftId"))
|
.eventId(getString(jobFields, "eventId"))
|
||||||
.jobType(getString(jobFields, "jobType"))
|
.jobType(getString(jobFields, "jobType"))
|
||||||
.status(getString(jobFields, "status"))
|
.status(getString(jobFields, "status"))
|
||||||
.progress(getInteger(jobFields, "progress"))
|
.progress(getInteger(jobFields, "progress"))
|
||||||
@ -349,23 +345,23 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
private static final String IMAGE_IDS_SET_KEY_PREFIX = "content:images:";
|
private static final String IMAGE_IDS_SET_KEY_PREFIX = "content:images:";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Content> findByEventDraftIdWithImages(Long eventDraftId) {
|
public Optional<Content> findByEventDraftIdWithImages(String eventId) {
|
||||||
try {
|
try {
|
||||||
String contentKey = CONTENT_META_KEY_PREFIX + eventDraftId;
|
String contentKey = CONTENT_META_KEY_PREFIX + eventId;
|
||||||
Map<Object, Object> contentFields = redisTemplate.opsForHash().entries(contentKey);
|
Map<Object, Object> contentFields = redisTemplate.opsForHash().entries(contentKey);
|
||||||
|
|
||||||
if (contentFields.isEmpty()) {
|
if (contentFields.isEmpty()) {
|
||||||
log.warn("Content를 찾을 수 없음: eventDraftId={}", eventDraftId);
|
log.warn("Content를 찾을 수 없음: eventId={}", eventId);
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 목록 조회
|
// 이미지 목록 조회
|
||||||
List<GeneratedImage> images = findImagesByEventDraftId(eventDraftId);
|
List<GeneratedImage> images = findImagesByEventDraftId(eventId);
|
||||||
|
|
||||||
// Content 재구성
|
// Content 재구성
|
||||||
Content content = Content.builder()
|
Content content = Content.builder()
|
||||||
.id(getLong(contentFields, "id"))
|
.id(getLong(contentFields, "id"))
|
||||||
.eventDraftId(getLong(contentFields, "eventDraftId"))
|
.eventId(getString(contentFields, "eventId"))
|
||||||
.eventTitle(getString(contentFields, "eventTitle"))
|
.eventTitle(getString(contentFields, "eventTitle"))
|
||||||
.eventDescription(getString(contentFields, "eventDescription"))
|
.eventDescription(getString(contentFields, "eventDescription"))
|
||||||
.images(images)
|
.images(images)
|
||||||
@ -375,7 +371,7 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
|
|
||||||
return Optional.of(content);
|
return Optional.of(content);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Content 조회 실패: eventDraftId={}", eventDraftId, e);
|
log.error("Content 조회 실패: eventId={}", eventId, e);
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -400,13 +396,13 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId) {
|
public List<GeneratedImage> findImagesByEventDraftId(String eventId) {
|
||||||
try {
|
try {
|
||||||
String setKey = IMAGE_IDS_SET_KEY_PREFIX + eventDraftId;
|
String setKey = IMAGE_IDS_SET_KEY_PREFIX + eventId;
|
||||||
var imageIdSet = redisTemplate.opsForSet().members(setKey);
|
var imageIdSet = redisTemplate.opsForSet().members(setKey);
|
||||||
|
|
||||||
if (imageIdSet == null || imageIdSet.isEmpty()) {
|
if (imageIdSet == null || imageIdSet.isEmpty()) {
|
||||||
log.info("이미지 목록이 비어있음: eventDraftId={}", eventDraftId);
|
log.info("이미지 목록이 비어있음: eventId={}", eventId);
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -416,10 +412,10 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
findImageById(imageId).ifPresent(images::add);
|
findImageById(imageId).ifPresent(images::add);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("이미지 목록 조회 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
log.info("이미지 목록 조회 완료: eventId={}, count={}", eventId, images.size());
|
||||||
return images;
|
return images;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 목록 조회 실패: eventDraftId={}", eventDraftId, e);
|
log.error("이미지 목록 조회 실패: eventId={}", eventId, e);
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -433,12 +429,12 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
public Content save(Content content) {
|
public Content save(Content content) {
|
||||||
try {
|
try {
|
||||||
Long id = content.getId() != null ? content.getId() : nextContentId++;
|
Long id = content.getId() != null ? content.getId() : nextContentId++;
|
||||||
String contentKey = CONTENT_META_KEY_PREFIX + content.getEventDraftId();
|
String contentKey = CONTENT_META_KEY_PREFIX + content.getEventId();
|
||||||
|
|
||||||
// Content 메타 정보 저장
|
// Content 메타 정보 저장
|
||||||
Map<String, String> contentFields = new java.util.HashMap<>();
|
Map<String, String> contentFields = new java.util.HashMap<>();
|
||||||
contentFields.put("id", String.valueOf(id));
|
contentFields.put("id", String.valueOf(id));
|
||||||
contentFields.put("eventDraftId", String.valueOf(content.getEventDraftId()));
|
contentFields.put("eventId", String.valueOf(content.getEventId()));
|
||||||
contentFields.put("eventTitle", content.getEventTitle() != null ? content.getEventTitle() : "");
|
contentFields.put("eventTitle", content.getEventTitle() != null ? content.getEventTitle() : "");
|
||||||
contentFields.put("eventDescription", content.getEventDescription() != null ? content.getEventDescription() : "");
|
contentFields.put("eventDescription", content.getEventDescription() != null ? content.getEventDescription() : "");
|
||||||
contentFields.put("createdAt", content.getCreatedAt() != null ? content.getCreatedAt().toString() : LocalDateTime.now().toString());
|
contentFields.put("createdAt", content.getCreatedAt() != null ? content.getCreatedAt().toString() : LocalDateTime.now().toString());
|
||||||
@ -450,7 +446,7 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
// Content 재구성하여 반환
|
// Content 재구성하여 반환
|
||||||
Content savedContent = Content.builder()
|
Content savedContent = Content.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.eventDraftId(content.getEventDraftId())
|
.eventId(content.getEventId())
|
||||||
.eventTitle(content.getEventTitle())
|
.eventTitle(content.getEventTitle())
|
||||||
.eventDescription(content.getEventDescription())
|
.eventDescription(content.getEventDescription())
|
||||||
.images(content.getImages())
|
.images(content.getImages())
|
||||||
@ -458,10 +454,10 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
.updatedAt(content.getUpdatedAt())
|
.updatedAt(content.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
log.info("Content 저장 완료: contentId={}, eventDraftId={}", id, content.getEventDraftId());
|
log.info("Content 저장 완료: contentId={}, eventId={}", id, content.getEventId());
|
||||||
return savedContent;
|
return savedContent;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Content 저장 실패: eventDraftId={}", content.getEventDraftId(), e);
|
log.error("Content 저장 실패: eventId={}", content.getEventId(), e);
|
||||||
throw new RuntimeException("Content 저장 실패", e);
|
throw new RuntimeException("Content 저장 실패", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -475,7 +471,7 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
String imageKey = IMAGE_BY_ID_KEY_PREFIX + imageId;
|
String imageKey = IMAGE_BY_ID_KEY_PREFIX + imageId;
|
||||||
GeneratedImage savedImage = GeneratedImage.builder()
|
GeneratedImage savedImage = GeneratedImage.builder()
|
||||||
.id(imageId)
|
.id(imageId)
|
||||||
.eventDraftId(image.getEventDraftId())
|
.eventId(image.getEventId())
|
||||||
.style(image.getStyle())
|
.style(image.getStyle())
|
||||||
.platform(image.getPlatform())
|
.platform(image.getPlatform())
|
||||||
.cdnUrl(image.getCdnUrl())
|
.cdnUrl(image.getCdnUrl())
|
||||||
@ -489,18 +485,29 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
redisTemplate.opsForValue().set(imageKey, json, DEFAULT_TTL);
|
redisTemplate.opsForValue().set(imageKey, json, DEFAULT_TTL);
|
||||||
|
|
||||||
// Image ID를 Set에 추가
|
// Image ID를 Set에 추가
|
||||||
String setKey = IMAGE_IDS_SET_KEY_PREFIX + image.getEventDraftId();
|
String setKey = IMAGE_IDS_SET_KEY_PREFIX + image.getEventId();
|
||||||
redisTemplate.opsForSet().add(setKey, imageId);
|
redisTemplate.opsForSet().add(setKey, imageId);
|
||||||
redisTemplate.expire(setKey, DEFAULT_TTL);
|
redisTemplate.expire(setKey, DEFAULT_TTL);
|
||||||
|
|
||||||
log.info("이미지 저장 완료: imageId={}, eventDraftId={}", imageId, image.getEventDraftId());
|
log.info("이미지 저장 완료: imageId={}, eventId={}", imageId, image.getEventId());
|
||||||
return savedImage;
|
return savedImage;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 저장 실패: eventDraftId={}", image.getEventDraftId(), e);
|
log.error("이미지 저장 실패: eventId={}", image.getEventId(), e);
|
||||||
throw new RuntimeException("이미지 저장 실패", e);
|
throw new RuntimeException("이미지 저장 실패", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GeneratedImage getImageById(Long imageId) {
|
||||||
|
try {
|
||||||
|
Optional<GeneratedImage> imageOpt = findImageById(imageId);
|
||||||
|
return imageOpt.orElse(null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("이미지 조회 실패: imageId={}", imageId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteImageById(Long imageId) {
|
public void deleteImageById(Long imageId) {
|
||||||
try {
|
try {
|
||||||
@ -518,10 +525,10 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
|
|||||||
redisTemplate.delete(imageKey);
|
redisTemplate.delete(imageKey);
|
||||||
|
|
||||||
// Set에서 Image ID 제거
|
// Set에서 Image ID 제거
|
||||||
String setKey = IMAGE_IDS_SET_KEY_PREFIX + image.getEventDraftId();
|
String setKey = IMAGE_IDS_SET_KEY_PREFIX + image.getEventId();
|
||||||
redisTemplate.opsForSet().remove(setKey, imageId);
|
redisTemplate.opsForSet().remove(setKey, imageId);
|
||||||
|
|
||||||
log.info("이미지 삭제 완료: imageId={}, eventDraftId={}", imageId, image.getEventDraftId());
|
log.info("이미지 삭제 완료: imageId={}, eventId={}", imageId, image.getEventId());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 삭제 실패: imageId={}", imageId, e);
|
log.error("이미지 삭제 실패: imageId={}", imageId, e);
|
||||||
throw new RuntimeException("이미지 삭제 실패", e);
|
throw new RuntimeException("이미지 삭제 실패", e);
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import jakarta.annotation.PostConstruct;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
@ -26,7 +25,6 @@ import java.util.UUID;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@Profile({"prod", "dev"}) // production 및 dev 환경에서 활성화 (local은 Mock 사용)
|
|
||||||
public class AzureBlobStorageUploader implements CDNUploader {
|
public class AzureBlobStorageUploader implements CDNUploader {
|
||||||
|
|
||||||
@Value("${azure.storage.connection-string}")
|
@Value("${azure.storage.connection-string}")
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
package com.kt.event.content.infra.gateway.client;
|
|
||||||
|
|
||||||
import com.kt.event.content.infra.gateway.client.dto.HuggingFaceRequest;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.client.RestClient;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hugging Face Inference API 클라이언트
|
|
||||||
*
|
|
||||||
* API 문서: https://huggingface.co/docs/api-inference/index
|
|
||||||
* Stable Diffusion 모델: stabilityai/stable-diffusion-2-1
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@Profile({"prod", "dev"})
|
|
||||||
public class HuggingFaceApiClient {
|
|
||||||
|
|
||||||
private final RestClient restClient;
|
|
||||||
|
|
||||||
@Value("${huggingface.api.url:https://api-inference.huggingface.co}")
|
|
||||||
private String apiUrl;
|
|
||||||
|
|
||||||
@Value("${huggingface.api.token}")
|
|
||||||
private String apiToken;
|
|
||||||
|
|
||||||
@Value("${huggingface.model:stabilityai/stable-diffusion-2-1}")
|
|
||||||
private String modelId;
|
|
||||||
|
|
||||||
public HuggingFaceApiClient(RestClient.Builder restClientBuilder) {
|
|
||||||
this.restClient = restClientBuilder.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 생성 요청 (동기 방식)
|
|
||||||
*
|
|
||||||
* @param request Hugging Face 요청
|
|
||||||
* @return 생성된 이미지 바이트 데이터
|
|
||||||
*/
|
|
||||||
public byte[] generateImage(HuggingFaceRequest request) {
|
|
||||||
String url = String.format("%s/models/%s", apiUrl, modelId);
|
|
||||||
|
|
||||||
return restClient.post()
|
|
||||||
.uri(url)
|
|
||||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiToken)
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(request)
|
|
||||||
.retrieve()
|
|
||||||
.body(byte[].class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -16,7 +16,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class ReplicateApiConfig {
|
public class ReplicateApiConfig {
|
||||||
|
|
||||||
@Value("${replicate.api.token}")
|
@Value("${replicate.api.token:}")
|
||||||
private String apiToken;
|
private String apiToken;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
package com.kt.event.content.infra.gateway.client.dto;
|
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hugging Face Inference API 요청 DTO
|
|
||||||
*
|
|
||||||
* API 문서: https://huggingface.co/docs/api-inference/index
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Builder
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class HuggingFaceRequest {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 생성 프롬프트
|
|
||||||
*/
|
|
||||||
private String inputs;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 생성 파라미터
|
|
||||||
*/
|
|
||||||
private Parameters parameters;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Builder
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public static class Parameters {
|
|
||||||
/**
|
|
||||||
* Negative prompt (생성하지 않을 내용)
|
|
||||||
*/
|
|
||||||
private String negative_prompt;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 너비
|
|
||||||
*/
|
|
||||||
private Integer width;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 높이
|
|
||||||
*/
|
|
||||||
private Integer height;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guidance scale (프롬프트 준수 정도, 기본: 7.5)
|
|
||||||
*/
|
|
||||||
private Double guidance_scale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inference steps (품질, 기본: 50)
|
|
||||||
*/
|
|
||||||
private Integer num_inference_steps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
package com.kt.event.content.infra.gateway.mock;
|
|
||||||
|
|
||||||
import com.kt.event.content.biz.usecase.out.CDNUploader;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock CDN Uploader (테스트용)
|
|
||||||
* 실제 Azure Blob Storage 연동 전까지 사용
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@Profile({"local", "test"})
|
|
||||||
public class MockCDNUploader implements CDNUploader {
|
|
||||||
|
|
||||||
private static final String MOCK_CDN_BASE_URL = "https://cdn.kt-event.com/images/mock";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String upload(byte[] imageData, String fileName) {
|
|
||||||
log.info("[MOCK] CDN에 이미지 업로드: fileName={}, size={} bytes",
|
|
||||||
fileName, imageData.length);
|
|
||||||
|
|
||||||
// Mock CDN URL 생성
|
|
||||||
String mockUrl = String.format("%s/%s", MOCK_CDN_BASE_URL, fileName);
|
|
||||||
|
|
||||||
log.info("[MOCK] 업로드된 CDN URL: {}", mockUrl);
|
|
||||||
|
|
||||||
return mockUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
package com.kt.event.content.infra.gateway.mock;
|
|
||||||
|
|
||||||
import com.kt.event.content.biz.domain.ImageStyle;
|
|
||||||
import com.kt.event.content.biz.domain.Platform;
|
|
||||||
import com.kt.event.content.biz.usecase.out.ImageGeneratorCaller;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock Image Generator (테스트용)
|
|
||||||
* 실제 AI 이미지 생성 API 연동 전까지 사용
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@Profile({"local", "test"})
|
|
||||||
public class MockImageGenerator implements ImageGeneratorCaller {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] generateImage(String prompt, ImageStyle style, Platform platform) {
|
|
||||||
log.info("[MOCK] AI 이미지 생성: prompt='{}', style={}, platform={}",
|
|
||||||
prompt, style, platform);
|
|
||||||
|
|
||||||
// Mock: 빈 바이트 배열 반환 (실제로는 AI가 생성한 이미지 데이터)
|
|
||||||
byte[] mockImageData = createMockImageData(style, platform);
|
|
||||||
|
|
||||||
log.info("[MOCK] 이미지 생성 완료: size={} bytes", mockImageData.length);
|
|
||||||
|
|
||||||
return mockImageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock 이미지 데이터 생성
|
|
||||||
* 실제로는 PNG/JPEG 이미지 바이너리 데이터
|
|
||||||
*/
|
|
||||||
private byte[] createMockImageData(ImageStyle style, Platform platform) {
|
|
||||||
// 간단한 Mock 데이터 생성 (실제로는 이미지 바이너리)
|
|
||||||
String mockContent = String.format("MOCK_IMAGE_DATA[style=%s,platform=%s]", style, platform);
|
|
||||||
return mockContent.getBytes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,430 +0,0 @@
|
|||||||
package com.kt.event.content.infra.gateway.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.RedisImageData;
|
|
||||||
import com.kt.event.content.biz.dto.RedisJobData;
|
|
||||||
import com.kt.event.content.biz.usecase.out.ContentReader;
|
|
||||||
import com.kt.event.content.biz.usecase.out.ContentWriter;
|
|
||||||
import com.kt.event.content.biz.usecase.out.ImageReader;
|
|
||||||
import com.kt.event.content.biz.usecase.out.ImageWriter;
|
|
||||||
import com.kt.event.content.biz.usecase.out.JobReader;
|
|
||||||
import com.kt.event.content.biz.usecase.out.JobWriter;
|
|
||||||
import com.kt.event.content.biz.usecase.out.RedisAIDataReader;
|
|
||||||
import com.kt.event.content.biz.usecase.out.RedisImageWriter;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.context.annotation.Primary;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock Redis Gateway (테스트용)
|
|
||||||
* 실제 Redis 연동 전까지 사용
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@Primary
|
|
||||||
@Profile({"local", "test"})
|
|
||||||
public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader, ContentReader, ContentWriter {
|
|
||||||
|
|
||||||
private final Map<Long, Map<String, Object>> aiDataCache = new HashMap<>();
|
|
||||||
|
|
||||||
// In-memory storage for contents, images, and jobs
|
|
||||||
private final Map<Long, Content> contentStorage = new ConcurrentHashMap<>();
|
|
||||||
private final Map<Long, GeneratedImage> imageByIdStorage = new ConcurrentHashMap<>();
|
|
||||||
private final Map<String, RedisImageData> imageStorage = new ConcurrentHashMap<>();
|
|
||||||
private final Map<String, RedisJobData> jobStorage = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// RedisAIDataReader 구현
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<Map<String, Object>> getAIRecommendation(Long eventDraftId) {
|
|
||||||
log.info("[MOCK] Redis에서 AI 추천 데이터 조회: eventDraftId={}", eventDraftId);
|
|
||||||
|
|
||||||
// Mock 데이터 반환
|
|
||||||
Map<String, Object> mockData = new HashMap<>();
|
|
||||||
mockData.put("title", "테스트 이벤트 제목");
|
|
||||||
mockData.put("description", "테스트 이벤트 설명");
|
|
||||||
mockData.put("brandColor", "#FF5733");
|
|
||||||
|
|
||||||
return Optional.of(mockData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// RedisImageWriter 구현
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void cacheImages(Long eventDraftId, List<GeneratedImage> images, long ttlSeconds) {
|
|
||||||
log.info("[MOCK] Redis에 이미지 캐싱: eventDraftId={}, count={}, ttl={}초",
|
|
||||||
eventDraftId, images.size(), ttlSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 이미지 CRUD ====================
|
|
||||||
|
|
||||||
private static final String IMAGE_KEY_PREFIX = "content:image:";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 저장
|
|
||||||
*/
|
|
||||||
public void saveImage(RedisImageData imageData, long ttlSeconds) {
|
|
||||||
try {
|
|
||||||
String key = buildImageKey(imageData.getEventDraftId(), imageData.getStyle(), imageData.getPlatform());
|
|
||||||
imageStorage.put(key, imageData);
|
|
||||||
log.info("[MOCK] 이미지 저장 완료: key={}, ttl={}초", key, ttlSeconds);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] 이미지 저장 실패: eventDraftId={}, style={}, platform={}",
|
|
||||||
imageData.getEventDraftId(), imageData.getStyle(), imageData.getPlatform(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 이미지 조회
|
|
||||||
*/
|
|
||||||
public Optional<RedisImageData> getImage(Long eventDraftId, ImageStyle style, Platform platform) {
|
|
||||||
try {
|
|
||||||
String key = buildImageKey(eventDraftId, style, platform);
|
|
||||||
RedisImageData imageData = imageStorage.get(key);
|
|
||||||
|
|
||||||
if (imageData == null) {
|
|
||||||
log.warn("[MOCK] 이미지를 찾을 수 없음: key={}", key);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Optional.of(imageData);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] 이미지 조회 실패: eventDraftId={}, style={}, platform={}",
|
|
||||||
eventDraftId, style, platform, e);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트의 모든 이미지 조회
|
|
||||||
*/
|
|
||||||
public List<RedisImageData> getImagesByEventId(Long eventDraftId) {
|
|
||||||
try {
|
|
||||||
String pattern = IMAGE_KEY_PREFIX + eventDraftId + ":";
|
|
||||||
|
|
||||||
List<RedisImageData> images = imageStorage.entrySet().stream()
|
|
||||||
.filter(entry -> entry.getKey().startsWith(pattern))
|
|
||||||
.map(Map.Entry::getValue)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
log.info("[MOCK] 이벤트 이미지 조회 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
|
||||||
return images;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] 이벤트 이미지 조회 실패: eventDraftId={}", eventDraftId, e);
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 삭제
|
|
||||||
*/
|
|
||||||
public void deleteImage(Long eventDraftId, ImageStyle style, Platform platform) {
|
|
||||||
try {
|
|
||||||
String key = buildImageKey(eventDraftId, style, platform);
|
|
||||||
imageStorage.remove(key);
|
|
||||||
log.info("[MOCK] 이미지 삭제 완료: key={}", key);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] 이미지 삭제 실패: eventDraftId={}, style={}, platform={}",
|
|
||||||
eventDraftId, style, platform, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 여러 이미지 저장
|
|
||||||
*/
|
|
||||||
public void saveImages(Long eventDraftId, List<RedisImageData> images, long ttlSeconds) {
|
|
||||||
images.forEach(image -> saveImage(image, ttlSeconds));
|
|
||||||
log.info("[MOCK] 여러 이미지 저장 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 Key 생성
|
|
||||||
*/
|
|
||||||
private String buildImageKey(Long eventDraftId, ImageStyle style, Platform platform) {
|
|
||||||
return IMAGE_KEY_PREFIX + eventDraftId + ":" + style.name() + ":" + platform.name();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Job 상태 관리 ====================
|
|
||||||
|
|
||||||
private static final String JOB_KEY_PREFIX = "job:";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Job 생성/저장
|
|
||||||
*/
|
|
||||||
public void saveJob(RedisJobData jobData, long ttlSeconds) {
|
|
||||||
try {
|
|
||||||
String key = JOB_KEY_PREFIX + jobData.getId();
|
|
||||||
jobStorage.put(key, jobData);
|
|
||||||
log.info("[MOCK] Job 저장 완료: jobId={}, status={}, ttl={}초",
|
|
||||||
jobData.getId(), jobData.getStatus(), ttlSeconds);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] Job 저장 실패: jobId={}", jobData.getId(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Job 조회
|
|
||||||
*/
|
|
||||||
public Optional<RedisJobData> getJob(String jobId) {
|
|
||||||
try {
|
|
||||||
String key = JOB_KEY_PREFIX + jobId;
|
|
||||||
RedisJobData jobData = jobStorage.get(key);
|
|
||||||
|
|
||||||
if (jobData == null) {
|
|
||||||
log.warn("[MOCK] Job을 찾을 수 없음: jobId={}", jobId);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Optional.of(jobData);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] Job 조회 실패: jobId={}", jobId, e);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Job 상태 업데이트
|
|
||||||
*/
|
|
||||||
public void updateJobStatus(String jobId, String status, Integer progress) {
|
|
||||||
try {
|
|
||||||
String key = JOB_KEY_PREFIX + jobId;
|
|
||||||
RedisJobData jobData = jobStorage.get(key);
|
|
||||||
|
|
||||||
if (jobData != null) {
|
|
||||||
jobData.setStatus(status);
|
|
||||||
jobData.setProgress(progress);
|
|
||||||
jobData.setUpdatedAt(LocalDateTime.now());
|
|
||||||
jobStorage.put(key, jobData);
|
|
||||||
log.info("[MOCK] Job 상태 업데이트: jobId={}, status={}, progress={}",
|
|
||||||
jobId, status, progress);
|
|
||||||
} else {
|
|
||||||
log.warn("[MOCK] Job을 찾을 수 없어 상태 업데이트 실패: jobId={}", jobId);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] Job 상태 업데이트 실패: jobId={}", jobId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Job 결과 메시지 업데이트
|
|
||||||
*/
|
|
||||||
public void updateJobResult(String jobId, String resultMessage) {
|
|
||||||
try {
|
|
||||||
String key = JOB_KEY_PREFIX + jobId;
|
|
||||||
RedisJobData jobData = jobStorage.get(key);
|
|
||||||
|
|
||||||
if (jobData != null) {
|
|
||||||
jobData.setResultMessage(resultMessage);
|
|
||||||
jobData.setUpdatedAt(LocalDateTime.now());
|
|
||||||
jobStorage.put(key, jobData);
|
|
||||||
log.info("[MOCK] Job 결과 업데이트: jobId={}, resultMessage={}", jobId, resultMessage);
|
|
||||||
} else {
|
|
||||||
log.warn("[MOCK] Job을 찾을 수 없어 결과 업데이트 실패: jobId={}", jobId);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] Job 결과 업데이트 실패: jobId={}", jobId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Job 에러 메시지 업데이트
|
|
||||||
*/
|
|
||||||
public void updateJobError(String jobId, String errorMessage) {
|
|
||||||
try {
|
|
||||||
String key = JOB_KEY_PREFIX + jobId;
|
|
||||||
RedisJobData jobData = jobStorage.get(key);
|
|
||||||
|
|
||||||
if (jobData != null) {
|
|
||||||
jobData.setErrorMessage(errorMessage);
|
|
||||||
jobData.setStatus("FAILED");
|
|
||||||
jobData.setUpdatedAt(LocalDateTime.now());
|
|
||||||
jobStorage.put(key, jobData);
|
|
||||||
log.info("[MOCK] Job 에러 업데이트: jobId={}, errorMessage={}", jobId, errorMessage);
|
|
||||||
} else {
|
|
||||||
log.warn("[MOCK] Job을 찾을 수 없어 에러 업데이트 실패: jobId={}", jobId);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] Job 에러 업데이트 실패: jobId={}", jobId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== ContentReader 구현 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public Optional<Content> findByEventDraftIdWithImages(Long eventDraftId) {
|
|
||||||
try {
|
|
||||||
Content content = contentStorage.get(eventDraftId);
|
|
||||||
if (content == null) {
|
|
||||||
log.warn("[MOCK] Content를 찾을 수 없음: eventDraftId={}", eventDraftId);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미지 목록 조회 및 Content 재생성 (immutable pattern)
|
|
||||||
List<GeneratedImage> images = findImagesByEventDraftId(eventDraftId);
|
|
||||||
Content contentWithImages = Content.builder()
|
|
||||||
.id(content.getId())
|
|
||||||
.eventDraftId(content.getEventDraftId())
|
|
||||||
.eventTitle(content.getEventTitle())
|
|
||||||
.eventDescription(content.getEventDescription())
|
|
||||||
.images(images)
|
|
||||||
.createdAt(content.getCreatedAt())
|
|
||||||
.updatedAt(content.getUpdatedAt())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return Optional.of(contentWithImages);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] Content 조회 실패: eventDraftId={}", eventDraftId, e);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 ID로 이미지 조회
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public Optional<GeneratedImage> findImageById(Long imageId) {
|
|
||||||
try {
|
|
||||||
GeneratedImage image = imageByIdStorage.get(imageId);
|
|
||||||
if (image == null) {
|
|
||||||
log.warn("[MOCK] 이미지를 찾을 수 없음: imageId={}", imageId);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return Optional.of(image);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] 이미지 조회 실패: imageId={}", imageId, e);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 초안 ID로 이미지 목록 조회
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId) {
|
|
||||||
try {
|
|
||||||
return imageByIdStorage.values().stream()
|
|
||||||
.filter(image -> image.getEventDraftId().equals(eventDraftId))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] 이미지 목록 조회 실패: eventDraftId={}", eventDraftId, e);
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== ContentWriter 구현 ====================
|
|
||||||
|
|
||||||
private static Long nextContentId = 1L;
|
|
||||||
private static Long nextImageId = 1L;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 콘텐츠 저장
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public Content save(Content content) {
|
|
||||||
try {
|
|
||||||
// ID가 없으면 생성하여 새 Content 객체 생성 (immutable pattern)
|
|
||||||
Long id = content.getId() != null ? content.getId() : nextContentId++;
|
|
||||||
|
|
||||||
Content savedContent = Content.builder()
|
|
||||||
.id(id)
|
|
||||||
.eventDraftId(content.getEventDraftId())
|
|
||||||
.eventTitle(content.getEventTitle())
|
|
||||||
.eventDescription(content.getEventDescription())
|
|
||||||
.images(content.getImages())
|
|
||||||
.createdAt(content.getCreatedAt())
|
|
||||||
.updatedAt(content.getUpdatedAt())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
contentStorage.put(savedContent.getEventDraftId(), savedContent);
|
|
||||||
log.info("[MOCK] Content 저장 완료: contentId={}, eventDraftId={}",
|
|
||||||
savedContent.getId(), savedContent.getEventDraftId());
|
|
||||||
|
|
||||||
return savedContent;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] Content 저장 실패: eventDraftId={}", content.getEventDraftId(), e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 저장
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public GeneratedImage saveImage(GeneratedImage image) {
|
|
||||||
try {
|
|
||||||
// ID가 없으면 생성하여 새 GeneratedImage 객체 생성 (immutable pattern)
|
|
||||||
Long id = image.getId() != null ? image.getId() : nextImageId++;
|
|
||||||
|
|
||||||
GeneratedImage savedImage = GeneratedImage.builder()
|
|
||||||
.id(id)
|
|
||||||
.eventDraftId(image.getEventDraftId())
|
|
||||||
.style(image.getStyle())
|
|
||||||
.platform(image.getPlatform())
|
|
||||||
.cdnUrl(image.getCdnUrl())
|
|
||||||
.prompt(image.getPrompt())
|
|
||||||
.selected(image.isSelected())
|
|
||||||
.createdAt(image.getCreatedAt())
|
|
||||||
.updatedAt(image.getUpdatedAt())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
imageByIdStorage.put(savedImage.getId(), savedImage);
|
|
||||||
log.info("[MOCK] 이미지 저장 완료: imageId={}, eventDraftId={}, style={}, platform={}",
|
|
||||||
savedImage.getId(), savedImage.getEventDraftId(), savedImage.getStyle(), savedImage.getPlatform());
|
|
||||||
|
|
||||||
return savedImage;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] 이미지 저장 실패: eventDraftId={}", image.getEventDraftId(), e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 ID로 이미지 삭제
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void deleteImageById(Long imageId) {
|
|
||||||
try {
|
|
||||||
// imageByIdStorage에서 이미지 조회
|
|
||||||
GeneratedImage image = imageByIdStorage.get(imageId);
|
|
||||||
|
|
||||||
if (image == null) {
|
|
||||||
log.warn("[MOCK] 삭제할 이미지를 찾을 수 없음: imageId={}", imageId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageByIdStorage에서 삭제
|
|
||||||
imageByIdStorage.remove(imageId);
|
|
||||||
|
|
||||||
// imageStorage에서도 삭제 (Redis 캐시 스토리지)
|
|
||||||
String key = buildImageKey(image.getEventDraftId(), image.getStyle(), image.getPlatform());
|
|
||||||
imageStorage.remove(key);
|
|
||||||
|
|
||||||
log.info("[MOCK] 이미지 삭제 완료: imageId={}, eventDraftId={}, style={}, platform={}",
|
|
||||||
imageId, image.getEventDraftId(), image.getStyle(), image.getPlatform());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MOCK] 이미지 삭제 실패: imageId={}", imageId, e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -31,7 +31,7 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/content")
|
@RequestMapping
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ContentController {
|
public class ContentController {
|
||||||
|
|
||||||
@ -52,8 +52,8 @@ public class ContentController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/images/generate")
|
@PostMapping("/images/generate")
|
||||||
public ResponseEntity<JobInfo> generateImages(@RequestBody ContentCommand.GenerateImages command) {
|
public ResponseEntity<JobInfo> generateImages(@RequestBody ContentCommand.GenerateImages command) {
|
||||||
log.info("이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
|
log.info("이미지 생성 요청: eventId={}, styles={}, platforms={}",
|
||||||
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
|
command.getEventId(), command.getStyles(), command.getPlatforms());
|
||||||
|
|
||||||
JobInfo jobInfo = generateImagesUseCase.execute(command);
|
JobInfo jobInfo = generateImagesUseCase.execute(command);
|
||||||
|
|
||||||
@ -77,42 +77,42 @@ public class ContentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/content/events/{eventDraftId}
|
* GET /api/v1/content/events/{eventId}
|
||||||
* 이벤트의 생성된 콘텐츠 조회
|
* 이벤트의 생성된 콘텐츠 조회
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 ID
|
||||||
* @return 200 OK - 콘텐츠 정보 (이미지 목록 포함)
|
* @return 200 OK - 콘텐츠 정보 (이미지 목록 포함)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/events/{eventDraftId}")
|
@GetMapping("/events/{eventId}")
|
||||||
public ResponseEntity<ContentInfo> getContentByEventId(@PathVariable Long eventDraftId) {
|
public ResponseEntity<ContentInfo> getContentByEventId(@PathVariable String eventId) {
|
||||||
log.info("이벤트 콘텐츠 조회: eventDraftId={}", eventDraftId);
|
log.info("이벤트 콘텐츠 조회: eventId={}", eventId);
|
||||||
|
|
||||||
ContentInfo contentInfo = getEventContentUseCase.execute(eventDraftId);
|
ContentInfo contentInfo = getEventContentUseCase.execute(eventId);
|
||||||
|
|
||||||
return ResponseEntity.ok(contentInfo);
|
return ResponseEntity.ok(contentInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/content/events/{eventDraftId}/images
|
* GET /api/v1/content/events/{eventId}/images
|
||||||
* 이벤트의 이미지 목록 조회 (필터링)
|
* 이벤트의 이미지 목록 조회 (필터링)
|
||||||
*
|
*
|
||||||
* @param eventDraftId 이벤트 초안 ID
|
* @param eventId 이벤트 ID
|
||||||
* @param style 이미지 스타일 필터 (선택)
|
* @param style 이미지 스타일 필터 (선택)
|
||||||
* @param platform 플랫폼 필터 (선택)
|
* @param platform 플랫폼 필터 (선택)
|
||||||
* @return 200 OK - 이미지 목록
|
* @return 200 OK - 이미지 목록
|
||||||
*/
|
*/
|
||||||
@GetMapping("/events/{eventDraftId}/images")
|
@GetMapping("/events/{eventId}/images")
|
||||||
public ResponseEntity<List<ImageInfo>> getImages(
|
public ResponseEntity<List<ImageInfo>> getImages(
|
||||||
@PathVariable Long eventDraftId,
|
@PathVariable String eventId,
|
||||||
@RequestParam(required = false) String style,
|
@RequestParam(required = false) String style,
|
||||||
@RequestParam(required = false) String platform) {
|
@RequestParam(required = false) String platform) {
|
||||||
log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform);
|
log.info("이미지 목록 조회: eventId={}, style={}, platform={}", eventId, style, platform);
|
||||||
|
|
||||||
// String -> Enum 변환
|
// String -> Enum 변환
|
||||||
ImageStyle imageStyle = style != null ? ImageStyle.valueOf(style.toUpperCase()) : null;
|
ImageStyle imageStyle = style != null ? ImageStyle.valueOf(style.toUpperCase()) : null;
|
||||||
Platform imagePlatform = platform != null ? Platform.valueOf(platform.toUpperCase()) : null;
|
Platform imagePlatform = platform != null ? Platform.valueOf(platform.toUpperCase()) : null;
|
||||||
|
|
||||||
List<ImageInfo> images = getImageListUseCase.execute(eventDraftId, imageStyle, imagePlatform);
|
List<ImageInfo> images = getImageListUseCase.execute(eventId, imageStyle, imagePlatform);
|
||||||
|
|
||||||
return ResponseEntity.ok(images);
|
return ResponseEntity.ok(images);
|
||||||
}
|
}
|
||||||
@ -124,7 +124,7 @@ public class ContentController {
|
|||||||
* @param imageId 이미지 ID
|
* @param imageId 이미지 ID
|
||||||
* @return 200 OK - 이미지 상세 정보
|
* @return 200 OK - 이미지 상세 정보
|
||||||
*/
|
*/
|
||||||
@GetMapping("/images/{imageId}")
|
@GetMapping("/images/{imageId:[0-9]+}")
|
||||||
public ResponseEntity<ImageInfo> getImageById(@PathVariable Long imageId) {
|
public ResponseEntity<ImageInfo> getImageById(@PathVariable Long imageId) {
|
||||||
log.info("이미지 상세 조회: imageId={}", imageId);
|
log.info("이미지 상세 조회: imageId={}", imageId);
|
||||||
|
|
||||||
@ -140,7 +140,7 @@ public class ContentController {
|
|||||||
* @param imageId 이미지 ID
|
* @param imageId 이미지 ID
|
||||||
* @return 204 NO CONTENT
|
* @return 204 NO CONTENT
|
||||||
*/
|
*/
|
||||||
@DeleteMapping("/images/{imageId}")
|
@DeleteMapping("/images/{imageId:[0-9]+}")
|
||||||
public ResponseEntity<Void> deleteImage(@PathVariable Long imageId) {
|
public ResponseEntity<Void> deleteImage(@PathVariable Long imageId) {
|
||||||
log.info("이미지 삭제 요청: imageId={}", imageId);
|
log.info("이미지 삭제 요청: imageId={}", imageId);
|
||||||
|
|
||||||
@ -157,7 +157,7 @@ public class ContentController {
|
|||||||
* @param requestBody 재생성 요청 정보 (선택)
|
* @param requestBody 재생성 요청 정보 (선택)
|
||||||
* @return 202 ACCEPTED - Job ID 반환
|
* @return 202 ACCEPTED - Job ID 반환
|
||||||
*/
|
*/
|
||||||
@PostMapping("/images/{imageId}/regenerate")
|
@PostMapping("/images/{imageId:[0-9]+}/regenerate")
|
||||||
public ResponseEntity<JobInfo> regenerateImage(
|
public ResponseEntity<JobInfo> regenerateImage(
|
||||||
@PathVariable Long imageId,
|
@PathVariable Long imageId,
|
||||||
@RequestBody(required = false) ContentCommand.RegenerateImage requestBody) {
|
@RequestBody(required = false) ContentCommand.RegenerateImage requestBody) {
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
spring:
|
|
||||||
application:
|
|
||||||
name: content-service
|
|
||||||
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
host: ${REDIS_HOST:20.214.210.71}
|
|
||||||
port: ${REDIS_PORT:6379}
|
|
||||||
password: ${REDIS_PASSWORD:}
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: ${SERVER_PORT:8084}
|
|
||||||
|
|
||||||
jwt:
|
|
||||||
secret: ${JWT_SECRET:kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025}
|
|
||||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
|
|
||||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000}
|
|
||||||
|
|
||||||
azure:
|
|
||||||
storage:
|
|
||||||
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
|
|
||||||
container-name: ${AZURE_CONTAINER_NAME:event-images}
|
|
||||||
|
|
||||||
replicate:
|
|
||||||
api:
|
|
||||||
url: ${REPLICATE_API_URL:https://api.replicate.com}
|
|
||||||
token: ${REPLICATE_API_TOKEN:r8_Q33U00fSnpjYlHNIRglwurV446h7g8V2wkFFa}
|
|
||||||
|
|
||||||
huggingface:
|
|
||||||
api:
|
|
||||||
url: ${HUGGINGFACE_API_URL:https://api-inference.huggingface.co}
|
|
||||||
token: ${HUGGINGFACE_API_TOKEN:}
|
|
||||||
model: ${HUGGINGFACE_MODEL:runwayml/stable-diffusion-v1-5}
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
com.kt.event: ${LOG_LEVEL_APP:DEBUG}
|
|
||||||
root: ${LOG_LEVEL_ROOT:INFO}
|
|
||||||
file:
|
|
||||||
name: ${LOG_FILE:logs/content-service.log}
|
|
||||||
logback:
|
|
||||||
rollingpolicy:
|
|
||||||
max-file-size: 10MB
|
|
||||||
max-history: 7
|
|
||||||
total-size-cap: 100MB
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
spring:
|
|
||||||
datasource:
|
|
||||||
url: jdbc:h2:mem:contentdb
|
|
||||||
username: sa
|
|
||||||
password:
|
|
||||||
driver-class-name: org.h2.Driver
|
|
||||||
|
|
||||||
h2:
|
|
||||||
console:
|
|
||||||
enabled: true
|
|
||||||
path: /h2-console
|
|
||||||
|
|
||||||
jpa:
|
|
||||||
database-platform: org.hibernate.dialect.H2Dialect
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: create-drop
|
|
||||||
show-sql: true
|
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
format_sql: true
|
|
||||||
dialect: org.hibernate.dialect.H2Dialect
|
|
||||||
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
# Redis 연결 비활성화 (Mock 사용)
|
|
||||||
repositories:
|
|
||||||
enabled: false
|
|
||||||
host: localhost
|
|
||||||
port: 6379
|
|
||||||
|
|
||||||
autoconfigure:
|
|
||||||
exclude:
|
|
||||||
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
|
|
||||||
- org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: 8084
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
com.kt.event: DEBUG
|
|
||||||
org.hibernate.SQL: DEBUG
|
|
||||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
|
||||||
@ -2,38 +2,92 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: content-service
|
name: content-service
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:localhost}
|
enabled: ${REDIS_ENABLED:true}
|
||||||
|
host: ${REDIS_HOST:20.214.210.71}
|
||||||
port: ${REDIS_PORT:6379}
|
port: ${REDIS_PORT:6379}
|
||||||
password: ${REDIS_PASSWORD:}
|
password: ${REDIS_PASSWORD:Hi5Jessica!}
|
||||||
|
timeout: ${REDIS_TIMEOUT:2000ms}
|
||||||
server:
|
lettuce:
|
||||||
port: ${SERVER_PORT:8084}
|
pool:
|
||||||
|
max-active: ${REDIS_POOL_MAX:8}
|
||||||
|
max-idle: ${REDIS_POOL_IDLE:8}
|
||||||
|
min-idle: ${REDIS_POOL_MIN:0}
|
||||||
|
max-wait: ${REDIS_POOL_WAIT:-1ms}
|
||||||
|
database: ${REDIS_DATABASE:0}
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:dev-jwt-secret-key}
|
secret: ${JWT_SECRET:kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025}
|
||||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
|
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
|
||||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000}
|
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000}
|
||||||
|
|
||||||
|
# Azure Blob Storage Configuration
|
||||||
azure:
|
azure:
|
||||||
storage:
|
storage:
|
||||||
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
|
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net}
|
||||||
container-name: ${AZURE_CONTAINER_NAME:event-images}
|
container-name: ${AZURE_CONTAINER_NAME:content-images}
|
||||||
|
|
||||||
|
# Replicate API Configuration (Stable Diffusion)
|
||||||
replicate:
|
replicate:
|
||||||
api:
|
api:
|
||||||
url: ${REPLICATE_API_URL:https://api.replicate.com}
|
url: ${REPLICATE_API_URL:https://api.replicate.com}
|
||||||
token: ${REPLICATE_API_TOKEN:}
|
token: ${REPLICATE_API_TOKEN:}
|
||||||
|
model:
|
||||||
|
version: ${REPLICATE_MODEL_VERSION:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
|
||||||
|
|
||||||
|
# Actuator
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics,prometheus
|
||||||
|
base-path: /actuator
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: always
|
||||||
|
show-components: always
|
||||||
|
health:
|
||||||
|
livenessState:
|
||||||
|
enabled: true
|
||||||
|
readinessState:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# OpenAPI Documentation
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
path: /v3/api-docs
|
||||||
|
swagger-ui:
|
||||||
|
path: /swagger-ui.html
|
||||||
|
tags-sorter: alpha
|
||||||
|
operations-sorter: alpha
|
||||||
|
show-actuator: false
|
||||||
|
|
||||||
|
# Logging
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.kt.event: ${LOG_LEVEL_APP:DEBUG}
|
com.kt.event.content: ${LOG_LEVEL_APP:DEBUG}
|
||||||
|
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
|
||||||
root: ${LOG_LEVEL_ROOT:INFO}
|
root: ${LOG_LEVEL_ROOT:INFO}
|
||||||
|
pattern:
|
||||||
|
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
|
||||||
|
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
file:
|
file:
|
||||||
name: ${LOG_FILE:logs/content-service.log}
|
name: ${LOG_FILE_PATH:logs/content-service.log}
|
||||||
logback:
|
logback:
|
||||||
rollingpolicy:
|
rollingpolicy:
|
||||||
max-file-size: 10MB
|
max-file-size: ${LOG_FILE_MAX_SIZE:10MB}
|
||||||
max-history: 7
|
max-history: ${LOG_FILE_MAX_HISTORY:7}
|
||||||
total-size-cap: 100MB
|
total-size-cap: ${LOG_FILE_TOTAL_CAP:100MB}
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8084}
|
||||||
|
servlet:
|
||||||
|
context-path: /api/v1/content
|
||||||
|
|||||||
@ -1,187 +1,195 @@
|
|||||||
# Content Service 컨테이너 이미지 빌드 및 배포 가이드
|
# Event Service 컨테이너 이미지 빌드 가이드
|
||||||
|
|
||||||
## 1. 사전 준비사항
|
## 1. 빌드 일시
|
||||||
|
- **빌드 날짜**: 2025-10-28
|
||||||
|
- **빌드 시간**: 14:35 KST
|
||||||
|
|
||||||
### 필수 소프트웨어
|
## 2. 수정 사항
|
||||||
- **Docker Desktop**: Docker 컨테이너 실행 환경
|
|
||||||
- **JDK 23**: Java 애플리케이션 빌드
|
|
||||||
- **Gradle**: 프로젝트 빌드 도구
|
|
||||||
|
|
||||||
### 외부 서비스
|
### 2.1 타입 불일치 수정
|
||||||
- **Redis 서버**: 20.214.210.71:6379
|
Event Service 컴파일 오류 해결을 위해 다음 파일들을 수정했습니다:
|
||||||
- **Kafka 서버**: 4.230.50.63:9092
|
|
||||||
- **Replicate API**: Stable Diffusion 이미지 생성
|
|
||||||
- **Azure Blob Storage**: 이미지 CDN
|
|
||||||
|
|
||||||
## 2. 빌드 설정
|
#### UserPrincipal.java (common 모듈)
|
||||||
|
- **파일 경로**: `common/src/main/java/com/kt/event/common/security/UserPrincipal.java`
|
||||||
|
- **수정 내용**: userId와 storeId 타입을 Long에서 UUID로 변경
|
||||||
|
- **변경 이유**: EventService의 메서드 시그니처가 UUID를 기대하므로 일관성 유지
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Before
|
||||||
|
private final Long userId;
|
||||||
|
private final Long storeId;
|
||||||
|
|
||||||
|
// After
|
||||||
|
private final UUID userId;
|
||||||
|
private final UUID storeId;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JwtTokenProvider.java (common 모듈)
|
||||||
|
- **파일 경로**: `common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java`
|
||||||
|
- **수정 내용**: JWT 토큰 파싱 시 Long.parseLong()을 UUID.fromString()으로 변경
|
||||||
|
- **변경 이유**: UserPrincipal의 타입 변경에 따른 파싱 로직 수정
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Before
|
||||||
|
Long userId = Long.parseLong(claims.getSubject());
|
||||||
|
Long storeId = storeIdStr != null ? Long.parseLong(storeIdStr) : null;
|
||||||
|
|
||||||
|
// After
|
||||||
|
UUID userId = UUID.fromString(claims.getSubject());
|
||||||
|
UUID storeId = storeIdStr != null ? UUID.fromString(storeIdStr) : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### event-service/build.gradle
|
||||||
|
- **수정 내용**: bootJar 설정 추가
|
||||||
|
- **변경 이유**: 컨테이너 이미지 빌드를 위한 JAR 파일명 명시
|
||||||
|
|
||||||
### build.gradle 설정 (content-service/build.gradle)
|
|
||||||
```gradle
|
```gradle
|
||||||
// 실행 JAR 파일명 설정
|
|
||||||
bootJar {
|
bootJar {
|
||||||
archiveFileName = 'content-service.jar'
|
archiveFileName = 'event-service.jar'
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 배포 파일 구조
|
## 3. 빌드 명령어
|
||||||
|
|
||||||
```
|
### 3.1 Common 모듈 컴파일
|
||||||
deployment/
|
|
||||||
└── container/
|
|
||||||
├── Dockerfile-backend # 백엔드 서비스용 Dockerfile
|
|
||||||
├── docker-compose.yml # Docker Compose 설정
|
|
||||||
└── build-and-run.sh # 자동화 배포 스크립트
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 수동 빌드 및 배포
|
|
||||||
|
|
||||||
### 4.1 Gradle 빌드
|
|
||||||
```bash
|
```bash
|
||||||
# 프로젝트 루트에서 실행
|
./gradlew common:compileJava
|
||||||
./gradlew clean content-service:bootJar
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Docker 이미지 빌드
|
**결과**: BUILD SUCCESSFUL in 6s
|
||||||
```bash
|
|
||||||
DOCKER_FILE=deployment/container/Dockerfile-backend
|
|
||||||
|
|
||||||
|
### 3.2 Event Service 컴파일
|
||||||
|
```bash
|
||||||
|
./gradlew event-service:compileJava
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과**: BUILD SUCCESSFUL in 6s
|
||||||
|
|
||||||
|
### 3.3 Event Service JAR 빌드
|
||||||
|
```bash
|
||||||
|
./gradlew event-service:bootJar
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과**:
|
||||||
|
- BUILD SUCCESSFUL in 5s
|
||||||
|
- JAR 파일 생성: `event-service/build/libs/event-service.jar` (94MB)
|
||||||
|
|
||||||
|
### 3.4 Docker 이미지 빌드
|
||||||
|
```bash
|
||||||
docker build \
|
docker build \
|
||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
|
--build-arg BUILD_LIB_DIR="event-service/build/libs" \
|
||||||
--build-arg ARTIFACTORY_FILE="content-service.jar" \
|
--build-arg ARTIFACTORY_FILE="event-service.jar" \
|
||||||
-f ${DOCKER_FILE} \
|
|
||||||
-t content-service:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 빌드된 이미지 확인
|
|
||||||
```bash
|
|
||||||
docker images | grep content-service
|
|
||||||
```
|
|
||||||
|
|
||||||
예상 출력:
|
|
||||||
```
|
|
||||||
content-service latest abc123def456 2 minutes ago 450MB
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Docker Compose로 컨테이너 실행
|
|
||||||
```bash
|
|
||||||
docker-compose -f deployment/container/docker-compose.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 컨테이너 상태 확인
|
|
||||||
```bash
|
|
||||||
# 실행 중인 컨테이너 확인
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# 로그 확인
|
|
||||||
docker logs -f content-service
|
|
||||||
|
|
||||||
# 헬스체크
|
|
||||||
curl http://localhost:8084/actuator/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 자동화 배포 스크립트 사용 (권장)
|
|
||||||
|
|
||||||
### 5.1 스크립트 실행
|
|
||||||
```bash
|
|
||||||
# 프로젝트 루트에서 실행
|
|
||||||
./deployment/container/build-and-run.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 스크립트 수행 단계
|
|
||||||
1. Gradle 빌드
|
|
||||||
2. Docker 이미지 빌드
|
|
||||||
3. 이미지 확인
|
|
||||||
4. 기존 컨테이너 정리
|
|
||||||
5. 새 컨테이너 실행
|
|
||||||
|
|
||||||
## 6. 환경변수 설정
|
|
||||||
|
|
||||||
`docker-compose.yml`에 다음 환경변수가 설정되어 있습니다:
|
|
||||||
|
|
||||||
### 필수 환경변수
|
|
||||||
- `SPRING_PROFILES_ACTIVE`: Spring Profile (prod)
|
|
||||||
- `SERVER_PORT`: 서버 포트 (8084)
|
|
||||||
- `REDIS_HOST`: Redis 호스트
|
|
||||||
- `REDIS_PORT`: Redis 포트
|
|
||||||
- `REDIS_PASSWORD`: Redis 비밀번호
|
|
||||||
- `JWT_SECRET`: JWT 서명 키 (최소 32자)
|
|
||||||
- `REPLICATE_API_TOKEN`: Replicate API 토큰
|
|
||||||
- `AZURE_STORAGE_CONNECTION_STRING`: Azure Storage 연결 문자열
|
|
||||||
- `AZURE_CONTAINER_NAME`: Azure Storage 컨테이너 이름
|
|
||||||
|
|
||||||
### JWT_SECRET 요구사항
|
|
||||||
- **최소 길이**: 32자 이상 (256비트)
|
|
||||||
- **형식**: 영문자, 숫자 조합
|
|
||||||
- **예시**: `kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025`
|
|
||||||
|
|
||||||
## 7. VM 배포
|
|
||||||
|
|
||||||
### 7.1 VM에 파일 전송
|
|
||||||
```bash
|
|
||||||
# VM으로 파일 복사 (예시)
|
|
||||||
scp -r deployment/ user@vm-host:/path/to/project/
|
|
||||||
scp docker-compose.yml user@vm-host:/path/to/project/deployment/container/
|
|
||||||
scp content-service/build/libs/content-service.jar user@vm-host:/path/to/project/content-service/build/libs/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 VM에서 이미지 빌드
|
|
||||||
```bash
|
|
||||||
# VM에 SSH 접속 후
|
|
||||||
cd /path/to/project
|
|
||||||
|
|
||||||
# 이미지 빌드
|
|
||||||
docker build \
|
|
||||||
--platform linux/amd64 \
|
|
||||||
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
|
|
||||||
--build-arg ARTIFACTORY_FILE="content-service.jar" \
|
|
||||||
-f deployment/container/Dockerfile-backend \
|
-f deployment/container/Dockerfile-backend \
|
||||||
-t content-service:latest .
|
-t event-service:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.3 VM에서 컨테이너 실행
|
**결과**: 이미지 빌드 성공
|
||||||
```bash
|
- Image ID: bbeecf2ccaf2
|
||||||
# Docker Compose로 실행
|
- Size: 1.08GB
|
||||||
docker-compose -f deployment/container/docker-compose.yml up -d
|
- Created: 19 seconds ago
|
||||||
|
|
||||||
# 또는 직접 실행
|
## 4. 빌드 검증
|
||||||
|
|
||||||
|
### 4.1 JAR 파일 확인
|
||||||
|
```bash
|
||||||
|
ls -lh event-service/build/libs/
|
||||||
|
```
|
||||||
|
|
||||||
|
**출력**:
|
||||||
|
```
|
||||||
|
-rw-r--r-- 1 KTDS 197121 94M 10월 28 14:35 event-service.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Docker 이미지 확인
|
||||||
|
```bash
|
||||||
|
docker images | grep event-service
|
||||||
|
```
|
||||||
|
|
||||||
|
**출력**:
|
||||||
|
```
|
||||||
|
event-service latest bbeecf2ccaf2 19 seconds ago 1.08GB
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Dockerfile 구조
|
||||||
|
|
||||||
|
**파일 위치**: `deployment/container/Dockerfile-backend`
|
||||||
|
|
||||||
|
### 빌드 스테이지 (Build Stage)
|
||||||
|
- **Base Image**: openjdk:23-oraclelinux8
|
||||||
|
- **작업**: JAR 파일 복사
|
||||||
|
|
||||||
|
### 실행 스테이지 (Run Stage)
|
||||||
|
- **Base Image**: openjdk:23-slim
|
||||||
|
- **사용자**: k8s (non-root user)
|
||||||
|
- **작업 디렉토리**: /home/k8s
|
||||||
|
- **진입점**: `java ${JAVA_OPTS} -jar app.jar`
|
||||||
|
|
||||||
|
## 6. 컨테이너 실행 가이드
|
||||||
|
|
||||||
|
### 6.1 기본 실행
|
||||||
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name content-service \
|
--name event-service \
|
||||||
-p 8084:8084 \
|
-p 8082:8082 \
|
||||||
-e SPRING_PROFILES_ACTIVE=prod \
|
-e SPRING_PROFILES_ACTIVE=dev \
|
||||||
-e SERVER_PORT=8084 \
|
-e SERVER_PORT=8082 \
|
||||||
-e REDIS_HOST=20.214.210.71 \
|
event-service:latest
|
||||||
-e REDIS_PORT=6379 \
|
|
||||||
-e REDIS_PASSWORD=Hi5Jessica! \
|
|
||||||
-e JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025 \
|
|
||||||
-e REPLICATE_API_TOKEN=r8_Q33U00fSnpjYlHNIRglwurV446h7g8V2wkFFa \
|
|
||||||
-e AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net" \
|
|
||||||
-e AZURE_CONTAINER_NAME=content-images \
|
|
||||||
content-service:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. 모니터링 및 로그
|
### 6.2 환경변수 설정
|
||||||
|
Event Service 실행을 위한 주요 환경변수:
|
||||||
|
|
||||||
### 8.1 컨테이너 상태 확인
|
#### 필수 환경변수
|
||||||
|
- `SERVER_PORT`: 서버 포트 (기본값: 8082)
|
||||||
|
- `DB_HOST`: PostgreSQL 호스트
|
||||||
|
- `DB_PORT`: PostgreSQL 포트 (기본값: 5432)
|
||||||
|
- `DB_NAME`: 데이터베이스 이름
|
||||||
|
- `DB_USERNAME`: 데이터베이스 사용자명
|
||||||
|
- `DB_PASSWORD`: 데이터베이스 비밀번호
|
||||||
|
- `REDIS_HOST`: Redis 호스트
|
||||||
|
- `REDIS_PORT`: Redis 포트 (기본값: 6379)
|
||||||
|
- `REDIS_PASSWORD`: Redis 비밀번호
|
||||||
|
- `KAFKA_BOOTSTRAP_SERVERS`: Kafka 브로커 주소
|
||||||
|
- `JWT_SECRET`: JWT 서명 키 (최소 32자)
|
||||||
|
|
||||||
|
#### 선택 환경변수
|
||||||
|
- `DISTRIBUTION_SERVICE_URL`: Distribution Service URL
|
||||||
|
- `JAVA_OPTS`: JVM 옵션
|
||||||
|
|
||||||
|
### 6.3 Docker Compose 실행 예시
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
event-service:
|
||||||
|
image: event-service:latest
|
||||||
|
container_name: event-service
|
||||||
|
ports:
|
||||||
|
- "8082:8082"
|
||||||
|
environment:
|
||||||
|
- SPRING_PROFILES_ACTIVE=prod
|
||||||
|
- SERVER_PORT=8082
|
||||||
|
- DB_HOST=your-db-host
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=event_db
|
||||||
|
- DB_USERNAME=event_user
|
||||||
|
- DB_PASSWORD=your-password
|
||||||
|
- REDIS_HOST=your-redis-host
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- REDIS_PASSWORD=your-redis-password
|
||||||
|
- KAFKA_BOOTSTRAP_SERVERS=your-kafka:9092
|
||||||
|
- JWT_SECRET=your-jwt-secret-key-minimum-32-characters
|
||||||
|
- DISTRIBUTION_SERVICE_URL=http://distribution-service:8086
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 헬스체크
|
||||||
|
|
||||||
|
### 7.1 Spring Boot Actuator
|
||||||
```bash
|
```bash
|
||||||
docker ps
|
curl http://localhost:8082/actuator/health
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.2 로그 확인
|
**예상 응답**:
|
||||||
```bash
|
|
||||||
# 실시간 로그
|
|
||||||
docker logs -f content-service
|
|
||||||
|
|
||||||
# 최근 100줄
|
|
||||||
docker logs --tail 100 content-service
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 헬스체크
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8084/actuator/health
|
|
||||||
```
|
|
||||||
|
|
||||||
예상 응답:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "UP",
|
"status": "UP",
|
||||||
@ -189,6 +197,9 @@ curl http://localhost:8084/actuator/health
|
|||||||
"ping": {
|
"ping": {
|
||||||
"status": "UP"
|
"status": "UP"
|
||||||
},
|
},
|
||||||
|
"db": {
|
||||||
|
"status": "UP"
|
||||||
|
},
|
||||||
"redis": {
|
"redis": {
|
||||||
"status": "UP"
|
"status": "UP"
|
||||||
}
|
}
|
||||||
@ -196,92 +207,115 @@ curl http://localhost:8084/actuator/health
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. Swagger UI 접근
|
### 7.2 Swagger UI
|
||||||
|
|
||||||
배포 후 Swagger UI로 API 테스트 가능:
|
|
||||||
```
|
```
|
||||||
http://localhost:8084/swagger-ui/index.html
|
http://localhost:8082/swagger-ui/index.html
|
||||||
```
|
```
|
||||||
|
|
||||||
## 10. 이미지 생성 API 테스트
|
## 8. 빌드 결과 요약
|
||||||
|
|
||||||
### 10.1 이미지 생성 요청
|
### 서비스 정보
|
||||||
|
- **서비스명**: event-service
|
||||||
|
- **포트**: 8082
|
||||||
|
- **JAR 크기**: 94MB
|
||||||
|
- **이미지 크기**: 1.08GB
|
||||||
|
- **Base Image**: openjdk:23-slim
|
||||||
|
- **Platform**: linux/amd64
|
||||||
|
|
||||||
|
### 빌드 통계
|
||||||
|
- **Common 컴파일**: 6초
|
||||||
|
- **Event Service 컴파일**: 6초
|
||||||
|
- **JAR 빌드**: 5초
|
||||||
|
- **Docker 이미지 빌드**: 약 120초
|
||||||
|
|
||||||
|
### 주요 의존성
|
||||||
|
- Spring Boot Actuator
|
||||||
|
- Spring Kafka
|
||||||
|
- Spring Data Redis
|
||||||
|
- Spring Cloud OpenFeign
|
||||||
|
- PostgreSQL Driver
|
||||||
|
- Jackson
|
||||||
|
|
||||||
|
## 9. 트러블슈팅
|
||||||
|
|
||||||
|
### 9.1 컴파일 오류 해결
|
||||||
|
**증상**: userId/storeId 타입 불일치 오류
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
- UserPrincipal의 userId, storeId를 UUID로 변경
|
||||||
|
- JwtTokenProvider의 파싱 로직을 UUID.fromString()으로 수정
|
||||||
|
|
||||||
|
### 9.2 Gradle Clean 오류
|
||||||
|
**증상**: `Unable to delete directory 'common\build'`
|
||||||
|
|
||||||
|
**해결**: clean 없이 빌드 수행
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "http://localhost:8084/api/v1/content/images/generate" \
|
./gradlew event-service:bootJar
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"eventDraftId": 1001,
|
|
||||||
"industry": "고깃집",
|
|
||||||
"location": "강남",
|
|
||||||
"trends": ["가을", "단풍", "BBQ"],
|
|
||||||
"styles": ["FANCY"],
|
|
||||||
"platforms": ["INSTAGRAM"]
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.2 Job 상태 확인
|
### 9.3 Docker 빌드 컨텍스트 오류
|
||||||
|
**증상**: JAR 파일을 찾을 수 없음
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
- JAR 파일이 실제로 빌드되었는지 확인
|
||||||
|
- 빌드 아규먼트 경로가 올바른지 확인
|
||||||
|
|
||||||
|
## 10. 다음 단계
|
||||||
|
|
||||||
|
### 빌드 수행 이력
|
||||||
|
|
||||||
|
#### 최신 빌드 (2025-10-28)
|
||||||
|
|
||||||
|
**1단계: JAR 빌드**
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8084/api/v1/content/jobs/{jobId}
|
./gradlew content-service:clean content-service:bootJar
|
||||||
```
|
```
|
||||||
|
|
||||||
## 11. 컨테이너 관리 명령어
|
빌드 결과:
|
||||||
|
```
|
||||||
|
BUILD SUCCESSFUL in 8s
|
||||||
|
9 actionable tasks: 6 executed, 3 up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
### 11.1 컨테이너 중지
|
**2단계: Docker 이미지 빌드**
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f deployment/container/docker-compose.yml down
|
docker build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
|
||||||
|
--build-arg ARTIFACTORY_FILE="content-service.jar" \
|
||||||
|
-f deployment/container/Dockerfile-backend \
|
||||||
|
-t content-service:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.2 컨테이너 재시작
|
빌드 결과:
|
||||||
|
- ✅ Build stage 완료 (openjdk:23-oraclelinux8)
|
||||||
|
- ✅ Run stage 완료 (openjdk:23-slim)
|
||||||
|
- ✅ 이미지 생성 완료
|
||||||
|
|
||||||
|
**3단계: 이미지 확인**
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f deployment/container/docker-compose.yml restart
|
docker images | grep content-service
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.3 컨테이너 삭제
|
확인 결과:
|
||||||
```bash
|
```
|
||||||
# 컨테이너만 삭제
|
content-service latest ff73258c94cc 15 seconds ago 393MB
|
||||||
docker rm -f content-service
|
|
||||||
|
|
||||||
# 이미지도 삭제
|
|
||||||
docker rmi content-service:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. 트러블슈팅
|
|
||||||
|
|
||||||
### 12.1 JWT 토큰 오류
|
|
||||||
**증상**: `Error creating bean with name 'jwtTokenProvider'`
|
|
||||||
|
|
||||||
**해결방법**:
|
|
||||||
- `JWT_SECRET` 환경변수가 32자 이상인지 확인
|
|
||||||
- docker-compose.yml에 올바르게 설정되어 있는지 확인
|
|
||||||
|
|
||||||
### 12.2 Redis 연결 오류
|
|
||||||
**증상**: `Unable to connect to Redis`
|
|
||||||
|
|
||||||
**해결방법**:
|
|
||||||
- Redis 서버(20.214.210.71:6379)가 실행 중인지 확인
|
|
||||||
- 방화벽 설정 확인
|
|
||||||
- 비밀번호 확인
|
|
||||||
|
|
||||||
### 12.3 Azure Storage 오류
|
|
||||||
**증상**: `Azure storage connection failed`
|
|
||||||
|
|
||||||
**해결방법**:
|
|
||||||
- `AZURE_STORAGE_CONNECTION_STRING`이 올바른지 확인
|
|
||||||
- Storage Account가 활성화되어 있는지 확인
|
|
||||||
- 컨테이너 이름(`content-images`)이 존재하는지 확인
|
|
||||||
|
|
||||||
## 13. 빌드 결과
|
|
||||||
|
|
||||||
### 빌드 정보
|
### 빌드 정보
|
||||||
- **서비스명**: content-service
|
- **서비스명**: content-service
|
||||||
- **JAR 파일**: content-service.jar
|
- **JAR 파일**: content-service.jar
|
||||||
- **Docker 이미지**: content-service:latest
|
- **Docker 이미지**: content-service:latest
|
||||||
|
- **이미지 ID**: ff73258c94cc
|
||||||
|
- **이미지 크기**: 393MB
|
||||||
- **노출 포트**: 8084
|
- **노출 포트**: 8084
|
||||||
|
|
||||||
### 빌드 일시
|
### 빌드 일시
|
||||||
- **빌드 날짜**: 2025-10-27
|
- **최신 빌드**: 2025-10-28
|
||||||
|
- **이전 빌드**: 2025-10-27
|
||||||
|
|
||||||
### 환경
|
### 환경
|
||||||
- **Base Image**: openjdk:23-slim
|
- **Base Image**: openjdk:23-slim
|
||||||
- **Platform**: linux/amd64
|
- **Platform**: linux/amd64
|
||||||
- **User**: k8s (non-root)
|
- **User**: k8s (non-root)
|
||||||
|
- **Java Version**: 23
|
||||||
|
|||||||
402
deployment/container/run-container-guide.md
Normal file
402
deployment/container/run-container-guide.md
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
# 백엔드 컨테이너 실행 가이드
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
1. [개요](#개요)
|
||||||
|
2. [VM 접속](#vm-접속)
|
||||||
|
3. [Git Repository 클론](#git-repository-클론)
|
||||||
|
4. [컨테이너 이미지 빌드](#컨테이너-이미지-빌드)
|
||||||
|
5. [컨테이너 레지스트리 설정](#컨테이너-레지스트리-설정)
|
||||||
|
6. [컨테이너 이미지 푸시](#컨테이너-이미지-푸시)
|
||||||
|
7. [컨테이너 실행](#컨테이너-실행)
|
||||||
|
8. [컨테이너 확인](#컨테이너-확인)
|
||||||
|
9. [재배포](#재배포)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
본 가이드는 **kt-event-marketing** 시스템의 백엔드 마이크로서비스들을 Docker 컨테이너로 실행하는 방법을 안내합니다.
|
||||||
|
|
||||||
|
### 시스템 정보
|
||||||
|
- **시스템명**: kt-event-marketing
|
||||||
|
- **ACR명**: acrdigitalgarage01
|
||||||
|
- **서비스 목록**:
|
||||||
|
- user-service (포트: 8081)
|
||||||
|
- event-service (포트: 8080)
|
||||||
|
- analytics-service (포트: 8086)
|
||||||
|
- participation-service (포트: 8084)
|
||||||
|
|
||||||
|
### VM 정보
|
||||||
|
- **IP**: 20.196.65.160
|
||||||
|
- **사용자 ID**: P82265804@ktds.co.kr
|
||||||
|
- **SSH Key 파일**: ~/home/bastion-dg0505
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VM 접속
|
||||||
|
|
||||||
|
### 1단계: 터미널 실행
|
||||||
|
- **Linux/Mac**: 기본 터미널 실행
|
||||||
|
- **Windows**: Windows Terminal 실행
|
||||||
|
|
||||||
|
### 2단계: SSH Key 파일 권한 설정 (최초 1회)
|
||||||
|
```bash
|
||||||
|
chmod 400 ~/home/bastion-dg0505
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: VM 접속
|
||||||
|
```bash
|
||||||
|
ssh -i ~/home/bastion-dg0505 P82265804@ktds.co.kr@20.196.65.160
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Repository 클론
|
||||||
|
|
||||||
|
### 1단계: workspace 디렉토리 생성 및 이동
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/home/workspace
|
||||||
|
cd ~/home/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2단계: 소스 클론
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ktds-dg0501/kt-event-marketing.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: 프로젝트 디렉토리로 이동
|
||||||
|
```bash
|
||||||
|
cd kt-event-marketing
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컨테이너 이미지 빌드
|
||||||
|
|
||||||
|
### 이미지 빌드 가이드 참조
|
||||||
|
프로젝트 내 빌드 가이드를 참조하여 컨테이너 이미지를 생성합니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 빌드 가이드 파일 열기
|
||||||
|
cat deployment/container/build-image.md
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드 가이드에 따라 각 서비스의 컨테이너 이미지를 생성하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컨테이너 레지스트리 설정
|
||||||
|
|
||||||
|
### 1단계: ACR 인증 정보 확인
|
||||||
|
Azure CLI를 사용하여 ACR 인증 정보를 확인합니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
az acr credential show --name acrdigitalgarage01
|
||||||
|
```
|
||||||
|
|
||||||
|
**출력 예시**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"passwords": [
|
||||||
|
{
|
||||||
|
"name": "password",
|
||||||
|
"value": "{암호}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password2",
|
||||||
|
"value": "{암호2}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"username": "acrdigitalgarage01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **ID**: `username` 값 (예: acrdigitalgarage01)
|
||||||
|
- **암호**: `passwords[0].value` 값
|
||||||
|
|
||||||
|
### 2단계: Docker 로그인
|
||||||
|
```bash
|
||||||
|
docker login acrdigitalgarage01.azurecr.io -u {ID} -p {암호}
|
||||||
|
```
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```bash
|
||||||
|
docker login acrdigitalgarage01.azurecr.io -u acrdigitalgarage01 -p mySecretPassword123
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컨테이너 이미지 푸시
|
||||||
|
|
||||||
|
각 서비스의 이미지를 ACR에 푸시합니다.
|
||||||
|
|
||||||
|
### user-service
|
||||||
|
```bash
|
||||||
|
docker tag user-service:latest acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service:latest
|
||||||
|
docker push acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### event-service
|
||||||
|
```bash
|
||||||
|
docker tag event-service:latest acrdigitalgarage01.azurecr.io/kt-event-marketing/event-service:latest
|
||||||
|
docker push acrdigitalgarage01.azurecr.io/kt-event-marketing/event-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### analytics-service
|
||||||
|
```bash
|
||||||
|
docker tag analytics-service:latest acrdigitalgarage01.azurecr.io/kt-event-marketing/analytics-service:latest
|
||||||
|
docker push acrdigitalgarage01.azurecr.io/kt-event-marketing/analytics-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### participation-service
|
||||||
|
```bash
|
||||||
|
docker tag participation-service:latest acrdigitalgarage01.azurecr.io/kt-event-marketing/participation-service:latest
|
||||||
|
docker push acrdigitalgarage01.azurecr.io/kt-event-marketing/participation-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컨테이너 실행
|
||||||
|
|
||||||
|
각 서비스를 Docker 컨테이너로 실행합니다.
|
||||||
|
|
||||||
|
### user-service 실행
|
||||||
|
```bash
|
||||||
|
SERVER_PORT=8081
|
||||||
|
|
||||||
|
docker run -d --name user-service --rm -p ${SERVER_PORT}:${SERVER_PORT} \
|
||||||
|
-e SERVER_PORT=8081 \
|
||||||
|
-e DB_URL=jdbc:postgresql://20.249.125.115:5432/userdb \
|
||||||
|
-e DB_DRIVER=org.postgresql.Driver \
|
||||||
|
-e DB_HOST=20.249.125.115 \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_NAME=userdb \
|
||||||
|
-e DB_USERNAME=eventuser \
|
||||||
|
-e DB_PASSWORD=Hi5Jessica! \
|
||||||
|
-e DB_KIND=postgresql \
|
||||||
|
-e DDL_AUTO=update \
|
||||||
|
-e SHOW_SQL=true \
|
||||||
|
-e JPA_DIALECT=org.hibernate.dialect.PostgreSQLDialect \
|
||||||
|
-e H2_CONSOLE_ENABLED=false \
|
||||||
|
-e REDIS_ENABLED=true \
|
||||||
|
-e REDIS_HOST=20.214.210.71 \
|
||||||
|
-e REDIS_PORT=6379 \
|
||||||
|
-e REDIS_PASSWORD=Hi5Jessica! \
|
||||||
|
-e REDIS_DATABASE=0 \
|
||||||
|
-e EXCLUDE_REDIS="" \
|
||||||
|
-e KAFKA_BOOTSTRAP_SERVERS=4.230.50.63:9092 \
|
||||||
|
-e KAFKA_CONSUMER_GROUP=user-service-consumers \
|
||||||
|
-e EXCLUDE_KAFKA="" \
|
||||||
|
-e JWT_SECRET=kt-event-marketing-secret-key-for-development-only-please-change-in-production \
|
||||||
|
-e JWT_ACCESS_TOKEN_VALIDITY=604800000 \
|
||||||
|
-e CORS_ALLOWED_ORIGINS="http://localhost:*,http://20.196.65.160:3000" \
|
||||||
|
-e LOG_LEVEL_APP=DEBUG \
|
||||||
|
-e LOG_LEVEL_WEB=INFO \
|
||||||
|
-e LOG_LEVEL_SQL=DEBUG \
|
||||||
|
-e LOG_LEVEL_SQL_TYPE=TRACE \
|
||||||
|
-e LOG_FILE_PATH=logs/user-service.log \
|
||||||
|
acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### event-service 실행
|
||||||
|
```bash
|
||||||
|
SERVER_PORT=8080
|
||||||
|
|
||||||
|
docker run -d --name event-service --rm -p ${SERVER_PORT}:${SERVER_PORT} \
|
||||||
|
-e SERVER_PORT=8080 \
|
||||||
|
-e DB_HOST=20.249.177.232 \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_NAME=eventdb \
|
||||||
|
-e DB_USERNAME=eventuser \
|
||||||
|
-e DB_PASSWORD=Hi5Jessica! \
|
||||||
|
-e DDL_AUTO=update \
|
||||||
|
-e REDIS_HOST=20.214.210.71 \
|
||||||
|
-e REDIS_PORT=6379 \
|
||||||
|
-e REDIS_PASSWORD=Hi5Jessica! \
|
||||||
|
-e KAFKA_BOOTSTRAP_SERVERS=20.249.182.13:9095,4.217.131.59:9095 \
|
||||||
|
-e CONTENT_SERVICE_URL=http://localhost:8082 \
|
||||||
|
-e DISTRIBUTION_SERVICE_URL=http://localhost:8084 \
|
||||||
|
-e JWT_SECRET=kt-event-marketing-secret-key-for-development-only-please-change-in-production \
|
||||||
|
-e LOG_LEVEL=DEBUG \
|
||||||
|
-e SQL_LOG_LEVEL=DEBUG \
|
||||||
|
acrdigitalgarage01.azurecr.io/kt-event-marketing/event-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### analytics-service 실행
|
||||||
|
```bash
|
||||||
|
SERVER_PORT=8086
|
||||||
|
|
||||||
|
docker run -d --name analytics-service --rm -p ${SERVER_PORT}:${SERVER_PORT} \
|
||||||
|
-e DB_KIND=postgresql \
|
||||||
|
-e DB_HOST=4.230.49.9 \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_NAME=analyticdb \
|
||||||
|
-e DB_USERNAME=eventuser \
|
||||||
|
-e DB_PASSWORD=Hi5Jessica! \
|
||||||
|
-e DDL_AUTO=update \
|
||||||
|
-e SHOW_SQL=true \
|
||||||
|
-e REDIS_HOST=20.214.210.71 \
|
||||||
|
-e REDIS_PORT=6379 \
|
||||||
|
-e REDIS_PASSWORD=Hi5Jessica! \
|
||||||
|
-e REDIS_DATABASE=5 \
|
||||||
|
-e KAFKA_ENABLED=true \
|
||||||
|
-e KAFKA_BOOTSTRAP_SERVERS=20.249.182.13:9095,4.217.131.59:9095 \
|
||||||
|
-e KAFKA_CONSUMER_GROUP_ID=analytics-service-consumers \
|
||||||
|
-e SAMPLE_DATA_ENABLED=true \
|
||||||
|
-e SERVER_PORT=8086 \
|
||||||
|
-e JWT_SECRET=dev-jwt-secret-key-for-development-only-kt-event-marketing \
|
||||||
|
-e JWT_ACCESS_TOKEN_VALIDITY=1800 \
|
||||||
|
-e JWT_REFRESH_TOKEN_VALIDITY=86400 \
|
||||||
|
-e CORS_ALLOWED_ORIGINS="http://localhost:*,http://20.196.65.160:3000" \
|
||||||
|
-e LOG_FILE=logs/analytics-service.log \
|
||||||
|
-e LOG_LEVEL_APP=DEBUG \
|
||||||
|
-e LOG_LEVEL_WEB=INFO \
|
||||||
|
-e LOG_LEVEL_SQL=DEBUG \
|
||||||
|
-e LOG_LEVEL_SQL_TYPE=TRACE \
|
||||||
|
acrdigitalgarage01.azurecr.io/kt-event-marketing/analytics-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### participation-service 실행
|
||||||
|
```bash
|
||||||
|
SERVER_PORT=8084
|
||||||
|
|
||||||
|
docker run -d --name participation-service --rm -p ${SERVER_PORT}:${SERVER_PORT} \
|
||||||
|
-e DB_HOST=4.230.72.147 \
|
||||||
|
-e DB_NAME=participationdb \
|
||||||
|
-e DB_PASSWORD=Hi5Jessica! \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_USERNAME=eventuser \
|
||||||
|
-e DDL_AUTO=update \
|
||||||
|
-e JWT_EXPIRATION=86400000 \
|
||||||
|
-e JWT_SECRET=kt-event-marketing-secret-key-for-development-only-change-in-production \
|
||||||
|
-e KAFKA_BOOTSTRAP_SERVERS=20.249.182.13:9095,4.217.131.59:9095 \
|
||||||
|
-e LOG_FILE=logs/participation-service.log \
|
||||||
|
-e LOG_LEVEL=INFO \
|
||||||
|
-e REDIS_HOST=20.214.210.71 \
|
||||||
|
-e REDIS_PASSWORD=Hi5Jessica! \
|
||||||
|
-e REDIS_PORT=6379 \
|
||||||
|
-e SERVER_PORT=8084 \
|
||||||
|
-e SHOW_SQL=true \
|
||||||
|
acrdigitalgarage01.azurecr.io/kt-event-marketing/participation-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컨테이너 확인
|
||||||
|
|
||||||
|
모든 서비스가 정상적으로 실행되었는지 확인합니다.
|
||||||
|
|
||||||
|
### 전체 서비스 확인
|
||||||
|
```bash
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 개별 서비스 확인
|
||||||
|
```bash
|
||||||
|
docker ps | grep user-service
|
||||||
|
docker ps | grep event-service
|
||||||
|
docker ps | grep analytics-service
|
||||||
|
docker ps | grep participation-service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서비스 로그 확인
|
||||||
|
```bash
|
||||||
|
docker logs user-service
|
||||||
|
docker logs event-service
|
||||||
|
docker logs analytics-service
|
||||||
|
docker logs participation-service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 재배포
|
||||||
|
|
||||||
|
소스 코드 수정 후 재배포 방법입니다.
|
||||||
|
|
||||||
|
### 1단계: 로컬에서 수정된 소스 푸시
|
||||||
|
로컬 개발 환경에서 소스 수정 후 Git에 푸시합니다.
|
||||||
|
|
||||||
|
### 2단계: VM 접속
|
||||||
|
```bash
|
||||||
|
ssh -i ~/home/bastion-dg0505 P82265804@ktds.co.kr@20.196.65.160
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: 디렉토리 이동 및 소스 내려받기
|
||||||
|
```bash
|
||||||
|
cd ~/home/workspace/kt-event-marketing
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4단계: 컨테이너 이미지 재생성
|
||||||
|
빌드 가이드에 따라 이미지를 재생성합니다:
|
||||||
|
```bash
|
||||||
|
cat deployment/container/build-image.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5단계: 컨테이너 이미지 푸시 (예: user-service)
|
||||||
|
```bash
|
||||||
|
docker tag user-service:latest acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service:latest
|
||||||
|
docker push acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6단계: 기존 컨테이너 중지
|
||||||
|
```bash
|
||||||
|
docker stop user-service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7단계: 컨테이너 이미지 삭제
|
||||||
|
```bash
|
||||||
|
docker rmi acrdigitalgarage01.azurecr.io/kt-event-marketing/user-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8단계: 컨테이너 재실행
|
||||||
|
위 [컨테이너 실행](#컨테이너-실행) 섹션의 명령을 다시 실행합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 사항
|
||||||
|
|
||||||
|
### CORS 설정
|
||||||
|
- 프론트엔드 접근을 위해 `CORS_ALLOWED_ORIGINS` 환경변수에 `http://20.196.65.160:3000`이 추가되었습니다.
|
||||||
|
- 필요에 따라 추가 도메인을 콤마(,)로 구분하여 추가할 수 있습니다.
|
||||||
|
|
||||||
|
### 포트 매핑
|
||||||
|
- user-service: 8081
|
||||||
|
- event-service: 8080
|
||||||
|
- analytics-service: 8086
|
||||||
|
- participation-service: 8084
|
||||||
|
|
||||||
|
### 환경변수 보안
|
||||||
|
- 본 가이드는 개발 환경용입니다.
|
||||||
|
- 운영 환경에서는 비밀번호, JWT 시크릿 등을 환경변수 파일이나 Secret 관리 시스템을 통해 관리해야 합니다.
|
||||||
|
|
||||||
|
### Docker 네트워크
|
||||||
|
- 현재는 호스트 네트워크 모드로 실행됩니다.
|
||||||
|
- 서비스 간 통신이 필요한 경우 Docker 네트워크를 생성하여 사용하는 것을 권장합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문제 해결
|
||||||
|
|
||||||
|
### 컨테이너가 시작되지 않는 경우
|
||||||
|
```bash
|
||||||
|
docker logs {서비스명}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 포트가 이미 사용 중인 경우
|
||||||
|
```bash
|
||||||
|
# 포트 사용 프로세스 확인
|
||||||
|
sudo netstat -tulpn | grep {포트번호}
|
||||||
|
|
||||||
|
# 기존 컨테이너 중지
|
||||||
|
docker stop {서비스명}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이미지 다운로드 실패
|
||||||
|
```bash
|
||||||
|
# Docker 로그인 재시도
|
||||||
|
docker login acrdigitalgarage01.azurecr.io -u {ID} -p {암호}
|
||||||
|
|
||||||
|
# 이미지 pull 재시도
|
||||||
|
docker pull acrdigitalgarage01.azurecr.io/kt-event-marketing/{서비스명}:latest
|
||||||
|
```
|
||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## 문서 정보
|
## 문서 정보
|
||||||
- **작성일**: 2025-10-24
|
- **작성일**: 2025-10-24
|
||||||
- **버전**: 1.0
|
- **최종 수정일**: 2025-10-28
|
||||||
|
- **버전**: 2.0
|
||||||
- **작성자**: Event Service Team
|
- **작성자**: Event Service Team
|
||||||
- **관련 문서**:
|
- **관련 문서**:
|
||||||
- [API 설계서](../../design/backend/api/API-설계서.md)
|
- [API 설계서](../../design/backend/api/API-설계서.md)
|
||||||
@ -14,16 +15,18 @@
|
|||||||
|
|
||||||
### 구현 현황
|
### 구현 현황
|
||||||
- **설계된 API**: 14개
|
- **설계된 API**: 14개
|
||||||
- **구현된 API**: 7개 (50.0%)
|
- **구현된 API**: 14개 (100%) ✅
|
||||||
- **미구현 API**: 7개 (50.0%)
|
- **미구현 API**: 0개 (0%)
|
||||||
|
|
||||||
### 구현률 세부
|
### 구현률 세부
|
||||||
| 카테고리 | 설계 | 구현 | 미구현 | 구현률 |
|
| 카테고리 | 설계 | 구현 | 미구현 | 구현률 |
|
||||||
|---------|------|------|--------|--------|
|
|---------|------|------|--------|--------|
|
||||||
| Dashboard & Event List | 2 | 2 | 0 | 100% |
|
| Dashboard & Event List | 2 | 2 | 0 | 100% ✅ |
|
||||||
| Event Creation Flow | 8 | 1 | 7 | 12.5% |
|
| Event Creation Flow | 8 | 8 | 0 | 100% ✅ |
|
||||||
| Event Management | 3 | 3 | 0 | 100% |
|
| Event Management | 3 | 3 | 0 | 100% ✅ |
|
||||||
| Job Status | 1 | 1 | 0 | 100% |
|
| Job Status | 1 | 1 | 0 | 100% ✅ |
|
||||||
|
|
||||||
|
**🎉 모든 API 구현 완료!** Event Service의 설계된 14개 API가 모두 구현되었습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -33,56 +36,53 @@
|
|||||||
|
|
||||||
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
||||||
|-----------|-----------|--------|------|----------|------|
|
|-----------|-----------|--------|------|----------|------|
|
||||||
| 이벤트 목록 조회 | EventController | GET | /api/events | ✅ 구현 | EventController:84 |
|
| 이벤트 목록 조회 | EventController | GET | /api/v1/events | ✅ 구현 | EventController:87 |
|
||||||
| 이벤트 상세 조회 | EventController | GET | /api/events/{eventId} | ✅ 구현 | EventController:130 |
|
| 이벤트 상세 조회 | EventController | GET | /api/v1/events/{eventId} | ✅ 구현 | EventController:133 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2.2 Event Creation Flow (구현률 12.5%)
|
### 2.2 Event Creation Flow (구현률 100% ✅)
|
||||||
|
|
||||||
#### Step 1: 이벤트 목적 선택
|
#### Step 1: 이벤트 목적 선택
|
||||||
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
||||||
|-----------|-----------|--------|------|----------|------|
|
|-----------|-----------|--------|------|----------|------|
|
||||||
| 이벤트 목적 선택 | EventController | POST | /api/events/objectives | ✅ 구현 | EventController:52 |
|
| 이벤트 목적 선택 | EventController | POST | /api/v1/events/objectives | ✅ 구현 | EventController:51 |
|
||||||
|
|
||||||
#### Step 2: AI 추천 (미구현)
|
#### Step 2: AI 추천 (구현률 100% ✅)
|
||||||
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 미구현 이유 |
|
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
||||||
|-----------|-----------|--------|------|----------|-----------|
|
|-----------|-----------|--------|------|----------|------|
|
||||||
| AI 추천 요청 | - | POST | /api/events/{eventId}/ai-recommendations | ❌ 미구현 | AI Service 연동 필요 |
|
| AI 추천 요청 | EventController | POST | /api/v1/events/{eventId}/ai-recommendations | ✅ 구현 | EventController:272 |
|
||||||
| AI 추천 선택 | - | PUT | /api/events/{eventId}/recommendations | ❌ 미구현 | AI Service 연동 필요 |
|
| AI 추천 선택 | EventController | PUT | /api/v1/events/{eventId}/recommendations | ✅ 구현 | EventController:300 |
|
||||||
|
|
||||||
**미구현 상세 이유**:
|
**구현 내용**:
|
||||||
- Kafka Topic `ai-event-generation-job` 발행 로직 필요
|
- **AI 추천 요청**: Kafka Topic `ai-event-generation-job`에 메시지 발행, Job ID 반환
|
||||||
- AI Service와의 연동이 선행되어야 함
|
- **AI 추천 선택**: 사용자가 AI 추천 중 하나를 선택하고 커스터마이징하여 이벤트에 적용
|
||||||
- Redis에서 AI 추천 결과를 읽어오는 로직 필요
|
|
||||||
- 현재 단계에서는 이벤트 생명주기 관리에 집중
|
|
||||||
|
|
||||||
#### Step 3: 이미지 생성 (미구현)
|
#### Step 3: 이미지 생성 (구현률 100% ✅)
|
||||||
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 미구현 이유 |
|
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
||||||
|-----------|-----------|--------|------|----------|-----------|
|
|-----------|-----------|--------|------|----------|------|
|
||||||
| 이미지 생성 요청 | - | POST | /api/events/{eventId}/images | ❌ 미구현 | Content Service 연동 필요 |
|
| 이미지 생성 요청 | EventController | POST | /api/v1/events/{eventId}/images | ✅ 구현 | EventController:214 |
|
||||||
| 이미지 선택 | - | PUT | /api/events/{eventId}/images/{imageId}/select | ❌ 미구현 | Content Service 연동 필요 |
|
| 이미지 선택 | EventController | PUT | /api/v1/events/{eventId}/images/{imageId}/select | ✅ 구현 | EventController:243 |
|
||||||
| 이미지 편집 | - | PUT | /api/events/{eventId}/images/{imageId}/edit | ❌ 미구현 | Content Service 연동 필요 |
|
| 이미지 편집 | EventController | PUT | /api/v1/events/{eventId}/images/{imageId}/edit | ✅ 구현 | EventController:328 |
|
||||||
|
|
||||||
**미구현 상세 이유**:
|
**구현 내용**:
|
||||||
- Kafka Topic `image-generation-job` 발행 로직 필요
|
- **이미지 생성 요청**: Kafka Topic `image-generation-job`에 메시지 발행, Job ID 반환
|
||||||
- Content Service와의 연동이 선행되어야 함
|
- **이미지 선택**: 사용자가 생성된 이미지 중 하나를 선택하여 이벤트에 연결
|
||||||
- Redis에서 생성된 이미지 URL을 읽어오는 로직 필요
|
- **이미지 편집**: 선택된 이미지를 편집하고 Content Service를 통해 재생성
|
||||||
- 이미지 편집은 Content Service의 이미지 재생성 API와 연동 필요
|
|
||||||
|
|
||||||
#### Step 4: 배포 채널 선택 (미구현)
|
#### Step 4: 배포 채널 선택 (구현률 100% ✅)
|
||||||
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 미구현 이유 |
|
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
||||||
|-----------|-----------|--------|------|----------|-----------|
|
|-----------|-----------|--------|------|----------|------|
|
||||||
| 배포 채널 선택 | - | PUT | /api/events/{eventId}/channels | ❌ 미구현 | Distribution Service 연동 필요 |
|
| 배포 채널 선택 | EventController | PUT | /api/v1/events/{eventId}/channels | ✅ 구현 | EventController:357 |
|
||||||
|
|
||||||
**미구현 상세 이유**:
|
**구현 내용**:
|
||||||
- Distribution Service의 채널 목록 검증 로직 필요
|
- 이벤트를 배포할 채널(SMS, KakaoTalk, App Push 등)을 선택
|
||||||
- Event 엔티티의 channels 필드 업데이트 로직은 구현 가능하나, 채널별 검증은 Distribution Service 개발 후 추가 예정
|
- Distribution Service와의 연동은 추후 추가 예정
|
||||||
|
|
||||||
#### Step 5: 최종 승인 및 배포
|
#### Step 5: 최종 승인 및 배포
|
||||||
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
||||||
|-----------|-----------|--------|------|----------|------|
|
|-----------|-----------|--------|------|----------|------|
|
||||||
| 최종 승인 및 배포 | EventController | POST | /api/events/{eventId}/publish | ✅ 구현 | EventController:172 |
|
| 최종 승인 및 배포 | EventController | POST | /api/v1/events/{eventId}/publish | ✅ 구현 | EventController:175 |
|
||||||
|
|
||||||
**구현 내용**:
|
**구현 내용**:
|
||||||
- 이벤트 상태를 DRAFT → PUBLISHED로 변경
|
- 이벤트 상태를 DRAFT → PUBLISHED로 변경
|
||||||
@ -91,19 +91,18 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2.3 Event Management (구현률 100%)
|
### 2.3 Event Management (구현률 100% ✅)
|
||||||
|
|
||||||
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
||||||
|-----------|-----------|--------|------|----------|------|
|
|-----------|-----------|--------|------|----------|------|
|
||||||
| 이벤트 수정 | - | PUT | /api/events/{eventId} | ❌ 미구현 | 이유는 아래 참조 |
|
| 이벤트 수정 | EventController | PUT | /api/v1/events/{eventId} | ✅ 구현 | EventController:384 |
|
||||||
| 이벤트 삭제 | EventController | DELETE | /api/events/{eventId} | ✅ 구현 | EventController:151 |
|
| 이벤트 삭제 | EventController | DELETE | /api/v1/events/{eventId} | ✅ 구현 | EventController:150 |
|
||||||
| 이벤트 조기 종료 | EventController | POST | /api/events/{eventId}/end | ✅ 구현 | EventController:193 |
|
| 이벤트 조기 종료 | EventController | POST | /api/v1/events/{eventId}/end | ✅ 구현 | EventController:192 |
|
||||||
|
|
||||||
**이벤트 수정 API 미구현 이유**:
|
**구현 내용**:
|
||||||
- 이벤트 수정은 여러 단계의 데이터를 수정하는 복잡한 로직
|
- **이벤트 수정**: 기존 이벤트의 정보를 수정합니다. DRAFT 상태만 수정 가능
|
||||||
- AI 추천 재선택, 이미지 재생성 등 다른 서비스와의 연동이 필요
|
- **이벤트 삭제**: DRAFT 상태의 이벤트만 삭제 가능
|
||||||
- 우선순위: 신규 이벤트 생성 플로우 완성 후 구현 예정
|
- **이벤트 조기 종료**: PUBLISHED 상태의 이벤트를 ENDED 상태로 변경
|
||||||
- 현재는 DRAFT 상태에서만 삭제 가능하므로 수정 대신 삭제 후 재생성 가능
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -111,15 +110,15 @@
|
|||||||
|
|
||||||
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
|
||||||
|-----------|-----------|--------|------|----------|------|
|
|-----------|-----------|--------|------|----------|------|
|
||||||
| Job 상태 폴링 | JobController | GET | /api/jobs/{jobId} | ✅ 구현 | JobController:42 |
|
| Job 상태 폴링 | JobController | GET | /api/v1/jobs/{jobId} | ✅ 구현 | JobController:42 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 구현된 API 상세
|
## 3. 구현된 API 상세
|
||||||
|
|
||||||
### 3.1 EventController (6개 API)
|
### 3.1 EventController (13개 API)
|
||||||
|
|
||||||
#### 1. POST /api/events/objectives
|
#### 1. POST /api/v1/events/objectives
|
||||||
- **설명**: 이벤트 생성의 첫 단계로 목적을 선택
|
- **설명**: 이벤트 생성의 첫 단계로 목적을 선택
|
||||||
- **유저스토리**: UFR-EVENT-020
|
- **유저스토리**: UFR-EVENT-020
|
||||||
- **요청**: SelectObjectiveRequest (objective)
|
- **요청**: SelectObjectiveRequest (objective)
|
||||||
@ -129,7 +128,7 @@
|
|||||||
- 초기 상태는 DRAFT
|
- 초기 상태는 DRAFT
|
||||||
- EventService.createEvent() 호출
|
- EventService.createEvent() 호출
|
||||||
|
|
||||||
#### 2. GET /api/events
|
#### 2. GET /api/v1/events
|
||||||
- **설명**: 사용자의 이벤트 목록 조회 (페이징, 필터링, 정렬)
|
- **설명**: 사용자의 이벤트 목록 조회 (페이징, 필터링, 정렬)
|
||||||
- **유저스토리**: UFR-EVENT-010, UFR-EVENT-070
|
- **유저스토리**: UFR-EVENT-010, UFR-EVENT-070
|
||||||
- **요청 파라미터**:
|
- **요청 파라미터**:
|
||||||
@ -143,7 +142,7 @@
|
|||||||
- Repository에서 필터링 및 페이징 처리
|
- Repository에서 필터링 및 페이징 처리
|
||||||
- EventService.getEvents() 호출
|
- EventService.getEvents() 호출
|
||||||
|
|
||||||
#### 3. GET /api/events/{eventId}
|
#### 3. GET /api/v1/events/{eventId}
|
||||||
- **설명**: 특정 이벤트의 상세 정보 조회
|
- **설명**: 특정 이벤트의 상세 정보 조회
|
||||||
- **유저스토리**: UFR-EVENT-060
|
- **유저스토리**: UFR-EVENT-060
|
||||||
- **요청**: eventId (UUID)
|
- **요청**: eventId (UUID)
|
||||||
@ -153,7 +152,7 @@
|
|||||||
- 사용자 소유 이벤트만 조회 가능 (보안)
|
- 사용자 소유 이벤트만 조회 가능 (보안)
|
||||||
- EventService.getEvent() 호출
|
- EventService.getEvent() 호출
|
||||||
|
|
||||||
#### 4. DELETE /api/events/{eventId}
|
#### 4. DELETE /api/v1/events/{eventId}
|
||||||
- **설명**: 이벤트 삭제 (DRAFT 상태만 가능)
|
- **설명**: 이벤트 삭제 (DRAFT 상태만 가능)
|
||||||
- **유저스토리**: UFR-EVENT-070
|
- **유저스토리**: UFR-EVENT-070
|
||||||
- **요청**: eventId (UUID)
|
- **요청**: eventId (UUID)
|
||||||
@ -163,7 +162,7 @@
|
|||||||
- 다른 상태(PUBLISHED, ENDED)는 삭제 불가
|
- 다른 상태(PUBLISHED, ENDED)는 삭제 불가
|
||||||
- EventService.deleteEvent() 호출
|
- EventService.deleteEvent() 호출
|
||||||
|
|
||||||
#### 5. POST /api/events/{eventId}/publish
|
#### 5. POST /api/v1/events/{eventId}/publish
|
||||||
- **설명**: 이벤트 배포 (DRAFT → PUBLISHED)
|
- **설명**: 이벤트 배포 (DRAFT → PUBLISHED)
|
||||||
- **유저스토리**: UFR-EVENT-050
|
- **유저스토리**: UFR-EVENT-050
|
||||||
- **요청**: eventId (UUID)
|
- **요청**: eventId (UUID)
|
||||||
@ -173,7 +172,7 @@
|
|||||||
- Distribution Service 호출은 추후 추가 예정
|
- Distribution Service 호출은 추후 추가 예정
|
||||||
- EventService.publishEvent() 호출
|
- EventService.publishEvent() 호출
|
||||||
|
|
||||||
#### 6. POST /api/events/{eventId}/end
|
#### 6. POST /api/v1/events/{eventId}/end
|
||||||
- **설명**: 이벤트 조기 종료 (PUBLISHED → ENDED)
|
- **설명**: 이벤트 조기 종료 (PUBLISHED → ENDED)
|
||||||
- **유저스토리**: UFR-EVENT-060
|
- **유저스토리**: UFR-EVENT-060
|
||||||
- **요청**: eventId (UUID)
|
- **요청**: eventId (UUID)
|
||||||
@ -183,11 +182,81 @@
|
|||||||
- PUBLISHED 상태만 종료 가능
|
- PUBLISHED 상태만 종료 가능
|
||||||
- EventService.endEvent() 호출
|
- EventService.endEvent() 호출
|
||||||
|
|
||||||
|
#### 7. POST /api/v1/events/{eventId}/images
|
||||||
|
- **설명**: AI를 통해 이벤트 이미지를 생성 요청
|
||||||
|
- **유저스토리**: UFR-CONT-010
|
||||||
|
- **요청**: ImageGenerationRequest (prompt, style, count)
|
||||||
|
- **응답**: ImageGenerationResponse (jobId)
|
||||||
|
- **비즈니스 로직**:
|
||||||
|
- Kafka Topic `image-generation-job`에 메시지 발행
|
||||||
|
- 비동기 작업을 위한 Job 엔티티 생성 및 반환
|
||||||
|
- EventService.requestImageGeneration() 호출
|
||||||
|
|
||||||
|
#### 8. PUT /api/v1/events/{eventId}/images/{imageId}/select
|
||||||
|
- **설명**: 생성된 이미지 중 하나를 선택
|
||||||
|
- **유저스토리**: UFR-CONT-020
|
||||||
|
- **요청**: SelectImageRequest (imageId)
|
||||||
|
- **응답**: ApiResponse<Void>
|
||||||
|
- **비즈니스 로직**:
|
||||||
|
- 선택한 이미지를 이벤트에 연결
|
||||||
|
- 이미지 URL을 Event 엔티티에 저장
|
||||||
|
- EventService.selectImage() 호출
|
||||||
|
|
||||||
|
#### 9. POST /api/v1/events/{eventId}/ai-recommendations
|
||||||
|
- **설명**: AI 서비스에 이벤트 추천 생성을 요청
|
||||||
|
- **유저스토리**: UFR-EVENT-030
|
||||||
|
- **요청**: AiRecommendationRequest (이벤트 컨텍스트 정보)
|
||||||
|
- **응답**: JobAcceptedResponse (jobId)
|
||||||
|
- **비즈니스 로직**:
|
||||||
|
- Kafka Topic `ai-event-generation-job`에 메시지 발행
|
||||||
|
- 비동기 작업을 위한 Job 엔티티 생성 및 반환
|
||||||
|
- EventService.requestAiRecommendations() 호출
|
||||||
|
|
||||||
|
#### 10. PUT /api/v1/events/{eventId}/recommendations
|
||||||
|
- **설명**: AI가 생성한 추천 중 하나를 선택하고 커스터마이징
|
||||||
|
- **유저스토리**: UFR-EVENT-030
|
||||||
|
- **요청**: SelectRecommendationRequest (recommendationId, customizations)
|
||||||
|
- **응답**: ApiResponse<Void>
|
||||||
|
- **비즈니스 로직**:
|
||||||
|
- 선택한 AI 추천을 이벤트에 적용
|
||||||
|
- 사용자 커스터마이징 반영
|
||||||
|
- EventService.selectRecommendation() 호출
|
||||||
|
|
||||||
|
#### 11. PUT /api/v1/events/{eventId}/images/{imageId}/edit
|
||||||
|
- **설명**: 선택된 이미지를 편집
|
||||||
|
- **유저스토리**: UFR-CONT-030
|
||||||
|
- **요청**: ImageEditRequest (editInstructions)
|
||||||
|
- **응답**: ImageEditResponse (editedImageUrl, jobId)
|
||||||
|
- **비즈니스 로직**:
|
||||||
|
- Content Service와 연동하여 이미지 편집 요청
|
||||||
|
- 편집된 이미지를 다시 생성하고 CDN에 업로드
|
||||||
|
- EventService.editImage() 호출
|
||||||
|
|
||||||
|
#### 12. PUT /api/v1/events/{eventId}/channels
|
||||||
|
- **설명**: 이벤트를 배포할 채널을 선택
|
||||||
|
- **유저스토리**: UFR-EVENT-040
|
||||||
|
- **요청**: SelectChannelsRequest (channels: List<String>)
|
||||||
|
- **응답**: ApiResponse<Void>
|
||||||
|
- **비즈니스 로직**:
|
||||||
|
- 배포 채널(SMS, KakaoTalk, App Push 등) 선택
|
||||||
|
- Event 엔티티의 channels 필드 업데이트
|
||||||
|
- EventService.selectChannels() 호출
|
||||||
|
|
||||||
|
#### 13. PUT /api/v1/events/{eventId}
|
||||||
|
- **설명**: 기존 이벤트의 정보를 수정
|
||||||
|
- **유저스토리**: UFR-EVENT-080
|
||||||
|
- **요청**: UpdateEventRequest (이벤트 수정 정보)
|
||||||
|
- **응답**: EventDetailResponse (수정된 이벤트 정보)
|
||||||
|
- **비즈니스 로직**:
|
||||||
|
- DRAFT 상태의 이벤트만 수정 가능
|
||||||
|
- 이벤트 기본 정보, AI 추천, 이미지, 채널 등 수정
|
||||||
|
- EventService.updateEvent() 호출
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.2 JobController (1개 API)
|
### 3.2 JobController (1개 API)
|
||||||
|
|
||||||
#### 1. GET /api/jobs/{jobId}
|
#### 1. GET /api/v1/jobs/{jobId}
|
||||||
- **설명**: 비동기 작업의 상태를 조회 (폴링 방식)
|
- **설명**: 비동기 작업의 상태를 조회 (폴링 방식)
|
||||||
- **유저스토리**: UFR-EVENT-030, UFR-CONT-010
|
- **유저스토리**: UFR-EVENT-030, UFR-CONT-010
|
||||||
- **요청**: jobId (UUID)
|
- **요청**: jobId (UUID)
|
||||||
@ -199,94 +268,120 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 미구현 API 개발 계획
|
## 4. 추가 구현된 API (설계서에 없음)
|
||||||
|
|
||||||
### 4.1 우선순위 1 (AI Service 연동)
|
|
||||||
- **POST /api/events/{eventId}/ai-recommendations** - AI 추천 요청
|
|
||||||
- **PUT /api/events/{eventId}/recommendations** - AI 추천 선택
|
|
||||||
|
|
||||||
**개발 선행 조건**:
|
|
||||||
1. AI Service 개발 완료
|
|
||||||
2. Kafka Topic `ai-event-generation-job` 설정
|
|
||||||
3. Redis 캐시 연동 구현
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 우선순위 2 (Content Service 연동)
|
|
||||||
- **POST /api/events/{eventId}/images** - 이미지 생성 요청
|
|
||||||
- **PUT /api/events/{eventId}/images/{imageId}/select** - 이미지 선택
|
|
||||||
- **PUT /api/events/{eventId}/images/{imageId}/edit** - 이미지 편집
|
|
||||||
|
|
||||||
**개발 선행 조건**:
|
|
||||||
1. Content Service 개발 완료
|
|
||||||
2. Kafka Topic `image-generation-job` 설정
|
|
||||||
3. Redis 캐시 연동 구현
|
|
||||||
4. CDN (Azure Blob Storage) 연동
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.3 우선순위 3 (Distribution Service 연동)
|
|
||||||
- **PUT /api/events/{eventId}/channels** - 배포 채널 선택
|
|
||||||
|
|
||||||
**개발 선행 조건**:
|
|
||||||
1. Distribution Service 개발 완료
|
|
||||||
2. 채널별 검증 로직 구현
|
|
||||||
3. POST /api/events/{eventId}/publish API에 Distribution Service 동기 호출 추가
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.4 우선순위 4 (이벤트 수정)
|
|
||||||
- **PUT /api/events/{eventId}** - 이벤트 수정
|
|
||||||
|
|
||||||
**개발 선행 조건**:
|
|
||||||
1. 우선순위 1~3 API 모두 구현 완료
|
|
||||||
2. 이벤트 수정 범위 정의 (이름/설명/날짜만 수정 vs 전체 재생성)
|
|
||||||
3. 각 단계별 수정 로직 설계
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 추가 구현된 API (설계서에 없음)
|
|
||||||
|
|
||||||
현재 추가 구현된 API는 없습니다. 모든 구현은 설계서를 기준으로 진행되었습니다.
|
현재 추가 구현된 API는 없습니다. 모든 구현은 설계서를 기준으로 진행되었습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 다음 단계
|
## 5. 다음 단계
|
||||||
|
|
||||||
### 6.1 즉시 가능한 작업
|
### 5.1 즉시 가능한 작업
|
||||||
1. **서버 시작 테스트**:
|
1. **서버 시작 테스트**:
|
||||||
- PostgreSQL 연결 확인
|
- PostgreSQL 연결 확인
|
||||||
|
- Kafka 연결 확인
|
||||||
|
- Redis 연결 확인
|
||||||
- Swagger UI 접근 테스트 (http://localhost:8081/swagger-ui.html)
|
- Swagger UI 접근 테스트 (http://localhost:8081/swagger-ui.html)
|
||||||
|
|
||||||
2. **구현된 API 테스트**:
|
2. **구현된 전체 API 테스트** (14개):
|
||||||
- POST /api/events/objectives
|
- POST /api/v1/events/objectives (이벤트 목적 선택)
|
||||||
- GET /api/events
|
- GET /api/v1/events (이벤트 목록 조회)
|
||||||
- GET /api/events/{eventId}
|
- GET /api/v1/events/{eventId} (이벤트 상세 조회)
|
||||||
- DELETE /api/events/{eventId}
|
- DELETE /api/v1/events/{eventId} (이벤트 삭제)
|
||||||
- POST /api/events/{eventId}/publish
|
- PUT /api/v1/events/{eventId} (이벤트 수정)
|
||||||
- POST /api/events/{eventId}/end
|
- POST /api/v1/events/{eventId}/ai-recommendations (AI 추천 요청)
|
||||||
- GET /api/jobs/{jobId}
|
- PUT /api/v1/events/{eventId}/recommendations (AI 추천 선택)
|
||||||
|
- POST /api/v1/events/{eventId}/images (이미지 생성 요청)
|
||||||
|
- PUT /api/v1/events/{eventId}/images/{imageId}/select (이미지 선택)
|
||||||
|
- PUT /api/v1/events/{eventId}/images/{imageId}/edit (이미지 편집)
|
||||||
|
- PUT /api/v1/events/{eventId}/channels (배포 채널 선택)
|
||||||
|
- POST /api/v1/events/{eventId}/publish (이벤트 배포)
|
||||||
|
- POST /api/v1/events/{eventId}/end (이벤트 종료)
|
||||||
|
- GET /api/v1/jobs/{jobId} (Job 상태 조회)
|
||||||
|
|
||||||
### 6.2 후속 개발 필요
|
### 5.2 서비스 간 연동 완성 필요
|
||||||
1. AI Service 개발 완료 → AI 추천 API 구현
|
1. **AI Service 연동**:
|
||||||
2. Content Service 개발 완료 → 이미지 관련 API 구현
|
- Kafka Consumer에서 `ai-event-generation-job` 처리
|
||||||
3. Distribution Service 개발 완료 → 배포 채널 선택 API 구현
|
- Redis를 통한 AI 추천 결과 캐싱
|
||||||
4. 전체 서비스 연동 → 이벤트 수정 API 구현
|
- AI 추천 API 완전 통합 테스트
|
||||||
|
|
||||||
|
2. **Content Service 연동**:
|
||||||
|
- 이미지 생성/편집 API 통합
|
||||||
|
- CDN 업로드 로직 연동
|
||||||
|
- 이미지 편집 API 완전 통합 테스트
|
||||||
|
|
||||||
|
3. **Distribution Service 연동**:
|
||||||
|
- 배포 채널 검증 로직 추가
|
||||||
|
- 이벤트 배포 시 Distribution Service 동기 호출
|
||||||
|
- 채널별 배포 상태 추적
|
||||||
|
|
||||||
|
### 5.3 통합 테스트 시나리오
|
||||||
|
전체 이벤트 생성 플로우를 End-to-End로 테스트:
|
||||||
|
1. 이벤트 목적 선택
|
||||||
|
2. AI 추천 요청 및 선택
|
||||||
|
3. 이미지 생성 및 선택/편집
|
||||||
|
4. 배포 채널 선택
|
||||||
|
5. 최종 배포 및 모니터링
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 부록
|
## 부록
|
||||||
|
|
||||||
### A. 개발 우선순위 결정 근거
|
### A. 개발 완료 요약
|
||||||
|
|
||||||
**현재 구현 범위 선정 이유**:
|
**Event Service API 개발 현황**:
|
||||||
1. **핵심 생명주기 먼저**: 이벤트 생성, 조회, 삭제, 상태 변경
|
- ✅ **전체 API 구현 완료**: 설계된 14개 API 모두 구현
|
||||||
2. **서비스 독립성**: 다른 서비스 없이도 Event Service 단독 테스트 가능
|
- ✅ **핵심 생명주기 관리**: 이벤트 생성, 조회, 수정, 삭제, 상태 변경
|
||||||
3. **점진적 통합**: 각 서비스 개발 완료 시점에 순차적 통합
|
- ✅ **AI 추천 플로우**: AI 추천 요청 및 선택 API 완성
|
||||||
4. **리스크 최소화**: 복잡한 서비스 간 연동은 각 서비스 안정화 후 진행
|
- ✅ **이미지 관리**: 생성, 선택, 편집 API 완성
|
||||||
|
- ✅ **배포 관리**: 채널 선택 및 배포 API 완성
|
||||||
|
- ✅ **비동기 작업 추적**: Job 상태 조회 API 완성
|
||||||
|
|
||||||
|
**다음 단계**:
|
||||||
|
- AI Service, Content Service, Distribution Service와의 완전한 통합 테스트
|
||||||
|
- End-to-End 시나리오 기반 통합 검증
|
||||||
|
- 성능 최적화 및 에러 핸들링 강화
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**문서 버전**: 1.0
|
**문서 버전**: 2.0
|
||||||
**최종 수정일**: 2025-10-24
|
**최종 수정일**: 2025-10-28
|
||||||
**작성자**: Event Service Team
|
**작성자**: Event Service Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
### v2.0 (2025-10-28) - 🎉 전체 API 구현 완료
|
||||||
|
- **구현 현황 업데이트**: 9개 → 14개 API (100% 구현 완료!)
|
||||||
|
- **신규 구현 API 추가 (5개)**:
|
||||||
|
1. POST /api/v1/events/{eventId}/ai-recommendations - AI 추천 요청
|
||||||
|
2. PUT /api/v1/events/{eventId}/recommendations - AI 추천 선택
|
||||||
|
3. PUT /api/v1/events/{eventId}/images/{imageId}/edit - 이미지 편집
|
||||||
|
4. PUT /api/v1/events/{eventId}/channels - 배포 채널 선택
|
||||||
|
5. PUT /api/v1/events/{eventId} - 이벤트 수정
|
||||||
|
- **구현률 100% 달성**:
|
||||||
|
- Event Creation Flow: 37.5% → 100%
|
||||||
|
- Event Management: 66.7% → 100%
|
||||||
|
- 모든 카테고리 100% 완성
|
||||||
|
- **문서 구조 개선**:
|
||||||
|
- 미구현 API 계획 섹션 제거
|
||||||
|
- 서비스 간 연동 완성 가이드 추가
|
||||||
|
- 통합 테스트 시나리오 추가
|
||||||
|
- **라인 번호 업데이트**: 모든 Controller 메서드의 정확한 라인 번호 반영
|
||||||
|
|
||||||
|
### v1.1 (2025-10-27)
|
||||||
|
- **구현 현황 업데이트**: 7개 → 9개 API (64.3% 구현)
|
||||||
|
- **신규 구현 API 추가**:
|
||||||
|
- POST /api/v1/events/{eventId}/images - 이미지 생성 요청
|
||||||
|
- PUT /api/v1/events/{eventId}/images/{imageId}/select - 이미지 선택
|
||||||
|
- **API 경로 수정**: /api/events → /api/v1/events (버전 명시)
|
||||||
|
- **구현률 재계산**:
|
||||||
|
- Event Creation Flow: 12.5% → 37.5%
|
||||||
|
- Event Management: 100% → 66.7% (이벤트 수정 미구현 반영)
|
||||||
|
- **미구현 API 계획 업데이트**: Content Service 연동 우선순위 조정
|
||||||
|
|
||||||
|
### v1.0 (2025-10-24)
|
||||||
|
- 초기 문서 작성
|
||||||
|
- 설계된 14개 API 목록 정리
|
||||||
|
- 초기 구현 상태 기록 (7개 API)
|
||||||
|
|||||||
@ -1,389 +1,411 @@
|
|||||||
# Content Service 백엔드 테스트 결과서
|
# Event Service 백엔드 API 테스트 결과
|
||||||
|
|
||||||
## 1. 테스트 개요
|
## 테스트 개요
|
||||||
|
|
||||||
### 1.1 테스트 정보
|
**테스트 일시**: 2025-10-28
|
||||||
- **테스트 일시**: 2025-10-23
|
**서비스**: Event Service
|
||||||
- **테스트 환경**: Local 개발 환경
|
**베이스 URL**: http://localhost:8080
|
||||||
- **서비스명**: Content Service
|
**인증 방식**: 없음 (개발 환경)
|
||||||
- **서비스 포트**: 8084
|
|
||||||
- **프로파일**: local (H2 in-memory database)
|
|
||||||
- **테스트 대상**: REST API 7개 엔드포인트
|
|
||||||
|
|
||||||
### 1.2 테스트 목적
|
## 테스트 환경 설정
|
||||||
- Content Service의 모든 REST API 엔드포인트 정상 동작 검증
|
|
||||||
- Mock 서비스 (MockGenerateImagesService, MockRedisGateway) 정상 동작 확인
|
|
||||||
- Local 환경에서 외부 인프라 의존성 없이 독립 실행 가능 여부 검증
|
|
||||||
|
|
||||||
## 2. 테스트 환경 구성
|
### 1. 환경 변수 검증 결과
|
||||||
|
|
||||||
### 2.1 데이터베이스
|
**application.yml 설정**:
|
||||||
- **DB 타입**: H2 In-Memory Database
|
- ✅ 모든 환경 변수가 플레이스홀더 형식으로 정의됨
|
||||||
- **연결 URL**: jdbc:h2:mem:contentdb
|
- ✅ 기본값 설정 확인: `${변수명:기본값}` 형식 사용
|
||||||
- **스키마 생성**: 자동 (ddl-auto: create-drop)
|
|
||||||
- **생성된 테이블**:
|
|
||||||
- contents (콘텐츠 정보)
|
|
||||||
- generated_images (생성된 이미지 정보)
|
|
||||||
- jobs (작업 상태 추적)
|
|
||||||
|
|
||||||
### 2.2 Mock 서비스
|
**event-service.run.xml 실행 프로파일**:
|
||||||
- **MockRedisGateway**: Redis 캐시 기능 Mock 구현
|
- ✅ 모든 필수 환경 변수 정의됨
|
||||||
- **MockGenerateImagesService**: AI 이미지 생성 비동기 처리 Mock 구현
|
- ✅ application.yml과 일치하는 변수명 사용
|
||||||
- 1초 지연 후 4개 이미지 자동 생성 (FANCY/SIMPLE x INSTAGRAM/KAKAO)
|
|
||||||
|
|
||||||
### 2.3 서버 시작 로그
|
**환경 변수 매핑 확인**:
|
||||||
```
|
| 환경 변수 | application.yml | run.xml | 일치 여부 |
|
||||||
Started ContentApplication in 2.856 seconds (process running for 3.212)
|
|----------|----------------|---------|----------|
|
||||||
Hibernate: create table contents (...)
|
| SERVER_PORT | ✅ ${SERVER_PORT:8080} | ✅ 8080 | ✅ |
|
||||||
Hibernate: create table generated_images (...)
|
| DB_HOST | ✅ ${DB_HOST:localhost} | ✅ 20.249.177.232 | ✅ |
|
||||||
Hibernate: create table jobs (...)
|
| DB_PORT | ✅ ${DB_PORT:5432} | ✅ 5432 | ✅ |
|
||||||
```
|
| DB_NAME | ✅ ${DB_NAME:eventdb} | ✅ eventdb | ✅ |
|
||||||
|
| DB_USERNAME | ✅ ${DB_USERNAME:eventuser} | ✅ eventuser | ✅ |
|
||||||
|
| DB_PASSWORD | ✅ ${DB_PASSWORD:eventpass} | ✅ Hi5Jessica! | ✅ |
|
||||||
|
| REDIS_HOST | ✅ ${REDIS_HOST:localhost} | ✅ 20.214.210.71 | ✅ |
|
||||||
|
| REDIS_PORT | ✅ ${REDIS_PORT:6379} | ✅ 6379 | ✅ |
|
||||||
|
| REDIS_PASSWORD | ✅ ${REDIS_PASSWORD:} | ✅ Hi5Jessica! | ✅ |
|
||||||
|
| KAFKA_BOOTSTRAP_SERVERS | ✅ ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} | ✅ 20.249.182.13:9095,4.217.131.59:9095 | ✅ |
|
||||||
|
| JWT_SECRET | ✅ ${JWT_SECRET:default...} | ✅ kt-event-marketing-secret... | ✅ |
|
||||||
|
| LOG_LEVEL | ✅ ${LOG_LEVEL:INFO} | ✅ DEBUG | ✅ |
|
||||||
|
|
||||||
## 3. API 테스트 결과
|
**결론**: ✅ 설정 일치 확인 완료
|
||||||
|
|
||||||
### 3.1 POST /content/images/generate - 이미지 생성 요청
|
### 2. 서비스 Health Check
|
||||||
|
|
||||||
**목적**: AI 이미지 생성 작업 시작
|
|
||||||
|
|
||||||
**요청**:
|
**요청**:
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8084/content/images/generate \
|
curl http://localhost:8080/actuator/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "UP",
|
||||||
|
"components": {
|
||||||
|
"db": {
|
||||||
|
"status": "UP",
|
||||||
|
"details": {
|
||||||
|
"database": "PostgreSQL",
|
||||||
|
"validationQuery": "isValid()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diskSpace": {
|
||||||
|
"status": "UP",
|
||||||
|
"details": {
|
||||||
|
"total": 511724277760,
|
||||||
|
"free": 268097769472,
|
||||||
|
"threshold": 10485760,
|
||||||
|
"path": "C:\\Users\\KTDS\\home\\workspace\\kt-event-marketing\\.",
|
||||||
|
"exists": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"livenessState": {
|
||||||
|
"status": "UP"
|
||||||
|
},
|
||||||
|
"ping": {
|
||||||
|
"status": "UP"
|
||||||
|
},
|
||||||
|
"readinessState": {
|
||||||
|
"status": "UP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과**: ✅ **서비스 정상 (UP)**
|
||||||
|
- PostgreSQL: UP
|
||||||
|
- Disk Space: UP
|
||||||
|
- Liveness: UP
|
||||||
|
- Readiness: UP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 테스트 결과
|
||||||
|
|
||||||
|
### 1. Redis 연결 테스트
|
||||||
|
|
||||||
|
**엔드포인트**: `GET /api/v1/redis-test/ping`
|
||||||
|
|
||||||
|
**요청**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/v1/redis-test/ping
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답**:
|
||||||
|
```
|
||||||
|
Redis OK - pong:1730104879446
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과**: ✅ **성공**
|
||||||
|
**비고**: Redis 연결 및 데이터 저장/조회 정상 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 이벤트 생성 API (목적 선택)
|
||||||
|
|
||||||
|
**엔드포인트**: `POST /api/v1/events/objectives`
|
||||||
|
|
||||||
|
**요청**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/events/objectives \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"objective":"customer_retention"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"eventId": "9caa45e8-668e-4e84-a4d4-98c841e6f727",
|
||||||
|
"status": "DRAFT",
|
||||||
|
"objective": "customer_retention",
|
||||||
|
"createdAt": "2025-10-28T14:54:40.1796612"
|
||||||
|
},
|
||||||
|
"timestamp": "2025-10-28T14:54:40.1906609"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과**: ✅ **성공**
|
||||||
|
**생성된 이벤트 ID**: 9caa45e8-668e-4e84-a4d4-98c841e6f727
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. AI 추천 요청 API
|
||||||
|
|
||||||
|
**엔드포인트**: `POST /api/v1/events/{eventId}/ai-recommendations`
|
||||||
|
|
||||||
|
**요청**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/events/9caa45e8-668e-4e84-a4d4-98c841e6f727/ai-recommendations \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"eventDraftId": 1,
|
"storeInfo": {
|
||||||
"styles": ["FANCY", "SIMPLE"],
|
"storeId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"platforms": ["INSTAGRAM", "KAKAO"]
|
"storeName": "Woojin BBQ",
|
||||||
|
"category": "Restaurant",
|
||||||
|
"description": "Korean BBQ restaurant in Seoul"
|
||||||
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**응답**:
|
**응답**:
|
||||||
- **HTTP 상태**: 202 Accepted
|
|
||||||
- **응답 본문**:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "job-mock-7ada8bd3",
|
"success": true,
|
||||||
"eventDraftId": 1,
|
"data": {
|
||||||
"jobType": "image-generation",
|
"jobId": "3e3e8214-131a-4a1f-93ce-bf8b7702cb81",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"progress": 0,
|
"message": "AI 추천 생성 요청이 접수되었습니다. /jobs/3e3e8214-131a-4a1f-93ce-bf8b7702cb81로 상태를 확인하세요."
|
||||||
"resultMessage": null,
|
},
|
||||||
"errorMessage": null,
|
"timestamp": "2025-10-28T14:55:23.4982302"
|
||||||
"createdAt": "2025-10-23T21:52:57.511438",
|
|
||||||
"updatedAt": "2025-10-23T21:52:57.511438"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**검증 결과**: ✅ PASS
|
**결과**: ✅ **성공**
|
||||||
- Job이 정상적으로 생성되어 PENDING 상태로 반환됨
|
**생성된 Job ID**: 3e3e8214-131a-4a1f-93ce-bf8b7702cb81
|
||||||
- 비동기 처리를 위한 Job ID 발급 확인
|
**비고**: Kafka 메시지 발행 성공 (비동기 처리)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.2 GET /content/images/jobs/{jobId} - 작업 상태 조회
|
### 4. Job 상태 조회 API
|
||||||
|
|
||||||
**목적**: 이미지 생성 작업의 진행 상태 확인
|
**엔드포인트**: `GET /api/v1/jobs/{jobId}`
|
||||||
|
|
||||||
**요청**:
|
**요청**:
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8084/content/images/jobs/job-mock-7ada8bd3
|
curl http://localhost:8080/api/v1/jobs/3e3e8214-131a-4a1f-93ce-bf8b7702cb81
|
||||||
```
|
|
||||||
|
|
||||||
**응답** (1초 후):
|
|
||||||
- **HTTP 상태**: 200 OK
|
|
||||||
- **응답 본문**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "job-mock-7ada8bd3",
|
|
||||||
"eventDraftId": 1,
|
|
||||||
"jobType": "image-generation",
|
|
||||||
"status": "COMPLETED",
|
|
||||||
"progress": 100,
|
|
||||||
"resultMessage": "4개의 이미지가 성공적으로 생성되었습니다.",
|
|
||||||
"errorMessage": null,
|
|
||||||
"createdAt": "2025-10-23T21:52:57.511438",
|
|
||||||
"updatedAt": "2025-10-23T21:52:58.571923"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**검증 결과**: ✅ PASS
|
|
||||||
- Job 상태가 PENDING → COMPLETED로 정상 전환
|
|
||||||
- progress가 0 → 100으로 업데이트
|
|
||||||
- resultMessage에 생성 결과 포함
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 GET /content/events/{eventDraftId} - 이벤트 콘텐츠 조회
|
|
||||||
|
|
||||||
**목적**: 특정 이벤트의 전체 콘텐츠 정보 조회 (이미지 포함)
|
|
||||||
|
|
||||||
**요청**:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8084/content/events/1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**응답**:
|
**응답**:
|
||||||
- **HTTP 상태**: 200 OK
|
|
||||||
- **응답 본문**:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"eventDraftId": 1,
|
"success": true,
|
||||||
"eventTitle": "Mock 이벤트 제목 1",
|
"data": {
|
||||||
"eventDescription": "Mock 이벤트 설명입니다. 테스트를 위한 Mock 데이터입니다.",
|
"jobId": "3e3e8214-131a-4a1f-93ce-bf8b7702cb81",
|
||||||
"images": [
|
"jobType": "AI_RECOMMENDATION",
|
||||||
{
|
"status": "PENDING",
|
||||||
"id": 1,
|
"eventId": "9caa45e8-668e-4e84-a4d4-98c841e6f727",
|
||||||
"style": "FANCY",
|
"createdAt": "2025-10-28T14:55:23.4982302",
|
||||||
"platform": "INSTAGRAM",
|
"updatedAt": "2025-10-28T14:55:23.4982302",
|
||||||
"cdnUrl": "https://mock-cdn.azure.com/images/1/fancy_instagram_7ada8bd3.png",
|
"completedAt": null,
|
||||||
"prompt": "Mock prompt for FANCY style on INSTAGRAM platform",
|
"errorMessage": null
|
||||||
"selected": true
|
|
||||||
},
|
},
|
||||||
{
|
"timestamp": "2025-10-28T14:55:47.9869931"
|
||||||
"id": 2,
|
}
|
||||||
"style": "FANCY",
|
```
|
||||||
"platform": "KAKAO",
|
|
||||||
"cdnUrl": "https://mock-cdn.azure.com/images/1/fancy_kakao_3e2eaacf.png",
|
**결과**: ✅ **성공**
|
||||||
"prompt": "Mock prompt for FANCY style on KAKAO platform",
|
**비고**: Job 상태 추적 정상 동작
|
||||||
"selected": false
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 이벤트 상세 조회 API
|
||||||
|
|
||||||
|
**엔드포인트**: `GET /api/v1/events/{eventId}`
|
||||||
|
|
||||||
|
**요청**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/v1/events/9caa45e8-668e-4e84-a4d4-98c841e6f727
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"eventId": "9caa45e8-668e-4e84-a4d4-98c841e6f727",
|
||||||
|
"userId": null,
|
||||||
|
"storeId": null,
|
||||||
|
"eventName": null,
|
||||||
|
"description": null,
|
||||||
|
"objective": "customer_retention",
|
||||||
|
"startDate": null,
|
||||||
|
"endDate": null,
|
||||||
|
"status": "DRAFT",
|
||||||
|
"selectedImageId": null,
|
||||||
|
"selectedImageUrl": null,
|
||||||
|
"generatedImages": [],
|
||||||
|
"aiRecommendations": [],
|
||||||
|
"channels": [],
|
||||||
|
"createdAt": "2025-10-28T14:54:40.179661",
|
||||||
|
"updatedAt": "2025-10-28T14:54:40.179661"
|
||||||
},
|
},
|
||||||
|
"timestamp": "2025-10-28T14:56:08.6623502"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과**: ✅ **성공**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 이벤트 목록 조회 API
|
||||||
|
|
||||||
|
**엔드포인트**: `GET /api/v1/events`
|
||||||
|
|
||||||
|
**요청**:
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8080/api/v1/events?page=0&size=10"
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"content": [
|
||||||
{
|
{
|
||||||
"id": 3,
|
"eventId": "9caa45e8-668e-4e84-a4d4-98c841e6f727",
|
||||||
"style": "SIMPLE",
|
"userId": null,
|
||||||
"platform": "INSTAGRAM",
|
"storeId": null,
|
||||||
"cdnUrl": "https://mock-cdn.azure.com/images/1/simple_instagram_56d91422.png",
|
"eventName": null,
|
||||||
"prompt": "Mock prompt for SIMPLE style on INSTAGRAM platform",
|
"description": null,
|
||||||
"selected": false
|
"objective": "customer_retention",
|
||||||
},
|
"startDate": null,
|
||||||
{
|
"endDate": null,
|
||||||
"id": 4,
|
"status": "DRAFT",
|
||||||
"style": "SIMPLE",
|
"selectedImageId": null,
|
||||||
"platform": "KAKAO",
|
"selectedImageUrl": null,
|
||||||
"cdnUrl": "https://mock-cdn.azure.com/images/1/simple_kakao_7c9a666a.png",
|
"generatedImages": [],
|
||||||
"prompt": "Mock prompt for SIMPLE style on KAKAO platform",
|
"aiRecommendations": [],
|
||||||
"selected": false
|
"channels": [],
|
||||||
|
"createdAt": "2025-10-28T14:54:40.179661",
|
||||||
|
"updatedAt": "2025-10-28T14:54:40.179661"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"createdAt": "2025-10-23T21:52:57.52133",
|
"page": 0,
|
||||||
"updatedAt": "2025-10-23T21:52:57.52133"
|
"size": 10,
|
||||||
}
|
"totalElements": 1,
|
||||||
```
|
"totalPages": 1,
|
||||||
|
"first": true,
|
||||||
**검증 결과**: ✅ PASS
|
"last": true
|
||||||
- 콘텐츠 정보와 생성된 이미지 목록이 모두 조회됨
|
|
||||||
- 4개 이미지 (FANCY/SIMPLE x INSTAGRAM/KAKAO) 생성 확인
|
|
||||||
- 첫 번째 이미지(FANCY+INSTAGRAM)가 selected:true로 설정됨
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 GET /content/events/{eventDraftId}/images - 이미지 목록 조회
|
|
||||||
|
|
||||||
**목적**: 특정 이벤트의 이미지 목록만 조회
|
|
||||||
|
|
||||||
**요청**:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8084/content/events/1/images
|
|
||||||
```
|
|
||||||
|
|
||||||
**응답**:
|
|
||||||
- **HTTP 상태**: 200 OK
|
|
||||||
- **응답 본문**: 4개의 이미지 객체 배열
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"eventDraftId": 1,
|
|
||||||
"style": "FANCY",
|
|
||||||
"platform": "INSTAGRAM",
|
|
||||||
"cdnUrl": "https://mock-cdn.azure.com/images/1/fancy_instagram_7ada8bd3.png",
|
|
||||||
"prompt": "Mock prompt for FANCY style on INSTAGRAM platform",
|
|
||||||
"selected": true,
|
|
||||||
"createdAt": "2025-10-23T21:52:57.524759",
|
|
||||||
"updatedAt": "2025-10-23T21:52:57.524759"
|
|
||||||
},
|
},
|
||||||
// ... 나머지 3개 이미지
|
"timestamp": "2025-10-28T14:56:33.9042874"
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**검증 결과**: ✅ PASS
|
|
||||||
- 이벤트에 속한 모든 이미지가 정상 조회됨
|
|
||||||
- createdAt, updatedAt 타임스탬프 포함
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.5 GET /content/images/{imageId} - 개별 이미지 상세 조회
|
|
||||||
|
|
||||||
**목적**: 특정 이미지의 상세 정보 조회
|
|
||||||
|
|
||||||
**요청**:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8084/content/images/1
|
|
||||||
```
|
|
||||||
|
|
||||||
**응답**:
|
|
||||||
- **HTTP 상태**: 200 OK
|
|
||||||
- **응답 본문**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"eventDraftId": 1,
|
|
||||||
"style": "FANCY",
|
|
||||||
"platform": "INSTAGRAM",
|
|
||||||
"cdnUrl": "https://mock-cdn.azure.com/images/1/fancy_instagram_7ada8bd3.png",
|
|
||||||
"prompt": "Mock prompt for FANCY style on INSTAGRAM platform",
|
|
||||||
"selected": true,
|
|
||||||
"createdAt": "2025-10-23T21:52:57.524759",
|
|
||||||
"updatedAt": "2025-10-23T21:52:57.524759"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**검증 결과**: ✅ PASS
|
**결과**: ✅ **성공**
|
||||||
- 개별 이미지 정보가 정상적으로 조회됨
|
**비고**: 페이지네이션 정상 동작
|
||||||
- 모든 필드가 올바르게 반환됨
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.6 POST /content/images/{imageId}/regenerate - 이미지 재생성
|
## 통합 기능 검증
|
||||||
|
|
||||||
**목적**: 특정 이미지를 다시 생성하는 작업 시작
|
### 1. PostgreSQL 연동
|
||||||
|
- ✅ **연결**: 정상 (20.249.177.232:5432)
|
||||||
|
- ✅ **데이터베이스**: eventdb
|
||||||
|
- ✅ **CRUD 작업**: 정상 동작
|
||||||
|
- ✅ **JPA/Hibernate**: 정상 동작
|
||||||
|
|
||||||
**요청**:
|
### 2. Redis 연동
|
||||||
|
- ✅ **연결**: 정상 (20.214.210.71:6379)
|
||||||
|
- ✅ **데이터 저장/조회**: 정상 동작
|
||||||
|
- ✅ **Lettuce 클라이언트**: 정상 동작
|
||||||
|
|
||||||
|
### 3. Kafka 연동
|
||||||
|
- ✅ **Producer**: 정상 동작 (메시지 발행 성공)
|
||||||
|
- ⚠️ **Consumer**: 역직렬화 오류 로그 발생 (기능 동작은 정상)
|
||||||
|
- ✅ **ErrorHandlingDeserializer**: 적용됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 발견된 이슈 및 개선사항
|
||||||
|
|
||||||
|
### 1. Kafka Consumer 역직렬화 오류 (경미)
|
||||||
|
|
||||||
|
**현상**:
|
||||||
|
```
|
||||||
|
No type information in headers and no default type provided
|
||||||
|
```
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- 토픽에 이전 테스트 메시지가 남아있음
|
||||||
|
- ErrorHandlingDeserializer가 오류를 처리하지만 로그에 기록됨
|
||||||
|
|
||||||
|
**영향**:
|
||||||
|
- 서비스 기능에는 영향 없음
|
||||||
|
- 오류 메시지 스킵 후 정상 동작
|
||||||
|
|
||||||
|
**해결 방안**:
|
||||||
|
- ✅ ErrorHandlingDeserializer 이미 적용됨
|
||||||
|
- ⚠️ 운영 환경에서는 토픽 초기화 또는 consumer group 재설정 권장
|
||||||
|
|
||||||
|
### 2. UTF-8 인코딩 이슈 (환경 제약)
|
||||||
|
|
||||||
|
**현상**:
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8084/content/images/1/regenerate \
|
curl -d '{"storeName":"우진네 고깃집"}'
|
||||||
-H "Content-Type: application/json"
|
# → "Invalid UTF-8 start byte 0xbf" 오류
|
||||||
```
|
```
|
||||||
|
|
||||||
**응답**:
|
**원인**:
|
||||||
- **HTTP 상태**: 200 OK
|
- MINGW64 bash 터미널의 인코딩 제약
|
||||||
- **응답 본문**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "job-regen-df2bb3a3",
|
|
||||||
"eventDraftId": 999,
|
|
||||||
"jobType": "image-regeneration",
|
|
||||||
"status": "PENDING",
|
|
||||||
"progress": 0,
|
|
||||||
"resultMessage": null,
|
|
||||||
"errorMessage": null,
|
|
||||||
"createdAt": "2025-10-23T21:55:40.490627",
|
|
||||||
"updatedAt": "2025-10-23T21:55:40.490627"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**검증 결과**: ✅ PASS
|
**해결 방법**:
|
||||||
- 재생성 Job이 정상적으로 생성됨
|
- ✅ 영문 텍스트로 테스트 진행 (기능 검증 완료)
|
||||||
- jobType이 "image-regeneration"으로 설정됨
|
- 💡 **권장**: 한글 데이터 테스트 시 Postman 사용 또는 JSON 파일로 저장 후 `curl -d @file.json` 방식 사용
|
||||||
- PENDING 상태로 시작
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.7 DELETE /content/images/{imageId} - 이미지 삭제
|
## 테스트 요약
|
||||||
|
|
||||||
**목적**: 특정 이미지 삭제
|
### 성공한 테스트 (8/8)
|
||||||
|
|
||||||
**요청**:
|
| # | API | 엔드포인트 | 결과 |
|
||||||
```bash
|
|---|-----|-----------|------|
|
||||||
curl -X DELETE http://localhost:8084/content/images/4
|
| 1 | Health Check | GET /actuator/health | ✅ |
|
||||||
```
|
| 2 | Redis 테스트 | GET /api/v1/redis-test/ping | ✅ |
|
||||||
|
| 3 | 이벤트 생성 | POST /api/v1/events/objectives | ✅ |
|
||||||
|
| 4 | AI 추천 요청 | POST /api/v1/events/{id}/ai-recommendations | ✅ |
|
||||||
|
| 5 | Job 상태 조회 | GET /api/v1/jobs/{jobId} | ✅ |
|
||||||
|
| 6 | 이벤트 조회 | GET /api/v1/events/{id} | ✅ |
|
||||||
|
| 7 | 이벤트 목록 | GET /api/v1/events | ✅ |
|
||||||
|
| 8 | 설정 일치 검증 | application.yml ↔ run.xml | ✅ |
|
||||||
|
|
||||||
**응답**:
|
**성공률**: 100% (8/8)
|
||||||
- **HTTP 상태**: 204 No Content
|
|
||||||
- **응답 본문**: 없음 (정상)
|
|
||||||
|
|
||||||
**검증 결과**: ✅ PASS
|
### 테스트되지 않은 API
|
||||||
- 삭제 요청이 정상적으로 처리됨
|
|
||||||
- HTTP 204 상태로 응답
|
|
||||||
|
|
||||||
**참고**: H2 in-memory 데이터베이스 특성상 물리적 삭제가 즉시 반영되지 않을 수 있음
|
다음 API는 Content Service 또는 Distribution Service가 필요하여 테스트 미진행:
|
||||||
|
- POST /api/v1/events/{eventId}/images - 이미지 생성 요청
|
||||||
|
- PUT /api/v1/events/{eventId}/images/{imageId}/select - 이미지 선택
|
||||||
|
- PUT /api/v1/events/{eventId}/recommendations - AI 추천 선택
|
||||||
|
- PUT /api/v1/events/{eventId} - 이벤트 수정
|
||||||
|
- POST /api/v1/events/{eventId}/publish - 이벤트 배포
|
||||||
|
- PUT /api/v1/events/{eventId}/channels - 배포 채널 선택
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 종합 테스트 결과
|
## 결론
|
||||||
|
|
||||||
### 4.1 테스트 요약
|
**전체 평가**: ✅ **매우 양호**
|
||||||
| API | Method | Endpoint | 상태 | 비고 |
|
|
||||||
|-----|--------|----------|------|------|
|
|
||||||
| 이미지 생성 | POST | /content/images/generate | ✅ PASS | Job 생성 확인 |
|
|
||||||
| 작업 조회 | GET | /content/images/jobs/{jobId} | ✅ PASS | 상태 전환 확인 |
|
|
||||||
| 콘텐츠 조회 | GET | /content/events/{eventDraftId} | ✅ PASS | 이미지 포함 조회 |
|
|
||||||
| 이미지 목록 | GET | /content/events/{eventDraftId}/images | ✅ PASS | 4개 이미지 확인 |
|
|
||||||
| 이미지 상세 | GET | /content/images/{imageId} | ✅ PASS | 단일 이미지 조회 |
|
|
||||||
| 이미지 재생성 | POST | /content/images/{imageId}/regenerate | ✅ PASS | 재생성 Job 확인 |
|
|
||||||
| 이미지 삭제 | DELETE | /content/images/{imageId} | ✅ PASS | 204 응답 확인 |
|
|
||||||
|
|
||||||
### 4.2 전체 결과
|
Event Service는 독립적으로 실행 가능한 모든 핵심 기능이 정상 동작합니다.
|
||||||
- **총 테스트 케이스**: 7개
|
|
||||||
- **성공**: 7개
|
|
||||||
- **실패**: 0개
|
|
||||||
- **성공률**: 100%
|
|
||||||
|
|
||||||
## 5. 검증된 기능
|
**검증 완료 항목**:
|
||||||
|
- ✅ PostgreSQL 연동 및 데이터 영속성
|
||||||
|
- ✅ Redis 캐싱 기능
|
||||||
|
- ✅ Kafka Producer (메시지 발행)
|
||||||
|
- ✅ REST API CRUD 작업
|
||||||
|
- ✅ 비동기 Job 처리 패턴
|
||||||
|
- ✅ 환경 변수 설정 일관성
|
||||||
|
|
||||||
### 5.1 비즈니스 로직
|
**남은 과제**:
|
||||||
✅ 이미지 생성 요청 → Job 생성 → 비동기 처리 → 완료 확인 흐름 정상 동작
|
1. Content Service 연동 후 이미지 생성/선택 기능 테스트
|
||||||
✅ Mock 서비스를 통한 4개 조합(2 스타일 x 2 플랫폼) 이미지 자동 생성
|
2. Distribution Service 연동 후 이벤트 배포 기능 테스트
|
||||||
✅ 첫 번째 이미지 자동 선택(selected:true) 로직 정상 동작
|
3. AI Service 실제 연동 후 추천 생성 완료 테스트
|
||||||
✅ Content와 GeneratedImage 엔티티 연관 관계 정상 동작
|
4. Kafka Consumer 토픽 초기화 또는 설정 개선
|
||||||
|
|
||||||
### 5.2 기술 구현
|
**다음 단계 권장사항**:
|
||||||
✅ Clean Architecture (Hexagonal Architecture) 구조 정상 동작
|
1. Content Service 개발 및 통합 테스트
|
||||||
✅ @Profile 기반 환경별 Bean 선택 정상 동작 (Mock vs Production)
|
2. Distribution Service 개발 및 통합 테스트
|
||||||
✅ H2 In-Memory 데이터베이스 자동 스키마 생성 및 데이터 저장
|
3. 전체 서비스 통합 시나리오 테스트
|
||||||
✅ @Async 비동기 처리 정상 동작
|
4. 성능 테스트 및 부하 테스트
|
||||||
✅ Spring Data JPA 엔티티 관계 및 쿼리 정상 동작
|
5. 운영 환경 배포 준비 (Kafka 토픽 설정, 로그 레벨 조정)
|
||||||
✅ REST API 표준 HTTP 상태 코드 사용 (200, 202, 204)
|
|
||||||
|
|
||||||
### 5.3 Mock 서비스
|
|
||||||
✅ MockGenerateImagesService: 1초 지연 후 이미지 생성 시뮬레이션
|
|
||||||
✅ MockRedisGateway: Redis 캐시 기능 Mock 구현
|
|
||||||
✅ Local 프로파일에서 외부 의존성 없이 독립 실행
|
|
||||||
|
|
||||||
## 6. 확인된 이슈 및 개선사항
|
|
||||||
|
|
||||||
### 6.1 경고 메시지 (Non-Critical)
|
|
||||||
```
|
|
||||||
WARN: Index "IDX_EVENT_DRAFT_ID" already exists
|
|
||||||
```
|
|
||||||
- **원인**: generated_images와 jobs 테이블에 동일한 이름의 인덱스 사용
|
|
||||||
- **영향**: H2에서만 발생하는 경고, 기능에 영향 없음
|
|
||||||
- **개선 방안**: 각 테이블별로 고유한 인덱스 이름 사용 권장
|
|
||||||
- `idx_generated_images_event_draft_id`
|
|
||||||
- `idx_jobs_event_draft_id`
|
|
||||||
|
|
||||||
### 6.2 Redis 구현 현황
|
|
||||||
✅ **Production용 구현 완료**:
|
|
||||||
- RedisConfig.java - RedisTemplate 설정
|
|
||||||
- RedisGateway.java - Redis 읽기/쓰기 구현
|
|
||||||
|
|
||||||
✅ **Local/Test용 Mock 구현**:
|
|
||||||
- MockRedisGateway - 캐시 기능 Mock
|
|
||||||
|
|
||||||
## 7. 다음 단계
|
|
||||||
|
|
||||||
### 7.1 추가 테스트 필요 사항
|
|
||||||
- [ ] 에러 케이스 테스트
|
|
||||||
- 존재하지 않는 eventDraftId 조회
|
|
||||||
- 존재하지 않는 imageId 조회
|
|
||||||
- 잘못된 요청 파라미터 (validation 테스트)
|
|
||||||
- [ ] 동시성 테스트
|
|
||||||
- 동일 이벤트에 대한 동시 이미지 생성 요청
|
|
||||||
- [ ] 성능 테스트
|
|
||||||
- 대량 이미지 생성 시 성능 측정
|
|
||||||
|
|
||||||
### 7.2 통합 테스트
|
|
||||||
- [ ] PostgreSQL 연동 테스트 (Production 프로파일)
|
|
||||||
- [ ] Redis 실제 연동 테스트
|
|
||||||
- [ ] Kafka 메시지 발행/구독 테스트
|
|
||||||
- [ ] 타 서비스(event-service 등)와의 통합 테스트
|
|
||||||
|
|
||||||
## 8. 결론
|
|
||||||
|
|
||||||
Content Service의 모든 핵심 REST API가 정상적으로 동작하며, Local 환경에서 Mock 서비스를 통해 독립적으로 실행 및 테스트 가능함을 확인했습니다.
|
|
||||||
|
|
||||||
### 주요 성과
|
|
||||||
1. ✅ 7개 API 엔드포인트 100% 정상 동작
|
|
||||||
2. ✅ Clean Architecture 구조 정상 동작
|
|
||||||
3. ✅ Profile 기반 환경 분리 정상 동작
|
|
||||||
4. ✅ 비동기 이미지 생성 흐름 정상 동작
|
|
||||||
5. ✅ Redis Gateway Production/Mock 구현 완료
|
|
||||||
|
|
||||||
Content Service는 Local 환경에서 완전히 검증되었으며, Production 환경 배포를 위한 준비가 완료되었습니다.
|
|
||||||
|
|||||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7.2-alpine
|
||||||
|
container_name: kt-event-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- kt-event-network
|
||||||
|
|
||||||
|
zookeeper:
|
||||||
|
image: confluentinc/cp-zookeeper:7.5.0
|
||||||
|
container_name: kt-event-zookeeper
|
||||||
|
environment:
|
||||||
|
ZOOKEEPER_CLIENT_PORT: 2181
|
||||||
|
ZOOKEEPER_TICK_TIME: 2000
|
||||||
|
ports:
|
||||||
|
- "2181:2181"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- kt-event-network
|
||||||
|
|
||||||
|
kafka:
|
||||||
|
image: confluentinc/cp-kafka:7.5.0
|
||||||
|
container_name: kt-event-kafka
|
||||||
|
depends_on:
|
||||||
|
- zookeeper
|
||||||
|
ports:
|
||||||
|
- "9092:9092"
|
||||||
|
environment:
|
||||||
|
KAFKA_BROKER_ID: 1
|
||||||
|
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||||
|
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
|
||||||
|
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT
|
||||||
|
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
||||||
|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||||
|
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- kt-event-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kt-event-network:
|
||||||
|
driver: bridge
|
||||||
71
event-service/.run/event-service.run.xml
Normal file
71
event-service/.run/event-service.run.xml
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="event-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<!-- Server Configuration -->
|
||||||
|
<entry key="SERVER_PORT" value="8080" />
|
||||||
|
|
||||||
|
<!-- Database Configuration -->
|
||||||
|
<entry key="DB_HOST" value="20.249.177.232" />
|
||||||
|
<entry key="DB_PORT" value="5432" />
|
||||||
|
<entry key="DB_NAME" value="eventdb" />
|
||||||
|
<entry key="DB_USERNAME" value="eventuser" />
|
||||||
|
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||||
|
|
||||||
|
<!-- JPA Configuration -->
|
||||||
|
<entry key="DDL_AUTO" value="update" />
|
||||||
|
|
||||||
|
<!-- Redis Configuration -->
|
||||||
|
<entry key="REDIS_HOST" value="20.214.210.71" />
|
||||||
|
<entry key="REDIS_PORT" value="6379" />
|
||||||
|
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||||
|
|
||||||
|
<!-- Kafka Configuration -->
|
||||||
|
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||||
|
|
||||||
|
<!-- Service URLs -->
|
||||||
|
<entry key="CONTENT_SERVICE_URL" value="http://localhost:8082" />
|
||||||
|
<entry key="DISTRIBUTION_SERVICE_URL" value="http://localhost:8084" />
|
||||||
|
|
||||||
|
<!-- JWT Configuration -->
|
||||||
|
<entry key="JWT_SECRET" value="kt-event-marketing-secret-key-for-development-only-please-change-in-production" />
|
||||||
|
|
||||||
|
<!-- Logging Configuration -->
|
||||||
|
<entry key="LOG_LEVEL" value="DEBUG" />
|
||||||
|
<entry key="SQL_LOG_LEVEL" value="DEBUG" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="event-service:bootRun" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" value="-Xms512m -Xmx2048m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Dspring.jmx.enabled=false -Dspring.devtools.restart.enabled=false" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="net.ashald.envfile">
|
||||||
|
<option name="IS_ENABLED" value="false" />
|
||||||
|
<option name="IS_SUBST" value="false" />
|
||||||
|
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
|
||||||
|
<option name="IS_IGNORE_MISSING_FILES" value="false" />
|
||||||
|
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
|
||||||
|
<ENTRIES>
|
||||||
|
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
|
||||||
|
</ENTRIES>
|
||||||
|
</extension>
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@ -1,4 +1,11 @@
|
|||||||
|
bootJar {
|
||||||
|
archiveFileName = 'event-service.jar'
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Actuator for health checks and monitoring
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
|
||||||
// Kafka for job publishing
|
// Kafka for job publishing
|
||||||
implementation 'org.springframework.kafka:spring-kafka'
|
implementation 'org.springframework.kafka:spring-kafka'
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,11 @@ import org.springframework.kafka.annotation.EnableKafka;
|
|||||||
"com.kt.event.eventservice",
|
"com.kt.event.eventservice",
|
||||||
"com.kt.event.common"
|
"com.kt.event.common"
|
||||||
},
|
},
|
||||||
exclude = {UserDetailsServiceAutoConfiguration.class}
|
exclude = {
|
||||||
|
UserDetailsServiceAutoConfiguration.class,
|
||||||
|
org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration.class,
|
||||||
|
org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration.class
|
||||||
|
}
|
||||||
)
|
)
|
||||||
@EnableJpaAuditing
|
@EnableJpaAuditing
|
||||||
@EnableKafka
|
@EnableKafka
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 생성 완료 메시지 DTO
|
* 이벤트 생성 완료 메시지 DTO
|
||||||
@ -20,16 +21,16 @@ import java.time.LocalDateTime;
|
|||||||
public class EventCreatedMessage {
|
public class EventCreatedMessage {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 ID
|
* 이벤트 ID (UUID)
|
||||||
*/
|
*/
|
||||||
@JsonProperty("event_id")
|
@JsonProperty("event_id")
|
||||||
private Long eventId;
|
private UUID eventId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 ID
|
* 사용자 ID (UUID)
|
||||||
*/
|
*/
|
||||||
@JsonProperty("user_id")
|
@JsonProperty("user_id")
|
||||||
private Long userId;
|
private UUID userId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 제목
|
* 이벤트 제목
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
package com.kt.event.eventservice.application.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 요청 DTO
|
||||||
|
*
|
||||||
|
* AI 서비스에 이벤트 추천 생성을 요청합니다.
|
||||||
|
*
|
||||||
|
* @author Event Service Team
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-10-27
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "AI 추천 요청")
|
||||||
|
public class AiRecommendationRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "매장 정보는 필수입니다.")
|
||||||
|
@Valid
|
||||||
|
@Schema(description = "매장 정보", required = true)
|
||||||
|
private StoreInfo storeInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 정보
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "매장 정보")
|
||||||
|
public static class StoreInfo {
|
||||||
|
|
||||||
|
@NotNull(message = "매장 ID는 필수입니다.")
|
||||||
|
@Schema(description = "매장 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440002")
|
||||||
|
private UUID storeId;
|
||||||
|
|
||||||
|
@NotNull(message = "매장명은 필수입니다.")
|
||||||
|
@Schema(description = "매장명", required = true, example = "우진네 고깃집")
|
||||||
|
private String storeName;
|
||||||
|
|
||||||
|
@NotNull(message = "업종은 필수입니다.")
|
||||||
|
@Schema(description = "업종", required = true, example = "음식점")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Schema(description = "매장 설명", example = "신선한 한우를 제공하는 고깃집")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user