Compare commits
27 Commits
docker/ana
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4fcc0dc3 | ||
|
|
de32a70f29 | ||
|
|
429f737066 | ||
|
|
7a99dc95fe | ||
|
|
d56ff7684b | ||
|
|
c152faff54 | ||
|
|
ee664a6134 | ||
|
|
50043add5d | ||
|
|
397a23063d | ||
|
|
5f8bd7cf68 | ||
|
|
bea547a463 | ||
|
|
c126c71e00 | ||
|
|
29dddd89b7 | ||
|
|
e0fc4286c7 | ||
|
|
060921e756 | ||
|
|
2da2f124a2 | ||
|
|
b198c46d06 | ||
|
|
003b3843cc | ||
|
|
453f77ef01 | ||
|
|
375fcb390b | ||
|
|
8323b795df | ||
|
|
ce3e01008a | ||
|
|
ea807cf33e | ||
|
|
394c7a0029 | ||
|
|
e70f121db5 | ||
|
|
6465719b2c | ||
|
|
f0699b2e2b |
14
.claude/commands/deploy-actions-cicd-guide-back.md
Normal file
14
.claude/commands/deploy-actions-cicd-guide-back.md
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
command: "/deploy-actions-cicd-guide-back"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- ACR_NAME: acrdigitalgarage01
|
||||
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||
- AKS_CLUSTER: aks-digitalgarage-01
|
||||
- NAMESPACE: phonebill-dg0500
|
||||
15
.claude/commands/deploy-actions-cicd-guide-front.md
Normal file
15
.claude/commands/deploy-actions-cicd-guide-front.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
command: "/deploy-actions-cicd-guide-front"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'프론트엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- SYSTEM_NAME: phonebill
|
||||
- ACR_NAME: acrdigitalgarage01
|
||||
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||
- AKS_CLUSTER: aks-digitalgarage-01
|
||||
- NAMESPACE: phonebill-dg0500
|
||||
6
.claude/commands/deploy-build-image-back.md
Normal file
6
.claude/commands/deploy-build-image-back.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
command: "/deploy-build-image-back"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
|
||||
6
.claude/commands/deploy-build-image-front.md
Normal file
6
.claude/commands/deploy-build-image-front.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
command: "/deploy-build-image-front"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'프론트엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.
|
||||
81
.claude/commands/deploy-help.md
Normal file
81
.claude/commands/deploy-help.md
Normal file
@ -0,0 +1,81 @@
|
||||
---
|
||||
command: "/deploy-help"
|
||||
---
|
||||
|
||||
# 배포 작업 순서
|
||||
|
||||
## 1단계: 컨테이너 이미지 작성
|
||||
### 백엔드
|
||||
```
|
||||
/deploy-build-image-back
|
||||
```
|
||||
- 백엔드컨테이너이미지작성가이드를 참고하여 컨테이너 이미지를 빌드합니다
|
||||
|
||||
### 프론트엔드
|
||||
```
|
||||
/deploy-build-image-front
|
||||
```
|
||||
- 프론트엔드컨테이너이미지작성가이드를 참고하여 컨테이너 이미지를 빌드합니다
|
||||
|
||||
## 2단계: 컨테이너 실행 가이드 작성
|
||||
### 백엔드
|
||||
```
|
||||
/deploy-run-container-guide-back
|
||||
```
|
||||
- 백엔드컨테이너실행방법가이드를 참고하여 컨테이너 실행 방법을 작성합니다
|
||||
- 실행정보(ACR명, VM정보)가 필요합니다
|
||||
|
||||
### 프론트엔드
|
||||
```
|
||||
/deploy-run-container-guide-front
|
||||
```
|
||||
- 프론트엔드컨테이너실행방법가이드를 참고하여 컨테이너 실행 방법을 작성합니다
|
||||
- 실행정보(시스템명, ACR명, VM정보)가 필요합니다
|
||||
|
||||
## 3단계: Kubernetes 배포 가이드 작성
|
||||
### 백엔드
|
||||
```
|
||||
/deploy-k8s-guide-back
|
||||
```
|
||||
- 백엔드배포가이드를 참고하여 쿠버네티스 배포 방법을 작성합니다
|
||||
- 실행정보(ACR명, k8s명, 네임스페이스, 리소스 설정)가 필요합니다
|
||||
|
||||
### 프론트엔드
|
||||
```
|
||||
/deploy-k8s-guide-front
|
||||
```
|
||||
- 프론트엔드배포가이드를 참고하여 쿠버네티스 배포 방법을 작성합니다
|
||||
- 실행정보(시스템명, ACR명, k8s명, 네임스페이스, Gateway Host, 리소스 설정)가 필요합니다
|
||||
|
||||
## 4단계: CI/CD 파이프라인 구성
|
||||
|
||||
### Jenkins 사용 시
|
||||
#### 백엔드
|
||||
```
|
||||
/deploy-jenkins-cicd-guide-back
|
||||
```
|
||||
- 백엔드Jenkins파이프라인작성가이드를 참고하여 Jenkins CI/CD 파이프라인을 구성합니다
|
||||
|
||||
#### 프론트엔드
|
||||
```
|
||||
/deploy-jenkins-cicd-guide-front
|
||||
```
|
||||
- 프론트엔드Jenkins파이프라인작성가이드를 참고하여 Jenkins CI/CD 파이프라인을 구성합니다
|
||||
|
||||
### GitHub Actions 사용 시
|
||||
#### 백엔드
|
||||
```
|
||||
/deploy-actions-cicd-guide-back
|
||||
```
|
||||
- 백엔드GitHubActions파이프라인작성가이드를 참고하여 GitHub Actions CI/CD 파이프라인을 구성합니다
|
||||
|
||||
#### 프론트엔드
|
||||
```
|
||||
/deploy-actions-cicd-guide-front
|
||||
```
|
||||
- 프론트엔드GitHubActions파이프라인작성가이드를 참고하여 GitHub Actions CI/CD 파이프라인을 구성합니다
|
||||
|
||||
## 참고사항
|
||||
- 각 명령 실행 전 필요한 실행정보를 프롬프트에 포함해야 합니다
|
||||
- 실행정보가 없으면 안내 메시지가 표시되며 작업이 중단됩니다
|
||||
- CI/CD 도구는 Jenkins 또는 GitHub Actions 중 선택하여 사용합니다
|
||||
14
.claude/commands/deploy-jenkins-cicd-guide-back.md
Normal file
14
.claude/commands/deploy-jenkins-cicd-guide-back.md
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
command: "/deploy-jenkins-cicd-guide-back"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- ACR_NAME: acrdigitalgarage01
|
||||
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||
- AKS_CLUSTER: aks-digitalgarage-01
|
||||
- NAMESPACE: phonebill-dg0500
|
||||
15
.claude/commands/deploy-jenkins-cicd-guide-front.md
Normal file
15
.claude/commands/deploy-jenkins-cicd-guide-front.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
command: "/deploy-jenkins-cicd-guide-front"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'프론트엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- SYSTEM_NAME: phonebill
|
||||
- ACR_NAME: acrdigitalgarage01
|
||||
- RESOURCE_GROUP: rg-digitalgarage-01
|
||||
- AKS_CLUSTER: aks-digitalgarage-01
|
||||
- NAMESPACE: phonebill-dg0500
|
||||
16
.claude/commands/deploy-k8s-guide-back.md
Normal file
16
.claude/commands/deploy-k8s-guide-back.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
command: "/deploy-k8s-guide-back"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드배포가이드'에 따라 백엔드 서비스 배포 방법을 작성해 주세요.
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- ACR명: acrdigitalgarage01
|
||||
- k8s명: aks-digitalgarage-01
|
||||
- 네임스페이스: tripgen
|
||||
- 파드수: 2
|
||||
- 리소스(CPU): 256m/1024m
|
||||
- 리소스(메모리): 256Mi/1024Mi
|
||||
18
.claude/commands/deploy-k8s-guide-front.md
Normal file
18
.claude/commands/deploy-k8s-guide-front.md
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
command: "/deploy-k8s-guide-front"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'프론트엔드배포가이드'에 따라 프론트엔드 서비스 배포 방법을 작성해 주세요.
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- 시스템명: tripgen
|
||||
- ACR명: acrdigitalgarage01
|
||||
- k8s명: aks-digitalgarage-01
|
||||
- 네임스페이스: tripgen
|
||||
- 파드수: 2
|
||||
- 리소스(CPU): 256m/1024m
|
||||
- 리소스(메모리): 256Mi/1024Mi
|
||||
- Gateway Host: http://tripgen-api.20.214.196.128.nip.io
|
||||
15
.claude/commands/deploy-run-container-guide-back.md
Normal file
15
.claude/commands/deploy-run-container-guide-back.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
command: "/deploy-run-container-guide-back"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'백엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- ACR명: acrdigitalgarage01
|
||||
- VM
|
||||
- KEY파일: ~/home/bastion-dg0500
|
||||
- USERID: azureuser
|
||||
- IP: 4.230.5.6
|
||||
16
.claude/commands/deploy-run-container-guide-front.md
Normal file
16
.claude/commands/deploy-run-container-guide-front.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
command: "/deploy-run-container-guide-front"
|
||||
---
|
||||
|
||||
@cicd
|
||||
'프론트엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
|
||||
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
|
||||
[실행정보]
|
||||
- 시스템명: tripgen
|
||||
- ACR명: acrdigitalgarage01
|
||||
- VM
|
||||
- KEY파일: ~/home/bastion-dg0500
|
||||
- USERID: azureuser
|
||||
- IP: 4.230.5.6
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-api"
|
||||
---
|
||||
@architecture
|
||||
API를 설계해 주세요:
|
||||
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-class"
|
||||
---
|
||||
@architecture
|
||||
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
|
||||
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-data"
|
||||
---
|
||||
@architecture
|
||||
데이터 설계를 해주세요:
|
||||
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-fix-prototype"
|
||||
---
|
||||
@fix as @front
|
||||
'[오류내용]'섹션에 제공된 오류를 해결해 주세요.
|
||||
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-front"
|
||||
---
|
||||
@plan as @front
|
||||
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
|
||||
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-high-level"
|
||||
---
|
||||
@architecture
|
||||
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
|
||||
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-improve-prototype"
|
||||
---
|
||||
@improve as @front
|
||||
'[개선내용]'섹션에 있는 내용을 개선해 주세요.
|
||||
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
|
||||
|
||||
@ -1,2 +1,5 @@
|
||||
---
|
||||
command: "/design-improve-userstory"
|
||||
---
|
||||
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
|
||||
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-logical"
|
||||
---
|
||||
@architecture
|
||||
논리 아키텍처를 설계해 주세요:
|
||||
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-pattern"
|
||||
---
|
||||
@design-pattern
|
||||
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
|
||||
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-physical"
|
||||
---
|
||||
@architecture
|
||||
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
|
||||
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-prototype"
|
||||
---
|
||||
@prototype
|
||||
프로토타입을 작성해 주세요:
|
||||
- '프로토타입작성가이드'를 준용하여 작성
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-seq-inner"
|
||||
---
|
||||
@architecture
|
||||
내부 시퀀스 설계를 해 주세요:
|
||||
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-seq-outer"
|
||||
---
|
||||
@architecture
|
||||
외부 시퀀스 설계를 해 주세요:
|
||||
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
|
||||
@ -1,2 +1,5 @@
|
||||
---
|
||||
command: "/design-test-prototype"
|
||||
---
|
||||
@test-front
|
||||
프로토타입을 테스트 해 주세요.
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/design-uiux"
|
||||
---
|
||||
@uiux
|
||||
UI/UX 설계를 해주세요:
|
||||
- 'UI/UX설계가이드'를 준용하여 작성
|
||||
@ -1,2 +1,5 @@
|
||||
---
|
||||
command: "/design-update-uiux"
|
||||
---
|
||||
@document @front
|
||||
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/think-help"
|
||||
---
|
||||
기획 작업 순서
|
||||
|
||||
1단계: 서비스 기획
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
---
|
||||
command: "/think-planning"
|
||||
---
|
||||
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
|
||||
```
|
||||
아래 가이드를 참고하여 서비스 기획을 수행합니다.
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
---
|
||||
command: "/think-userstory"
|
||||
---
|
||||
```
|
||||
@document
|
||||
유저스토리를 작성하세요.
|
||||
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
@ -16,3 +20,5 @@ Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을
|
||||
2. 유저스토리 작성
|
||||
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
|
||||
- 결과파일은 'design/userstory.md'에 생성
|
||||
|
||||
```
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -8,6 +8,7 @@ yarn-error.log*
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.run/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@ -31,6 +32,13 @@ logs/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Gradle
|
||||
.gradle/
|
||||
gradle-app.setting
|
||||
!gradle-wrapper.jar
|
||||
!gradle-wrapper.properties
|
||||
.gradletasknamecache
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="EventServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" folderName="Event Service">
|
||||
<option name="ACTIVE_PROFILES" />
|
||||
<option name="ENABLE_LAUNCH_OPTIMIZATION" value="true" />
|
||||
<envs>
|
||||
<env name="DB_HOST" value="20.249.177.232" />
|
||||
<env name="DB_PORT" value="5432" />
|
||||
<env name="DB_NAME" value="eventdb" />
|
||||
<env name="DB_USERNAME" value="eventuser" />
|
||||
<env name="DB_PASSWORD" value="Hi5Jessica!" />
|
||||
<env name="REDIS_HOST" value="localhost" />
|
||||
<env name="REDIS_PORT" value="6379" />
|
||||
<env name="REDIS_PASSWORD" value="" />
|
||||
<env name="KAFKA_BOOTSTRAP_SERVERS" value="localhost:9092" />
|
||||
<env name="SERVER_PORT" value="8081" />
|
||||
<env name="DDL_AUTO" value="update" />
|
||||
<env name="LOG_LEVEL" value="DEBUG" />
|
||||
<env name="SQL_LOG_LEVEL" value="DEBUG" />
|
||||
<env name="DISTRIBUTION_SERVICE_URL" value="http://localhost:8084" />
|
||||
</envs>
|
||||
<module name="kt-event-marketing.event-service.main" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.event.eventservice.EventServiceApplication" />
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@ -43,7 +43,7 @@
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value="participation-service:bootRun" />
|
||||
<option value=":participation-service:bootRun" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" />
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="analytics-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="env">
|
||||
<map>
|
||||
<!-- Database Settings -->
|
||||
<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!" />
|
||||
|
||||
<!-- Redis Settings -->
|
||||
<entry key="REDIS_HOST" value="20.214.210.71" />
|
||||
<entry key="REDIS_PORT" value="6379" />
|
||||
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||
<entry key="REDIS_DATABASE" value="5" />
|
||||
|
||||
<!-- Kafka Settings -->
|
||||
<entry key="KAFKA_ENABLED" value="true" />
|
||||
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
|
||||
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service" />
|
||||
|
||||
<!-- Sample Data Settings (MVP Only) -->
|
||||
<!-- ⚠️ 실제 운영 환경에서는 false로 설정 (다른 서비스들이 이벤트 발행) -->
|
||||
<entry key="SAMPLE_DATA_ENABLED" value="true" />
|
||||
|
||||
<!-- JPA Settings -->
|
||||
<entry key="SHOW_SQL" value="true" />
|
||||
<entry key="DDL_AUTO" value="update" />
|
||||
|
||||
<!-- Server Settings -->
|
||||
<entry key="SERVER_PORT" value="8086" />
|
||||
|
||||
<!-- JWT Settings -->
|
||||
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only-analytics-service-2024" />
|
||||
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="1800" />
|
||||
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
|
||||
|
||||
<!-- CORS Settings -->
|
||||
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
|
||||
|
||||
<!-- Logging Settings -->
|
||||
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_WEB" value="INFO" />
|
||||
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
|
||||
<entry key="LOG_FILE" value="logs/analytics-service.log" />
|
||||
|
||||
<!-- Batch Settings -->
|
||||
<entry key="BATCH_ENABLED" value="true" />
|
||||
<entry key="BATCH_REFRESH_INTERVAL" value="300000" />
|
||||
<entry key="BATCH_INITIAL_DELAY" value="30000" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="executionName" />
|
||||
<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>
|
||||
@ -2,8 +2,8 @@ dependencies {
|
||||
// Kafka Consumer
|
||||
implementation 'org.springframework.kafka:spring-kafka'
|
||||
|
||||
// Redis for result caching
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
// Redis for result caching (already in root build.gradle)
|
||||
// implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
// OpenFeign for Claude/GPT API
|
||||
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
|
||||
@ -14,4 +14,20 @@ dependencies {
|
||||
|
||||
// Jackson for JSON
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,222 @@
|
||||
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.model.dto.response.TrendAnalysis;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 트렌드 분석 서비스
|
||||
* - Claude AI를 통한 업종/지역/계절 트렌드 분석
|
||||
* - Circuit Breaker 적용
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TrendAnalysisService {
|
||||
|
||||
private final ClaudeApiClient claudeApiClient;
|
||||
private final CircuitBreakerManager circuitBreakerManager;
|
||||
private final AIServiceFallback fallback;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@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;
|
||||
|
||||
/**
|
||||
* 트렌드 분석 수행
|
||||
*
|
||||
* @param industry 업종
|
||||
* @param region 지역
|
||||
* @return 트렌드 분석 결과
|
||||
*/
|
||||
public TrendAnalysis analyzeTrend(String industry, String region) {
|
||||
log.info("트렌드 분석 시작 - industry={}, region={}", industry, region);
|
||||
|
||||
return circuitBreakerManager.executeWithCircuitBreaker(
|
||||
"claudeApi",
|
||||
() -> callClaudeApi(industry, region),
|
||||
() -> fallback.getDefaultTrendAnalysis(industry, region)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude API 호출
|
||||
*/
|
||||
private TrendAnalysis callClaudeApi(String industry, String region) {
|
||||
// 프롬프트 생성
|
||||
String prompt = buildPrompt(industry, region);
|
||||
|
||||
// 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 parseResponse(responseText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프롬프트 생성
|
||||
*/
|
||||
private String buildPrompt(String industry, String region) {
|
||||
return String.format("""
|
||||
# 트렌드 분석 요청
|
||||
|
||||
다음 조건에 맞는 마케팅 트렌드를 분석해주세요:
|
||||
- 업종: %s
|
||||
- 지역: %s
|
||||
|
||||
## 분석 요구사항
|
||||
1. **업종 트렌드**: 해당 업종에서 현재 주목받는 마케팅 트렌드 3개
|
||||
2. **지역 트렌드**: 해당 지역의 특성과 소비자 성향을 반영한 트렌드 2개
|
||||
3. **계절 트렌드**: 현재 계절(또는 다가오는 시즌)에 적합한 트렌드 2개
|
||||
|
||||
## 응답 형식
|
||||
응답은 반드시 다음 JSON 형식으로 작성해주세요:
|
||||
|
||||
```json
|
||||
{
|
||||
"industryTrends": [
|
||||
{
|
||||
"keyword": "트렌드 키워드",
|
||||
"relevance": 0.9,
|
||||
"description": "트렌드에 대한 상세 설명 (2-3문장)"
|
||||
}
|
||||
],
|
||||
"regionalTrends": [
|
||||
{
|
||||
"keyword": "트렌드 키워드",
|
||||
"relevance": 0.85,
|
||||
"description": "트렌드에 대한 상세 설명 (2-3문장)"
|
||||
}
|
||||
],
|
||||
"seasonalTrends": [
|
||||
{
|
||||
"keyword": "트렌드 키워드",
|
||||
"relevance": 0.8,
|
||||
"description": "트렌드에 대한 상세 설명 (2-3문장)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
- relevance 값은 0.0 ~ 1.0 사이의 소수점 값
|
||||
- description은 구체적이고 실행 가능한 인사이트 포함
|
||||
- 한국 시장과 문화를 고려한 분석
|
||||
""", industry, region);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude 응답 파싱
|
||||
*/
|
||||
private TrendAnalysis parseResponse(String responseText) {
|
||||
try {
|
||||
// JSON 부분만 추출 (```json ... ``` 형태로 올 수 있음)
|
||||
String jsonText = extractJsonFromMarkdown(responseText);
|
||||
|
||||
// JSON 파싱
|
||||
JsonNode rootNode = objectMapper.readTree(jsonText);
|
||||
|
||||
// TrendAnalysis 객체 생성
|
||||
return TrendAnalysis.builder()
|
||||
.industryTrends(parseTrendKeywords(rootNode.get("industryTrends")))
|
||||
.regionalTrends(parseTrendKeywords(rootNode.get("regionalTrends")))
|
||||
.seasonalTrends(parseTrendKeywords(rootNode.get("seasonalTrends")))
|
||||
.build();
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("응답 파싱 실패", e);
|
||||
throw new RuntimeException("트렌드 분석 응답 파싱 중 오류 발생", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* TrendKeyword 리스트 파싱
|
||||
*/
|
||||
private List<TrendAnalysis.TrendKeyword> parseTrendKeywords(JsonNode arrayNode) {
|
||||
List<TrendAnalysis.TrendKeyword> keywords = new ArrayList<>();
|
||||
|
||||
if (arrayNode != null && arrayNode.isArray()) {
|
||||
arrayNode.forEach(node -> {
|
||||
keywords.add(TrendAnalysis.TrendKeyword.builder()
|
||||
.keyword(node.get("keyword").asText())
|
||||
.relevance(node.get("relevance").asDouble())
|
||||
.description(node.get("description").asText())
|
||||
.build());
|
||||
});
|
||||
}
|
||||
|
||||
return keywords;
|
||||
}
|
||||
}
|
||||
174
ai-service/src/main/resources/application.yml
Normal file
174
ai-service/src/main/resources/application.yml
Normal file
@ -0,0 +1,174 @@
|
||||
spring:
|
||||
application:
|
||||
name: ai-service
|
||||
|
||||
# Redis Configuration
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:redis-external} # Production: redis-external, Local: 20.214.210.71
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0} # AI Service uses database 3
|
||||
timeout: ${REDIS_TIMEOUT:3000}
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 2
|
||||
max-wait: -1ms
|
||||
|
||||
# Kafka Consumer Configuration
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
|
||||
consumer:
|
||||
group-id: ai-service-consumers
|
||||
auto-offset-reset: earliest
|
||||
enable-auto-commit: false
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||
properties:
|
||||
spring.json.trusted.packages: "*"
|
||||
max.poll.records: ${KAFKA_MAX_POLL_RECORDS:10}
|
||||
session.timeout.ms: ${KAFKA_SESSION_TIMEOUT:30000}
|
||||
listener:
|
||||
ack-mode: manual
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
servlet:
|
||||
context-path: /
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
enabled: true
|
||||
force: true
|
||||
|
||||
# JWT Configuration
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
|
||||
|
||||
# CORS Configuration
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
|
||||
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
|
||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||
max-age: ${CORS_MAX_AGE:3600}
|
||||
|
||||
# Actuator Configuration
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
health:
|
||||
redis:
|
||||
enabled: true
|
||||
kafka:
|
||||
enabled: true
|
||||
|
||||
# OpenAPI Documentation Configuration
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
enabled: true
|
||||
operations-sorter: method
|
||||
tags-sorter: alpha
|
||||
display-request-duration: true
|
||||
doc-expansion: none
|
||||
show-actuator: false
|
||||
default-consumes-media-type: application/json
|
||||
default-produces-media-type: application/json
|
||||
|
||||
# Logging Configuration
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.kt.ai: DEBUG
|
||||
org.springframework.kafka: INFO
|
||||
org.springframework.data.redis: INFO
|
||||
io.github.resilience4j: DEBUG
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: ${LOG_FILE:logs/ai-service.log}
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-file-size: 10MB
|
||||
max-history: 7
|
||||
total-size-cap: 100MB
|
||||
|
||||
# Kafka Topics Configuration
|
||||
kafka:
|
||||
topics:
|
||||
ai-job: ${KAFKA_TOPIC_AI_JOB:ai-event-generation-job}
|
||||
ai-job-dlq: ${KAFKA_TOPIC_AI_JOB_DLQ:ai-event-generation-job-dlq}
|
||||
|
||||
# AI External API Configuration
|
||||
ai:
|
||||
claude:
|
||||
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
anthropic-version: ${CLAUDE_ANTHROPIC_VERSION:2023-06-01}
|
||||
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
|
||||
max-tokens: ${CLAUDE_MAX_TOKENS:4096}
|
||||
temperature: ${CLAUDE_TEMPERATURE:0.7}
|
||||
timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes
|
||||
gpt4:
|
||||
api-url: ${GPT4_API_URL:https://api.openai.com/v1/chat/completions}
|
||||
api-key: ${GPT4_API_KEY:}
|
||||
model: ${GPT4_MODEL:gpt-4-turbo-preview}
|
||||
max-tokens: ${GPT4_MAX_TOKENS:4096}
|
||||
timeout: ${GPT4_TIMEOUT:300000} # 5 minutes
|
||||
provider: ${AI_PROVIDER:CLAUDE} # CLAUDE or GPT4
|
||||
|
||||
# Circuit Breaker Configuration
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
configs:
|
||||
default:
|
||||
failure-rate-threshold: 50
|
||||
slow-call-rate-threshold: 50
|
||||
slow-call-duration-threshold: 60s
|
||||
permitted-number-of-calls-in-half-open-state: 3
|
||||
max-wait-duration-in-half-open-state: 0
|
||||
sliding-window-type: COUNT_BASED
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
wait-duration-in-open-state: 60s
|
||||
automatic-transition-from-open-to-half-open-enabled: true
|
||||
instances:
|
||||
claudeApi:
|
||||
base-config: default
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 60s
|
||||
gpt4Api:
|
||||
base-config: default
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 60s
|
||||
timelimiter:
|
||||
configs:
|
||||
default:
|
||||
timeout-duration: 300s # 5 minutes
|
||||
instances:
|
||||
claudeApi:
|
||||
timeout-duration: 300s
|
||||
gpt4Api:
|
||||
timeout-duration: 300s
|
||||
|
||||
# Redis Cache TTL Configuration (seconds)
|
||||
cache:
|
||||
ttl:
|
||||
recommendation: ${CACHE_TTL_RECOMMENDATION:86400} # 24 hours
|
||||
job-status: ${CACHE_TTL_JOB_STATUS:86400} # 24 hours
|
||||
trend: ${CACHE_TTL_TREND:3600} # 1 hour
|
||||
fallback: ${CACHE_TTL_FALLBACK:604800} # 7 days
|
||||
@ -0,0 +1,127 @@
|
||||
package com.kt.ai.test.integration.kafka;
|
||||
|
||||
import com.kt.ai.kafka.message.AIJobMessage;
|
||||
import com.kt.ai.service.CacheService;
|
||||
import com.kt.ai.service.JobStatusService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* AIJobConsumer Kafka 통합 테스트
|
||||
*
|
||||
* 실제 Kafka 브로커가 실행 중이어야 합니다.
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@DisplayName("AIJobConsumer Kafka 통합 테스트")
|
||||
class AIJobConsumerIntegrationTest {
|
||||
|
||||
@Value("${spring.kafka.bootstrap-servers}")
|
||||
private String bootstrapServers;
|
||||
|
||||
@Value("${kafka.topics.ai-job}")
|
||||
private String aiJobTopic;
|
||||
|
||||
@Autowired
|
||||
private JobStatusService jobStatusService;
|
||||
|
||||
@Autowired
|
||||
private CacheService cacheService;
|
||||
|
||||
private KafkaTestProducer testProducer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testProducer = new KafkaTestProducer(bootstrapServers, aiJobTopic);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (testProducer != null) {
|
||||
testProducer.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid AI job message, When send to Kafka, Then consumer processes and saves to Redis")
|
||||
void givenValidAIJobMessage_whenSendToKafka_thenConsumerProcessesAndSavesToRedis() {
|
||||
// Given
|
||||
String jobId = "test-job-" + System.currentTimeMillis();
|
||||
String eventId = "test-event-" + System.currentTimeMillis();
|
||||
AIJobMessage message = KafkaTestProducer.createSampleMessage(jobId, eventId);
|
||||
|
||||
// When
|
||||
testProducer.sendAIJobMessage(message);
|
||||
|
||||
// Then - Kafka Consumer가 메시지를 처리하고 Redis에 저장할 때까지 대기
|
||||
await()
|
||||
.atMost(30, TimeUnit.SECONDS)
|
||||
.pollInterval(1, TimeUnit.SECONDS)
|
||||
.untilAsserted(() -> {
|
||||
// Job 상태가 Redis에 저장되었는지 확인
|
||||
Object jobStatus = cacheService.getJobStatus(jobId);
|
||||
assertThat(jobStatus).isNotNull();
|
||||
System.out.println("Job 상태 확인: " + jobStatus);
|
||||
});
|
||||
|
||||
// 최종 상태 확인 (COMPLETED 또는 FAILED)
|
||||
await()
|
||||
.atMost(60, TimeUnit.SECONDS)
|
||||
.pollInterval(2, TimeUnit.SECONDS)
|
||||
.untilAsserted(() -> {
|
||||
Object jobStatus = cacheService.getJobStatus(jobId);
|
||||
assertThat(jobStatus).isNotNull();
|
||||
|
||||
// AI 추천 결과도 저장되었는지 확인 (COMPLETED 상태인 경우)
|
||||
Object recommendation = cacheService.getRecommendation(eventId);
|
||||
System.out.println("AI 추천 결과: " + (recommendation != null ? "있음" : "없음"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given multiple messages, When send to Kafka, Then all messages are processed")
|
||||
void givenMultipleMessages_whenSendToKafka_thenAllMessagesAreProcessed() {
|
||||
// Given
|
||||
int messageCount = 3;
|
||||
String[] jobIds = new String[messageCount];
|
||||
String[] eventIds = new String[messageCount];
|
||||
|
||||
// When - 여러 메시지 전송
|
||||
for (int i = 0; i < messageCount; i++) {
|
||||
jobIds[i] = "batch-job-" + i + "-" + System.currentTimeMillis();
|
||||
eventIds[i] = "batch-event-" + i + "-" + System.currentTimeMillis();
|
||||
AIJobMessage message = KafkaTestProducer.createSampleMessage(jobIds[i], eventIds[i]);
|
||||
testProducer.sendAIJobMessage(message);
|
||||
}
|
||||
|
||||
// Then - 모든 메시지가 처리되었는지 확인
|
||||
await()
|
||||
.atMost(90, TimeUnit.SECONDS)
|
||||
.pollInterval(2, TimeUnit.SECONDS)
|
||||
.untilAsserted(() -> {
|
||||
int processedCount = 0;
|
||||
for (int i = 0; i < messageCount; i++) {
|
||||
Object jobStatus = cacheService.getJobStatus(jobIds[i]);
|
||||
if (jobStatus != null) {
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
assertThat(processedCount).isEqualTo(messageCount);
|
||||
System.out.println("처리된 메시지 수: " + processedCount + "/" + messageCount);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package com.kt.ai.test.integration.kafka;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.kt.ai.kafka.message.AIJobMessage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.kafka.clients.producer.KafkaProducer;
|
||||
import org.apache.kafka.clients.producer.ProducerConfig;
|
||||
import org.apache.kafka.clients.producer.ProducerRecord;
|
||||
import org.apache.kafka.clients.producer.RecordMetadata;
|
||||
import org.apache.kafka.common.serialization.StringSerializer;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
/**
|
||||
* Kafka 테스트용 Producer 유틸리티
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
public class KafkaTestProducer {
|
||||
|
||||
private final KafkaProducer<String, String> producer;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String topic;
|
||||
|
||||
public KafkaTestProducer(String bootstrapServers, String topic) {
|
||||
this.topic = topic;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
|
||||
Properties props = new Properties();
|
||||
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
||||
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
||||
props.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||
props.put(ProducerConfig.RETRIES_CONFIG, 3);
|
||||
|
||||
this.producer = new KafkaProducer<>(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Job 메시지 전송
|
||||
*/
|
||||
public RecordMetadata sendAIJobMessage(AIJobMessage message) {
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(message);
|
||||
ProducerRecord<String, String> record = new ProducerRecord<>(topic, message.getJobId(), json);
|
||||
|
||||
Future<RecordMetadata> future = producer.send(record);
|
||||
RecordMetadata metadata = future.get();
|
||||
|
||||
log.info("Kafka 메시지 전송 성공: topic={}, partition={}, offset={}, jobId={}",
|
||||
metadata.topic(), metadata.partition(), metadata.offset(), message.getJobId());
|
||||
|
||||
return metadata;
|
||||
} catch (Exception e) {
|
||||
log.error("Kafka 메시지 전송 실패: jobId={}", message.getJobId(), e);
|
||||
throw new RuntimeException("Kafka 메시지 전송 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 샘플 메시지 생성
|
||||
*/
|
||||
public static AIJobMessage createSampleMessage(String jobId, String eventId) {
|
||||
return AIJobMessage.builder()
|
||||
.jobId(jobId)
|
||||
.eventId(eventId)
|
||||
.objective("신규 고객 유치")
|
||||
.industry("음식점")
|
||||
.region("강남구")
|
||||
.storeName("테스트 BBQ 레스토랑")
|
||||
.targetAudience("20-30대 직장인")
|
||||
.budget(500000)
|
||||
.requestedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer 종료
|
||||
*/
|
||||
public void close() {
|
||||
if (producer != null) {
|
||||
producer.close();
|
||||
log.info("Kafka Producer 종료");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
package com.kt.ai.test.manual;
|
||||
|
||||
import com.kt.ai.kafka.message.AIJobMessage;
|
||||
import com.kt.ai.test.integration.kafka.KafkaTestProducer;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Kafka 수동 테스트
|
||||
*
|
||||
* 이 클래스는 main 메서드를 실행하여 Kafka에 메시지를 직접 전송할 수 있습니다.
|
||||
* IDE에서 직접 실행하거나 Gradle로 실행할 수 있습니다.
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class KafkaManualTest {
|
||||
|
||||
// Kafka 설정 (환경에 맞게 수정)
|
||||
private static final String BOOTSTRAP_SERVERS = "20.249.182.13:9095,4.217.131.59:9095";
|
||||
private static final String TOPIC = "ai-event-generation-job";
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("=== Kafka 수동 테스트 시작 ===");
|
||||
System.out.println("Bootstrap Servers: " + BOOTSTRAP_SERVERS);
|
||||
System.out.println("Topic: " + TOPIC);
|
||||
|
||||
KafkaTestProducer producer = new KafkaTestProducer(BOOTSTRAP_SERVERS, TOPIC);
|
||||
|
||||
try {
|
||||
// 테스트 메시지 1: 기본 메시지
|
||||
AIJobMessage message1 = createTestMessage(
|
||||
"manual-job-001",
|
||||
"manual-event-001",
|
||||
"신규 고객 유치",
|
||||
"음식점",
|
||||
"강남구",
|
||||
"테스트 BBQ 레스토랑",
|
||||
500000
|
||||
);
|
||||
|
||||
System.out.println("\n[메시지 1] 전송 중...");
|
||||
producer.sendAIJobMessage(message1);
|
||||
System.out.println("[메시지 1] 전송 완료");
|
||||
|
||||
// 테스트 메시지 2: 다른 업종
|
||||
AIJobMessage message2 = createTestMessage(
|
||||
"manual-job-002",
|
||||
"manual-event-002",
|
||||
"재방문 유도",
|
||||
"카페",
|
||||
"서초구",
|
||||
"테스트 카페",
|
||||
300000
|
||||
);
|
||||
|
||||
System.out.println("\n[메시지 2] 전송 중...");
|
||||
producer.sendAIJobMessage(message2);
|
||||
System.out.println("[메시지 2] 전송 완료");
|
||||
|
||||
// 테스트 메시지 3: 저예산
|
||||
AIJobMessage message3 = createTestMessage(
|
||||
"manual-job-003",
|
||||
"manual-event-003",
|
||||
"매출 증대",
|
||||
"소매점",
|
||||
"마포구",
|
||||
"테스트 편의점",
|
||||
100000
|
||||
);
|
||||
|
||||
System.out.println("\n[메시지 3] 전송 중...");
|
||||
producer.sendAIJobMessage(message3);
|
||||
System.out.println("[메시지 3] 전송 완료");
|
||||
|
||||
System.out.println("\n=== 모든 메시지 전송 완료 ===");
|
||||
System.out.println("\n다음 API로 결과를 확인하세요:");
|
||||
System.out.println("- Job 상태: GET http://localhost:8083/api/v1/ai-service/internal/jobs/{jobId}/status");
|
||||
System.out.println("- AI 추천: GET http://localhost:8083/api/v1/ai-service/internal/recommendations/{eventId}");
|
||||
System.out.println("\n예시:");
|
||||
System.out.println(" curl http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status");
|
||||
System.out.println(" curl http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001");
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("에러 발생: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
producer.close();
|
||||
System.out.println("\n=== Kafka Producer 종료 ===");
|
||||
}
|
||||
}
|
||||
|
||||
private static AIJobMessage createTestMessage(
|
||||
String jobId,
|
||||
String eventId,
|
||||
String objective,
|
||||
String industry,
|
||||
String region,
|
||||
String storeName,
|
||||
int budget
|
||||
) {
|
||||
return AIJobMessage.builder()
|
||||
.jobId(jobId)
|
||||
.eventId(eventId)
|
||||
.objective(objective)
|
||||
.industry(industry)
|
||||
.region(region)
|
||||
.storeName(storeName)
|
||||
.targetAudience("20-40대 고객")
|
||||
.budget(budget)
|
||||
.requestedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
package com.kt.ai.test.unit.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kt.ai.controller.InternalJobController;
|
||||
import com.kt.ai.exception.JobNotFoundException;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/**
|
||||
* InternalJobController 단위 테스트
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@WebMvcTest(controllers = InternalJobController.class,
|
||||
excludeAutoConfiguration = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class})
|
||||
@DisplayName("InternalJobController 단위 테스트")
|
||||
class InternalJobControllerUnitTest {
|
||||
|
||||
// Constants
|
||||
private static final String VALID_JOB_ID = "job-123";
|
||||
private static final String INVALID_JOB_ID = "job-999";
|
||||
private static final String BASE_URL = "/api/v1/ai-service/internal/jobs";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private JobStatusService jobStatusService;
|
||||
|
||||
@MockBean
|
||||
private CacheService cacheService;
|
||||
|
||||
private JobStatusResponse sampleJobStatusResponse;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sampleJobStatusResponse = JobStatusResponse.builder()
|
||||
.jobId(VALID_JOB_ID)
|
||||
.status(JobStatus.PROCESSING)
|
||||
.progress(50)
|
||||
.message("AI 추천 생성 중 (50%)")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ========== GET /{jobId}/status 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing job, When get status, Then return 200 with job status")
|
||||
void givenExistingJob_whenGetStatus_thenReturn200WithJobStatus() throws Exception {
|
||||
// Given
|
||||
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(sampleJobStatusResponse);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.jobId", is(VALID_JOB_ID)))
|
||||
.andExpect(jsonPath("$.status", is("PROCESSING")))
|
||||
.andExpect(jsonPath("$.progress", is(50)))
|
||||
.andExpect(jsonPath("$.message", is("AI 추천 생성 중 (50%)")))
|
||||
.andExpect(jsonPath("$.createdAt", notNullValue()));
|
||||
|
||||
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given non-existing job, When get status, Then return 404")
|
||||
void givenNonExistingJob_whenGetStatus_thenReturn404() throws Exception {
|
||||
// Given
|
||||
when(jobStatusService.getJobStatus(INVALID_JOB_ID))
|
||||
.thenThrow(new JobNotFoundException(INVALID_JOB_ID));
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/{jobId}/status", INVALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code", is("JOB_NOT_FOUND")))
|
||||
.andExpect(jsonPath("$.message", containsString(INVALID_JOB_ID)));
|
||||
|
||||
verify(jobStatusService, times(1)).getJobStatus(INVALID_JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given completed job, When get status, Then return COMPLETED status with 100% progress")
|
||||
void givenCompletedJob_whenGetStatus_thenReturnCompletedStatus() throws Exception {
|
||||
// Given
|
||||
JobStatusResponse completedResponse = JobStatusResponse.builder()
|
||||
.jobId(VALID_JOB_ID)
|
||||
.status(JobStatus.COMPLETED)
|
||||
.progress(100)
|
||||
.message("AI 추천 완료")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(completedResponse);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status", is("COMPLETED")))
|
||||
.andExpect(jsonPath("$.progress", is(100)));
|
||||
|
||||
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given failed job, When get status, Then return FAILED status")
|
||||
void givenFailedJob_whenGetStatus_thenReturnFailedStatus() throws Exception {
|
||||
// Given
|
||||
JobStatusResponse failedResponse = JobStatusResponse.builder()
|
||||
.jobId(VALID_JOB_ID)
|
||||
.status(JobStatus.FAILED)
|
||||
.progress(0)
|
||||
.message("AI API 호출 실패")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(failedResponse);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status", is("FAILED")))
|
||||
.andExpect(jsonPath("$.progress", is(0)))
|
||||
.andExpect(jsonPath("$.message", containsString("실패")));
|
||||
|
||||
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
}
|
||||
|
||||
// ========== 디버그 엔드포인트 테스트 (선택사항) ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid jobId, When create test job, Then return 200 with test data")
|
||||
void givenValidJobId_whenCreateTestJob_thenReturn200WithTestData() throws Exception {
|
||||
// Given
|
||||
doNothing().when(jobStatusService).updateJobStatus(anyString(), org.mockito.ArgumentMatchers.any(JobStatus.class), anyString());
|
||||
when(cacheService.getJobStatus(VALID_JOB_ID)).thenReturn(sampleJobStatusResponse);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/debug/create-test-job/{jobId}", VALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success", is(true)))
|
||||
.andExpect(jsonPath("$.jobId", is(VALID_JOB_ID)))
|
||||
.andExpect(jsonPath("$.saved", is(true)))
|
||||
.andExpect(jsonPath("$.additionalSamples", notNullValue()));
|
||||
|
||||
// updateJobStatus가 4번 호출되어야 함 (main + 3 additional samples)
|
||||
verify(jobStatusService, times(4)).updateJobStatus(anyString(), org.mockito.ArgumentMatchers.any(JobStatus.class), anyString());
|
||||
verify(cacheService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,268 @@
|
||||
package com.kt.ai.test.unit.service;
|
||||
|
||||
import com.kt.ai.service.CacheService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
/**
|
||||
* CacheService 단위 테스트
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("CacheService 단위 테스트")
|
||||
class CacheServiceUnitTest {
|
||||
|
||||
// Constants
|
||||
private static final String VALID_KEY = "test:key";
|
||||
private static final String VALID_VALUE = "test-value";
|
||||
private static final long VALID_TTL = 3600L;
|
||||
private static final String VALID_JOB_ID = "job-123";
|
||||
private static final String VALID_EVENT_ID = "evt-001";
|
||||
private static final String VALID_INDUSTRY = "음식점";
|
||||
private static final String VALID_REGION = "강남구";
|
||||
|
||||
@Mock
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@Mock
|
||||
private ValueOperations<String, Object> valueOperations;
|
||||
|
||||
@InjectMocks
|
||||
private CacheService cacheService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// TTL 값 설정
|
||||
ReflectionTestUtils.setField(cacheService, "recommendationTtl", 86400L);
|
||||
ReflectionTestUtils.setField(cacheService, "jobStatusTtl", 86400L);
|
||||
ReflectionTestUtils.setField(cacheService, "trendTtl", 3600L);
|
||||
|
||||
// RedisTemplate Mock 설정 (lenient를 사용하여 모든 테스트에서 사용하지 않아도 됨)
|
||||
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
}
|
||||
|
||||
// ========== set() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid key and value, When set, Then success")
|
||||
void givenValidKeyAndValue_whenSet_thenSuccess() {
|
||||
// Given
|
||||
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When
|
||||
cacheService.set(VALID_KEY, VALID_VALUE, VALID_TTL);
|
||||
|
||||
// Then
|
||||
verify(valueOperations, times(1))
|
||||
.set(VALID_KEY, VALID_VALUE, VALID_TTL, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given Redis exception, When set, Then log error and continue")
|
||||
void givenRedisException_whenSet_thenLogErrorAndContinue() {
|
||||
// Given
|
||||
doThrow(new RuntimeException("Redis connection failed"))
|
||||
.when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When & Then (예외가 전파되지 않아야 함)
|
||||
cacheService.set(VALID_KEY, VALID_VALUE, VALID_TTL);
|
||||
|
||||
verify(valueOperations, times(1))
|
||||
.set(VALID_KEY, VALID_VALUE, VALID_TTL, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ========== get() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing key, When get, Then return value")
|
||||
void givenExistingKey_whenGet_thenReturnValue() {
|
||||
// Given
|
||||
when(valueOperations.get(VALID_KEY)).thenReturn(VALID_VALUE);
|
||||
|
||||
// When
|
||||
Object result = cacheService.get(VALID_KEY);
|
||||
|
||||
// Then
|
||||
assertThat(result).isEqualTo(VALID_VALUE);
|
||||
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given non-existing key, When get, Then return null")
|
||||
void givenNonExistingKey_whenGet_thenReturnNull() {
|
||||
// Given
|
||||
when(valueOperations.get(VALID_KEY)).thenReturn(null);
|
||||
|
||||
// When
|
||||
Object result = cacheService.get(VALID_KEY);
|
||||
|
||||
// Then
|
||||
assertThat(result).isNull();
|
||||
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given Redis exception, When get, Then return null")
|
||||
void givenRedisException_whenGet_thenReturnNull() {
|
||||
// Given
|
||||
when(valueOperations.get(VALID_KEY))
|
||||
.thenThrow(new RuntimeException("Redis connection failed"));
|
||||
|
||||
// When
|
||||
Object result = cacheService.get(VALID_KEY);
|
||||
|
||||
// Then
|
||||
assertThat(result).isNull();
|
||||
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||
}
|
||||
|
||||
// ========== delete() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid key, When delete, Then invoke RedisTemplate delete")
|
||||
void givenValidKey_whenDelete_thenInvokeRedisTemplateDelete() {
|
||||
// Given - No specific setup needed
|
||||
|
||||
// When
|
||||
cacheService.delete(VALID_KEY);
|
||||
|
||||
// Then
|
||||
verify(redisTemplate, times(1)).delete(VALID_KEY);
|
||||
}
|
||||
|
||||
// ========== saveJobStatus() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid job status, When save, Then success")
|
||||
void givenValidJobStatus_whenSave_thenSuccess() {
|
||||
// Given
|
||||
Object jobStatus = "PROCESSING";
|
||||
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When
|
||||
cacheService.saveJobStatus(VALID_JOB_ID, jobStatus);
|
||||
|
||||
// Then
|
||||
verify(valueOperations, times(1))
|
||||
.set("ai:job:status:" + VALID_JOB_ID, jobStatus, 86400L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ========== getJobStatus() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing job, When get status, Then return status")
|
||||
void givenExistingJob_whenGetStatus_thenReturnStatus() {
|
||||
// Given
|
||||
Object expectedStatus = "COMPLETED";
|
||||
when(valueOperations.get("ai:job:status:" + VALID_JOB_ID)).thenReturn(expectedStatus);
|
||||
|
||||
// When
|
||||
Object result = cacheService.getJobStatus(VALID_JOB_ID);
|
||||
|
||||
// Then
|
||||
assertThat(result).isEqualTo(expectedStatus);
|
||||
verify(valueOperations, times(1)).get("ai:job:status:" + VALID_JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given non-existing job, When get status, Then return null")
|
||||
void givenNonExistingJob_whenGetStatus_thenReturnNull() {
|
||||
// Given
|
||||
when(valueOperations.get("ai:job:status:" + VALID_JOB_ID)).thenReturn(null);
|
||||
|
||||
// When
|
||||
Object result = cacheService.getJobStatus(VALID_JOB_ID);
|
||||
|
||||
// Then
|
||||
assertThat(result).isNull();
|
||||
verify(valueOperations, times(1)).get("ai:job:status:" + VALID_JOB_ID);
|
||||
}
|
||||
|
||||
// ========== saveRecommendation() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid recommendation, When save, Then success")
|
||||
void givenValidRecommendation_whenSave_thenSuccess() {
|
||||
// Given
|
||||
Object recommendation = "recommendation-data";
|
||||
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When
|
||||
cacheService.saveRecommendation(VALID_EVENT_ID, recommendation);
|
||||
|
||||
// Then
|
||||
verify(valueOperations, times(1))
|
||||
.set("ai:recommendation:" + VALID_EVENT_ID, recommendation, 86400L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ========== getRecommendation() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing recommendation, When get, Then return recommendation")
|
||||
void givenExistingRecommendation_whenGet_thenReturnRecommendation() {
|
||||
// Given
|
||||
Object expectedRecommendation = "recommendation-data";
|
||||
when(valueOperations.get("ai:recommendation:" + VALID_EVENT_ID))
|
||||
.thenReturn(expectedRecommendation);
|
||||
|
||||
// When
|
||||
Object result = cacheService.getRecommendation(VALID_EVENT_ID);
|
||||
|
||||
// Then
|
||||
assertThat(result).isEqualTo(expectedRecommendation);
|
||||
verify(valueOperations, times(1)).get("ai:recommendation:" + VALID_EVENT_ID);
|
||||
}
|
||||
|
||||
// ========== saveTrend() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid trend, When save, Then success")
|
||||
void givenValidTrend_whenSave_thenSuccess() {
|
||||
// Given
|
||||
Object trend = "trend-data";
|
||||
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When
|
||||
cacheService.saveTrend(VALID_INDUSTRY, VALID_REGION, trend);
|
||||
|
||||
// Then
|
||||
verify(valueOperations, times(1))
|
||||
.set("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION, trend, 3600L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ========== getTrend() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing trend, When get, Then return trend")
|
||||
void givenExistingTrend_whenGet_thenReturnTrend() {
|
||||
// Given
|
||||
Object expectedTrend = "trend-data";
|
||||
when(valueOperations.get("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION))
|
||||
.thenReturn(expectedTrend);
|
||||
|
||||
// When
|
||||
Object result = cacheService.getTrend(VALID_INDUSTRY, VALID_REGION);
|
||||
|
||||
// Then
|
||||
assertThat(result).isEqualTo(expectedTrend);
|
||||
verify(valueOperations, times(1))
|
||||
.get("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
package com.kt.ai.test.unit.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 com.kt.ai.service.CacheService;
|
||||
import com.kt.ai.service.JobStatusService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* JobStatusService 단위 테스트
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("JobStatusService 단위 테스트")
|
||||
class JobStatusServiceUnitTest {
|
||||
|
||||
// Constants
|
||||
private static final String VALID_JOB_ID = "job-123";
|
||||
private static final String INVALID_JOB_ID = "job-999";
|
||||
private static final String VALID_MESSAGE = "AI 추천 생성 중";
|
||||
|
||||
@Mock
|
||||
private CacheService cacheService;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private JobStatusService jobStatusService;
|
||||
|
||||
private JobStatusResponse sampleJobStatusResponse;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sampleJobStatusResponse = JobStatusResponse.builder()
|
||||
.jobId(VALID_JOB_ID)
|
||||
.status(JobStatus.PROCESSING)
|
||||
.progress(50)
|
||||
.message(VALID_MESSAGE)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ========== getJobStatus() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing job, When get status, Then return job status")
|
||||
void givenExistingJob_whenGetStatus_thenReturnJobStatus() {
|
||||
// Given
|
||||
Map<String, Object> cachedData = createCachedJobStatusData();
|
||||
when(cacheService.getJobStatus(VALID_JOB_ID)).thenReturn(cachedData);
|
||||
when(objectMapper.convertValue(cachedData, JobStatusResponse.class))
|
||||
.thenReturn(sampleJobStatusResponse);
|
||||
|
||||
// When
|
||||
JobStatusResponse result = jobStatusService.getJobStatus(VALID_JOB_ID);
|
||||
|
||||
// Then
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(result.getStatus()).isEqualTo(JobStatus.PROCESSING);
|
||||
assertThat(result.getProgress()).isEqualTo(50);
|
||||
assertThat(result.getMessage()).isEqualTo(VALID_MESSAGE);
|
||||
|
||||
verify(cacheService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
verify(objectMapper, times(1)).convertValue(cachedData, JobStatusResponse.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given non-existing job, When get status, Then throw JobNotFoundException")
|
||||
void givenNonExistingJob_whenGetStatus_thenThrowJobNotFoundException() {
|
||||
// Given
|
||||
when(cacheService.getJobStatus(INVALID_JOB_ID)).thenReturn(null);
|
||||
|
||||
// When & Then
|
||||
assertThatThrownBy(() -> jobStatusService.getJobStatus(INVALID_JOB_ID))
|
||||
.isInstanceOf(JobNotFoundException.class)
|
||||
.hasMessageContaining(INVALID_JOB_ID);
|
||||
|
||||
verify(cacheService, times(1)).getJobStatus(INVALID_JOB_ID);
|
||||
verify(objectMapper, never()).convertValue(any(), eq(JobStatusResponse.class));
|
||||
}
|
||||
|
||||
// ========== updateJobStatus() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given PENDING status, When update, Then save with 0% progress")
|
||||
void givenPendingStatus_whenUpdate_thenSaveWithZeroProgress() {
|
||||
// Given
|
||||
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||
|
||||
// When
|
||||
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.PENDING, "대기 중");
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||
|
||||
JobStatusResponse saved = captor.getValue();
|
||||
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(saved.getStatus()).isEqualTo(JobStatus.PENDING);
|
||||
assertThat(saved.getProgress()).isEqualTo(0);
|
||||
assertThat(saved.getMessage()).isEqualTo("대기 중");
|
||||
assertThat(saved.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given PROCESSING status, When update, Then save with 50% progress")
|
||||
void givenProcessingStatus_whenUpdate_thenSaveWithFiftyProgress() {
|
||||
// Given
|
||||
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||
|
||||
// When
|
||||
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.PROCESSING, VALID_MESSAGE);
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||
|
||||
JobStatusResponse saved = captor.getValue();
|
||||
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(saved.getStatus()).isEqualTo(JobStatus.PROCESSING);
|
||||
assertThat(saved.getProgress()).isEqualTo(50);
|
||||
assertThat(saved.getMessage()).isEqualTo(VALID_MESSAGE);
|
||||
assertThat(saved.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given COMPLETED status, When update, Then save with 100% progress")
|
||||
void givenCompletedStatus_whenUpdate_thenSaveWithHundredProgress() {
|
||||
// Given
|
||||
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||
|
||||
// When
|
||||
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.COMPLETED, "AI 추천 완료");
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||
|
||||
JobStatusResponse saved = captor.getValue();
|
||||
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(saved.getStatus()).isEqualTo(JobStatus.COMPLETED);
|
||||
assertThat(saved.getProgress()).isEqualTo(100);
|
||||
assertThat(saved.getMessage()).isEqualTo("AI 추천 완료");
|
||||
assertThat(saved.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given FAILED status, When update, Then save with 0% progress")
|
||||
void givenFailedStatus_whenUpdate_thenSaveWithZeroProgress() {
|
||||
// Given
|
||||
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||
|
||||
// When
|
||||
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.FAILED, "AI API 호출 실패");
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||
|
||||
JobStatusResponse saved = captor.getValue();
|
||||
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(saved.getStatus()).isEqualTo(JobStatus.FAILED);
|
||||
assertThat(saved.getProgress()).isEqualTo(0);
|
||||
assertThat(saved.getMessage()).isEqualTo("AI API 호출 실패");
|
||||
assertThat(saved.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
// ========== Helper Methods ==========
|
||||
|
||||
/**
|
||||
* Cache에 저장된 Job 상태 데이터 생성 (LinkedHashMap 형태)
|
||||
*/
|
||||
private Map<String, Object> createCachedJobStatusData() {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("jobId", VALID_JOB_ID);
|
||||
data.put("status", JobStatus.PROCESSING.name());
|
||||
data.put("progress", 50);
|
||||
data.put("message", VALID_MESSAGE);
|
||||
data.put("createdAt", LocalDateTime.now().toString());
|
||||
return data;
|
||||
}
|
||||
}
|
||||
69
ai-service/src/test/resources/application-test.yml
Normal file
69
ai-service/src/test/resources/application-test.yml
Normal file
@ -0,0 +1,69 @@
|
||||
spring:
|
||||
application:
|
||||
name: ai-service-test
|
||||
|
||||
# Redis Configuration (테스트용)
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:20.214.210.71}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:Hi5Jessica!}
|
||||
database: ${REDIS_DATABASE:3}
|
||||
timeout: 3000
|
||||
|
||||
# Kafka Configuration (테스트용)
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
|
||||
consumer:
|
||||
group-id: ai-service-test-consumers
|
||||
auto-offset-reset: earliest
|
||||
enable-auto-commit: false
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||
properties:
|
||||
spring.json.trusted.packages: "*"
|
||||
listener:
|
||||
ack-mode: manual
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: 0 # 랜덤 포트 사용
|
||||
|
||||
# JWT Configuration (테스트용)
|
||||
jwt:
|
||||
secret: test-jwt-secret-key-for-testing-only
|
||||
access-token-validity: 1800
|
||||
refresh-token-validity: 86400
|
||||
|
||||
# Kafka Topics
|
||||
kafka:
|
||||
topics:
|
||||
ai-job: ai-event-generation-job
|
||||
ai-job-dlq: ai-event-generation-job-dlq
|
||||
|
||||
# AI API Configuration (테스트용 - Mock 사용)
|
||||
ai:
|
||||
provider: CLAUDE
|
||||
claude:
|
||||
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
||||
api-key: ${CLAUDE_API_KEY:test-key}
|
||||
anthropic-version: 2023-06-01
|
||||
model: claude-3-5-sonnet-20241022
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
timeout: 300000
|
||||
|
||||
# Cache TTL
|
||||
cache:
|
||||
ttl:
|
||||
recommendation: 86400
|
||||
job-status: 86400
|
||||
trend: 3600
|
||||
fallback: 604800
|
||||
|
||||
# Logging
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.kt.ai: DEBUG
|
||||
org.springframework.kafka: DEBUG
|
||||
@ -286,6 +286,11 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
|
||||
publishEvent(PARTICIPANT_REGISTERED_TOPIC, event);
|
||||
totalPublished++;
|
||||
|
||||
// 동시성 충돌 방지: 10개마다 100ms 대기
|
||||
if ((j + 1) % 10 == 0) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.kafka.annotation.KafkaListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@ -37,7 +38,10 @@ public class DistributionCompletedConsumer {
|
||||
|
||||
/**
|
||||
* DistributionCompleted 이벤트 처리 (설계서 기준 - 여러 채널 배열)
|
||||
*
|
||||
* @Transactional 필수: DB 저장 작업을 위해 트랜잭션 컨텍스트 필요
|
||||
*/
|
||||
@Transactional
|
||||
@KafkaListener(topics = "sample.distribution.completed", groupId = "${spring.kafka.consumer.group-id}")
|
||||
public void handleDistributionCompleted(String message) {
|
||||
try {
|
||||
@ -128,8 +132,8 @@ public class DistributionCompletedConsumer {
|
||||
.mapToInt(ChannelStats::getImpressions)
|
||||
.sum();
|
||||
|
||||
// EventStats 업데이트
|
||||
eventStatsRepository.findByEventId(eventId)
|
||||
// EventStats 업데이트 - 비관적 락 적용
|
||||
eventStatsRepository.findByEventIdWithLock(eventId)
|
||||
.ifPresentOrElse(
|
||||
eventStats -> {
|
||||
eventStats.setTotalViews(totalViews);
|
||||
|
||||
@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.kafka.annotation.KafkaListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@ -34,7 +35,10 @@ public class EventCreatedConsumer {
|
||||
|
||||
/**
|
||||
* EventCreated 이벤트 처리 (MVP용 샘플 토픽)
|
||||
*
|
||||
* @Transactional 필수: DB 저장 작업을 위해 트랜잭션 컨텍스트 필요
|
||||
*/
|
||||
@Transactional
|
||||
@KafkaListener(topics = "sample.event.created", groupId = "${spring.kafka.consumer.group-id}")
|
||||
public void handleEventCreated(String message) {
|
||||
try {
|
||||
|
||||
@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.kafka.annotation.KafkaListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@ -34,7 +35,10 @@ public class ParticipantRegisteredConsumer {
|
||||
|
||||
/**
|
||||
* ParticipantRegistered 이벤트 처리 (MVP용 샘플 토픽)
|
||||
*
|
||||
* @Transactional 필수: 비관적 락 사용을 위해 트랜잭션 컨텍스트 필요
|
||||
*/
|
||||
@Transactional
|
||||
@KafkaListener(topics = "sample.participant.registered", groupId = "${spring.kafka.consumer.group-id}")
|
||||
public void handleParticipantRegistered(String message) {
|
||||
try {
|
||||
@ -51,8 +55,8 @@ public class ParticipantRegisteredConsumer {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 이벤트 통계 업데이트 (참여자 수 +1)
|
||||
eventStatsRepository.findByEventId(eventId)
|
||||
// 2. 이벤트 통계 업데이트 (참여자 수 +1) - 비관적 락 적용
|
||||
eventStatsRepository.findByEventIdWithLock(eventId)
|
||||
.ifPresentOrElse(
|
||||
eventStats -> {
|
||||
eventStats.incrementParticipants();
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
package com.kt.event.analytics.repository;
|
||||
|
||||
import com.kt.event.analytics.entity.EventStats;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
@ -20,6 +24,20 @@ public interface EventStatsRepository extends JpaRepository<EventStats, Long> {
|
||||
*/
|
||||
Optional<EventStats> findByEventId(String eventId);
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 통계 조회 (비관적 락 적용)
|
||||
*
|
||||
* 동시성 충돌 방지를 위해 PESSIMISTIC_WRITE 락 사용
|
||||
* - 읽는 순간부터 락을 걸어 다른 트랜잭션 차단
|
||||
* - ParticipantRegistered 이벤트 처리 시 사용
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 이벤트 통계
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT e FROM EventStats e WHERE e.eventId = :eventId")
|
||||
Optional<EventStats> findByEventIdWithLock(@Param("eventId") String eventId);
|
||||
|
||||
/**
|
||||
* 매장 ID와 이벤트 ID로 통계 조회
|
||||
*
|
||||
|
||||
82
claude/build-image-back.md
Normal file
82
claude/build-image-back.md
Normal file
@ -0,0 +1,82 @@
|
||||
# 백엔드 컨테이너이미지 작성가이드
|
||||
|
||||
[요청사항]
|
||||
- 백엔드 각 서비스를의 컨테이너 이미지 생성
|
||||
- 실제 빌드 수행 및 검증까지 완료
|
||||
- '[결과파일]'에 수행한 명령어를 포함하여 컨테이너 이미지 작성 과정 생성
|
||||
|
||||
[작업순서]
|
||||
- 서비스명 확인
|
||||
서비스명은 settings.gradle에서 확인
|
||||
|
||||
예시) include 'common'하위의 4개가 서비스명임.
|
||||
```
|
||||
rootProject.name = 'tripgen'
|
||||
|
||||
include 'common'
|
||||
include 'user-service'
|
||||
include 'location-service'
|
||||
include 'ai-service'
|
||||
include 'trip-service'
|
||||
```
|
||||
|
||||
- 실행Jar 파일 설정
|
||||
실행Jar 파일명을 서비스명과 일치하도록 build.gradle에 설정 합니다.
|
||||
```
|
||||
bootJar {
|
||||
archiveFileName = '{서비스명}.jar'
|
||||
}
|
||||
```
|
||||
|
||||
- Dockerfile 생성
|
||||
아래 내용으로 deployment/container/Dockerfile-backend 생성
|
||||
```
|
||||
# Build stage
|
||||
FROM openjdk:23-oraclelinux8 AS builder
|
||||
ARG BUILD_LIB_DIR
|
||||
ARG ARTIFACTORY_FILE
|
||||
COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
|
||||
|
||||
# Run stage
|
||||
FROM openjdk:23-slim
|
||||
ENV USERNAME=k8s
|
||||
ENV ARTIFACTORY_HOME=/home/${USERNAME}
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
# Add a non-root user
|
||||
RUN adduser --system --group ${USERNAME} && \
|
||||
mkdir -p ${ARTIFACTORY_HOME} && \
|
||||
chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
|
||||
|
||||
WORKDIR ${ARTIFACTORY_HOME}
|
||||
COPY --from=builder app.jar app.jar
|
||||
RUN chown ${USERNAME}:${USERNAME} app.jar
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
ENTRYPOINT [ "sh", "-c" ]
|
||||
CMD ["java ${JAVA_OPTS} -jar app.jar"]
|
||||
```
|
||||
|
||||
- 컨테이너 이미지 생성
|
||||
아래 명령으로 각 서비스 빌드. shell 파일을 생성하지 말고 command로 수행.
|
||||
서브에이젼트를 생성하여 병렬로 수행.
|
||||
```
|
||||
DOCKER_FILE=deployment/container/Dockerfile-backend
|
||||
service={서비스명}
|
||||
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
--build-arg BUILD_LIB_DIR="${서비스명}/build/libs" \
|
||||
--build-arg ARTIFACTORY_FILE="${서비스명}.jar" \
|
||||
-f ${DOCKER_FILE} \
|
||||
-t ${서비스명}:latest .
|
||||
```
|
||||
- 생성된 이미지 확인
|
||||
아래 명령으로 모든 서비스의 이미지가 빌드되었는지 확인
|
||||
```
|
||||
docker images | grep {서비스명}
|
||||
```
|
||||
|
||||
[결과파일]
|
||||
deployment/container/build-image.md
|
||||
220
claude/design-prompt.md
Normal file
220
claude/design-prompt.md
Normal file
@ -0,0 +1,220 @@
|
||||
# 설계 프롬프트
|
||||
아래 순서대로 설계합니다.
|
||||
|
||||
## UI/UX 설계
|
||||
command: "/design-uiux"
|
||||
prompt:
|
||||
```
|
||||
@uiux
|
||||
UI/UX 설계를 해주세요:
|
||||
- 'UI/UX설계가이드'를 준용하여 작성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 프로토타입 작성
|
||||
command: "/design-prototype"
|
||||
prompt:
|
||||
**1.작성**
|
||||
```
|
||||
@prototype
|
||||
프로토타입을 작성해 주세요:
|
||||
- '프로토타입작성가이드'를 준용하여 작성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**2.검증**
|
||||
command: "/design-test-prototype"
|
||||
prompt:
|
||||
```
|
||||
@test-front
|
||||
프로토타입을 테스트 해 주세요.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**3.오류수정**
|
||||
command: "/design-fix-prototype"
|
||||
prompt:
|
||||
```
|
||||
@fix as @front
|
||||
'[오류내용]'섹션에 제공된 오류를 해결해 주세요.
|
||||
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
|
||||
{안내메시지}
|
||||
'[오류내용]'섹션 하위에 오류 내용을 제공
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**4.개선**
|
||||
command: "/design-improve-prototype"
|
||||
prompt:
|
||||
```
|
||||
@improve as @front
|
||||
'[개선내용]'섹션에 있는 내용을 개선해 주세요.
|
||||
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
|
||||
{안내메시지}
|
||||
'[개선내용]'섹션 하위에 개선할 내용을 제공
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**5.유저스토리 품질 높이기**
|
||||
command: "/design-improve-userstory"
|
||||
prompt:
|
||||
```
|
||||
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
|
||||
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**6.설계서 다시 업데이트**
|
||||
command: "/design-update-uiux"
|
||||
prompt:
|
||||
```
|
||||
@document @front
|
||||
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 클라우드 아키텍처 패턴 선정
|
||||
command: "/design-pattern"
|
||||
prompt:
|
||||
```
|
||||
@design-pattern
|
||||
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
|
||||
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 논리아키텍처 설계
|
||||
command: "/design-logical"
|
||||
prompt:
|
||||
```
|
||||
@architecture
|
||||
논리 아키텍처를 설계해 주세요:
|
||||
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 외부 시퀀스 설계
|
||||
command: "/design-seq-outer"
|
||||
prompt:
|
||||
```
|
||||
@architecture
|
||||
외부 시퀀스 설계를 해 주세요:
|
||||
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 내부 시퀀스 설계
|
||||
command: "/design-seq-inner"
|
||||
prompt:
|
||||
```
|
||||
@architecture
|
||||
내부 시퀀스 설계를 해 주세요:
|
||||
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 설계
|
||||
command: "/design-api"
|
||||
prompt:
|
||||
```
|
||||
@architecture
|
||||
API를 설계해 주세요:
|
||||
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 클래스 설계
|
||||
command: "/design-class"
|
||||
prompt:
|
||||
```
|
||||
@architecture
|
||||
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
|
||||
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
{안내메시지}
|
||||
'[클래스설계 정보]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
|
||||
[클래스설계 정보]
|
||||
- 패키지 그룹: com.unicorn.tripgen
|
||||
- 설계 아키텍처 패턴
|
||||
- User: Layered
|
||||
- Trip: Clean
|
||||
- Location: Layered
|
||||
- AI: Layered
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 설계
|
||||
command: "/design-data"
|
||||
prompt:
|
||||
```
|
||||
@architecture
|
||||
데이터 설계를 해주세요:
|
||||
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Level 아키텍처 정의서 작성
|
||||
command: "/design-high-level"
|
||||
prompt:
|
||||
```
|
||||
@architecture
|
||||
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
|
||||
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
||||
{안내메시지}
|
||||
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
|
||||
- CLOUD: Azure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 물리 아키텍처 설계
|
||||
command: "/design-physical"
|
||||
prompt:
|
||||
```
|
||||
@architecture
|
||||
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
|
||||
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
|
||||
{안내메시지}
|
||||
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
|
||||
- CLOUD: Azure
|
||||
```
|
||||
|
||||
## 프론트엔드 설계
|
||||
command: "/design-front"
|
||||
prompt:
|
||||
```
|
||||
@plan as @front
|
||||
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
|
||||
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
{안내메시지}
|
||||
'[백엔드시스템]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
|
||||
[백엔드시스템]
|
||||
- 시스템: tripgen
|
||||
- 마이크로서비스: user-service, location-service, trip-service, ai-service
|
||||
- API문서
|
||||
- user service: http://localhost:8081/v3/api-docs
|
||||
- location service: http://localhost:8082/v3/api-docs
|
||||
- trip service: http://localhost:8083/v3/api-docs
|
||||
- ai service: http://localhost:8084/v3/api-docs
|
||||
[요구사항]
|
||||
- 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시
|
||||
- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
|
||||
```
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
# 백엔드 개발 가이드
|
||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||
Dload Upload Total Spent Left Speed
|
||||
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 백엔드 개발 가이드
|
||||
|
||||
[요청사항]
|
||||
- <개발원칙>을 준용하여 개발
|
||||
@ -601,7 +603,7 @@ public class UserPrincipal {
|
||||
* 일반 사용자 권한 여부 확인
|
||||
*/
|
||||
public boolean isUser() {
|
||||
return "USER".equals(authority) || authority == null;
|
||||
return "USER".equals(authority) ||
100 22883 100 22883 0 0 76277 0 --:--:-- --:--:-- --:--:-- 76788authority == null;
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -660,3 +662,4 @@ public class SwaggerConfig {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
180
claude/develop-prompt.md
Normal file
180
claude/develop-prompt.md
Normal file
@ -0,0 +1,180 @@
|
||||
# 개발 프롬프트
|
||||
|
||||
## 데이터베이스 설치계획서 작성 요청
|
||||
command: "/develop-db-guide"
|
||||
prompt:
|
||||
```
|
||||
@backing-service
|
||||
"데이터베이스설치계획서가이드"에 따라 데이터베이스 설치계획서를 작성해 주십시오.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 설치 수행 요청
|
||||
command: "/develop-db-install"
|
||||
prompt:
|
||||
```
|
||||
@backing-service
|
||||
[요구사항]
|
||||
'데이터베이스설치가이드'에 따라 설치해 주세요.
|
||||
'[설치정보]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시하세요.
|
||||
{안내메시지}
|
||||
'[설치정보]'섹션 하위에 아래 예와 같이 설치에 필요한 정보를 추가해 주세요.
|
||||
- 설치대상환경: 개발환경
|
||||
- AKS Resource Group: rg-digitalgarage-01
|
||||
- AKS Name: aks-digitalgarage-01
|
||||
- Namespace: tripgen-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 설치 제거 요청 (필요시)
|
||||
command: "/develop-db-remove"
|
||||
prompt:
|
||||
```
|
||||
@backing-service
|
||||
[요구사항]
|
||||
- "데이터베이스설치결과서"를 보고 관련된 모든 리소스를 삭제
|
||||
- "캐시설치결과서"를 보고 관련된 모든 리소스를 삭제
|
||||
- 현재 OS에 맞게 수행
|
||||
- 서브 에이젼트를 병렬로 수행하여 삭제
|
||||
- 결과파일은 생성할 필요 없고 화면에만 결과 표시
|
||||
[참고자료]
|
||||
- 데이터베이스설치결과서
|
||||
- 캐시설치결과서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message Queue 설치 계획서 작성 요청
|
||||
command: "/develop-mq-guide"
|
||||
prompt:
|
||||
```
|
||||
@backing-service
|
||||
"MQ설치게획서가이드"에 따라 Message Queue 설치계획서를 작성해 주세요.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message Queue 설치 수행 요청(필요시)
|
||||
command: "/develop-mq-install"
|
||||
prompt:
|
||||
```
|
||||
@backing-service
|
||||
[요구사항]
|
||||
'MQ설치가이드'에 따라 설치해 주세요.
|
||||
'[설치정보]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시하세요.
|
||||
{안내메시지}
|
||||
'[설치정보]'섹션 하위에 아래 예와 같이 설치에 필요한 정보를 추가해 주세요.
|
||||
- 설치대상환경: 개발환경
|
||||
- Resource Group: rg-digitalgarage-01
|
||||
- Namespace: tripgen-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message Queue 설치 제거 요청
|
||||
command: "/develop-mq-remove"
|
||||
prompt:
|
||||
```
|
||||
@backing-service
|
||||
[요구사항]
|
||||
- "MQ설치결과서"를 보고 관련된 모든 리소스를 삭제
|
||||
- 현재 OS에 맞게 수행
|
||||
- 서브 에이젼트를 병렬로 수행하여 삭제
|
||||
- 결과파일은 생성할 필요 없고 화면에만 결과 표시
|
||||
[참고자료]
|
||||
- MQ설치결과서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 백엔드 개발 요청
|
||||
command: "/develop-dev-backend"
|
||||
prompt:
|
||||
```
|
||||
@dev-backend
|
||||
"백엔드개발가이드"에 따라 개발해 주세요.
|
||||
프롬프트에 '[개발정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
[개발정보]
|
||||
- 개발 아키텍처패턴
|
||||
- auth: Layered
|
||||
- bill-inquiry: Clean
|
||||
- product-change: Layered
|
||||
- kos-mock: Layered
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 백엔드 오류 해결 요청
|
||||
command: "/develop-fix-backend"
|
||||
prompt:
|
||||
```
|
||||
@fix as @back
|
||||
개발된 각 서비스와 common 모듈을 컴파일하고 에러를 해결해 주세요.
|
||||
- common 모듈 우선 수행
|
||||
- 각 서비스별로 서브 에이젠트를 병렬로 수행
|
||||
- 컴파일이 모두 성공할때까지 계속 수행
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서비스 실행파일 작성 요청
|
||||
command: "/develop-make-run-profile"
|
||||
prompt:
|
||||
```
|
||||
@test-backend
|
||||
'서비스실행파일작성가이드'에 따라 테스트를 해 주세요.
|
||||
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
|
||||
{안내메시지}
|
||||
[작성정보]
|
||||
- API Key
|
||||
- Claude: sk-ant-ap...
|
||||
- OpenAI: sk-proj-An4Q...
|
||||
- Open Weather Map: 1aa5b...
|
||||
- Kakao API Key: 5cdc24....
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 백엔드 테스트 요청
|
||||
command: "/develop-test-backend"
|
||||
prompt:
|
||||
```
|
||||
@test-backend
|
||||
'백엔드테스트가이드'에 따라 테스트를 해 주세요.
|
||||
프롬프트에 '[테스트정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
테스트 대상 서비스를 지정안하면 모든 서비스를 테스트 합니다.
|
||||
{안내메시지}
|
||||
'[테스트정보]'섹션 하위에 아래 예와 같이 테스트에 필요한 정보를 제시해 주세요.
|
||||
테스트 대상 서비스를 콤마로 구분하여 입력할 수 있으며 전체를 테스트 할 때는 '전체'라고 입력하세요.
|
||||
- 서비스: user-service
|
||||
- API Key
|
||||
- Claude: sk-ant-ap...
|
||||
- OpenAI: sk-proj-An4Q...
|
||||
- Open Weather Map: 1aa5b...
|
||||
- Kakao API Key: 5cdc24....
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 개발 요청
|
||||
command: "/develop-dev-front"
|
||||
prompt:
|
||||
```
|
||||
@dev-front
|
||||
"프론트엔드개발가이드"에 따라 개발해 주세요.
|
||||
프롬프트에 '[개발정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||
{안내메시지}
|
||||
'[개발정보]'섹션 하위에 아래 예와 같이 개발에 필요한 정보를 제시해 주세요.
|
||||
[개발정보]
|
||||
- 개발프레임워크: Typescript + React 18
|
||||
- UI프레임워크: MUI v5
|
||||
- 상태관리: Redux Toolkit
|
||||
- 라우팅: React Router v6
|
||||
- API통신: Axios
|
||||
- 스타일링: MUI + styled-components
|
||||
- 빌드도구: Vite
|
||||
```
|
||||
@ -1,7 +1,4 @@
|
||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||
Dload Upload Total Spent Left Speed
|
||||
|
||||
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드
|
||||
# 서비스실행프로파일작성가이드
|
||||
|
||||
[요청사항]
|
||||
- <수행원칙>을 준용하여 수행
|
||||
@ -151,8 +148,7 @@
|
||||
<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
|
||||
100 9115 100 9115 0 0 28105 0 --:--:-- --:--:-- --:--:-- 28219" />
|
||||
<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" />
|
||||
@ -177,4 +173,3 @@
|
||||
- MQ 유형 및 연결 정보
|
||||
- 연결에 필요한 호스트, 포트, 인증 정보
|
||||
- LoadBalancer Service External IP (해당하는 경우)
|
||||
|
||||
|
||||
41
claude/think-prompt.md
Normal file
41
claude/think-prompt.md
Normal file
@ -0,0 +1,41 @@
|
||||
# 서비스 기획 프롬프트
|
||||
|
||||
## 서비스 기획
|
||||
command: "/think-planning"
|
||||
prompt:
|
||||
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
|
||||
```
|
||||
아래 가이드를 참고하여 서비스 기획을 수행합니다.
|
||||
|
||||
https://github.com/cna-bootcamp/aiguide/blob/main/AI%ED%99%9C%EC%9A%A9%20%EC%84%9C%EB%B9%84%EC%8A%A4%20%EA%B8%B0%ED%9A%8D%20%EA%B0%80%EC%9D%B4%EB%93%9C.md
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 유저스토리 작성
|
||||
command: "/think-userstory"
|
||||
prompt:
|
||||
|
||||
```
|
||||
@document
|
||||
유저스토리를 작성하세요.
|
||||
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
|
||||
{안내메시지}
|
||||
'[요구사항]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
|
||||
[요구사항]
|
||||
Case 1) 이벤트스토밍을 피그마로 수행한 경우는 피그마 채널ID를 제공
|
||||
예) 피그마 채널ID 'abcde'에 접속하여 분석
|
||||
Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을 정리한 파일 경로를 제공
|
||||
예) 요구사항문서 'design/requirement.md'를 읽어 분석
|
||||
|
||||
프롬프트에 '[요구사항]'섹션이 있으면 아래와 같이 수행합니다.
|
||||
1. 요구사항 분석
|
||||
- 피그마 채널ID가 제공된 경우 figma MCP를 이용하여 해당 채널에 접속하여 분석
|
||||
- 요구사항문서 경로가 제공된 경우 해당 문서를 읽어 요구사항을 분석
|
||||
2. 유저스토리 작성
|
||||
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
|
||||
- 결과파일은 'design/userstory.md'에 생성
|
||||
|
||||
```
|
||||
|
||||
@ -32,4 +32,7 @@ dependencies {
|
||||
// Jackson for JSON
|
||||
api 'com.fasterxml.jackson.core:jackson-databind'
|
||||
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
// Swagger/OpenAPI
|
||||
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
|
||||
}
|
||||
|
||||
@ -56,13 +56,14 @@ public class JwtTokenProvider {
|
||||
* @param roles 역할 목록
|
||||
* @return Access Token
|
||||
*/
|
||||
public String createAccessToken(UUID userId, UUID storeId, String email, String name, List<String> roles) {
|
||||
|
||||
public String createAccessToken(Long userId, Long storeId, String email, String name, List<String> roles) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("storeId", storeId.toString())
|
||||
.claim("storeId", storeId != null ? storeId.toString() : null)
|
||||
.claim("email", email)
|
||||
.claim("name", name)
|
||||
.claim("roles", roles)
|
||||
@ -112,8 +113,9 @@ public class JwtTokenProvider {
|
||||
public UserPrincipal getUserPrincipalFromToken(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
UUID storeId = UUID.fromString(claims.get("storeId", String.class));
|
||||
Long userId = Long.parseLong(claims.getSubject());
|
||||
String storeIdStr = claims.get("storeId", String.class);
|
||||
Long storeId = storeIdStr != null ? Long.parseLong(storeIdStr) : null;
|
||||
String email = claims.get("email", String.class);
|
||||
String name = claims.get("name", String.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@ -24,12 +24,12 @@ public class UserPrincipal implements UserDetails {
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private final UUID userId;
|
||||
private final Long userId;
|
||||
|
||||
/**
|
||||
* 매장 ID
|
||||
*/
|
||||
private final UUID storeId;
|
||||
private final Long storeId;
|
||||
|
||||
/**
|
||||
* 사용자 이메일
|
||||
|
||||
@ -21,3 +21,8 @@ dependencies {
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
}
|
||||
|
||||
// 실행 JAR 파일명 설정
|
||||
bootJar {
|
||||
archiveFileName = 'content-service.jar'
|
||||
}
|
||||
|
||||
@ -23,6 +23,22 @@ public class ContentCommand {
|
||||
private Long eventDraftId;
|
||||
private String eventTitle;
|
||||
private String eventDescription;
|
||||
|
||||
/**
|
||||
* 업종 (예: "고깃집", "카페", "베이커리")
|
||||
*/
|
||||
private String industry;
|
||||
|
||||
/**
|
||||
* 지역 (예: "강남", "홍대", "서울")
|
||||
*/
|
||||
private String location;
|
||||
|
||||
/**
|
||||
* 트렌드 키워드 (최대 3개 권장, 예: ["할인", "신메뉴", "이벤트"])
|
||||
*/
|
||||
private List<String> trends;
|
||||
|
||||
private List<ImageStyle> styles;
|
||||
private List<Platform> platforms;
|
||||
}
|
||||
|
||||
@ -0,0 +1,288 @@
|
||||
package com.kt.event.content.biz.service;
|
||||
|
||||
import com.kt.event.content.biz.domain.Content;
|
||||
import com.kt.event.content.biz.domain.GeneratedImage;
|
||||
import com.kt.event.content.biz.domain.ImageStyle;
|
||||
import com.kt.event.content.biz.domain.Job;
|
||||
import com.kt.event.content.biz.domain.Platform;
|
||||
import com.kt.event.content.biz.dto.ContentCommand;
|
||||
import com.kt.event.content.biz.dto.JobInfo;
|
||||
import com.kt.event.content.biz.dto.RedisJobData;
|
||||
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
|
||||
import com.kt.event.content.biz.usecase.out.CDNUploader;
|
||||
import com.kt.event.content.biz.usecase.out.ContentWriter;
|
||||
import com.kt.event.content.biz.usecase.out.JobWriter;
|
||||
import com.kt.event.content.infra.gateway.client.HuggingFaceApiClient;
|
||||
import com.kt.event.content.infra.gateway.client.dto.HuggingFaceRequest;
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Hugging Face Inference API 이미지 생성 서비스
|
||||
*
|
||||
* Hugging Face Inference API를 사용하여 Stable Diffusion으로 이미지 생성 (무료)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile({"prod", "dev"}) // production 및 dev 환경에서 활성화 (local은 Mock 사용)
|
||||
public class HuggingFaceImageGenerator implements GenerateImagesUseCase {
|
||||
|
||||
private final HuggingFaceApiClient huggingFaceClient;
|
||||
private final CDNUploader cdnUploader;
|
||||
private final JobWriter jobWriter;
|
||||
private final ContentWriter contentWriter;
|
||||
private final CircuitBreaker circuitBreaker;
|
||||
|
||||
public HuggingFaceImageGenerator(
|
||||
HuggingFaceApiClient huggingFaceClient,
|
||||
CDNUploader cdnUploader,
|
||||
JobWriter jobWriter,
|
||||
ContentWriter contentWriter,
|
||||
@Qualifier("huggingfaceCircuitBreaker") CircuitBreaker circuitBreaker) {
|
||||
this.huggingFaceClient = huggingFaceClient;
|
||||
this.cdnUploader = cdnUploader;
|
||||
this.jobWriter = jobWriter;
|
||||
this.contentWriter = contentWriter;
|
||||
this.circuitBreaker = circuitBreaker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JobInfo execute(ContentCommand.GenerateImages command) {
|
||||
log.info("Hugging Face 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
|
||||
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
|
||||
|
||||
// Job 생성
|
||||
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Job job = Job.builder()
|
||||
.id(jobId)
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.jobType("image-generation")
|
||||
.status(Job.Status.PENDING)
|
||||
.progress(0)
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Job 저장
|
||||
RedisJobData jobData = RedisJobData.builder()
|
||||
.id(job.getId())
|
||||
.eventDraftId(job.getEventDraftId())
|
||||
.jobType(job.getJobType())
|
||||
.status(job.getStatus().name())
|
||||
.progress(job.getProgress())
|
||||
.createdAt(job.getCreatedAt())
|
||||
.updatedAt(job.getUpdatedAt())
|
||||
.build();
|
||||
|
||||
jobWriter.saveJob(jobData, 3600); // TTL 1시간
|
||||
log.info("Job 생성 완료: jobId={}", jobId);
|
||||
|
||||
// 비동기로 이미지 생성
|
||||
processImageGeneration(jobId, command);
|
||||
|
||||
return JobInfo.from(job);
|
||||
}
|
||||
|
||||
@Async
|
||||
private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
|
||||
try {
|
||||
log.info("Hugging Face 이미지 생성 시작: jobId={}", jobId);
|
||||
|
||||
// Content 생성 또는 조회
|
||||
Content content = Content.builder()
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.eventTitle(command.getEventDraftId() + " 이벤트")
|
||||
.eventDescription("AI 생성 이벤트 이미지")
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
Content savedContent = contentWriter.save(content);
|
||||
log.info("Content 생성 완료: contentId={}", savedContent.getId());
|
||||
|
||||
// 스타일 x 플랫폼 조합으로 이미지 생성
|
||||
List<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
|
||||
? command.getStyles()
|
||||
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
|
||||
|
||||
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
|
||||
? command.getPlatforms()
|
||||
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
|
||||
|
||||
List<GeneratedImage> images = new ArrayList<>();
|
||||
int totalCount = styles.size() * platforms.size();
|
||||
int currentCount = 0;
|
||||
|
||||
for (ImageStyle style : styles) {
|
||||
for (Platform platform : platforms) {
|
||||
currentCount++;
|
||||
|
||||
// 진행률 업데이트
|
||||
int progress = (currentCount * 100) / totalCount;
|
||||
jobWriter.updateJobStatus(jobId, "IN_PROGRESS", progress);
|
||||
|
||||
// Hugging Face로 이미지 생성
|
||||
String prompt = buildPrompt(command, style, platform);
|
||||
String imageUrl = generateImage(prompt, platform);
|
||||
|
||||
// GeneratedImage 저장
|
||||
GeneratedImage image = GeneratedImage.builder()
|
||||
.eventDraftId(command.getEventDraftId())
|
||||
.style(style)
|
||||
.platform(platform)
|
||||
.cdnUrl(imageUrl)
|
||||
.prompt(prompt)
|
||||
.selected(currentCount == 1) // 첫 번째 이미지를 선택
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.updatedAt(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
if (currentCount == 1) {
|
||||
image.select();
|
||||
}
|
||||
|
||||
GeneratedImage savedImage = contentWriter.saveImage(image);
|
||||
images.add(savedImage);
|
||||
log.info("이미지 생성 완료: imageId={}, style={}, platform={}, url={}",
|
||||
savedImage.getId(), style, platform, imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Job 완료
|
||||
String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
|
||||
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
|
||||
jobWriter.updateJobResult(jobId, resultMessage);
|
||||
log.info("Hugging Face Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Hugging Face 이미지 생성 실패: jobId={}", jobId, e);
|
||||
jobWriter.updateJobError(jobId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hugging Face로 이미지 생성
|
||||
*
|
||||
* @param prompt 이미지 생성 프롬프트
|
||||
* @param platform 플랫폼 (이미지 크기 결정)
|
||||
* @return 생성된 이미지 URL
|
||||
*/
|
||||
private String generateImage(String prompt, Platform platform) {
|
||||
try {
|
||||
// 플랫폼별 이미지 크기 설정
|
||||
int width = platform.getWidth();
|
||||
int height = platform.getHeight();
|
||||
|
||||
// Hugging Face API 요청
|
||||
HuggingFaceRequest request = HuggingFaceRequest.builder()
|
||||
.inputs(prompt)
|
||||
.parameters(HuggingFaceRequest.Parameters.builder()
|
||||
.negative_prompt("blurry, bad quality, distorted, ugly, low resolution")
|
||||
.width(width)
|
||||
.height(height)
|
||||
.guidance_scale(7.5)
|
||||
.num_inference_steps(50)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
log.info("Hugging Face API 호출: prompt={}, size={}x{}", prompt, width, height);
|
||||
|
||||
// 이미지 생성 (동기 방식)
|
||||
byte[] imageData = generateImageWithCircuitBreaker(request);
|
||||
log.info("Hugging Face 이미지 생성 완료: size={} bytes", imageData.length);
|
||||
|
||||
// Azure Blob Storage에 업로드
|
||||
String fileName = String.format("event-%s-%s-%s.png",
|
||||
platform.name().toLowerCase(),
|
||||
UUID.randomUUID().toString().substring(0, 8),
|
||||
System.currentTimeMillis());
|
||||
String azureCdnUrl = cdnUploader.upload(imageData, fileName);
|
||||
log.info("Azure CDN 업로드 완료: fileName={}, url={}", fileName, azureCdnUrl);
|
||||
|
||||
return azureCdnUrl;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Hugging Face 이미지 생성 실패: prompt={}", prompt, e);
|
||||
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 생성 프롬프트 구성
|
||||
*/
|
||||
private String buildPrompt(ContentCommand.GenerateImages command, ImageStyle style, Platform platform) {
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
|
||||
// 업종 정보 추가
|
||||
if (command.getIndustry() != null && !command.getIndustry().trim().isEmpty()) {
|
||||
prompt.append(command.getIndustry()).append(" ");
|
||||
}
|
||||
|
||||
// 기본 프롬프트
|
||||
prompt.append("event promotion image");
|
||||
|
||||
// 지역 정보 추가
|
||||
if (command.getLocation() != null && !command.getLocation().trim().isEmpty()) {
|
||||
prompt.append(" in ").append(command.getLocation());
|
||||
}
|
||||
|
||||
// 트렌드 키워드 추가 (최대 3개)
|
||||
if (command.getTrends() != null && !command.getTrends().isEmpty()) {
|
||||
prompt.append(", featuring ");
|
||||
int count = Math.min(3, command.getTrends().size());
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i > 0) prompt.append(", ");
|
||||
prompt.append(command.getTrends().get(i));
|
||||
}
|
||||
}
|
||||
|
||||
prompt.append(", ");
|
||||
|
||||
// 스타일별 프롬프트
|
||||
switch (style) {
|
||||
case FANCY:
|
||||
prompt.append("elegant, luxurious, premium design, vibrant colors, ");
|
||||
break;
|
||||
case SIMPLE:
|
||||
prompt.append("minimalist, clean design, simple layout, modern, ");
|
||||
break;
|
||||
case TRENDY:
|
||||
prompt.append("trendy, contemporary, stylish, modern design, ");
|
||||
break;
|
||||
}
|
||||
|
||||
// 플랫폼별 특성 추가
|
||||
prompt.append("optimized for ").append(platform.name().toLowerCase()).append(" platform, ");
|
||||
prompt.append("high quality, detailed, 4k resolution");
|
||||
|
||||
return prompt.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker로 보호된 Hugging Face 이미지 생성
|
||||
*
|
||||
* @param request Hugging Face 요청
|
||||
* @return 생성된 이미지 바이트 데이터
|
||||
*/
|
||||
private byte[] generateImageWithCircuitBreaker(HuggingFaceRequest request) {
|
||||
try {
|
||||
return circuitBreaker.executeSupplier(() -> huggingFaceClient.generateImage(request));
|
||||
} catch (CallNotPermittedException e) {
|
||||
log.error("Hugging Face Circuit Breaker가 OPEN 상태입니다. 이미지 생성 차단");
|
||||
throw new RuntimeException("Hugging Face API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
|
||||
} catch (Exception e) {
|
||||
log.error("Hugging Face 이미지 생성 실패", e);
|
||||
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
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