Merge origin/develop into feature/distribution
- develop 브랜치의 최신 변경사항 병합 - .gradle 캐시 파일 충돌 해결 (삭제) - ParticipationServiceApplication.run.xml 충돌 해결 (develop 버전 선택) - make-run-profile.md 충돌 해결 (develop 버전 선택) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
63ba449f93
17
.claude/commands/deploy-actions-cicd-guide-back.md
Normal file
17
.claude/commands/deploy-actions-cicd-guide-back.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-actions-cicd-guide-back"
|
||||||
|
description: "백엔드 GitHub Actions CI/CD 파이프라인 가이드 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'백엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||||
|
|
||||||
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
|
{안내메시지}
|
||||||
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
|
[실행정보]
|
||||||
|
- ACR_NAME: acrdigitalgarage01
|
||||||
|
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||||
|
- AKS_CLUSTER: aks-digitalgarage-01
|
||||||
|
- NAMESPACE: phonebill-dg0500
|
||||||
18
.claude/commands/deploy-actions-cicd-guide-front.md
Normal file
18
.claude/commands/deploy-actions-cicd-guide-front.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-actions-cicd-guide-front"
|
||||||
|
description: "프론트엔드 GitHub Actions CI/CD 파이프라인 가이드 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'프론트엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||||
|
|
||||||
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
|
{안내메시지}
|
||||||
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
|
[실행정보]
|
||||||
|
- SYSTEM_NAME: phonebill
|
||||||
|
- ACR_NAME: acrdigitalgarage01
|
||||||
|
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||||
|
- AKS_CLUSTER: aks-digitalgarage-01
|
||||||
|
- NAMESPACE: phonebill-dg0500
|
||||||
7
.claude/commands/deploy-build-image-back.md
Normal file
7
.claude/commands/deploy-build-image-back.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-build-image-back"
|
||||||
|
description: "백엔드 컨테이너 이미지 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'백엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
|
||||||
7
.claude/commands/deploy-build-image-front.md
Normal file
7
.claude/commands/deploy-build-image-front.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-build-image-front"
|
||||||
|
description: "프론트엔드 컨테이너 이미지 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'프론트엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
|
||||||
64
.claude/commands/deploy-help.md
Normal file
64
.claude/commands/deploy-help.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-help"
|
||||||
|
description: "배포 작업 순서 및 명령어 안내"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 배포 작업 순서
|
||||||
|
|
||||||
|
## 컨테이너 이미지 작성
|
||||||
|
### 백엔드
|
||||||
|
/deploy-build-image-back
|
||||||
|
- 백엔드 서비스들의 컨테이너 이미지를 작성합니다
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
/deploy-build-image-front
|
||||||
|
- 프론트엔드 서비스의 컨테이너 이미지를 작성합니다
|
||||||
|
|
||||||
|
## 컨테이너 실행 가이드 작성
|
||||||
|
### 백엔드
|
||||||
|
/deploy-run-container-guide-back
|
||||||
|
- 백엔드 컨테이너 실행 가이드를 작성합니다
|
||||||
|
- [실행정보] 섹션에 ACR명, VM 접속 정보 제공 필요
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
/deploy-run-container-guide-front
|
||||||
|
- 프론트엔드 컨테이너 실행 가이드를 작성합니다
|
||||||
|
- [실행정보] 섹션에 시스템명, ACR명, VM 접속 정보 제공 필요
|
||||||
|
|
||||||
|
## Kubernetes 배포 가이드 작성
|
||||||
|
### 백엔드
|
||||||
|
/deploy-k8s-guide-back
|
||||||
|
- 백엔드 서비스 Kubernetes 배포 가이드를 작성합니다
|
||||||
|
- [실행정보] 섹션에 ACR명, k8s명, 네임스페이스, 리소스 정보 제공 필요
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
/deploy-k8s-guide-front
|
||||||
|
- 프론트엔드 서비스 Kubernetes 배포 가이드를 작성합니다
|
||||||
|
- [실행정보] 섹션에 시스템명, ACR명, k8s명, 네임스페이스, Gateway Host 정보 제공 필요
|
||||||
|
|
||||||
|
## CI/CD 파이프라인 작성
|
||||||
|
### Jenkins CI/CD
|
||||||
|
#### 백엔드
|
||||||
|
/deploy-jenkins-cicd-guide-back
|
||||||
|
- Jenkins를 이용한 백엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||||
|
- [실행정보] 섹션에 ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||||
|
|
||||||
|
#### 프론트엔드
|
||||||
|
/deploy-jenkins-cicd-guide-front
|
||||||
|
- Jenkins를 이용한 프론트엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||||
|
- [실행정보] 섹션에 SYSTEM_NAME, ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||||
|
|
||||||
|
### GitHub Actions CI/CD
|
||||||
|
#### 백엔드
|
||||||
|
/deploy-actions-cicd-guide-back
|
||||||
|
- GitHub Actions를 이용한 백엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||||
|
- [실행정보] 섹션에 ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||||
|
|
||||||
|
#### 프론트엔드
|
||||||
|
/deploy-actions-cicd-guide-front
|
||||||
|
- GitHub Actions를 이용한 프론트엔드 CI/CD 파이프라인 가이드를 작성합니다
|
||||||
|
- [실행정보] 섹션에 SYSTEM_NAME, ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**참고**: 각 명령어 실행 시 [실행정보] 섹션에 필요한 정보를 함께 제공해야 합니다.
|
||||||
17
.claude/commands/deploy-jenkins-cicd-guide-back.md
Normal file
17
.claude/commands/deploy-jenkins-cicd-guide-back.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-jenkins-cicd-guide-back"
|
||||||
|
description: "백엔드 Jenkins CI/CD 파이프라인 가이드 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'백엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||||
|
|
||||||
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
|
{안내메시지}
|
||||||
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
|
[실행정보]
|
||||||
|
- ACR_NAME: acrdigitalgarage01
|
||||||
|
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||||
|
- AKS_CLUSTER: aks-digitalgarage-01
|
||||||
|
- NAMESPACE: phonebill-dg0500
|
||||||
18
.claude/commands/deploy-jenkins-cicd-guide-front.md
Normal file
18
.claude/commands/deploy-jenkins-cicd-guide-front.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-jenkins-cicd-guide-front"
|
||||||
|
description: "프론트엔드 Jenkins CI/CD 파이프라인 가이드 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'프론트엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||||
|
|
||||||
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
|
{안내메시지}
|
||||||
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
|
[실행정보]
|
||||||
|
- SYSTEM_NAME: phonebill
|
||||||
|
- ACR_NAME: acrdigitalgarage01
|
||||||
|
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||||
|
- AKS_CLUSTER: aks-digitalgarage-01
|
||||||
|
- NAMESPACE: phonebill-dg0500
|
||||||
19
.claude/commands/deploy-k8s-guide-back.md
Normal file
19
.claude/commands/deploy-k8s-guide-back.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-k8s-guide-back"
|
||||||
|
description: "백엔드 Kubernetes 배포 가이드 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'백엔드배포가이드'에 따라 백엔드 서비스 배포 방법을 작성해 주세요.
|
||||||
|
|
||||||
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
|
{안내메시지}
|
||||||
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
|
[실행정보]
|
||||||
|
- ACR명: acrdigitalgarage01
|
||||||
|
- k8s명: aks-digitalgarage-01
|
||||||
|
- 네임스페이스: tripgen
|
||||||
|
- 파드수: 2
|
||||||
|
- 리소스(CPU): 256m/1024m
|
||||||
|
- 리소스(메모리): 256Mi/1024Mi
|
||||||
21
.claude/commands/deploy-k8s-guide-front.md
Normal file
21
.claude/commands/deploy-k8s-guide-front.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-k8s-guide-front"
|
||||||
|
description: "프론트엔드 Kubernetes 배포 가이드 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'프론트엔드배포가이드'에 따라 프론트엔드 서비스 배포 방법을 작성해 주세요.
|
||||||
|
|
||||||
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
|
{안내메시지}
|
||||||
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
|
[실행정보]
|
||||||
|
- 시스템명: tripgen
|
||||||
|
- ACR명: acrdigitalgarage01
|
||||||
|
- k8s명: aks-digitalgarage-01
|
||||||
|
- 네임스페이스: tripgen
|
||||||
|
- 파드수: 2
|
||||||
|
- 리소스(CPU): 256m/1024m
|
||||||
|
- 리소스(메모리): 256Mi/1024Mi
|
||||||
|
- Gateway Host: http://tripgen-api.20.214.196.128.nip.io
|
||||||
18
.claude/commands/deploy-run-container-guide-back.md
Normal file
18
.claude/commands/deploy-run-container-guide-back.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-run-container-guide-back"
|
||||||
|
description: "백엔드 컨테이너 실행방법 가이드 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'백엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
||||||
|
|
||||||
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
|
{안내메시지}
|
||||||
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
|
[실행정보]
|
||||||
|
- ACR명: acrdigitalgarage01
|
||||||
|
- VM
|
||||||
|
- KEY파일: ~/home/bastion-dg0500
|
||||||
|
- USERID: azureuser
|
||||||
|
- IP: 4.230.5.6
|
||||||
19
.claude/commands/deploy-run-container-guide-front.md
Normal file
19
.claude/commands/deploy-run-container-guide-front.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
command: "/deploy-run-container-guide-front"
|
||||||
|
description: "프론트엔드 컨테이너 실행방법 가이드 작성"
|
||||||
|
---
|
||||||
|
|
||||||
|
@cicd
|
||||||
|
'프론트엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
||||||
|
|
||||||
|
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
|
|
||||||
|
{안내메시지}
|
||||||
|
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||||
|
[실행정보]
|
||||||
|
- 시스템명: tripgen
|
||||||
|
- ACR명: acrdigitalgarage01
|
||||||
|
- VM
|
||||||
|
- KEY파일: ~/home/bastion-dg0500
|
||||||
|
- USERID: azureuser
|
||||||
|
- IP: 4.230.5.6
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-api"
|
||||||
|
---
|
||||||
@architecture
|
@architecture
|
||||||
API를 설계해 주세요:
|
API를 설계해 주세요:
|
||||||
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
|
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-class"
|
||||||
|
---
|
||||||
@architecture
|
@architecture
|
||||||
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
|
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
|
||||||
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-data"
|
||||||
|
---
|
||||||
@architecture
|
@architecture
|
||||||
데이터 설계를 해주세요:
|
데이터 설계를 해주세요:
|
||||||
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
|
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-fix-prototype"
|
||||||
|
---
|
||||||
@fix as @front
|
@fix as @front
|
||||||
'[오류내용]'섹션에 제공된 오류를 해결해 주세요.
|
'[오류내용]'섹션에 제공된 오류를 해결해 주세요.
|
||||||
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
|
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-front"
|
||||||
|
---
|
||||||
@plan as @front
|
@plan as @front
|
||||||
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
|
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
|
||||||
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-high-level"
|
||||||
|
---
|
||||||
@architecture
|
@architecture
|
||||||
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
|
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
|
||||||
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-improve-prototype"
|
||||||
|
---
|
||||||
@improve as @front
|
@improve as @front
|
||||||
'[개선내용]'섹션에 있는 내용을 개선해 주세요.
|
'[개선내용]'섹션에 있는 내용을 개선해 주세요.
|
||||||
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
|
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
|
||||||
|
|||||||
@ -1,2 +1,5 @@
|
|||||||
|
---
|
||||||
|
command: "/design-improve-userstory"
|
||||||
|
---
|
||||||
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
|
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
|
||||||
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
|
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-logical"
|
||||||
|
---
|
||||||
@architecture
|
@architecture
|
||||||
논리 아키텍처를 설계해 주세요:
|
논리 아키텍처를 설계해 주세요:
|
||||||
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
|
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-pattern"
|
||||||
|
---
|
||||||
@design-pattern
|
@design-pattern
|
||||||
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
|
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
|
||||||
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
|
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-physical"
|
||||||
|
---
|
||||||
@architecture
|
@architecture
|
||||||
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
|
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
|
||||||
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-prototype"
|
||||||
|
---
|
||||||
@prototype
|
@prototype
|
||||||
프로토타입을 작성해 주세요:
|
프로토타입을 작성해 주세요:
|
||||||
- '프로토타입작성가이드'를 준용하여 작성
|
- '프로토타입작성가이드'를 준용하여 작성
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-seq-inner"
|
||||||
|
---
|
||||||
@architecture
|
@architecture
|
||||||
내부 시퀀스 설계를 해 주세요:
|
내부 시퀀스 설계를 해 주세요:
|
||||||
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
|
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-seq-outer"
|
||||||
|
---
|
||||||
@architecture
|
@architecture
|
||||||
외부 시퀀스 설계를 해 주세요:
|
외부 시퀀스 설계를 해 주세요:
|
||||||
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
|
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
|
||||||
@ -1,2 +1,5 @@
|
|||||||
|
---
|
||||||
|
command: "/design-test-prototype"
|
||||||
|
---
|
||||||
@test-front
|
@test-front
|
||||||
프로토타입을 테스트 해 주세요.
|
프로토타입을 테스트 해 주세요.
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/design-uiux"
|
||||||
|
---
|
||||||
@uiux
|
@uiux
|
||||||
UI/UX 설계를 해주세요:
|
UI/UX 설계를 해주세요:
|
||||||
- 'UI/UX설계가이드'를 준용하여 작성
|
- 'UI/UX설계가이드'를 준용하여 작성
|
||||||
@ -1,2 +1,5 @@
|
|||||||
|
---
|
||||||
|
command: "/design-update-uiux"
|
||||||
|
---
|
||||||
@document @front
|
@document @front
|
||||||
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
|
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
|
||||||
@ -1,5 +1,5 @@
|
|||||||
@test-backend
|
@test-backend
|
||||||
'서비스실행파일작성가이드'에 따라 테스트를 해 주세요.
|
'서비스실행프로파일작성가이드'에 따라 테스트를 해 주세요.
|
||||||
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
|
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/think-help"
|
||||||
|
---
|
||||||
기획 작업 순서
|
기획 작업 순서
|
||||||
|
|
||||||
1단계: 서비스 기획
|
1단계: 서비스 기획
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
---
|
||||||
|
command: "/think-planning"
|
||||||
|
---
|
||||||
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
|
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
|
||||||
```
|
```
|
||||||
아래 가이드를 참고하여 서비스 기획을 수행합니다.
|
아래 가이드를 참고하여 서비스 기획을 수행합니다.
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
command: "/think-userstory"
|
||||||
|
---
|
||||||
|
```
|
||||||
@document
|
@document
|
||||||
유저스토리를 작성하세요.
|
유저스토리를 작성하세요.
|
||||||
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||||
@ -16,3 +20,5 @@ Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을
|
|||||||
2. 유저스토리 작성
|
2. 유저스토리 작성
|
||||||
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
|
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
|
||||||
- 결과파일은 'design/userstory.md'에 생성
|
- 결과파일은 'design/userstory.md'에 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
|||||||
@ -15,7 +15,40 @@
|
|||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git push)",
|
"Bash(git push)",
|
||||||
"Bash(git pull:*)"
|
"Bash(git pull:*)",
|
||||||
|
"Bash(netstat:*)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"Bash(./gradlew analytics-service:compileJava:*)",
|
||||||
|
"Bash(python -m json.tool:*)",
|
||||||
|
"Bash(powershell:*)"
|
||||||
|
"Bash(./gradlew participation-service:compileJava:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(netstat:*)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"Bash(docker-compose up:*)",
|
||||||
|
"Bash(docker --version:*)",
|
||||||
|
"Bash(timeout 60 bash:*)",
|
||||||
|
"Bash(docker ps:*)",
|
||||||
|
"Bash(docker exec:*)",
|
||||||
|
"Bash(docker-compose down:*)",
|
||||||
|
"Bash(git rm:*)",
|
||||||
|
"Bash(git restore:*)",
|
||||||
|
"Bash(./gradlew participation-service:test:*)",
|
||||||
|
"Bash(timeout 30 bash:*)",
|
||||||
|
"Bash(helm list:*)",
|
||||||
|
"Bash(helm upgrade:*)",
|
||||||
|
"Bash(helm repo add:*)",
|
||||||
|
"Bash(helm repo update:*)",
|
||||||
|
"Bash(kubectl get:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(timeout 120 bash -c 'while true; do sleep 5; kubectl get pods -n kt-event-marketing | grep kafka | grep -v Running && continue; echo \"\"\"\"All Kafka pods are Running!\"\"\"\"; break; done')",
|
||||||
|
"Bash(kubectl delete:*)",
|
||||||
|
"Bash(kubectl logs:*)",
|
||||||
|
"Bash(kubectl describe:*)",
|
||||||
|
"Bash(kubectl exec:*)",
|
||||||
|
"mcp__context7__resolve-library-id",
|
||||||
|
"mcp__context7__get-library-docs",
|
||||||
|
"Bash(python -m json.tool:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
33
.gitignore
vendored
33
.gitignore
vendored
@ -8,6 +8,7 @@ yarn-error.log*
|
|||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.run/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
@ -20,6 +21,23 @@ Thumbs.db
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.log
|
*.log
|
||||||
|
.gradle/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
gradle-app.setting
|
||||||
|
!gradle-wrapper.jar
|
||||||
|
!gradle-wrapper.properties
|
||||||
|
.gradletasknamecache
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
@ -30,3 +48,18 @@ build/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# Kubernetes Secrets (민감한 정보 포함)
|
||||||
|
k8s/**/secret.yaml
|
||||||
|
k8s/**/*-secret.yaml
|
||||||
|
k8s/**/*-prod.yaml
|
||||||
|
k8s/**/*-dev.yaml
|
||||||
|
k8s/**/*-local.yaml
|
||||||
|
|
||||||
|
# IntelliJ 실행 프로파일 (민감한 환경 변수 포함 가능)
|
||||||
|
.run/*.run.xml
|
||||||
|
|
||||||
|
# Gradle (로컬 환경 설정)
|
||||||
|
gradle.properties
|
||||||
|
*.hprof
|
||||||
|
test-data.json
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,2 +0,0 @@
|
|||||||
#Thu Oct 23 17:51:21 KST 2025
|
|
||||||
gradle.version=8.10
|
|
||||||
Binary file not shown.
Binary file not shown.
@ -1,29 +1,69 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="ParticipationServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
|
<configuration default="false" name="ParticipationServiceApplication" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
<option name="ACTIVE_PROFILES" />
|
<ExternalSystemSettings>
|
||||||
<module name="kt-event-marketing.participation-service.main" />
|
<option name="env">
|
||||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.participation.ParticipationApplication" />
|
<map>
|
||||||
<extension name="coverage">
|
<!-- 서버 설정 -->
|
||||||
<pattern>
|
<entry key="SERVER_PORT" value="8084" />
|
||||||
<option name="PATTERN" value="com.kt.participation.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
<!-- 데이터베이스 설정 -->
|
||||||
</pattern>
|
<entry key="DB_HOST" value="4.230.72.147" />
|
||||||
</extension>
|
<entry key="DB_PORT" value="5432" />
|
||||||
<envs>
|
<entry key="DB_NAME" value="participationdb" />
|
||||||
<env name="SERVER_PORT" value="8086" />
|
<entry key="DB_USERNAME" value="eventuser" />
|
||||||
<env name="DB_HOST" value="4.230.72.147" />
|
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||||
<env name="DB_PORT" value="5432" />
|
|
||||||
<env name="DB_NAME" value="participationdb" />
|
<!-- JPA 설정 -->
|
||||||
<env name="DB_USERNAME" value="eventuser" />
|
<entry key="DDL_AUTO" value="none" />
|
||||||
<env name="DB_PASSWORD" value="Hi5Jessica!" />
|
<entry key="SHOW_SQL" value="true" />
|
||||||
<env name="REDIS_HOST" value="20.214.210.71" />
|
|
||||||
<env name="REDIS_PORT" value="6379" />
|
<!-- Redis 설정 -->
|
||||||
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
|
<entry key="REDIS_HOST" value="20.214.210.71" />
|
||||||
<env name="JPA_DDL_AUTO" value="update" />
|
<entry key="REDIS_PORT" value="6379" />
|
||||||
<env name="JPA_SHOW_SQL" value="false" />
|
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||||
</envs>
|
|
||||||
<method v="2">
|
<!-- Kafka 설정 -->
|
||||||
<option name="Make" enabled="true" />
|
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||||
</method>
|
|
||||||
|
<!-- JWT 설정 -->
|
||||||
|
<entry key="JWT_SECRET" value="kt-event-marketing-secret-key-for-development-only-change-in-production" />
|
||||||
|
<entry key="JWT_EXPIRATION" value="86400000" />
|
||||||
|
|
||||||
|
<!-- 로깅 설정 -->
|
||||||
|
<entry key="LOG_LEVEL" value="INFO" />
|
||||||
|
<entry key="LOG_FILE" value="logs/participation-service.log" />
|
||||||
|
</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=":participation-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>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
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>
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"liveServer.settings.port": 5501
|
|
||||||
}
|
|
||||||
@ -2,8 +2,8 @@ dependencies {
|
|||||||
// Kafka Consumer
|
// Kafka Consumer
|
||||||
implementation 'org.springframework.kafka:spring-kafka'
|
implementation 'org.springframework.kafka:spring-kafka'
|
||||||
|
|
||||||
// Redis for result caching
|
// Redis for result caching (already in root build.gradle)
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
// implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
|
||||||
// OpenFeign for Claude/GPT API
|
// OpenFeign for Claude/GPT API
|
||||||
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
|
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
|
||||||
@ -14,4 +14,20 @@ dependencies {
|
|||||||
|
|
||||||
// Jackson for JSON
|
// Jackson for JSON
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||||
|
|
||||||
|
// JWT (for security)
|
||||||
|
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
||||||
|
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
||||||
|
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
|
||||||
|
|
||||||
|
// Note: PostgreSQL dependency is in root build.gradle but AI Service doesn't use DB
|
||||||
|
// We still include it for consistency, but no JPA entities will be created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kafka Manual Test 실행 태스크
|
||||||
|
task runKafkaManualTest(type: JavaExec) {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'Run Kafka manual test'
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = 'com.kt.ai.test.manual.KafkaManualTest'
|
||||||
}
|
}
|
||||||
|
|||||||
24
ai-service/src/main/java/com/kt/ai/AiServiceApplication.java
Normal file
24
ai-service/src/main/java/com/kt/ai/AiServiceApplication.java
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package com.kt.ai;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||||
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Service Application
|
||||||
|
* - Kafka를 통한 비동기 AI 추천 처리
|
||||||
|
* - Claude API / GPT-4 API 연동
|
||||||
|
* - Redis 기반 결과 캐싱
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@EnableFeignClients
|
||||||
|
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
|
||||||
|
public class AiServiceApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(AiServiceApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package com.kt.ai.circuitbreaker;
|
||||||
|
|
||||||
|
import com.kt.ai.exception.CircuitBreakerOpenException;
|
||||||
|
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||||
|
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker Manager
|
||||||
|
* - Claude API / GPT-4 API 호출 시 Circuit Breaker 적용
|
||||||
|
* - Fallback 처리
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CircuitBreakerManager {
|
||||||
|
|
||||||
|
private final CircuitBreakerRegistry circuitBreakerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker를 통한 API 호출
|
||||||
|
*
|
||||||
|
* @param circuitBreakerName Circuit Breaker 이름 (claudeApi, gpt4Api)
|
||||||
|
* @param supplier API 호출 로직
|
||||||
|
* @param fallback Fallback 로직
|
||||||
|
* @return API 호출 결과 또는 Fallback 결과
|
||||||
|
*/
|
||||||
|
public <T> T executeWithCircuitBreaker(
|
||||||
|
String circuitBreakerName,
|
||||||
|
Supplier<T> supplier,
|
||||||
|
Supplier<T> fallback
|
||||||
|
) {
|
||||||
|
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Circuit Breaker 상태 확인
|
||||||
|
if (circuitBreaker.getState() == CircuitBreaker.State.OPEN) {
|
||||||
|
log.warn("Circuit Breaker is OPEN: {}", circuitBreakerName);
|
||||||
|
throw new CircuitBreakerOpenException(circuitBreakerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circuit Breaker를 통한 API 호출
|
||||||
|
return circuitBreaker.executeSupplier(() -> {
|
||||||
|
log.debug("Executing with Circuit Breaker: {}", circuitBreakerName);
|
||||||
|
return supplier.get();
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (CircuitBreakerOpenException e) {
|
||||||
|
// Circuit Breaker가 열린 경우 Fallback 실행
|
||||||
|
log.warn("Circuit Breaker OPEN, executing fallback: {}", circuitBreakerName);
|
||||||
|
if (fallback != null) {
|
||||||
|
return fallback.get();
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 기타 예외 발생 시 Fallback 실행
|
||||||
|
log.error("API call failed, executing fallback: {}", circuitBreakerName, e);
|
||||||
|
if (fallback != null) {
|
||||||
|
return fallback.get();
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker를 통한 API 호출 (Fallback 없음)
|
||||||
|
*/
|
||||||
|
public <T> T executeWithCircuitBreaker(String circuitBreakerName, Supplier<T> supplier) {
|
||||||
|
return executeWithCircuitBreaker(circuitBreakerName, supplier, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker 상태 조회
|
||||||
|
*/
|
||||||
|
public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) {
|
||||||
|
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
|
||||||
|
return circuitBreaker.getState();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
package com.kt.ai.circuitbreaker.fallback;
|
||||||
|
|
||||||
|
import com.kt.ai.model.dto.response.EventRecommendation;
|
||||||
|
import com.kt.ai.model.dto.response.ExpectedMetrics;
|
||||||
|
import com.kt.ai.model.dto.response.TrendAnalysis;
|
||||||
|
import com.kt.ai.model.enums.EventMechanicsType;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Service Fallback 처리
|
||||||
|
* - Circuit Breaker가 열린 경우 기본 데이터 반환
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class AIServiceFallback {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 트렌드 분석 결과 반환
|
||||||
|
*/
|
||||||
|
public TrendAnalysis getDefaultTrendAnalysis(String industry, String region) {
|
||||||
|
log.info("Fallback: 기본 트렌드 분석 결과 반환 - industry={}, region={}", industry, region);
|
||||||
|
|
||||||
|
List<TrendAnalysis.TrendKeyword> industryTrends = List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("고객 만족도 향상")
|
||||||
|
.relevance(0.8)
|
||||||
|
.description(industry + " 업종에서 고객 만족도가 중요한 트렌드입니다")
|
||||||
|
.build(),
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("디지털 마케팅")
|
||||||
|
.relevance(0.75)
|
||||||
|
.description("SNS 및 온라인 마케팅이 효과적입니다")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
|
List<TrendAnalysis.TrendKeyword> regionalTrends = List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("지역 커뮤니티")
|
||||||
|
.relevance(0.7)
|
||||||
|
.description(region + " 지역 커뮤니티 참여가 효과적입니다")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
|
List<TrendAnalysis.TrendKeyword> seasonalTrends = List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("시즌 이벤트")
|
||||||
|
.relevance(0.85)
|
||||||
|
.description("계절 특성을 반영한 이벤트가 효과적입니다")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
|
return TrendAnalysis.builder()
|
||||||
|
.industryTrends(industryTrends)
|
||||||
|
.regionalTrends(regionalTrends)
|
||||||
|
.seasonalTrends(seasonalTrends)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 이벤트 추천안 반환
|
||||||
|
*/
|
||||||
|
public List<EventRecommendation> getDefaultRecommendations(String objective, String industry) {
|
||||||
|
log.info("Fallback: 기본 이벤트 추천안 반환 - objective={}, industry={}", objective, industry);
|
||||||
|
|
||||||
|
List<EventRecommendation> recommendations = new ArrayList<>();
|
||||||
|
|
||||||
|
// 옵션 1: 저비용 이벤트
|
||||||
|
recommendations.add(createDefaultRecommendation(1, "저비용 SNS 이벤트", objective, industry, 100000, 200000));
|
||||||
|
|
||||||
|
// 옵션 2: 중비용 이벤트
|
||||||
|
recommendations.add(createDefaultRecommendation(2, "중비용 방문 유도 이벤트", objective, industry, 300000, 500000));
|
||||||
|
|
||||||
|
// 옵션 3: 고비용 이벤트
|
||||||
|
recommendations.add(createDefaultRecommendation(3, "고비용 프리미엄 이벤트", objective, industry, 500000, 1000000));
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 추천안 생성
|
||||||
|
*/
|
||||||
|
private EventRecommendation createDefaultRecommendation(
|
||||||
|
int optionNumber,
|
||||||
|
String concept,
|
||||||
|
String objective,
|
||||||
|
String industry,
|
||||||
|
int minCost,
|
||||||
|
int maxCost
|
||||||
|
) {
|
||||||
|
return EventRecommendation.builder()
|
||||||
|
.optionNumber(optionNumber)
|
||||||
|
.concept(concept)
|
||||||
|
.title(objective + " - " + concept)
|
||||||
|
.description("AI 서비스가 일시적으로 사용 불가능하여 기본 추천안을 제공합니다. " +
|
||||||
|
industry + " 업종에 적합한 " + concept + "입니다.")
|
||||||
|
.targetAudience("일반 고객")
|
||||||
|
.duration(EventRecommendation.Duration.builder()
|
||||||
|
.recommendedDays(14)
|
||||||
|
.recommendedPeriod("2주")
|
||||||
|
.build())
|
||||||
|
.mechanics(EventRecommendation.Mechanics.builder()
|
||||||
|
.type(EventMechanicsType.DISCOUNT)
|
||||||
|
.details("할인 쿠폰 제공 또는 경품 추첨")
|
||||||
|
.build())
|
||||||
|
.promotionChannels(List.of("Instagram", "네이버 블로그", "카카오톡 채널"))
|
||||||
|
.estimatedCost(EventRecommendation.EstimatedCost.builder()
|
||||||
|
.min(minCost)
|
||||||
|
.max(maxCost)
|
||||||
|
.breakdown(Map.of(
|
||||||
|
"경품비", minCost / 2,
|
||||||
|
"홍보비", minCost / 2
|
||||||
|
))
|
||||||
|
.build())
|
||||||
|
.expectedMetrics(ExpectedMetrics.builder()
|
||||||
|
.newCustomers(ExpectedMetrics.Range.builder().min(30.0).max(50.0).build())
|
||||||
|
.revenueIncrease(ExpectedMetrics.Range.builder().min(10.0).max(20.0).build())
|
||||||
|
.roi(ExpectedMetrics.Range.builder().min(100.0).max(150.0).build())
|
||||||
|
.build())
|
||||||
|
.differentiator("AI 분석이 제한적으로 제공되는 기본 추천안입니다")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.kt.ai.client;
|
||||||
|
|
||||||
|
import com.kt.ai.client.config.FeignClientConfig;
|
||||||
|
import com.kt.ai.client.dto.ClaudeRequest;
|
||||||
|
import com.kt.ai.client.dto.ClaudeResponse;
|
||||||
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude API Feign Client
|
||||||
|
* API Docs: https://docs.anthropic.com/claude/reference/messages_post
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@FeignClient(
|
||||||
|
name = "claudeApiClient",
|
||||||
|
url = "${ai.claude.api-url}",
|
||||||
|
configuration = FeignClientConfig.class
|
||||||
|
)
|
||||||
|
public interface ClaudeApiClient {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude Messages API 호출
|
||||||
|
*
|
||||||
|
* @param apiKey Claude API Key
|
||||||
|
* @param anthropicVersion API Version (2023-06-01)
|
||||||
|
* @param request Claude 요청
|
||||||
|
* @return Claude 응답
|
||||||
|
*/
|
||||||
|
@PostMapping(consumes = "application/json", produces = "application/json")
|
||||||
|
ClaudeResponse sendMessage(
|
||||||
|
@RequestHeader("x-api-key") String apiKey,
|
||||||
|
@RequestHeader("anthropic-version") String anthropicVersion,
|
||||||
|
@RequestBody ClaudeRequest request
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.kt.ai.client.config;
|
||||||
|
|
||||||
|
import feign.Logger;
|
||||||
|
import feign.Request;
|
||||||
|
import feign.Retryer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feign Client 설정
|
||||||
|
* - Claude API / GPT-4 API 연동 설정
|
||||||
|
* - Timeout, Retry 설정
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class FeignClientConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feign Logger Level 설정
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public Logger.Level feignLoggerLevel() {
|
||||||
|
return Logger.Level.FULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feign Request Options (Timeout 설정)
|
||||||
|
* - Connect Timeout: 10초
|
||||||
|
* - Read Timeout: 5분 (300초)
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public Request.Options requestOptions() {
|
||||||
|
return new Request.Options(
|
||||||
|
10, TimeUnit.SECONDS, // connectTimeout
|
||||||
|
300, TimeUnit.SECONDS, // readTimeout (5분)
|
||||||
|
true // followRedirects
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feign Retryer 설정
|
||||||
|
* - 최대 3회 재시도
|
||||||
|
* - Exponential Backoff: 1초, 5초, 10초
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public Retryer retryer() {
|
||||||
|
return new Retryer.Default(
|
||||||
|
1000L, // period (1초)
|
||||||
|
5000L, // maxPeriod (5초)
|
||||||
|
3 // maxAttempts (3회)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.kt.ai.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude API 요청 DTO
|
||||||
|
* API Docs: https://docs.anthropic.com/claude/reference/messages_post
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ClaudeRequest {
|
||||||
|
/**
|
||||||
|
* 모델명 (예: claude-3-5-sonnet-20241022)
|
||||||
|
*/
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메시지 목록
|
||||||
|
*/
|
||||||
|
private List<Message> messages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최대 토큰 수
|
||||||
|
*/
|
||||||
|
@JsonProperty("max_tokens")
|
||||||
|
private Integer maxTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temperature (0.0 ~ 1.0)
|
||||||
|
*/
|
||||||
|
private Double temperature;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System 프롬프트 (선택)
|
||||||
|
*/
|
||||||
|
private String system;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메시지
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Message {
|
||||||
|
/**
|
||||||
|
* 역할 (user, assistant)
|
||||||
|
*/
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메시지 내용
|
||||||
|
*/
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
package com.kt.ai.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude API 응답 DTO
|
||||||
|
* API Docs: https://docs.anthropic.com/claude/reference/messages_post
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ClaudeResponse {
|
||||||
|
/**
|
||||||
|
* 응답 ID
|
||||||
|
*/
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타입 (message)
|
||||||
|
*/
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 역할 (assistant)
|
||||||
|
*/
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
private List<Content> content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델명
|
||||||
|
*/
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중단 이유 (end_turn, max_tokens, stop_sequence)
|
||||||
|
*/
|
||||||
|
@JsonProperty("stop_reason")
|
||||||
|
private String stopReason;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용량
|
||||||
|
*/
|
||||||
|
private Usage usage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Content {
|
||||||
|
/**
|
||||||
|
* 타입 (text)
|
||||||
|
*/
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 내용
|
||||||
|
*/
|
||||||
|
private String text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토큰 사용량
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Usage {
|
||||||
|
/**
|
||||||
|
* 입력 토큰 수
|
||||||
|
*/
|
||||||
|
@JsonProperty("input_tokens")
|
||||||
|
private Integer inputTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출력 토큰 수
|
||||||
|
*/
|
||||||
|
@JsonProperty("output_tokens")
|
||||||
|
private Integer outputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 내용 추출
|
||||||
|
*/
|
||||||
|
public String extractText() {
|
||||||
|
if (content != null && !content.isEmpty()) {
|
||||||
|
return content.get(0).getText();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package com.kt.ai.config;
|
||||||
|
|
||||||
|
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||||
|
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType;
|
||||||
|
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||||
|
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker 설정
|
||||||
|
* - Claude API / GPT-4 API 장애 대응
|
||||||
|
* - Timeout: 5분 (300초)
|
||||||
|
* - Failure Threshold: 50%
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class CircuitBreakerConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker Registry 설정
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CircuitBreakerRegistry circuitBreakerRegistry() {
|
||||||
|
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig config =
|
||||||
|
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
|
||||||
|
.failureRateThreshold(50)
|
||||||
|
.slowCallRateThreshold(50)
|
||||||
|
.slowCallDurationThreshold(Duration.ofSeconds(60))
|
||||||
|
.permittedNumberOfCallsInHalfOpenState(3)
|
||||||
|
.maxWaitDurationInHalfOpenState(Duration.ZERO)
|
||||||
|
.slidingWindowType(SlidingWindowType.COUNT_BASED)
|
||||||
|
.slidingWindowSize(10)
|
||||||
|
.minimumNumberOfCalls(5)
|
||||||
|
.waitDurationInOpenState(Duration.ofSeconds(60))
|
||||||
|
.automaticTransitionFromOpenToHalfOpenEnabled(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return CircuitBreakerRegistry.of(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude API Circuit Breaker
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CircuitBreaker claudeApiCircuitBreaker(CircuitBreakerRegistry registry) {
|
||||||
|
return registry.circuitBreaker("claudeApi");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPT-4 API Circuit Breaker
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CircuitBreaker gpt4ApiCircuitBreaker(CircuitBreakerRegistry registry) {
|
||||||
|
return registry.circuitBreaker("gpt4Api");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time Limiter 설정 (5분)
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public TimeLimiterConfig timeLimiterConfig() {
|
||||||
|
return TimeLimiterConfig.custom()
|
||||||
|
.timeoutDuration(Duration.ofSeconds(300))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java
Normal file
25
ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package com.kt.ai.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jackson ObjectMapper 설정
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ObjectMapper objectMapper() {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
mapper.registerModule(new JavaTimeModule());
|
||||||
|
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
return mapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
package com.kt.ai.config;
|
||||||
|
|
||||||
|
import com.kt.ai.kafka.message.AIJobMessage;
|
||||||
|
import org.apache.kafka.clients.consumer.ConsumerConfig;
|
||||||
|
import org.apache.kafka.common.serialization.StringDeserializer;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.kafka.annotation.EnableKafka;
|
||||||
|
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
|
||||||
|
import org.springframework.kafka.core.ConsumerFactory;
|
||||||
|
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
|
||||||
|
import org.springframework.kafka.listener.ContainerProperties;
|
||||||
|
import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer;
|
||||||
|
import org.springframework.kafka.support.serializer.JsonDeserializer;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka Consumer 설정
|
||||||
|
* - Topic: ai-event-generation-job
|
||||||
|
* - Consumer Group: ai-service-consumers
|
||||||
|
* - Manual ACK 모드
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@EnableKafka
|
||||||
|
@Configuration
|
||||||
|
public class KafkaConsumerConfig {
|
||||||
|
|
||||||
|
@Value("${spring.kafka.bootstrap-servers}")
|
||||||
|
private String bootstrapServers;
|
||||||
|
|
||||||
|
@Value("${spring.kafka.consumer.group-id}")
|
||||||
|
private String groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka Consumer 팩토리 설정
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public ConsumerFactory<String, AIJobMessage> consumerFactory() {
|
||||||
|
Map<String, Object> props = new HashMap<>();
|
||||||
|
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||||
|
props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
|
||||||
|
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
|
||||||
|
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
|
||||||
|
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 10);
|
||||||
|
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30000);
|
||||||
|
|
||||||
|
// Key Deserializer
|
||||||
|
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
|
||||||
|
|
||||||
|
// Value Deserializer with Error Handling
|
||||||
|
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
|
||||||
|
props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName());
|
||||||
|
props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, AIJobMessage.class.getName());
|
||||||
|
props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
|
||||||
|
|
||||||
|
return new DefaultKafkaConsumerFactory<>(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka Listener Container Factory 설정
|
||||||
|
* - Manual ACK 모드
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public ConcurrentKafkaListenerContainerFactory<String, AIJobMessage> kafkaListenerContainerFactory() {
|
||||||
|
ConcurrentKafkaListenerContainerFactory<String, AIJobMessage> factory =
|
||||||
|
new ConcurrentKafkaListenerContainerFactory<>();
|
||||||
|
factory.setConsumerFactory(consumerFactory());
|
||||||
|
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
ai-service/src/main/java/com/kt/ai/config/RedisConfig.java
Normal file
120
ai-service/src/main/java/com/kt/ai/config/RedisConfig.java
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package com.kt.ai.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import io.lettuce.core.ClientOptions;
|
||||||
|
import io.lettuce.core.SocketOptions;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 설정
|
||||||
|
* - 작업 상태 및 추천 결과 캐싱
|
||||||
|
* - TTL: 추천 24시간, Job 상태 24시간, 트렌드 1시간
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.host}")
|
||||||
|
private String redisHost;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.port}")
|
||||||
|
private int redisPort;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.password}")
|
||||||
|
private String redisPassword;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.database}")
|
||||||
|
private int redisDatabase;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.timeout:3000}")
|
||||||
|
private long redisTimeout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 연결 팩토리 설정
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RedisConnectionFactory redisConnectionFactory() {
|
||||||
|
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
|
||||||
|
config.setHostName(redisHost);
|
||||||
|
config.setPort(redisPort);
|
||||||
|
if (redisPassword != null && !redisPassword.isEmpty()) {
|
||||||
|
config.setPassword(redisPassword);
|
||||||
|
}
|
||||||
|
config.setDatabase(redisDatabase);
|
||||||
|
|
||||||
|
// Lettuce Client 설정: Timeout 및 Connection 옵션
|
||||||
|
SocketOptions socketOptions = SocketOptions.builder()
|
||||||
|
.connectTimeout(Duration.ofMillis(redisTimeout))
|
||||||
|
.keepAlive(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ClientOptions clientOptions = ClientOptions.builder()
|
||||||
|
.socketOptions(socketOptions)
|
||||||
|
.autoReconnect(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
|
||||||
|
.commandTimeout(Duration.ofMillis(redisTimeout))
|
||||||
|
.clientOptions(clientOptions)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// afterPropertiesSet() 제거: Spring이 자동으로 호출함
|
||||||
|
return new LettuceConnectionFactory(config, clientConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ObjectMapper for Redis (Java 8 Date/Time 지원)
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public ObjectMapper redisObjectMapper() {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
// Java 8 Date/Time 모듈 등록
|
||||||
|
mapper.registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
|
// Timestamp 대신 ISO-8601 형식으로 직렬화
|
||||||
|
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
|
||||||
|
return mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RedisTemplate 설정
|
||||||
|
* - Key: String
|
||||||
|
* - Value: JSON (Jackson with Java 8 Date/Time support)
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
|
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(connectionFactory);
|
||||||
|
|
||||||
|
// Key Serializer: String
|
||||||
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
|
|
||||||
|
// Value Serializer: JSON with Java 8 Date/Time support
|
||||||
|
GenericJackson2JsonRedisSerializer serializer =
|
||||||
|
new GenericJackson2JsonRedisSerializer(redisObjectMapper());
|
||||||
|
|
||||||
|
template.setValueSerializer(serializer);
|
||||||
|
template.setHashValueSerializer(serializer);
|
||||||
|
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.kt.ai.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security 설정
|
||||||
|
* - Internal API만 제공 (Event Service에서만 호출)
|
||||||
|
* - JWT 인증 없음 (내부 통신)
|
||||||
|
* - CORS 설정
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security Filter Chain 설정
|
||||||
|
* - 모든 요청 허용 (내부 API)
|
||||||
|
* - CSRF 비활성화
|
||||||
|
* - Stateless 세션
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||||
|
.requestMatchers("/internal/**").permitAll() // Internal API
|
||||||
|
.anyRequest().permitAll()
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS 설정
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080"));
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||||
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java
Normal file
64
ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package com.kt.ai.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Contact;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.servers.Server;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swagger/OpenAPI 설정
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class SwaggerConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
Server localServer = new Server();
|
||||||
|
localServer.setUrl("http://localhost:8083");
|
||||||
|
localServer.setDescription("Local Development Server");
|
||||||
|
|
||||||
|
Server devServer = new Server();
|
||||||
|
devServer.setUrl("https://dev-api.kt-event-marketing.com/ai/v1");
|
||||||
|
devServer.setDescription("Development Server");
|
||||||
|
|
||||||
|
Server prodServer = new Server();
|
||||||
|
prodServer.setUrl("https://api.kt-event-marketing.com/ai/v1");
|
||||||
|
prodServer.setDescription("Production Server");
|
||||||
|
|
||||||
|
Contact contact = new Contact();
|
||||||
|
contact.setName("Digital Garage Team");
|
||||||
|
contact.setEmail("support@kt-event-marketing.com");
|
||||||
|
|
||||||
|
Info info = new Info()
|
||||||
|
.title("AI Service API")
|
||||||
|
.version("1.0.0")
|
||||||
|
.description("""
|
||||||
|
KT AI 기반 소상공인 이벤트 자동 생성 서비스 - AI Service
|
||||||
|
|
||||||
|
## 서비스 개요
|
||||||
|
- Kafka를 통한 비동기 AI 추천 처리
|
||||||
|
- Claude API / GPT-4 API 연동
|
||||||
|
- Redis 기반 결과 캐싱 (TTL 24시간)
|
||||||
|
|
||||||
|
## 처리 흐름
|
||||||
|
1. Event Service가 Kafka Topic에 Job 메시지 발행
|
||||||
|
2. AI Service가 메시지 구독 및 처리
|
||||||
|
3. 트렌드 분석 수행 (Claude/GPT-4 API)
|
||||||
|
4. 3가지 이벤트 추천안 생성
|
||||||
|
5. 결과를 Redis에 저장 (TTL 24시간)
|
||||||
|
6. Job 상태를 Redis에 업데이트
|
||||||
|
""")
|
||||||
|
.contact(contact);
|
||||||
|
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(info)
|
||||||
|
.servers(List.of(localServer, devServer, prodServer));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
package com.kt.ai.controller;
|
||||||
|
|
||||||
|
import com.kt.ai.model.dto.response.HealthCheckResponse;
|
||||||
|
import com.kt.ai.model.enums.CircuitBreakerState;
|
||||||
|
import com.kt.ai.model.enums.ServiceStatus;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 헬스체크 Controller
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "Health Check", description = "서비스 상태 확인")
|
||||||
|
@RestController
|
||||||
|
public class HealthController {
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 헬스체크
|
||||||
|
*/
|
||||||
|
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
|
||||||
|
@GetMapping("/api/v1/ai-service/health")
|
||||||
|
public ResponseEntity<HealthCheckResponse> healthCheck() {
|
||||||
|
// Redis 상태 확인
|
||||||
|
ServiceStatus redisStatus = checkRedis();
|
||||||
|
|
||||||
|
// 전체 서비스 상태 (Redis가 DOWN이면 DEGRADED, UNKNOWN이면 UP으로 처리)
|
||||||
|
ServiceStatus overallStatus;
|
||||||
|
if (redisStatus == ServiceStatus.DOWN) {
|
||||||
|
overallStatus = ServiceStatus.DEGRADED;
|
||||||
|
} else {
|
||||||
|
overallStatus = ServiceStatus.UP;
|
||||||
|
}
|
||||||
|
|
||||||
|
HealthCheckResponse.Services services = HealthCheckResponse.Services.builder()
|
||||||
|
.kafka(ServiceStatus.UP) // TODO: 실제 Kafka 상태 확인
|
||||||
|
.redis(redisStatus)
|
||||||
|
.claudeApi(ServiceStatus.UP) // TODO: 실제 Claude API 상태 확인
|
||||||
|
.gpt4Api(ServiceStatus.UP) // TODO: 실제 GPT-4 API 상태 확인 (선택)
|
||||||
|
.circuitBreaker(CircuitBreakerState.CLOSED) // TODO: 실제 Circuit Breaker 상태 확인
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HealthCheckResponse response = HealthCheckResponse.builder()
|
||||||
|
.status(overallStatus)
|
||||||
|
.timestamp(LocalDateTime.now())
|
||||||
|
.services(services)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 연결 상태 확인
|
||||||
|
*/
|
||||||
|
private ServiceStatus checkRedis() {
|
||||||
|
// RedisTemplate이 주입되지 않은 경우 (로컬 환경 등)
|
||||||
|
if (redisTemplate == null) {
|
||||||
|
log.warn("RedisTemplate이 주입되지 않았습니다. Redis 상태를 UNKNOWN으로 표시합니다.");
|
||||||
|
return ServiceStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug("Redis 연결 테스트 시작...");
|
||||||
|
String pong = redisTemplate.getConnectionFactory().getConnection().ping();
|
||||||
|
log.info("✅ Redis 연결 성공! PING 응답: {}", pong);
|
||||||
|
return ServiceStatus.UP;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ Redis 연결 실패", e);
|
||||||
|
log.error("상세 오류 정보:");
|
||||||
|
log.error(" - 오류 타입: {}", e.getClass().getName());
|
||||||
|
log.error(" - 오류 메시지: {}", e.getMessage());
|
||||||
|
if (e.getCause() != null) {
|
||||||
|
log.error(" - 원인: {}", e.getCause().getMessage());
|
||||||
|
}
|
||||||
|
return ServiceStatus.DOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.kt.ai.controller;
|
||||||
|
|
||||||
|
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||||
|
import com.kt.ai.model.enums.JobStatus;
|
||||||
|
import com.kt.ai.service.CacheService;
|
||||||
|
import com.kt.ai.service.JobStatusService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal Job Controller
|
||||||
|
* Event Service에서 호출하는 내부 API
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/ai-service/internal/jobs")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InternalJobController {
|
||||||
|
|
||||||
|
private final JobStatusService jobStatusService;
|
||||||
|
private final CacheService cacheService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 상태 조회
|
||||||
|
*/
|
||||||
|
@Operation(summary = "작업 상태 조회", description = "Redis에 저장된 AI 추천 작업 상태 조회")
|
||||||
|
@GetMapping("/{jobId}/status")
|
||||||
|
public ResponseEntity<JobStatusResponse> getJobStatus(@PathVariable String jobId) {
|
||||||
|
log.info("Job 상태 조회 요청: jobId={}", jobId);
|
||||||
|
JobStatusResponse response = jobStatusService.getJobStatus(jobId);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: Job 상태 테스트 데이터 생성
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Job 테스트 데이터 생성 (디버그)", description = "Redis에 샘플 Job 상태 데이터 저장")
|
||||||
|
@GetMapping("/debug/create-test-job/{jobId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> createTestJob(@PathVariable String jobId) {
|
||||||
|
log.info("Job 테스트 데이터 생성 요청: jobId={}", jobId);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 다양한 상태의 테스트 데이터 생성
|
||||||
|
JobStatus[] statuses = JobStatus.values();
|
||||||
|
|
||||||
|
// 요청된 jobId로 PROCESSING 상태 데이터 생성
|
||||||
|
jobStatusService.updateJobStatus(jobId, JobStatus.PROCESSING, "AI 추천 생성 중 (50%)");
|
||||||
|
|
||||||
|
// 추가 샘플 데이터 생성 (다양한 상태)
|
||||||
|
jobStatusService.updateJobStatus(jobId + "-pending", JobStatus.PENDING, "대기 중");
|
||||||
|
jobStatusService.updateJobStatus(jobId + "-completed", JobStatus.COMPLETED, "AI 추천 완료");
|
||||||
|
jobStatusService.updateJobStatus(jobId + "-failed", JobStatus.FAILED, "AI API 호출 실패");
|
||||||
|
|
||||||
|
// 저장 확인
|
||||||
|
Object saved = cacheService.getJobStatus(jobId);
|
||||||
|
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("jobId", jobId);
|
||||||
|
result.put("saved", saved != null);
|
||||||
|
result.put("data", saved);
|
||||||
|
result.put("additionalSamples", Map.of(
|
||||||
|
"pending", jobId + "-pending",
|
||||||
|
"completed", jobId + "-completed",
|
||||||
|
"failed", jobId + "-failed"
|
||||||
|
));
|
||||||
|
|
||||||
|
log.info("Job 테스트 데이터 생성 완료: jobId={}, saved={}", jobId, saved != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Job 테스트 데이터 생성 실패: jobId={}", jobId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,264 @@
|
|||||||
|
package com.kt.ai.controller;
|
||||||
|
|
||||||
|
import com.kt.ai.model.dto.response.AIRecommendationResult;
|
||||||
|
import com.kt.ai.model.dto.response.EventRecommendation;
|
||||||
|
import com.kt.ai.model.dto.response.TrendAnalysis;
|
||||||
|
import com.kt.ai.service.AIRecommendationService;
|
||||||
|
import com.kt.ai.service.CacheService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal Recommendation Controller
|
||||||
|
* Event Service에서 호출하는 내부 API
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/ai-service/internal/recommendations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InternalRecommendationController {
|
||||||
|
|
||||||
|
private final AIRecommendationService aiRecommendationService;
|
||||||
|
private final CacheService cacheService;
|
||||||
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 결과 조회
|
||||||
|
*/
|
||||||
|
@Operation(summary = "AI 추천 결과 조회", description = "Redis에 캐시된 AI 추천 결과 조회")
|
||||||
|
@GetMapping("/{eventId}")
|
||||||
|
public ResponseEntity<AIRecommendationResult> getRecommendation(@PathVariable String eventId) {
|
||||||
|
log.info("AI 추천 결과 조회 요청: eventId={}", eventId);
|
||||||
|
AIRecommendationResult response = aiRecommendationService.getRecommendation(eventId);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: 모든 키 조회
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Redis 키 조회 (디버그)", description = "Redis에 저장된 모든 키 조회")
|
||||||
|
@GetMapping("/debug/redis-keys")
|
||||||
|
public ResponseEntity<Map<String, Object>> debugRedisKeys() {
|
||||||
|
log.info("Redis 키 디버그 요청");
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모든 ai:* 키 조회
|
||||||
|
Set<String> keys = redisTemplate.keys("ai:*");
|
||||||
|
result.put("totalKeys", keys != null ? keys.size() : 0);
|
||||||
|
result.put("keys", keys);
|
||||||
|
|
||||||
|
// 특정 키의 값 조회
|
||||||
|
if (keys != null && !keys.isEmpty()) {
|
||||||
|
Map<String, Object> values = new HashMap<>();
|
||||||
|
for (String key : keys) {
|
||||||
|
Object value = redisTemplate.opsForValue().get(key);
|
||||||
|
values.put(key, value);
|
||||||
|
}
|
||||||
|
result.put("values", values);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Redis 키 조회 성공: {} 개의 키 발견", keys != null ? keys.size() : 0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Redis 키 조회 실패", e);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: 특정 키 조회
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Redis 특정 키 조회 (디버그)", description = "Redis에서 특정 키의 값 조회")
|
||||||
|
@GetMapping("/debug/redis-key/{key}")
|
||||||
|
public ResponseEntity<Map<String, Object>> debugRedisKey(@PathVariable String key) {
|
||||||
|
log.info("Redis 특정 키 조회 요청: key={}", key);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("key", key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object value = redisTemplate.opsForValue().get(key);
|
||||||
|
result.put("exists", value != null);
|
||||||
|
result.put("value", value);
|
||||||
|
|
||||||
|
log.info("Redis 키 조회: key={}, exists={}", key, value != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Redis 키 조회 실패: key={}", key, e);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: 모든 database 검색
|
||||||
|
*/
|
||||||
|
@Operation(summary = "모든 Redis DB 검색 (디버그)", description = "Redis database 0~15에서 ai:* 키 검색")
|
||||||
|
@GetMapping("/debug/search-all-databases")
|
||||||
|
public ResponseEntity<Map<String, Object>> searchAllDatabases() {
|
||||||
|
log.info("모든 Redis database 검색 시작");
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
Map<Integer, Set<String>> databaseKeys = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Redis connection factory를 통해 database 변경하며 검색
|
||||||
|
var connectionFactory = redisTemplate.getConnectionFactory();
|
||||||
|
|
||||||
|
for (int db = 0; db < 16; db++) {
|
||||||
|
try {
|
||||||
|
var connection = connectionFactory.getConnection();
|
||||||
|
connection.select(db);
|
||||||
|
|
||||||
|
Set<byte[]> keyBytes = connection.keys("ai:*".getBytes());
|
||||||
|
if (keyBytes != null && !keyBytes.isEmpty()) {
|
||||||
|
Set<String> keys = new java.util.HashSet<>();
|
||||||
|
for (byte[] keyByte : keyBytes) {
|
||||||
|
keys.add(new String(keyByte));
|
||||||
|
}
|
||||||
|
databaseKeys.put(db, keys);
|
||||||
|
log.info("Database {} 에서 {} 개의 ai:* 키 발견", db, keys.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Database {} 검색 실패: {}", db, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("databasesWithKeys", databaseKeys);
|
||||||
|
result.put("totalDatabases", databaseKeys.size());
|
||||||
|
|
||||||
|
log.info("모든 database 검색 완료: {} 개의 database에 키 존재", databaseKeys.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("모든 database 검색 실패", e);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: 테스트 데이터 생성
|
||||||
|
*/
|
||||||
|
@Operation(summary = "테스트 데이터 생성 (디버그)", description = "Redis에 샘플 AI 추천 데이터 저장")
|
||||||
|
@GetMapping("/debug/create-test-data/{eventId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> createTestData(@PathVariable String eventId) {
|
||||||
|
log.info("테스트 데이터 생성 요청: eventId={}", eventId);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 샘플 AI 추천 결과 생성
|
||||||
|
AIRecommendationResult testData = AIRecommendationResult.builder()
|
||||||
|
.eventId(eventId)
|
||||||
|
.trendAnalysis(TrendAnalysis.builder()
|
||||||
|
.industryTrends(List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("BBQ 고기집")
|
||||||
|
.relevance(0.95)
|
||||||
|
.description("음식점 업종, 고기 구이 인기 트렌드")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.regionalTrends(List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("강남 맛집")
|
||||||
|
.relevance(0.90)
|
||||||
|
.description("강남구 지역 외식 인기 증가")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.seasonalTrends(List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("봄나들이 외식")
|
||||||
|
.relevance(0.85)
|
||||||
|
.description("봄철 야외 활동 및 외식 증가")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build())
|
||||||
|
.recommendations(List.of(
|
||||||
|
EventRecommendation.builder()
|
||||||
|
.optionNumber(1)
|
||||||
|
.concept("SNS 이벤트")
|
||||||
|
.title("인스타그램 후기 이벤트")
|
||||||
|
.description("음식 사진을 인스타그램에 올리고 해시태그를 달면 할인 쿠폰 제공")
|
||||||
|
.targetAudience("20-30대 SNS 활동층")
|
||||||
|
.duration(EventRecommendation.Duration.builder()
|
||||||
|
.recommendedDays(14)
|
||||||
|
.recommendedPeriod("2주")
|
||||||
|
.build())
|
||||||
|
.mechanics(EventRecommendation.Mechanics.builder()
|
||||||
|
.type(com.kt.ai.model.enums.EventMechanicsType.DISCOUNT)
|
||||||
|
.details("인스타그램 게시물 작성 시 10% 할인")
|
||||||
|
.build())
|
||||||
|
.promotionChannels(List.of("Instagram", "Facebook", "매장 포스터"))
|
||||||
|
.estimatedCost(EventRecommendation.EstimatedCost.builder()
|
||||||
|
.min(100000)
|
||||||
|
.max(200000)
|
||||||
|
.breakdown(Map.of(
|
||||||
|
"할인비용", 150000,
|
||||||
|
"홍보비", 50000
|
||||||
|
))
|
||||||
|
.build())
|
||||||
|
.expectedMetrics(com.kt.ai.model.dto.response.ExpectedMetrics.builder()
|
||||||
|
.newCustomers(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||||
|
.min(30.0)
|
||||||
|
.max(50.0)
|
||||||
|
.build())
|
||||||
|
.revenueIncrease(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||||
|
.min(10.0)
|
||||||
|
.max(20.0)
|
||||||
|
.build())
|
||||||
|
.roi(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||||
|
.min(100.0)
|
||||||
|
.max(150.0)
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
.differentiator("SNS를 활용한 바이럴 마케팅")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.generatedAt(java.time.LocalDateTime.now())
|
||||||
|
.expiresAt(java.time.LocalDateTime.now().plusDays(1))
|
||||||
|
.aiProvider(com.kt.ai.model.enums.AIProvider.CLAUDE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Redis에 저장
|
||||||
|
cacheService.saveRecommendation(eventId, testData);
|
||||||
|
|
||||||
|
// 저장 확인
|
||||||
|
Object saved = cacheService.getRecommendation(eventId);
|
||||||
|
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("eventId", eventId);
|
||||||
|
result.put("saved", saved != null);
|
||||||
|
result.put("data", saved);
|
||||||
|
|
||||||
|
log.info("테스트 데이터 생성 완료: eventId={}, saved={}", eventId, saved != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("테스트 데이터 생성 실패: eventId={}", eventId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.kt.ai.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Service 공통 예외
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public class AIServiceException extends RuntimeException {
|
||||||
|
private final String errorCode;
|
||||||
|
|
||||||
|
public AIServiceException(String errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AIServiceException(String errorCode, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.kt.ai.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker가 열린 상태 예외
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public class CircuitBreakerOpenException extends AIServiceException {
|
||||||
|
public CircuitBreakerOpenException(String apiName) {
|
||||||
|
super("CIRCUIT_BREAKER_OPEN", "Circuit Breaker가 열린 상태입니다: " + apiName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package com.kt.ai.exception;
|
||||||
|
|
||||||
|
import com.kt.ai.model.dto.response.ErrorResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 예외 처리 핸들러
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job을 찾을 수 없는 예외 처리
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(JobNotFoundException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleJobNotFoundException(JobNotFoundException ex) {
|
||||||
|
log.error("Job not found: {}", ex.getMessage());
|
||||||
|
|
||||||
|
ErrorResponse error = ErrorResponse.builder()
|
||||||
|
.code(ex.getErrorCode())
|
||||||
|
.message(ex.getMessage())
|
||||||
|
.timestamp(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 결과를 찾을 수 없는 예외 처리
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(RecommendationNotFoundException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleRecommendationNotFoundException(RecommendationNotFoundException ex) {
|
||||||
|
log.error("Recommendation not found: {}", ex.getMessage());
|
||||||
|
|
||||||
|
ErrorResponse error = ErrorResponse.builder()
|
||||||
|
.code(ex.getErrorCode())
|
||||||
|
.message(ex.getMessage())
|
||||||
|
.timestamp(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker가 열린 상태 예외 처리
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(CircuitBreakerOpenException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleCircuitBreakerOpenException(CircuitBreakerOpenException ex) {
|
||||||
|
log.error("Circuit breaker open: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> details = new HashMap<>();
|
||||||
|
details.put("message", "외부 AI API가 일시적으로 사용 불가능합니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
|
||||||
|
ErrorResponse error = ErrorResponse.builder()
|
||||||
|
.code(ex.getErrorCode())
|
||||||
|
.message(ex.getMessage())
|
||||||
|
.timestamp(LocalDateTime.now())
|
||||||
|
.details(details)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Service 공통 예외 처리
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AIServiceException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleAIServiceException(AIServiceException ex) {
|
||||||
|
log.error("AI Service error: {}", ex.getMessage(), ex);
|
||||||
|
|
||||||
|
ErrorResponse error = ErrorResponse.builder()
|
||||||
|
.code(ex.getErrorCode())
|
||||||
|
.message(ex.getMessage())
|
||||||
|
.timestamp(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 리소스를 찾을 수 없는 예외 처리 (favicon.ico 등)
|
||||||
|
* WARN 레벨로 로깅하여 에러 로그 오염 방지
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleNoResourceFoundException(NoResourceFoundException ex) {
|
||||||
|
// favicon.ico 등 브라우저가 자동으로 요청하는 리소스는 DEBUG 레벨로 로깅
|
||||||
|
String resourcePath = ex.getResourcePath();
|
||||||
|
if (resourcePath != null && (resourcePath.contains("favicon") || resourcePath.endsWith(".ico"))) {
|
||||||
|
log.debug("Static resource not found (expected): {}", resourcePath);
|
||||||
|
} else {
|
||||||
|
log.warn("Static resource not found: {}", resourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorResponse error = ErrorResponse.builder()
|
||||||
|
.code("RESOURCE_NOT_FOUND")
|
||||||
|
.message("요청하신 리소스를 찾을 수 없습니다")
|
||||||
|
.timestamp(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일반 예외 처리
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
|
||||||
|
log.error("Unexpected error: {}", ex.getMessage(), ex);
|
||||||
|
|
||||||
|
ErrorResponse error = ErrorResponse.builder()
|
||||||
|
.code("INTERNAL_ERROR")
|
||||||
|
.message("서버 내부 오류가 발생했습니다")
|
||||||
|
.timestamp(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.kt.ai.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job을 찾을 수 없는 예외
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public class JobNotFoundException extends AIServiceException {
|
||||||
|
public JobNotFoundException(String jobId) {
|
||||||
|
super("JOB_NOT_FOUND", "작업을 찾을 수 없습니다: " + jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.kt.ai.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 결과를 찾을 수 없는 예외
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public class RecommendationNotFoundException extends AIServiceException {
|
||||||
|
public RecommendationNotFoundException(String eventId) {
|
||||||
|
super("RECOMMENDATION_NOT_FOUND", "추천 결과를 찾을 수 없습니다: " + eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package com.kt.ai.kafka.consumer;
|
||||||
|
|
||||||
|
import com.kt.ai.kafka.message.AIJobMessage;
|
||||||
|
import com.kt.ai.service.AIRecommendationService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.kafka.annotation.KafkaListener;
|
||||||
|
import org.springframework.kafka.support.Acknowledgment;
|
||||||
|
import org.springframework.kafka.support.KafkaHeaders;
|
||||||
|
import org.springframework.messaging.handler.annotation.Header;
|
||||||
|
import org.springframework.messaging.handler.annotation.Payload;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Job Kafka Consumer
|
||||||
|
* - Topic: ai-event-generation-job
|
||||||
|
* - Consumer Group: ai-service-consumers
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AIJobConsumer {
|
||||||
|
|
||||||
|
private final AIRecommendationService aiRecommendationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka 메시지 수신 및 처리
|
||||||
|
*/
|
||||||
|
@KafkaListener(
|
||||||
|
topics = "${kafka.topics.ai-job}",
|
||||||
|
groupId = "${spring.kafka.consumer.group-id}",
|
||||||
|
containerFactory = "kafkaListenerContainerFactory"
|
||||||
|
)
|
||||||
|
public void consume(
|
||||||
|
@Payload AIJobMessage message,
|
||||||
|
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
|
||||||
|
@Header(KafkaHeaders.OFFSET) Long offset,
|
||||||
|
Acknowledgment acknowledgment
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
log.info("Kafka 메시지 수신: topic={}, offset={}, jobId={}, eventId={}",
|
||||||
|
topic, offset, message.getJobId(), message.getEventId());
|
||||||
|
|
||||||
|
// AI 추천 생성
|
||||||
|
aiRecommendationService.generateRecommendations(message);
|
||||||
|
|
||||||
|
// Manual ACK
|
||||||
|
acknowledgment.acknowledge();
|
||||||
|
log.info("Kafka 메시지 처리 완료: jobId={}", message.getJobId());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Kafka 메시지 처리 실패: jobId={}", message.getJobId(), e);
|
||||||
|
// DLQ로 이동하거나 재시도 로직 추가 가능
|
||||||
|
acknowledgment.acknowledge(); // 실패한 메시지도 ACK (DLQ로 이동)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package com.kt.ai.kafka.message;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 이벤트 생성 요청 메시지 (Kafka)
|
||||||
|
* Topic: ai-event-generation-job
|
||||||
|
* Consumer Group: ai-service-consumers
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AIJobMessage {
|
||||||
|
/**
|
||||||
|
* Job 고유 ID
|
||||||
|
*/
|
||||||
|
private String jobId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 ID (Event Service에서 생성)
|
||||||
|
*/
|
||||||
|
private String eventId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 목적
|
||||||
|
* - "신규 고객 유치"
|
||||||
|
* - "재방문 유도"
|
||||||
|
* - "매출 증대"
|
||||||
|
* - "브랜드 인지도 향상"
|
||||||
|
*/
|
||||||
|
private String objective;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업종
|
||||||
|
*/
|
||||||
|
private String industry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지역 (시/구/동)
|
||||||
|
*/
|
||||||
|
private String region;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장명 (선택)
|
||||||
|
*/
|
||||||
|
private String storeName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목표 고객층 (선택)
|
||||||
|
*/
|
||||||
|
private String targetAudience;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예산 (원) (선택)
|
||||||
|
*/
|
||||||
|
private Integer budget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 시각
|
||||||
|
*/
|
||||||
|
private LocalDateTime requestedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.kt.ai.model.dto.response;
|
||||||
|
|
||||||
|
import com.kt.ai.model.enums.AIProvider;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 이벤트 추천 결과 DTO
|
||||||
|
* Redis Key: ai:recommendation:{eventId}
|
||||||
|
* TTL: 86400초 (24시간)
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AIRecommendationResult {
|
||||||
|
/**
|
||||||
|
* 이벤트 ID
|
||||||
|
*/
|
||||||
|
private String eventId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트렌드 분석 결과
|
||||||
|
*/
|
||||||
|
private TrendAnalysis trendAnalysis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 이벤트 기획안 (3개)
|
||||||
|
*/
|
||||||
|
private List<EventRecommendation> recommendations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 시각
|
||||||
|
*/
|
||||||
|
private LocalDateTime generatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 만료 시각 (생성 시각 + 24시간)
|
||||||
|
*/
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용된 AI 제공자
|
||||||
|
*/
|
||||||
|
private AIProvider aiProvider;
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.kt.ai.model.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 응답 DTO
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ErrorResponse {
|
||||||
|
/**
|
||||||
|
* 에러 코드
|
||||||
|
*/
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 메시지
|
||||||
|
*/
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 발생 시각
|
||||||
|
*/
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추가 에러 상세
|
||||||
|
*/
|
||||||
|
private Map<String, Object> details;
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
package com.kt.ai.model.dto.response;
|
||||||
|
|
||||||
|
import com.kt.ai.model.enums.EventMechanicsType;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 추천안 DTO
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class EventRecommendation {
|
||||||
|
/**
|
||||||
|
* 옵션 번호 (1-3)
|
||||||
|
*/
|
||||||
|
private Integer optionNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 컨셉
|
||||||
|
*/
|
||||||
|
private String concept;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 제목
|
||||||
|
*/
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 설명
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목표 고객층
|
||||||
|
*/
|
||||||
|
private String targetAudience;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 기간
|
||||||
|
*/
|
||||||
|
private Duration duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 메커니즘
|
||||||
|
*/
|
||||||
|
private Mechanics mechanics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 홍보 채널 (최대 5개)
|
||||||
|
*/
|
||||||
|
private List<String> promotionChannels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예상 비용
|
||||||
|
*/
|
||||||
|
private EstimatedCost estimatedCost;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예상 성과 지표
|
||||||
|
*/
|
||||||
|
private ExpectedMetrics expectedMetrics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다른 옵션과의 차별점
|
||||||
|
*/
|
||||||
|
private String differentiator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 기간
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Duration {
|
||||||
|
/**
|
||||||
|
* 권장 진행 일수
|
||||||
|
*/
|
||||||
|
private Integer recommendedDays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권장 진행 시기
|
||||||
|
*/
|
||||||
|
private String recommendedPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 메커니즘
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Mechanics {
|
||||||
|
/**
|
||||||
|
* 이벤트 유형
|
||||||
|
*/
|
||||||
|
private EventMechanicsType type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상세 메커니즘
|
||||||
|
*/
|
||||||
|
private String details;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예상 비용
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class EstimatedCost {
|
||||||
|
/**
|
||||||
|
* 최소 비용 (원)
|
||||||
|
*/
|
||||||
|
private Integer min;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최대 비용 (원)
|
||||||
|
*/
|
||||||
|
private Integer max;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비용 구성
|
||||||
|
*/
|
||||||
|
private Map<String, Integer> breakdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
package com.kt.ai.model.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예상 성과 지표 DTO
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExpectedMetrics {
|
||||||
|
/**
|
||||||
|
* 신규 고객 수
|
||||||
|
*/
|
||||||
|
private Range newCustomers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재방문 고객 수 (선택)
|
||||||
|
*/
|
||||||
|
private Range repeatVisits;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매출 증가율 (%)
|
||||||
|
*/
|
||||||
|
private Range revenueIncrease;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ROI - 투자 대비 수익률 (%)
|
||||||
|
*/
|
||||||
|
private Range roi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 참여도 (선택)
|
||||||
|
*/
|
||||||
|
private SocialEngagement socialEngagement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 범위 값 (최소-최대)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Range {
|
||||||
|
private Double min;
|
||||||
|
private Double max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 참여도
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class SocialEngagement {
|
||||||
|
/**
|
||||||
|
* 예상 게시물 수
|
||||||
|
*/
|
||||||
|
private Integer estimatedPosts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예상 도달 수
|
||||||
|
*/
|
||||||
|
private Integer estimatedReach;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
package com.kt.ai.model.dto.response;
|
||||||
|
|
||||||
|
import com.kt.ai.model.enums.CircuitBreakerState;
|
||||||
|
import com.kt.ai.model.enums.ServiceStatus;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 헬스체크 응답 DTO
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class HealthCheckResponse {
|
||||||
|
/**
|
||||||
|
* 전체 서비스 상태
|
||||||
|
*/
|
||||||
|
private ServiceStatus status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 체크 시각
|
||||||
|
*/
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 서비스 상태
|
||||||
|
*/
|
||||||
|
private Services services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 서비스 상태 정보
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Services {
|
||||||
|
/**
|
||||||
|
* Kafka 연결 상태
|
||||||
|
*/
|
||||||
|
private ServiceStatus kafka;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 연결 상태
|
||||||
|
*/
|
||||||
|
private ServiceStatus redis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude API 상태
|
||||||
|
*/
|
||||||
|
private ServiceStatus claudeApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPT-4 API 상태 (선택)
|
||||||
|
*/
|
||||||
|
private ServiceStatus gpt4Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker 상태
|
||||||
|
*/
|
||||||
|
private CircuitBreakerState circuitBreaker;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package com.kt.ai.model.dto.response;
|
||||||
|
|
||||||
|
import com.kt.ai.model.enums.JobStatus;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 상태 응답 DTO
|
||||||
|
* Redis Key: ai:job:status:{jobId}
|
||||||
|
* TTL: 86400초 (24시간)
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class JobStatusResponse {
|
||||||
|
/**
|
||||||
|
* Job ID
|
||||||
|
*/
|
||||||
|
private String jobId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 상태
|
||||||
|
*/
|
||||||
|
private JobStatus status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진행률 (0-100)
|
||||||
|
*/
|
||||||
|
private Integer progress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 메시지
|
||||||
|
*/
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 ID
|
||||||
|
*/
|
||||||
|
private String eventId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 생성 시각
|
||||||
|
*/
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 시작 시각
|
||||||
|
*/
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 완료 시각 (완료 시)
|
||||||
|
*/
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 실패 시각 (실패 시)
|
||||||
|
*/
|
||||||
|
private LocalDateTime failedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 메시지 (실패 시)
|
||||||
|
*/
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재시도 횟수
|
||||||
|
*/
|
||||||
|
private Integer retryCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 시간 (밀리초)
|
||||||
|
*/
|
||||||
|
private Long processingTimeMs;
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package com.kt.ai.model.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트렌드 분석 결과 DTO
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TrendAnalysis {
|
||||||
|
/**
|
||||||
|
* 업종 트렌드 키워드 (최대 5개)
|
||||||
|
*/
|
||||||
|
private List<TrendKeyword> industryTrends;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지역 트렌드 키워드 (최대 5개)
|
||||||
|
*/
|
||||||
|
private List<TrendKeyword> regionalTrends;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시즌 트렌드 키워드 (최대 5개)
|
||||||
|
*/
|
||||||
|
private List<TrendKeyword> seasonalTrends;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트렌드 키워드 정보
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class TrendKeyword {
|
||||||
|
/**
|
||||||
|
* 트렌드 키워드
|
||||||
|
*/
|
||||||
|
private String keyword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연관도 (0-1)
|
||||||
|
*/
|
||||||
|
private Double relevance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트렌드 설명
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.kt.ai.model.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 제공자 타입
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public enum AIProvider {
|
||||||
|
/**
|
||||||
|
* Claude API (Anthropic)
|
||||||
|
*/
|
||||||
|
CLAUDE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPT-4 API (OpenAI)
|
||||||
|
*/
|
||||||
|
GPT4
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.kt.ai.model.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit Breaker 상태
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public enum CircuitBreakerState {
|
||||||
|
/**
|
||||||
|
* 닫힘 - 정상 동작
|
||||||
|
*/
|
||||||
|
CLOSED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 열림 - 장애 발생, 요청 차단
|
||||||
|
*/
|
||||||
|
OPEN,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반열림 - 복구 시도 중
|
||||||
|
*/
|
||||||
|
HALF_OPEN
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.kt.ai.model.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 메커니즘 타입
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public enum EventMechanicsType {
|
||||||
|
/**
|
||||||
|
* 할인형 이벤트
|
||||||
|
*/
|
||||||
|
DISCOUNT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경품 증정형 이벤트
|
||||||
|
*/
|
||||||
|
GIFT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스탬프 적립형 이벤트
|
||||||
|
*/
|
||||||
|
STAMP,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 체험형 이벤트
|
||||||
|
*/
|
||||||
|
EXPERIENCE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추첨형 이벤트
|
||||||
|
*/
|
||||||
|
LOTTERY,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 묶음 구매형 이벤트
|
||||||
|
*/
|
||||||
|
COMBO
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.kt.ai.model.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 작업 상태
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public enum JobStatus {
|
||||||
|
/**
|
||||||
|
* 대기 중 - Kafka 메시지 수신 후 처리 대기
|
||||||
|
*/
|
||||||
|
PENDING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 중 - AI API 호출 및 분석 진행 중
|
||||||
|
*/
|
||||||
|
PROCESSING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완료 - AI 추천 결과 생성 완료
|
||||||
|
*/
|
||||||
|
COMPLETED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 - AI API 호출 실패 또는 타임아웃
|
||||||
|
*/
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.kt.ai.model.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 상태
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public enum ServiceStatus {
|
||||||
|
/**
|
||||||
|
* 정상 동작
|
||||||
|
*/
|
||||||
|
UP,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 중단
|
||||||
|
*/
|
||||||
|
DOWN,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성능 저하
|
||||||
|
*/
|
||||||
|
DEGRADED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 알 수 없음 (설정되지 않음)
|
||||||
|
*/
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
@ -0,0 +1,418 @@
|
|||||||
|
package com.kt.ai.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.kt.ai.circuitbreaker.CircuitBreakerManager;
|
||||||
|
import com.kt.ai.circuitbreaker.fallback.AIServiceFallback;
|
||||||
|
import com.kt.ai.client.ClaudeApiClient;
|
||||||
|
import com.kt.ai.client.dto.ClaudeRequest;
|
||||||
|
import com.kt.ai.client.dto.ClaudeResponse;
|
||||||
|
import com.kt.ai.exception.RecommendationNotFoundException;
|
||||||
|
import com.kt.ai.kafka.message.AIJobMessage;
|
||||||
|
import com.kt.ai.model.dto.response.AIRecommendationResult;
|
||||||
|
import com.kt.ai.model.dto.response.EventRecommendation;
|
||||||
|
import com.kt.ai.model.dto.response.ExpectedMetrics;
|
||||||
|
import com.kt.ai.model.dto.response.TrendAnalysis;
|
||||||
|
import com.kt.ai.model.enums.AIProvider;
|
||||||
|
import com.kt.ai.model.enums.EventMechanicsType;
|
||||||
|
import com.kt.ai.model.enums.JobStatus;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 서비스
|
||||||
|
* - 트렌드 분석 및 이벤트 추천 총괄
|
||||||
|
* - Claude API 연동
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AIRecommendationService {
|
||||||
|
|
||||||
|
private final CacheService cacheService;
|
||||||
|
private final JobStatusService jobStatusService;
|
||||||
|
private final TrendAnalysisService trendAnalysisService;
|
||||||
|
private final ClaudeApiClient claudeApiClient;
|
||||||
|
private final CircuitBreakerManager circuitBreakerManager;
|
||||||
|
private final AIServiceFallback fallback;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${ai.provider:CLAUDE}")
|
||||||
|
private String aiProvider;
|
||||||
|
|
||||||
|
@Value("${ai.claude.api-key}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Value("${ai.claude.anthropic-version}")
|
||||||
|
private String anthropicVersion;
|
||||||
|
|
||||||
|
@Value("${ai.claude.model}")
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@Value("${ai.claude.max-tokens}")
|
||||||
|
private Integer maxTokens;
|
||||||
|
|
||||||
|
@Value("${ai.claude.temperature}")
|
||||||
|
private Double temperature;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 결과 조회
|
||||||
|
*/
|
||||||
|
public AIRecommendationResult getRecommendation(String eventId) {
|
||||||
|
Object cached = cacheService.getRecommendation(eventId);
|
||||||
|
if (cached == null) {
|
||||||
|
throw new RecommendationNotFoundException(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectMapper.convertValue(cached, AIRecommendationResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 생성 (Kafka Consumer에서 호출)
|
||||||
|
*/
|
||||||
|
public void generateRecommendations(AIJobMessage message) {
|
||||||
|
try {
|
||||||
|
log.info("AI 추천 생성 시작: jobId={}, eventId={}", message.getJobId(), message.getEventId());
|
||||||
|
|
||||||
|
// Job 상태 업데이트: PROCESSING
|
||||||
|
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "트렌드 분석 중 (10%)");
|
||||||
|
|
||||||
|
// 1. 트렌드 분석
|
||||||
|
TrendAnalysis trendAnalysis = analyzeTrend(message);
|
||||||
|
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "이벤트 추천안 생성 중 (50%)");
|
||||||
|
|
||||||
|
// 2. 이벤트 추천안 생성
|
||||||
|
List<EventRecommendation> recommendations = createRecommendations(message, trendAnalysis);
|
||||||
|
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "결과 저장 중 (90%)");
|
||||||
|
|
||||||
|
// 3. 결과 생성 및 저장
|
||||||
|
AIRecommendationResult result = AIRecommendationResult.builder()
|
||||||
|
.eventId(message.getEventId())
|
||||||
|
.trendAnalysis(trendAnalysis)
|
||||||
|
.recommendations(recommendations)
|
||||||
|
.generatedAt(LocalDateTime.now())
|
||||||
|
.expiresAt(LocalDateTime.now().plusDays(1))
|
||||||
|
.aiProvider(AIProvider.valueOf(aiProvider))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 결과 캐싱
|
||||||
|
cacheService.saveRecommendation(message.getEventId(), result);
|
||||||
|
|
||||||
|
// Job 상태 업데이트: COMPLETED
|
||||||
|
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.COMPLETED, "AI 추천 완료");
|
||||||
|
|
||||||
|
log.info("AI 추천 생성 완료: jobId={}, eventId={}", message.getJobId(), message.getEventId());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AI 추천 생성 실패: jobId={}", message.getJobId(), e);
|
||||||
|
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.FAILED, "AI 추천 실패: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트렌드 분석
|
||||||
|
*/
|
||||||
|
private TrendAnalysis analyzeTrend(AIJobMessage message) {
|
||||||
|
String industry = message.getIndustry();
|
||||||
|
String region = message.getRegion();
|
||||||
|
|
||||||
|
// 캐시 확인
|
||||||
|
Object cached = cacheService.getTrend(industry, region);
|
||||||
|
if (cached != null) {
|
||||||
|
log.info("트렌드 분석 캐시 히트 - industry={}, region={}", industry, region);
|
||||||
|
return objectMapper.convertValue(cached, TrendAnalysis.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendAnalysisService를 통한 실제 분석
|
||||||
|
log.info("트렌드 분석 시작 - industry={}, region={}", industry, region);
|
||||||
|
TrendAnalysis analysis = trendAnalysisService.analyzeTrend(industry, region);
|
||||||
|
|
||||||
|
// 캐시 저장
|
||||||
|
cacheService.saveTrend(industry, region, analysis);
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 추천안 생성
|
||||||
|
*/
|
||||||
|
private List<EventRecommendation> createRecommendations(AIJobMessage message, TrendAnalysis trendAnalysis) {
|
||||||
|
log.info("이벤트 추천안 생성 시작 - eventId={}", message.getEventId());
|
||||||
|
|
||||||
|
return circuitBreakerManager.executeWithCircuitBreaker(
|
||||||
|
"claudeApi",
|
||||||
|
() -> callClaudeApiForRecommendations(message, trendAnalysis),
|
||||||
|
() -> fallback.getDefaultRecommendations(message.getObjective(), message.getIndustry())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude API를 통한 추천안 생성
|
||||||
|
*/
|
||||||
|
private List<EventRecommendation> callClaudeApiForRecommendations(AIJobMessage message, TrendAnalysis trendAnalysis) {
|
||||||
|
// 프롬프트 생성
|
||||||
|
String prompt = buildRecommendationPrompt(message, trendAnalysis);
|
||||||
|
|
||||||
|
// Claude API 요청 생성
|
||||||
|
ClaudeRequest request = ClaudeRequest.builder()
|
||||||
|
.model(model)
|
||||||
|
.messages(List.of(
|
||||||
|
ClaudeRequest.Message.builder()
|
||||||
|
.role("user")
|
||||||
|
.content(prompt)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.maxTokens(maxTokens)
|
||||||
|
.temperature(temperature)
|
||||||
|
.system("당신은 소상공인을 위한 마케팅 이벤트 기획 전문가입니다. 트렌드 분석을 바탕으로 실행 가능한 이벤트 추천안을 제공합니다.")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
log.debug("Claude API 호출 (추천안 생성) - model={}", model);
|
||||||
|
ClaudeResponse response = claudeApiClient.sendMessage(
|
||||||
|
apiKey,
|
||||||
|
anthropicVersion,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
// 응답 파싱
|
||||||
|
String responseText = response.extractText();
|
||||||
|
log.debug("Claude API 응답 수신 (추천안) - length={}", responseText.length());
|
||||||
|
|
||||||
|
return parseRecommendationResponse(responseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천안 프롬프트 생성
|
||||||
|
*/
|
||||||
|
private String buildRecommendationPrompt(AIJobMessage message, TrendAnalysis trendAnalysis) {
|
||||||
|
StringBuilder trendSummary = new StringBuilder();
|
||||||
|
|
||||||
|
trendSummary.append("**업종 트렌드:**\n");
|
||||||
|
trendAnalysis.getIndustryTrends().forEach(trend ->
|
||||||
|
trendSummary.append(String.format("- %s (연관도: %.2f): %s\n",
|
||||||
|
trend.getKeyword(), trend.getRelevance(), trend.getDescription()))
|
||||||
|
);
|
||||||
|
|
||||||
|
trendSummary.append("\n**지역 트렌드:**\n");
|
||||||
|
trendAnalysis.getRegionalTrends().forEach(trend ->
|
||||||
|
trendSummary.append(String.format("- %s (연관도: %.2f): %s\n",
|
||||||
|
trend.getKeyword(), trend.getRelevance(), trend.getDescription()))
|
||||||
|
);
|
||||||
|
|
||||||
|
trendSummary.append("\n**계절 트렌드:**\n");
|
||||||
|
trendAnalysis.getSeasonalTrends().forEach(trend ->
|
||||||
|
trendSummary.append(String.format("- %s (연관도: %.2f): %s\n",
|
||||||
|
trend.getKeyword(), trend.getRelevance(), trend.getDescription()))
|
||||||
|
);
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
# 이벤트 추천안 생성 요청
|
||||||
|
|
||||||
|
## 고객 정보
|
||||||
|
- 매장명: %s
|
||||||
|
- 업종: %s
|
||||||
|
- 지역: %s
|
||||||
|
- 목표: %s
|
||||||
|
- 타겟 고객: %s
|
||||||
|
- 예산: %,d원
|
||||||
|
|
||||||
|
## 트렌드 분석 결과
|
||||||
|
%s
|
||||||
|
|
||||||
|
## 요구사항
|
||||||
|
위 트렌드 분석을 바탕으로 **3가지 이벤트 추천안**을 생성해주세요:
|
||||||
|
1. **저비용 옵션** (100,000 ~ 200,000원): SNS/온라인 중심
|
||||||
|
2. **중비용 옵션** (300,000 ~ 500,000원): 온/오프라인 결합
|
||||||
|
3. **고비용 옵션** (500,000 ~ 1,000,000원): 프리미엄 경험 제공
|
||||||
|
|
||||||
|
## 응답 형식
|
||||||
|
응답은 반드시 다음 JSON 형식으로 작성해주세요:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
{
|
||||||
|
"optionNumber": 1,
|
||||||
|
"concept": "이벤트 컨셉 (10자 이내)",
|
||||||
|
"title": "이벤트 제목 (20자 이내)",
|
||||||
|
"description": "이벤트 상세 설명 (3-5문장)",
|
||||||
|
"targetAudience": "타겟 고객층",
|
||||||
|
"duration": {
|
||||||
|
"recommendedDays": 14,
|
||||||
|
"recommendedPeriod": "2주"
|
||||||
|
},
|
||||||
|
"mechanics": {
|
||||||
|
"type": "DISCOUNT",
|
||||||
|
"details": "이벤트 참여 방법 및 혜택 상세"
|
||||||
|
},
|
||||||
|
"promotionChannels": ["채널1", "채널2", "채널3"],
|
||||||
|
"estimatedCost": {
|
||||||
|
"min": 100000,
|
||||||
|
"max": 200000,
|
||||||
|
"breakdown": {
|
||||||
|
"경품비": 50000,
|
||||||
|
"홍보비": 50000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expectedMetrics": {
|
||||||
|
"newCustomers": { "min": 30.0, "max": 50.0 },
|
||||||
|
"revenueIncrease": { "min": 10.0, "max": 20.0 },
|
||||||
|
"roi": { "min": 100.0, "max": 150.0 }
|
||||||
|
},
|
||||||
|
"differentiator": "차별화 포인트 (2-3문장)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## mechanics.type 값
|
||||||
|
- DISCOUNT: 할인
|
||||||
|
- GIFT: 경품/사은품
|
||||||
|
- STAMP: 스탬프 적립
|
||||||
|
- EXPERIENCE: 체험형 이벤트
|
||||||
|
- LOTTERY: 추첨 이벤트
|
||||||
|
- COMBO: 결합 혜택
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
- 각 옵션은 예산 범위 내에서 실행 가능해야 함
|
||||||
|
- 트렌드 분석 결과를 반영한 구체적인 기획
|
||||||
|
- 타겟 고객과 지역 특성을 고려
|
||||||
|
- expectedMetrics는 백분율(%%로 표기)
|
||||||
|
- promotionChannels는 실제 활용 가능한 채널로 제시
|
||||||
|
""",
|
||||||
|
message.getStoreName(),
|
||||||
|
message.getIndustry(),
|
||||||
|
message.getRegion(),
|
||||||
|
message.getObjective(),
|
||||||
|
message.getTargetAudience(),
|
||||||
|
message.getBudget(),
|
||||||
|
trendSummary.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천안 응답 파싱
|
||||||
|
*/
|
||||||
|
private List<EventRecommendation> parseRecommendationResponse(String responseText) {
|
||||||
|
try {
|
||||||
|
// JSON 부분만 추출
|
||||||
|
String jsonText = extractJsonFromMarkdown(responseText);
|
||||||
|
|
||||||
|
// JSON 파싱
|
||||||
|
JsonNode rootNode = objectMapper.readTree(jsonText);
|
||||||
|
JsonNode recommendationsNode = rootNode.get("recommendations");
|
||||||
|
|
||||||
|
List<EventRecommendation> recommendations = new ArrayList<>();
|
||||||
|
|
||||||
|
if (recommendationsNode != null && recommendationsNode.isArray()) {
|
||||||
|
recommendationsNode.forEach(node -> {
|
||||||
|
recommendations.add(parseEventRecommendation(node));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("추천안 응답 파싱 실패", e);
|
||||||
|
throw new RuntimeException("이벤트 추천안 응답 파싱 중 오류 발생", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventRecommendation 파싱
|
||||||
|
*/
|
||||||
|
private EventRecommendation parseEventRecommendation(JsonNode node) {
|
||||||
|
// Mechanics Type 파싱
|
||||||
|
String mechanicsTypeStr = node.get("mechanics").get("type").asText();
|
||||||
|
EventMechanicsType mechanicsType = EventMechanicsType.valueOf(mechanicsTypeStr);
|
||||||
|
|
||||||
|
// Promotion Channels 파싱
|
||||||
|
List<String> promotionChannels = new ArrayList<>();
|
||||||
|
JsonNode channelsNode = node.get("promotionChannels");
|
||||||
|
if (channelsNode != null && channelsNode.isArray()) {
|
||||||
|
channelsNode.forEach(channel -> promotionChannels.add(channel.asText()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breakdown 파싱
|
||||||
|
Map<String, Integer> breakdown = new HashMap<>();
|
||||||
|
JsonNode breakdownNode = node.get("estimatedCost").get("breakdown");
|
||||||
|
if (breakdownNode != null && breakdownNode.isObject()) {
|
||||||
|
breakdownNode.fields().forEachRemaining(entry ->
|
||||||
|
breakdown.put(entry.getKey(), entry.getValue().asInt())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventRecommendation.builder()
|
||||||
|
.optionNumber(node.get("optionNumber").asInt())
|
||||||
|
.concept(node.get("concept").asText())
|
||||||
|
.title(node.get("title").asText())
|
||||||
|
.description(node.get("description").asText())
|
||||||
|
.targetAudience(node.get("targetAudience").asText())
|
||||||
|
.duration(EventRecommendation.Duration.builder()
|
||||||
|
.recommendedDays(node.get("duration").get("recommendedDays").asInt())
|
||||||
|
.recommendedPeriod(node.get("duration").get("recommendedPeriod").asText())
|
||||||
|
.build())
|
||||||
|
.mechanics(EventRecommendation.Mechanics.builder()
|
||||||
|
.type(mechanicsType)
|
||||||
|
.details(node.get("mechanics").get("details").asText())
|
||||||
|
.build())
|
||||||
|
.promotionChannels(promotionChannels)
|
||||||
|
.estimatedCost(EventRecommendation.EstimatedCost.builder()
|
||||||
|
.min(node.get("estimatedCost").get("min").asInt())
|
||||||
|
.max(node.get("estimatedCost").get("max").asInt())
|
||||||
|
.breakdown(breakdown)
|
||||||
|
.build())
|
||||||
|
.expectedMetrics(ExpectedMetrics.builder()
|
||||||
|
.newCustomers(parseRange(node.get("expectedMetrics").get("newCustomers")))
|
||||||
|
.revenueIncrease(parseRange(node.get("expectedMetrics").get("revenueIncrease")))
|
||||||
|
.roi(parseRange(node.get("expectedMetrics").get("roi")))
|
||||||
|
.build())
|
||||||
|
.differentiator(node.get("differentiator").asText())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Range 파싱
|
||||||
|
*/
|
||||||
|
private ExpectedMetrics.Range parseRange(JsonNode node) {
|
||||||
|
return ExpectedMetrics.Range.builder()
|
||||||
|
.min(node.get("min").asDouble())
|
||||||
|
.max(node.get("max").asDouble())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown에서 JSON 추출
|
||||||
|
*/
|
||||||
|
private String extractJsonFromMarkdown(String text) {
|
||||||
|
// ```json ... ``` 형태에서 JSON만 추출
|
||||||
|
if (text.contains("```json")) {
|
||||||
|
int start = text.indexOf("```json") + 7;
|
||||||
|
int end = text.indexOf("```", start);
|
||||||
|
return text.substring(start, end).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ```{ ... }``` 형태에서 JSON만 추출
|
||||||
|
if (text.contains("```")) {
|
||||||
|
int start = text.indexOf("```") + 3;
|
||||||
|
int end = text.indexOf("```", start);
|
||||||
|
return text.substring(start, end).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 순수 JSON인 경우
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
134
ai-service/src/main/java/com/kt/ai/service/CacheService.java
Normal file
134
ai-service/src/main/java/com/kt/ai/service/CacheService.java
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package com.kt.ai.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 캐시 서비스
|
||||||
|
* - Job 상태 관리
|
||||||
|
* - AI 추천 결과 캐싱
|
||||||
|
* - 트렌드 분석 결과 캐싱
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CacheService {
|
||||||
|
|
||||||
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
@Value("${cache.ttl.recommendation:86400}")
|
||||||
|
private long recommendationTtl;
|
||||||
|
|
||||||
|
@Value("${cache.ttl.job-status:86400}")
|
||||||
|
private long jobStatusTtl;
|
||||||
|
|
||||||
|
@Value("${cache.ttl.trend:3600}")
|
||||||
|
private long trendTtl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 저장
|
||||||
|
*
|
||||||
|
* @param key Redis Key
|
||||||
|
* @param value 저장할 값
|
||||||
|
* @param ttlSeconds TTL (초)
|
||||||
|
*/
|
||||||
|
public void set(String key, Object value, long ttlSeconds) {
|
||||||
|
try {
|
||||||
|
redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
|
||||||
|
log.debug("캐시 저장 성공: key={}, ttl={}초", key, ttlSeconds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("캐시 저장 실패: key={}", key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 조회
|
||||||
|
*
|
||||||
|
* @param key Redis Key
|
||||||
|
* @return 캐시된 값 (없으면 null)
|
||||||
|
*/
|
||||||
|
public Object get(String key) {
|
||||||
|
try {
|
||||||
|
Object value = redisTemplate.opsForValue().get(key);
|
||||||
|
if (value != null) {
|
||||||
|
log.debug("캐시 조회 성공: key={}", key);
|
||||||
|
} else {
|
||||||
|
log.debug("캐시 미스: key={}", key);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("캐시 조회 실패: key={}", key, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 삭제
|
||||||
|
*
|
||||||
|
* @param key Redis Key
|
||||||
|
*/
|
||||||
|
public void delete(String key) {
|
||||||
|
try {
|
||||||
|
redisTemplate.delete(key);
|
||||||
|
log.debug("캐시 삭제 성공: key={}", key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("캐시 삭제 실패: key={}", key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 저장
|
||||||
|
*/
|
||||||
|
public void saveJobStatus(String jobId, Object status) {
|
||||||
|
String key = "ai:job:status:" + jobId;
|
||||||
|
set(key, status, jobStatusTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 조회
|
||||||
|
*/
|
||||||
|
public Object getJobStatus(String jobId) {
|
||||||
|
String key = "ai:job:status:" + jobId;
|
||||||
|
return get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 결과 저장
|
||||||
|
*/
|
||||||
|
public void saveRecommendation(String eventId, Object recommendation) {
|
||||||
|
String key = "ai:recommendation:" + eventId;
|
||||||
|
set(key, recommendation, recommendationTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 결과 조회
|
||||||
|
*/
|
||||||
|
public Object getRecommendation(String eventId) {
|
||||||
|
String key = "ai:recommendation:" + eventId;
|
||||||
|
return get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트렌드 분석 결과 저장
|
||||||
|
*/
|
||||||
|
public void saveTrend(String industry, String region, Object trend) {
|
||||||
|
String key = "ai:trend:" + industry + ":" + region;
|
||||||
|
set(key, trend, trendTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트렌드 분석 결과 조회
|
||||||
|
*/
|
||||||
|
public Object getTrend(String industry, String region) {
|
||||||
|
String key = "ai:trend:" + industry + ":" + region;
|
||||||
|
return get(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.kt.ai.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.kt.ai.exception.JobNotFoundException;
|
||||||
|
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||||
|
import com.kt.ai.model.enums.JobStatus;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 관리 서비스
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JobStatusService {
|
||||||
|
|
||||||
|
private final CacheService cacheService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 조회
|
||||||
|
*/
|
||||||
|
public JobStatusResponse getJobStatus(String jobId) {
|
||||||
|
Object cached = cacheService.getJobStatus(jobId);
|
||||||
|
if (cached == null) {
|
||||||
|
throw new JobNotFoundException(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectMapper.convertValue(cached, JobStatusResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 업데이트
|
||||||
|
*/
|
||||||
|
public void updateJobStatus(String jobId, JobStatus status, String message) {
|
||||||
|
JobStatusResponse response = JobStatusResponse.builder()
|
||||||
|
.jobId(jobId)
|
||||||
|
.status(status)
|
||||||
|
.progress(calculateProgress(status))
|
||||||
|
.message(message)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
cacheService.saveJobStatus(jobId, response);
|
||||||
|
log.info("Job 상태 업데이트: jobId={}, status={}", jobId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int calculateProgress(JobStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
case PENDING -> 0;
|
||||||
|
case PROCESSING -> 50;
|
||||||
|
case COMPLETED -> 100;
|
||||||
|
case FAILED -> 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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