diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5d56e9d..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.claude/commands/deploy-actions-cicd-guide-back.md b/.claude/commands/deploy-actions-cicd-guide-back.md new file mode 100644 index 0000000..ae97c1d --- /dev/null +++ b/.claude/commands/deploy-actions-cicd-guide-back.md @@ -0,0 +1,17 @@ +--- +command: "/deploy-actions-cicd-guide-back" +description: "백엔드 GitHub Actions CI/CD 파이프라인 가이드 작성" +--- + +@cicd +'백엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요. + +프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. + +{안내메시지} +'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요. +[실행정보] +- ACR_NAME: acrdigitalgarage01 +- RESOURCE_GROUP: rg-digitalgarage-01 +- AKS_CLUSTER: aks-digitalgarage-01 +- NAMESPACE: phonebill-dg0500 diff --git a/.claude/commands/deploy-actions-cicd-guide-front.md b/.claude/commands/deploy-actions-cicd-guide-front.md new file mode 100644 index 0000000..b4e818a --- /dev/null +++ b/.claude/commands/deploy-actions-cicd-guide-front.md @@ -0,0 +1,18 @@ +--- +command: "/deploy-actions-cicd-guide-front" +description: "프론트엔드 GitHub Actions CI/CD 파이프라인 가이드 작성" +--- + +@cicd +'프론트엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요. + +프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. + +{안내메시지} +'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요. +[실행정보] +- SYSTEM_NAME: phonebill +- ACR_NAME: acrdigitalgarage01 +- RESOURCE_GROUP: rg-digitalgarage-01 +- AKS_CLUSTER: aks-digitalgarage-01 +- NAMESPACE: phonebill-dg0500 diff --git a/.claude/commands/deploy-build-image-back.md b/.claude/commands/deploy-build-image-back.md new file mode 100644 index 0000000..242c4a4 --- /dev/null +++ b/.claude/commands/deploy-build-image-back.md @@ -0,0 +1,7 @@ +--- +command: "/deploy-build-image-back" +description: "백엔드 컨테이너 이미지 작성" +--- + +@cicd +'백엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요. diff --git a/.claude/commands/deploy-build-image-front.md b/.claude/commands/deploy-build-image-front.md new file mode 100644 index 0000000..e2d8426 --- /dev/null +++ b/.claude/commands/deploy-build-image-front.md @@ -0,0 +1,7 @@ +--- +command: "/deploy-build-image-front" +description: "프론트엔드 컨테이너 이미지 작성" +--- + +@cicd +'프론트엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요. diff --git a/.claude/commands/deploy-help.md b/.claude/commands/deploy-help.md new file mode 100644 index 0000000..660195b --- /dev/null +++ b/.claude/commands/deploy-help.md @@ -0,0 +1,64 @@ +--- +command: "/deploy-help" +description: "배포 작업 순서 및 명령어 안내" +--- + +# 배포 작업 순서 + +## 컨테이너 이미지 작성 +### 백엔드 +/deploy-build-image-back +- 백엔드 서비스들의 컨테이너 이미지를 작성합니다 + +### 프론트엔드 +/deploy-build-image-front +- 프론트엔드 서비스의 컨테이너 이미지를 작성합니다 + +## 컨테이너 실행 가이드 작성 +### 백엔드 +/deploy-run-container-guide-back +- 백엔드 컨테이너 실행 가이드를 작성합니다 +- [실행정보] 섹션에 ACR명, VM 접속 정보 제공 필요 + +### 프론트엔드 +/deploy-run-container-guide-front +- 프론트엔드 컨테이너 실행 가이드를 작성합니다 +- [실행정보] 섹션에 시스템명, ACR명, VM 접속 정보 제공 필요 + +## Kubernetes 배포 가이드 작성 +### 백엔드 +/deploy-k8s-guide-back +- 백엔드 서비스 Kubernetes 배포 가이드를 작성합니다 +- [실행정보] 섹션에 ACR명, k8s명, 네임스페이스, 리소스 정보 제공 필요 + +### 프론트엔드 +/deploy-k8s-guide-front +- 프론트엔드 서비스 Kubernetes 배포 가이드를 작성합니다 +- [실행정보] 섹션에 시스템명, ACR명, k8s명, 네임스페이스, Gateway Host 정보 제공 필요 + +## CI/CD 파이프라인 작성 +### Jenkins CI/CD +#### 백엔드 +/deploy-jenkins-cicd-guide-back +- Jenkins를 이용한 백엔드 CI/CD 파이프라인 가이드를 작성합니다 +- [실행정보] 섹션에 ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요 + +#### 프론트엔드 +/deploy-jenkins-cicd-guide-front +- Jenkins를 이용한 프론트엔드 CI/CD 파이프라인 가이드를 작성합니다 +- [실행정보] 섹션에 SYSTEM_NAME, ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요 + +### GitHub Actions CI/CD +#### 백엔드 +/deploy-actions-cicd-guide-back +- GitHub Actions를 이용한 백엔드 CI/CD 파이프라인 가이드를 작성합니다 +- [실행정보] 섹션에 ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요 + +#### 프론트엔드 +/deploy-actions-cicd-guide-front +- GitHub Actions를 이용한 프론트엔드 CI/CD 파이프라인 가이드를 작성합니다 +- [실행정보] 섹션에 SYSTEM_NAME, ACR_NAME, RESOURCE_GROUP, AKS_CLUSTER, NAMESPACE 제공 필요 + +--- + +**참고**: 각 명령어 실행 시 [실행정보] 섹션에 필요한 정보를 함께 제공해야 합니다. diff --git a/.claude/commands/deploy-jenkins-cicd-guide-back.md b/.claude/commands/deploy-jenkins-cicd-guide-back.md new file mode 100644 index 0000000..96a9093 --- /dev/null +++ b/.claude/commands/deploy-jenkins-cicd-guide-back.md @@ -0,0 +1,17 @@ +--- +command: "/deploy-jenkins-cicd-guide-back" +description: "백엔드 Jenkins CI/CD 파이프라인 가이드 작성" +--- + +@cicd +'백엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요. + +프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. + +{안내메시지} +'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요. +[실행정보] +- ACR_NAME: acrdigitalgarage01 +- RESOURCE_GROUP: rg-digitalgarage-01 +- AKS_CLUSTER: aks-digitalgarage-01 +- NAMESPACE: phonebill-dg0500 diff --git a/.claude/commands/deploy-jenkins-cicd-guide-front.md b/.claude/commands/deploy-jenkins-cicd-guide-front.md new file mode 100644 index 0000000..af3807d --- /dev/null +++ b/.claude/commands/deploy-jenkins-cicd-guide-front.md @@ -0,0 +1,18 @@ +--- +command: "/deploy-jenkins-cicd-guide-front" +description: "프론트엔드 Jenkins CI/CD 파이프라인 가이드 작성" +--- + +@cicd +'프론트엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요. + +프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. + +{안내메시지} +'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요. +[실행정보] +- SYSTEM_NAME: phonebill +- ACR_NAME: acrdigitalgarage01 +- RESOURCE_GROUP: rg-digitalgarage-01 +- AKS_CLUSTER: aks-digitalgarage-01 +- NAMESPACE: phonebill-dg0500 diff --git a/.claude/commands/deploy-k8s-guide-back.md b/.claude/commands/deploy-k8s-guide-back.md new file mode 100644 index 0000000..e5f4009 --- /dev/null +++ b/.claude/commands/deploy-k8s-guide-back.md @@ -0,0 +1,19 @@ +--- +command: "/deploy-k8s-guide-back" +description: "백엔드 Kubernetes 배포 가이드 작성" +--- + +@cicd +'백엔드배포가이드'에 따라 백엔드 서비스 배포 방법을 작성해 주세요. + +프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. + +{안내메시지} +'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요. +[실행정보] +- ACR명: acrdigitalgarage01 +- k8s명: aks-digitalgarage-01 +- 네임스페이스: tripgen +- 파드수: 2 +- 리소스(CPU): 256m/1024m +- 리소스(메모리): 256Mi/1024Mi diff --git a/.claude/commands/deploy-k8s-guide-front.md b/.claude/commands/deploy-k8s-guide-front.md new file mode 100644 index 0000000..0d62215 --- /dev/null +++ b/.claude/commands/deploy-k8s-guide-front.md @@ -0,0 +1,21 @@ +--- +command: "/deploy-k8s-guide-front" +description: "프론트엔드 Kubernetes 배포 가이드 작성" +--- + +@cicd +'프론트엔드배포가이드'에 따라 프론트엔드 서비스 배포 방법을 작성해 주세요. + +프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. + +{안내메시지} +'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요. +[실행정보] +- 시스템명: tripgen +- ACR명: acrdigitalgarage01 +- k8s명: aks-digitalgarage-01 +- 네임스페이스: tripgen +- 파드수: 2 +- 리소스(CPU): 256m/1024m +- 리소스(메모리): 256Mi/1024Mi +- Gateway Host: http://tripgen-api.20.214.196.128.nip.io diff --git a/.claude/commands/deploy-run-container-guide-back.md b/.claude/commands/deploy-run-container-guide-back.md new file mode 100644 index 0000000..47dc409 --- /dev/null +++ b/.claude/commands/deploy-run-container-guide-back.md @@ -0,0 +1,18 @@ +--- +command: "/deploy-run-container-guide-back" +description: "백엔드 컨테이너 실행방법 가이드 작성" +--- + +@cicd +'백엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요. + +프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. + +{안내메시지} +'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요. +[실행정보] +- ACR명: acrdigitalgarage01 +- VM + - KEY파일: ~/home/bastion-dg0500 + - USERID: azureuser + - IP: 4.230.5.6 diff --git a/.claude/commands/deploy-run-container-guide-front.md b/.claude/commands/deploy-run-container-guide-front.md new file mode 100644 index 0000000..ff3f3d4 --- /dev/null +++ b/.claude/commands/deploy-run-container-guide-front.md @@ -0,0 +1,19 @@ +--- +command: "/deploy-run-container-guide-front" +description: "프론트엔드 컨테이너 실행방법 가이드 작성" +--- + +@cicd +'프론트엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요. + +프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. + +{안내메시지} +'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요. +[실행정보] +- 시스템명: tripgen +- ACR명: acrdigitalgarage01 +- VM + - KEY파일: ~/home/bastion-dg0500 + - USERID: azureuser + - IP: 4.230.5.6 diff --git a/.claude/commands/design-api.md b/.claude/commands/design-api.md index 5375bf7..750eae3 100644 --- a/.claude/commands/design-api.md +++ b/.claude/commands/design-api.md @@ -1,3 +1,6 @@ +--- +command: "/design-api" +--- @architecture API를 설계해 주세요: -- '공통설계원칙'과 'API설계가이드'를 준용하여 설계 +- '공통설계원칙'과 'API설계가이드'를 준용하여 설계 \ No newline at end of file diff --git a/.claude/commands/design-class.md b/.claude/commands/design-class.md index dc76da9..178bdb1 100644 --- a/.claude/commands/design-class.md +++ b/.claude/commands/design-class.md @@ -1,3 +1,6 @@ +--- +command: "/design-class" +--- @architecture '공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요. 프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다. @@ -9,4 +12,4 @@ - User: Layered - Trip: Clean - Location: Layered - - AI: Layered + - AI: Layered \ No newline at end of file diff --git a/.claude/commands/design-data.md b/.claude/commands/design-data.md index 8d9fd77..b5ff1dd 100644 --- a/.claude/commands/design-data.md +++ b/.claude/commands/design-data.md @@ -1,3 +1,6 @@ +--- +command: "/design-data" +--- @architecture 데이터 설계를 해주세요: -- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계 +- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계 \ No newline at end of file diff --git a/.claude/commands/design-fix-prototype.md b/.claude/commands/design-fix-prototype.md index d1ddb8a..5cc1890 100644 --- a/.claude/commands/design-fix-prototype.md +++ b/.claude/commands/design-fix-prototype.md @@ -1,5 +1,8 @@ +--- +command: "/design-fix-prototype" +--- @fix as @front '[오류내용]'섹션에 제공된 오류를 해결해 주세요. 프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시 {안내메시지} -'[오류내용]'섹션 하위에 오류 내용을 제공 +'[오류내용]'섹션 하위에 오류 내용을 제공 \ No newline at end of file diff --git a/.claude/commands/design-front.md b/.claude/commands/design-front.md index 67bc0a5..8dd99c9 100644 --- a/.claude/commands/design-front.md +++ b/.claude/commands/design-front.md @@ -1,3 +1,6 @@ +--- +command: "/design-front" +--- @plan as @front '프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요. 프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다. @@ -13,4 +16,4 @@ - ai service: http://localhost:8084/v3/api-docs [요구사항] - 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시 -- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기 +- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기 \ No newline at end of file diff --git a/.claude/commands/design-high-level.md b/.claude/commands/design-high-level.md index d7028b1..0debc5e 100644 --- a/.claude/commands/design-high-level.md +++ b/.claude/commands/design-high-level.md @@ -1,6 +1,9 @@ +--- +command: "/design-high-level" +--- @architecture 'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요. 'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요. {안내메시지} 아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요. -- CLOUD: Azure +- CLOUD: Azure \ No newline at end of file diff --git a/.claude/commands/design-improve-prototype.md b/.claude/commands/design-improve-prototype.md index 0d1b31b..22bc079 100644 --- a/.claude/commands/design-improve-prototype.md +++ b/.claude/commands/design-improve-prototype.md @@ -1,5 +1,8 @@ +--- +command: "/design-improve-prototype" +--- @improve as @front '[개선내용]'섹션에 있는 내용을 개선해 주세요. 프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시 {안내메시지} -'[개선내용]'섹션 하위에 개선할 내용을 제공 +'[개선내용]'섹션 하위에 개선할 내용을 제공 \ No newline at end of file diff --git a/.claude/commands/design-improve-userstory.md b/.claude/commands/design-improve-userstory.md index a1055f2..73fd453 100644 --- a/.claude/commands/design-improve-userstory.md +++ b/.claude/commands/design-improve-userstory.md @@ -1,2 +1,5 @@ +--- +command: "/design-improve-userstory" +--- @analyze as @front 프로토타입을 웹브라우저에서 분석한 후, -@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오. +@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오. \ No newline at end of file diff --git a/.claude/commands/design-logical.md b/.claude/commands/design-logical.md index 28f15e9..3d50c8f 100644 --- a/.claude/commands/design-logical.md +++ b/.claude/commands/design-logical.md @@ -1,3 +1,6 @@ +--- +command: "/design-logical" +--- @architecture 논리 아키텍처를 설계해 주세요: -- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계 +- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계 \ No newline at end of file diff --git a/.claude/commands/design-pattern.md b/.claude/commands/design-pattern.md index 06ed88d..decb145 100644 --- a/.claude/commands/design-pattern.md +++ b/.claude/commands/design-pattern.md @@ -1,3 +1,6 @@ +--- +command: "/design-pattern" +--- @design-pattern 클라우드 아키텍처 패턴 적용 방안을 작성해 주세요: -- '클라우드아키텍처패턴선정가이드'를 준용하여 작성 +- '클라우드아키텍처패턴선정가이드'를 준용하여 작성 \ No newline at end of file diff --git a/.claude/commands/design-physical.md b/.claude/commands/design-physical.md index 2dc8a51..7df5bca 100644 --- a/.claude/commands/design-physical.md +++ b/.claude/commands/design-physical.md @@ -1,6 +1,9 @@ +--- +command: "/design-physical" +--- @architecture '물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요. 'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요. {안내메시지} 아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요. -- CLOUD: Azure +- CLOUD: Azure \ No newline at end of file diff --git a/.claude/commands/design-prototype.md b/.claude/commands/design-prototype.md index f43547f..dbd24a0 100644 --- a/.claude/commands/design-prototype.md +++ b/.claude/commands/design-prototype.md @@ -1,3 +1,6 @@ +--- +command: "/design-prototype" +--- @prototype 프로토타입을 작성해 주세요: -- '프로토타입작성가이드'를 준용하여 작성 +- '프로토타입작성가이드'를 준용하여 작성 \ No newline at end of file diff --git a/.claude/commands/design-seq-inner.md b/.claude/commands/design-seq-inner.md index 5583610..d2bc4ac 100644 --- a/.claude/commands/design-seq-inner.md +++ b/.claude/commands/design-seq-inner.md @@ -1,3 +1,6 @@ +--- +command: "/design-seq-inner" +--- @architecture 내부 시퀀스 설계를 해 주세요: -- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계 +- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계 \ No newline at end of file diff --git a/.claude/commands/design-seq-outer.md b/.claude/commands/design-seq-outer.md index 0546370..8e05435 100644 --- a/.claude/commands/design-seq-outer.md +++ b/.claude/commands/design-seq-outer.md @@ -1,3 +1,6 @@ +--- +command: "/design-seq-outer" +--- @architecture 외부 시퀀스 설계를 해 주세요: -- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계 +- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계 \ No newline at end of file diff --git a/.claude/commands/design-test-prototype.md b/.claude/commands/design-test-prototype.md index bd45346..350788a 100644 --- a/.claude/commands/design-test-prototype.md +++ b/.claude/commands/design-test-prototype.md @@ -1,2 +1,5 @@ +--- +command: "/design-test-prototype" +--- @test-front -프로토타입을 테스트 해 주세요. +프로토타입을 테스트 해 주세요. \ No newline at end of file diff --git a/.claude/commands/design-uiux.md b/.claude/commands/design-uiux.md index 2b1c387..d68d857 100644 --- a/.claude/commands/design-uiux.md +++ b/.claude/commands/design-uiux.md @@ -1,3 +1,6 @@ +--- +command: "/design-uiux" +--- @uiux UI/UX 설계를 해주세요: -- 'UI/UX설계가이드'를 준용하여 작성 +- 'UI/UX설계가이드'를 준용하여 작성 \ No newline at end of file diff --git a/.claude/commands/design-update-uiux.md b/.claude/commands/design-update-uiux.md index 6994cd9..afd7cf9 100644 --- a/.claude/commands/design-update-uiux.md +++ b/.claude/commands/design-update-uiux.md @@ -1,2 +1,5 @@ +--- +command: "/design-update-uiux" +--- @document @front -현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요. +현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요. \ No newline at end of file diff --git a/.claude/commands/develop-make-run-profile.md b/.claude/commands/develop-make-run-profile.md index 65740e5..06b2768 100644 --- a/.claude/commands/develop-make-run-profile.md +++ b/.claude/commands/develop-make-run-profile.md @@ -1,5 +1,5 @@ @test-backend -'서비스실행파일작성가이드'에 따라 테스트를 해 주세요. +'서비스실행프로파일작성가이드'에 따라 테스트를 해 주세요. 프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요. DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요. {안내메시지} diff --git a/.claude/commands/think-help.md b/.claude/commands/think-help.md index 49bc697..17ad05a 100644 --- a/.claude/commands/think-help.md +++ b/.claude/commands/think-help.md @@ -1,3 +1,6 @@ +--- +command: "/think-help" +--- 기획 작업 순서 1단계: 서비스 기획 diff --git a/.claude/commands/think-planning.md b/.claude/commands/think-planning.md index c40eaec..beec938 100644 --- a/.claude/commands/think-planning.md +++ b/.claude/commands/think-planning.md @@ -1,3 +1,6 @@ +--- +command: "/think-planning" +--- 아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다. ``` 아래 가이드를 참고하여 서비스 기획을 수행합니다. diff --git a/.claude/commands/think-userstory.md b/.claude/commands/think-userstory.md index abdcb97..a002c30 100644 --- a/.claude/commands/think-userstory.md +++ b/.claude/commands/think-userstory.md @@ -1,3 +1,7 @@ +--- +command: "/think-userstory" +--- +``` @document 유저스토리를 작성하세요. 프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다. @@ -16,3 +20,5 @@ Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을 2. 유저스토리 작성 - '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성 - 결과파일은 'design/userstory.md'에 생성 + +``` diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8d1f14d..f0a5018 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,40 @@ "Bash(git add:*)", "Bash(git commit:*)", "Bash(git push)", - "Bash(git pull:*)" + "Bash(git pull:*)", + "Bash(netstat:*)", + "Bash(findstr:*)", + "Bash(./gradlew analytics-service:compileJava:*)", + "Bash(python -m json.tool:*)", + "Bash(powershell:*)" + "Bash(./gradlew participation-service:compileJava:*)", + "Bash(find:*)", + "Bash(netstat:*)", + "Bash(findstr:*)", + "Bash(docker-compose up:*)", + "Bash(docker --version:*)", + "Bash(timeout 60 bash:*)", + "Bash(docker ps:*)", + "Bash(docker exec:*)", + "Bash(docker-compose down:*)", + "Bash(git rm:*)", + "Bash(git restore:*)", + "Bash(./gradlew participation-service:test:*)", + "Bash(timeout 30 bash:*)", + "Bash(helm list:*)", + "Bash(helm upgrade:*)", + "Bash(helm repo add:*)", + "Bash(helm repo update:*)", + "Bash(kubectl get:*)", + "Bash(python3:*)", + "Bash(timeout 120 bash -c 'while true; do sleep 5; kubectl get pods -n kt-event-marketing | grep kafka | grep -v Running && continue; echo \"\"\"\"All Kafka pods are Running!\"\"\"\"; break; done')", + "Bash(kubectl delete:*)", + "Bash(kubectl logs:*)", + "Bash(kubectl describe:*)", + "Bash(kubectl exec:*)", + "mcp__context7__resolve-library-id", + "mcp__context7__get-library-docs", + "Bash(python -m json.tool:*)" ], "deny": [], "ask": [] diff --git a/.gitignore b/.gitignore index 2a41541..9f987d9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ yarn-error.log* # IDE .idea/ .vscode/ +.run/ *.swp *.swo *~ @@ -20,6 +21,23 @@ Thumbs.db dist/ build/ *.log +.gradle/ +logs/ + +# Gradle +.gradle/ +!gradle/wrapper/gradle-wrapper.jar + +# Logs +logs/ +*.log + +# Gradle +.gradle/ +gradle-app.setting +!gradle-wrapper.jar +!gradle-wrapper.properties +.gradletasknamecache # Environment .env @@ -30,3 +48,18 @@ build/ tmp/ temp/ *.tmp + +# Kubernetes Secrets (민감한 정보 포함) +k8s/**/secret.yaml +k8s/**/*-secret.yaml +k8s/**/*-prod.yaml +k8s/**/*-dev.yaml +k8s/**/*-local.yaml + +# IntelliJ 실행 프로파일 (민감한 환경 변수 포함 가능) +.run/*.run.xml + +# Gradle (로컬 환경 설정) +gradle.properties +*.hprof +test-data.json diff --git a/.gradle/8.10/checksums/checksums.lock b/.gradle/8.10/checksums/checksums.lock deleted file mode 100644 index a1da254..0000000 Binary files a/.gradle/8.10/checksums/checksums.lock and /dev/null differ diff --git a/.gradle/8.10/checksums/md5-checksums.bin b/.gradle/8.10/checksums/md5-checksums.bin deleted file mode 100644 index 589eb50..0000000 Binary files a/.gradle/8.10/checksums/md5-checksums.bin and /dev/null differ diff --git a/.gradle/8.10/checksums/sha1-checksums.bin b/.gradle/8.10/checksums/sha1-checksums.bin deleted file mode 100644 index 73ed738..0000000 Binary files a/.gradle/8.10/checksums/sha1-checksums.bin and /dev/null differ diff --git a/.gradle/8.10/dependencies-accessors/gc.properties b/.gradle/8.10/dependencies-accessors/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/.gradle/8.10/executionHistory/executionHistory.bin b/.gradle/8.10/executionHistory/executionHistory.bin deleted file mode 100644 index 472f852..0000000 Binary files a/.gradle/8.10/executionHistory/executionHistory.bin and /dev/null differ diff --git a/.gradle/8.10/executionHistory/executionHistory.lock b/.gradle/8.10/executionHistory/executionHistory.lock deleted file mode 100644 index a97ecba..0000000 Binary files a/.gradle/8.10/executionHistory/executionHistory.lock and /dev/null differ diff --git a/.gradle/8.10/fileChanges/last-build.bin b/.gradle/8.10/fileChanges/last-build.bin deleted file mode 100644 index f76dd23..0000000 Binary files a/.gradle/8.10/fileChanges/last-build.bin and /dev/null differ diff --git a/.gradle/8.10/fileHashes/fileHashes.bin b/.gradle/8.10/fileHashes/fileHashes.bin deleted file mode 100644 index fc087d0..0000000 Binary files a/.gradle/8.10/fileHashes/fileHashes.bin and /dev/null differ diff --git a/.gradle/8.10/fileHashes/fileHashes.lock b/.gradle/8.10/fileHashes/fileHashes.lock deleted file mode 100644 index e6a5ac0..0000000 Binary files a/.gradle/8.10/fileHashes/fileHashes.lock and /dev/null differ diff --git a/.gradle/8.10/fileHashes/resourceHashesCache.bin b/.gradle/8.10/fileHashes/resourceHashesCache.bin deleted file mode 100644 index 4fd9dd7..0000000 Binary files a/.gradle/8.10/fileHashes/resourceHashesCache.bin and /dev/null differ diff --git a/.gradle/8.10/gc.properties b/.gradle/8.10/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/.gradle/9.1.0/checksums/checksums.lock b/.gradle/9.1.0/checksums/checksums.lock deleted file mode 100644 index 3d9ab52..0000000 Binary files a/.gradle/9.1.0/checksums/checksums.lock and /dev/null differ diff --git a/.gradle/9.1.0/executionHistory/executionHistory.bin b/.gradle/9.1.0/executionHistory/executionHistory.bin deleted file mode 100644 index c3b4cb1..0000000 Binary files a/.gradle/9.1.0/executionHistory/executionHistory.bin and /dev/null differ diff --git a/.gradle/9.1.0/executionHistory/executionHistory.lock b/.gradle/9.1.0/executionHistory/executionHistory.lock deleted file mode 100644 index 4cc7cd5..0000000 Binary files a/.gradle/9.1.0/executionHistory/executionHistory.lock and /dev/null differ diff --git a/.gradle/9.1.0/fileChanges/last-build.bin b/.gradle/9.1.0/fileChanges/last-build.bin deleted file mode 100644 index f76dd23..0000000 Binary files a/.gradle/9.1.0/fileChanges/last-build.bin and /dev/null differ diff --git a/.gradle/9.1.0/fileHashes/fileHashes.bin b/.gradle/9.1.0/fileHashes/fileHashes.bin deleted file mode 100644 index 5c96b1a..0000000 Binary files a/.gradle/9.1.0/fileHashes/fileHashes.bin and /dev/null differ diff --git a/.gradle/9.1.0/fileHashes/fileHashes.lock b/.gradle/9.1.0/fileHashes/fileHashes.lock deleted file mode 100644 index abbb4d0..0000000 Binary files a/.gradle/9.1.0/fileHashes/fileHashes.lock and /dev/null differ diff --git a/.gradle/9.1.0/gc.properties b/.gradle/9.1.0/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock deleted file mode 100644 index 5885331..0000000 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and /dev/null differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties deleted file mode 100644 index 80e1268..0000000 --- a/.gradle/buildOutputCleanup/cache.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Thu Oct 23 17:51:21 KST 2025 -gradle.version=8.10 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin deleted file mode 100644 index 695f9c2..0000000 Binary files a/.gradle/buildOutputCleanup/outputFiles.bin and /dev/null differ diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe deleted file mode 100644 index 372a73a..0000000 Binary files a/.gradle/file-system.probe and /dev/null differ diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml index 20b0261..8102290 100644 --- a/.run/ParticipationServiceApplication.run.xml +++ b/.run/ParticipationServiceApplication.run.xml @@ -1,29 +1,69 @@ - - diff --git a/.run/analytics-service.run.xml b/.run/analytics-service.run.xml new file mode 100644 index 0000000..15941a1 --- /dev/null +++ b/.run/analytics-service.run.xml @@ -0,0 +1,84 @@ + + + + + + + + true + true + + + + + false + false + + + diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 6b665aa..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "liveServer.settings.port": 5501 -} diff --git a/ai-service/build.gradle b/ai-service/build.gradle index a39127e..ffa12b5 100644 --- a/ai-service/build.gradle +++ b/ai-service/build.gradle @@ -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' } diff --git a/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java b/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java new file mode 100644 index 0000000..be8b721 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java @@ -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); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java b/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java new file mode 100644 index 0000000..870b4b1 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java @@ -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 executeWithCircuitBreaker( + String circuitBreakerName, + Supplier supplier, + Supplier 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 executeWithCircuitBreaker(String circuitBreakerName, Supplier supplier) { + return executeWithCircuitBreaker(circuitBreakerName, supplier, null); + } + + /** + * Circuit Breaker 상태 조회 + */ + public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + return circuitBreaker.getState(); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java b/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java new file mode 100644 index 0000000..d7860cf --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java @@ -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 industryTrends = List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("고객 만족도 향상") + .relevance(0.8) + .description(industry + " 업종에서 고객 만족도가 중요한 트렌드입니다") + .build(), + TrendAnalysis.TrendKeyword.builder() + .keyword("디지털 마케팅") + .relevance(0.75) + .description("SNS 및 온라인 마케팅이 효과적입니다") + .build() + ); + + List regionalTrends = List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("지역 커뮤니티") + .relevance(0.7) + .description(region + " 지역 커뮤니티 참여가 효과적입니다") + .build() + ); + + List seasonalTrends = List.of( + TrendAnalysis.TrendKeyword.builder() + .keyword("시즌 이벤트") + .relevance(0.85) + .description("계절 특성을 반영한 이벤트가 효과적입니다") + .build() + ); + + return TrendAnalysis.builder() + .industryTrends(industryTrends) + .regionalTrends(regionalTrends) + .seasonalTrends(seasonalTrends) + .build(); + } + + /** + * 기본 이벤트 추천안 반환 + */ + public List getDefaultRecommendations(String objective, String industry) { + log.info("Fallback: 기본 이벤트 추천안 반환 - objective={}, industry={}", objective, industry); + + List 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(); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java b/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java new file mode 100644 index 0000000..abc2137 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java @@ -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 + ); +} diff --git a/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java b/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java new file mode 100644 index 0000000..f68466c --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java @@ -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회) + ); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java new file mode 100644 index 0000000..6dd394b --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java @@ -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 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; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java new file mode 100644 index 0000000..d587474 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java @@ -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; + + /** + * 모델명 + */ + 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; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java b/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java new file mode 100644 index 0000000..c4e7b8d --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java @@ -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(); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java b/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java new file mode 100644 index 0000000..16de92f --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java @@ -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; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java b/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..23df4d9 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java @@ -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 consumerFactory() { + Map 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 kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java b/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java new file mode 100644 index 0000000..1790966 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java @@ -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 redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate 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; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java b/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java new file mode 100644 index 0000000..08e9b2e --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java @@ -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; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java b/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java new file mode 100644 index 0000000..4523c0d --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java @@ -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)); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/controller/HealthController.java b/ai-service/src/main/java/com/kt/ai/controller/HealthController.java new file mode 100644 index 0000000..b54b890 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/controller/HealthController.java @@ -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 redisTemplate; + + /** + * 서비스 헬스체크 + */ + @Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인") + @GetMapping("/api/v1/ai-service/health") + public ResponseEntity 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; + } + } +} diff --git a/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java b/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java new file mode 100644 index 0000000..aba5cc0 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java @@ -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 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> createTestJob(@PathVariable String jobId) { + log.info("Job 테스트 데이터 생성 요청: jobId={}", jobId); + + Map 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); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/controller/InternalRecommendationController.java b/ai-service/src/main/java/com/kt/ai/controller/InternalRecommendationController.java new file mode 100644 index 0000000..883d1d8 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/controller/InternalRecommendationController.java @@ -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 redisTemplate; + + /** + * AI 추천 결과 조회 + */ + @Operation(summary = "AI 추천 결과 조회", description = "Redis에 캐시된 AI 추천 결과 조회") + @GetMapping("/{eventId}") + public ResponseEntity 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> debugRedisKeys() { + log.info("Redis 키 디버그 요청"); + + Map result = new HashMap<>(); + + try { + // 모든 ai:* 키 조회 + Set keys = redisTemplate.keys("ai:*"); + result.put("totalKeys", keys != null ? keys.size() : 0); + result.put("keys", keys); + + // 특정 키의 값 조회 + if (keys != null && !keys.isEmpty()) { + Map 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> debugRedisKey(@PathVariable String key) { + log.info("Redis 특정 키 조회 요청: key={}", key); + + Map 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> searchAllDatabases() { + log.info("모든 Redis database 검색 시작"); + + Map result = new HashMap<>(); + Map> 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 keyBytes = connection.keys("ai:*".getBytes()); + if (keyBytes != null && !keyBytes.isEmpty()) { + Set 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> createTestData(@PathVariable String eventId) { + log.info("테스트 데이터 생성 요청: eventId={}", eventId); + + Map 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); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/AIServiceException.java b/ai-service/src/main/java/com/kt/ai/exception/AIServiceException.java new file mode 100644 index 0000000..3167bf2 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/AIServiceException.java @@ -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; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/CircuitBreakerOpenException.java b/ai-service/src/main/java/com/kt/ai/exception/CircuitBreakerOpenException.java new file mode 100644 index 0000000..82b2118 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/CircuitBreakerOpenException.java @@ -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); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/GlobalExceptionHandler.java b/ai-service/src/main/java/com/kt/ai/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e00c26c --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/GlobalExceptionHandler.java @@ -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 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 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 handleCircuitBreakerOpenException(CircuitBreakerOpenException ex) { + log.error("Circuit breaker open: {}", ex.getMessage()); + + Map 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 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 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 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); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/JobNotFoundException.java b/ai-service/src/main/java/com/kt/ai/exception/JobNotFoundException.java new file mode 100644 index 0000000..b574dca --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/JobNotFoundException.java @@ -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); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/exception/RecommendationNotFoundException.java b/ai-service/src/main/java/com/kt/ai/exception/RecommendationNotFoundException.java new file mode 100644 index 0000000..feba7e5 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/exception/RecommendationNotFoundException.java @@ -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); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/kafka/consumer/AIJobConsumer.java b/ai-service/src/main/java/com/kt/ai/kafka/consumer/AIJobConsumer.java new file mode 100644 index 0000000..2b82f8a --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/kafka/consumer/AIJobConsumer.java @@ -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로 이동) + } + } +} diff --git a/ai-service/src/main/java/com/kt/ai/kafka/message/AIJobMessage.java b/ai-service/src/main/java/com/kt/ai/kafka/message/AIJobMessage.java new file mode 100644 index 0000000..e0165d6 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/kafka/message/AIJobMessage.java @@ -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; +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/AIRecommendationResult.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/AIRecommendationResult.java new file mode 100644 index 0000000..294dafa --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/AIRecommendationResult.java @@ -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 recommendations; + + /** + * 생성 시각 + */ + private LocalDateTime generatedAt; + + /** + * 캐시 만료 시각 (생성 시각 + 24시간) + */ + private LocalDateTime expiresAt; + + /** + * 사용된 AI 제공자 + */ + private AIProvider aiProvider; +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/ErrorResponse.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/ErrorResponse.java new file mode 100644 index 0000000..612093b --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/ErrorResponse.java @@ -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 details; +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/EventRecommendation.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/EventRecommendation.java new file mode 100644 index 0000000..284793f --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/EventRecommendation.java @@ -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 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 breakdown; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/ExpectedMetrics.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/ExpectedMetrics.java new file mode 100644 index 0000000..e7a2a6b --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/ExpectedMetrics.java @@ -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; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/HealthCheckResponse.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/HealthCheckResponse.java new file mode 100644 index 0000000..a8cc11f --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/HealthCheckResponse.java @@ -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; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/JobStatusResponse.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/JobStatusResponse.java new file mode 100644 index 0000000..0bbe149 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/JobStatusResponse.java @@ -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; +} diff --git a/ai-service/src/main/java/com/kt/ai/model/dto/response/TrendAnalysis.java b/ai-service/src/main/java/com/kt/ai/model/dto/response/TrendAnalysis.java new file mode 100644 index 0000000..aa5c99d --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/dto/response/TrendAnalysis.java @@ -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 industryTrends; + + /** + * 지역 트렌드 키워드 (최대 5개) + */ + private List regionalTrends; + + /** + * 시즌 트렌드 키워드 (최대 5개) + */ + private List seasonalTrends; + + /** + * 트렌드 키워드 정보 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TrendKeyword { + /** + * 트렌드 키워드 + */ + private String keyword; + + /** + * 연관도 (0-1) + */ + private Double relevance; + + /** + * 트렌드 설명 + */ + private String description; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/AIProvider.java b/ai-service/src/main/java/com/kt/ai/model/enums/AIProvider.java new file mode 100644 index 0000000..1bc7084 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/AIProvider.java @@ -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 +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/CircuitBreakerState.java b/ai-service/src/main/java/com/kt/ai/model/enums/CircuitBreakerState.java new file mode 100644 index 0000000..a2120fc --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/CircuitBreakerState.java @@ -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 +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/EventMechanicsType.java b/ai-service/src/main/java/com/kt/ai/model/enums/EventMechanicsType.java new file mode 100644 index 0000000..e027024 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/EventMechanicsType.java @@ -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 +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/JobStatus.java b/ai-service/src/main/java/com/kt/ai/model/enums/JobStatus.java new file mode 100644 index 0000000..0381d80 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/JobStatus.java @@ -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 +} diff --git a/ai-service/src/main/java/com/kt/ai/model/enums/ServiceStatus.java b/ai-service/src/main/java/com/kt/ai/model/enums/ServiceStatus.java new file mode 100644 index 0000000..f5bbba6 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/model/enums/ServiceStatus.java @@ -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 +} diff --git a/ai-service/src/main/java/com/kt/ai/service/AIRecommendationService.java b/ai-service/src/main/java/com/kt/ai/service/AIRecommendationService.java new file mode 100644 index 0000000..1f56bf7 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/service/AIRecommendationService.java @@ -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 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 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 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 parseRecommendationResponse(String responseText) { + try { + // JSON 부분만 추출 + String jsonText = extractJsonFromMarkdown(responseText); + + // JSON 파싱 + JsonNode rootNode = objectMapper.readTree(jsonText); + JsonNode recommendationsNode = rootNode.get("recommendations"); + + List 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 promotionChannels = new ArrayList<>(); + JsonNode channelsNode = node.get("promotionChannels"); + if (channelsNode != null && channelsNode.isArray()) { + channelsNode.forEach(channel -> promotionChannels.add(channel.asText())); + } + + // Breakdown 파싱 + Map 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(); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/service/CacheService.java b/ai-service/src/main/java/com/kt/ai/service/CacheService.java new file mode 100644 index 0000000..9b36d39 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/service/CacheService.java @@ -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 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); + } +} diff --git a/ai-service/src/main/java/com/kt/ai/service/JobStatusService.java b/ai-service/src/main/java/com/kt/ai/service/JobStatusService.java new file mode 100644 index 0000000..cf1e332 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/service/JobStatusService.java @@ -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; + }; + } +} diff --git a/ai-service/src/main/java/com/kt/ai/service/TrendAnalysisService.java b/ai-service/src/main/java/com/kt/ai/service/TrendAnalysisService.java new file mode 100644 index 0000000..dc82b99 --- /dev/null +++ b/ai-service/src/main/java/com/kt/ai/service/TrendAnalysisService.java @@ -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 parseTrendKeywords(JsonNode arrayNode) { + List 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; + } +} diff --git a/ai-service/src/main/resources/application.yml b/ai-service/src/main/resources/application.yml new file mode 100644 index 0000000..0da6277 --- /dev/null +++ b/ai-service/src/main/resources/application.yml @@ -0,0 +1,168 @@ +spring: + application: + name: ai-service + + # Redis Configuration + data: + redis: + host: 20.214.210.71 + port: 6379 + password: Hi5Jessica! + database: 3 + timeout: 3000 + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 2 + max-wait: -1ms + + # Kafka Consumer Configuration + kafka: + bootstrap-servers: 4.230.50.63: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: 10 + session.timeout.ms: 30000 + listener: + ack-mode: manual + +# Server Configuration +server: + port: 8083 + servlet: + context-path: / + encoding: + charset: UTF-8 + enabled: true + force: true + +# JWT Configuration +jwt: + secret: kt-event-marketing-secret-key-for-development-only-please-change-in-production + access-token-validity: 604800000 + refresh-token-validity: 86400 + +# CORS Configuration +cors: + allowed-origins: http://localhost:* + allowed-methods: GET,POST,PUT,DELETE,OPTIONS,PATCH + allowed-headers: "*" + allow-credentials: true + 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: logs/ai-service.log + logback: + rollingpolicy: + max-file-size: 10MB + max-history: 7 + total-size-cap: 100MB + +# Kafka Topics Configuration +kafka: + topics: + ai-job: ai-event-generation-job + ai-job-dlq: ai-event-generation-job-dlq + +# AI API Configuration (실제 API 사용) +ai: + provider: CLAUDE + claude: + api-url: https://api.anthropic.com/v1/messages + api-key: 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 + anthropic-version: 2023-06-01 + model: claude-sonnet-4-5-20250929 + max-tokens: 4096 + temperature: 0.7 + timeout: 300000 + +# Circuit Breaker Configuration +resilience4j: + 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: 86400 # 24 hours + job-status: 86400 # 24 hours + trend: 3600 # 1 hour + fallback: 604800 # 7 days diff --git a/ai-service/src/test/java/com/kt/ai/test/integration/kafka/AIJobConsumerIntegrationTest.java b/ai-service/src/test/java/com/kt/ai/test/integration/kafka/AIJobConsumerIntegrationTest.java new file mode 100644 index 0000000..a7180f7 --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/integration/kafka/AIJobConsumerIntegrationTest.java @@ -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); + }); + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/integration/kafka/KafkaTestProducer.java b/ai-service/src/test/java/com/kt/ai/test/integration/kafka/KafkaTestProducer.java new file mode 100644 index 0000000..1889a97 --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/integration/kafka/KafkaTestProducer.java @@ -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 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 record = new ProducerRecord<>(topic, message.getJobId(), json); + + Future 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 종료"); + } + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/manual/KafkaManualTest.java b/ai-service/src/test/java/com/kt/ai/test/manual/KafkaManualTest.java new file mode 100644 index 0000000..38cf813 --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/manual/KafkaManualTest.java @@ -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(); + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/unit/controller/InternalJobControllerUnitTest.java b/ai-service/src/test/java/com/kt/ai/test/unit/controller/InternalJobControllerUnitTest.java new file mode 100644 index 0000000..4a26729 --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/unit/controller/InternalJobControllerUnitTest.java @@ -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); + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/unit/service/CacheServiceUnitTest.java b/ai-service/src/test/java/com/kt/ai/test/unit/service/CacheServiceUnitTest.java new file mode 100644 index 0000000..bc7ac8c --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/unit/service/CacheServiceUnitTest.java @@ -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 redisTemplate; + + @Mock + private ValueOperations 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); + } +} diff --git a/ai-service/src/test/java/com/kt/ai/test/unit/service/JobStatusServiceUnitTest.java b/ai-service/src/test/java/com/kt/ai/test/unit/service/JobStatusServiceUnitTest.java new file mode 100644 index 0000000..afabe1f --- /dev/null +++ b/ai-service/src/test/java/com/kt/ai/test/unit/service/JobStatusServiceUnitTest.java @@ -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 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 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 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 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 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 createCachedJobStatusData() { + Map 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; + } +} diff --git a/ai-service/src/test/resources/application-test.yml b/ai-service/src/test/resources/application-test.yml new file mode 100644 index 0000000..037cc95 --- /dev/null +++ b/ai-service/src/test/resources/application-test.yml @@ -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 diff --git a/analytics-service/.run/analytics-service.run.xml b/analytics-service/.run/analytics-service.run.xml new file mode 100644 index 0000000..15941a1 --- /dev/null +++ b/analytics-service/.run/analytics-service.run.xml @@ -0,0 +1,84 @@ + + + + + + + + true + true + + + + + false + false + + + diff --git a/analytics-service/frontend-backend-validation.md b/analytics-service/frontend-backend-validation.md new file mode 100644 index 0000000..8f36b9a --- /dev/null +++ b/analytics-service/frontend-backend-validation.md @@ -0,0 +1,108 @@ +# 백엔드-프론트엔드 API 연동 검증 및 수정 결과 + +**작업일시**: 2025-10-28 +**브랜치**: feature/analytics +**작업 범위**: Analytics Service 백엔드 DTO 및 Service 수정 + +--- + +## 📝 수정 요약 + +### 1️⃣ 필드명 통일 (프론트엔드 호환) + +**목적**: 프론트엔드 Mock 데이터 필드명과 백엔드 Response DTO 필드명 일치 + +| 수정 전 (백엔드) | 수정 후 (백엔드) | 프론트엔드 | +|-----------------|----------------|-----------| +| `summary.totalParticipants` | `summary.participants` | `summary.participants` ✅ | +| `channelPerformance[].channelName` | `channelPerformance[].channel` | `channelPerformance[].channel` ✅ | +| `roi.totalInvestment` | `roi.totalCost` | `roiDetail.totalCost` ✅ | + +### 2️⃣ 증감 데이터 추가 + +**목적**: 프론트엔드에서 요구하는 증감 표시 및 목표값 제공 + +| 필드 | 타입 | 설명 | 현재 값 | +|-----|------|------|---------| +| `summary.participantsDelta` | `Integer` | 참여자 증감 (이전 기간 대비) | `0` (TODO: 계산 로직 필요) | +| `summary.targetRoi` | `Double` | 목표 ROI (%) | EventStats에서 가져옴 | + +--- + +## 🔧 수정 파일 목록 + +### DTO (Response 구조 변경) + +1. **AnalyticsSummary.java** + - ✅ `totalParticipants` → `participants` + - ✅ `participantsDelta` 필드 추가 + - ✅ `targetRoi` 필드 추가 + +2. **ChannelSummary.java** + - ✅ `channelName` → `channel` + +3. **RoiSummary.java** + - ✅ `totalInvestment` → `totalCost` + +### Entity (데이터베이스 스키마 변경) + +4. **EventStats.java** + - ✅ `targetRoi` 필드 추가 (`BigDecimal`, default: 0) + +### Service (비즈니스 로직 수정) + +5. **AnalyticsService.java** + - ✅ `.participants()` 사용 + - ✅ `.participantsDelta(0)` 추가 (TODO 마킹) + - ✅ `.targetRoi()` 추가 + - ✅ `.channel()` 사용 + +6. **ROICalculator.java** + - ✅ `.totalCost()` 사용 + +7. **UserAnalyticsService.java** + - ✅ `.participants()` 사용 + - ✅ `.participantsDelta(0)` 추가 + - ✅ `.channel()` 사용 + - ✅ `.totalCost()` 사용 + +--- + +## ✅ 검증 결과 + +### 컴파일 성공 +\`\`\`bash +$ ./gradlew analytics-service:compileJava + +BUILD SUCCESSFUL in 8s +\`\`\` + +--- + +## 📊 데이터베이스 스키마 변경 + +### EventStats 테이블 + +\`\`\`sql +ALTER TABLE event_stats +ADD COLUMN target_roi DECIMAL(10,2) DEFAULT 0.00; +\`\`\` + +**⚠️ 주의사항** +- Spring Boot JPA `ddl-auto` 설정에 따라 자동 적용됨 + +--- + +## 📌 다음 단계 + +### 우선순위 HIGH + +1. **프론트엔드 API 연동 테스트** +2. **participantsDelta 계산 로직 구현** +3. **targetRoi 데이터 입력** (Event Service 연동) + +### 우선순위 MEDIUM + +4. 시간대별 분석 구현 +5. 참여자 프로필 구현 +6. ROI 세분화 구현 diff --git a/analytics-service/src/main/java/com/kt/event/analytics/AnalyticsServiceApplication.java b/analytics-service/src/main/java/com/kt/event/analytics/AnalyticsServiceApplication.java new file mode 100644 index 0000000..c109743 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/AnalyticsServiceApplication.java @@ -0,0 +1,29 @@ +package com.kt.event.analytics; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Analytics Service 애플리케이션 메인 클래스 + * + * 실시간 효과 측정 및 통합 대시보드를 제공하는 Analytics Service + */ +@SpringBootApplication(scanBasePackages = {"com.kt.event.analytics", "com.kt.event.common"}) +@EntityScan(basePackages = {"com.kt.event.analytics.entity", "com.kt.event.common.entity"}) +@EnableJpaRepositories(basePackages = "com.kt.event.analytics.repository") +@EnableJpaAuditing +@EnableFeignClients +@EnableKafka +@EnableScheduling +public class AnalyticsServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AnalyticsServiceApplication.class, args); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java b/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java new file mode 100644 index 0000000..82263fd --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java @@ -0,0 +1,116 @@ +package com.kt.event.analytics.batch; + +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.kt.event.analytics.service.AnalyticsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Analytics 배치 스케줄러 + * + * 5분 단위로 Analytics 대시보드 데이터를 갱신하는 배치 작업 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AnalyticsBatchScheduler { + + private final AnalyticsService analyticsService; + private final EventStatsRepository eventStatsRepository; + private final RedisTemplate redisTemplate; + + /** + * 5분 단위 Analytics 데이터 갱신 배치 + * + * - 각 이벤트마다 Redis 캐시 확인 + * - 캐시 있음 → 건너뛰기 (1시간 유효) + * - 캐시 없음 → PostgreSQL + 외부 API → Redis 저장 + */ + @Scheduled(fixedRate = 300000) // 5분 = 300,000ms + public void refreshAnalyticsDashboard() { + log.info("===== Analytics 배치 시작: {} =====", LocalDateTime.now()); + + try { + // 1. 모든 활성 이벤트 조회 + List activeEvents = eventStatsRepository.findAll(); + log.info("활성 이벤트 수: {}", activeEvents.size()); + + // 2. 각 이벤트별로 캐시 확인 및 갱신 + int successCount = 0; + int skipCount = 0; + int failCount = 0; + + for (EventStats event : activeEvents) { + String cacheKey = "analytics:dashboard:" + event.getEventId(); + + try { + // 2-1. Redis 캐시 확인 + if (redisTemplate.hasKey(cacheKey)) { + log.debug("✅ 캐시 유효, 건너뜀: eventId={}", event.getEventId()); + skipCount++; + continue; + } + + // 2-2. 캐시 없음 → 데이터 갱신 + log.info("캐시 만료, 갱신 시작: eventId={}, title={}", + event.getEventId(), event.getEventTitle()); + + // refresh=true로 호출하여 캐시 갱신 및 외부 API 호출 + analyticsService.getDashboardData(event.getEventId(), null, null, true); + + successCount++; + log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId()); + + } catch (Exception e) { + failCount++; + log.error("❌ 배치 갱신 실패: eventId={}, error={}", + event.getEventId(), e.getMessage(), e); + } + } + + log.info("===== Analytics 배치 완료: 성공={}, 건너뜀={}, 실패={}, 종료시각={} =====", + successCount, skipCount, failCount, LocalDateTime.now()); + + } catch (Exception e) { + log.error("Analytics 배치 실행 중 오류 발생: {}", e.getMessage(), e); + } + } + + /** + * 초기 데이터 로딩 (애플리케이션 시작 후 30초 뒤 1회 실행) + * + * - 서버 시작 직후 캐시 워밍업 + * - 첫 API 요청 시 응답 시간 단축 + */ + @Scheduled(initialDelay = 30000, fixedDelay = Long.MAX_VALUE) + public void initialDataLoad() { + log.info("===== 초기 데이터 로딩 시작: {} =====", LocalDateTime.now()); + + try { + List allEvents = eventStatsRepository.findAll(); + log.info("초기 로딩 대상 이벤트 수: {}", allEvents.size()); + + for (EventStats event : allEvents) { + try { + analyticsService.getDashboardData(event.getEventId(), null, null, true); + log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId()); + } catch (Exception e) { + log.warn("초기 데이터 로딩 실패: eventId={}, error={}", + event.getEventId(), e.getMessage()); + } + } + + log.info("===== 초기 데이터 로딩 완료: {} =====", LocalDateTime.now()); + + } catch (Exception e) { + log.error("초기 데이터 로딩 중 오류 발생: {}", e.getMessage(), e); + } + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..8ffefb7 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java @@ -0,0 +1,50 @@ +package com.kt.event.analytics.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; + +import java.util.HashMap; +import java.util.Map; + +/** + * Kafka Consumer 설정 + */ +@Configuration +@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true) +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id:analytics-service}") + private String groupId; + + @Bean + public ConsumerFactory consumerFactory() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true); + return new DefaultKafkaConsumerFactory<>(props); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + // Kafka Consumer 자동 시작 활성화 + factory.setAutoStartup(true); + return factory; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaTopicConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaTopicConfig.java new file mode 100644 index 0000000..3c77521 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaTopicConfig.java @@ -0,0 +1,53 @@ +package com.kt.event.analytics.config; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +/** + * Kafka 토픽 자동 생성 설정 + * + * ⚠️ MVP 전용: 샘플 데이터용 토픽을 생성합니다. + * 실제 운영 토픽(event.created 등)과 구분하기 위해 "sample." 접두사 사용 + * + * 서비스 시작 시 필요한 Kafka 토픽을 자동으로 생성합니다. + */ +@Configuration +@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false) +public class KafkaTopicConfig { + + /** + * sample.event.created 토픽 (MVP 샘플 데이터용) + */ + @Bean + public NewTopic eventCreatedTopic() { + return TopicBuilder.name("sample.event.created") + .partitions(3) + .replicas(1) + .build(); + } + + /** + * sample.participant.registered 토픽 (MVP 샘플 데이터용) + */ + @Bean + public NewTopic participantRegisteredTopic() { + return TopicBuilder.name("sample.participant.registered") + .partitions(3) + .replicas(1) + .build(); + } + + /** + * sample.distribution.completed 토픽 (MVP 샘플 데이터용) + */ + @Bean + public NewTopic distributionCompletedTopic() { + return TopicBuilder.name("sample.distribution.completed") + .partitions(3) + .replicas(1) + .build(); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/RedisConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/RedisConfig.java new file mode 100644 index 0000000..5c6eebb --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/RedisConfig.java @@ -0,0 +1,35 @@ +package com.kt.event.analytics.config; + +import io.lettuce.core.ReadFrom; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 캐시 설정 + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + + // Read-only 오류 방지: 마스터 노드 우선 사용 + if (connectionFactory instanceof LettuceConnectionFactory) { + LettuceConnectionFactory lettuceFactory = (LettuceConnectionFactory) connectionFactory; + lettuceFactory.setValidateConnection(true); + } + + return template; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/Resilience4jConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/Resilience4jConfig.java new file mode 100644 index 0000000..ab4f50e --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/Resilience4jConfig.java @@ -0,0 +1,27 @@ +package com.kt.event.analytics.config; + +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** + * Resilience4j Circuit Breaker 설정 + */ +@Configuration +public class Resilience4jConfig { + + @Bean + public CircuitBreakerRegistry circuitBreakerRegistry() { + CircuitBreakerConfig config = CircuitBreakerConfig.custom() + .failureRateThreshold(50) + .waitDurationInOpenState(Duration.ofSeconds(30)) + .slidingWindowSize(10) + .permittedNumberOfCallsInHalfOpenState(3) + .build(); + + return CircuitBreakerRegistry.of(config); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java new file mode 100644 index 0000000..527e840 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java @@ -0,0 +1,366 @@ +package com.kt.event.analytics.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.event.analytics.messaging.event.DistributionCompletedEvent; +import com.kt.event.analytics.messaging.event.EventCreatedEvent; +import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent; +import com.kt.event.analytics.repository.ChannelStatsRepository; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.kt.event.analytics.repository.TimelineDataRepository; +import jakarta.annotation.PreDestroy; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +/** + * 샘플 데이터 로더 (Kafka Producer 방식) + * + * ⚠️ MVP 전용: 다른 마이크로서비스(Event, Participant, Distribution)가 + * 없는 환경에서 해당 서비스들의 역할을 시뮬레이션합니다. + * + * ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행해야 하며, + * 이 클래스는 비활성화되어야 합니다. + * → SAMPLE_DATA_ENABLED=false 설정 + * + * - 서비스 시작 시: Kafka 이벤트 발행하여 샘플 데이터 자동 생성 + * - 서비스 종료 시: PostgreSQL 전체 데이터 삭제 + * + * 활성화 조건: spring.sample-data.enabled=true (기본값: true) + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "spring.sample-data.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class SampleDataLoader implements ApplicationRunner { + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + private final EventStatsRepository eventStatsRepository; + private final ChannelStatsRepository channelStatsRepository; + private final TimelineDataRepository timelineDataRepository; + private final EntityManager entityManager; + private final RedisTemplate redisTemplate; + + private final Random random = new Random(); + + // Kafka Topic Names (MVP용 샘플 토픽) + private static final String EVENT_CREATED_TOPIC = "sample.event.created"; + private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered"; + private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed"; + + @Override + @Transactional + public void run(ApplicationArguments args) { + log.info("========================================"); + log.info("🚀 서비스 시작: Kafka 이벤트 발행하여 샘플 데이터 생성"); + log.info("========================================"); + + // 항상 기존 데이터 삭제 후 새로 생성 + long existingCount = eventStatsRepository.count(); + if (existingCount > 0) { + log.info("기존 데이터 {} 건 삭제 중...", existingCount); + timelineDataRepository.deleteAll(); + channelStatsRepository.deleteAll(); + eventStatsRepository.deleteAll(); + + // 삭제 커밋 보장 + entityManager.flush(); + entityManager.clear(); + + log.info("✅ 기존 데이터 삭제 완료"); + } + + // Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해) + log.info("Redis 멱등성 키 삭제 중..."); + redisTemplate.delete("processed_events"); + redisTemplate.delete("distribution_completed"); + redisTemplate.delete("processed_participants"); + log.info("✅ Redis 멱등성 키 삭제 완료"); + + try { + // 1. EventCreated 이벤트 발행 (3개 이벤트) + publishEventCreatedEvents(); + log.info("⏳ EventStats 생성 대기 중... (5초)"); + Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간 + + // 2. DistributionCompleted 이벤트 발행 (각 이벤트당 4개 채널) + publishDistributionCompletedEvents(); + log.info("⏳ ChannelStats 생성 대기 중... (3초)"); + Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간 + + // 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자) + publishParticipantRegisteredEvents(); + + log.info("========================================"); + log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)"); + log.info("========================================"); + log.info("발행된 이벤트:"); + log.info(" - EventCreated: 3건"); + log.info(" - DistributionCompleted: 3건 (각 이벤트당 4개 채널 배열)"); + log.info(" - ParticipantRegistered: 180건 (MVP 테스트용)"); + log.info("========================================"); + + // Consumer 처리 대기 (5초) + log.info("⏳ 참여자 수 업데이트 대기 중... (5초)"); + Thread.sleep(5000); + + // 4. TimelineData 생성 (시간대별 데이터) + createTimelineData(); + log.info("✅ TimelineData 생성 완료"); + + } catch (Exception e) { + log.error("샘플 데이터 적재 중 오류 발생", e); + } + } + + /** + * 서비스 종료 시 전체 데이터 삭제 + */ + @PreDestroy + @Transactional + public void onShutdown() { + log.info("========================================"); + log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제"); + log.info("========================================"); + + try { + long timelineCount = timelineDataRepository.count(); + long channelCount = channelStatsRepository.count(); + long eventCount = eventStatsRepository.count(); + + log.info("삭제 대상: 이벤트={}, 채널={}, 타임라인={}", + eventCount, channelCount, timelineCount); + + timelineDataRepository.deleteAll(); + channelStatsRepository.deleteAll(); + eventStatsRepository.deleteAll(); + + // 삭제 커밋 보장 + entityManager.flush(); + entityManager.clear(); + + log.info("✅ 모든 샘플 데이터 삭제 완료!"); + log.info("========================================"); + + } catch (Exception e) { + log.error("샘플 데이터 삭제 중 오류 발생", e); + } + } + + /** + * EventCreated 이벤트 발행 + */ + private void publishEventCreatedEvents() throws Exception { + // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과) + EventCreatedEvent event1 = EventCreatedEvent.builder() + .eventId("evt_2025012301") + .eventTitle("신년맞이 20% 할인 이벤트") + .storeId("store_001") + .totalInvestment(new BigDecimal("5000000")) + .status("ACTIVE") + .build(); + publishEvent(EVENT_CREATED_TOPIC, event1); + + // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과) + EventCreatedEvent event2 = EventCreatedEvent.builder() + .eventId("evt_2025020101") + .eventTitle("설날 특가 선물세트 이벤트") + .storeId("store_001") + .totalInvestment(new BigDecimal("3500000")) + .status("ACTIVE") + .build(); + publishEvent(EVENT_CREATED_TOPIC, event2); + + // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과) + EventCreatedEvent event3 = EventCreatedEvent.builder() + .eventId("evt_2025011501") + .eventTitle("겨울 신메뉴 런칭 이벤트") + .storeId("store_001") + .totalInvestment(new BigDecimal("2000000")) + .status("COMPLETED") + .build(); + publishEvent(EVENT_CREATED_TOPIC, event3); + + log.info("✅ EventCreated 이벤트 3건 발행 완료"); + } + + /** + * DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열) + */ + private void publishDistributionCompletedEvents() throws Exception { + String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; + int[][] expectedViews = { + {5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS + {3500, 7000, 2000, 1500}, // 이벤트2 + {1500, 3000, 1000, 500} // 이벤트3 + }; + + for (int i = 0; i < eventIds.length; i++) { + String eventId = eventIds[i]; + + // 4개 채널을 배열로 구성 + List channels = new ArrayList<>(); + + // 1. 우리동네TV (TV) + channels.add(DistributionCompletedEvent.ChannelDistribution.builder() + .channel("우리동네TV") + .channelType("TV") + .status("SUCCESS") + .expectedViews(expectedViews[i][0]) + .build()); + + // 2. 지니TV (TV) + channels.add(DistributionCompletedEvent.ChannelDistribution.builder() + .channel("지니TV") + .channelType("TV") + .status("SUCCESS") + .expectedViews(expectedViews[i][1]) + .build()); + + // 3. 링고비즈 (CALL) + channels.add(DistributionCompletedEvent.ChannelDistribution.builder() + .channel("링고비즈") + .channelType("CALL") + .status("SUCCESS") + .expectedViews(expectedViews[i][2]) + .build()); + + // 4. SNS (SNS) + channels.add(DistributionCompletedEvent.ChannelDistribution.builder() + .channel("SNS") + .channelType("SNS") + .status("SUCCESS") + .expectedViews(expectedViews[i][3]) + .build()); + + // 이벤트 발행 (채널 배열 포함) + DistributionCompletedEvent event = DistributionCompletedEvent.builder() + .eventId(eventId) + .distributedChannels(channels) + .completedAt(java.time.LocalDateTime.now()) + .build(); + + publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event); + } + + log.info("✅ DistributionCompleted 이벤트 3건 발행 완료 (3 이벤트 × 4 채널 배열)"); + } + + /** + * ParticipantRegistered 이벤트 발행 + */ + private void publishParticipantRegisteredEvents() throws Exception { + String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; + int[] totalParticipants = {100, 50, 30}; // MVP 테스트용 샘플 데이터 (총 180명) + String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"}; + + int totalPublished = 0; + + for (int i = 0; i < eventIds.length; i++) { + String eventId = eventIds[i]; + int participants = totalParticipants[i]; + + // 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행 + for (int j = 0; j < participants; j++) { + String participantId = UUID.randomUUID().toString(); + String channel = channels[j % channels.length]; // 채널 순환 배정 + + ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder() + .eventId(eventId) + .participantId(participantId) + .channel(channel) + .build(); + + publishEvent(PARTICIPANT_REGISTERED_TOPIC, event); + totalPublished++; + + // 동시성 충돌 방지: 10개마다 100ms 대기 + if ((j + 1) % 10 == 0) { + Thread.sleep(100); + } + } + } + + log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished); + } + + /** + * TimelineData 생성 (시간대별 샘플 데이터) + * + * - 각 이벤트마다 30일 치 daily 데이터 생성 + * - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수 + */ + private void createTimelineData() { + log.info("📊 TimelineData 생성 시작..."); + + String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; + + // 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름) + int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음) + + for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) { + String eventId = eventIds[eventIndex]; + int baseParticipant = baseParticipants[eventIndex]; + int cumulativeParticipants = 0; + + // 30일 치 데이터 생성 (2024-09-24부터) + java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0); + + for (int day = 0; day < 30; day++) { + java.time.LocalDateTime timestamp = startDate.plusDays(day); + + // 랜덤한 참여자 수 생성 (기준값 ± 50%) + int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1); + cumulativeParticipants += dailyParticipants; + + // 조회수는 참여자의 3~5배 + int dailyViews = dailyParticipants * (3 + random.nextInt(3)); + + // 참여행동은 참여자의 1~2배 + int dailyEngagement = dailyParticipants * (1 + random.nextInt(2)); + + // 전환수는 참여자의 50~80% + int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3)); + + // TimelineData 생성 + com.kt.event.analytics.entity.TimelineData timelineData = + com.kt.event.analytics.entity.TimelineData.builder() + .eventId(eventId) + .timestamp(timestamp) + .participants(dailyParticipants) + .views(dailyViews) + .engagement(dailyEngagement) + .conversions(dailyConversions) + .cumulativeParticipants(cumulativeParticipants) + .build(); + + timelineDataRepository.save(timelineData); + } + + log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId); + } + + log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건"); + } + + /** + * Kafka 이벤트 발행 공통 메서드 + */ + private void publishEvent(String topic, Object event) throws Exception { + String jsonMessage = objectMapper.writeValueAsString(event); + kafkaTemplate.send(topic, jsonMessage); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java new file mode 100644 index 0000000..b340f83 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java @@ -0,0 +1,79 @@ +package com.kt.event.analytics.config; + +import com.kt.event.common.security.JwtAuthenticationFilter; +import com.kt.event.common.security.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +/** + * Spring Security 설정 + * JWT 기반 인증 및 API 보안 설정 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + + @Value("${cors.allowed-origins:http://localhost:*}") + private String allowedOrigins; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // Actuator endpoints + .requestMatchers("/actuator/**").permitAll() + // Swagger UI endpoints + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll() + // Health check + .requestMatchers("/health").permitAll() + // Analytics API endpoints (테스트 및 개발 용도로 공개) + .requestMatchers("/api/**").permitAll() + // All other requests require authentication + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + String[] origins = allowedOrigins.split(","); + configuration.setAllowedOriginPatterns(Arrays.asList(origins)); + + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + configuration.setAllowedHeaders(Arrays.asList( + "Authorization", "Content-Type", "X-Requested-With", "Accept", + "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers" + )); + + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SwaggerConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SwaggerConfig.java new file mode 100644 index 0000000..c0660af --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SwaggerConfig.java @@ -0,0 +1,63 @@ +package com.kt.event.analytics.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger/OpenAPI 설정 + * Analytics Service API 문서화를 위한 설정 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(apiInfo()) + .addServersItem(new Server() + .url("http://localhost:8086") + .description("Local Development")) + .addServersItem(new Server() + .url("{protocol}://{host}:{port}") + .description("Custom Server") + .variables(new io.swagger.v3.oas.models.servers.ServerVariables() + .addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("http") + .description("Protocol (http or https)") + .addEnumItem("http") + .addEnumItem("https")) + .addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("localhost") + .description("Server host")) + .addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("8086") + .description("Server port")))) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new Components() + .addSecuritySchemes("Bearer Authentication", createAPIKeyScheme())); + } + + private Info apiInfo() { + return new Info() + .title("Analytics Service API") + .description("실시간 효과 측정 및 통합 대시보드를 제공하는 Analytics Service API") + .version("1.0.0") + .contact(new Contact() + .name("Digital Garage Team") + .email("support@kt-event-marketing.com")); + } + + private SecurityScheme createAPIKeyScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .bearerFormat("JWT") + .scheme("bearer"); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java new file mode 100644 index 0000000..2dc1d8a --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java @@ -0,0 +1,71 @@ +package com.kt.event.analytics.controller; + +import com.kt.event.analytics.dto.response.AnalyticsDashboardResponse; +import com.kt.event.analytics.service.AnalyticsService; +import com.kt.event.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +/** + * Analytics Dashboard Controller + * + * 이벤트 성과 대시보드 API + */ +@Tag(name = "Analytics", description = "이벤트 성과 분석 및 대시보드 API") +@Slf4j +@RestController +@RequestMapping("/api/v1/events") +@RequiredArgsConstructor +public class AnalyticsDashboardController { + + private final AnalyticsService analyticsService; + + /** + * 성과 대시보드 조회 + * + * @param eventId 이벤트 ID + * @param startDate 조회 시작 날짜 + * @param endDate 조회 종료 날짜 + * @param refresh 캐시 갱신 여부 + * @return 성과 대시보드 + */ + @Operation( + summary = "성과 대시보드 조회", + description = "이벤트의 전체 성과를 통합하여 조회합니다." + ) + @GetMapping("/{eventId}/analytics") + public ResponseEntity> getEventAnalytics( + @Parameter(description = "이벤트 ID", required = true) + @PathVariable String eventId, + + @Parameter(description = "조회 시작 날짜 (ISO 8601 format)") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startDate, + + @Parameter(description = "조회 종료 날짜 (ISO 8601 format)") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime endDate, + + @Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)") + @RequestParam(required = false, defaultValue = "false") + Boolean refresh + ) { + log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh); + + AnalyticsDashboardResponse response = analyticsService.getDashboardData( + eventId, startDate, endDate, refresh + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/ChannelAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/ChannelAnalyticsController.java new file mode 100644 index 0000000..ea78687 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/ChannelAnalyticsController.java @@ -0,0 +1,73 @@ +package com.kt.event.analytics.controller; + +import com.kt.event.analytics.dto.response.ChannelAnalyticsResponse; +import com.kt.event.analytics.service.ChannelAnalyticsService; +import com.kt.event.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * Channel Analytics Controller + * + * 채널별 성과 분석 API + */ +@Tag(name = "Channels", description = "채널별 성과 분석 API") +@Slf4j +@RestController +@RequestMapping("/api/v1/events") +@RequiredArgsConstructor +public class ChannelAnalyticsController { + + private final ChannelAnalyticsService channelAnalyticsService; + + /** + * 채널별 성과 분석 + * + * @param eventId 이벤트 ID + * @param channels 조회할 채널 목록 (쉼표로 구분) + * @param sortBy 정렬 기준 + * @param order 정렬 순서 + * @return 채널별 성과 분석 + */ + @Operation( + summary = "채널별 성과 분석", + description = "각 배포 채널별 성과를 상세하게 분석합니다." + ) + @GetMapping("/{eventId}/analytics/channels") + public ResponseEntity> getChannelAnalytics( + @Parameter(description = "이벤트 ID", required = true) + @PathVariable String eventId, + + @Parameter(description = "조회할 채널 목록 (쉼표로 구분, 미지정 시 전체)") + @RequestParam(required = false) + String channels, + + @Parameter(description = "정렬 기준 (views, participants, engagement_rate, conversion_rate, roi)") + @RequestParam(required = false, defaultValue = "roi") + String sortBy, + + @Parameter(description = "정렬 순서 (asc, desc)") + @RequestParam(required = false, defaultValue = "desc") + String order + ) { + log.info("채널별 성과 분석 API 호출: eventId={}, sortBy={}", eventId, sortBy); + + List channelList = channels != null && !channels.isBlank() + ? Arrays.asList(channels.split(",")) + : null; + + ChannelAnalyticsResponse response = channelAnalyticsService.getChannelAnalytics( + eventId, channelList, sortBy, order + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/RoiAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/RoiAnalyticsController.java new file mode 100644 index 0000000..29d6980 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/RoiAnalyticsController.java @@ -0,0 +1,54 @@ +package com.kt.event.analytics.controller; + +import com.kt.event.analytics.dto.response.RoiAnalyticsResponse; +import com.kt.event.analytics.service.RoiAnalyticsService; +import com.kt.event.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * ROI Analytics Controller + * + * 투자 대비 수익률 분석 API + */ +@Tag(name = "ROI", description = "투자 대비 수익률 분석 API") +@Slf4j +@RestController +@RequestMapping("/api/v1/events") +@RequiredArgsConstructor +public class RoiAnalyticsController { + + private final RoiAnalyticsService roiAnalyticsService; + + /** + * 투자 대비 수익률 상세 + * + * @param eventId 이벤트 ID + * @param includeProjection 예상 수익 포함 여부 + * @return ROI 상세 분석 + */ + @Operation( + summary = "투자 대비 수익률 상세", + description = "이벤트의 투자 대비 수익률을 상세하게 분석합니다." + ) + @GetMapping("/{eventId}/analytics/roi") + public ResponseEntity> getRoiAnalytics( + @Parameter(description = "이벤트 ID", required = true) + @PathVariable String eventId, + + @Parameter(description = "예상 수익 포함 여부") + @RequestParam(required = false, defaultValue = "true") + Boolean includeProjection + ) { + log.info("ROI 상세 분석 API 호출: eventId={}, includeProjection={}", eventId, includeProjection); + + RoiAnalyticsResponse response = roiAnalyticsService.getRoiAnalytics(eventId, includeProjection); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java new file mode 100644 index 0000000..5fc882f --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java @@ -0,0 +1,82 @@ +package com.kt.event.analytics.controller; + +import com.kt.event.analytics.dto.response.TimelineAnalyticsResponse; +import com.kt.event.analytics.service.TimelineAnalyticsService; +import com.kt.event.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +/** + * Timeline Analytics Controller + * + * 시간대별 분석 API + */ +@Tag(name = "Timeline", description = "시간대별 분석 API") +@Slf4j +@RestController +@RequestMapping("/api/v1/events") +@RequiredArgsConstructor +public class TimelineAnalyticsController { + + private final TimelineAnalyticsService timelineAnalyticsService; + + /** + * 시간대별 참여 추이 + * + * @param eventId 이벤트 ID + * @param interval 시간 간격 단위 + * @param startDate 조회 시작 날짜 + * @param endDate 조회 종료 날짜 + * @param metrics 조회할 지표 목록 + * @return 시간대별 참여 추이 + */ + @Operation( + summary = "시간대별 참여 추이", + description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다." + ) + @GetMapping("/{eventId}/analytics/timeline") + public ResponseEntity> getTimelineAnalytics( + @Parameter(description = "이벤트 ID", required = true) + @PathVariable String eventId, + + @Parameter(description = "시간 간격 단위 (hourly, daily, weekly)") + @RequestParam(required = false, defaultValue = "daily") + String interval, + + @Parameter(description = "조회 시작 날짜 (ISO 8601 format)") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startDate, + + @Parameter(description = "조회 종료 날짜 (ISO 8601 format)") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime endDate, + + @Parameter(description = "조회할 지표 목록 (쉼표로 구분)") + @RequestParam(required = false) + String metrics + ) { + log.info("시간대별 참여 추이 API 호출: eventId={}, interval={}", eventId, interval); + + List metricList = metrics != null && !metrics.isBlank() + ? Arrays.asList(metrics.split(",")) + : null; + + TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics( + eventId, interval, startDate, endDate, metricList + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java new file mode 100644 index 0000000..1822fde --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java @@ -0,0 +1,71 @@ +package com.kt.event.analytics.controller; + +import com.kt.event.analytics.dto.response.UserAnalyticsDashboardResponse; +import com.kt.event.analytics.service.UserAnalyticsService; +import com.kt.event.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +/** + * User Analytics Dashboard Controller + * + * 사용자 전체 이벤트 통합 성과 대시보드 API + */ +@Tag(name = "User Analytics", description = "사용자 전체 이벤트 통합 성과 분석 API") +@Slf4j +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserAnalyticsDashboardController { + + private final UserAnalyticsService userAnalyticsService; + + /** + * 사용자 전체 성과 대시보드 조회 + * + * @param userId 사용자 ID + * @param startDate 조회 시작 날짜 + * @param endDate 조회 종료 날짜 + * @param refresh 캐시 갱신 여부 + * @return 전체 통합 성과 대시보드 + */ + @Operation( + summary = "사용자 전체 성과 대시보드 조회", + description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다." + ) + @GetMapping("/{userId}/analytics") + public ResponseEntity> getUserAnalytics( + @Parameter(description = "사용자 ID", required = true) + @PathVariable String userId, + + @Parameter(description = "조회 시작 날짜 (ISO 8601 format)") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startDate, + + @Parameter(description = "조회 종료 날짜 (ISO 8601 format)") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime endDate, + + @Parameter(description = "캐시 갱신 여부") + @RequestParam(required = false, defaultValue = "false") + Boolean refresh + ) { + log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh); + + UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData( + userId, startDate, endDate, refresh + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java new file mode 100644 index 0000000..2b68cb6 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java @@ -0,0 +1,78 @@ +package com.kt.event.analytics.controller; + +import com.kt.event.analytics.dto.response.UserChannelAnalyticsResponse; +import com.kt.event.analytics.service.UserChannelAnalyticsService; +import com.kt.event.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +/** + * User Channel Analytics Controller + */ +@Tag(name = "User Channels", description = "사용자 전체 이벤트 채널별 성과 분석 API") +@Slf4j +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserChannelAnalyticsController { + + private final UserChannelAnalyticsService userChannelAnalyticsService; + + @Operation( + summary = "사용자 전체 채널별 성과 분석", + description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다." + ) + @GetMapping("/{userId}/analytics/channels") + public ResponseEntity> getUserChannelAnalytics( + @Parameter(description = "사용자 ID", required = true) + @PathVariable String userId, + + @Parameter(description = "조회할 채널 목록 (쉼표로 구분)") + @RequestParam(required = false) + String channels, + + @Parameter(description = "정렬 기준") + @RequestParam(required = false, defaultValue = "participants") + String sortBy, + + @Parameter(description = "정렬 순서") + @RequestParam(required = false, defaultValue = "desc") + String order, + + @Parameter(description = "조회 시작 날짜") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startDate, + + @Parameter(description = "조회 종료 날짜") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime endDate, + + @Parameter(description = "캐시 갱신 여부") + @RequestParam(required = false, defaultValue = "false") + Boolean refresh + ) { + log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy); + + List channelList = channels != null && !channels.isBlank() + ? Arrays.asList(channels.split(",")) + : null; + + UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics( + userId, channelList, sortBy, order, startDate, endDate, refresh + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java new file mode 100644 index 0000000..58a098f --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java @@ -0,0 +1,64 @@ +package com.kt.event.analytics.controller; + +import com.kt.event.analytics.dto.response.UserRoiAnalyticsResponse; +import com.kt.event.analytics.service.UserRoiAnalyticsService; +import com.kt.event.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +/** + * User ROI Analytics Controller + */ +@Tag(name = "User ROI", description = "사용자 전체 이벤트 ROI 분석 API") +@Slf4j +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserRoiAnalyticsController { + + private final UserRoiAnalyticsService userRoiAnalyticsService; + + @Operation( + summary = "사용자 전체 ROI 상세 분석", + description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다." + ) + @GetMapping("/{userId}/analytics/roi") + public ResponseEntity> getUserRoiAnalytics( + @Parameter(description = "사용자 ID", required = true) + @PathVariable String userId, + + @Parameter(description = "예상 수익 포함 여부") + @RequestParam(required = false, defaultValue = "true") + Boolean includeProjection, + + @Parameter(description = "조회 시작 날짜") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startDate, + + @Parameter(description = "조회 종료 날짜") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime endDate, + + @Parameter(description = "캐시 갱신 여부") + @RequestParam(required = false, defaultValue = "false") + Boolean refresh + ) { + log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection); + + UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics( + userId, includeProjection, startDate, endDate, refresh + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java new file mode 100644 index 0000000..40fe700 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java @@ -0,0 +1,74 @@ +package com.kt.event.analytics.controller; + +import com.kt.event.analytics.dto.response.UserTimelineAnalyticsResponse; +import com.kt.event.analytics.service.UserTimelineAnalyticsService; +import com.kt.event.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +/** + * User Timeline Analytics Controller + */ +@Tag(name = "User Timeline", description = "사용자 전체 이벤트 시간대별 분석 API") +@Slf4j +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserTimelineAnalyticsController { + + private final UserTimelineAnalyticsService userTimelineAnalyticsService; + + @Operation( + summary = "사용자 전체 시간대별 참여 추이", + description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다." + ) + @GetMapping("/{userId}/analytics/timeline") + public ResponseEntity> getUserTimelineAnalytics( + @Parameter(description = "사용자 ID", required = true) + @PathVariable String userId, + + @Parameter(description = "시간 간격 단위 (hourly, daily, weekly, monthly)") + @RequestParam(required = false, defaultValue = "daily") + String interval, + + @Parameter(description = "조회 시작 날짜") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime startDate, + + @Parameter(description = "조회 종료 날짜") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime endDate, + + @Parameter(description = "조회할 지표 목록 (쉼표로 구분)") + @RequestParam(required = false) + String metrics, + + @Parameter(description = "캐시 갱신 여부") + @RequestParam(required = false, defaultValue = "false") + Boolean refresh + ) { + log.info("사용자 타임라인 분석 API 호출: userId={}, interval={}", userId, interval); + + List metricList = metrics != null && !metrics.isBlank() + ? Arrays.asList(metrics.split(",")) + : null; + + UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics( + userId, interval, startDate, endDate, metricList, refresh + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java new file mode 100644 index 0000000..9fb9b3e --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java @@ -0,0 +1,59 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 이벤트 성과 대시보드 응답 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AnalyticsDashboardResponse { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 이벤트 제목 + */ + private String eventTitle; + + /** + * 조회 기간 정보 + */ + private PeriodInfo period; + + /** + * 성과 요약 + */ + private AnalyticsSummary summary; + + /** + * 채널별 성과 요약 + */ + private List channelPerformance; + + /** + * ROI 요약 + */ + private RoiSummary roi; + + /** + * 마지막 업데이트 시간 + */ + private LocalDateTime lastUpdatedAt; + + /** + * 데이터 출처 (real-time, cached, fallback) + */ + private String dataSource; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java new file mode 100644 index 0000000..2aafc74 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java @@ -0,0 +1,61 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 성과 요약 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AnalyticsSummary { + + /** + * 총 참여자 수 + */ + private Integer participants; + + /** + * 참여자 증감 (이전 기간 대비) + */ + private Integer participantsDelta; + + /** + * 총 조회수 + */ + private Integer totalViews; + + /** + * 총 도달 수 + */ + private Integer totalReach; + + /** + * 참여율 (%) + */ + private Double engagementRate; + + /** + * 전환율 (%) + */ + private Double conversionRate; + + /** + * 평균 참여 시간 (초) + */ + private Integer averageEngagementTime; + + /** + * 목표 ROI (%) + */ + private Double targetRoi; + + /** + * SNS 반응 통계 + */ + private SocialInteractionStats socialInteractions; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalytics.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalytics.java new file mode 100644 index 0000000..51dccaa --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalytics.java @@ -0,0 +1,46 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 채널별 상세 분석 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelAnalytics { + + /** + * 채널명 + */ + private String channelName; + + /** + * 채널 유형 + */ + private String channelType; + + /** + * 채널 지표 + */ + private ChannelMetrics metrics; + + /** + * 성과 지표 + */ + private ChannelPerformance performance; + + /** + * 비용 정보 + */ + private ChannelCosts costs; + + /** + * 외부 API 연동 상태 (success, fallback, failed) + */ + private String externalApiStatus; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalyticsResponse.java new file mode 100644 index 0000000..2bd8f0c --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalyticsResponse.java @@ -0,0 +1,39 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 채널별 성과 분석 응답 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelAnalyticsResponse { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 채널별 상세 분석 + */ + private List channels; + + /** + * 채널 간 비교 분석 + */ + private ChannelComparison comparison; + + /** + * 마지막 업데이트 시간 + */ + private LocalDateTime lastUpdatedAt; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelComparison.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelComparison.java new file mode 100644 index 0000000..24d2584 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelComparison.java @@ -0,0 +1,28 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 채널 간 비교 분석 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelComparison { + + /** + * 최고 성과 채널 + */ + private Map bestPerforming; + + /** + * 전체 채널 평균 지표 + */ + private Map averageMetrics; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelCosts.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelCosts.java new file mode 100644 index 0000000..d74e647 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelCosts.java @@ -0,0 +1,43 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 채널별 비용 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelCosts { + + /** + * 배포 비용 (원) + */ + private BigDecimal distributionCost; + + /** + * 조회당 비용 (CPV, 원) + */ + private Double costPerView; + + /** + * 클릭당 비용 (CPC, 원) + */ + private Double costPerClick; + + /** + * 고객 획득 비용 (CPA, 원) + */ + private Double costPerAcquisition; + + /** + * ROI (%) + */ + private Double roi; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelMetrics.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelMetrics.java new file mode 100644 index 0000000..0029a71 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelMetrics.java @@ -0,0 +1,51 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 채널 지표 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelMetrics { + + /** + * 노출 수 + */ + private Integer impressions; + + /** + * 조회수 + */ + private Integer views; + + /** + * 클릭 수 + */ + private Integer clicks; + + /** + * 참여자 수 + */ + private Integer participants; + + /** + * 전환 수 + */ + private Integer conversions; + + /** + * SNS 반응 통계 + */ + private SocialInteractionStats socialInteractions; + + /** + * 링고비즈 통화 통계 + */ + private VoiceCallStats voiceCallStats; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelPerformance.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelPerformance.java new file mode 100644 index 0000000..0e4db39 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelPerformance.java @@ -0,0 +1,41 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 채널 성과 지표 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelPerformance { + + /** + * 클릭률 (CTR, %) + */ + private Double clickThroughRate; + + /** + * 참여율 (%) + */ + private Double engagementRate; + + /** + * 전환율 (%) + */ + private Double conversionRate; + + /** + * 평균 참여 시간 (초) + */ + private Integer averageEngagementTime; + + /** + * 이탈율 (%) + */ + private Double bounceRate; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java new file mode 100644 index 0000000..65abb37 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java @@ -0,0 +1,46 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 채널별 성과 요약 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelSummary { + + /** + * 채널명 + */ + private String channel; + + /** + * 조회수 + */ + private Integer views; + + /** + * 참여자 수 + */ + private Integer participants; + + /** + * 참여율 (%) + */ + private Double engagementRate; + + /** + * 전환율 (%) + */ + private Double conversionRate; + + /** + * ROI (%) + */ + private Double roi; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/CostEfficiency.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/CostEfficiency.java new file mode 100644 index 0000000..7c3919b --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/CostEfficiency.java @@ -0,0 +1,36 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 비용 효율성 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CostEfficiency { + + /** + * 참여자당 비용 (원) + */ + private Double costPerParticipant; + + /** + * 전환당 비용 (원) + */ + private Double costPerConversion; + + /** + * 조회당 비용 (원) + */ + private Double costPerView; + + /** + * 참여자당 수익 (원) + */ + private Double revenuePerParticipant; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java new file mode 100644 index 0000000..abff813 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java @@ -0,0 +1,45 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * 투자 비용 상세 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InvestmentDetails { + + /** + * 콘텐츠 제작비 (원) + */ + private BigDecimal contentCreation; + + /** + * 배포 비용 (원) + */ + private BigDecimal distribution; + + /** + * 운영 비용 (원) + */ + private BigDecimal operation; + + /** + * 총 투자 비용 (원) + */ + private BigDecimal total; + + /** + * 채널별 비용 상세 + */ + private List> breakdown; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/PeakTimeInfo.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/PeakTimeInfo.java new file mode 100644 index 0000000..4908b91 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/PeakTimeInfo.java @@ -0,0 +1,38 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 피크 타임 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PeakTimeInfo { + + /** + * 피크 시간 + */ + private LocalDateTime timestamp; + + /** + * 피크 지표 (participants, views, engagement, conversions) + */ + private String metric; + + /** + * 피크 값 + */ + private Integer value; + + /** + * 피크 설명 + */ + private String description; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/PeriodInfo.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/PeriodInfo.java new file mode 100644 index 0000000..328acf7 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/PeriodInfo.java @@ -0,0 +1,33 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 조회 기간 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PeriodInfo { + + /** + * 조회 시작 날짜 + */ + private LocalDateTime startDate; + + /** + * 조회 종료 날짜 + */ + private LocalDateTime endDate; + + /** + * 기간 (일) + */ + private Integer durationDays; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueDetails.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueDetails.java new file mode 100644 index 0000000..873fe20 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueDetails.java @@ -0,0 +1,38 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 수익 상세 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RevenueDetails { + + /** + * 직접 매출 (원) + */ + private BigDecimal directSales; + + /** + * 예상 추가 매출 (원) + */ + private BigDecimal expectedSales; + + /** + * 브랜드 가치 향상 추정액 (원) + */ + private BigDecimal brandValue; + + /** + * 총 수익 (원) + */ + private BigDecimal total; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueProjection.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueProjection.java new file mode 100644 index 0000000..db6c07c --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueProjection.java @@ -0,0 +1,38 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 수익 예측 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RevenueProjection { + + /** + * 현재 누적 수익 (원) + */ + private BigDecimal currentRevenue; + + /** + * 예상 최종 수익 (원) + */ + private BigDecimal projectedFinalRevenue; + + /** + * 예측 신뢰도 (%) + */ + private Double confidenceLevel; + + /** + * 예측 기반 + */ + private String basedOn; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiAnalyticsResponse.java new file mode 100644 index 0000000..12348b5 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiAnalyticsResponse.java @@ -0,0 +1,53 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * ROI 상세 분석 응답 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoiAnalyticsResponse { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 투자 비용 상세 + */ + private InvestmentDetails investment; + + /** + * 수익 상세 + */ + private RevenueDetails revenue; + + /** + * ROI 계산 + */ + private RoiCalculation roi; + + /** + * 비용 효율성 + */ + private CostEfficiency costEfficiency; + + /** + * 수익 예측 + */ + private RevenueProjection projection; + + /** + * 마지막 업데이트 시간 + */ + private LocalDateTime lastUpdatedAt; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiCalculation.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiCalculation.java new file mode 100644 index 0000000..8f9046c --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiCalculation.java @@ -0,0 +1,39 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * ROI 계산 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoiCalculation { + + /** + * 순이익 (원) + */ + private BigDecimal netProfit; + + /** + * ROI (%) + */ + private Double roiPercentage; + + /** + * 손익분기점 도달 시점 + */ + private LocalDateTime breakEvenPoint; + + /** + * 투자 회수 기간 (일) + */ + private Integer paybackPeriod; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiSummary.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiSummary.java new file mode 100644 index 0000000..9a995f3 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RoiSummary.java @@ -0,0 +1,43 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * ROI 요약 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoiSummary { + + /** + * 총 투자 비용 (원) + */ + private BigDecimal totalCost; + + /** + * 예상 매출 증대 (원) + */ + private BigDecimal expectedRevenue; + + /** + * 순이익 (원) + */ + private BigDecimal netProfit; + + /** + * ROI (%) + */ + private Double roi; + + /** + * 고객 획득 비용 (CPA, 원) + */ + private Double costPerAcquisition; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/SocialInteractionStats.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/SocialInteractionStats.java new file mode 100644 index 0000000..574426e --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/SocialInteractionStats.java @@ -0,0 +1,31 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * SNS 반응 통계 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SocialInteractionStats { + + /** + * 좋아요 수 + */ + private Integer likes; + + /** + * 댓글 수 + */ + private Integer comments; + + /** + * 공유 수 + */ + private Integer shares; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TimelineAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TimelineAnalyticsResponse.java new file mode 100644 index 0000000..4ce91f2 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TimelineAnalyticsResponse.java @@ -0,0 +1,49 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 시간대별 참여 추이 응답 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TimelineAnalyticsResponse { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 시간 간격 (hourly, daily, weekly) + */ + private String interval; + + /** + * 시간대별 데이터 + */ + private List dataPoints; + + /** + * 추세 분석 + */ + private TrendAnalysis trends; + + /** + * 피크 타임 정보 + */ + private List peakTimes; + + /** + * 마지막 업데이트 시간 + */ + private LocalDateTime lastUpdatedAt; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TimelineDataPoint.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TimelineDataPoint.java new file mode 100644 index 0000000..6191f47 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TimelineDataPoint.java @@ -0,0 +1,48 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 시간대별 데이터 포인트 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TimelineDataPoint { + + /** + * 시간 + */ + private LocalDateTime timestamp; + + /** + * 참여자 수 + */ + private Integer participants; + + /** + * 조회수 + */ + private Integer views; + + /** + * 참여 행동 수 + */ + private Integer engagement; + + /** + * 전환 수 + */ + private Integer conversions; + + /** + * 누적 참여자 수 + */ + private Integer cumulativeParticipants; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TrendAnalysis.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TrendAnalysis.java new file mode 100644 index 0000000..24d502f --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/TrendAnalysis.java @@ -0,0 +1,36 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 추세 분석 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrendAnalysis { + + /** + * 전체 추세 (increasing, stable, decreasing) + */ + private String overallTrend; + + /** + * 증가율 (%) + */ + private Double growthRate; + + /** + * 예상 참여자 수 (기간 종료 시점) + */ + private Integer projectedParticipants; + + /** + * 피크 기간 + */ + private String peakPeriod; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserAnalyticsDashboardResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserAnalyticsDashboardResponse.java new file mode 100644 index 0000000..ebe2f82 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserAnalyticsDashboardResponse.java @@ -0,0 +1,87 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 사용자 전체 이벤트 통합 대시보드 응답 + * + * 사용자 ID 기반으로 모든 이벤트의 성과를 통합하여 제공 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserAnalyticsDashboardResponse { + + /** + * 사용자 ID + */ + private String userId; + + /** + * 조회 기간 정보 + */ + private PeriodInfo period; + + /** + * 전체 이벤트 수 + */ + private Integer totalEvents; + + /** + * 활성 이벤트 수 + */ + private Integer activeEvents; + + /** + * 전체 성과 요약 (모든 이벤트 통합) + */ + private AnalyticsSummary overallSummary; + + /** + * 채널별 성과 요약 (모든 이벤트 통합) + */ + private List channelPerformance; + + /** + * 전체 ROI 요약 + */ + private RoiSummary overallRoi; + + /** + * 이벤트별 성과 목록 (간략) + */ + private List eventPerformances; + + /** + * 마지막 업데이트 시간 + */ + private LocalDateTime lastUpdatedAt; + + /** + * 데이터 출처 (real-time, cached, fallback) + */ + private String dataSource; + + /** + * 이벤트별 성과 요약 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EventPerformanceSummary { + private String eventId; + private String eventTitle; + private Integer participants; + private Integer views; + private Double roi; + private String status; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserChannelAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserChannelAnalyticsResponse.java new file mode 100644 index 0000000..f20e5d8 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserChannelAnalyticsResponse.java @@ -0,0 +1,56 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 사용자 전체 이벤트의 채널별 성과 분석 응답 + * + * 사용자 ID 기반으로 모든 이벤트의 채널 성과를 통합하여 제공 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserChannelAnalyticsResponse { + + /** + * 사용자 ID + */ + private String userId; + + /** + * 조회 기간 정보 + */ + private PeriodInfo period; + + /** + * 전체 이벤트 수 + */ + private Integer totalEvents; + + /** + * 채널별 통합 성과 목록 + */ + private List channels; + + /** + * 채널 간 비교 분석 + */ + private ChannelComparison comparison; + + /** + * 마지막 업데이트 시간 + */ + private LocalDateTime lastUpdatedAt; + + /** + * 데이터 출처 + */ + private String dataSource; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserRoiAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserRoiAnalyticsResponse.java new file mode 100644 index 0000000..dcda8f2 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserRoiAnalyticsResponse.java @@ -0,0 +1,92 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 사용자 전체 이벤트의 ROI 분석 응답 + * + * 사용자 ID 기반으로 모든 이벤트의 ROI를 통합하여 제공 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserRoiAnalyticsResponse { + + /** + * 사용자 ID + */ + private String userId; + + /** + * 조회 기간 정보 + */ + private PeriodInfo period; + + /** + * 전체 이벤트 수 + */ + private Integer totalEvents; + + /** + * 전체 투자 정보 (모든 이벤트 합계) + */ + private InvestmentDetails overallInvestment; + + /** + * 전체 수익 정보 (모든 이벤트 합계) + */ + private RevenueDetails overallRevenue; + + /** + * 전체 ROI 계산 결과 + */ + private RoiCalculation overallRoi; + + /** + * 비용 효율성 분석 + */ + private CostEfficiency costEfficiency; + + /** + * 수익 예측 (포함 여부에 따라 nullable) + */ + private RevenueProjection projection; + + /** + * 이벤트별 ROI 목록 + */ + private List eventRois; + + /** + * 마지막 업데이트 시간 + */ + private LocalDateTime lastUpdatedAt; + + /** + * 데이터 출처 + */ + private String dataSource; + + /** + * 이벤트별 ROI 요약 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EventRoiSummary { + private String eventId; + private String eventTitle; + private Double totalInvestment; + private Double expectedRevenue; + private Double roi; + private String status; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserTimelineAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserTimelineAnalyticsResponse.java new file mode 100644 index 0000000..7a41d13 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/UserTimelineAnalyticsResponse.java @@ -0,0 +1,66 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 사용자 전체 이벤트의 시간대별 분석 응답 + * + * 사용자 ID 기반으로 모든 이벤트의 시간대별 데이터를 통합하여 제공 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserTimelineAnalyticsResponse { + + /** + * 사용자 ID + */ + private String userId; + + /** + * 조회 기간 정보 + */ + private PeriodInfo period; + + /** + * 전체 이벤트 수 + */ + private Integer totalEvents; + + /** + * 시간 간격 (hourly, daily, weekly, monthly) + */ + private String interval; + + /** + * 시간대별 데이터 포인트 (모든 이벤트 통합) + */ + private List dataPoints; + + /** + * 트렌드 분석 + */ + private TrendAnalysis trend; + + /** + * 피크 시간 정보 + */ + private PeakTimeInfo peakTime; + + /** + * 마지막 업데이트 시간 + */ + private LocalDateTime lastUpdatedAt; + + /** + * 데이터 출처 + */ + private String dataSource; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/VoiceCallStats.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/VoiceCallStats.java new file mode 100644 index 0000000..483cbb5 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/VoiceCallStats.java @@ -0,0 +1,36 @@ +package com.kt.event.analytics.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 링고비즈 음성 통화 통계 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VoiceCallStats { + + /** + * 총 통화 수 + */ + private Integer totalCalls; + + /** + * 완료된 통화 수 + */ + private Integer completedCalls; + + /** + * 평균 통화 시간 (초) + */ + private Integer averageDuration; + + /** + * 통화 완료율 (%) + */ + private Double completionRate; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/entity/ChannelStats.java b/analytics-service/src/main/java/com/kt/event/analytics/entity/ChannelStats.java new file mode 100644 index 0000000..10696e1 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/entity/ChannelStats.java @@ -0,0 +1,128 @@ +package com.kt.event.analytics.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +/** + * 채널별 통계 엔티티 + * + * 각 배포 채널별 성과 데이터를 저장 + */ +@Entity +@Table(name = "channel_stats", indexes = { + @Index(name = "idx_event_id", columnList = "event_id"), + @Index(name = "idx_event_channel", columnList = "event_id, channel_name") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChannelStats extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 이벤트 ID + */ + @Column(name = "event_id", nullable = false, length = 50) + private String eventId; + + /** + * 채널명 (우리동네TV, 지니TV, 링고비즈, SNS) + */ + @Column(name = "channel_name", nullable = false, length = 50) + private String channelName; + + /** + * 채널 유형 + */ + @Column(name = "channel_type", length = 30) + private String channelType; + + /** + * 노출 수 + */ + @Column(nullable = false) + @Builder.Default + private Integer impressions = 0; + + /** + * 조회수 + */ + @Column(nullable = false) + @Builder.Default + private Integer views = 0; + + /** + * 클릭 수 + */ + @Column(nullable = false) + @Builder.Default + private Integer clicks = 0; + + /** + * 참여자 수 + */ + @Column(nullable = false) + @Builder.Default + private Integer participants = 0; + + /** + * 전환 수 + */ + @Column(nullable = false) + @Builder.Default + private Integer conversions = 0; + + /** + * 배포 비용 (원) + */ + @Column(name = "distribution_cost", precision = 15, scale = 2) + @Builder.Default + private BigDecimal distributionCost = BigDecimal.ZERO; + + /** + * 좋아요 수 (SNS 전용) + */ + @Builder.Default + private Integer likes = 0; + + /** + * 댓글 수 (SNS 전용) + */ + @Builder.Default + private Integer comments = 0; + + /** + * 공유 수 (SNS 전용) + */ + @Builder.Default + private Integer shares = 0; + + /** + * 통화 수 (링고비즈 전용) + */ + @Column(name = "total_calls") + @Builder.Default + private Integer totalCalls = 0; + + /** + * 완료된 통화 수 (링고비즈 전용) + */ + @Column(name = "completed_calls") + @Builder.Default + private Integer completedCalls = 0; + + /** + * 평균 통화 시간 (초) (링고비즈 전용) + */ + @Column(name = "average_duration") + @Builder.Default + private Integer averageDuration = 0; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/entity/EventStats.java b/analytics-service/src/main/java/com/kt/event/analytics/entity/EventStats.java new file mode 100644 index 0000000..e3b4464 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/entity/EventStats.java @@ -0,0 +1,113 @@ +package com.kt.event.analytics.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +/** + * 이벤트 통계 엔티티 + * + * Kafka Event Subscription을 통해 실시간으로 업데이트되는 이벤트 통계 정보 + */ +@Entity +@Table(name = "event_stats") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EventStats extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 이벤트 ID + */ + @Column(nullable = false, unique = true, length = 50) + private String eventId; + + /** + * 이벤트 제목 + */ + @Column(nullable = false, length = 200) + private String eventTitle; + + /** + * 사용자 ID (소유자) + */ + @Column(nullable = false, length = 50) + private String userId; + + /** + * 총 참여자 수 + */ + @Column(nullable = false) + @Builder.Default + private Integer totalParticipants = 0; + + /** + * 총 노출 수 (모든 채널의 노출 수 합계) + */ + @Column(nullable = false) + @Builder.Default + private Integer totalViews = 0; + + /** + * 예상 ROI (%) + */ + @Column(precision = 10, scale = 2) + @Builder.Default + private BigDecimal estimatedRoi = BigDecimal.ZERO; + + /** + * 목표 ROI (%) + */ + @Column(precision = 10, scale = 2) + @Builder.Default + private BigDecimal targetRoi = BigDecimal.ZERO; + + /** + * 매출 증가율 (%) + */ + @Column(precision = 10, scale = 2) + @Builder.Default + private BigDecimal salesGrowthRate = BigDecimal.ZERO; + + /** + * 총 투자 비용 (원) + */ + @Column(precision = 15, scale = 2) + @Builder.Default + private BigDecimal totalInvestment = BigDecimal.ZERO; + + /** + * 예상 수익 (원) + */ + @Column(precision = 15, scale = 2) + @Builder.Default + private BigDecimal expectedRevenue = BigDecimal.ZERO; + + /** + * 이벤트 상태 + */ + @Column(length = 20) + private String status; + + /** + * 참여자 수 증가 + */ + public void incrementParticipants() { + this.totalParticipants++; + } + + /** + * 참여자 수 증가 (특정 수) + */ + public void incrementParticipants(int count) { + this.totalParticipants += count; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/entity/TimelineData.java b/analytics-service/src/main/java/com/kt/event/analytics/entity/TimelineData.java new file mode 100644 index 0000000..912a9c6 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/entity/TimelineData.java @@ -0,0 +1,75 @@ +package com.kt.event.analytics.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 시간대별 데이터 엔티티 + * + * 이벤트 기간 동안의 시간대별 참여 추이 데이터 + */ +@Entity +@Table(name = "timeline_data", indexes = { + @Index(name = "idx_event_timestamp", columnList = "event_id, timestamp") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TimelineData extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 이벤트 ID + */ + @Column(name = "event_id", nullable = false, length = 50) + private String eventId; + + /** + * 시간 (집계 기준 시간) + */ + @Column(nullable = false) + private LocalDateTime timestamp; + + /** + * 참여자 수 + */ + @Column(nullable = false) + @Builder.Default + private Integer participants = 0; + + /** + * 조회수 + */ + @Column(nullable = false) + @Builder.Default + private Integer views = 0; + + /** + * 참여 행동 수 + */ + @Column(nullable = false) + @Builder.Default + private Integer engagement = 0; + + /** + * 전환 수 + */ + @Column(nullable = false) + @Builder.Default + private Integer conversions = 0; + + /** + * 누적 참여자 수 + */ + @Column(name = "cumulative_participants", nullable = false) + @Builder.Default + private Integer cumulativeParticipants = 0; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/DistributionCompletedConsumer.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/DistributionCompletedConsumer.java new file mode 100644 index 0000000..0d77956 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/DistributionCompletedConsumer.java @@ -0,0 +1,149 @@ +package com.kt.event.analytics.messaging.consumer; + +import com.kt.event.analytics.entity.ChannelStats; +import com.kt.event.analytics.messaging.event.DistributionCompletedEvent; +import com.kt.event.analytics.repository.ChannelStatsRepository; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 배포 완료 Consumer + * + * 배포 완료 시 채널 통계 업데이트 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false) +@RequiredArgsConstructor +public class DistributionCompletedConsumer { + + private final ChannelStatsRepository channelStatsRepository; + private final EventStatsRepository eventStatsRepository; + private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; + + private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed"; + private static final String CACHE_KEY_PREFIX = "analytics:dashboard:"; + private static final long IDEMPOTENCY_TTL_DAYS = 7; + + /** + * DistributionCompleted 이벤트 처리 (설계서 기준 - 여러 채널 배열) + * + * @Transactional 필수: DB 저장 작업을 위해 트랜잭션 컨텍스트 필요 + */ + @Transactional + @KafkaListener(topics = "sample.distribution.completed", groupId = "${spring.kafka.consumer.group-id}") + public void handleDistributionCompleted(String message) { + try { + log.info("📩 DistributionCompleted 이벤트 수신: {}", message); + + DistributionCompletedEvent event = objectMapper.readValue(message, DistributionCompletedEvent.class); + String eventId = event.getEventId(); + + // ✅ 1. 멱등성 체크 (중복 처리 방지) - eventId 기반 + Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_DISTRIBUTIONS_KEY, eventId); + if (Boolean.TRUE.equals(isProcessed)) { + log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}", eventId); + return; + } + + // 2. 채널 배열 루프 처리 (설계서: distributedChannels 배열) + if (event.getDistributedChannels() != null && !event.getDistributedChannels().isEmpty()) { + for (DistributionCompletedEvent.ChannelDistribution channel : event.getDistributedChannels()) { + processChannelStats(eventId, channel); + } + + log.info("✅ 채널 통계 일괄 업데이트 완료: eventId={}, channelCount={}", + eventId, event.getDistributedChannels().size()); + } else { + log.warn("⚠️ 배포된 채널 없음: eventId={}", eventId); + } + + // 3. EventStats의 totalViews 업데이트 (모든 채널 노출 수 합계) + updateTotalViews(eventId); + + // 4. 캐시 무효화 (다음 조회 시 최신 배포 통계 반영) + String cacheKey = CACHE_KEY_PREFIX + eventId; + redisTemplate.delete(cacheKey); + log.debug("🗑️ 캐시 무효화: {}", cacheKey); + + // 5. 멱등성 처리 완료 기록 (7일 TTL) - eventId 기반 + redisTemplate.opsForSet().add(PROCESSED_DISTRIBUTIONS_KEY, eventId); + redisTemplate.expire(PROCESSED_DISTRIBUTIONS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS); + log.debug("✅ 멱등성 기록: eventId={}", eventId); + + } catch (Exception e) { + log.error("❌ DistributionCompleted 이벤트 처리 실패: {}", e.getMessage(), e); + throw new RuntimeException("DistributionCompleted 처리 실패", e); + } + } + + /** + * 개별 채널 통계 처리 + */ + private void processChannelStats(String eventId, DistributionCompletedEvent.ChannelDistribution channel) { + try { + String channelName = channel.getChannel(); + + // 채널 통계 생성 또는 업데이트 + ChannelStats channelStats = channelStatsRepository + .findByEventIdAndChannelName(eventId, channelName) + .orElse(ChannelStats.builder() + .eventId(eventId) + .channelName(channelName) + .channelType(channel.getChannelType()) + .build()); + + // 예상 노출 수 저장 + if (channel.getExpectedViews() != null) { + channelStats.setImpressions(channel.getExpectedViews()); + } + + channelStatsRepository.save(channelStats); + + log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}", + eventId, channelName, channel.getExpectedViews()); + + } catch (Exception e) { + log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e); + } + } + + /** + * 모든 채널의 예상 노출 수를 합산하여 EventStats.totalViews 업데이트 + */ + private void updateTotalViews(String eventId) { + try { + // 모든 채널 통계 조회 + List channelStatsList = channelStatsRepository.findByEventId(eventId); + + // 총 노출 수 계산 + int totalViews = channelStatsList.stream() + .mapToInt(ChannelStats::getImpressions) + .sum(); + + // EventStats 업데이트 - 비관적 락 적용 + eventStatsRepository.findByEventIdWithLock(eventId) + .ifPresentOrElse( + eventStats -> { + eventStats.setTotalViews(totalViews); + eventStatsRepository.save(eventStats); + log.info("✅ 총 노출 수 업데이트: eventId={}, totalViews={}", eventId, totalViews); + }, + () -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId) + ); + } catch (Exception e) { + log.error("❌ totalViews 업데이트 실패: eventId={}", eventId, e); + } + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java new file mode 100644 index 0000000..f4be5ef --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java @@ -0,0 +1,85 @@ +package com.kt.event.analytics.messaging.consumer; + +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.messaging.event.EventCreatedEvent; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.TimeUnit; + +/** + * 이벤트 생성 Consumer + * + * 이벤트 생성 시 Analytics 통계 초기화 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false) +@RequiredArgsConstructor +public class EventCreatedConsumer { + + private final EventStatsRepository eventStatsRepository; + private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; + + private static final String PROCESSED_EVENTS_KEY = "processed_events"; + private static final String CACHE_KEY_PREFIX = "analytics:dashboard:"; + private static final long IDEMPOTENCY_TTL_DAYS = 7; + + /** + * EventCreated 이벤트 처리 (MVP용 샘플 토픽) + * + * @Transactional 필수: DB 저장 작업을 위해 트랜잭션 컨텍스트 필요 + */ + @Transactional + @KafkaListener(topics = "sample.event.created", groupId = "${spring.kafka.consumer.group-id}") + public void handleEventCreated(String message) { + try { + log.info("📩 EventCreated 이벤트 수신: {}", message); + + EventCreatedEvent event = objectMapper.readValue(message, EventCreatedEvent.class); + String eventId = event.getEventId(); + + // ✅ 1. 멱등성 체크 (중복 처리 방지) + Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_EVENTS_KEY, eventId); + if (Boolean.TRUE.equals(isProcessed)) { + log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}", eventId); + return; + } + + // 2. 이벤트 통계 초기화 (1:1 관계: storeId → userId 매핑) + EventStats eventStats = EventStats.builder() + .eventId(eventId) + .eventTitle(event.getEventTitle()) + .userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑 + .totalParticipants(0) + .totalInvestment(event.getTotalInvestment()) + .status(event.getStatus()) + .build(); + + eventStatsRepository.save(eventStats); + log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId); + + // 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영) + String cacheKey = CACHE_KEY_PREFIX + eventId; + redisTemplate.delete(cacheKey); + log.debug("🗑️ 캐시 무효화: {}", cacheKey); + + // 4. 멱등성 처리 완료 기록 (7일 TTL) + redisTemplate.opsForSet().add(PROCESSED_EVENTS_KEY, eventId); + redisTemplate.expire(PROCESSED_EVENTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS); + log.debug("✅ 멱등성 기록: eventId={}", eventId); + + } catch (Exception e) { + log.error("❌ EventCreated 이벤트 처리 실패: {}", e.getMessage(), e); + throw new RuntimeException("EventCreated 처리 실패", e); + } + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/ParticipantRegisteredConsumer.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/ParticipantRegisteredConsumer.java new file mode 100644 index 0000000..54d2fb5 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/ParticipantRegisteredConsumer.java @@ -0,0 +1,85 @@ +package com.kt.event.analytics.messaging.consumer; + +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.TimeUnit; + +/** + * 참여자 등록 Consumer + * + * 참여자 등록 시 실시간 참여자 수 업데이트 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false) +@RequiredArgsConstructor +public class ParticipantRegisteredConsumer { + + private final EventStatsRepository eventStatsRepository; + private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; + + private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants"; + private static final String CACHE_KEY_PREFIX = "analytics:dashboard:"; + private static final long IDEMPOTENCY_TTL_DAYS = 7; + + /** + * ParticipantRegistered 이벤트 처리 (MVP용 샘플 토픽) + * + * @Transactional 필수: 비관적 락 사용을 위해 트랜잭션 컨텍스트 필요 + */ + @Transactional + @KafkaListener(topics = "sample.participant.registered", groupId = "${spring.kafka.consumer.group-id}") + public void handleParticipantRegistered(String message) { + try { + log.info("📩 ParticipantRegistered 이벤트 수신: {}", message); + + ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class); + String participantId = event.getParticipantId(); + String eventId = event.getEventId(); + + // ✅ 1. 멱등성 체크 (중복 처리 방지) + Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId); + if (Boolean.TRUE.equals(isProcessed)) { + log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId); + return; + } + + // 2. 이벤트 통계 업데이트 (참여자 수 +1) - 비관적 락 적용 + eventStatsRepository.findByEventIdWithLock(eventId) + .ifPresentOrElse( + eventStats -> { + eventStats.incrementParticipants(); + eventStatsRepository.save(eventStats); + log.info("✅ 참여자 수 업데이트: eventId={}, totalParticipants={}", + eventId, eventStats.getTotalParticipants()); + }, + () -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId) + ); + + // 3. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영) + String cacheKey = CACHE_KEY_PREFIX + eventId; + redisTemplate.delete(cacheKey); + log.debug("🗑️ 캐시 무효화: {}", cacheKey); + + // 4. 멱등성 처리 완료 기록 (7일 TTL) + redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId); + redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS); + log.debug("✅ 멱등성 기록: participantId={}", participantId); + + } catch (Exception e) { + log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e); + throw new RuntimeException("ParticipantRegistered 처리 실패", e); + } + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/DistributionCompletedEvent.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/DistributionCompletedEvent.java new file mode 100644 index 0000000..0883697 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/DistributionCompletedEvent.java @@ -0,0 +1,66 @@ +package com.kt.event.analytics.messaging.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 배포 완료 이벤트 (설계서 기준) + * + * Distribution Service가 한 이벤트의 모든 채널 배포 완료 시 발행 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DistributionCompletedEvent { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 배포된 채널 목록 (여러 채널을 배열로 포함) + */ + private List distributedChannels; + + /** + * 배포 완료 시각 + */ + private LocalDateTime completedAt; + + /** + * 개별 채널 배포 정보 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ChannelDistribution { + + /** + * 채널명 (우리동네TV, 지니TV, 링고비즈, SNS) + */ + private String channel; + + /** + * 채널 유형 (TV, CALL, SNS) + */ + private String channelType; + + /** + * 배포 상태 (SUCCESS, FAILURE) + */ + private String status; + + /** + * 예상 노출 수 + */ + private Integer expectedViews; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/EventCreatedEvent.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/EventCreatedEvent.java new file mode 100644 index 0000000..db04917 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/EventCreatedEvent.java @@ -0,0 +1,43 @@ +package com.kt.event.analytics.messaging.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 이벤트 생성 이벤트 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventCreatedEvent { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 이벤트 제목 + */ + private String eventTitle; + + /** + * 매장 ID + */ + private String storeId; + + /** + * 총 투자 비용 + */ + private BigDecimal totalInvestment; + + /** + * 이벤트 상태 + */ + private String status; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/ParticipantRegisteredEvent.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/ParticipantRegisteredEvent.java new file mode 100644 index 0000000..8433661 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/ParticipantRegisteredEvent.java @@ -0,0 +1,31 @@ +package com.kt.event.analytics.messaging.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 참여자 등록 이벤트 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantRegisteredEvent { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 참여자 ID + */ + private String participantId; + + /** + * 참여 채널 + */ + private String channel; +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java b/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java new file mode 100644 index 0000000..a049da6 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java @@ -0,0 +1,40 @@ +package com.kt.event.analytics.repository; + +import com.kt.event.analytics.entity.ChannelStats; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 채널 통계 Repository + */ +@Repository +public interface ChannelStatsRepository extends JpaRepository { + + /** + * 이벤트 ID로 모든 채널 통계 조회 + * + * @param eventId 이벤트 ID + * @return 채널 통계 목록 + */ + List findByEventId(String eventId); + + /** + * 이벤트 ID와 채널명으로 통계 조회 + * + * @param eventId 이벤트 ID + * @param channelName 채널명 + * @return 채널 통계 + */ + Optional findByEventIdAndChannelName(String eventId, String channelName); + + /** + * 여러 이벤트 ID로 모든 채널 통계 조회 + * + * @param eventIds 이벤트 ID 목록 + * @return 채널 통계 목록 + */ + List findByEventIdIn(List eventIds); +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java b/analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java new file mode 100644 index 0000000..ac36dd2 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java @@ -0,0 +1,57 @@ +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; + +/** + * 이벤트 통계 Repository + */ +@Repository +public interface EventStatsRepository extends JpaRepository { + + /** + * 이벤트 ID로 통계 조회 + * + * @param eventId 이벤트 ID + * @return 이벤트 통계 + */ + Optional 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 findByEventIdWithLock(@Param("eventId") String eventId); + + /** + * 사용자 ID와 이벤트 ID로 통계 조회 + * + * @param userId 사용자 ID + * @param eventId 이벤트 ID + * @return 이벤트 통계 + */ + Optional findByUserIdAndEventId(String userId, String eventId); + + /** + * 사용자 ID로 모든 이벤트 통계 조회 + * + * @param userId 사용자 ID + * @return 이벤트 통계 목록 + */ + java.util.List findAllByUserId(String userId); +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java b/analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java new file mode 100644 index 0000000..78c63c1 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java @@ -0,0 +1,63 @@ +package com.kt.event.analytics.repository; + +import com.kt.event.analytics.entity.TimelineData; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 시간대별 데이터 Repository + */ +@Repository +public interface TimelineDataRepository extends JpaRepository { + + /** + * 이벤트 ID로 시간대별 데이터 조회 (시간 순 정렬) + * + * @param eventId 이벤트 ID + * @return 시간대별 데이터 목록 + */ + List findByEventIdOrderByTimestampAsc(String eventId); + + /** + * 이벤트 ID와 기간으로 시간대별 데이터 조회 + * + * @param eventId 이벤트 ID + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 시간대별 데이터 목록 + */ + @Query("SELECT t FROM TimelineData t WHERE t.eventId = :eventId AND t.timestamp BETWEEN :startDate AND :endDate ORDER BY t.timestamp ASC") + List findByEventIdAndTimestampBetween( + @Param("eventId") String eventId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate + ); + + /** + * 여러 이벤트 ID로 시간대별 데이터 조회 (시간 순 정렬) + * + * @param eventIds 이벤트 ID 목록 + * @return 시간대별 데이터 목록 + */ + List findByEventIdInOrderByTimestampAsc(List eventIds); + + /** + * 여러 이벤트 ID와 기간으로 시간대별 데이터 조회 + * + * @param eventIds 이벤트 ID 목록 + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 시간대별 데이터 목록 + */ + @Query("SELECT t FROM TimelineData t WHERE t.eventId IN :eventIds AND t.timestamp BETWEEN :startDate AND :endDate ORDER BY t.timestamp ASC") + List findByEventIdInAndTimestampBetween( + @Param("eventIds") List eventIds, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate + ); +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java new file mode 100644 index 0000000..4402e06 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java @@ -0,0 +1,218 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.*; +import com.kt.event.analytics.entity.ChannelStats; +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.repository.ChannelStatsRepository; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.kt.event.common.exception.BusinessException; +import com.kt.event.common.exception.ErrorCode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Analytics Service + * + * 이벤트 성과 대시보드 데이터를 제공하는 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalyticsService { + + private final EventStatsRepository eventStatsRepository; + private final ChannelStatsRepository channelStatsRepository; + private final ExternalChannelService externalChannelService; + private final ROICalculator roiCalculator; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final String CACHE_KEY_PREFIX = "analytics:dashboard:"; + private static final long CACHE_TTL = 3600; // 1시간 (단일 캐시) + + /** + * 대시보드 데이터 조회 + * + * @param eventId 이벤트 ID + * @param startDate 조회 시작 날짜 (선택) + * @param endDate 조회 종료 날짜 (선택) + * @param refresh 캐시 갱신 여부 + * @return 대시보드 응답 + */ + public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) { + log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh); + + String cacheKey = CACHE_KEY_PREFIX + eventId; + + // 1. Redis 캐시 조회 (refresh가 false일 때만) + if (!refresh) { + String cachedData = redisTemplate.opsForValue().get(cacheKey); + if (cachedData != null) { + try { + log.info("✅ 캐시 HIT: {} (1시간 캐시)", cacheKey); + return objectMapper.readValue(cachedData, AnalyticsDashboardResponse.class); + } catch (JsonProcessingException e) { + log.warn("캐시 데이터 역직렬화 실패: {}", e.getMessage()); + } + } + } + + // 2. 캐시 MISS: 데이터 통합 작업 + log.info("캐시 MISS 또는 refresh=true: PostgreSQL + 외부 API 호출"); + + // 2-1. Analytics DB 조회 (PostgreSQL) + EventStats eventStats = eventStatsRepository.findByEventId(eventId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + List channelStatsList = channelStatsRepository.findByEventId(eventId); + log.debug("PostgreSQL 조회 완료: eventId={}, 채널 수={}", eventId, channelStatsList.size()); + + // 2-2. 외부 채널 API 병렬 호출 (Circuit Breaker 적용) + try { + externalChannelService.updateChannelStatsFromExternalAPIs(eventId, channelStatsList); + log.info("외부 API 호출 성공: eventId={}", eventId); + } catch (Exception e) { + log.warn("외부 API 호출 실패, PostgreSQL 샘플 데이터 사용: eventId={}, error={}", + eventId, e.getMessage()); + // Fallback: PostgreSQL 샘플 데이터만 사용 + } + + // 3. 대시보드 데이터 구성 + AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate); + + // 4. Redis 캐싱 (1시간 TTL) + try { + String jsonData = objectMapper.writeValueAsString(response); + redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS); + log.info("✅ Redis 캐시 저장 완료: {} (TTL: 1시간)", cacheKey); + } catch (JsonProcessingException e) { + log.warn("캐시 데이터 직렬화 실패: {}", e.getMessage()); + } catch (Exception e) { + log.warn("캐시 저장 실패 (무시하고 계속 진행): {}", e.getMessage()); + } + + return response; + } + + /** + * 대시보드 데이터 구성 + */ + private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List channelStatsList, + LocalDateTime startDate, LocalDateTime endDate) { + // 기간 정보 + PeriodInfo period = buildPeriodInfo(startDate, endDate); + + // 성과 요약 + AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList); + + // 채널별 성과 요약 + List channelPerformance = buildChannelPerformance(channelStatsList, eventStats.getTotalInvestment()); + + // ROI 요약 + RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats); + + return AnalyticsDashboardResponse.builder() + .eventId(eventStats.getEventId()) + .eventTitle(eventStats.getEventTitle()) + .period(period) + .summary(summary) + .channelPerformance(channelPerformance) + .roi(roiSummary) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("cached") + .build(); + } + + /** + * 기간 정보 구성 + */ + private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { + LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); + LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); + + long durationDays = ChronoUnit.DAYS.between(start, end); + + return PeriodInfo.builder() + .startDate(start) + .endDate(end) + .durationDays((int) durationDays) + .build(); + } + + /** + * 성과 요약 구성 + */ + private AnalyticsSummary buildAnalyticsSummary(EventStats eventStats, List channelStatsList) { + int totalViews = channelStatsList.stream() + .mapToInt(ChannelStats::getViews) + .sum(); + + int totalReach = channelStatsList.stream() + .mapToInt(ChannelStats::getImpressions) + .sum(); + + double engagementRate = totalViews > 0 ? (eventStats.getTotalParticipants() * 100.0 / totalViews) : 0.0; + double conversionRate = totalViews > 0 ? (eventStats.getTotalParticipants() * 100.0 / totalViews) : 0.0; + + // SNS 반응 통계 집계 + int totalLikes = channelStatsList.stream().mapToInt(ChannelStats::getLikes).sum(); + int totalComments = channelStatsList.stream().mapToInt(ChannelStats::getComments).sum(); + int totalShares = channelStatsList.stream().mapToInt(ChannelStats::getShares).sum(); + + SocialInteractionStats socialStats = SocialInteractionStats.builder() + .likes(totalLikes) + .comments(totalComments) + .shares(totalShares) + .build(); + + return AnalyticsSummary.builder() + .participants(eventStats.getTotalParticipants()) + .participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산 + .totalViews(totalViews) + .totalReach(totalReach) + .engagementRate(Math.round(engagementRate * 10.0) / 10.0) + .conversionRate(Math.round(conversionRate * 10.0) / 10.0) + .averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 함) + .targetRoi(eventStats.getTargetRoi() != null ? eventStats.getTargetRoi().doubleValue() : null) + .socialInteractions(socialStats) + .build(); + } + + /** + * 채널별 성과 구성 + */ + private List buildChannelPerformance(List channelStatsList, java.math.BigDecimal totalInvestment) { + List summaries = new ArrayList<>(); + + for (ChannelStats stats : channelStatsList) { + double engagementRate = stats.getViews() > 0 ? (stats.getParticipants() * 100.0 / stats.getViews()) : 0.0; + double conversionRate = stats.getViews() > 0 ? (stats.getConversions() * 100.0 / stats.getViews()) : 0.0; + double roi = stats.getDistributionCost().compareTo(java.math.BigDecimal.ZERO) > 0 ? + (stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0; + + summaries.add(ChannelSummary.builder() + .channel(stats.getChannelName()) + .views(stats.getViews()) + .participants(stats.getParticipants()) + .engagementRate(Math.round(engagementRate * 10.0) / 10.0) + .conversionRate(Math.round(conversionRate * 10.0) / 10.0) + .roi(Math.round(roi * 10.0) / 10.0) + .build()); + } + + return summaries; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/ChannelAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/ChannelAnalyticsService.java new file mode 100644 index 0000000..a7d2258 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/ChannelAnalyticsService.java @@ -0,0 +1,241 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.*; +import com.kt.event.analytics.entity.ChannelStats; +import com.kt.event.analytics.repository.ChannelStatsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 채널별 분석 Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChannelAnalyticsService { + + private final ChannelStatsRepository channelStatsRepository; + private final ExternalChannelService externalChannelService; + + /** + * 채널별 성과 분석 + */ + public ChannelAnalyticsResponse getChannelAnalytics(String eventId, List channels, String sortBy, String order) { + log.info("채널별 성과 분석 조회: eventId={}", eventId); + + List channelStatsList = channelStatsRepository.findByEventId(eventId); + + // 외부 API 호출하여 최신 데이터 반영 + externalChannelService.updateChannelStatsFromExternalAPIs(eventId, channelStatsList); + + // 필터링 (특정 채널만 조회) + if (channels != null && !channels.isEmpty()) { + channelStatsList = channelStatsList.stream() + .filter(stats -> channels.contains(stats.getChannelName())) + .collect(Collectors.toList()); + } + + // 채널별 상세 분석 구성 + List channelAnalytics = buildChannelAnalytics(channelStatsList); + + // 정렬 + channelAnalytics = sortChannelAnalytics(channelAnalytics, sortBy, order); + + // 채널 간 비교 분석 + ChannelComparison comparison = buildChannelComparison(channelAnalytics); + + return ChannelAnalyticsResponse.builder() + .eventId(eventId) + .channels(channelAnalytics) + .comparison(comparison) + .lastUpdatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 채널별 상세 분석 구성 + */ + private List buildChannelAnalytics(List channelStatsList) { + return channelStatsList.stream() + .map(this::buildChannelAnalytics) + .collect(Collectors.toList()); + } + + private ChannelAnalytics buildChannelAnalytics(ChannelStats stats) { + ChannelMetrics metrics = buildChannelMetrics(stats); + ChannelPerformance performance = buildChannelPerformance(stats); + ChannelCosts costs = buildChannelCosts(stats); + + return ChannelAnalytics.builder() + .channelName(stats.getChannelName()) + .channelType(stats.getChannelType()) + .metrics(metrics) + .performance(performance) + .costs(costs) + .externalApiStatus("success") + .build(); + } + + /** + * 채널 지표 구성 + */ + private ChannelMetrics buildChannelMetrics(ChannelStats stats) { + SocialInteractionStats socialStats = null; + if (stats.getLikes() > 0 || stats.getComments() > 0 || stats.getShares() > 0) { + socialStats = SocialInteractionStats.builder() + .likes(stats.getLikes()) + .comments(stats.getComments()) + .shares(stats.getShares()) + .build(); + } + + VoiceCallStats voiceStats = null; + if (stats.getTotalCalls() > 0) { + double completionRate = stats.getTotalCalls() > 0 ? + (stats.getCompletedCalls() * 100.0 / stats.getTotalCalls()) : 0.0; + + voiceStats = VoiceCallStats.builder() + .totalCalls(stats.getTotalCalls()) + .completedCalls(stats.getCompletedCalls()) + .averageDuration(stats.getAverageDuration()) + .completionRate(Math.round(completionRate * 10.0) / 10.0) + .build(); + } + + return ChannelMetrics.builder() + .impressions(stats.getImpressions()) + .views(stats.getViews()) + .clicks(stats.getClicks()) + .participants(stats.getParticipants()) + .conversions(stats.getConversions()) + .socialInteractions(socialStats) + .voiceCallStats(voiceStats) + .build(); + } + + /** + * 채널 성과 지표 구성 + */ + private ChannelPerformance buildChannelPerformance(ChannelStats stats) { + double ctr = stats.getImpressions() > 0 ? (stats.getClicks() * 100.0 / stats.getImpressions()) : 0.0; + double engagementRate = stats.getViews() > 0 ? (stats.getParticipants() * 100.0 / stats.getViews()) : 0.0; + double conversionRate = stats.getViews() > 0 ? (stats.getConversions() * 100.0 / stats.getViews()) : 0.0; + + return ChannelPerformance.builder() + .clickThroughRate(Math.round(ctr * 10.0) / 10.0) + .engagementRate(Math.round(engagementRate * 10.0) / 10.0) + .conversionRate(Math.round(conversionRate * 10.0) / 10.0) + .averageEngagementTime(165) + .bounceRate(35.8) + .build(); + } + + /** + * 채널 비용 구성 + */ + private ChannelCosts buildChannelCosts(ChannelStats stats) { + double cpv = stats.getViews() > 0 ? + stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getViews()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0; + double cpc = stats.getClicks() > 0 ? + stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getClicks()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0; + double cpa = stats.getParticipants() > 0 ? + stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getParticipants()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0; + + double roi = stats.getDistributionCost().compareTo(BigDecimal.ZERO) > 0 ? + (stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0; + + return ChannelCosts.builder() + .distributionCost(stats.getDistributionCost()) + .costPerView(Math.round(cpv * 100.0) / 100.0) + .costPerClick(Math.round(cpc * 100.0) / 100.0) + .costPerAcquisition(Math.round(cpa * 100.0) / 100.0) + .roi(Math.round(roi * 10.0) / 10.0) + .build(); + } + + /** + * 채널 정렬 + */ + private List sortChannelAnalytics(List channelAnalytics, String sortBy, String order) { + Comparator comparator = switch (sortBy != null ? sortBy : "roi") { + case "views" -> Comparator.comparing(c -> c.getMetrics().getViews()); + case "participants" -> Comparator.comparing(c -> c.getMetrics().getParticipants()); + case "engagement_rate" -> Comparator.comparing(c -> c.getPerformance().getEngagementRate()); + case "conversion_rate" -> Comparator.comparing(c -> c.getPerformance().getConversionRate()); + default -> Comparator.comparing(c -> c.getCosts().getRoi()); + }; + + if ("asc".equals(order)) { + channelAnalytics.sort(comparator); + } else { + channelAnalytics.sort(comparator.reversed()); + } + + return channelAnalytics; + } + + /** + * 채널 간 비교 분석 구성 + */ + private ChannelComparison buildChannelComparison(List channelAnalytics) { + if (channelAnalytics.isEmpty()) { + return null; + } + + // 최고 성과 채널 찾기 + String bestByViews = channelAnalytics.stream() + .max(Comparator.comparing(c -> c.getMetrics().getViews())) + .map(ChannelAnalytics::getChannelName) + .orElse(null); + + String bestByEngagement = channelAnalytics.stream() + .max(Comparator.comparing(c -> c.getPerformance().getEngagementRate())) + .map(ChannelAnalytics::getChannelName) + .orElse(null); + + String bestByRoi = channelAnalytics.stream() + .max(Comparator.comparing(c -> c.getCosts().getRoi())) + .map(ChannelAnalytics::getChannelName) + .orElse(null); + + Map bestPerforming = new HashMap<>(); + bestPerforming.put("byViews", bestByViews); + bestPerforming.put("byEngagement", bestByEngagement); + bestPerforming.put("byRoi", bestByRoi); + + // 평균 지표 계산 + double avgEngagementRate = channelAnalytics.stream() + .mapToDouble(c -> c.getPerformance().getEngagementRate()) + .average() + .orElse(0.0); + + double avgConversionRate = channelAnalytics.stream() + .mapToDouble(c -> c.getPerformance().getConversionRate()) + .average() + .orElse(0.0); + + double avgRoi = channelAnalytics.stream() + .mapToDouble(c -> c.getCosts().getRoi()) + .average() + .orElse(0.0); + + Map averageMetrics = new HashMap<>(); + averageMetrics.put("engagementRate", Math.round(avgEngagementRate * 10.0) / 10.0); + averageMetrics.put("conversionRate", Math.round(avgConversionRate * 10.0) / 10.0); + averageMetrics.put("roi", Math.round(avgRoi * 10.0) / 10.0); + + return ChannelComparison.builder() + .bestPerforming(bestPerforming) + .averageMetrics(averageMetrics) + .build(); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/ExternalChannelService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/ExternalChannelService.java new file mode 100644 index 0000000..5e0bd4c --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/ExternalChannelService.java @@ -0,0 +1,142 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.entity.ChannelStats; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * 외부 채널 Service + * + * 외부 API 호출 및 Circuit Breaker 적용 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ExternalChannelService { + + /** + * 외부 채널 API에서 통계 업데이트 + * + * @param eventId 이벤트 ID + * @param channelStatsList 채널 통계 목록 + */ + public void updateChannelStatsFromExternalAPIs(String eventId, List channelStatsList) { + log.info("외부 채널 API 병렬 호출 시작: eventId={}", eventId); + + List> futures = channelStatsList.stream() + .map(channelStats -> CompletableFuture.runAsync(() -> + updateChannelStatsFromAPI(eventId, channelStats))) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + log.info("외부 채널 API 병렬 호출 완료: eventId={}", eventId); + } + + /** + * 개별 채널 통계 업데이트 + */ + private void updateChannelStatsFromAPI(String eventId, ChannelStats channelStats) { + String channelName = channelStats.getChannelName(); + log.debug("채널 통계 업데이트: eventId={}, channel={}", eventId, channelName); + + switch (channelName) { + case "우리동네TV" -> updateWooriTVStats(eventId, channelStats); + case "지니TV" -> updateGenieTVStats(eventId, channelStats); + case "링고비즈" -> updateRingoBizStats(eventId, channelStats); + case "SNS" -> updateSNSStats(eventId, channelStats); + default -> log.warn("알 수 없는 채널: {}", channelName); + } + } + + /** + * 우리동네TV 통계 업데이트 + */ + @CircuitBreaker(name = "wooriTV", fallbackMethod = "wooriTVFallback") + private void updateWooriTVStats(String eventId, ChannelStats channelStats) { + log.debug("우리동네TV API 호출: eventId={}", eventId); + // 실제 API 호출 로직 (Feign Client 사용) + // 예시 데이터 설정 + channelStats.setViews(45000); + channelStats.setClicks(5500); + channelStats.setImpressions(120000); + } + + /** + * 우리동네TV Fallback + */ + private void wooriTVFallback(String eventId, ChannelStats channelStats, Exception e) { + log.warn("우리동네TV API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage()); + // Fallback 데이터 (캐시 또는 기본값) + channelStats.setViews(0); + channelStats.setClicks(0); + } + + /** + * 지니TV 통계 업데이트 + */ + @CircuitBreaker(name = "genieTV", fallbackMethod = "genieTVFallback") + private void updateGenieTVStats(String eventId, ChannelStats channelStats) { + log.debug("지니TV API 호출: eventId={}", eventId); + // 예시 데이터 설정 + channelStats.setViews(30000); + channelStats.setClicks(3000); + channelStats.setImpressions(80000); + } + + /** + * 지니TV Fallback + */ + private void genieTVFallback(String eventId, ChannelStats channelStats, Exception e) { + log.warn("지니TV API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage()); + channelStats.setViews(0); + channelStats.setClicks(0); + } + + /** + * 링고비즈 통계 업데이트 + */ + @CircuitBreaker(name = "ringoBiz", fallbackMethod = "ringoBizFallback") + private void updateRingoBizStats(String eventId, ChannelStats channelStats) { + log.debug("링고비즈 API 호출: eventId={}", eventId); + // 예시 데이터 설정 + channelStats.setTotalCalls(3000); + channelStats.setCompletedCalls(2500); + channelStats.setAverageDuration(45); + } + + /** + * 링고비즈 Fallback + */ + private void ringoBizFallback(String eventId, ChannelStats channelStats, Exception e) { + log.warn("링고비즈 API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage()); + channelStats.setTotalCalls(0); + channelStats.setCompletedCalls(0); + } + + /** + * SNS 통계 업데이트 + */ + @CircuitBreaker(name = "sns", fallbackMethod = "snsFallback") + private void updateSNSStats(String eventId, ChannelStats channelStats) { + log.debug("SNS API 호출: eventId={}", eventId); + // 예시 데이터 설정 + channelStats.setLikes(3450); + channelStats.setComments(890); + channelStats.setShares(1250); + } + + /** + * SNS Fallback + */ + private void snsFallback(String eventId, ChannelStats channelStats, Exception e) { + log.warn("SNS API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage()); + channelStats.setLikes(0); + channelStats.setComments(0); + channelStats.setShares(0); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java b/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java new file mode 100644 index 0000000..29196e4 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java @@ -0,0 +1,202 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.*; +import com.kt.event.analytics.entity.ChannelStats; +import com.kt.event.analytics.entity.EventStats; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.List; + +/** + * ROI 계산 유틸리티 + * + * 이벤트의 투자 대비 수익률을 계산하는 비즈니스 로직 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ROICalculator { + + /** + * ROI 상세 계산 + * + * @param eventStats 이벤트 통계 + * @param channelStats 채널별 통계 + * @return ROI 상세 분석 결과 + */ + public RoiAnalyticsResponse calculateDetailedRoi(EventStats eventStats, List channelStats) { + log.debug("ROI 상세 계산 시작: eventId={}", eventStats.getEventId()); + + // 투자 비용 계산 + InvestmentDetails investment = calculateInvestment(eventStats, channelStats); + + // 수익 계산 + RevenueDetails revenue = calculateRevenue(eventStats); + + // ROI 계산 + RoiCalculation roiCalc = calculateRoi(investment, revenue); + + // 비용 효율성 계산 + CostEfficiency costEfficiency = calculateCostEfficiency(investment, revenue, eventStats); + + // 수익 예측 + RevenueProjection projection = projectRevenue(revenue, eventStats); + + return RoiAnalyticsResponse.builder() + .eventId(eventStats.getEventId()) + .investment(investment) + .revenue(revenue) + .roi(roiCalc) + .costEfficiency(costEfficiency) + .projection(projection) + .lastUpdatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 투자 비용 계산 + */ + private InvestmentDetails calculateInvestment(EventStats eventStats, List channelStats) { + BigDecimal distributionCost = channelStats.stream() + .map(ChannelStats::getDistributionCost) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal contentCreation = eventStats.getTotalInvestment() + .multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40%를 콘텐츠 제작비로 가정 + + BigDecimal operation = eventStats.getTotalInvestment() + .multiply(BigDecimal.valueOf(0.1)); // 10%를 운영비로 가정 + + return InvestmentDetails.builder() + .contentCreation(contentCreation) + .distribution(distributionCost) + .operation(operation) + .total(eventStats.getTotalInvestment()) + .build(); + } + + /** + * 수익 계산 + */ + private RevenueDetails calculateRevenue(EventStats eventStats) { + BigDecimal directSales = eventStats.getExpectedRevenue() + .multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66%를 직접 매출로 가정 + + BigDecimal expectedSales = eventStats.getExpectedRevenue() + .multiply(BigDecimal.valueOf(0.34)); // 34%를 예상 추가 매출로 가정 + + BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요 + + return RevenueDetails.builder() + .directSales(directSales) + .expectedSales(expectedSales) + .brandValue(brandValue) + .total(eventStats.getExpectedRevenue()) + .build(); + } + + /** + * ROI 계산 + */ + private RoiCalculation calculateRoi(InvestmentDetails investment, RevenueDetails revenue) { + BigDecimal netProfit = revenue.getTotal().subtract(investment.getTotal()); + + double roiPercentage = 0.0; + if (investment.getTotal().compareTo(BigDecimal.ZERO) > 0) { + roiPercentage = netProfit.divide(investment.getTotal(), 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .doubleValue(); + } + + // 손익분기점 계산 (간단한 선형 모델) + LocalDateTime breakEvenPoint = null; + if (roiPercentage > 0) { + breakEvenPoint = LocalDateTime.now().minusDays(5); // 예시 + } + + Integer paybackPeriod = roiPercentage > 0 ? 10 : null; // 예시 + + return RoiCalculation.builder() + .netProfit(netProfit) + .roiPercentage(roiPercentage) + .breakEvenPoint(breakEvenPoint) + .paybackPeriod(paybackPeriod) + .build(); + } + + /** + * 비용 효율성 계산 + */ + private CostEfficiency calculateCostEfficiency(InvestmentDetails investment, RevenueDetails revenue, EventStats eventStats) { + double costPerParticipant = 0.0; + double costPerConversion = 0.0; + double costPerView = 0.0; + double revenuePerParticipant = 0.0; + + if (eventStats.getTotalParticipants() > 0) { + costPerParticipant = investment.getTotal() + .divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP) + .doubleValue(); + + revenuePerParticipant = revenue.getTotal() + .divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP) + .doubleValue(); + } + + return CostEfficiency.builder() + .costPerParticipant(costPerParticipant) + .costPerConversion(costPerConversion) + .costPerView(costPerView) + .revenuePerParticipant(revenuePerParticipant) + .build(); + } + + /** + * 수익 예측 + */ + private RevenueProjection projectRevenue(RevenueDetails revenue, EventStats eventStats) { + BigDecimal projectedFinal = revenue.getTotal() + .multiply(BigDecimal.valueOf(1.1)); // 현재 수익의 110%로 예측 + + return RevenueProjection.builder() + .currentRevenue(revenue.getTotal()) + .projectedFinalRevenue(projectedFinal) + .confidenceLevel(85.5) + .basedOn("현재 추세 및 과거 유사 이벤트 데이터") + .build(); + } + + /** + * ROI 요약 계산 + */ + public RoiSummary calculateRoiSummary(EventStats eventStats) { + BigDecimal netProfit = eventStats.getExpectedRevenue().subtract(eventStats.getTotalInvestment()); + + double roi = 0.0; + if (eventStats.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0) { + roi = netProfit.divide(eventStats.getTotalInvestment(), 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .doubleValue(); + } + + double cpa = 0.0; + if (eventStats.getTotalParticipants() > 0) { + cpa = eventStats.getTotalInvestment() + .divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP) + .doubleValue(); + } + + return RoiSummary.builder() + .totalCost(eventStats.getTotalInvestment()) + .expectedRevenue(eventStats.getExpectedRevenue()) + .netProfit(netProfit) + .roi(roi) + .costPerAcquisition(cpa) + .build(); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/RoiAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/RoiAnalyticsService.java new file mode 100644 index 0000000..dca068e --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/RoiAnalyticsService.java @@ -0,0 +1,53 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.RoiAnalyticsResponse; +import com.kt.event.analytics.entity.ChannelStats; +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.repository.ChannelStatsRepository; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.kt.event.common.exception.BusinessException; +import com.kt.event.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * ROI 분석 Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RoiAnalyticsService { + + private final EventStatsRepository eventStatsRepository; + private final ChannelStatsRepository channelStatsRepository; + private final ROICalculator roiCalculator; + + /** + * ROI 상세 분석 조회 + */ + public RoiAnalyticsResponse getRoiAnalytics(String eventId, boolean includeProjection) { + log.info("ROI 상세 분석 조회: eventId={}, includeProjection={}", eventId, includeProjection); + + // 이벤트 통계 조회 + EventStats eventStats = eventStatsRepository.findByEventId(eventId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // 채널별 통계 조회 + List channelStatsList = channelStatsRepository.findByEventId(eventId); + + // ROI 상세 계산 + RoiAnalyticsResponse response = roiCalculator.calculateDetailedRoi(eventStats, channelStatsList); + + // 예측 데이터 제외 옵션 + if (!includeProjection) { + response.setProjection(null); + } + + return response; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/TimelineAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/TimelineAnalyticsService.java new file mode 100644 index 0000000..789646d --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/TimelineAnalyticsService.java @@ -0,0 +1,206 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.*; +import com.kt.event.analytics.entity.TimelineData; +import com.kt.event.analytics.repository.TimelineDataRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 시간대별 분석 Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimelineAnalyticsService { + + private final TimelineDataRepository timelineDataRepository; + + /** + * 시간대별 참여 추이 조회 + */ + public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, + LocalDateTime startDate, LocalDateTime endDate, + List metrics) { + log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval); + + // 시간대별 데이터 조회 + List timelineDataList; + if (startDate != null && endDate != null) { + timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate); + } else { + timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId); + } + + // 시간대별 데이터 포인트 구성 + List dataPoints = buildTimelineDataPoints(timelineDataList); + + // 추세 분석 + TrendAnalysis trends = buildTrendAnalysis(dataPoints); + + // 피크 타임 분석 + List peakTimes = buildPeakTimes(dataPoints); + + return TimelineAnalyticsResponse.builder() + .eventId(eventId) + .interval(interval != null ? interval : "daily") + .dataPoints(dataPoints) + .trends(trends) + .peakTimes(peakTimes) + .lastUpdatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 시간대별 데이터 포인트 구성 + */ + private List buildTimelineDataPoints(List timelineDataList) { + return timelineDataList.stream() + .map(data -> TimelineDataPoint.builder() + .timestamp(data.getTimestamp()) + .participants(data.getParticipants()) + .views(data.getViews()) + .engagement(data.getEngagement()) + .conversions(data.getConversions()) + .cumulativeParticipants(data.getCumulativeParticipants()) + .build()) + .collect(Collectors.toList()); + } + + /** + * 추세 분석 구성 + */ + private TrendAnalysis buildTrendAnalysis(List dataPoints) { + if (dataPoints.isEmpty()) { + return null; + } + + // 전체 추세 계산 + String overallTrend = calculateOverallTrend(dataPoints); + + // 증가율 계산 + double growthRate = calculateGrowthRate(dataPoints); + + // 예상 참여자 수 + int projectedParticipants = calculateProjectedParticipants(dataPoints); + + // 피크 기간 계산 + String peakPeriod = calculatePeakPeriod(dataPoints); + + return TrendAnalysis.builder() + .overallTrend(overallTrend) + .growthRate(Math.round(growthRate * 10.0) / 10.0) + .projectedParticipants(projectedParticipants) + .peakPeriod(peakPeriod) + .build(); + } + + /** + * 전체 추세 계산 + */ + private String calculateOverallTrend(List dataPoints) { + if (dataPoints.size() < 2) { + return "stable"; + } + + int firstHalfParticipants = dataPoints.stream() + .limit(dataPoints.size() / 2) + .mapToInt(TimelineDataPoint::getParticipants) + .sum(); + + int secondHalfParticipants = dataPoints.stream() + .skip(dataPoints.size() / 2) + .mapToInt(TimelineDataPoint::getParticipants) + .sum(); + + if (secondHalfParticipants > firstHalfParticipants * 1.1) { + return "increasing"; + } else if (secondHalfParticipants < firstHalfParticipants * 0.9) { + return "decreasing"; + } else { + return "stable"; + } + } + + /** + * 증가율 계산 + */ + private double calculateGrowthRate(List dataPoints) { + if (dataPoints.size() < 2) { + return 0.0; + } + + int firstParticipants = dataPoints.get(0).getParticipants(); + int lastParticipants = dataPoints.get(dataPoints.size() - 1).getParticipants(); + + if (firstParticipants == 0) { + return 0.0; + } + + return ((lastParticipants - firstParticipants) * 100.0 / firstParticipants); + } + + /** + * 예상 참여자 수 계산 + */ + private int calculateProjectedParticipants(List dataPoints) { + if (dataPoints.isEmpty()) { + return 0; + } + + return dataPoints.get(dataPoints.size() - 1).getCumulativeParticipants(); + } + + /** + * 피크 기간 계산 + */ + private String calculatePeakPeriod(List dataPoints) { + TimelineDataPoint peakPoint = dataPoints.stream() + .max(Comparator.comparing(TimelineDataPoint::getParticipants)) + .orElse(null); + + if (peakPoint == null) { + return ""; + } + + return peakPoint.getTimestamp().toLocalDate().toString(); + } + + /** + * 피크 타임 구성 + */ + private List buildPeakTimes(List dataPoints) { + List peakTimes = new ArrayList<>(); + + // 참여자 수 피크 + dataPoints.stream() + .max(Comparator.comparing(TimelineDataPoint::getParticipants)) + .ifPresent(point -> peakTimes.add(PeakTimeInfo.builder() + .timestamp(point.getTimestamp()) + .metric("participants") + .value(point.getParticipants()) + .description("최대 참여자 수") + .build())); + + // 조회수 피크 + dataPoints.stream() + .max(Comparator.comparing(TimelineDataPoint::getViews)) + .ifPresent(point -> peakTimes.add(PeakTimeInfo.builder() + .timestamp(point.getTimestamp()) + .metric("views") + .value(point.getViews()) + .description("최대 조회수") + .build())); + + return peakTimes; + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java new file mode 100644 index 0000000..98a7b51 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java @@ -0,0 +1,339 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.*; +import com.kt.event.analytics.entity.ChannelStats; +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.repository.ChannelStatsRepository; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * User Analytics Service + * + * 매장(사용자) 전체 이벤트의 통합 성과 대시보드를 제공하는 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserAnalyticsService { + + private final EventStatsRepository eventStatsRepository; + private final ChannelStatsRepository channelStatsRepository; + private final ROICalculator roiCalculator; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final String CACHE_KEY_PREFIX = "analytics:user:dashboard:"; + private static final long CACHE_TTL = 1800; // 30분 (여러 이벤트 통합이므로 짧게) + + /** + * 사용자 전체 대시보드 데이터 조회 + * + * @param userId 사용자 ID + * @param startDate 조회 시작 날짜 (선택) + * @param endDate 조회 종료 날짜 (선택) + * @param refresh 캐시 갱신 여부 + * @return 사용자 통합 대시보드 응답 + */ + public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) { + log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh); + + String cacheKey = CACHE_KEY_PREFIX + userId; + + // 1. Redis 캐시 조회 (refresh가 false일 때만) + if (!refresh) { + String cachedData = redisTemplate.opsForValue().get(cacheKey); + if (cachedData != null) { + try { + log.info("✅ 캐시 HIT: {}", cacheKey); + return objectMapper.readValue(cachedData, UserAnalyticsDashboardResponse.class); + } catch (JsonProcessingException e) { + log.warn("캐시 데이터 역직렬화 실패: {}", e.getMessage()); + } + } + } + + // 2. 캐시 MISS: 데이터 조회 및 통합 + log.info("캐시 MISS 또는 refresh=true: PostgreSQL 조회"); + + // 2-1. 사용자의 모든 이벤트 조회 + List allEvents = eventStatsRepository.findAllByUserId(userId); + if (allEvents.isEmpty()) { + log.warn("사용자에 이벤트가 없음: userId={}", userId); + return buildEmptyResponse(userId, startDate, endDate); + } + + log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size()); + + // 2-2. 모든 이벤트의 채널 통계 조회 + List eventIds = allEvents.stream() + .map(EventStats::getEventId) + .collect(Collectors.toList()); + List allChannelStats = channelStatsRepository.findByEventIdIn(eventIds); + + // 3. 통합 대시보드 데이터 구성 + UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate); + + // 4. Redis 캐싱 (30분 TTL) + try { + String jsonData = objectMapper.writeValueAsString(response); + redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS); + log.info("✅ Redis 캐시 저장 완료: {} (TTL: 30분)", cacheKey); + } catch (Exception e) { + log.warn("캐시 저장 실패 (무시하고 계속 진행): {}", e.getMessage()); + } + + return response; + } + + /** + * 빈 응답 생성 (이벤트가 없는 경우) + */ + private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) { + return UserAnalyticsDashboardResponse.builder() + .userId(userId) + .period(buildPeriodInfo(startDate, endDate)) + .totalEvents(0) + .activeEvents(0) + .overallSummary(buildEmptyAnalyticsSummary()) + .channelPerformance(new ArrayList<>()) + .overallRoi(buildEmptyRoiSummary()) + .eventPerformances(new ArrayList<>()) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("empty") + .build(); + } + + /** + * 사용자 통합 대시보드 데이터 구성 + */ + private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List allEvents, + List allChannelStats, + LocalDateTime startDate, LocalDateTime endDate) { + // 기간 정보 + PeriodInfo period = buildPeriodInfo(startDate, endDate); + + // 전체 이벤트 수 및 활성 이벤트 수 + int totalEvents = allEvents.size(); + long activeEvents = allEvents.stream() + .filter(e -> "ACTIVE".equalsIgnoreCase(e.getStatus()) || "RUNNING".equalsIgnoreCase(e.getStatus())) + .count(); + + // 전체 성과 요약 (모든 이벤트 통합) + AnalyticsSummary overallSummary = buildOverallSummary(allEvents, allChannelStats); + + // 채널별 성과 요약 (모든 이벤트 통합) + List channelPerformance = buildAggregatedChannelPerformance(allChannelStats, allEvents); + + // 전체 ROI 요약 + RoiSummary overallRoi = calculateOverallRoi(allEvents); + + // 이벤트별 성과 목록 + List eventPerformances = buildEventPerformances(allEvents); + + return UserAnalyticsDashboardResponse.builder() + .userId(userId) + .period(period) + .totalEvents(totalEvents) + .activeEvents((int) activeEvents) + .overallSummary(overallSummary) + .channelPerformance(channelPerformance) + .overallRoi(overallRoi) + .eventPerformances(eventPerformances) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("cached") + .build(); + } + + /** + * 전체 성과 요약 계산 (모든 이벤트 통합) + */ + private AnalyticsSummary buildOverallSummary(List allEvents, List allChannelStats) { + int totalParticipants = allEvents.stream() + .mapToInt(EventStats::getTotalParticipants) + .sum(); + + int totalViews = allEvents.stream() + .mapToInt(EventStats::getTotalViews) + .sum(); + + BigDecimal totalInvestment = allEvents.stream() + .map(EventStats::getTotalInvestment) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal totalExpectedRevenue = allEvents.stream() + .map(EventStats::getExpectedRevenue) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 평균 참여율 계산 + double avgEngagementRate = totalViews > 0 ? (double) totalParticipants / totalViews * 100 : 0.0; + + // 평균 전환율 계산 (채널 통계 기반) + int totalConversions = allChannelStats.stream() + .mapToInt(ChannelStats::getConversions) + .sum(); + double avgConversionRate = totalParticipants > 0 ? (double) totalConversions / totalParticipants * 100 : 0.0; + + return AnalyticsSummary.builder() + .participants(totalParticipants) + .participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산 + .totalViews(totalViews) + .engagementRate(Math.round(avgEngagementRate * 10) / 10.0) + .conversionRate(Math.round(avgConversionRate * 10) / 10.0) + .build(); + } + + /** + * 채널별 성과 통합 (모든 이벤트의 채널 데이터 집계) + */ + private List buildAggregatedChannelPerformance(List allChannelStats, List allEvents) { + if (allChannelStats.isEmpty()) { + return new ArrayList<>(); + } + + BigDecimal totalInvestment = allEvents.stream() + .map(EventStats::getTotalInvestment) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 채널명별로 그룹화하여 집계 + Map> channelGroups = allChannelStats.stream() + .collect(Collectors.groupingBy(ChannelStats::getChannelName)); + + return channelGroups.entrySet().stream() + .map(entry -> { + String channelName = entry.getKey(); + List channelList = entry.getValue(); + + int participants = channelList.stream().mapToInt(ChannelStats::getParticipants).sum(); + int views = channelList.stream().mapToInt(ChannelStats::getViews).sum(); + double engagementRate = views > 0 ? (double) participants / views * 100 : 0.0; + + BigDecimal channelCost = channelList.stream() + .map(ChannelStats::getDistributionCost) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + double channelRoi = channelCost.compareTo(BigDecimal.ZERO) > 0 + ? (participants - channelCost.doubleValue()) / channelCost.doubleValue() * 100 + : 0.0; + + return ChannelSummary.builder() + .channel(channelName) + .participants(participants) + .views(views) + .engagementRate(Math.round(engagementRate * 10) / 10.0) + .roi(Math.round(channelRoi * 10) / 10.0) + .build(); + }) + .sorted(Comparator.comparingInt(ChannelSummary::getParticipants).reversed()) + .collect(Collectors.toList()); + } + + /** + * 전체 ROI 계산 + */ + private RoiSummary calculateOverallRoi(List allEvents) { + BigDecimal totalInvestment = allEvents.stream() + .map(EventStats::getTotalInvestment) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal totalExpectedRevenue = allEvents.stream() + .map(EventStats::getExpectedRevenue) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal totalProfit = totalExpectedRevenue.subtract(totalInvestment); + + Double roi = totalInvestment.compareTo(BigDecimal.ZERO) > 0 + ? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .doubleValue() + : 0.0; + + return RoiSummary.builder() + .totalCost(totalInvestment) + .expectedRevenue(totalExpectedRevenue) + .netProfit(totalProfit) + .roi(Math.round(roi * 10) / 10.0) + .build(); + } + + /** + * 이벤트별 성과 목록 생성 + */ + private List buildEventPerformances(List allEvents) { + return allEvents.stream() + .map(event -> { + Double roi = event.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0 + ? event.getExpectedRevenue().subtract(event.getTotalInvestment()) + .divide(event.getTotalInvestment(), 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .doubleValue() + : 0.0; + + return UserAnalyticsDashboardResponse.EventPerformanceSummary.builder() + .eventId(event.getEventId()) + .eventTitle(event.getEventTitle()) + .participants(event.getTotalParticipants()) + .views(event.getTotalViews()) + .roi(Math.round(roi * 10) / 10.0) + .status(event.getStatus()) + .build(); + }) + .sorted(Comparator.comparingInt(UserAnalyticsDashboardResponse.EventPerformanceSummary::getParticipants).reversed()) + .collect(Collectors.toList()); + } + + /** + * 기간 정보 구성 + */ + private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { + LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); + LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); + long durationDays = ChronoUnit.DAYS.between(start, end); + + return PeriodInfo.builder() + .startDate(start) + .endDate(end) + .durationDays((int) durationDays) + .build(); + } + + /** + * 빈 성과 요약 + */ + private AnalyticsSummary buildEmptyAnalyticsSummary() { + return AnalyticsSummary.builder() + .participants(0) + .participantsDelta(0) + .totalViews(0) + .engagementRate(0.0) + .conversionRate(0.0) + .build(); + } + + /** + * 빈 ROI 요약 + */ + private RoiSummary buildEmptyRoiSummary() { + return RoiSummary.builder() + .totalCost(BigDecimal.ZERO) + .expectedRevenue(BigDecimal.ZERO) + .netProfit(BigDecimal.ZERO) + .roi(0.0) + .build(); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java new file mode 100644 index 0000000..057b10e --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java @@ -0,0 +1,260 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.*; +import com.kt.event.analytics.entity.ChannelStats; +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.repository.ChannelStatsRepository; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.HashMap; + +/** + * User Channel Analytics Service + * + * 매장(사용자) 전체 이벤트의 채널별 성과를 통합하여 제공하는 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserChannelAnalyticsService { + + private final EventStatsRepository eventStatsRepository; + private final ChannelStatsRepository channelStatsRepository; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final String CACHE_KEY_PREFIX = "analytics:user:channels:"; + private static final long CACHE_TTL = 1800; // 30분 + + /** + * 사용자 전체 채널 분석 데이터 조회 + */ + public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List channels, String sortBy, String order, + LocalDateTime startDate, LocalDateTime endDate, boolean refresh) { + log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh); + + String cacheKey = CACHE_KEY_PREFIX + userId; + + // 1. 캐시 조회 + if (!refresh) { + String cachedData = redisTemplate.opsForValue().get(cacheKey); + if (cachedData != null) { + try { + log.info("✅ 캐시 HIT: {}", cacheKey); + return objectMapper.readValue(cachedData, UserChannelAnalyticsResponse.class); + } catch (JsonProcessingException e) { + log.warn("캐시 역직렬화 실패: {}", e.getMessage()); + } + } + } + + // 2. 데이터 조회 + List allEvents = eventStatsRepository.findAllByUserId(userId); + if (allEvents.isEmpty()) { + return buildEmptyResponse(userId, startDate, endDate); + } + + List eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList()); + List allChannelStats = channelStatsRepository.findByEventIdIn(eventIds); + + // 3. 응답 구성 + UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate); + + // 4. 캐싱 + try { + String jsonData = objectMapper.writeValueAsString(response); + redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS); + log.info("✅ 캐시 저장 완료: {}", cacheKey); + } catch (Exception e) { + log.warn("캐시 저장 실패: {}", e.getMessage()); + } + + return response; + } + + private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) { + return UserChannelAnalyticsResponse.builder() + .userId(userId) + .period(buildPeriodInfo(startDate, endDate)) + .totalEvents(0) + .channels(new ArrayList<>()) + .comparison(ChannelComparison.builder().build()) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("empty") + .build(); + } + + private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List allEvents, + List allChannelStats, List channels, + String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) { + // 채널 필터링 + List filteredChannels = channels != null && !channels.isEmpty() + ? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList()) + : allChannelStats; + + // 채널별 집계 + List channelAnalyticsList = aggregateChannelAnalytics(filteredChannels); + + // 정렬 + channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order); + + // 채널 비교 + ChannelComparison comparison = buildChannelComparison(channelAnalyticsList); + + return UserChannelAnalyticsResponse.builder() + .userId(userId) + .period(buildPeriodInfo(startDate, endDate)) + .totalEvents(allEvents.size()) + .channels(channelAnalyticsList) + .comparison(comparison) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("cached") + .build(); + } + + private List aggregateChannelAnalytics(List allChannelStats) { + Map> channelGroups = allChannelStats.stream() + .collect(Collectors.groupingBy(ChannelStats::getChannelName)); + + return channelGroups.entrySet().stream() + .map(entry -> { + String channelName = entry.getKey(); + List channelList = entry.getValue(); + + int views = channelList.stream().mapToInt(ChannelStats::getViews).sum(); + int participants = channelList.stream().mapToInt(ChannelStats::getParticipants).sum(); + int clicks = channelList.stream().mapToInt(ChannelStats::getClicks).sum(); + int conversions = channelList.stream().mapToInt(ChannelStats::getConversions).sum(); + + double engagementRate = views > 0 ? (double) participants / views * 100 : 0.0; + double conversionRate = participants > 0 ? (double) conversions / participants * 100 : 0.0; + + BigDecimal cost = channelList.stream() + .map(ChannelStats::getDistributionCost) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + double roi = cost.compareTo(BigDecimal.ZERO) > 0 + ? (participants - cost.doubleValue()) / cost.doubleValue() * 100 + : 0.0; + + ChannelMetrics metrics = ChannelMetrics.builder() + .impressions(channelList.stream().mapToInt(ChannelStats::getImpressions).sum()) + .views(views) + .clicks(clicks) + .participants(participants) + .conversions(conversions) + .build(); + + ChannelPerformance performance = ChannelPerformance.builder() + .engagementRate(Math.round(engagementRate * 10) / 10.0) + .conversionRate(Math.round(conversionRate * 10) / 10.0) + .clickThroughRate(views > 0 ? Math.round((double) clicks / views * 1000) / 10.0 : 0.0) + .build(); + + ChannelCosts costs = ChannelCosts.builder() + .distributionCost(cost) + .costPerView(views > 0 ? cost.doubleValue() / views : 0.0) + .costPerClick(clicks > 0 ? cost.doubleValue() / clicks : 0.0) + .costPerAcquisition(participants > 0 ? cost.doubleValue() / participants : 0.0) + .roi(Math.round(roi * 10) / 10.0) + .build(); + + return ChannelAnalytics.builder() + .channelName(channelName) + .channelType(channelList.get(0).getChannelType()) + .metrics(metrics) + .performance(performance) + .costs(costs) + .build(); + }) + .collect(Collectors.toList()); + } + + private List sortChannels(List channels, String sortBy, String order) { + Comparator comparator; + + switch (sortBy != null ? sortBy.toLowerCase() : "participants") { + case "views": + comparator = Comparator.comparingInt(c -> c.getMetrics().getViews()); + break; + case "engagement_rate": + comparator = Comparator.comparingDouble(c -> c.getPerformance().getEngagementRate()); + break; + case "conversion_rate": + comparator = Comparator.comparingDouble(c -> c.getPerformance().getConversionRate()); + break; + case "roi": + comparator = Comparator.comparingDouble(c -> c.getCosts().getRoi()); + break; + case "participants": + default: + comparator = Comparator.comparingInt(c -> c.getMetrics().getParticipants()); + break; + } + + if ("desc".equalsIgnoreCase(order)) { + comparator = comparator.reversed(); + } + + return channels.stream().sorted(comparator).collect(Collectors.toList()); + } + + private ChannelComparison buildChannelComparison(List channels) { + if (channels.isEmpty()) { + return ChannelComparison.builder().build(); + } + + String bestPerformingChannel = channels.stream() + .max(Comparator.comparingInt(c -> c.getMetrics().getParticipants())) + .map(ChannelAnalytics::getChannelName) + .orElse("N/A"); + + Map bestPerforming = new HashMap<>(); + bestPerforming.put("channel", bestPerformingChannel); + bestPerforming.put("metric", "participants"); + + Map averageMetrics = new HashMap<>(); + int totalChannels = channels.size(); + if (totalChannels > 0) { + double avgParticipants = channels.stream().mapToInt(c -> c.getMetrics().getParticipants()).average().orElse(0.0); + double avgEngagement = channels.stream().mapToDouble(c -> c.getPerformance().getEngagementRate()).average().orElse(0.0); + double avgRoi = channels.stream().mapToDouble(c -> c.getCosts().getRoi()).average().orElse(0.0); + + averageMetrics.put("participants", avgParticipants); + averageMetrics.put("engagementRate", avgEngagement); + averageMetrics.put("roi", avgRoi); + } + + return ChannelComparison.builder() + .bestPerforming(bestPerforming) + .averageMetrics(averageMetrics) + .build(); + } + + private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { + LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); + LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); + long durationDays = ChronoUnit.DAYS.between(start, end); + + return PeriodInfo.builder() + .startDate(start) + .endDate(end) + .durationDays((int) durationDays) + .build(); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java new file mode 100644 index 0000000..44ea2eb --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java @@ -0,0 +1,176 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.*; +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * User ROI Analytics Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserRoiAnalyticsService { + + private final EventStatsRepository eventStatsRepository; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final String CACHE_KEY_PREFIX = "analytics:user:roi:"; + private static final long CACHE_TTL = 1800; + + public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, + LocalDateTime startDate, LocalDateTime endDate, boolean refresh) { + log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh); + + String cacheKey = CACHE_KEY_PREFIX + userId; + + if (!refresh) { + String cachedData = redisTemplate.opsForValue().get(cacheKey); + if (cachedData != null) { + try { + return objectMapper.readValue(cachedData, UserRoiAnalyticsResponse.class); + } catch (JsonProcessingException e) { + log.warn("캐시 역직렬화 실패: {}", e.getMessage()); + } + } + } + + List allEvents = eventStatsRepository.findAllByUserId(userId); + if (allEvents.isEmpty()) { + return buildEmptyResponse(userId, startDate, endDate); + } + + UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate); + + try { + String jsonData = objectMapper.writeValueAsString(response); + redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("캐시 저장 실패: {}", e.getMessage()); + } + + return response; + } + + private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) { + return UserRoiAnalyticsResponse.builder() + .userId(userId) + .period(buildPeriodInfo(startDate, endDate)) + .totalEvents(0) + .overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build()) + .overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build()) + .overallRoi(RoiCalculation.builder() + .netProfit(BigDecimal.ZERO) + .roiPercentage(0.0) + .build()) + .eventRois(new ArrayList<>()) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("empty") + .build(); + } + + private UserRoiAnalyticsResponse buildRoiResponse(String userId, List allEvents, boolean includeProjection, + LocalDateTime startDate, LocalDateTime endDate) { + BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal totalProfit = totalRevenue.subtract(totalInvestment); + + Double roiPercentage = totalInvestment.compareTo(BigDecimal.ZERO) > 0 + ? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue() + : 0.0; + + InvestmentDetails investment = InvestmentDetails.builder() + .total(totalInvestment) + .contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6))) + .operation(totalInvestment.multiply(BigDecimal.valueOf(0.2))) + .distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2))) + .build(); + + RevenueDetails revenue = RevenueDetails.builder() + .total(totalRevenue) + .directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7))) + .expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3))) + .build(); + + RoiCalculation roiCalc = RoiCalculation.builder() + .netProfit(totalProfit) + .roiPercentage(Math.round(roiPercentage * 10) / 10.0) + .build(); + + int totalParticipants = allEvents.stream().mapToInt(EventStats::getTotalParticipants).sum(); + CostEfficiency efficiency = CostEfficiency.builder() + .costPerParticipant(totalParticipants > 0 ? totalInvestment.doubleValue() / totalParticipants : 0.0) + .revenuePerParticipant(totalParticipants > 0 ? totalRevenue.doubleValue() / totalParticipants : 0.0) + .build(); + + RevenueProjection projection = includeProjection ? RevenueProjection.builder() + .currentRevenue(totalRevenue) + .projectedFinalRevenue(totalRevenue.multiply(BigDecimal.valueOf(1.2))) + .confidenceLevel(85.0) + .basedOn("Historical trend analysis") + .build() : null; + + List eventRois = allEvents.stream() + .map(event -> { + Double eventRoi = event.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0 + ? event.getExpectedRevenue().subtract(event.getTotalInvestment()) + .divide(event.getTotalInvestment(), 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)).doubleValue() + : 0.0; + + return UserRoiAnalyticsResponse.EventRoiSummary.builder() + .eventId(event.getEventId()) + .eventTitle(event.getEventTitle()) + .totalInvestment(event.getTotalInvestment().doubleValue()) + .expectedRevenue(event.getExpectedRevenue().doubleValue()) + .roi(Math.round(eventRoi * 10) / 10.0) + .status(event.getStatus()) + .build(); + }) + .sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed()) + .collect(Collectors.toList()); + + return UserRoiAnalyticsResponse.builder() + .userId(userId) + .period(buildPeriodInfo(startDate, endDate)) + .totalEvents(allEvents.size()) + .overallInvestment(investment) + .overallRevenue(revenue) + .overallRoi(roiCalc) + .costEfficiency(efficiency) + .projection(projection) + .eventRois(eventRois) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("cached") + .build(); + } + + private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { + LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); + LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); + return PeriodInfo.builder() + .startDate(start) + .endDate(end) + .durationDays((int) ChronoUnit.DAYS.between(start, end)) + .build(); + } +} diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java new file mode 100644 index 0000000..abee9b8 --- /dev/null +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java @@ -0,0 +1,191 @@ +package com.kt.event.analytics.service; + +import com.kt.event.analytics.dto.response.*; +import com.kt.event.analytics.entity.EventStats; +import com.kt.event.analytics.entity.TimelineData; +import com.kt.event.analytics.repository.EventStatsRepository; +import com.kt.event.analytics.repository.TimelineDataRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * User Timeline Analytics Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserTimelineAnalyticsService { + + private final EventStatsRepository eventStatsRepository; + private final TimelineDataRepository timelineDataRepository; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final String CACHE_KEY_PREFIX = "analytics:user:timeline:"; + private static final long CACHE_TTL = 1800; + + public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval, + LocalDateTime startDate, LocalDateTime endDate, + List metrics, boolean refresh) { + log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh); + + String cacheKey = CACHE_KEY_PREFIX + userId + ":" + interval; + + if (!refresh) { + String cachedData = redisTemplate.opsForValue().get(cacheKey); + if (cachedData != null) { + try { + return objectMapper.readValue(cachedData, UserTimelineAnalyticsResponse.class); + } catch (JsonProcessingException e) { + log.warn("캐시 역직렬화 실패: {}", e.getMessage()); + } + } + } + + List allEvents = eventStatsRepository.findAllByUserId(userId); + if (allEvents.isEmpty()) { + return buildEmptyResponse(userId, interval, startDate, endDate); + } + + List eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList()); + List allTimelineData = startDate != null && endDate != null + ? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate) + : timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds); + + UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate); + + try { + String jsonData = objectMapper.writeValueAsString(response); + redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("캐시 저장 실패: {}", e.getMessage()); + } + + return response; + } + + private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) { + return UserTimelineAnalyticsResponse.builder() + .userId(userId) + .period(buildPeriodInfo(startDate, endDate)) + .totalEvents(0) + .interval(interval != null ? interval : "daily") + .dataPoints(new ArrayList<>()) + .trend(TrendAnalysis.builder().overallTrend("stable").build()) + .peakTime(PeakTimeInfo.builder().build()) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("empty") + .build(); + } + + private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List allEvents, + List allTimelineData, String interval, + LocalDateTime startDate, LocalDateTime endDate) { + Map aggregatedData = new LinkedHashMap<>(); + + for (TimelineData data : allTimelineData) { + LocalDateTime key = normalizeTimestamp(data.getTimestamp(), interval); + aggregatedData.computeIfAbsent(key, k -> TimelineDataPoint.builder() + .timestamp(k) + .participants(0) + .views(0) + .engagement(0) + .conversions(0) + .build()); + + TimelineDataPoint point = aggregatedData.get(key); + point.setParticipants(point.getParticipants() + data.getParticipants()); + point.setViews(point.getViews() + data.getViews()); + point.setEngagement(point.getEngagement() + data.getEngagement()); + point.setConversions(point.getConversions() + data.getConversions()); + } + + List dataPoints = new ArrayList<>(aggregatedData.values()); + + TrendAnalysis trend = analyzeTrend(dataPoints); + PeakTimeInfo peakTime = findPeakTime(dataPoints); + + return UserTimelineAnalyticsResponse.builder() + .userId(userId) + .period(buildPeriodInfo(startDate, endDate)) + .totalEvents(allEvents.size()) + .interval(interval != null ? interval : "daily") + .dataPoints(dataPoints) + .trend(trend) + .peakTime(peakTime) + .lastUpdatedAt(LocalDateTime.now()) + .dataSource("cached") + .build(); + } + + private LocalDateTime normalizeTimestamp(LocalDateTime timestamp, String interval) { + switch (interval != null ? interval.toLowerCase() : "daily") { + case "hourly": + return timestamp.truncatedTo(ChronoUnit.HOURS); + case "weekly": + return timestamp.truncatedTo(ChronoUnit.DAYS).minusDays(timestamp.getDayOfWeek().getValue() - 1); + case "monthly": + return timestamp.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS); + case "daily": + default: + return timestamp.truncatedTo(ChronoUnit.DAYS); + } + } + + private TrendAnalysis analyzeTrend(List dataPoints) { + if (dataPoints.size() < 2) { + return TrendAnalysis.builder().overallTrend("stable").build(); + } + + int firstHalf = dataPoints.subList(0, dataPoints.size() / 2).stream() + .mapToInt(TimelineDataPoint::getParticipants).sum(); + int secondHalf = dataPoints.subList(dataPoints.size() / 2, dataPoints.size()).stream() + .mapToInt(TimelineDataPoint::getParticipants).sum(); + + double growthRate = firstHalf > 0 ? ((double) (secondHalf - firstHalf) / firstHalf) * 100 : 0.0; + String trend = growthRate > 5 ? "increasing" : (growthRate < -5 ? "decreasing" : "stable"); + + return TrendAnalysis.builder() + .overallTrend(trend) + .build(); + } + + private PeakTimeInfo findPeakTime(List dataPoints) { + if (dataPoints.isEmpty()) { + return PeakTimeInfo.builder().build(); + } + + TimelineDataPoint peak = dataPoints.stream() + .max(Comparator.comparingInt(TimelineDataPoint::getParticipants)) + .orElse(null); + + return peak != null ? PeakTimeInfo.builder() + .timestamp(peak.getTimestamp()) + .metric("participants") + .value(peak.getParticipants()) + .description(peak.getViews() + " views at peak time") + .build() : PeakTimeInfo.builder().build(); + } + + private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { + LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); + LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); + return PeriodInfo.builder() + .startDate(start) + .endDate(end) + .durationDays((int) ChronoUnit.DAYS.between(start, end)) + .build(); + } +} diff --git a/analytics-service/src/main/resources/application.yml b/analytics-service/src/main/resources/application.yml new file mode 100644 index 0000000..d5820b3 --- /dev/null +++ b/analytics-service/src/main/resources/application.yml @@ -0,0 +1,158 @@ +spring: + application: + name: analytics-service + + # Database + datasource: + url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:analytics_db} + username: ${DB_USERNAME:analytics_user} + password: ${DB_PASSWORD:analytics_pass} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + leak-detection-threshold: 60000 + + # JPA + jpa: + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + format_sql: true + use_sql_comments: true + hibernate: + ddl-auto: ${DDL_AUTO:update} + + # Redis + data: + redis: + host: ${REDIS_HOST:20.214.210.71} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:Hi5Jessica!} + timeout: 2000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + max-wait: -1ms + database: ${REDIS_DATABASE:5} + + # Kafka (원격 서버 사용) + kafka: + enabled: ${KAFKA_ENABLED:true} + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095} + consumer: + group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service} + auto-offset-reset: earliest + enable-auto-commit: true + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + acks: all + retries: 3 + properties: + connections.max.idle.ms: 540000 + request.timeout.ms: 30000 + session.timeout.ms: 30000 + heartbeat.interval.ms: 3000 + max.poll.interval.ms: 300000 + + # Sample Data (MVP Only) + # ⚠️ 실제 운영: false로 설정 (다른 서비스들이 이벤트 발행) + # ⚠️ MVP 환경: true로 설정 (SampleDataLoader가 이벤트 발행) + sample-data: + enabled: ${SAMPLE_DATA_ENABLED:true} + +# Server +server: + port: ${SERVER_PORT:8086} + +# JWT +jwt: + secret: ${JWT_SECRET:} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400} + +# CORS Configuration +cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*} + +# Actuator +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + base-path: /actuator + endpoint: + health: + show-details: always + show-components: always + health: + livenessState: + enabled: true + readinessState: + enabled: true + +# OpenAPI Documentation +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + show-actuator: false + +# Logging +logging: + level: + com.kt.event.analytics: ${LOG_LEVEL_APP:DEBUG} + org.springframework.web: ${LOG_LEVEL_WEB:INFO} + org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG} + org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE} + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: ${LOG_FILE:logs/analytics-service.log} + logback: + rollingpolicy: + max-file-size: 10MB + max-history: 7 + total-size-cap: 100MB + +# Resilience4j Circuit Breaker +resilience4j: + circuitbreaker: + instances: + wooriTV: + failure-rate-threshold: 50 + wait-duration-in-open-state: 30s + sliding-window-size: 10 + permitted-number-of-calls-in-half-open-state: 3 + genieTV: + failure-rate-threshold: 50 + wait-duration-in-open-state: 30s + sliding-window-size: 10 + ringoBiz: + failure-rate-threshold: 50 + wait-duration-in-open-state: 30s + sliding-window-size: 10 + sns: + failure-rate-threshold: 50 + wait-duration-in-open-state: 30s + sliding-window-size: 10 + +# Batch Scheduler +batch: + analytics: + refresh-interval: ${BATCH_REFRESH_INTERVAL:300000} # 5분 (밀리초) + initial-delay: ${BATCH_INITIAL_DELAY:30000} # 30초 (밀리초) + enabled: ${BATCH_ENABLED:true} # 배치 활성화 여부 diff --git a/analytics-service/test-backend.md b/analytics-service/test-backend.md new file mode 100644 index 0000000..a7f0347 --- /dev/null +++ b/analytics-service/test-backend.md @@ -0,0 +1,494 @@ +# Analytics Service 백엔드 테스트 결과서 + +## 1. 개요 + +### 1.1 테스트 목적 +- **userId 기반 통합 성과 분석 API 개발 및 검증** +- 사용자 전체 이벤트를 통합하여 분석하는 4개 API 개발 +- 기존 eventId 기반 API와 독립적으로 동작하는 구조 검증 +- MVP 환경: 1:1 관계 (1 user = 1 store) + +### 1.2 테스트 환경 +- **프로젝트**: kt-event-marketing +- **서비스**: analytics-service +- **브랜치**: feature/analytics +- **빌드 도구**: Gradle 8.10 +- **프레임워크**: Spring Boot 3.3.0 +- **언어**: Java 21 + +### 1.3 테스트 일시 +- **작성일**: 2025-10-28 +- **컴파일 테스트**: 2025-10-28 + +--- + +## 2. 개발 범위 + +### 2.1 Repository 수정 +**파일**: 3개 Repository 인터페이스 + +#### EventStatsRepository +```java +// 추가된 메소드 +List findAllByUserId(String userId); +``` +- **목적**: 특정 사용자의 모든 이벤트 통계 조회 +- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java` + +#### ChannelStatsRepository +```java +// 추가된 메소드 +List findByEventIdIn(List eventIds); +``` +- **목적**: 여러 이벤트의 채널 통계 일괄 조회 +- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java` + +#### TimelineDataRepository +```java +// 추가된 메소드 +List findByEventIdInOrderByTimestampAsc(List eventIds); + +@Query("SELECT t FROM TimelineData t WHERE t.eventId IN :eventIds " + + "AND t.timestamp BETWEEN :startDate AND :endDate " + + "ORDER BY t.timestamp ASC") +List findByEventIdInAndTimestampBetween( + @Param("eventIds") List eventIds, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate +); +``` +- **목적**: 여러 이벤트의 타임라인 데이터 조회 +- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java` + +--- + +### 2.2 Response DTO 작성 +**파일**: 4개 Response DTO + +#### UserAnalyticsDashboardResponse +- **경로**: `com.kt.event.analytics.dto.response.UserAnalyticsDashboardResponse` +- **역할**: 사용자 전체 통합 성과 대시보드 응답 +- **주요 필드**: + - `userId`: 사용자 ID + - `totalEvents`: 총 이벤트 수 + - `activeEvents`: 활성 이벤트 수 + - `overallSummary`: 전체 성과 요약 (AnalyticsSummary) + - `channelPerformance`: 채널별 성과 (List) + - `overallRoi`: 전체 ROI 요약 (RoiSummary) + - `eventPerformances`: 이벤트별 성과 목록 (EventPerformanceSummary) + - `period`: 조회 기간 (PeriodInfo) + +#### UserChannelAnalyticsResponse +- **경로**: `com.kt.event.analytics.dto.response.UserChannelAnalyticsResponse` +- **역할**: 사용자 전체 채널별 성과 분석 응답 +- **주요 필드**: + - `userId`: 사용자 ID + - `totalEvents`: 총 이벤트 수 + - `channels`: 채널별 상세 분석 (List) + - `comparison`: 채널 간 비교 (ChannelComparison) + - `period`: 조회 기간 (PeriodInfo) + +#### UserRoiAnalyticsResponse +- **경로**: `com.kt.event.analytics.dto.response.UserRoiAnalyticsResponse` +- **역할**: 사용자 전체 ROI 상세 분석 응답 +- **주요 필드**: + - `userId`: 사용자 ID + - `totalEvents`: 총 이벤트 수 + - `overallInvestment`: 전체 투자 내역 (InvestmentDetails) + - `overallRevenue`: 전체 수익 내역 (RevenueDetails) + - `overallRoi`: ROI 계산 (RoiCalculation) + - `costEfficiency`: 비용 효율성 (CostEfficiency) + - `projection`: 수익 예측 (RevenueProjection) + - `eventRois`: 이벤트별 ROI (EventRoiSummary) + - `period`: 조회 기간 (PeriodInfo) + +#### UserTimelineAnalyticsResponse +- **경로**: `com.kt.event.analytics.dto.response.UserTimelineAnalyticsResponse` +- **역할**: 사용자 전체 시간대별 참여 추이 분석 응답 +- **주요 필드**: + - `userId`: 사용자 ID + - `totalEvents`: 총 이벤트 수 + - `interval`: 시간 간격 단위 (hourly, daily, weekly, monthly) + - `dataPoints`: 시간대별 데이터 포인트 (List) + - `trend`: 추세 분석 (TrendAnalysis) + - `peakTime`: 피크 시간대 정보 (PeakTimeInfo) + - `period`: 조회 기간 (PeriodInfo) + +--- + +### 2.3 Service 개발 +**파일**: 4개 Service 클래스 + +#### UserAnalyticsService +- **경로**: `com.kt.event.analytics.service.UserAnalyticsService` +- **역할**: 사용자 전체 이벤트 통합 성과 대시보드 서비스 +- **주요 기능**: + - `getUserDashboardData()`: 사용자 전체 대시보드 데이터 조회 + - Redis 캐싱 (TTL: 30분) + - 전체 성과 요약 계산 (참여자, 조회수, 참여율, 전환율) + - 채널별 성과 통합 집계 + - 전체 ROI 계산 + - 이벤트별 성과 목록 생성 +- **특징**: + - 모든 이벤트의 메트릭을 합산하여 통합 분석 + - 채널명 기준으로 그룹화하여 채널 성과 집계 + - BigDecimal 타입으로 금액 정확도 보장 + +#### UserChannelAnalyticsService +- **경로**: `com.kt.event.analytics.service.UserChannelAnalyticsService` +- **역할**: 사용자 전체 이벤트의 채널별 성과 통합 서비스 +- **주요 기능**: + - `getUserChannelAnalytics()`: 사용자 전체 채널 분석 데이터 조회 + - Redis 캐싱 (TTL: 30분) + - 채널별 메트릭 집계 (조회수, 참여자, 클릭, 전환) + - 채널 성과 지표 계산 (참여율, 전환율, CTR, ROI) + - 채널 비용 분석 (조회당/클릭당/획득당 비용) + - 채널 간 비교 분석 (최고 성과, 평균 지표) +- **특징**: + - 채널명 기준으로 그룹화하여 통합 집계 + - 다양한 정렬 옵션 지원 (participants, views, engagement_rate, conversion_rate, roi) + - 채널 필터링 기능 + +#### UserRoiAnalyticsService +- **경로**: `com.kt.event.analytics.service.UserRoiAnalyticsService` +- **역할**: 사용자 전체 이벤트의 ROI 통합 분석 서비스 +- **주요 기능**: + - `getUserRoiAnalytics()`: 사용자 전체 ROI 분석 데이터 조회 + - Redis 캐싱 (TTL: 30분) + - 전체 투자 금액 집계 (콘텐츠 제작, 운영, 배포 비용) + - 전체 수익 집계 (직접 판매, 예상 판매) + - ROI 계산 (순이익, ROI %) + - 비용 효율성 분석 (참여자당 비용/수익) + - 수익 예측 (현재 수익 기반 최종 수익 예측) +- **특징**: + - BigDecimal로 금액 정밀 계산 + - 이벤트별 ROI 순위 제공 + - 선택적 수익 예측 기능 + +#### UserTimelineAnalyticsService +- **경로**: `com.kt.event.analytics.service.UserTimelineAnalyticsService` +- **역할**: 사용자 전체 이벤트의 시간대별 추이 통합 서비스 +- **주요 기능**: + - `getUserTimelineAnalytics()`: 사용자 전체 타임라인 분석 데이터 조회 + - Redis 캐싱 (TTL: 30분) + - 시간 간격별 데이터 집계 (hourly, daily, weekly, monthly) + - 추세 분석 (증가/감소/안정) + - 피크 시간대 식별 (최대 참여자 시점) +- **특징**: + - 시간대별로 정규화하여 데이터 집계 + - 전반부/후반부 비교를 통한 성장률 계산 + - 메트릭별 필터링 지원 + +--- + +### 2.4 Controller 개발 +**파일**: 4개 Controller 클래스 + +#### UserAnalyticsDashboardController +- **경로**: `com.kt.event.analytics.controller.UserAnalyticsDashboardController` +- **엔드포인트**: `GET /api/v1/users/{userId}/analytics` +- **역할**: 사용자 전체 성과 대시보드 API +- **Request Parameters**: + - `userId` (Path): 사용자 ID (필수) + - `startDate` (Query): 조회 시작 날짜 (선택, ISO 8601 format) + - `endDate` (Query): 조회 종료 날짜 (선택, ISO 8601 format) + - `refresh` (Query): 캐시 갱신 여부 (선택, default: false) +- **Response**: `ApiResponse` + +#### UserChannelAnalyticsController +- **경로**: `com.kt.event.analytics.controller.UserChannelAnalyticsController` +- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/channels` +- **역할**: 사용자 전체 채널별 성과 분석 API +- **Request Parameters**: + - `userId` (Path): 사용자 ID (필수) + - `channels` (Query): 조회할 채널 목록 (쉼표 구분, 선택) + - `sortBy` (Query): 정렬 기준 (선택, default: participants) + - `order` (Query): 정렬 순서 (선택, default: desc) + - `startDate` (Query): 조회 시작 날짜 (선택) + - `endDate` (Query): 조회 종료 날짜 (선택) + - `refresh` (Query): 캐시 갱신 여부 (선택, default: false) +- **Response**: `ApiResponse` + +#### UserRoiAnalyticsController +- **경로**: `com.kt.event.analytics.controller.UserRoiAnalyticsController` +- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/roi` +- **역할**: 사용자 전체 ROI 상세 분석 API +- **Request Parameters**: + - `userId` (Path): 사용자 ID (필수) + - `includeProjection` (Query): 예상 수익 포함 여부 (선택, default: true) + - `startDate` (Query): 조회 시작 날짜 (선택) + - `endDate` (Query): 조회 종료 날짜 (선택) + - `refresh` (Query): 캐시 갱신 여부 (선택, default: false) +- **Response**: `ApiResponse` + +#### UserTimelineAnalyticsController +- **경로**: `com.kt.event.analytics.controller.UserTimelineAnalyticsController` +- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/timeline` +- **역할**: 사용자 전체 시간대별 참여 추이 분석 API +- **Request Parameters**: + - `userId` (Path): 사용자 ID (필수) + - `interval` (Query): 시간 간격 단위 (선택, default: daily) + - 값: hourly, daily, weekly, monthly + - `startDate` (Query): 조회 시작 날짜 (선택) + - `endDate` (Query): 조회 종료 날짜 (선택) + - `metrics` (Query): 조회할 지표 목록 (쉼표 구분, 선택) + - `refresh` (Query): 캐시 갱신 여부 (선택, default: false) +- **Response**: `ApiResponse` + +--- + +## 3. 컴파일 테스트 + +### 3.1 테스트 명령 +```bash +./gradlew.bat analytics-service:compileJava +``` + +### 3.2 테스트 결과 +**상태**: ✅ **성공 (BUILD SUCCESSFUL)** + +**출력**: +``` +> Task :common:generateEffectiveLombokConfig UP-TO-DATE +> Task :common:compileJava UP-TO-DATE +> Task :analytics-service:generateEffectiveLombokConfig +> Task :analytics-service:compileJava + +BUILD SUCCESSFUL in 8s +4 actionable tasks: 2 executed, 2 up-to-date +``` + +### 3.3 오류 해결 과정 + +#### 3.3.1 초기 컴파일 오류 (19개) +**문제**: 기존 DTO 구조와 Service 코드 간 필드명/타입 불일치 + +**해결**: +1. **AnalyticsSummary**: totalInvestment, expectedRevenue 필드 제거 +2. **ChannelSummary**: cost 필드 제거 +3. **RoiSummary**: BigDecimal 타입 사용 +4. **InvestmentDetails**: totalAmount → total 변경, 필드명 수정 (contentCreation, operation, distribution) +5. **RevenueDetails**: totalRevenue → total 변경, 필드명 수정 (directSales, expectedSales) +6. **RoiCalculation**: totalInvestment, totalRevenue 필드 제거 +7. **TrendAnalysis**: direction → overallTrend 변경 +8. **PeakTimeInfo**: participants → value 변경, metric, description 추가 +9. **ChannelPerformance**: participationRate 필드 제거 +10. **ChannelCosts**: totalCost → distributionCost 변경, costPerParticipant → costPerAcquisition 변경 +11. **ChannelComparison**: mostEfficient, highestEngagement → averageMetrics로 통합 +12. **RevenueProjection**: projectedRevenue → projectedFinalRevenue 변경, basedOn 필드 추가 + +#### 3.3.2 수정된 파일 +- `UserAnalyticsService.java`: DTO 필드명 수정 (5곳) +- `UserChannelAnalyticsService.java`: DTO 필드명 수정, HashMap import 추가 (3곳) +- `UserRoiAnalyticsService.java`: DTO 필드명 수정, BigDecimal 타입 사용 (4곳) +- `UserTimelineAnalyticsService.java`: DTO 필드명 수정 (3곳) + +--- + +## 4. API 설계 요약 + +### 4.1 API 엔드포인트 구조 +``` +/api/v1/users/{userId}/analytics +├─ GET / # 전체 통합 대시보드 +├─ GET /channels # 채널별 성과 분석 +├─ GET /roi # ROI 상세 분석 +└─ GET /timeline # 시간대별 참여 추이 +``` + +### 4.2 기존 API와의 비교 +| 구분 | 기존 API | 신규 API | +|------|----------|----------| +| **기준** | eventId (개별 이벤트) | userId (사용자 전체) | +| **범위** | 단일 이벤트 | 사용자의 모든 이벤트 통합 | +| **엔드포인트** | `/api/v1/events/{eventId}/...` | `/api/v1/users/{userId}/...` | +| **캐시 TTL** | 3600초 (60분) | 1800초 (30분) | +| **데이터 집계** | 개별 이벤트 데이터 | 여러 이벤트 합산/평균 | + +### 4.3 캐싱 전략 +- **캐시 키 형식**: `analytics:user:{category}:{userId}` +- **TTL**: 30분 (1800초) + - 여러 이벤트 통합으로 데이터 변동성이 높아 기존보다 짧게 설정 +- **갱신 방식**: `refresh=true` 파라미터로 강제 갱신 가능 +- **구현**: RedisTemplate + Jackson ObjectMapper + +--- + +## 5. 주요 기능 + +### 5.1 데이터 집계 로직 +#### 5.1.1 통합 성과 계산 +- **참여자 수**: 모든 이벤트의 totalParticipants 합산 +- **조회수**: 모든 이벤트의 totalViews 합산 +- **참여율**: 전체 참여자 / 전체 조회수 * 100 +- **전환율**: 전체 전환 / 전체 참여자 * 100 + +#### 5.1.2 채널 성과 집계 +- **그룹화**: 채널명(channelName) 기준 +- **메트릭 합산**: views, participants, clicks, conversions +- **비용 집계**: distributionCost 합산 +- **ROI 계산**: (참여자 - 비용) / 비용 * 100 + +#### 5.1.3 ROI 계산 +- **투자 금액**: 모든 이벤트의 totalInvestment 합산 +- **수익**: 모든 이벤트의 expectedRevenue 합산 +- **순이익**: 수익 - 투자 +- **ROI**: (순이익 / 투자) * 100 + +#### 5.1.4 시간대별 집계 +- **정규화**: interval에 따라 timestamp 정규화 + - hourly: 시간 단위로 truncate + - daily: 일 단위로 truncate + - weekly: 주 시작일로 정규화 + - monthly: 월 시작일로 정규화 +- **데이터 포인트 합산**: 동일 시간대의 participants, views, engagement, conversions 합산 + +### 5.2 추세 분석 +- **전반부/후반부 비교**: 데이터 포인트를 반으로 나누어 성장률 계산 +- **추세 결정**: + - 성장률 > 5%: "increasing" + - 성장률 < -5%: "decreasing" + - -5% ≤ 성장률 ≤ 5%: "stable" + +### 5.3 피크 시간 식별 +- **기준**: 참여자 수(participants) 최대 시점 +- **정보**: timestamp, metric, value, description + +--- + +## 6. 아키텍처 특징 + +### 6.1 계층 구조 +``` +Controller + ↓ +Service (비즈니스 로직) + ↓ +Repository (데이터 접근) + ↓ +Entity (JPA) +``` + +### 6.2 독립성 보장 +- **기존 eventId 기반 API와 독립적 구조** +- **별도의 Controller, Service 클래스** +- **공통 Repository 재사용** +- **기존 DTO 구조 준수** + +### 6.3 확장성 +- **새로운 메트릭 추가 용이**: Service 레이어에서 계산 로직 추가 +- **캐싱 전략 개별 조정 가능**: 각 Service마다 독립적인 캐시 키 +- **채널/이벤트 필터링 지원**: 동적 쿼리 지원 + +--- + +## 7. 검증 결과 + +### 7.1 컴파일 검증 +- ✅ **Service 계층**: 4개 클래스 컴파일 성공 +- ✅ **Controller 계층**: 4개 클래스 컴파일 성공 +- ✅ **Repository 계층**: 3개 인터페이스 컴파일 성공 +- ✅ **DTO 계층**: 4개 Response 클래스 컴파일 성공 + +### 7.2 코드 품질 +- ✅ **Lombok 활용**: Builder 패턴, Data 클래스 +- ✅ **로깅**: Slf4j 적용 +- ✅ **트랜잭션**: @Transactional(readOnly = true) +- ✅ **예외 처리**: try-catch로 캐시 오류 대응 +- ✅ **타입 안정성**: BigDecimal로 금액 처리 + +### 7.3 Swagger 문서화 +- ✅ **@Tag**: API 그룹 정의 +- ✅ **@Operation**: 엔드포인트 설명 +- ✅ **@Parameter**: 파라미터 설명 + +--- + +## 8. 다음 단계 + +### 8.1 백엔드 개발 완료 항목 +- ✅ Repository 쿼리 메소드 추가 +- ✅ Response DTO 작성 +- ✅ Service 로직 구현 +- ✅ Controller API 개발 +- ✅ 컴파일 검증 + +### 8.2 향후 작업 +1. **백엔드 서버 실행 테스트** (Phase 1 완료 후) + - 애플리케이션 실행 확인 + - API 엔드포인트 접근 테스트 + - Swagger UI 확인 + +2. **API 통합 테스트** (Phase 1 완료 후) + - Postman/curl로 API 호출 테스트 + - 실제 데이터로 응답 검증 + - 에러 핸들링 확인 + +3. **프론트엔드 연동** (Phase 2) + - 프론트엔드에서 4개 API 호출 + - 응답 데이터 바인딩 + - UI 렌더링 검증 + +--- + +## 9. 결론 + +### 9.1 성과 +- ✅ **userId 기반 통합 분석 API 4개 개발 완료** +- ✅ **컴파일 성공** +- ✅ **기존 구조와 독립적인 설계** +- ✅ **확장 가능한 아키텍처** +- ✅ **MVP 환경 1:1 관계 (1 user = 1 store) 적용** + +### 9.2 특이사항 +- **기존 DTO 구조 재사용**: 새로운 DTO 생성 최소화 +- **BigDecimal 타입 사용**: 금액 정확도 보장 +- **캐싱 전략**: Redis 캐싱으로 성능 최적화 (TTL: 30분) + +### 9.3 개발 시간 +- **예상 개발 기간**: 3~4일 +- **실제 개발 완료**: 1일 (컴파일 테스트까지) + +--- + +## 10. 첨부 + +### 10.1 주요 파일 목록 +``` +analytics-service/src/main/java/com/kt/event/analytics/ +├── repository/ +│ ├── EventStatsRepository.java (수정) +│ ├── ChannelStatsRepository.java (수정) +│ └── TimelineDataRepository.java (수정) +├── dto/response/ +│ ├── UserAnalyticsDashboardResponse.java (신규) +│ ├── UserChannelAnalyticsResponse.java (신규) +│ ├── UserRoiAnalyticsResponse.java (신규) +│ └── UserTimelineAnalyticsResponse.java (신규) +├── service/ +│ ├── UserAnalyticsService.java (신규) +│ ├── UserChannelAnalyticsService.java (신규) +│ ├── UserRoiAnalyticsService.java (신규) +│ └── UserTimelineAnalyticsService.java (신규) +└── controller/ + ├── UserAnalyticsDashboardController.java (신규) + ├── UserChannelAnalyticsController.java (신규) + ├── UserRoiAnalyticsController.java (신규) + └── UserTimelineAnalyticsController.java (신규) +``` + +### 10.2 API 목록 +| No | HTTP Method | Endpoint | 설명 | +|----|-------------|----------|------| +| 1 | GET | `/api/v1/users/{userId}/analytics` | 사용자 전체 성과 대시보드 | +| 2 | GET | `/api/v1/users/{userId}/analytics/channels` | 사용자 전체 채널별 성과 분석 | +| 3 | GET | `/api/v1/users/{userId}/analytics/roi` | 사용자 전체 ROI 상세 분석 | +| 4 | GET | `/api/v1/users/{userId}/analytics/timeline` | 사용자 전체 시간대별 참여 추이 | + +--- + +**작성자**: AI Backend Developer +**검토자**: - +**승인자**: - +**버전**: 1.0 +**최종 수정일**: 2025-10-28 diff --git a/backing-service/docker-compose.yml b/backing-service/docker-compose.yml new file mode 100644 index 0000000..32c7ec3 --- /dev/null +++ b/backing-service/docker-compose.yml @@ -0,0 +1,53 @@ +version: '3.8' + +services: + # PostgreSQL - Participation Service + postgres-participation: + image: postgres:15-alpine + container_name: participation-db + environment: + POSTGRES_DB: participation_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres-participation-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + # Kafka + zookeeper: + image: confluentinc/cp-zookeeper:7.5.0 + container_name: zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - "2181:2181" + + kafka: + image: confluentinc/cp-kafka:7.5.0 + container_name: kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + healthcheck: + test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092"] + interval: 10s + timeout: 10s + retries: 5 + +volumes: + postgres-participation-data: diff --git a/backing-service/install/postgres/values-user.yaml b/backing-service/install/postgres/values-user.yaml index 665a2fa..af3a323 100644 --- a/backing-service/install/postgres/values-user.yaml +++ b/backing-service/install/postgres/values-user.yaml @@ -18,7 +18,7 @@ primary: enabled: true storageClass: "managed-premium" size: 10Gi - + resources: limits: memory: "4Gi" @@ -26,12 +26,14 @@ primary: requests: memory: "2Gi" cpu: "0.5" - - # 성능 최적화 설정 + + # 성능 최적화 설정 extraEnvVars: + - name: POSTGRESQL_READ_ONLY_MODE + value: "no" - name: POSTGRESQL_SHARED_BUFFERS value: "1GB" - - name: POSTGRESQL_EFFECTIVE_CACHE_SIZE + - name: POSTGRESQL_EFFECTIVE_CACHE_SIZE value: "3GB" - name: POSTGRESQL_MAX_CONNECTIONS value: "200" diff --git a/claude/build-image-back.md b/claude/build-image-back.md new file mode 100644 index 0000000..d7b822f --- /dev/null +++ b/claude/build-image-back.md @@ -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 diff --git a/claude/check-mermaid.sh b/claude/check-mermaid.sh old mode 100755 new mode 100644 diff --git a/claude/design-prompt.md b/claude/design-prompt.md new file mode 100644 index 0000000..ea4ce28 --- /dev/null +++ b/claude/design-prompt.md @@ -0,0 +1,220 @@ +# 설계 프롬프트 +아래 순서대로 설계합니다. + +## UI/UX 설계 +command: "/design-uiux" +prompt: +``` +@uiux +UI/UX 설계를 해주세요: +- 'UI/UX설계가이드'를 준용하여 작성 +``` + +--- + +# 프로토타입 작성 +command: "/design-prototype" +prompt: +**1.작성** +``` +@prototype +프로토타입을 작성해 주세요: +- '프로토타입작성가이드'를 준용하여 작성 +``` + +--- + +**2.검증** +command: "/design-test-prototype" +prompt: +``` +@test-front +프로토타입을 테스트 해 주세요. +``` + +--- + +**3.오류수정** +command: "/design-fix-prototype" +prompt: +``` +@fix as @front +'[오류내용]'섹션에 제공된 오류를 해결해 주세요. +프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시 +{안내메시지} +'[오류내용]'섹션 하위에 오류 내용을 제공 +``` + +--- + +**4.개선** +command: "/design-improve-prototype" +prompt: +``` +@improve as @front +'[개선내용]'섹션에 있는 내용을 개선해 주세요. +프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시 +{안내메시지} +'[개선내용]'섹션 하위에 개선할 내용을 제공 +``` + +--- + +**5.유저스토리 품질 높이기** +command: "/design-improve-userstory" +prompt: +``` +@analyze as @front 프로토타입을 웹브라우저에서 분석한 후, +@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오. +``` + +--- + +**6.설계서 다시 업데이트** +command: "/design-update-uiux" +prompt: +``` +@document @front +현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요. +``` + +--- + +## 클라우드 아키텍처 패턴 선정 +command: "/design-pattern" +prompt: +``` +@design-pattern +클라우드 아키텍처 패턴 적용 방안을 작성해 주세요: +- '클라우드아키텍처패턴선정가이드'를 준용하여 작성 +``` + +--- + +## 논리아키텍처 설계 +command: "/design-logical" +prompt: +``` +@architecture +논리 아키텍처를 설계해 주세요: +- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계 + +``` + +--- + +## 외부 시퀀스 설계 +command: "/design-seq-outer" +prompt: +``` +@architecture +외부 시퀀스 설계를 해 주세요: +- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계 + +``` + +--- + +## 내부 시퀀스 설계 +command: "/design-seq-inner" +prompt: +``` +@architecture +내부 시퀀스 설계를 해 주세요: +- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계 + +``` + +--- + +## API 설계 +command: "/design-api" +prompt: +``` +@architecture +API를 설계해 주세요: +- '공통설계원칙'과 'API설계가이드'를 준용하여 설계 + +``` + +--- + +## 클래스 설계 +command: "/design-class" +prompt: +``` +@architecture +'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요. +프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다. +{안내메시지} +'[클래스설계 정보]' 섹션에 아래 예와 같은 정보를 제공해 주십시오. +[클래스설계 정보] +- 패키지 그룹: com.unicorn.tripgen +- 설계 아키텍처 패턴 + - User: Layered + - Trip: Clean + - Location: Layered + - AI: Layered +``` + +--- + +## 데이터 설계 +command: "/design-data" +prompt: +``` +@architecture +데이터 설계를 해주세요: +- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계 +``` + +--- + +## High Level 아키텍처 정의서 작성 +command: "/design-high-level" +prompt: +``` +@architecture +'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요. +'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요. +{안내메시지} +아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요. +- CLOUD: Azure +``` + +--- + +## 물리 아키텍처 설계 +command: "/design-physical" +prompt: +``` +@architecture +'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요. +'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요. +{안내메시지} +아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요. +- CLOUD: Azure +``` + +## 프론트엔드 설계 +command: "/design-front" +prompt: +``` +@plan as @front +'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요. +프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다. +{안내메시지} +'[백엔드시스템]' 섹션에 아래 예와 같은 정보를 제공해 주십시오. +[백엔드시스템] +- 시스템: tripgen +- 마이크로서비스: user-service, location-service, trip-service, ai-service +- API문서 + - user service: http://localhost:8081/v3/api-docs + - location service: http://localhost:8082/v3/api-docs + - trip service: http://localhost:8083/v3/api-docs + - ai service: http://localhost:8084/v3/api-docs +[요구사항] +- 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시 +- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기 +``` + diff --git a/claude/dev-backend.md b/claude/dev-backend.md index 81ece9d..dfe8f7c 100644 --- a/claude/dev-backend.md +++ b/claude/dev-backend.md @@ -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 { } } ``` + diff --git a/claude/develop-prompt.md b/claude/develop-prompt.md new file mode 100644 index 0000000..57c5a06 --- /dev/null +++ b/claude/develop-prompt.md @@ -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 +``` \ No newline at end of file diff --git a/claude/make-run-profile.md b/claude/make-run-profile.md index f363a91..2afafe5 100644 --- a/claude/make-run-profile.md +++ b/claude/make-run-profile.md @@ -1,4 +1,4 @@ -# 서비스실행파일작성가이드 +# 서비스실행프로파일작성가이드 [요청사항] - <수행원칙>을 준용하여 수행 diff --git a/claude/run-container-guide-back.md b/claude/run-container-guide-back.md new file mode 100644 index 0000000..4e41684 --- /dev/null +++ b/claude/run-container-guide-back.md @@ -0,0 +1,187 @@ +# 백엔드 컨테이너 실행방법 가이드 + +[요청사항] +- 백엔드 각 서비스들의 컨테이너 이미지를 컨테이너로 실행하는 가이드 작성 +- 실제 컨테이너 실행은 하지 않음 +- '[결과파일]'에 수행할 명령어를 포함하여 컨테이너 실행 가이드 생성 + +[작업순서] +- 실행정보 확인 + 프롬프트의 '[실행정보]'섹션에서 아래정보를 확인 + - {ACR명}: 컨테이너 레지스트리 이름 + - {VM.KEY파일}: VM 접속하는 Private Key파일 경로 + - {VM.USERID}: VM 접속하는 OS 유저명 + - {VM.IP}: VM IP + 예시) + ``` + [실행정보] + - ACR명: acrdigitalgarage01 + - VM + - KEY파일: ~/home/bastion-dg0500 + - USERID: azureuser + - IP: 4.230.5.6 + ``` + +- 시스템명과 서비스명 확인 + settings.gradle에서 확인. + - 시스템명: rootProject.name + - 서비스명: include 'common'하위의 include문 뒤의 값임 + + 예시) include 'common'하위의 4개가 서비스명임. + ``` + rootProject.name = 'tripgen' + + include 'common' + include 'user-service' + include 'location-service' + include 'ai-service' + include 'trip-service' + ``` + +- VM 접속 방법 안내 + - Linux/Mac은 기본 터미널을 실행하고 Window는 Window Terminal을 실행하도록 안내 + - 터미널에서 아래 명령으로 VM에 접속하도록 안내 + 최초 한번 Private key파일의 모드를 변경. + ``` + chmod 400 {VM.KEY파일} + ``` + + private key를 이용하여 접속. + ``` + ssh -i {VM.KEY파일} {VM.USERID}@{VM.IP} + ``` + - 접속 후 docker login 방법 안내 + ``` + docker login {ACR명}.azurecr.io -u {ID} -p {암호} + ``` + +- Git Repository 클론 안내 + - workspace 디렉토리 생성 및 이동 + ``` + mkdir -p ~/home/workspace + cd ~/home/workspace + ``` + - 소스 Clone + ``` + git clone {원격 Git Repository 주소} + ``` + 예) + ``` + git clone https://github.com/cna-bootcamp/phonebill.git + ``` + - 프로젝트 디렉토리로 이동 + ``` + cd {시스템명} + ``` + +- 어플리케이션 빌드 및 컨테이너 이미지 생성 방법 안내 + 'deployment/container/build-image.md' 파일을 열어 가이드대로 수행하도록 안내 + +- 컨테이너 레지스트리 로그인 방법 안내 + 아래 명령으로 {ACR명}의 인증정보를 구합니다. + 'username'이 ID이고 'passwords[0].value'가 암호임. + ``` + az acr credential show --name {ACR명} + ``` + + 예시) ID=dg0200cr, 암호={암호} + ``` + $ az acr credential show --name dg0200cr + { + "passwords": [ + { + "name": "password", + "value": "{암호}" + }, + { + "name": "password2", + "value": "{암호2}" + } + ], + "username": "dg0200cr" + } + ``` + + 아래와 같이 로그인 명령을 작성합니다. + ``` + docker login {ACR명}.azurecr.io -u {ID} -p {암호} + ``` + +- 컨테이너 푸시 방법 안내 + Docker Tag 명령으로 이미지를 tag하는 명령을 작성합니다. + ``` + docker tag {서비스명}:latest {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest + ``` + 이미지 푸시 명령을 작성합니다. + ``` + docker push {ACR명}.azurecr.io/{시스템명}/{서비스명}:latest + ``` + +- 컨테이너 실행 명령 생성 + - 환경변수 확인 + '{서비스명}/.run/{서비스명}.run.xml' 을 읽어 각 서비스의 환경변수 찾음. + "env.map"의 각 entry의 key와 value가 환경변수임. + + 예제) SERVER_PORT=8081, DB_HOST=20.249.137.175가 환경변수임 + ``` + + + + - + \ No newline at end of file diff --git a/distribution-service/src/main/java/com/kt/distribution/config/KafkaConfig.java b/distribution-service/src/main/java/com/kt/distribution/config/KafkaConfig.java index 92f2c90..c40d782 100644 --- a/distribution-service/src/main/java/com/kt/distribution/config/KafkaConfig.java +++ b/distribution-service/src/main/java/com/kt/distribution/config/KafkaConfig.java @@ -5,10 +5,13 @@ import org.apache.kafka.common.serialization.StringSerializer; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.kafka.core.DefaultKafkaProducerFactory; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.core.ProducerFactory; import org.springframework.kafka.support.serializer.JsonSerializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.Map; @@ -23,24 +26,34 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; * @since 2025-10-23 */ @Configuration -@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false) public class KafkaConfig { - @Value("${spring.kafka.bootstrap-servers}") + private static final Logger log = LoggerFactory.getLogger(KafkaConfig.class); + + @Value("${spring.kafka.bootstrap-servers:localhost:9092}") private String bootstrapServers; @Bean + @Primary public ProducerFactory producerFactory() { + log.info("Initializing Kafka ProducerFactory with bootstrap servers: {}", bootstrapServers); + Map configProps = new HashMap<>(); configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); configProps.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + + log.debug("Kafka Producer Config: {}", configProps); + return new DefaultKafkaProducerFactory<>(configProps); } @Bean + @Primary public KafkaTemplate kafkaTemplate() { + log.info("Creating KafkaTemplate with custom ProducerFactory"); return new KafkaTemplate<>(producerFactory()); } } diff --git a/distribution-service/src/main/resources/application.yml b/distribution-service/src/main/resources/application.yml index d6c5e99..d3bebe8 100644 --- a/distribution-service/src/main/resources/application.yml +++ b/distribution-service/src/main/resources/application.yml @@ -40,15 +40,16 @@ spring: max-idle: 8 min-idle: 2 - # Disable security auto-configuration + # Disable security and kafka auto-configuration autoconfigure: exclude: - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration + - org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration kafka: enabled: ${KAFKA_ENABLED:true} - bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092} + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095} producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3f4e9ce --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +version: '3.8' + +services: + redis: + image: redis:7.2-alpine + container_name: kt-event-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + restart: unless-stopped + networks: + - kt-event-network + + zookeeper: + image: confluentinc/cp-zookeeper:7.5.0 + container_name: kt-event-zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - "2181:2181" + restart: unless-stopped + networks: + - kt-event-network + + kafka: + image: confluentinc/cp-kafka:7.5.0 + container_name: kt-event-kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + restart: unless-stopped + networks: + - kt-event-network + +volumes: + redis-data: + driver: local + +networks: + kt-event-network: + driver: bridge diff --git a/event-service/.run/event-service.run.xml b/event-service/.run/event-service.run.xml new file mode 100644 index 0000000..20639a9 --- /dev/null +++ b/event-service/.run/event-service.run.xml @@ -0,0 +1,71 @@ + + + + + + + + true + true + + + + + false + false + + + diff --git a/event-service/build.gradle b/event-service/build.gradle index 0f2d88c..7515d5e 100644 --- a/event-service/build.gradle +++ b/event-service/build.gradle @@ -1,4 +1,11 @@ +bootJar { + archiveFileName = 'event-service.jar' +} + dependencies { + // Actuator for health checks and monitoring + implementation 'org.springframework.boot:spring-boot-starter-actuator' + // Kafka for job publishing implementation 'org.springframework.kafka:spring-kafka' @@ -10,4 +17,7 @@ dependencies { // Jackson for JSON implementation 'com.fasterxml.jackson.core:jackson-databind' + + // Hibernate 6 네이티브로 배열 타입 지원하므로 별도 라이브러리 불필요 + // implementation 'com.vladmihalcea:hibernate-types-60:2.21.1' } diff --git a/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java b/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java new file mode 100644 index 0000000..82eb160 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java @@ -0,0 +1,41 @@ +package com.kt.event.eventservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.kafka.annotation.EnableKafka; + +/** + * Event Service Application + * + * 이벤트 전체 생명주기 관리 서비스 + * - AI 기반 이벤트 추천 및 커스터마이징 + * - 이미지 생성 및 편집 오케스트레이션 + * - 배포 채널 관리 및 최종 배포 + * - 이벤트 상태 관리 (DRAFT, PUBLISHED, ENDED) + * + * @version 1.0.0 + * @since 2025-10-23 + */ +@SpringBootApplication( + scanBasePackages = { + "com.kt.event.eventservice", + "com.kt.event.common" + }, + exclude = { + UserDetailsServiceAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration.class + } +) +@EnableJpaAuditing +@EnableKafka +@EnableFeignClients +public class EventServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(EventServiceApplication.class, args); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/AIEventGenerationJobMessage.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/AIEventGenerationJobMessage.java new file mode 100644 index 0000000..966778f --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/AIEventGenerationJobMessage.java @@ -0,0 +1,95 @@ +package com.kt.event.eventservice.application.dto.kafka; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * AI 이벤트 생성 작업 메시지 DTO + * + * ai-event-generation-job 토픽에서 구독하는 메시지 형식 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AIEventGenerationJobMessage { + + /** + * 작업 ID + */ + @JsonProperty("job_id") + private String jobId; + + /** + * 사용자 ID + */ + @JsonProperty("user_id") + private Long userId; + + /** + * 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED) + */ + @JsonProperty("status") + private String status; + + /** + * AI 추천 결과 데이터 + */ + @JsonProperty("ai_recommendation") + private AIRecommendationData aiRecommendation; + + /** + * 에러 메시지 (실패 시) + */ + @JsonProperty("error_message") + private String errorMessage; + + /** + * 작업 생성 일시 + */ + @JsonProperty("created_at") + private LocalDateTime createdAt; + + /** + * 작업 완료/실패 일시 + */ + @JsonProperty("completed_at") + private LocalDateTime completedAt; + + /** + * AI 추천 데이터 내부 클래스 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class AIRecommendationData { + + @JsonProperty("event_title") + private String eventTitle; + + @JsonProperty("event_description") + private String eventDescription; + + @JsonProperty("event_type") + private String eventType; + + @JsonProperty("target_keywords") + private List targetKeywords; + + @JsonProperty("recommended_benefits") + private List recommendedBenefits; + + @JsonProperty("start_date") + private String startDate; + + @JsonProperty("end_date") + private String endDate; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/EventCreatedMessage.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/EventCreatedMessage.java new file mode 100644 index 0000000..75560c0 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/EventCreatedMessage.java @@ -0,0 +1,58 @@ +package com.kt.event.eventservice.application.dto.kafka; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 이벤트 생성 완료 메시지 DTO + * + * event-created 토픽에 발행되는 메시지 형식 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventCreatedMessage { + + /** + * 이벤트 ID (UUID) + */ + @JsonProperty("event_id") + private UUID eventId; + + /** + * 사용자 ID (UUID) + */ + @JsonProperty("user_id") + private UUID userId; + + /** + * 이벤트 제목 + */ + @JsonProperty("title") + private String title; + + /** + * 이벤트 생성 일시 + */ + @JsonProperty("created_at") + private LocalDateTime createdAt; + + /** + * 이벤트 타입 (COUPON, DISCOUNT, GIFT, POINT 등) + */ + @JsonProperty("event_type") + private String eventType; + + /** + * 메시지 타임스탬프 + */ + @JsonProperty("timestamp") + private LocalDateTime timestamp; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/ImageGenerationJobMessage.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/ImageGenerationJobMessage.java new file mode 100644 index 0000000..dd52243 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/ImageGenerationJobMessage.java @@ -0,0 +1,75 @@ +package com.kt.event.eventservice.application.dto.kafka; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 이미지 생성 작업 메시지 DTO + * + * image-generation-job 토픽에서 구독하는 메시지 형식 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImageGenerationJobMessage { + + /** + * 작업 ID + */ + @JsonProperty("job_id") + private String jobId; + + /** + * 이벤트 ID + */ + @JsonProperty("event_id") + private Long eventId; + + /** + * 사용자 ID + */ + @JsonProperty("user_id") + private Long userId; + + /** + * 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED) + */ + @JsonProperty("status") + private String status; + + /** + * 생성된 이미지 URL + */ + @JsonProperty("image_url") + private String imageUrl; + + /** + * 이미지 생성 프롬프트 + */ + @JsonProperty("prompt") + private String prompt; + + /** + * 에러 메시지 (실패 시) + */ + @JsonProperty("error_message") + private String errorMessage; + + /** + * 작업 생성 일시 + */ + @JsonProperty("created_at") + private LocalDateTime createdAt; + + /** + * 작업 완료/실패 일시 + */ + @JsonProperty("completed_at") + private LocalDateTime completedAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/AiRecommendationRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/AiRecommendationRequest.java new file mode 100644 index 0000000..8c94bea --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/AiRecommendationRequest.java @@ -0,0 +1,59 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * AI 추천 요청 DTO + * + * AI 서비스에 이벤트 추천 생성을 요청합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "AI 추천 요청") +public class AiRecommendationRequest { + + @NotNull(message = "매장 정보는 필수입니다.") + @Valid + @Schema(description = "매장 정보", required = true) + private StoreInfo storeInfo; + + /** + * 매장 정보 + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "매장 정보") + public static class StoreInfo { + + @NotNull(message = "매장 ID는 필수입니다.") + @Schema(description = "매장 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440002") + private UUID storeId; + + @NotNull(message = "매장명은 필수입니다.") + @Schema(description = "매장명", required = true, example = "우진네 고깃집") + private String storeName; + + @NotNull(message = "업종은 필수입니다.") + @Schema(description = "업종", required = true, example = "음식점") + private String category; + + @Schema(description = "매장 설명", example = "신선한 한우를 제공하는 고깃집") + private String description; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageEditRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageEditRequest.java new file mode 100644 index 0000000..fa8e518 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageEditRequest.java @@ -0,0 +1,47 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 이미지 편집 요청 DTO + * + * 선택된 이미지를 편집합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "이미지 편집 요청") +public class ImageEditRequest { + + @NotNull(message = "편집 유형은 필수입니다.") + @Schema(description = "편집 유형", required = true, example = "TEXT_OVERLAY", + allowableValues = {"TEXT_OVERLAY", "COLOR_ADJUST", "CROP", "FILTER"}) + private EditType editType; + + @NotNull(message = "편집 파라미터는 필수입니다.") + @Schema(description = "편집 파라미터 (편집 유형에 따라 다름)", required = true, + example = "{\"text\": \"20% 할인\", \"fontSize\": 48, \"color\": \"#FF0000\", \"position\": \"center\"}") + private Map parameters; + + /** + * 편집 유형 + */ + public enum EditType { + TEXT_OVERLAY, // 텍스트 오버레이 + COLOR_ADJUST, // 색상 조정 + CROP, // 자르기 + FILTER // 필터 적용 + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageGenerationRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageGenerationRequest.java new file mode 100644 index 0000000..55a947e --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageGenerationRequest.java @@ -0,0 +1,36 @@ +package com.kt.event.eventservice.application.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 이미지 생성 요청 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ImageGenerationRequest { + + @NotEmpty(message = "이미지 스타일은 최소 1개 이상 선택해야 합니다.") + private List styles; + + @NotEmpty(message = "플랫폼은 최소 1개 이상 선택해야 합니다.") + private List platforms; + + @Min(value = 1, message = "이미지 개수는 최소 1개 이상이어야 합니다.") + @Max(value = 9, message = "이미지 개수는 최대 9개까지 가능합니다.") + @Builder.Default + private int imageCount = 3; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectChannelsRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectChannelsRequest.java new file mode 100644 index 0000000..91d508f --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectChannelsRequest.java @@ -0,0 +1,32 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 배포 채널 선택 요청 DTO + * + * 이벤트를 배포할 채널을 선택합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "배포 채널 선택 요청") +public class SelectChannelsRequest { + + @NotEmpty(message = "배포 채널을 최소 1개 이상 선택해야 합니다.") + @Schema(description = "배포 채널 목록", required = true, + example = "[\"WEBSITE\", \"KAKAO\", \"INSTAGRAM\"]") + private List channels; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectImageRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectImageRequest.java new file mode 100644 index 0000000..23562fb --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectImageRequest.java @@ -0,0 +1,28 @@ +package com.kt.event.eventservice.application.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * 이미지 선택 요청 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SelectImageRequest { + + @NotNull(message = "이미지 ID는 필수입니다.") + private UUID imageId; + + private String imageUrl; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectObjectiveRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectObjectiveRequest.java new file mode 100644 index 0000000..7267d44 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectObjectiveRequest.java @@ -0,0 +1,24 @@ +package com.kt.event.eventservice.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 이벤트 목적 선택 요청 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SelectObjectiveRequest { + + @NotBlank(message = "이벤트 목적은 필수입니다.") + private String objective; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectRecommendationRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectRecommendationRequest.java new file mode 100644 index 0000000..78d2ce9 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectRecommendationRequest.java @@ -0,0 +1,63 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * AI 추천 선택 요청 DTO + * + * AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "AI 추천 선택 요청") +public class SelectRecommendationRequest { + + @NotNull(message = "추천 ID는 필수입니다.") + @Schema(description = "선택한 추천 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440007") + private UUID recommendationId; + + @Valid + @Schema(description = "커스터마이징 항목") + private Customizations customizations; + + /** + * 커스터마이징 항목 + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "커스터마이징 항목") + public static class Customizations { + + @Schema(description = "수정된 이벤트명", example = "봄맞이 특별 할인 이벤트") + private String eventName; + + @Schema(description = "수정된 설명", example = "봄을 맞이하여 전 메뉴 20% 할인") + private String description; + + @Schema(description = "수정된 시작일", example = "2025-03-01") + private LocalDate startDate; + + @Schema(description = "수정된 종료일", example = "2025-03-31") + private LocalDate endDate; + + @Schema(description = "수정된 할인율", example = "20") + private Integer discountRate; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/UpdateEventRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/UpdateEventRequest.java new file mode 100644 index 0000000..95c28cd --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/UpdateEventRequest.java @@ -0,0 +1,41 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 이벤트 수정 요청 DTO + * + * 기존 이벤트의 정보를 수정합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "이벤트 수정 요청") +public class UpdateEventRequest { + + @Schema(description = "이벤트명", example = "봄맞이 특별 할인 이벤트") + private String eventName; + + @Schema(description = "이벤트 설명", example = "봄을 맞이하여 전 메뉴 20% 할인") + private String description; + + @Schema(description = "시작일", example = "2025-03-01") + private LocalDate startDate; + + @Schema(description = "종료일", example = "2025-03-31") + private LocalDate endDate; + + @Schema(description = "할인율", example = "20") + private Integer discountRate; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventCreatedResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventCreatedResponse.java new file mode 100644 index 0000000..40b0fa3 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventCreatedResponse.java @@ -0,0 +1,29 @@ +package com.kt.event.eventservice.application.dto.response; + +import com.kt.event.eventservice.domain.enums.EventStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 이벤트 생성 응답 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EventCreatedResponse { + + private UUID eventId; + private EventStatus status; + private String objective; + private LocalDateTime createdAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventDetailResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventDetailResponse.java new file mode 100644 index 0000000..b895a80 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventDetailResponse.java @@ -0,0 +1,77 @@ +package com.kt.event.eventservice.application.dto.response; + +import com.kt.event.eventservice.domain.enums.EventStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 이벤트 상세 응답 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EventDetailResponse { + + private UUID eventId; + private UUID userId; + private UUID storeId; + private String eventName; + private String description; + private String objective; + private LocalDate startDate; + private LocalDate endDate; + private EventStatus status; + private UUID selectedImageId; + private String selectedImageUrl; + + @Builder.Default + private List generatedImages = new ArrayList<>(); + + @Builder.Default + private List aiRecommendations = new ArrayList<>(); + + @Builder.Default + private List channels = new ArrayList<>(); + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class GeneratedImageDto { + private UUID imageId; + private String imageUrl; + private String style; + private String platform; + private boolean isSelected; + private LocalDateTime createdAt; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AiRecommendationDto { + private UUID recommendationId; + private String eventName; + private String description; + private String promotionType; + private String targetAudience; + private boolean isSelected; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageEditResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageEditResponse.java new file mode 100644 index 0000000..3879c73 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageEditResponse.java @@ -0,0 +1,36 @@ +package com.kt.event.eventservice.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 이미지 편집 응답 DTO + * + * 편집된 이미지 정보를 반환합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "이미지 편집 응답") +public class ImageEditResponse { + + @Schema(description = "편집된 이미지 ID", example = "550e8400-e29b-41d4-a716-446655440008") + private UUID imageId; + + @Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg") + private String imageUrl; + + @Schema(description = "편집일시", example = "2025-02-16T15:20:00") + private LocalDateTime editedAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageGenerationResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageGenerationResponse.java new file mode 100644 index 0000000..8aea98e --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageGenerationResponse.java @@ -0,0 +1,28 @@ +package com.kt.event.eventservice.application.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 이미지 생성 응답 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ImageGenerationResponse { + + private UUID jobId; + private String status; + private String message; + private LocalDateTime createdAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobAcceptedResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobAcceptedResponse.java new file mode 100644 index 0000000..bffcad0 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobAcceptedResponse.java @@ -0,0 +1,36 @@ +package com.kt.event.eventservice.application.dto.response; + +import com.kt.event.eventservice.domain.enums.JobStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * Job 접수 응답 DTO + * + * 비동기 작업이 접수되었음을 알리는 응답입니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Job 접수 응답") +public class JobAcceptedResponse { + + @Schema(description = "생성된 Job ID", example = "550e8400-e29b-41d4-a716-446655440005") + private UUID jobId; + + @Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING") + private JobStatus status; + + @Schema(description = "안내 메시지", example = "AI 추천 생성 요청이 접수되었습니다. /jobs/{jobId}로 상태를 확인하세요.") + private String message; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobStatusResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobStatusResponse.java new file mode 100644 index 0000000..a1b0899 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobStatusResponse.java @@ -0,0 +1,34 @@ +package com.kt.event.eventservice.application.dto.response; + +import com.kt.event.eventservice.domain.enums.JobStatus; +import com.kt.event.eventservice.domain.enums.JobType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Job 상태 응답 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class JobStatusResponse { + + private UUID jobId; + private JobType jobType; + private JobStatus status; + private int progress; + private String resultKey; + private String errorMessage; + private LocalDateTime createdAt; + private LocalDateTime completedAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java new file mode 100644 index 0000000..43a515e --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java @@ -0,0 +1,550 @@ +package com.kt.event.eventservice.application.service; + +import com.kt.event.common.exception.BusinessException; +import com.kt.event.common.exception.ErrorCode; +import com.kt.event.eventservice.application.dto.request.*; +import com.kt.event.eventservice.application.dto.response.*; +import com.kt.event.eventservice.domain.enums.JobType; +import com.kt.event.eventservice.domain.entity.*; +import com.kt.event.eventservice.domain.enums.EventStatus; +import com.kt.event.eventservice.domain.repository.EventRepository; +import com.kt.event.eventservice.domain.repository.JobRepository; +import com.kt.event.eventservice.infrastructure.client.ContentServiceClient; +import com.kt.event.eventservice.infrastructure.client.dto.ContentImageGenerationRequest; +import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse; +import com.kt.event.eventservice.infrastructure.kafka.AIJobKafkaProducer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.Hibernate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 이벤트 서비스 + * + * 이벤트 전체 생명주기를 관리합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EventService { + + private final EventRepository eventRepository; + private final JobRepository jobRepository; + private final ContentServiceClient contentServiceClient; + private final AIJobKafkaProducer aiJobKafkaProducer; + + /** + * 이벤트 생성 (Step 1: 목적 선택) + * + * @param userId 사용자 ID (UUID) + * @param storeId 매장 ID (UUID) + * @param request 목적 선택 요청 + * @return 생성된 이벤트 응답 + */ + @Transactional + public EventCreatedResponse createEvent(UUID userId, UUID storeId, SelectObjectiveRequest request) { + log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}", + userId, storeId, request.getObjective()); + + // 이벤트 엔티티 생성 + Event event = Event.builder() + .userId(userId) + .storeId(storeId) + .objective(request.getObjective()) + .eventName("") // 초기에는 비어있음, AI 추천 후 설정 + .status(EventStatus.DRAFT) + .build(); + + // 저장 + event = eventRepository.save(event); + + log.info("이벤트 생성 완료 - eventId: {}", event.getEventId()); + + return EventCreatedResponse.builder() + .eventId(event.getEventId()) + .status(event.getStatus()) + .objective(event.getObjective()) + .createdAt(event.getCreatedAt()) + .build(); + } + + /** + * 이벤트 상세 조회 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @return 이벤트 상세 응답 + */ + public EventDetailResponse getEvent(UUID userId, UUID eventId) { + log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId); + + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // Lazy 컬렉션 초기화 + Hibernate.initialize(event.getChannels()); + Hibernate.initialize(event.getGeneratedImages()); + Hibernate.initialize(event.getAiRecommendations()); + + return mapToDetailResponse(event); + } + + /** + * 이벤트 목록 조회 (페이징, 필터링) + * + * @param userId 사용자 ID (UUID) + * @param status 상태 필터 + * @param search 검색어 + * @param objective 목적 필터 + * @param pageable 페이징 정보 + * @return 이벤트 목록 + */ + public Page getEvents( + UUID userId, + EventStatus status, + String search, + String objective, + Pageable pageable) { + + log.info("이벤트 목록 조회 - userId: {}, status: {}, search: {}, objective: {}", + userId, status, search, objective); + + Page events = eventRepository.findEventsByUser(userId, status, search, objective, pageable); + + return events.map(event -> { + // Lazy 컬렉션 초기화 + Hibernate.initialize(event.getChannels()); + Hibernate.initialize(event.getGeneratedImages()); + Hibernate.initialize(event.getAiRecommendations()); + return mapToDetailResponse(event); + }); + } + + /** + * 이벤트 삭제 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + */ + @Transactional + public void deleteEvent(UUID userId, UUID eventId) { + log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId); + + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + if (!event.isDeletable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + eventRepository.delete(event); + + log.info("이벤트 삭제 완료 - eventId: {}", eventId); + } + + /** + * 이벤트 배포 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + */ + @Transactional + public void publishEvent(UUID userId, UUID eventId) { + log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId); + + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // 배포 가능 여부 검증 및 상태 변경 + event.publish(); + + eventRepository.save(event); + + log.info("이벤트 배포 완료 - eventId: {}", eventId); + } + + /** + * 이벤트 종료 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + */ + @Transactional + public void endEvent(UUID userId, UUID eventId) { + log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId); + + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + event.end(); + + eventRepository.save(event); + + log.info("이벤트 종료 완료 - eventId: {}", eventId); + } + + /** + * 이미지 생성 요청 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request 이미지 생성 요청 + * @return 이미지 생성 응답 (Job ID 포함) + */ + @Transactional + public ImageGenerationResponse requestImageGeneration(UUID userId, UUID eventId, ImageGenerationRequest request) { + log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // Content Service 요청 DTO 생성 + ContentImageGenerationRequest contentRequest = ContentImageGenerationRequest.builder() + .eventDraftId(event.getEventId().getMostSignificantBits()) + .eventTitle(event.getEventName() != null ? event.getEventName() : "") + .eventDescription(event.getDescription() != null ? event.getDescription() : "") + .styles(request.getStyles()) + .platforms(request.getPlatforms()) + .build(); + + // Content Service 호출 + ContentJobResponse jobResponse = contentServiceClient.generateImages(contentRequest); + + log.info("Content Service 이미지 생성 요청 완료 - jobId: {}", jobResponse.getId()); + + // 응답 생성 + return ImageGenerationResponse.builder() + .jobId(UUID.fromString(jobResponse.getId())) + .status(jobResponse.getStatus()) + .message("이미지 생성 요청이 접수되었습니다.") + .createdAt(jobResponse.getCreatedAt()) + .build(); + } + + /** + * 이미지 선택 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param imageId 이미지 ID + * @param request 이미지 선택 요청 + */ + @Transactional + public void selectImage(UUID userId, UUID eventId, UUID imageId, SelectImageRequest request) { + log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // 이미지 선택 + event.selectImage(request.getImageId(), request.getImageUrl()); + + eventRepository.save(event); + + log.info("이미지 선택 완료 - eventId: {}, imageId: {}", eventId, imageId); + } + + /** + * AI 추천 요청 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request AI 추천 요청 + * @return Job 접수 응답 + */ + @Transactional + public JobAcceptedResponse requestAiRecommendations(UUID userId, UUID eventId, AiRecommendationRequest request) { + log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // Job 엔티티 생성 + Job job = Job.builder() + .eventId(eventId) + .jobType(JobType.AI_RECOMMENDATION) + .build(); + + job = jobRepository.save(job); + + // Kafka 메시지 발행 + aiJobKafkaProducer.publishAIGenerationJob( + job.getJobId().toString(), + userId.getMostSignificantBits(), // Long으로 변환 + eventId.toString(), + request.getStoreInfo().getStoreName(), + request.getStoreInfo().getCategory(), + request.getStoreInfo().getDescription(), + event.getObjective() + ); + + log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId()); + + return JobAcceptedResponse.builder() + .jobId(job.getJobId()) + .status(job.getStatus()) + .message("AI 추천 생성 요청이 접수되었습니다. /jobs/" + job.getJobId() + "로 상태를 확인하세요.") + .build(); + } + + /** + * AI 추천 선택 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request AI 추천 선택 요청 + */ + @Transactional + public void selectRecommendation(UUID userId, UUID eventId, SelectRecommendationRequest request) { + log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}", + userId, eventId, request.getRecommendationId()); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // Lazy 컬렉션 초기화 + Hibernate.initialize(event.getAiRecommendations()); + + // AI 추천 조회 + AiRecommendation selectedRecommendation = event.getAiRecommendations().stream() + .filter(rec -> rec.getRecommendationId().equals(request.getRecommendationId())) + .findFirst() + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_003)); + + // 모든 추천 선택 해제 + event.getAiRecommendations().forEach(rec -> rec.setSelected(false)); + + // 선택한 추천만 선택 처리 + selectedRecommendation.setSelected(true); + + // 커스터마이징이 있으면 적용 + if (request.getCustomizations() != null) { + SelectRecommendationRequest.Customizations custom = request.getCustomizations(); + + if (custom.getEventName() != null) { + event.updateEventName(custom.getEventName()); + } else { + event.updateEventName(selectedRecommendation.getEventName()); + } + + if (custom.getDescription() != null) { + event.updateDescription(custom.getDescription()); + } else { + event.updateDescription(selectedRecommendation.getDescription()); + } + + if (custom.getStartDate() != null && custom.getEndDate() != null) { + event.updateEventPeriod(custom.getStartDate(), custom.getEndDate()); + } + } else { + // 커스터마이징이 없으면 AI 추천 그대로 적용 + event.updateEventName(selectedRecommendation.getEventName()); + event.updateDescription(selectedRecommendation.getDescription()); + } + + eventRepository.save(event); + + log.info("AI 추천 선택 완료 - eventId: {}, recommendationId: {}", eventId, request.getRecommendationId()); + } + + /** + * 이미지 편집 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param imageId 이미지 ID + * @param request 이미지 편집 요청 + * @return 이미지 편집 응답 + */ + @Transactional + public ImageEditResponse editImage(UUID userId, UUID eventId, UUID imageId, ImageEditRequest request) { + log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // 이미지가 선택된 이미지인지 확인 + if (!imageId.equals(event.getSelectedImageId())) { + throw new BusinessException(ErrorCode.EVENT_003); + } + + // TODO: Content Service에 이미지 편집 요청 + // 현재는 Content Service 연동이 없으므로 Mock 응답 반환 + // 실제로는 ContentServiceClient를 통해 편집 요청을 보내야 함 + + log.info("이미지 편집 완료 - eventId: {}, imageId: {}", eventId, imageId); + + // Mock 응답 (실제로는 Content Service의 응답을 반환해야 함) + return ImageEditResponse.builder() + .imageId(imageId) + .imageUrl(event.getSelectedImageUrl()) // 편집된 URL은 Content Service에서 받아와야 함 + .editedAt(java.time.LocalDateTime.now()) + .build(); + } + + /** + * 배포 채널 선택 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request 배포 채널 선택 요청 + */ + @Transactional + public void selectChannels(UUID userId, UUID eventId, SelectChannelsRequest request) { + log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}", + userId, eventId, request.getChannels()); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // 배포 채널 설정 + event.updateChannels(request.getChannels()); + + eventRepository.save(event); + + log.info("배포 채널 선택 완료 - eventId: {}, channels: {}", eventId, request.getChannels()); + } + + /** + * 이벤트 수정 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request 이벤트 수정 요청 + * @return 이벤트 상세 응답 + */ + @Transactional + public EventDetailResponse updateEvent(UUID userId, UUID eventId, UpdateEventRequest request) { + log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // 이벤트명 수정 + if (request.getEventName() != null && !request.getEventName().trim().isEmpty()) { + event.updateEventName(request.getEventName()); + } + + // 설명 수정 + if (request.getDescription() != null && !request.getDescription().trim().isEmpty()) { + event.updateDescription(request.getDescription()); + } + + // 이벤트 기간 수정 + if (request.getStartDate() != null && request.getEndDate() != null) { + event.updateEventPeriod(request.getStartDate(), request.getEndDate()); + } + + event = eventRepository.save(event); + + // Lazy 컬렉션 초기화 + Hibernate.initialize(event.getChannels()); + Hibernate.initialize(event.getGeneratedImages()); + Hibernate.initialize(event.getAiRecommendations()); + + log.info("이벤트 수정 완료 - eventId: {}", eventId); + + return mapToDetailResponse(event); + } + + // ==== Private Helper Methods ==== // + + /** + * Event Entity를 EventDetailResponse DTO로 변환 + */ + private EventDetailResponse mapToDetailResponse(Event event) { + return EventDetailResponse.builder() + .eventId(event.getEventId()) + .userId(event.getUserId()) + .storeId(event.getStoreId()) + .eventName(event.getEventName()) + .description(event.getDescription()) + .objective(event.getObjective()) + .startDate(event.getStartDate()) + .endDate(event.getEndDate()) + .status(event.getStatus()) + .selectedImageId(event.getSelectedImageId()) + .selectedImageUrl(event.getSelectedImageUrl()) + .generatedImages( + event.getGeneratedImages().stream() + .map(img -> EventDetailResponse.GeneratedImageDto.builder() + .imageId(img.getImageId()) + .imageUrl(img.getImageUrl()) + .style(img.getStyle()) + .platform(img.getPlatform()) + .isSelected(img.isSelected()) + .createdAt(img.getCreatedAt()) + .build()) + .collect(Collectors.toList()) + ) + .aiRecommendations( + event.getAiRecommendations().stream() + .map(rec -> EventDetailResponse.AiRecommendationDto.builder() + .recommendationId(rec.getRecommendationId()) + .eventName(rec.getEventName()) + .description(rec.getDescription()) + .promotionType(rec.getPromotionType()) + .targetAudience(rec.getTargetAudience()) + .isSelected(rec.isSelected()) + .build()) + .collect(Collectors.toList()) + ) + .channels(event.getChannels()) + .createdAt(event.getCreatedAt()) + .updatedAt(event.getUpdatedAt()) + .build(); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/service/JobService.java b/event-service/src/main/java/com/kt/event/eventservice/application/service/JobService.java new file mode 100644 index 0000000..9cba649 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/service/JobService.java @@ -0,0 +1,146 @@ +package com.kt.event.eventservice.application.service; + +import com.kt.event.common.exception.BusinessException; +import com.kt.event.common.exception.ErrorCode; +import com.kt.event.eventservice.application.dto.response.JobStatusResponse; +import com.kt.event.eventservice.domain.entity.Job; +import com.kt.event.eventservice.domain.enums.JobType; +import com.kt.event.eventservice.domain.repository.JobRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * Job 서비스 + * + * 비동기 작업 상태를 관리합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JobService { + + private final JobRepository jobRepository; + + /** + * Job 생성 + * + * @param eventId 이벤트 ID + * @param jobType 작업 유형 + * @return 생성된 Job + */ + @Transactional + public Job createJob(UUID eventId, JobType jobType) { + log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType); + + Job job = Job.builder() + .eventId(eventId) + .jobType(jobType) + .build(); + + job = jobRepository.save(job); + + log.info("Job 생성 완료 - jobId: {}", job.getJobId()); + + return job; + } + + /** + * Job 상태 조회 + * + * @param jobId Job ID + * @return Job 상태 응답 + */ + public JobStatusResponse getJobStatus(UUID jobId) { + log.info("Job 상태 조회 - jobId: {}", jobId); + + Job job = jobRepository.findById(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.JOB_001)); + + return mapToJobStatusResponse(job); + } + + /** + * Job 상태 업데이트 + * + * @param jobId Job ID + * @param progress 진행률 + */ + @Transactional + public void updateJobProgress(UUID jobId, int progress) { + log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress); + + Job job = jobRepository.findById(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.JOB_001)); + + job.updateProgress(progress); + + jobRepository.save(job); + } + + /** + * Job 완료 처리 + * + * @param jobId Job ID + * @param resultKey Redis 결과 키 + */ + @Transactional + public void completeJob(UUID jobId, String resultKey) { + log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey); + + Job job = jobRepository.findById(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.JOB_001)); + + job.complete(resultKey); + + jobRepository.save(job); + + log.info("Job 완료 처리 완료 - jobId: {}", jobId); + } + + /** + * Job 실패 처리 + * + * @param jobId Job ID + * @param errorMessage 에러 메시지 + */ + @Transactional + public void failJob(UUID jobId, String errorMessage) { + log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage); + + Job job = jobRepository.findById(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.JOB_001)); + + job.fail(errorMessage); + + jobRepository.save(job); + + log.info("Job 실패 처리 완료 - jobId: {}", jobId); + } + + // ==== Private Helper Methods ==== // + + /** + * Job Entity를 JobStatusResponse DTO로 변환 + */ + private JobStatusResponse mapToJobStatusResponse(Job job) { + return JobStatusResponse.builder() + .jobId(job.getJobId()) + .jobType(job.getJobType()) + .status(job.getStatus()) + .progress(job.getProgress()) + .resultKey(job.getResultKey()) + .errorMessage(job.getErrorMessage()) + .createdAt(job.getCreatedAt()) + .completedAt(job.getCompletedAt()) + .build(); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java b/event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java new file mode 100644 index 0000000..fb56ea8 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java @@ -0,0 +1,53 @@ +package com.kt.event.eventservice.config; + +import com.kt.event.common.security.UserPrincipal; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.UUID; + +/** + * 개발 환경용 인증 필터 + * + * User Service가 구현되지 않은 개발 환경에서 테스트를 위해 + * 기본 UserPrincipal을 자동으로 생성하여 SecurityContext에 설정합니다. + * + * TODO: 프로덕션 환경에서는 이 필터를 비활성화하고 실제 JWT 인증 필터를 사용해야 합니다. + */ +public class DevAuthenticationFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 이미 인증된 경우 스킵 + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + // 개발용 기본 UserPrincipal 생성 + UserPrincipal userPrincipal = new UserPrincipal( + UUID.fromString("11111111-1111-1111-1111-111111111111"), // userId + UUID.fromString("22222222-2222-2222-2222-222222222222"), // storeId + "dev@test.com", // email + "개발테스트사용자", // name + Collections.singletonList("USER") // roles + ); + + // Authentication 객체 생성 및 SecurityContext에 설정 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userPrincipal, null, userPrincipal.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java b/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java new file mode 100644 index 0000000..632327c --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java @@ -0,0 +1,119 @@ +package com.kt.event.eventservice.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +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.*; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Kafka 설정 클래스 + * + * Producer와 Consumer 설정을 정의합니다. + * - Producer: event-created 토픽에 이벤트 발행 + * - Consumer: ai-event-generation-job, image-generation-job 토픽 구독 + */ +@Configuration +@EnableKafka +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String consumerGroupId; + + /** + * Kafka Producer 설정 + * + * @return ProducerFactory 인스턴스 + */ + @Bean + public ProducerFactory producerFactory() { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + + // Producer 성능 최적화 설정 + config.put(ProducerConfig.ACKS_CONFIG, "all"); + config.put(ProducerConfig.RETRIES_CONFIG, 3); + config.put(ProducerConfig.LINGER_MS_CONFIG, 1); + config.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy"); + + return new DefaultKafkaProducerFactory<>(config); + } + + /** + * KafkaTemplate 빈 생성 + * + * @return KafkaTemplate 인스턴스 + */ + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } + + /** + * Kafka Consumer 설정 + * ErrorHandlingDeserializer를 사용하여 역직렬화 오류를 처리합니다. + * + * @return ConsumerFactory 인스턴스 + */ + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId); + + // ErrorHandlingDeserializer로 래핑하여 역직렬화 오류 처리 + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); + + // 실제 Deserializer 설정 + config.put(ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS, StringDeserializer.class); + config.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class); + + // JsonDeserializer 설정 + config.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + config.put(JsonDeserializer.VALUE_DEFAULT_TYPE, "java.util.HashMap"); + + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + + // Consumer 성능 최적화 설정 + config.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500); + config.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000); + + return new DefaultKafkaConsumerFactory<>(config); + } + + /** + * Kafka Listener Container Factory 설정 + * + * @return ConcurrentKafkaListenerContainerFactory 인스턴스 + */ + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.setConcurrency(3); // 동시 처리 스레드 수 + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java b/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java new file mode 100644 index 0000000..5aea9e1 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java @@ -0,0 +1,65 @@ +package com.kt.event.eventservice.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.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Spring Security 설정 클래스 + * + * 현재 User Service가 구현되지 않았으므로 임시로 모든 API 접근을 허용합니다. + * TODO: User Service 구현 후 JWT 기반 인증/인가 활성화 필요 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + /** + * Spring Security 필터 체인 설정 + * - 모든 요청에 대해 인증 없이 접근 허용 + * - CSRF 보호 비활성화 (개발 환경) + * + * @param http HttpSecurity 설정 객체 + * @return SecurityFilterChain 보안 필터 체인 + * @throws Exception 설정 중 예외 발생 시 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 보호 비활성화 (개발 환경) + .csrf(AbstractHttpConfigurer::disable) + + // CORS 설정 + .cors(AbstractHttpConfigurer::disable) + + // 폼 로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + + // 로그아웃 비활성화 + .logout(AbstractHttpConfigurer::disable) + + // HTTP Basic 인증 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + + // 세션 관리 - STATELESS (세션 사용 안 함) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // 요청 인증 설정 + .authorizeHttpRequests(authz -> authz + // 모든 요청 허용 (개발 환경) + .anyRequest().permitAll() + ) + + // 개발용 인증 필터 추가 (User Service 구현 전까지 임시 사용) + .addFilterBefore(new DevAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/AiRecommendation.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/AiRecommendation.java new file mode 100644 index 0000000..978f9a0 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/AiRecommendation.java @@ -0,0 +1,53 @@ +package com.kt.event.eventservice.domain.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.GenericGenerator; + +import java.util.UUID; + +/** + * AI 추천 엔티티 + * + * AI가 추천한 이벤트 기획안을 관리합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Entity +@Table(name = "ai_recommendations") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class AiRecommendation extends BaseTimeEntity { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(name = "recommendation_id", columnDefinition = "uuid") + private UUID recommendationId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = false) + private Event event; + + @Column(name = "event_name", nullable = false, length = 200) + private String eventName; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "promotion_type", length = 50) + private String promotionType; + + @Column(name = "target_audience", length = 100) + private String targetAudience; + + @Column(name = "is_selected", nullable = false) + @Builder.Default + private boolean isSelected = false; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java new file mode 100644 index 0000000..9602b65 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java @@ -0,0 +1,209 @@ +package com.kt.event.eventservice.domain.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import com.kt.event.eventservice.domain.enums.EventStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.annotations.GenericGenerator; + +import java.time.LocalDate; +import java.util.*; + +/** + * 이벤트 엔티티 + * + * 이벤트의 전체 생명주기를 관리합니다. + * - 생성, 수정, 배포, 종료 + * - AI 추천 및 이미지 관리 + * - 배포 채널 관리 + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Entity +@Table(name = "events") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Event extends BaseTimeEntity { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(name = "event_id", columnDefinition = "uuid") + private UUID eventId; + + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + private UUID userId; + + @Column(name = "store_id", nullable = false, columnDefinition = "uuid") + private UUID storeId; + + @Column(name = "event_name", length = 200) + private String eventName; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "objective", nullable = false, length = 100) + private String objective; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private EventStatus status = EventStatus.DRAFT; + + @Column(name = "selected_image_id", columnDefinition = "uuid") + private UUID selectedImageId; + + @Column(name = "selected_image_url", length = 500) + private String selectedImageUrl; + + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable( + name = "event_channels", + joinColumns = @JoinColumn(name = "event_id") + ) + @Column(name = "channel", length = 50) + @Fetch(FetchMode.SUBSELECT) + @Builder.Default + private List channels = new ArrayList<>(); + + @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @Builder.Default + private Set generatedImages = new HashSet<>(); + + @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @Builder.Default + private Set aiRecommendations = new HashSet<>(); + + // ==== 비즈니스 로직 ==== // + + /** + * 이벤트명 수정 + */ + public void updateEventName(String eventName) { + this.eventName = eventName; + } + + /** + * 설명 수정 + */ + public void updateDescription(String description) { + this.description = description; + } + + /** + * 이벤트 기간 수정 + */ + public void updateEventPeriod(LocalDate startDate, LocalDate endDate) { + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException("시작일은 종료일보다 이전이어야 합니다."); + } + this.startDate = startDate; + this.endDate = endDate; + } + + /** + * 이미지 선택 + */ + public void selectImage(UUID imageId, String imageUrl) { + this.selectedImageId = imageId; + this.selectedImageUrl = imageUrl; + + // 기존 선택 해제 + this.generatedImages.forEach(img -> img.setSelected(false)); + + // 새로운 이미지 선택 + this.generatedImages.stream() + .filter(img -> img.getImageId().equals(imageId)) + .findFirst() + .ifPresent(img -> img.setSelected(true)); + } + + /** + * 배포 채널 설정 + */ + public void updateChannels(List channels) { + this.channels.clear(); + this.channels.addAll(channels); + } + + /** + * 이벤트 배포 (상태 변경: DRAFT → PUBLISHED) + */ + public void publish() { + if (this.status != EventStatus.DRAFT) { + throw new IllegalStateException("DRAFT 상태에서만 배포할 수 있습니다."); + } + + // 필수 데이터 검증 + if (eventName == null || eventName.trim().isEmpty()) { + throw new IllegalStateException("이벤트명을 입력해야 합니다."); + } + if (startDate == null || endDate == null) { + throw new IllegalStateException("이벤트 기간을 설정해야 합니다."); + } + if (startDate.isAfter(endDate)) { + throw new IllegalStateException("시작일은 종료일보다 이전이어야 합니다."); + } + if (selectedImageId == null) { + throw new IllegalStateException("이미지를 선택해야 합니다."); + } + if (channels.isEmpty()) { + throw new IllegalStateException("배포 채널을 선택해야 합니다."); + } + + this.status = EventStatus.PUBLISHED; + } + + /** + * 이벤트 종료 + */ + public void end() { + if (this.status != EventStatus.PUBLISHED) { + throw new IllegalStateException("PUBLISHED 상태에서만 종료할 수 있습니다."); + } + this.status = EventStatus.ENDED; + } + + /** + * 생성된 이미지 추가 + */ + public void addGeneratedImage(GeneratedImage image) { + this.generatedImages.add(image); + image.setEvent(this); + } + + /** + * AI 추천 추가 + */ + public void addAiRecommendation(AiRecommendation recommendation) { + this.aiRecommendations.add(recommendation); + recommendation.setEvent(this); + } + + /** + * 수정 가능 여부 확인 + */ + public boolean isModifiable() { + return this.status == EventStatus.DRAFT; + } + + /** + * 삭제 가능 여부 확인 + */ + public boolean isDeletable() { + return this.status == EventStatus.DRAFT; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/GeneratedImage.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/GeneratedImage.java new file mode 100644 index 0000000..1e3db69 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/GeneratedImage.java @@ -0,0 +1,50 @@ +package com.kt.event.eventservice.domain.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.GenericGenerator; + +import java.util.UUID; + +/** + * 생성된 이미지 엔티티 + * + * 이벤트별로 생성된 이미지를 관리합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Entity +@Table(name = "generated_images") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class GeneratedImage extends BaseTimeEntity { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(name = "image_id", columnDefinition = "uuid") + private UUID imageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = false) + private Event event; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + @Column(name = "style", length = 50) + private String style; + + @Column(name = "platform", length = 50) + private String platform; + + @Column(name = "is_selected", nullable = false) + @Builder.Default + private boolean isSelected = false; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Job.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Job.java new file mode 100644 index 0000000..818dc30 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Job.java @@ -0,0 +1,100 @@ +package com.kt.event.eventservice.domain.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import com.kt.event.eventservice.domain.enums.JobStatus; +import com.kt.event.eventservice.domain.enums.JobType; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.GenericGenerator; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 비동기 작업 엔티티 + * + * AI 추천 생성, 이미지 생성 등의 비동기 작업 상태를 관리합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Entity +@Table(name = "jobs") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Job extends BaseTimeEntity { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(name = "job_id", columnDefinition = "uuid") + private UUID jobId; + + @Column(name = "event_id", nullable = false, columnDefinition = "uuid") + private UUID eventId; + + @Enumerated(EnumType.STRING) + @Column(name = "job_type", nullable = false, length = 30) + private JobType jobType; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private JobStatus status = JobStatus.PENDING; + + @Column(name = "progress", nullable = false) + @Builder.Default + private int progress = 0; + + @Column(name = "result_key", length = 200) + private String resultKey; + + @Column(name = "error_message", length = 500) + private String errorMessage; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + // ==== 비즈니스 로직 ==== // + + /** + * 작업 시작 + */ + public void start() { + this.status = JobStatus.PROCESSING; + this.progress = 0; + } + + /** + * 진행률 업데이트 + */ + public void updateProgress(int progress) { + if (progress < 0 || progress > 100) { + throw new IllegalArgumentException("진행률은 0~100 사이여야 합니다."); + } + this.progress = progress; + } + + /** + * 작업 완료 + */ + public void complete(String resultKey) { + this.status = JobStatus.COMPLETED; + this.progress = 100; + this.resultKey = resultKey; + this.completedAt = LocalDateTime.now(); + } + + /** + * 작업 실패 + */ + public void fail(String errorMessage) { + this.status = JobStatus.FAILED; + this.errorMessage = errorMessage; + this.completedAt = LocalDateTime.now(); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/enums/EventStatus.java b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/EventStatus.java new file mode 100644 index 0000000..1ff1f7e --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/EventStatus.java @@ -0,0 +1,25 @@ +package com.kt.event.eventservice.domain.enums; + +/** + * 이벤트 상태 + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +public enum EventStatus { + /** + * 임시 저장 (작성 중) + */ + DRAFT, + + /** + * 배포됨 (진행 중) + */ + PUBLISHED, + + /** + * 종료됨 + */ + ENDED +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobStatus.java b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobStatus.java new file mode 100644 index 0000000..ad31da4 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobStatus.java @@ -0,0 +1,30 @@ +package com.kt.event.eventservice.domain.enums; + +/** + * 비동기 작업 상태 + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +public enum JobStatus { + /** + * 대기 중 + */ + PENDING, + + /** + * 처리 중 + */ + PROCESSING, + + /** + * 완료 + */ + COMPLETED, + + /** + * 실패 + */ + FAILED +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobType.java b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobType.java new file mode 100644 index 0000000..aaa251a --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobType.java @@ -0,0 +1,20 @@ +package com.kt.event.eventservice.domain.enums; + +/** + * 비동기 작업 유형 + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +public enum JobType { + /** + * AI 이벤트 추천 생성 + */ + AI_RECOMMENDATION, + + /** + * 이미지 생성 + */ + IMAGE_GENERATION +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/AiRecommendationRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/AiRecommendationRepository.java new file mode 100644 index 0000000..7b0b58f --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/AiRecommendationRepository.java @@ -0,0 +1,29 @@ +package com.kt.event.eventservice.domain.repository; + +import com.kt.event.eventservice.domain.entity.AiRecommendation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +/** + * AI 추천 Repository + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Repository +public interface AiRecommendationRepository extends JpaRepository { + + /** + * 이벤트별 AI 추천 목록 조회 + */ + List findByEventEventId(UUID eventId); + + /** + * 이벤트별 선택된 AI 추천 조회 + */ + AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId); +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java new file mode 100644 index 0000000..22add09 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java @@ -0,0 +1,56 @@ +package com.kt.event.eventservice.domain.repository; + +import com.kt.event.eventservice.domain.entity.Event; +import com.kt.event.eventservice.domain.enums.EventStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +/** + * 이벤트 Repository + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Repository +public interface EventRepository extends JpaRepository { + + /** + * 사용자 ID와 이벤트 ID로 조회 + */ + @Query("SELECT DISTINCT e FROM Event e " + + "LEFT JOIN FETCH e.channels " + + "WHERE e.eventId = :eventId AND e.userId = :userId") + Optional findByEventIdAndUserId( + @Param("eventId") UUID eventId, + @Param("userId") UUID userId + ); + + /** + * 사용자별 이벤트 목록 조회 (페이징, 상태 필터) + */ + @Query("SELECT e FROM Event e " + + "WHERE e.userId = :userId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:search IS NULL OR e.eventName LIKE %:search%) " + + "AND (:objective IS NULL OR e.objective = :objective)") + Page findEventsByUser( + @Param("userId") UUID userId, + @Param("status") EventStatus status, + @Param("search") String search, + @Param("objective") String objective, + Pageable pageable + ); + + /** + * 사용자별 이벤트 개수 조회 (상태별) + */ + long countByUserIdAndStatus(UUID userId, EventStatus status); +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/GeneratedImageRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/GeneratedImageRepository.java new file mode 100644 index 0000000..203c267 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/GeneratedImageRepository.java @@ -0,0 +1,29 @@ +package com.kt.event.eventservice.domain.repository; + +import com.kt.event.eventservice.domain.entity.GeneratedImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +/** + * 생성된 이미지 Repository + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Repository +public interface GeneratedImageRepository extends JpaRepository { + + /** + * 이벤트별 생성된 이미지 목록 조회 + */ + List findByEventEventId(UUID eventId); + + /** + * 이벤트별 선택된 이미지 조회 + */ + GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId); +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/JobRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/JobRepository.java new file mode 100644 index 0000000..8673859 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/JobRepository.java @@ -0,0 +1,42 @@ +package com.kt.event.eventservice.domain.repository; + +import com.kt.event.eventservice.domain.entity.Job; +import com.kt.event.eventservice.domain.enums.JobStatus; +import com.kt.event.eventservice.domain.enums.JobType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * 비동기 작업 Repository + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Repository +public interface JobRepository extends JpaRepository { + + /** + * 이벤트별 작업 목록 조회 + */ + List findByEventId(UUID eventId); + + /** + * 이벤트 및 작업 유형별 조회 + */ + Optional findByEventIdAndJobType(UUID eventId, JobType jobType); + + /** + * 이벤트 및 작업 유형별 최신 작업 조회 + */ + Optional findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType); + + /** + * 상태별 작업 목록 조회 + */ + List findByStatus(JobStatus status); +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/ContentServiceClient.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/ContentServiceClient.java new file mode 100644 index 0000000..510f252 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/ContentServiceClient.java @@ -0,0 +1,32 @@ +package com.kt.event.eventservice.infrastructure.client; + +import com.kt.event.eventservice.infrastructure.client.dto.ContentImageGenerationRequest; +import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * Content Service Feign Client + * + * Content Service의 이미지 생성 API를 호출합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@FeignClient( + name = "content-service", + url = "${feign.content-service.url:http://localhost:8082}" +) +public interface ContentServiceClient { + + /** + * 이미지 생성 요청 + * + * @param request 이미지 생성 요청 정보 + * @return Job 정보 + */ + @PostMapping("/api/v1/content/images/generate") + ContentJobResponse generateImages(@RequestBody ContentImageGenerationRequest request); +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentImageGenerationRequest.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentImageGenerationRequest.java new file mode 100644 index 0000000..1ca7fff --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentImageGenerationRequest.java @@ -0,0 +1,28 @@ +package com.kt.event.eventservice.infrastructure.client.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Content Service 이미지 생성 요청 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ContentImageGenerationRequest { + + private Long eventDraftId; + private String eventTitle; + private String eventDescription; + private List styles; + private List platforms; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentJobResponse.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentJobResponse.java new file mode 100644 index 0000000..15c0e2d --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentJobResponse.java @@ -0,0 +1,32 @@ +package com.kt.event.eventservice.infrastructure.client.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Content Service Job 응답 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ContentJobResponse { + + private String id; + private Long eventDraftId; + private String jobType; + private String status; + private int progress; + private String resultMessage; + private String errorMessage; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/config/RedisConfig.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/config/RedisConfig.java new file mode 100644 index 0000000..345f3f5 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/config/RedisConfig.java @@ -0,0 +1,87 @@ +package com.kt.event.eventservice.infrastructure.config; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import lombok.extern.slf4j.Slf4j; +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.core.StringRedisTemplate; + +import java.time.Duration; + +/** + * Redis 설정 + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Slf4j +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Value("${spring.data.redis.password:}") + private String redisPassword; + + @Bean + @org.springframework.context.annotation.Primary + public RedisConnectionFactory redisConnectionFactory() { + System.out.println("========================================"); + System.out.println("REDIS CONFIG: Configuring Redis connection"); + System.out.println("REDIS CONFIG: host=" + redisHost + ", port=" + redisPort); + System.out.println("========================================"); + + log.info("Configuring Redis connection - host: {}, port: {}", redisHost, redisPort); + + RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(redisHost); + redisConfig.setPort(redisPort); + + if (redisPassword != null && !redisPassword.isEmpty()) { + redisConfig.setPassword(redisPassword); + } + + // Lettuce Client 설정 + SocketOptions socketOptions = SocketOptions.builder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + ClientOptions clientOptions = ClientOptions.builder() + .socketOptions(socketOptions) + .build(); + + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(10)) + .clientOptions(clientOptions) + .build(); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(redisConfig, clientConfig); + + log.info("Redis connection factory created successfully"); + return factory; + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + return template; + } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaConsumer.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaConsumer.java new file mode 100644 index 0000000..f4f1608 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaConsumer.java @@ -0,0 +1,102 @@ +package com.kt.event.eventservice.infrastructure.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage; +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 이벤트 생성 작업 메시지 구독 Consumer + * + * ai-event-generation-job 토픽의 메시지를 구독하여 처리합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AIJobKafkaConsumer { + + private final ObjectMapper objectMapper; + + /** + * AI 이벤트 생성 작업 메시지 수신 처리 + * + * @param message AI 이벤트 생성 작업 메시지 + * @param partition 파티션 번호 + * @param offset 오프셋 + * @param acknowledgment 수동 커밋용 Acknowledgment + */ + @KafkaListener( + topics = "${app.kafka.topics.ai-event-generation-job}", + groupId = "${spring.kafka.consumer.group-id}", + containerFactory = "kafkaListenerContainerFactory" + ) + public void consumeAIEventGenerationJob( + @Payload String payload, + @Header(KafkaHeaders.RECEIVED_PARTITION) int partition, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment acknowledgment + ) { + try { + log.info("AI 이벤트 생성 작업 메시지 수신 - Partition: {}, Offset: {}", partition, offset); + + // JSON을 객체로 변환 + AIEventGenerationJobMessage message = objectMapper.readValue( + payload, + AIEventGenerationJobMessage.class + ); + + log.info("AI 작업 메시지 파싱 완료 - JobId: {}, UserId: {}, Status: {}", + message.getJobId(), message.getUserId(), message.getStatus()); + + // 메시지 처리 로직 + processAIEventGenerationJob(message); + + // 수동 커밋 + acknowledgment.acknowledge(); + log.info("AI 이벤트 생성 작업 메시지 처리 완료 - JobId: {}", message.getJobId()); + + } catch (Exception e) { + log.error("AI 이벤트 생성 작업 메시지 처리 중 오류 발생 - Partition: {}, Offset: {}, Error: {}", + partition, offset, e.getMessage(), e); + // 에러 발생 시에도 커밋 (재처리 방지, DLQ 사용 권장) + acknowledgment.acknowledge(); + } + } + + /** + * AI 이벤트 생성 작업 처리 + * + * @param message AI 이벤트 생성 작업 메시지 + */ + private void processAIEventGenerationJob(AIEventGenerationJobMessage message) { + switch (message.getStatus()) { + case "COMPLETED": + log.info("AI 작업 완료 처리 - JobId: {}, UserId: {}", + message.getJobId(), message.getUserId()); + // TODO: AI 추천 결과를 캐시 또는 DB에 저장 + // TODO: 사용자에게 알림 전송 + break; + + case "FAILED": + log.error("AI 작업 실패 처리 - JobId: {}, Error: {}", + message.getJobId(), message.getErrorMessage()); + // TODO: 실패 로그 저장 및 사용자 알림 + break; + + case "PROCESSING": + log.info("AI 작업 진행 중 - JobId: {}", message.getJobId()); + // TODO: 작업 상태 업데이트 + break; + + default: + log.warn("알 수 없는 작업 상태 - JobId: {}, Status: {}", + message.getJobId(), message.getStatus()); + } + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaProducer.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaProducer.java new file mode 100644 index 0000000..c60a72c --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaProducer.java @@ -0,0 +1,91 @@ +package com.kt.event.eventservice.infrastructure.kafka; + +import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; + +/** + * AI 이벤트 생성 작업 메시지 발행 Producer + * + * ai-event-generation-job 토픽에 AI 추천 생성 작업 메시지를 발행합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AIJobKafkaProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}") + private String aiEventGenerationJobTopic; + + /** + * AI 이벤트 생성 작업 메시지 발행 + * + * @param jobId 작업 ID + * @param userId 사용자 ID + * @param eventId 이벤트 ID + * @param storeName 매장명 + * @param storeCategory 매장 업종 + * @param storeDescription 매장 설명 + * @param objective 이벤트 목적 + */ + public void publishAIGenerationJob( + String jobId, + Long userId, + String eventId, + String storeName, + String storeCategory, + String storeDescription, + String objective) { + + AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder() + .jobId(jobId) + .userId(userId) + .status("PENDING") + .createdAt(LocalDateTime.now()) + .build(); + + publishMessage(message); + } + + /** + * AI 이벤트 생성 작업 메시지 발행 + * + * @param message AIEventGenerationJobMessage 객체 + */ + public void publishMessage(AIEventGenerationJobMessage message) { + try { + CompletableFuture> future = + kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), message); + + future.whenComplete((result, ex) -> { + if (ex == null) { + log.info("AI 작업 메시지 발행 성공 - Topic: {}, JobId: {}, Offset: {}", + aiEventGenerationJobTopic, + message.getJobId(), + result.getRecordMetadata().offset()); + } else { + log.error("AI 작업 메시지 발행 실패 - Topic: {}, JobId: {}, Error: {}", + aiEventGenerationJobTopic, + message.getJobId(), + ex.getMessage(), ex); + } + }); + } catch (Exception e) { + log.error("AI 작업 메시지 발행 중 예외 발생 - JobId: {}, Error: {}", + message.getJobId(), e.getMessage(), e); + } + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/EventKafkaProducer.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/EventKafkaProducer.java new file mode 100644 index 0000000..4f21e6c --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/EventKafkaProducer.java @@ -0,0 +1,78 @@ +package com.kt.event.eventservice.infrastructure.kafka; + +import com.kt.event.eventservice.application.dto.kafka.EventCreatedMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; + +/** + * 이벤트 생성 메시지 발행 Producer + * + * event-created 토픽에 이벤트 생성 완료 메시지를 발행합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EventKafkaProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${app.kafka.topics.event-created}") + private String eventCreatedTopic; + + /** + * 이벤트 생성 완료 메시지 발행 + * + * @param eventId 이벤트 ID (UUID) + * @param userId 사용자 ID (UUID) + * @param title 이벤트 제목 + * @param eventType 이벤트 타입 + */ + public void publishEventCreated(java.util.UUID eventId, java.util.UUID userId, String title, String eventType) { + EventCreatedMessage message = EventCreatedMessage.builder() + .eventId(eventId) + .userId(userId) + .title(title) + .eventType(eventType) + .createdAt(LocalDateTime.now()) + .timestamp(LocalDateTime.now()) + .build(); + + publishEventCreatedMessage(message); + } + + /** + * 이벤트 생성 메시지 발행 + * + * @param message EventCreatedMessage 객체 + */ + public void publishEventCreatedMessage(EventCreatedMessage message) { + try { + CompletableFuture> future = + kafkaTemplate.send(eventCreatedTopic, message.getEventId().toString(), message); + + future.whenComplete((result, ex) -> { + if (ex == null) { + log.info("이벤트 생성 메시지 발행 성공 - Topic: {}, EventId: {}, Offset: {}", + eventCreatedTopic, + message.getEventId(), + result.getRecordMetadata().offset()); + } else { + log.error("이벤트 생성 메시지 발행 실패 - Topic: {}, EventId: {}, Error: {}", + eventCreatedTopic, + message.getEventId(), + ex.getMessage(), ex); + } + }); + } catch (Exception e) { + log.error("이벤트 생성 메시지 발행 중 예외 발생 - EventId: {}, Error: {}", + message.getEventId(), e.getMessage(), e); + } + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/ImageJobKafkaConsumer.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/ImageJobKafkaConsumer.java new file mode 100644 index 0000000..f66f3e7 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/ImageJobKafkaConsumer.java @@ -0,0 +1,105 @@ +package com.kt.event.eventservice.infrastructure.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kt.event.eventservice.application.dto.kafka.ImageGenerationJobMessage; +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; + +/** + * 이미지 생성 작업 메시지 구독 Consumer + * + * image-generation-job 토픽의 메시지를 구독하여 처리합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ImageJobKafkaConsumer { + + private final ObjectMapper objectMapper; + + /** + * 이미지 생성 작업 메시지 수신 처리 + * + * @param payload 메시지 페이로드 (JSON) + * @param partition 파티션 번호 + * @param offset 오프셋 + * @param acknowledgment 수동 커밋용 Acknowledgment + */ + @KafkaListener( + topics = "${app.kafka.topics.image-generation-job}", + groupId = "${spring.kafka.consumer.group-id}", + containerFactory = "kafkaListenerContainerFactory" + ) + public void consumeImageGenerationJob( + @Payload String payload, + @Header(KafkaHeaders.RECEIVED_PARTITION) int partition, + @Header(KafkaHeaders.OFFSET) long offset, + Acknowledgment acknowledgment + ) { + try { + log.info("이미지 생성 작업 메시지 수신 - Partition: {}, Offset: {}", partition, offset); + + // JSON을 객체로 변환 + ImageGenerationJobMessage message = objectMapper.readValue( + payload, + ImageGenerationJobMessage.class + ); + + log.info("이미지 작업 메시지 파싱 완료 - JobId: {}, EventId: {}, Status: {}", + message.getJobId(), message.getEventId(), message.getStatus()); + + // 메시지 처리 로직 + processImageGenerationJob(message); + + // 수동 커밋 + acknowledgment.acknowledge(); + log.info("이미지 생성 작업 메시지 처리 완료 - JobId: {}", message.getJobId()); + + } catch (Exception e) { + log.error("이미지 생성 작업 메시지 처리 중 오류 발생 - Partition: {}, Offset: {}, Error: {}", + partition, offset, e.getMessage(), e); + // 에러 발생 시에도 커밋 (재처리 방지, DLQ 사용 권장) + acknowledgment.acknowledge(); + } + } + + /** + * 이미지 생성 작업 처리 + * + * @param message 이미지 생성 작업 메시지 + */ + private void processImageGenerationJob(ImageGenerationJobMessage message) { + switch (message.getStatus()) { + case "COMPLETED": + log.info("이미지 작업 완료 처리 - JobId: {}, EventId: {}, ImageURL: {}", + message.getJobId(), message.getEventId(), message.getImageUrl()); + // TODO: 생성된 이미지 URL을 캐시 또는 DB에 저장 + // TODO: 이벤트 엔티티에 이미지 URL 업데이트 + // TODO: 사용자에게 알림 전송 + break; + + case "FAILED": + log.error("이미지 작업 실패 처리 - JobId: {}, EventId: {}, Error: {}", + message.getJobId(), message.getEventId(), message.getErrorMessage()); + // TODO: 실패 로그 저장 및 사용자 알림 + // TODO: 재시도 로직 또는 기본 이미지 사용 + break; + + case "PROCESSING": + log.info("이미지 작업 진행 중 - JobId: {}, EventId: {}", + message.getJobId(), message.getEventId()); + // TODO: 작업 상태 업데이트 + break; + + default: + log.warn("알 수 없는 작업 상태 - JobId: {}, EventId: {}, Status: {}", + message.getJobId(), message.getEventId(), message.getStatus()); + } + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java new file mode 100644 index 0000000..41cbb74 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java @@ -0,0 +1,402 @@ +package com.kt.event.eventservice.presentation.controller; + +import com.kt.event.common.dto.ApiResponse; +import com.kt.event.common.dto.PageResponse; +import com.kt.event.common.security.UserPrincipal; +import com.kt.event.eventservice.application.dto.request.*; +import com.kt.event.eventservice.application.dto.response.*; +import com.kt.event.eventservice.application.service.EventService; +import com.kt.event.eventservice.domain.enums.EventStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * 이벤트 컨트롤러 + * + * 이벤트 전체 생명주기 관리 API를 제공합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/events") +@RequiredArgsConstructor +@Tag(name = "Event", description = "이벤트 관리 API") +public class EventController { + + private final EventService eventService; + + /** + * 이벤트 목적 선택 (Step 1: 이벤트 생성) + * + * @param request 목적 선택 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 생성된 이벤트 응답 + */ + @PostMapping("/objectives") + @Operation(summary = "이벤트 목적 선택", description = "이벤트 생성의 첫 단계로 목적을 선택합니다.") + public ResponseEntity> selectObjective( + @Valid @RequestBody SelectObjectiveRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이벤트 목적 선택 API 호출 - userId: {}, objective: {}", + userPrincipal.getUserId(), request.getObjective()); + + EventCreatedResponse response = eventService.createEvent( + userPrincipal.getUserId(), + userPrincipal.getStoreId(), + request + ); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(response)); + } + + /** + * 이벤트 목록 조회 + * + * @param status 상태 필터 + * @param search 검색어 + * @param objective 목적 필터 + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param sort 정렬 기준 + * @param order 정렬 순서 + * @param userPrincipal 인증된 사용자 정보 + * @return 이벤트 목록 응답 + */ + @GetMapping + @Operation(summary = "이벤트 목록 조회", description = "사용자의 이벤트 목록을 조회합니다.") + public ResponseEntity>> getEvents( + @RequestParam(required = false) EventStatus status, + @RequestParam(required = false) String search, + @RequestParam(required = false) String objective, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "createdAt") String sort, + @RequestParam(defaultValue = "desc") String order, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이벤트 목록 조회 API 호출 - userId: {}", userPrincipal.getUserId()); + + // Pageable 생성 + Sort.Direction direction = "asc".equalsIgnoreCase(order) ? Sort.Direction.ASC : Sort.Direction.DESC; + Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort)); + + Page events = eventService.getEvents( + userPrincipal.getUserId(), + status, + search, + objective, + pageable + ); + + PageResponse pageResponse = PageResponse.builder() + .content(events.getContent()) + .page(events.getNumber()) + .size(events.getSize()) + .totalElements(events.getTotalElements()) + .totalPages(events.getTotalPages()) + .first(events.isFirst()) + .last(events.isLast()) + .build(); + + return ResponseEntity.ok(ApiResponse.success(pageResponse)); + } + + /** + * 이벤트 상세 조회 + * + * @param eventId 이벤트 ID + * @param userPrincipal 인증된 사용자 정보 + * @return 이벤트 상세 응답 + */ + @GetMapping("/{eventId}") + @Operation(summary = "이벤트 상세 조회", description = "특정 이벤트의 상세 정보를 조회합니다.") + public ResponseEntity> getEvent( + @PathVariable UUID eventId, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + EventDetailResponse response = eventService.getEvent(userPrincipal.getUserId(), eventId); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 이벤트 삭제 + * + * @param eventId 이벤트 ID + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @DeleteMapping("/{eventId}") + @Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다. DRAFT 상태만 삭제 가능합니다.") + public ResponseEntity> deleteEvent( + @PathVariable UUID eventId, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + eventService.deleteEvent(userPrincipal.getUserId(), eventId); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * 이벤트 배포 + * + * @param eventId 이벤트 ID + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PostMapping("/{eventId}/publish") + @Operation(summary = "이벤트 배포", description = "이벤트를 배포합니다. DRAFT → PUBLISHED 상태 변경.") + public ResponseEntity> publishEvent( + @PathVariable UUID eventId, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + eventService.publishEvent(userPrincipal.getUserId(), eventId); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * 이벤트 종료 + * + * @param eventId 이벤트 ID + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PostMapping("/{eventId}/end") + @Operation(summary = "이벤트 종료", description = "이벤트를 종료합니다. PUBLISHED → ENDED 상태 변경.") + public ResponseEntity> endEvent( + @PathVariable UUID eventId, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + eventService.endEvent(userPrincipal.getUserId(), eventId); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * 이미지 생성 요청 + * + * @param eventId 이벤트 ID + * @param request 이미지 생성 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 이미지 생성 응답 (Job ID 포함) + */ + @PostMapping("/{eventId}/images") + @Operation(summary = "이미지 생성 요청", description = "AI를 통해 이벤트 이미지를 생성합니다.") + public ResponseEntity> requestImageGeneration( + @PathVariable UUID eventId, + @Valid @RequestBody ImageGenerationRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이미지 생성 요청 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + ImageGenerationResponse response = eventService.requestImageGeneration( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(ApiResponse.success(response)); + } + + /** + * 이미지 선택 + * + * @param eventId 이벤트 ID + * @param imageId 이미지 ID + * @param request 이미지 선택 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PutMapping("/{eventId}/images/{imageId}/select") + @Operation(summary = "이미지 선택", description = "생성된 이미지 중 하나를 선택합니다.") + public ResponseEntity> selectImage( + @PathVariable UUID eventId, + @PathVariable UUID imageId, + @Valid @RequestBody SelectImageRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이미지 선택 API 호출 - userId: {}, eventId: {}, imageId: {}", + userPrincipal.getUserId(), eventId, imageId); + + eventService.selectImage( + userPrincipal.getUserId(), + eventId, + imageId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * AI 추천 요청 (Step 2) + * + * @param eventId 이벤트 ID + * @param request AI 추천 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return AI 추천 요청 응답 (Job ID 포함) + */ + @PostMapping("/{eventId}/ai-recommendations") + @Operation(summary = "AI 추천 요청", description = "AI 서비스에 이벤트 추천 생성을 요청합니다.") + public ResponseEntity> requestAiRecommendations( + @PathVariable UUID eventId, + @Valid @RequestBody AiRecommendationRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("AI 추천 요청 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + JobAcceptedResponse response = eventService.requestAiRecommendations( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(ApiResponse.success(response)); + } + + /** + * AI 추천 선택 (Step 2-2) + * + * @param eventId 이벤트 ID + * @param request AI 추천 선택 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PutMapping("/{eventId}/recommendations") + @Operation(summary = "AI 추천 선택", description = "AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다.") + public ResponseEntity> selectRecommendation( + @PathVariable UUID eventId, + @Valid @RequestBody SelectRecommendationRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("AI 추천 선택 API 호출 - userId: {}, eventId: {}, recommendationId: {}", + userPrincipal.getUserId(), eventId, request.getRecommendationId()); + + eventService.selectRecommendation( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * 이미지 편집 (Step 3-3) + * + * @param eventId 이벤트 ID + * @param imageId 이미지 ID + * @param request 이미지 편집 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 이미지 편집 응답 + */ + @PutMapping("/{eventId}/images/{imageId}/edit") + @Operation(summary = "이미지 편집", description = "선택된 이미지를 편집합니다.") + public ResponseEntity> editImage( + @PathVariable UUID eventId, + @PathVariable UUID imageId, + @Valid @RequestBody ImageEditRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이미지 편집 API 호출 - userId: {}, eventId: {}, imageId: {}", + userPrincipal.getUserId(), eventId, imageId); + + ImageEditResponse response = eventService.editImage( + userPrincipal.getUserId(), + eventId, + imageId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 배포 채널 선택 (Step 4) + * + * @param eventId 이벤트 ID + * @param request 배포 채널 선택 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PutMapping("/{eventId}/channels") + @Operation(summary = "배포 채널 선택", description = "이벤트를 배포할 채널을 선택합니다.") + public ResponseEntity> selectChannels( + @PathVariable UUID eventId, + @Valid @RequestBody SelectChannelsRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("배포 채널 선택 API 호출 - userId: {}, eventId: {}, channels: {}", + userPrincipal.getUserId(), eventId, request.getChannels()); + + eventService.selectChannels( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * 이벤트 수정 + * + * @param eventId 이벤트 ID + * @param request 이벤트 수정 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PutMapping("/{eventId}") + @Operation(summary = "이벤트 수정", description = "기존 이벤트의 정보를 수정합니다. DRAFT 상태만 수정 가능합니다.") + public ResponseEntity> updateEvent( + @PathVariable UUID eventId, + @Valid @RequestBody UpdateEventRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이벤트 수정 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + EventDetailResponse response = eventService.updateEvent( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/JobController.java b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/JobController.java new file mode 100644 index 0000000..149be77 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/JobController.java @@ -0,0 +1,51 @@ +package com.kt.event.eventservice.presentation.controller; + +import com.kt.event.common.dto.ApiResponse; +import com.kt.event.eventservice.application.dto.response.JobStatusResponse; +import com.kt.event.eventservice.application.service.JobService; +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.UUID; + +/** + * Job 컨트롤러 + * + * 비동기 작업 상태 조회 API를 제공합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/jobs") +@RequiredArgsConstructor +@Tag(name = "Job", description = "비동기 작업 상태 조회 API") +public class JobController { + + private final JobService jobService; + + /** + * Job 상태 조회 + * + * @param jobId Job ID + * @return Job 상태 응답 + */ + @GetMapping("/{jobId}") + @Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).") + public ResponseEntity> getJobStatus(@PathVariable UUID jobId) { + log.info("Job 상태 조회 API 호출 - jobId: {}", jobId); + + JobStatusResponse response = jobService.getJobStatus(jobId); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/RedisTestController.java b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/RedisTestController.java new file mode 100644 index 0000000..0bdebde --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/RedisTestController.java @@ -0,0 +1,39 @@ +package com.kt.event.eventservice.presentation.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; + +/** + * Redis 연결 테스트 컨트롤러 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/redis-test") +@RequiredArgsConstructor +public class RedisTestController { + + private final StringRedisTemplate redisTemplate; + + @GetMapping("/ping") + public String ping() { + try { + String key = "test:ping"; + String value = "pong:" + System.currentTimeMillis(); + + log.info("Redis test - setting key: {}, value: {}", key, value); + redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(60)); + + String result = redisTemplate.opsForValue().get(key); + log.info("Redis test - retrieved value: {}", result); + + return "Redis OK - " + result; + } catch (Exception e) { + log.error("Redis connection failed", e); + return "Redis FAILED - " + e.getMessage(); + } + } +} diff --git a/event-service/src/main/resources/application.yml b/event-service/src/main/resources/application.yml new file mode 100644 index 0000000..8e8da42 --- /dev/null +++ b/event-service/src/main/resources/application.yml @@ -0,0 +1,168 @@ +spring: + application: + name: event-service + + # Database Configuration (PostgreSQL) + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:eventdb} + username: ${DB_USERNAME:eventuser} + password: ${DB_PASSWORD:eventpass} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 5 + minimum-idle: 2 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + # JPA Configuration + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: ${DDL_AUTO:update} + properties: + hibernate: + format_sql: false + show_sql: false + use_sql_comments: false + jdbc: + batch_size: 20 + time_zone: Asia/Seoul + open-in-view: false + + # Redis Configuration + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: 60000ms + connect-timeout: 60000ms + lettuce: + pool: + max-active: 5 + max-idle: 3 + min-idle: 1 + max-wait: -1ms + shutdown-timeout: 200ms + + # Kafka Configuration + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.add.type.headers: false + consumer: + group-id: event-service-consumers + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + spring.json.use.type.headers: false + auto-offset-reset: earliest + enable-auto-commit: false + listener: + ack-mode: manual + +# Server Configuration +server: + port: ${SERVER_PORT:8080} + servlet: + context-path: / + shutdown: graceful + +# Actuator Configuration +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + base-path: /actuator + endpoint: + health: + show-details: always + show-components: always + health: + redis: + enabled: false + livenessState: + enabled: true + readinessState: + enabled: true + +# Logging Configuration +logging: + level: + root: INFO + com.kt.event: ${LOG_LEVEL:INFO} + org.springframework: WARN + org.springframework.data.redis: WARN + io.lettuce.core: WARN + org.hibernate.SQL: ${SQL_LOG_LEVEL:WARN} + org.hibernate.type.descriptor.sql.BasicBinder: WARN + 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/event-service.log} + logback: + rollingpolicy: + max-file-size: 10MB + max-history: 7 + total-size-cap: 100MB + +# Springdoc OpenAPI Configuration +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + tags-sorter: alpha + show-actuator: false + +# Feign Client Configuration +feign: + client: + config: + default: + connectTimeout: 5000 + readTimeout: 10000 + loggerLevel: basic + + # Content Service Client + content-service: + url: ${CONTENT_SERVICE_URL:http://localhost:8082} + + # Distribution Service Client + distribution-service: + url: ${DISTRIBUTION_SERVICE_URL:http://localhost:8084} + +# Application Configuration +app: + kafka: + topics: + ai-event-generation-job: ai-event-generation-job + image-generation-job: image-generation-job + event-created: event-created + + redis: + ttl: + ai-result: 86400 # 24시간 (초 단위) + image-result: 604800 # 7일 (초 단위) + key-prefix: + ai-recommendation: "ai:recommendation:" + image-generation: "image:generation:" + job-status: "job:status:" + + job: + timeout: + ai-generation: 300000 # 5분 (밀리초 단위) + image-generation: 300000 # 5분 (밀리초 단위) + +# JWT Configuration +jwt: + secret: ${JWT_SECRET:default-jwt-secret-key-for-development-minimum-32-bytes-required} + expiration: 86400000 # 24시간 (밀리초 단위) diff --git a/generate-test-token.py b/generate-test-token.py new file mode 100644 index 0000000..70aaf7e --- /dev/null +++ b/generate-test-token.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +JWT 테스트 토큰 생성 스크립트 +Event Service API 테스트용 +""" + +import jwt +import datetime +import uuid + +# JWT Secret (run-event-service.ps1과 동일) +JWT_SECRET = "kt-event-marketing-jwt-secret-key-for-development-only-minimum-256-bits-required" + +# 유효기간을 매우 길게 설정 (테스트용) +EXPIRATION_DAYS = 365 + +# 테스트 사용자 정보 +USER_ID = str(uuid.uuid4()) +STORE_ID = str(uuid.uuid4()) +EMAIL = "test@example.com" +NAME = "Test User" +ROLES = ["ROLE_USER"] + +def generate_access_token(): + """Access Token 생성""" + now = datetime.datetime.utcnow() + expiry = now + datetime.timedelta(days=EXPIRATION_DAYS) + + payload = { + 'sub': USER_ID, + 'storeId': STORE_ID, + 'email': EMAIL, + 'name': NAME, + 'roles': ROLES, + 'type': 'access', + 'iat': now, + 'exp': expiry + } + + token = jwt.encode(payload, JWT_SECRET, algorithm='HS256') + return token + +if __name__ == '__main__': + print("=" * 80) + print("JWT 테스트 토큰 생성") + print("=" * 80) + print() + print(f"User ID: {USER_ID}") + print(f"Store ID: {STORE_ID}") + print(f"Email: {EMAIL}") + print(f"Name: {NAME}") + print(f"Roles: {ROLES}") + print() + print("=" * 80) + print("Access Token:") + print("=" * 80) + + token = generate_access_token() + print(token) + print() + print("=" * 80) + print("사용 방법:") + print("=" * 80) + print("curl -H \"Authorization: Bearer \" http://localhost:8081/api/v1/events") + print() diff --git a/gradlew.bat b/gradlew.bat index e509b2d..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,93 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/logs/participation-service.log.2025-10-24.0.gz b/logs/participation-service.log.2025-10-24.0.gz new file mode 100644 index 0000000..921949b Binary files /dev/null and b/logs/participation-service.log.2025-10-24.0.gz differ diff --git a/logs/participation-service.log.2025-10-24.1.gz b/logs/participation-service.log.2025-10-24.1.gz new file mode 100644 index 0000000..c62d1f8 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.1.gz differ diff --git a/logs/participation-service.log.2025-10-24.10.gz b/logs/participation-service.log.2025-10-24.10.gz new file mode 100644 index 0000000..70246ae Binary files /dev/null and b/logs/participation-service.log.2025-10-24.10.gz differ diff --git a/logs/participation-service.log.2025-10-24.11.gz b/logs/participation-service.log.2025-10-24.11.gz new file mode 100644 index 0000000..b1d0946 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.11.gz differ diff --git a/logs/participation-service.log.2025-10-24.12.gz b/logs/participation-service.log.2025-10-24.12.gz new file mode 100644 index 0000000..32025f9 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.12.gz differ diff --git a/logs/participation-service.log.2025-10-24.13.gz b/logs/participation-service.log.2025-10-24.13.gz new file mode 100644 index 0000000..d67a7ed Binary files /dev/null and b/logs/participation-service.log.2025-10-24.13.gz differ diff --git a/logs/participation-service.log.2025-10-24.2.gz b/logs/participation-service.log.2025-10-24.2.gz new file mode 100644 index 0000000..9ed3647 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.2.gz differ diff --git a/logs/participation-service.log.2025-10-24.3.gz b/logs/participation-service.log.2025-10-24.3.gz new file mode 100644 index 0000000..bdf4578 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.3.gz differ diff --git a/logs/participation-service.log.2025-10-24.4.gz b/logs/participation-service.log.2025-10-24.4.gz new file mode 100644 index 0000000..3579675 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.4.gz differ diff --git a/logs/participation-service.log.2025-10-24.5.gz b/logs/participation-service.log.2025-10-24.5.gz new file mode 100644 index 0000000..5bd8296 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.5.gz differ diff --git a/logs/participation-service.log.2025-10-24.6.gz b/logs/participation-service.log.2025-10-24.6.gz new file mode 100644 index 0000000..3d9d459 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.6.gz differ diff --git a/logs/participation-service.log.2025-10-24.7.gz b/logs/participation-service.log.2025-10-24.7.gz new file mode 100644 index 0000000..65dfba7 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.7.gz differ diff --git a/logs/participation-service.log.2025-10-24.8.gz b/logs/participation-service.log.2025-10-24.8.gz new file mode 100644 index 0000000..851b928 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.8.gz differ diff --git a/logs/participation-service.log.2025-10-24.9.gz b/logs/participation-service.log.2025-10-24.9.gz new file mode 100644 index 0000000..a095746 Binary files /dev/null and b/logs/participation-service.log.2025-10-24.9.gz differ diff --git a/participation-service/.run/ParticipationServiceApplication.run.xml b/participation-service/.run/ParticipationServiceApplication.run.xml new file mode 100644 index 0000000..cfab385 --- /dev/null +++ b/participation-service/.run/ParticipationServiceApplication.run.xml @@ -0,0 +1,64 @@ + + + + + + + + true + true + + + + + false + false + + + diff --git a/participation-service/.run/participation-service.run.xml b/participation-service/.run/participation-service.run.xml new file mode 100644 index 0000000..672ca87 --- /dev/null +++ b/participation-service/.run/participation-service.run.xml @@ -0,0 +1,56 @@ + + + + + + + + true + true + + + + + false + false + + + \ No newline at end of file diff --git a/participation-service/add-channel-column.sql b/participation-service/add-channel-column.sql new file mode 100644 index 0000000..25612a9 --- /dev/null +++ b/participation-service/add-channel-column.sql @@ -0,0 +1,14 @@ +-- participation-service channel 컬럼 추가 스크립트 +-- 실행 방법: psql -h 4.230.72.147 -U eventuser -d participationdb -f add-channel-column.sql + +-- channel 컬럼 추가 +ALTER TABLE participants +ADD COLUMN IF NOT EXISTS channel VARCHAR(20); + +-- 기존 데이터에 기본값 설정 +UPDATE participants +SET channel = 'SNS' +WHERE channel IS NULL; + +-- 커밋 +COMMIT; diff --git a/participation-service/build.gradle b/participation-service/build.gradle index c5507a9..12730de 100644 --- a/participation-service/build.gradle +++ b/participation-service/build.gradle @@ -1,7 +1,51 @@ +plugins { + id 'java' + id 'org.springframework.boot' + id 'io.spring.dependency-management' +} + +group = 'com.kt.event' +version = '1.0.0' +sourceCompatibility = '21' + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + dependencies { - // Kafka for event publishing + // Common 모듈 + implementation project(':common') + + // Spring Boot Starters + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.kafka:spring-kafka' + // PostgreSQL + runtimeOnly 'org.postgresql:postgresql' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + // Jackson for JSON implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.kafka:spring-kafka-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' +} + +tasks.named('test') { + useJUnitPlatform() } diff --git a/participation-service/fix-indexes.sql b/participation-service/fix-indexes.sql new file mode 100644 index 0000000..136b256 --- /dev/null +++ b/participation-service/fix-indexes.sql @@ -0,0 +1,14 @@ +-- participation-service 인덱스 중복 문제 해결 스크립트 +-- 실행 방법: psql -h 4.230.72.147 -U eventuser -d participationdb -f fix-indexes.sql + +-- 기존 중복 인덱스 삭제 (존재하는 경우만) +DROP INDEX IF EXISTS idx_event_id; +DROP INDEX IF EXISTS idx_event_phone; + +-- 새로운 고유 인덱스는 Hibernate가 자동 생성하므로 별도 생성 불필요 +-- 다음 서비스 시작 시 자동으로 생성됩니다: +-- - idx_draw_log_event_id (draw_logs 테이블) +-- - idx_participant_event_id (participants 테이블) +-- - idx_participant_event_phone (participants 테이블) + +COMMIT; diff --git a/participation-service/logs/participation-service.log.2025-10-24.0.gz b/participation-service/logs/participation-service.log.2025-10-24.0.gz new file mode 100644 index 0000000..54b4dd9 Binary files /dev/null and b/participation-service/logs/participation-service.log.2025-10-24.0.gz differ diff --git a/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java b/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java new file mode 100644 index 0000000..1edcb91 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java @@ -0,0 +1,23 @@ +package com.kt.event.participation; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * Participation Service Main Application + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@SpringBootApplication(scanBasePackages = { + "com.kt.event.participation", + "com.kt.event.common" +}) +@EnableJpaAuditing +public class ParticipationServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ParticipationServiceApplication.class, args); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersRequest.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersRequest.java new file mode 100644 index 0000000..5e167cc --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersRequest.java @@ -0,0 +1,21 @@ +package com.kt.event.participation.application.dto; + +import jakarta.validation.constraints.*; +import lombok.*; + +/** + * 당첨자 추첨 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DrawWinnersRequest { + + @NotNull(message = "당첨자 수는 필수입니다") + @Min(value = 1, message = "당첨자 수는 최소 1명 이상이어야 합니다") + private Integer winnerCount; + + @Builder.Default + private Boolean applyStoreVisitBonus = true; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersResponse.java new file mode 100644 index 0000000..d9ff7a0 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersResponse.java @@ -0,0 +1,33 @@ +package com.kt.event.participation.application.dto; + +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 당첨자 추첨 응답 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DrawWinnersResponse { + + private String eventId; + private Integer totalParticipants; + private Integer winnerCount; + private LocalDateTime drawnAt; + private List winners; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class WinnerSummary { + private String participantId; + private String name; + private String phoneNumber; + private Integer rank; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java new file mode 100644 index 0000000..6f85b6c --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java @@ -0,0 +1,37 @@ +package com.kt.event.participation.application.dto; + +import jakarta.validation.constraints.*; +import lombok.*; + +/** + * 이벤트 참여 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ParticipationRequest { + + @NotBlank(message = "이름은 필수입니다") + @Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다") + private String name; + + @NotBlank(message = "전화번호는 필수입니다") + @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다") + private String phoneNumber; + + @Email(message = "이메일 형식이 올바르지 않습니다") + private String email; + + @Builder.Default + private String channel = "SNS"; + + @Builder.Default + private Boolean agreeMarketing = false; + + @NotNull(message = "개인정보 수집 및 이용 동의는 필수입니다") + private Boolean agreePrivacy; + + @Builder.Default + private Boolean storeVisited = false; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java new file mode 100644 index 0000000..9ffeec4 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java @@ -0,0 +1,42 @@ +package com.kt.event.participation.application.dto; + +import com.kt.event.participation.domain.participant.Participant; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 이벤트 참여 응답 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ParticipationResponse { + + private String participantId; + private String eventId; + private String name; + private String phoneNumber; + private String email; + private String channel; + private LocalDateTime participatedAt; + private Boolean storeVisited; + private Integer bonusEntries; + private Boolean isWinner; + + public static ParticipationResponse from(Participant participant) { + return ParticipationResponse.builder() + .participantId(participant.getParticipantId()) + .eventId(participant.getEventId()) + .name(participant.getName()) + .phoneNumber(participant.getPhoneNumber()) + .email(participant.getEmail()) + .channel(participant.getChannel()) + .participatedAt(participant.getCreatedAt()) + .storeVisited(participant.getStoreVisited()) + .bonusEntries(participant.getBonusEntries()) + .isWinner(participant.getIsWinner()) + .build(); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java new file mode 100644 index 0000000..2cfe768 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java @@ -0,0 +1,150 @@ +package com.kt.event.participation.application.service; + +import com.kt.event.common.dto.PageResponse; +import com.kt.event.participation.application.dto.ParticipationRequest; +import com.kt.event.participation.application.dto.ParticipationResponse; +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import com.kt.event.participation.exception.ParticipationException.*; +import static com.kt.event.participation.exception.ParticipationException.EventNotFoundException; +import static com.kt.event.participation.exception.ParticipationException.ParticipantNotFoundException; +import com.kt.event.participation.infrastructure.kafka.KafkaProducerService; +import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 이벤트 참여 서비스 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ParticipationService { + + private final ParticipantRepository participantRepository; + private final KafkaProducerService kafkaProducerService; + + /** + * 이벤트 참여 + * + * @param eventId 이벤트 ID + * @param request 참여 요청 + * @return 참여 응답 + */ + @Transactional + public ParticipationResponse participate(String eventId, ParticipationRequest request) { + log.info("이벤트 참여 시작 - eventId: {}, phoneNumber: {}", eventId, request.getPhoneNumber()); + + // 중복 참여 체크 - 상세 디버깅 + log.info("중복 참여 체크 시작 - eventId: '{}', phoneNumber: '{}'", eventId, request.getPhoneNumber()); + + boolean isDuplicate = participantRepository.existsByEventIdAndPhoneNumber(eventId, request.getPhoneNumber()); + log.info("중복 참여 체크 결과 - isDuplicate: {}", isDuplicate); + + if (isDuplicate) { + log.warn("중복 참여 감지! eventId: '{}', phoneNumber: '{}'", eventId, request.getPhoneNumber()); + throw new DuplicateParticipationException(); + } + + log.info("중복 참여 체크 통과 - 참여 진행"); + + // 참여자 ID 생성 - 날짜별 최대 순번 기반 + String dateTime; + if (eventId != null && eventId.length() >= 16 && eventId.startsWith("evt_")) { + dateTime = eventId.substring(4, 12); // "20250124" + } else { + dateTime = java.time.LocalDate.now().format( + java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + String datePrefix = "prt_" + dateTime + "_"; + Integer maxSequence = participantRepository.findMaxSequenceByDatePrefix(datePrefix); + String participantId = String.format("prt_%s_%03d", dateTime, maxSequence + 1); + + // 참여자 저장 + Participant participant = Participant.builder() + .participantId(participantId) + .eventId(eventId) + .name(request.getName()) + .phoneNumber(request.getPhoneNumber()) + .email(request.getEmail()) + .channel(request.getChannel()) + .storeVisited(request.getStoreVisited()) + .bonusEntries(Participant.calculateBonusEntries(request.getStoreVisited())) + .agreeMarketing(request.getAgreeMarketing()) + .agreePrivacy(request.getAgreePrivacy()) + .isWinner(false) + .build(); + + participant = participantRepository.save(participant); + log.info("참여자 저장 완료 - participantId: {}", participantId); + + // Kafka 이벤트 발행 + kafkaProducerService.publishParticipantRegistered( + ParticipantRegisteredEvent.from(participant) + ); + + return ParticipationResponse.from(participant); + } + + /** + * 참여자 목록 조회 + * + * @param eventId 이벤트 ID + * @param storeVisited 매장 방문 여부 필터 (nullable) + * @param pageable 페이징 정보 + * @return 참여자 목록 + */ + @Transactional(readOnly = true) + public PageResponse getParticipants( + String eventId, Boolean storeVisited, Pageable pageable) { + + Page participantPage; + if (storeVisited != null) { + participantPage = participantRepository + .findByEventIdAndStoreVisitedOrderByCreatedAtDesc(eventId, storeVisited, pageable); + } else { + participantPage = participantRepository + .findByEventIdOrderByCreatedAtDesc(eventId, pageable); + } + + Page responsePage = participantPage.map(ParticipationResponse::from); + return PageResponse.of(responsePage); + } + + /** + * 참여자 상세 조회 + * + * @param eventId 이벤트 ID + * @param participantId 참여자 ID + * @return 참여자 정보 + */ + @Transactional(readOnly = true) + public ParticipationResponse getParticipant(String eventId, String participantId) { + // 참여자 조회 + Optional participantOpt = participantRepository + .findByEventIdAndParticipantId(eventId, participantId); + + // 참여자가 없으면 이벤트 존재 여부 확인 + if (participantOpt.isEmpty()) { + long participantCount = participantRepository.countByEventId(eventId); + if (participantCount == 0) { + // 이벤트에 참여자가 한 명도 없음 = 이벤트가 존재하지 않음 + throw new EventNotFoundException(); + } + // 이벤트는 존재하지만 해당 참여자가 없음 + throw new ParticipantNotFoundException(); + } + + return ParticipationResponse.from(participantOpt.get()); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java b/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java new file mode 100644 index 0000000..68cb4e0 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java @@ -0,0 +1,158 @@ +package com.kt.event.participation.application.service; + +import com.kt.event.common.dto.PageResponse; +import com.kt.event.participation.application.dto.DrawWinnersRequest; +import com.kt.event.participation.application.dto.DrawWinnersResponse; +import com.kt.event.participation.application.dto.DrawWinnersResponse.WinnerSummary; +import com.kt.event.participation.application.dto.ParticipationResponse; +import com.kt.event.participation.domain.draw.DrawLog; +import com.kt.event.participation.domain.draw.DrawLogRepository; +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import com.kt.event.participation.exception.ParticipationException.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 당첨자 추첨 서비스 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WinnerDrawService { + + private final ParticipantRepository participantRepository; + private final DrawLogRepository drawLogRepository; + + /** + * 당첨자 추첨 + * + * @param eventId 이벤트 ID + * @param request 추첨 요청 + * @return 추첨 결과 + */ + @Transactional + public DrawWinnersResponse drawWinners(String eventId, DrawWinnersRequest request) { + log.info("당첨자 추첨 시작 - eventId: {}, winnerCount: {}", eventId, request.getWinnerCount()); + + // 이미 추첨이 완료되었는지 확인 + if (drawLogRepository.existsByEventId(eventId)) { + throw new AlreadyDrawnException(); + } + + // 참여자 목록 조회 + List participants = participantRepository.findByEventIdAndIsWinnerFalse(eventId); + long participantCount = participants.size(); + + // 참여자 수 검증 + if (participantCount < request.getWinnerCount()) { + throw new InsufficientParticipantsException(participantCount, request.getWinnerCount()); + } + + // 가중치 적용 추첨 풀 생성 + List drawPool = createDrawPool(participants, request.getApplyStoreVisitBonus()); + + // 추첨 실행 + Collections.shuffle(drawPool); + List winners = drawPool.stream() + .distinct() + .limit(request.getWinnerCount()) + .collect(Collectors.toList()); + + // 당첨자 업데이트 + LocalDateTime now = LocalDateTime.now(); + for (int i = 0; i < winners.size(); i++) { + winners.get(i).markAsWinner(i + 1); + } + participantRepository.saveAll(winners); + + // 추첨 로그 저장 + DrawLog drawLog = DrawLog.builder() + .eventId(eventId) + .totalParticipants((int) participantCount) + .winnerCount(request.getWinnerCount()) + .applyStoreVisitBonus(request.getApplyStoreVisitBonus()) + .algorithm("WEIGHTED_RANDOM") + .drawnAt(now) + .drawnBy("SYSTEM") + .build(); + drawLogRepository.save(drawLog); + + log.info("당첨자 추첨 완료 - eventId: {}, winners: {}", eventId, winners.size()); + + // 응답 생성 + List winnerSummaries = winners.stream() + .map(w -> WinnerSummary.builder() + .participantId(w.getParticipantId()) + .name(w.getName()) + .phoneNumber(w.getPhoneNumber()) + .rank(w.getWinnerRank()) + .build()) + .collect(Collectors.toList()); + + return DrawWinnersResponse.builder() + .eventId(eventId) + .totalParticipants((int) participantCount) + .winnerCount(winners.size()) + .drawnAt(now) + .winners(winnerSummaries) + .build(); + } + + /** + * 당첨자 목록 조회 + * + * @param eventId 이벤트 ID + * @param pageable 페이징 정보 + * @return 당첨자 목록 + */ + @Transactional(readOnly = true) + public PageResponse getWinners(String eventId, Pageable pageable) { + // 추첨 완료 확인 + if (!drawLogRepository.existsByEventId(eventId)) { + throw new NoWinnersYetException(); + } + + Page winnerPage = participantRepository + .findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(eventId, pageable); + + Page responsePage = winnerPage.map(ParticipationResponse::from); + return PageResponse.of(responsePage); + } + + /** + * 추첨 풀 생성 (매장 방문 보너스 적용) + * + * @param participants 참여자 목록 + * @param applyBonus 보너스 적용 여부 + * @return 추첨 풀 + */ + private List createDrawPool(List participants, Boolean applyBonus) { + if (!applyBonus) { + return new ArrayList<>(participants); + } + + List pool = new ArrayList<>(); + for (Participant participant : participants) { + // 보너스 응모권 수만큼 추첨 풀에 추가 + int entries = participant.getBonusEntries(); + for (int i = 0; i < entries; i++) { + pool.add(participant); + } + } + return pool; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java new file mode 100644 index 0000000..fb0fad9 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java @@ -0,0 +1,71 @@ +package com.kt.event.participation.domain.draw; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +/** + * 당첨자 추첨 로그 엔티티 + * 추첨 이력 관리 및 재추첨 방지 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Entity +@Table(name = "draw_logs", + indexes = { + @Index(name = "idx_draw_log_event_id", columnList = "event_id") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class DrawLog extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 이벤트 ID + */ + @Column(name = "event_id", nullable = false, length = 50) + private String eventId; + + /** + * 전체 참여자 수 + */ + @Column(name = "total_participants", nullable = false) + private Integer totalParticipants; + + /** + * 당첨자 수 + */ + @Column(name = "winner_count", nullable = false) + private Integer winnerCount; + + /** + * 매장 방문 보너스 적용 여부 + */ + @Column(name = "apply_store_visit_bonus", nullable = false) + private Boolean applyStoreVisitBonus; + + /** + * 추첨 알고리즘 + */ + @Column(name = "algorithm", nullable = false, length = 50) + private String algorithm; + + /** + * 추첨 일시 + */ + @Column(name = "drawn_at", nullable = false) + private java.time.LocalDateTime drawnAt; + + /** + * 추첨 실행자 ID (관리자 또는 시스템) + */ + @Column(name = "drawn_by", length = 50) + private String drawnBy; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java new file mode 100644 index 0000000..432aa6e --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java @@ -0,0 +1,33 @@ +package com.kt.event.participation.domain.draw; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 추첨 로그 리포지토리 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Repository +public interface DrawLogRepository extends JpaRepository { + + /** + * 이벤트 ID로 추첨 로그 조회 + * 이미 추첨이 진행되었는지 확인 + * + * @param eventId 이벤트 ID + * @return 추첨 로그 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID로 추첨 여부 확인 + * + * @param eventId 이벤트 ID + * @return 추첨 여부 + */ + boolean existsByEventId(String eventId); +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java new file mode 100644 index 0000000..5ee4fa5 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java @@ -0,0 +1,181 @@ +package com.kt.event.participation.domain.participant; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +/** + * 이벤트 참여자 엔티티 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Entity +@Table(name = "participants", + indexes = { + @Index(name = "idx_participant_event_id", columnList = "event_id"), + @Index(name = "idx_participant_event_phone", columnList = "event_id, phone_number") + + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_event_phone", columnNames = {"event_id", "phone_number"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Participant extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 참여자 ID (외부 노출용) + * 예: prt_20250123_001 + */ + @Column(name = "participant_id", nullable = false, unique = true, length = 50) + private String participantId; + + /** + * 이벤트 ID + * Event Service의 이벤트 식별자 + */ + @Column(name = "event_id", nullable = false, length = 50) + private String eventId; + + /** + * 참여자 이름 + */ + @Column(name = "name", nullable = false, length = 50) + private String name; + + /** + * 참여자 전화번호 + * 중복 참여 체크 키로 사용 + */ + @Column(name = "phone_number", nullable = false, length = 20) + private String phoneNumber; + + /** + * 참여자 이메일 + */ + @Column(name = "email", length = 100) + private String email; + + /** + * 참여 채널 + * 기본값: SNS + * TODO: 기존 데이터 마이그레이션 후 nullable = false로 변경 + */ + @Column(name = "channel", length = 20, nullable = true) + private String channel; + + /** + * 매장 방문 여부 + * true일 경우 보너스 응모권 부여 + */ + @Column(name = "store_visited", nullable = false) + private Boolean storeVisited; + + /** + * 보너스 응모권 수 + * 기본 1, 매장 방문 시 +1 + */ + @Column(name = "bonus_entries", nullable = false) + private Integer bonusEntries; + + /** + * 마케팅 정보 수신 동의 + */ + @Column(name = "agree_marketing", nullable = false) + private Boolean agreeMarketing; + + /** + * 개인정보 수집 및 이용 동의 (필수) + */ + @Column(name = "agree_privacy", nullable = false) + private Boolean agreePrivacy; + + /** + * 당첨 여부 + */ + @Column(name = "is_winner", nullable = false) + private Boolean isWinner; + + /** + * 당첨 순위 (당첨자일 경우) + */ + @Column(name = "winner_rank") + private Integer winnerRank; + + /** + * 당첨 일시 + */ + @Column(name = "won_at") + private java.time.LocalDateTime wonAt; + + /** + * 참여자 ID 생성 + * + * @param eventId 이벤트 ID + * @param sequenceNumber 순번 + * @return 생성된 참여자 ID + */ + public static String generateParticipantId(String eventId, Long sequenceNumber) { + // eventId가 "evt_YYYYMMDD_XXX" 형식인 경우 + if (eventId != null && eventId.length() >= 16 && eventId.startsWith("evt_")) { + String dateTime = eventId.substring(4, 12); // "20250124" + return String.format("prt_%s_%03d", dateTime, sequenceNumber); + } + + // 그 외의 경우 (짧은 eventId 등): 현재 날짜 사용 + String dateTime = java.time.LocalDate.now().format( + java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + return String.format("prt_%s_%03d", dateTime, sequenceNumber); + } + + /** + * 보너스 응모권 계산 + * + * @param storeVisited 매장 방문 여부 + * @return 보너스 응모권 수 + */ + public static Integer calculateBonusEntries(Boolean storeVisited) { + return storeVisited ? 5 : 1; + } + + /** + * 당첨자로 설정 + * + * @param rank 당첨 순위 + */ + public void markAsWinner(Integer rank) { + this.isWinner = true; + this.winnerRank = rank; + this.wonAt = java.time.LocalDateTime.now(); + } + + /** + * 참여자 생성 전 유효성 검증 + */ + @PrePersist + public void prePersist() { + if (this.agreePrivacy == null || !this.agreePrivacy) { + throw new IllegalStateException("개인정보 수집 및 이용 동의는 필수입니다"); + } + if (this.bonusEntries == null) { + this.bonusEntries = calculateBonusEntries(this.storeVisited); + } + if (this.isWinner == null) { + this.isWinner = false; + } + if (this.agreeMarketing == null) { + this.agreeMarketing = false; + } + if (this.channel == null || this.channel.isBlank()) { + this.channel = "SNS"; + } + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java new file mode 100644 index 0000000..e03560f --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java @@ -0,0 +1,121 @@ +package com.kt.event.participation.domain.participant; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 참여자 리포지토리 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Repository +public interface ParticipantRepository extends JpaRepository { + + /** + * 참여자 ID로 조회 + * + * @param participantId 참여자 ID + * @return 참여자 Optional + */ + Optional findByParticipantId(String participantId); + + /** + * 이벤트 ID와 전화번호로 중복 참여 체크 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @return 참여 여부 + */ + boolean existsByEventIdAndPhoneNumber(String eventId, String phoneNumber); + + /** + * 이벤트 ID로 참여자 목록 조회 (페이징) + * + * @param eventId 이벤트 ID + * @param pageable 페이징 정보 + * @return 참여자 페이지 + */ + Page findByEventIdOrderByCreatedAtDesc(String eventId, Pageable pageable); + + /** + * 이벤트 ID와 매장 방문 여부로 참여자 목록 조회 (페이징) + * + * @param eventId 이벤트 ID + * @param storeVisited 매장 방문 여부 + * @param pageable 페이징 정보 + * @return 참여자 페이지 + */ + Page findByEventIdAndStoreVisitedOrderByCreatedAtDesc( + String eventId, Boolean storeVisited, Pageable pageable); + + /** + * 이벤트 ID로 전체 참여자 수 조회 + * + * @param eventId 이벤트 ID + * @return 참여자 수 + */ + long countByEventId(String eventId); + + /** + * 이벤트 ID로 당첨자 목록 조회 (페이징) + * + * @param eventId 이벤트 ID + * @param pageable 페이징 정보 + * @return 당첨자 페이지 + */ + Page findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(String eventId, Pageable pageable); + + /** + * 이벤트 ID로 당첨자 수 조회 + * + * @param eventId 이벤트 ID + * @return 당첨자 수 + */ + long countByEventIdAndIsWinnerTrue(String eventId); + + /** + * 이벤트 ID로 참여자 ID 최대값 조회 (순번 생성용) + * + * @param eventId 이벤트 ID + * @return 최대 ID + */ + @Query("SELECT MAX(p.id) FROM Participant p WHERE p.eventId = :eventId") + Optional findMaxIdByEventId(@Param("eventId") String eventId); + + /** + * 이벤트 ID로 비당첨자 목록 조회 (추첨용) + * + * @param eventId 이벤트 ID + * @return 비당첨자 목록 + */ + List findByEventIdAndIsWinnerFalse(String eventId); + + /** + * 이벤트 ID와 참여자 ID로 조회 + * + * @param eventId 이벤트 ID + * @param participantId 참여자 ID + * @return 참여자 Optional + */ + Optional findByEventIdAndParticipantId(String eventId, String participantId); + + /** + * 특정 날짜 패턴의 참여자 ID 중 최대 순번 조회 + * + * @param datePrefix 날짜 접두사 (예: "prt_20251028_") + * @return 최대 순번 + */ + @Query(value = "SELECT COALESCE(MAX(CAST(SUBSTRING(participant_id FROM LENGTH(?1) + 1) AS INTEGER)), 0) " + + "FROM participants " + + "WHERE participant_id LIKE CONCAT(?1, '%')", + nativeQuery = true) + Integer findMaxSequenceByDatePrefix(@Param("datePrefix") String datePrefix); +} diff --git a/participation-service/src/main/java/com/kt/event/participation/exception/ParticipationException.java b/participation-service/src/main/java/com/kt/event/participation/exception/ParticipationException.java new file mode 100644 index 0000000..0561e05 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/exception/ParticipationException.java @@ -0,0 +1,85 @@ +package com.kt.event.participation.exception; + +import com.kt.event.common.exception.BusinessException; +import com.kt.event.common.exception.ErrorCode; + +/** + * 참여 관련 비즈니스 예외 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +public class ParticipationException extends BusinessException { + + public ParticipationException(ErrorCode errorCode) { + super(errorCode); + } + + public ParticipationException(ErrorCode errorCode, String message) { + super(errorCode, message); + } + + /** + * 중복 참여 예외 + */ + public static class DuplicateParticipationException extends ParticipationException { + public DuplicateParticipationException() { + super(ErrorCode.DUPLICATE_PARTICIPATION, "이미 참여하신 이벤트입니다"); + } + } + + /** + * 이벤트를 찾을 수 없음 예외 + */ + public static class EventNotFoundException extends ParticipationException { + public EventNotFoundException() { + super(ErrorCode.EVENT_001, "이벤트를 찾을 수 없습니다"); + } + } + + /** + * 이벤트가 활성 상태가 아님 예외 + */ + public static class EventNotActiveException extends ParticipationException { + public EventNotActiveException() { + super(ErrorCode.EVENT_NOT_ACTIVE, "현재 참여할 수 없는 이벤트입니다"); + } + } + + /** + * 참여자를 찾을 수 없음 예외 + */ + public static class ParticipantNotFoundException extends ParticipationException { + public ParticipantNotFoundException() { + super(ErrorCode.PARTICIPANT_NOT_FOUND, "참여자를 찾을 수 없습니다"); + } + } + + /** + * 이미 추첨이 완료됨 예외 + */ + public static class AlreadyDrawnException extends ParticipationException { + public AlreadyDrawnException() { + super(ErrorCode.ALREADY_DRAWN, "이미 당첨자 추첨이 완료되었습니다"); + } + } + + /** + * 참여자 수 부족 예외 + */ + public static class InsufficientParticipantsException extends ParticipationException { + public InsufficientParticipantsException(long participantCount, int winnerCount) { + super(ErrorCode.INSUFFICIENT_PARTICIPANTS, + String.format("참여자 수(%d)가 당첨자 수(%d)보다 적습니다", participantCount, winnerCount)); + } + } + + /** + * 당첨자가 없음 예외 + */ + public static class NoWinnersYetException extends ParticipationException { + public NoWinnersYetException() { + super(ErrorCode.NO_WINNERS_YET, "아직 당첨자 추첨이 진행되지 않았습니다"); + } + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java new file mode 100644 index 0000000..855ba0f --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java @@ -0,0 +1,34 @@ +package com.kt.event.participation.infrastructure.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.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security Configuration for Participation Service + * 이벤트 참여 API는 공개 API로 인증 불필요 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // Actuator endpoints + .requestMatchers("/actuator/**").permitAll() + .anyRequest().permitAll() + ); + + return http.build(); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java new file mode 100644 index 0000000..d2e8f61 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java @@ -0,0 +1,39 @@ +package com.kt.event.participation.infrastructure.kafka; + +import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +/** + * Kafka Producer 서비스 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KafkaProducerService { + + private static final String PARTICIPANT_REGISTERED_TOPIC = "participant-registered-events"; + + private final KafkaTemplate kafkaTemplate; + + /** + * 참여자 등록 이벤트 발행 + * + * @param event 참여자 등록 이벤트 + */ + public void publishParticipantRegistered(ParticipantRegisteredEvent event) { + try { + kafkaTemplate.send(PARTICIPANT_REGISTERED_TOPIC, event.getEventId(), event); + log.info("Kafka 이벤트 발행 성공 - topic: {}, participantId: {}", + PARTICIPANT_REGISTERED_TOPIC, event.getParticipantId()); + } catch (Exception e) { + log.error("Kafka 이벤트 발행 실패 - participantId: {}", event.getParticipantId(), e); + // 이벤트 발행 실패는 서비스 로직에 영향을 주지 않음 + } + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java new file mode 100644 index 0000000..25ea454 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java @@ -0,0 +1,41 @@ +package com.kt.event.participation.infrastructure.kafka.event; + +import com.kt.event.participation.domain.participant.Participant; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 참여자 등록 Kafka 이벤트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ParticipantRegisteredEvent { + + private String participantId; + private String eventId; + private String name; + private String phoneNumber; + private String channel; + private Boolean storeVisited; + private Integer bonusEntries; + private LocalDateTime participatedAt; + + public static ParticipantRegisteredEvent from(Participant participant) { + return ParticipantRegisteredEvent.builder() + .participantId(participant.getParticipantId()) + .eventId(participant.getEventId()) + .name(participant.getName()) + .phoneNumber(participant.getPhoneNumber()) + .channel(participant.getChannel()) + .storeVisited(participant.getStoreVisited()) + .bonusEntries(participant.getBonusEntries()) + .participatedAt(participant.getCreatedAt()) + .build(); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/DebugController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/DebugController.java new file mode 100644 index 0000000..a186d0f --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/DebugController.java @@ -0,0 +1,104 @@ +package com.kt.event.participation.presentation.controller; + +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 디버깅용 컨트롤러 + */ +@Slf4j +@CrossOrigin(origins = "http://localhost:3000") +@RestController +@RequestMapping("/debug") +@RequiredArgsConstructor +public class DebugController { + + private final ParticipantRepository participantRepository; + + /** + * 중복 참여 체크 테스트 + */ + @GetMapping("/exists/{eventId}/{phoneNumber}") + public String testExists(@PathVariable String eventId, @PathVariable String phoneNumber) { + try { + log.info("디버그: 중복 체크 시작 - eventId: {}, phoneNumber: {}", eventId, phoneNumber); + + boolean exists = participantRepository.existsByEventIdAndPhoneNumber(eventId, phoneNumber); + + log.info("디버그: 중복 체크 결과 - exists: {}", exists); + + long totalCount = participantRepository.count(); + long eventCount = participantRepository.countByEventId(eventId); + + return String.format( + "eventId: %s, phoneNumber: %s, exists: %s, totalCount: %d, eventCount: %d", + eventId, phoneNumber, exists, totalCount, eventCount + ); + + } catch (Exception e) { + log.error("디버그: 예외 발생", e); + return "ERROR: " + e.getMessage(); + } + } + + /** + * 모든 참여자 데이터 조회 + */ + @GetMapping("/participants") + public String getAllParticipants() { + try { + List participants = participantRepository.findAll(); + + StringBuilder sb = new StringBuilder(); + sb.append("Total participants: ").append(participants.size()).append("\n\n"); + + for (Participant p : participants) { + sb.append(String.format("ID: %s, EventID: %s, Phone: %s, Name: %s\n", + p.getParticipantId(), p.getEventId(), p.getPhoneNumber(), p.getName())); + } + + return sb.toString(); + + } catch (Exception e) { + log.error("디버그: 참여자 조회 예외 발생", e); + return "ERROR: " + e.getMessage(); + } + } + + /** + * 특정 전화번호의 참여 이력 조회 + */ + @GetMapping("/phone/{phoneNumber}") + public String getByPhoneNumber(@PathVariable String phoneNumber) { + try { + List participants = participantRepository.findAll(); + + StringBuilder sb = new StringBuilder(); + sb.append("Participants with phone: ").append(phoneNumber).append("\n\n"); + + int count = 0; + for (Participant p : participants) { + if (phoneNumber.equals(p.getPhoneNumber())) { + sb.append(String.format("ID: %s, EventID: %s, Name: %s\n", + p.getParticipantId(), p.getEventId(), p.getName())); + count++; + } + } + + if (count == 0) { + sb.append("No participants found with this phone number."); + } + + return sb.toString(); + + } catch (Exception e) { + log.error("디버그: 전화번호별 조회 예외 발생", e); + return "ERROR: " + e.getMessage(); + } + } +} \ No newline at end of file diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java new file mode 100644 index 0000000..078f913 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java @@ -0,0 +1,104 @@ +package com.kt.event.participation.presentation.controller; + +import com.kt.event.common.dto.ApiResponse; +import com.kt.event.common.dto.PageResponse; +import com.kt.event.participation.application.dto.ParticipationRequest; +import com.kt.event.participation.application.dto.ParticipationResponse; +import com.kt.event.participation.application.service.ParticipationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 이벤트 참여 컨트롤러 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Slf4j +@CrossOrigin(origins = "http://localhost:3000") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class ParticipationController { + + private final ParticipationService participationService; + + /** + * 이벤트 참여 + * POST /events/{eventId}/participate + */ + @PostMapping("/events/{eventId}/participate") + public ResponseEntity> participate( + @PathVariable String eventId, + @Valid @RequestBody ParticipationRequest request) { + + log.info("컨트롤러: 이벤트 참여 요청 시작 - eventId: '{}', phoneNumber: '{}'", eventId, request.getPhoneNumber()); + + try { + log.info("컨트롤러: 서비스 호출 전"); + ParticipationResponse response = participationService.participate(eventId, request); + log.info("컨트롤러: 서비스 호출 완료 - participantId: {}", response.getParticipantId()); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(ApiResponse.success(response)); + + } catch (Exception e) { + log.error("컨트롤러: 예외 발생 - type: {}, message: {}", e.getClass().getSimpleName(), e.getMessage()); + throw e; + } + } + + /** + * 참여자 목록 조회 + * GET /events/{eventId}/participants + */ + @Operation( + summary = "참여자 목록 조회", + description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " + + "정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt" + ) + @GetMapping("/events/{eventId}/participants") + public ResponseEntity>> getParticipants( + @Parameter(description = "이벤트 ID", example = "evt_20250124_001") + @PathVariable String eventId, + + @Parameter(description = "매장 방문 여부 필터 (true: 방문자만, false: 미방문자만, null: 전체)") + @RequestParam(required = false) Boolean storeVisited, + + @ParameterObject + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable) { + + log.info("참여자 목록 조회 요청 - eventId: {}, storeVisited: {}", eventId, storeVisited); + PageResponse response = + participationService.getParticipants(eventId, storeVisited, pageable); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 참여자 상세 조회 + * GET /events/{eventId}/participants/{participantId} + */ + @GetMapping("/events/{eventId}/participants/{participantId}") + public ResponseEntity> getParticipant( + @PathVariable String eventId, + @PathVariable String participantId) { + + log.info("참여자 상세 조회 요청 - eventId: {}, participantId: {}", eventId, participantId); + ParticipationResponse response = participationService.getParticipant(eventId, participantId); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java new file mode 100644 index 0000000..3adf1fe --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java @@ -0,0 +1,74 @@ +package com.kt.event.participation.presentation.controller; + +import com.kt.event.common.dto.ApiResponse; +import com.kt.event.common.dto.PageResponse; +import com.kt.event.participation.application.dto.DrawWinnersRequest; +import com.kt.event.participation.application.dto.DrawWinnersResponse; +import com.kt.event.participation.application.dto.ParticipationResponse; +import com.kt.event.participation.application.service.WinnerDrawService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 당첨자 추첨 컨트롤러 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Slf4j +@CrossOrigin(origins = "http://localhost:3000") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class WinnerController { + + private final WinnerDrawService winnerDrawService; + + /** + * 당첨자 추첨 + * POST /events/{eventId}/draw-winners + */ + @PostMapping("/events/{eventId}/draw-winners") + public ResponseEntity> drawWinners( + @PathVariable String eventId, + @Valid @RequestBody DrawWinnersRequest request) { + + log.info("당첨자 추첨 요청 - eventId: {}, winnerCount: {}", eventId, request.getWinnerCount()); + DrawWinnersResponse response = winnerDrawService.drawWinners(eventId, request); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 당첨자 목록 조회 + * GET /events/{eventId}/winners + */ + @Operation( + summary = "당첨자 목록 조회", + description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " + + "정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries" + ) + @GetMapping("/events/{eventId}/winners") + public ResponseEntity>> getWinners( + @Parameter(description = "이벤트 ID", example = "evt_20250124_001") + @PathVariable String eventId, + + @ParameterObject + @PageableDefault(size = 20, sort = "winnerRank", direction = Sort.Direction.ASC) + Pageable pageable) { + + log.info("당첨자 목록 조회 요청 - eventId: {}", eventId); + PageResponse response = winnerDrawService.getWinners(eventId, pageable); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/participation-service/src/main/resources/application.yml b/participation-service/src/main/resources/application.yml new file mode 100644 index 0000000..dcc2575 --- /dev/null +++ b/participation-service/src/main/resources/application.yml @@ -0,0 +1,91 @@ +spring: + application: + name: participation-service + + # 데이터베이스 설정 + datasource: + url: jdbc:postgresql://${DB_HOST:4.230.72.147}:${DB_PORT:5432}/${DB_NAME:participationdb} + username: ${DB_USERNAME:eventuser} + password: ${DB_PASSWORD:Hi5Jessica!} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + # JPA 설정 + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + default_batch_fetch_size: 100 + + # Redis 설정 + data: + redis: + host: ${REDIS_HOST:20.214.210.71} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:Hi5Jessica!} + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 2 + max-wait: -1ms + + # Kafka 설정 + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.217.131.59:9095} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: all + retries: 3 + +# JWT 설정 +jwt: + secret: ${JWT_SECRET:dev-jwt-secret-key-for-development-only} + expiration: ${JWT_EXPIRATION:86400000} + +# 서버 설정 +server: + port: ${SERVER_PORT:8084} + +# 로깅 설정 +logging: + level: + com.kt.event.participation: ${LOG_LEVEL:INFO} + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.springframework.kafka: DEBUG + org.apache.kafka: DEBUG + file: + name: ${LOG_FILE:logs/participation-service.log} + logback: + rollingpolicy: + max-file-size: 10MB + max-history: 7 + total-size-cap: 100MB +# Actuator +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + base-path: /actuator + endpoint: + health: + show-details: always + show-components: always + health: + livenessState: + enabled: true + readinessState: + enabled: true \ No newline at end of file diff --git a/participation-service/src/test/java/com/kt/event/participation/test/integration/DrawLogRepositoryIntegrationTest.java b/participation-service/src/test/java/com/kt/event/participation/test/integration/DrawLogRepositoryIntegrationTest.java new file mode 100644 index 0000000..32881dc --- /dev/null +++ b/participation-service/src/test/java/com/kt/event/participation/test/integration/DrawLogRepositoryIntegrationTest.java @@ -0,0 +1,167 @@ +package com.kt.event.participation.test.integration; + +import com.kt.event.participation.domain.draw.DrawLog; +import com.kt.event.participation.domain.draw.DrawLogRepository; +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.orm.jpa.DataJpaTest; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * DrawLogRepository 통합 테스트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@DataJpaTest +@DisplayName("DrawLogRepository 통합 테스트") +class DrawLogRepositoryIntegrationTest { + + @Autowired + private DrawLogRepository drawLogRepository; + + // 테스트 데이터 상수 + private static final String VALID_EVENT_ID = "evt_20250124_001"; + private static final Integer TOTAL_PARTICIPANTS = 100; + private static final Integer WINNER_COUNT = 10; + private static final String ALGORITHM = "WEIGHTED_RANDOM"; + private static final String DRAWN_BY = "SYSTEM"; + + @BeforeEach + void setUp() { + drawLogRepository.deleteAll(); + } + + @Test + @DisplayName("추첨 로그를 저장하면 정상적으로 조회할 수 있다") + void givenDrawLog_whenSave_thenCanRetrieve() { + // Given + DrawLog drawLog = createDrawLog(VALID_EVENT_ID, true); + + // When + DrawLog saved = drawLogRepository.save(drawLog); + + // Then + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getEventId()).isEqualTo(VALID_EVENT_ID); + assertThat(saved.getTotalParticipants()).isEqualTo(TOTAL_PARTICIPANTS); + assertThat(saved.getWinnerCount()).isEqualTo(WINNER_COUNT); + } + + @Test + @DisplayName("이벤트 ID로 추첨 로그를 조회할 수 있다") + void givenSavedDrawLog_whenFindByEventId_thenReturnDrawLog() { + // Given + DrawLog drawLog = createDrawLog(VALID_EVENT_ID, true); + drawLogRepository.save(drawLog); + + // When + Optional found = drawLogRepository.findByEventId(VALID_EVENT_ID); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getEventId()).isEqualTo(VALID_EVENT_ID); + assertThat(found.get().getApplyStoreVisitBonus()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 이벤트 ID로 조회하면 Empty가 반환된다") + void givenNoDrawLog_whenFindByEventId_thenReturnEmpty() { + // Given + String nonExistentEventId = "evt_99999999_999"; + + // When + Optional found = drawLogRepository.findByEventId(nonExistentEventId); + + // Then + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("이벤트 ID로 추첨 여부를 확인할 수 있다") + void givenSavedDrawLog_whenExistsByEventId_thenReturnTrue() { + // Given + DrawLog drawLog = createDrawLog(VALID_EVENT_ID, false); + drawLogRepository.save(drawLog); + + // When + boolean exists = drawLogRepository.existsByEventId(VALID_EVENT_ID); + + // Then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("추첨이 없는 이벤트 ID로 확인하면 false가 반환된다") + void givenNoDrawLog_whenExistsByEventId_thenReturnFalse() { + // Given + String nonExistentEventId = "evt_99999999_999"; + + // When + boolean exists = drawLogRepository.existsByEventId(nonExistentEventId); + + // Then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("매장 방문 보너스 미적용 추첨 로그를 저장할 수 있다") + void givenDrawLogWithoutBonus_whenSave_thenCanRetrieve() { + // Given + DrawLog drawLog = createDrawLog(VALID_EVENT_ID, false); + + // When + DrawLog saved = drawLogRepository.save(drawLog); + + // Then + assertThat(saved.getApplyStoreVisitBonus()).isFalse(); + } + + @Test + @DisplayName("추첨 로그의 모든 필드가 정상적으로 저장된다") + void givenCompleteDrawLog_whenSave_thenAllFieldsPersisted() { + // Given + LocalDateTime now = LocalDateTime.now(); + DrawLog drawLog = DrawLog.builder() + .eventId(VALID_EVENT_ID) + .totalParticipants(TOTAL_PARTICIPANTS) + .winnerCount(WINNER_COUNT) + .applyStoreVisitBonus(true) + .algorithm(ALGORITHM) + .drawnAt(now) + .drawnBy(DRAWN_BY) + .build(); + + // When + DrawLog saved = drawLogRepository.save(drawLog); + + // Then + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getEventId()).isEqualTo(VALID_EVENT_ID); + assertThat(saved.getTotalParticipants()).isEqualTo(TOTAL_PARTICIPANTS); + assertThat(saved.getWinnerCount()).isEqualTo(WINNER_COUNT); + assertThat(saved.getApplyStoreVisitBonus()).isTrue(); + assertThat(saved.getAlgorithm()).isEqualTo(ALGORITHM); + assertThat(saved.getDrawnAt()).isEqualToIgnoringNanos(now); + assertThat(saved.getDrawnBy()).isEqualTo(DRAWN_BY); + } + + // 헬퍼 메서드 + private DrawLog createDrawLog(String eventId, boolean applyBonus) { + return DrawLog.builder() + .eventId(eventId) + .totalParticipants(TOTAL_PARTICIPANTS) + .winnerCount(WINNER_COUNT) + .applyStoreVisitBonus(applyBonus) + .algorithm(ALGORITHM) + .drawnAt(LocalDateTime.now()) + .drawnBy(DRAWN_BY) + .build(); + } +} diff --git a/participation-service/src/test/java/com/kt/event/participation/test/integration/KafkaEventPublishIntegrationTest.java b/participation-service/src/test/java/com/kt/event/participation/test/integration/KafkaEventPublishIntegrationTest.java new file mode 100644 index 0000000..08983d4 --- /dev/null +++ b/participation-service/src/test/java/com/kt/event/participation/test/integration/KafkaEventPublishIntegrationTest.java @@ -0,0 +1,173 @@ +package com.kt.event.participation.test.integration; + +import com.kt.event.participation.application.dto.ParticipationRequest; +import com.kt.event.participation.application.service.ParticipationService; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent; +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.kafka.test.utils.KafkaTestUtils; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Kafka 이벤트 발행 통합 테스트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@Disabled("Kafka producer가 embedded broker의 bootstrap servers를 사용하도록 설정 필요") +@SpringBootTest +@EmbeddedKafka(partitions = 1, topics = {"participant-registered-events"}, ports = {0}) +@DisplayName("Kafka 이벤트 발행 통합 테스트") +class KafkaEventPublishIntegrationTest { + + private static final String TOPIC = "participant-registered-events"; + private static final String TEST_EVENT_ID = "EVT-TEST-001"; + + @Autowired + private ParticipationService participationService; + + @Autowired + private ParticipantRepository participantRepository; + + @Autowired + private EmbeddedKafkaBroker embeddedKafka; + + private Consumer consumer; + + @BeforeEach + void setUp() { + // Kafka Consumer 설정 + Map consumerProps = KafkaTestUtils.consumerProps( + embeddedKafka.getBrokersAsString(), "test-group", "false"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + consumerProps.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + consumerProps.put(JsonDeserializer.VALUE_DEFAULT_TYPE, ParticipantRegisteredEvent.class); + consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest"); + + DefaultKafkaConsumerFactory consumerFactory = + new DefaultKafkaConsumerFactory<>(consumerProps); + consumer = consumerFactory.createConsumer(); + consumer.subscribe(Collections.singletonList(TOPIC)); + } + + @AfterEach + void tearDown() { + if (consumer != null) { + consumer.close(); + } + // 테스트 데이터 정리 + participantRepository.deleteAll(); + } + + @Test + @DisplayName("이벤트 참여 시 Kafka 이벤트가 발행되어야 한다") + void shouldPublishKafkaEventWhenParticipate() throws Exception { + // Given: 참여 요청 데이터 + ParticipationRequest request = ParticipationRequest.builder() + .name("테스트사용자") + .phoneNumber("01012345678") + .email("test@example.com") + .storeVisited(true) + .agreeMarketing(true) + .agreePrivacy(true) + .build(); + + // When: 이벤트 참여 + participationService.participate(TEST_EVENT_ID, request); + + // Then: Kafka 메시지 수신 확인 + ConsumerRecord record = + KafkaTestUtils.getSingleRecord(consumer, TOPIC, Duration.ofSeconds(10)); + + assertThat(record).isNotNull(); + assertThat(record.key()).isEqualTo(TEST_EVENT_ID); + + ParticipantRegisteredEvent event = record.value(); + assertThat(event).isNotNull(); + assertThat(event.getEventId()).isEqualTo(TEST_EVENT_ID); + assertThat(event.getName()).isEqualTo("테스트사용자"); + assertThat(event.getPhoneNumber()).isEqualTo("01012345678"); + assertThat(event.getStoreVisited()).isTrue(); + assertThat(event.getBonusEntries()).isEqualTo(5); + assertThat(event.getParticipatedAt()).isNotNull(); + } + + @Test + @DisplayName("매장 미방문 참여자의 이벤트가 발행되어야 한다") + void shouldPublishEventForNonStoreVisitor() throws Exception { + // Given: 매장 미방문 참여 요청 + ParticipationRequest request = ParticipationRequest.builder() + .name("온라인사용자") + .phoneNumber("01098765432") + .email("online@example.com") + .storeVisited(false) + .agreeMarketing(false) + .agreePrivacy(true) + .build(); + + // When: 이벤트 참여 + participationService.participate(TEST_EVENT_ID, request); + + // Then: Kafka 메시지 수신 확인 + ConsumerRecord record = + KafkaTestUtils.getSingleRecord(consumer, TOPIC, Duration.ofSeconds(10)); + + assertThat(record).isNotNull(); + + ParticipantRegisteredEvent event = record.value(); + assertThat(event.getStoreVisited()).isFalse(); + assertThat(event.getBonusEntries()).isEqualTo(1); + } + + @Test + @DisplayName("여러 참여자의 이벤트가 순차적으로 발행되어야 한다") + void shouldPublishMultipleEventsSequentially() throws Exception { + // Given: 3명의 참여자 + for (int i = 1; i <= 3; i++) { + ParticipationRequest request = ParticipationRequest.builder() + .name("참여자" + i) + .phoneNumber("0101234567" + i) + .email("user" + i + "@example.com") + .storeVisited(i % 2 == 0) + .agreeMarketing(true) + .agreePrivacy(true) + .build(); + + // When: 이벤트 참여 + participationService.participate(TEST_EVENT_ID, request); + } + + // Then: 3개의 Kafka 메시지 수신 확인 + for (int i = 1; i <= 3; i++) { + ConsumerRecord record = + KafkaTestUtils.getSingleRecord(consumer, TOPIC, Duration.ofSeconds(10)); + + assertThat(record).isNotNull(); + + ParticipantRegisteredEvent event = record.value(); + assertThat(event.getName()).startsWith("참여자"); + assertThat(event.getEventId()).isEqualTo(TEST_EVENT_ID); + } + } +} diff --git a/participation-service/src/test/java/com/kt/event/participation/test/integration/ParticipantRepositoryIntegrationTest.java b/participation-service/src/test/java/com/kt/event/participation/test/integration/ParticipantRepositoryIntegrationTest.java new file mode 100644 index 0000000..25c3ea6 --- /dev/null +++ b/participation-service/src/test/java/com/kt/event/participation/test/integration/ParticipantRepositoryIntegrationTest.java @@ -0,0 +1,324 @@ +package com.kt.event.participation.test.integration; + +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +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.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ParticipantRepository 통합 테스트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@DataJpaTest +@DisplayName("ParticipantRepository 통합 테스트") +class ParticipantRepositoryIntegrationTest { + + @Autowired + private ParticipantRepository participantRepository; + + // 테스트 데이터 상수 + private static final String VALID_EVENT_ID = "evt_20250124_001"; + private static final String VALID_NAME = "홍길동"; + private static final String VALID_PHONE = "010-1234-5678"; + private static final String VALID_EMAIL = "hong@test.com"; + + @BeforeEach + void setUp() { + participantRepository.deleteAll(); + } + + @Test + @DisplayName("참여자를 저장하면 정상적으로 조회할 수 있다") + void givenParticipant_whenSave_thenCanRetrieve() { + // Given + Participant participant = createValidParticipant(); + + // When + Participant saved = participantRepository.save(participant); + + // Then + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getParticipantId()).isEqualTo(participant.getParticipantId()); + assertThat(saved.getName()).isEqualTo(VALID_NAME); + } + + @Test + @DisplayName("참여자 ID로 조회하면 해당 참여자가 반환된다") + void givenSavedParticipant_whenFindByParticipantId_thenReturnParticipant() { + // Given + Participant participant = createValidParticipant(); + participantRepository.save(participant); + + // When + Optional found = participantRepository.findByParticipantId(participant.getParticipantId()); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo(VALID_NAME); + } + + @Test + @DisplayName("이벤트 ID와 전화번호로 중복 참여를 확인할 수 있다") + void givenSavedParticipant_whenExistsByEventIdAndPhoneNumber_thenReturnTrue() { + // Given + Participant participant = createValidParticipant(); + participantRepository.save(participant); + + // When + boolean exists = participantRepository.existsByEventIdAndPhoneNumber(VALID_EVENT_ID, VALID_PHONE); + + // Then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("이벤트 ID로 참여자 목록을 페이징 조회할 수 있다") + void givenMultipleParticipants_whenFindByEventId_thenReturnPagedList() { + // Given + for (int i = 1; i <= 5; i++) { + Participant participant = Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("참여자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("test" + i + "@test.com") + .storeVisited(i % 2 == 0) + .bonusEntries(i % 2 == 0 ? 2 : 1) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + participantRepository.save(participant); + } + Pageable pageable = PageRequest.of(0, 3); + + // When + Page page = participantRepository.findByEventIdOrderByCreatedAtDesc(VALID_EVENT_ID, pageable); + + // Then + assertThat(page.getContent()).hasSize(3); + assertThat(page.getTotalElements()).isEqualTo(5); + assertThat(page.getTotalPages()).isEqualTo(2); + } + + @Test + @DisplayName("매장 방문 여부로 필터링하여 참여자 목록을 조회할 수 있다") + void givenParticipantsWithStoreVisit_whenFindByStoreVisited_thenReturnFiltered() { + // Given + for (int i = 1; i <= 5; i++) { + Participant participant = Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("참여자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("test" + i + "@test.com") + .storeVisited(i % 2 == 0) + .bonusEntries(i % 2 == 0 ? 2 : 1) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + participantRepository.save(participant); + } + Pageable pageable = PageRequest.of(0, 10); + + // When + Page page = participantRepository + .findByEventIdAndStoreVisitedOrderByCreatedAtDesc(VALID_EVENT_ID, true, pageable); + + // Then + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent()).allMatch(Participant::getStoreVisited); + } + + @Test + @DisplayName("이벤트 ID로 전체 참여자 수를 조회할 수 있다") + void givenParticipants_whenCountByEventId_thenReturnCount() { + // Given + for (int i = 1; i <= 3; i++) { + Participant participant = Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("참여자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("test" + i + "@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + participantRepository.save(participant); + } + + // When + long count = participantRepository.countByEventId(VALID_EVENT_ID); + + // Then + assertThat(count).isEqualTo(3); + } + + @Test + @DisplayName("당첨자만 순위 순으로 조회할 수 있다") + void givenWinners_whenFindWinners_thenReturnSortedByRank() { + // Given + for (int i = 1; i <= 3; i++) { + Participant participant = Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("당첨자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("winner" + i + "@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(true) + .build(); + participant.markAsWinner(4 - i); // 역순으로 순위 부여 + participantRepository.save(participant); + } + Pageable pageable = PageRequest.of(0, 10); + + // When + Page page = participantRepository + .findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(VALID_EVENT_ID, pageable); + + // Then + assertThat(page.getContent()).hasSize(3); + assertThat(page.getContent().get(0).getWinnerRank()).isEqualTo(1); + assertThat(page.getContent().get(1).getWinnerRank()).isEqualTo(2); + assertThat(page.getContent().get(2).getWinnerRank()).isEqualTo(3); + } + + @Test + @DisplayName("이벤트 ID로 당첨자 수를 조회할 수 있다") + void givenWinners_whenCountWinners_thenReturnCount() { + // Given + for (int i = 1; i <= 5; i++) { + Participant participant = Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("참여자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("test" + i + "@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(i <= 2) + .build(); + if (i <= 2) { + participant.markAsWinner(i); + } + participantRepository.save(participant); + } + + // When + long count = participantRepository.countByEventIdAndIsWinnerTrue(VALID_EVENT_ID); + + // Then + assertThat(count).isEqualTo(2); + } + + @Test + @DisplayName("이벤트 ID로 최대 ID를 조회할 수 있다") + void givenParticipants_whenFindMaxId_thenReturnMaxId() { + // Given + for (int i = 1; i <= 3; i++) { + Participant participant = Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("참여자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("test" + i + "@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + participantRepository.save(participant); + } + + // When + Optional maxId = participantRepository.findMaxIdByEventId(VALID_EVENT_ID); + + // Then + assertThat(maxId).isPresent(); + assertThat(maxId.get()).isGreaterThan(0); + } + + @Test + @DisplayName("비당첨자 목록만 조회할 수 있다") + void givenMixedParticipants_whenFindNonWinners_thenReturnOnlyNonWinners() { + // Given + for (int i = 1; i <= 5; i++) { + Participant participant = Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("참여자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("test" + i + "@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(i <= 2) + .build(); + participantRepository.save(participant); + } + + // When + List nonWinners = participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID); + + // Then + assertThat(nonWinners).hasSize(3); + assertThat(nonWinners).allMatch(p -> !p.getIsWinner()); + } + + @Test + @DisplayName("이벤트 ID와 참여자 ID로 조회할 수 있다") + void givenParticipant_whenFindByEventIdAndParticipantId_thenReturnParticipant() { + // Given + Participant participant = createValidParticipant(); + participantRepository.save(participant); + + // When + Optional found = participantRepository + .findByEventIdAndParticipantId(VALID_EVENT_ID, participant.getParticipantId()); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo(VALID_NAME); + } + + // 헬퍼 메서드 + private Participant createValidParticipant() { + return Participant.builder() + .participantId("prt_20250124_001") + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .email(VALID_EMAIL) + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + } +} diff --git a/participation-service/src/test/java/com/kt/event/participation/test/integration/QueryVerificationTest.java b/participation-service/src/test/java/com/kt/event/participation/test/integration/QueryVerificationTest.java new file mode 100644 index 0000000..9cfa6bd --- /dev/null +++ b/participation-service/src/test/java/com/kt/event/participation/test/integration/QueryVerificationTest.java @@ -0,0 +1,114 @@ +package com.kt.event.participation.test.integration; + +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +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.orm.jpa.DataJpaTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Spring Data JPA 메서드의 실제 쿼리 확인용 테스트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@DataJpaTest +@TestPropertySource(properties = { + "spring.jpa.show-sql=true", + "spring.jpa.properties.hibernate.format_sql=true", + "logging.level.org.hibernate.SQL=DEBUG", + "logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE" +}) +@DisplayName("JPA 쿼리 검증 테스트") +class QueryVerificationTest { + + @Autowired + private ParticipantRepository participantRepository; + + @Test + @DisplayName("countByEventIdAndIsWinnerTrue 메서드의 실제 쿼리 확인") + void verifyCountByEventIdAndIsWinnerTrueQuery() { + // Given + String eventId = "evt_test_001"; + + // 테스트 데이터 생성 + for (int i = 1; i <= 5; i++) { + Participant participant = Participant.builder() + .participantId("prt_test_" + i) + .eventId(eventId) + .name("참여자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("test" + i + "@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(i <= 2) + .build(); + participantRepository.save(participant); + } + + // When - 이 쿼리가 실행되면서 콘솔에 SQL이 출력됨 + System.out.println("\n========== countByEventIdAndIsWinnerTrue 실행 =========="); + long count = participantRepository.countByEventIdAndIsWinnerTrue(eventId); + System.out.println("========== 결과: " + count + " ==========\n"); + } + + @Test + @DisplayName("findByEventIdAndPhoneNumber 메서드의 실제 쿼리 확인") + void verifyExistsByEventIdAndPhoneNumberQuery() { + // Given + String eventId = "evt_test_002"; + String phoneNumber = "010-1234-5678"; + + Participant participant = Participant.builder() + .participantId("prt_test_001") + .eventId(eventId) + .name("홍길동") + .phoneNumber(phoneNumber) + .email("hong@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + participantRepository.save(participant); + + // When + System.out.println("\n========== existsByEventIdAndPhoneNumber 실행 =========="); + boolean exists = participantRepository.existsByEventIdAndPhoneNumber(eventId, phoneNumber); + System.out.println("========== 결과: " + exists + " ==========\n"); + } + + @Test + @DisplayName("findByEventIdOrderByCreatedAtDesc 메서드의 실제 쿼리 확인") + void verifyFindByEventIdOrderByCreatedAtDescQuery() { + // Given + String eventId = "evt_test_003"; + + for (int i = 1; i <= 3; i++) { + Participant participant = Participant.builder() + .participantId("prt_test_" + i) + .eventId(eventId) + .name("참여자" + i) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("test" + i + "@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + participantRepository.save(participant); + } + + // When + System.out.println("\n========== findByEventIdOrderByCreatedAtDesc 실행 =========="); + participantRepository.findByEventIdOrderByCreatedAtDesc(eventId, + org.springframework.data.domain.PageRequest.of(0, 10)); + System.out.println("========== 쿼리 실행 완료 ==========\n"); + } +} diff --git a/participation-service/src/test/java/com/kt/event/participation/test/unit/DrawLogUnitTest.java b/participation-service/src/test/java/com/kt/event/participation/test/unit/DrawLogUnitTest.java new file mode 100644 index 0000000..18e72ee --- /dev/null +++ b/participation-service/src/test/java/com/kt/event/participation/test/unit/DrawLogUnitTest.java @@ -0,0 +1,97 @@ +package com.kt.event.participation.test.unit; + +import com.kt.event.participation.domain.draw.DrawLog; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * DrawLog Entity 단위 테스트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@DisplayName("DrawLog 엔티티 단위 테스트") +class DrawLogUnitTest { + + // 테스트 데이터 상수 + private static final String VALID_EVENT_ID = "evt_20250124_001"; + private static final Integer TOTAL_PARTICIPANTS = 100; + private static final Integer WINNER_COUNT = 10; + private static final String ALGORITHM = "WEIGHTED_RANDOM"; + private static final String DRAWN_BY = "admin"; + + @Test + @DisplayName("빌더로 추첨 로그를 생성하면 필드가 정상 설정된다") + void givenValidData_whenBuild_thenDrawLogCreated() { + // Given + LocalDateTime drawnAt = LocalDateTime.now(); + + // When + DrawLog drawLog = DrawLog.builder() + .eventId(VALID_EVENT_ID) + .totalParticipants(TOTAL_PARTICIPANTS) + .winnerCount(WINNER_COUNT) + .applyStoreVisitBonus(true) + .algorithm(ALGORITHM) + .drawnAt(drawnAt) + .drawnBy(DRAWN_BY) + .build(); + + // Then + assertThat(drawLog.getEventId()).isEqualTo(VALID_EVENT_ID); + assertThat(drawLog.getTotalParticipants()).isEqualTo(TOTAL_PARTICIPANTS); + assertThat(drawLog.getWinnerCount()).isEqualTo(WINNER_COUNT); + assertThat(drawLog.getApplyStoreVisitBonus()).isTrue(); + assertThat(drawLog.getAlgorithm()).isEqualTo(ALGORITHM); + assertThat(drawLog.getDrawnAt()).isEqualTo(drawnAt); + assertThat(drawLog.getDrawnBy()).isEqualTo(DRAWN_BY); + } + + @Test + @DisplayName("매장 방문 보너스 미적용으로 추첨 로그를 생성할 수 있다") + void givenNoBonus_whenBuild_thenDrawLogCreated() { + // Given + LocalDateTime drawnAt = LocalDateTime.now(); + + // When + DrawLog drawLog = DrawLog.builder() + .eventId(VALID_EVENT_ID) + .totalParticipants(TOTAL_PARTICIPANTS) + .winnerCount(WINNER_COUNT) + .applyStoreVisitBonus(false) + .algorithm(ALGORITHM) + .drawnAt(drawnAt) + .drawnBy(DRAWN_BY) + .build(); + + // Then + assertThat(drawLog.getApplyStoreVisitBonus()).isFalse(); + } + + @Test + @DisplayName("당첨자가 없는 경우도 추첨 로그를 생성할 수 있다") + void givenNoWinners_whenBuild_thenDrawLogCreated() { + // Given + LocalDateTime drawnAt = LocalDateTime.now(); + Integer zeroWinners = 0; + + // When + DrawLog drawLog = DrawLog.builder() + .eventId(VALID_EVENT_ID) + .totalParticipants(TOTAL_PARTICIPANTS) + .winnerCount(zeroWinners) + .applyStoreVisitBonus(true) + .algorithm(ALGORITHM) + .drawnAt(drawnAt) + .drawnBy(DRAWN_BY) + .build(); + + // Then + assertThat(drawLog.getWinnerCount()).isZero(); + assertThat(drawLog.getTotalParticipants()).isEqualTo(TOTAL_PARTICIPANTS); + } +} diff --git a/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipantUnitTest.java b/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipantUnitTest.java new file mode 100644 index 0000000..e96747a --- /dev/null +++ b/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipantUnitTest.java @@ -0,0 +1,222 @@ +package com.kt.event.participation.test.unit; + +import com.kt.event.participation.domain.participant.Participant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +/** + * Participant Entity 단위 테스트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@DisplayName("Participant 엔티티 단위 테스트") +class ParticipantUnitTest { + + // 테스트 데이터 상수 + private static final String VALID_EVENT_ID = "evt_20250124_001"; + private static final String VALID_NAME = "홍길동"; + private static final String VALID_PHONE = "010-1234-5678"; + private static final String VALID_EMAIL = "hong@test.com"; + private static final Long VALID_SEQUENCE = 1L; + + @Test + @DisplayName("매장 방문 시 participantId가 정상적으로 생성된다") + void givenStoreVisited_whenGenerateParticipantId_thenSuccess() { + // Given + String eventId = VALID_EVENT_ID; + Long sequenceNumber = VALID_SEQUENCE; + + // When + String participantId = Participant.generateParticipantId(eventId, sequenceNumber); + + // Then + assertThat(participantId).isEqualTo("prt_20250124_001"); + assertThat(participantId).startsWith("prt_"); + assertThat(participantId).hasSize(16); + } + + @Test + @DisplayName("시퀀스 번호가 증가하면 participantId도 증가한다") + void givenLargeSequence_whenGenerateParticipantId_thenIdIncreases() { + // Given + String eventId = VALID_EVENT_ID; + Long sequenceNumber = 999L; + + // When + String participantId = Participant.generateParticipantId(eventId, sequenceNumber); + + // Then + assertThat(participantId).isEqualTo("prt_20250124_999"); + } + + @Test + @DisplayName("매장 방문 시 보너스 응모권이 5개가 된다") + void givenStoreVisited_whenCalculateBonusEntries_thenFive() { + // Given + Boolean storeVisited = true; + + // When + Integer bonusEntries = Participant.calculateBonusEntries(storeVisited); + + // Then + assertThat(bonusEntries).isEqualTo(5); + } + + @Test + @DisplayName("매장 미방문 시 보너스 응모권이 1개가 된다") + void givenNotVisited_whenCalculateBonusEntries_thenOne() { + // Given + Boolean storeVisited = false; + + // When + Integer bonusEntries = Participant.calculateBonusEntries(storeVisited); + + // Then + assertThat(bonusEntries).isEqualTo(1); + } + + @Test + @DisplayName("당첨자로 표시하면 isWinner가 true가 되고 당첨 정보가 설정된다") + void givenParticipant_whenMarkAsWinner_thenWinnerFieldsSet() { + // Given + Participant participant = createValidParticipant(); + Integer winnerRank = 1; + + // When + participant.markAsWinner(winnerRank); + + // Then + assertThat(participant.getIsWinner()).isTrue(); + assertThat(participant.getWinnerRank()).isEqualTo(1); + assertThat(participant.getWonAt()).isNotNull(); + } + + @Test + @DisplayName("빌더로 참여자를 생성하면 필드가 정상 설정된다") + void givenValidData_whenBuild_thenParticipantCreated() { + // Given & When + Participant participant = Participant.builder() + .participantId("prt_20250124_001") + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .email(VALID_EMAIL) + .storeVisited(true) + .bonusEntries(5) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + + // Then + assertThat(participant.getParticipantId()).isEqualTo("prt_20250124_001"); + assertThat(participant.getEventId()).isEqualTo(VALID_EVENT_ID); + assertThat(participant.getName()).isEqualTo(VALID_NAME); + assertThat(participant.getPhoneNumber()).isEqualTo(VALID_PHONE); + assertThat(participant.getEmail()).isEqualTo(VALID_EMAIL); + assertThat(participant.getStoreVisited()).isTrue(); + assertThat(participant.getBonusEntries()).isEqualTo(5); + assertThat(participant.getAgreeMarketing()).isTrue(); + assertThat(participant.getAgreePrivacy()).isTrue(); + assertThat(participant.getIsWinner()).isFalse(); + } + + @Test + @DisplayName("prePersist에서 개인정보 동의가 null이면 예외가 발생한다") + void givenNullPrivacyAgree_whenPrePersist_thenThrowException() { + // Given + Participant participant = Participant.builder() + .participantId("prt_20250124_001") + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .storeVisited(true) + .agreePrivacy(null) + .build(); + + // When & Then + assertThatThrownBy(participant::prePersist) + .isInstanceOf(IllegalStateException.class) + .hasMessage("개인정보 수집 및 이용 동의는 필수입니다"); + } + + @Test + @DisplayName("prePersist에서 개인정보 동의가 false이면 예외가 발생한다") + void givenFalsePrivacyAgree_whenPrePersist_thenThrowException() { + // Given + Participant participant = Participant.builder() + .participantId("prt_20250124_001") + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .storeVisited(true) + .agreePrivacy(false) + .build(); + + // When & Then + assertThatThrownBy(participant::prePersist) + .isInstanceOf(IllegalStateException.class) + .hasMessage("개인정보 수집 및 이용 동의는 필수입니다"); + } + + @Test + @DisplayName("prePersist에서 bonusEntries가 null이면 자동 계산된다") + void givenNullBonusEntries_whenPrePersist_thenCalculated() { + // Given + Participant participant = Participant.builder() + .participantId("prt_20250124_001") + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .storeVisited(true) + .agreePrivacy(true) + .bonusEntries(null) + .build(); + + // When + participant.prePersist(); + + // Then + assertThat(participant.getBonusEntries()).isEqualTo(5); + } + + @Test + @DisplayName("prePersist에서 isWinner가 null이면 false로 설정된다") + void givenNullIsWinner_whenPrePersist_thenSetFalse() { + // Given + Participant participant = Participant.builder() + .participantId("prt_20250124_001") + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .storeVisited(true) + .agreePrivacy(true) + .isWinner(null) + .build(); + + // When + participant.prePersist(); + + // Then + assertThat(participant.getIsWinner()).isFalse(); + } + + // 헬퍼 메서드 + private Participant createValidParticipant() { + return Participant.builder() + .participantId("prt_20250124_001") + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .email(VALID_EMAIL) + .storeVisited(true) + .bonusEntries(5) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + } +} diff --git a/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipationServiceUnitTest.java b/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipationServiceUnitTest.java new file mode 100644 index 0000000..754a303 --- /dev/null +++ b/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipationServiceUnitTest.java @@ -0,0 +1,271 @@ +package com.kt.event.participation.test.unit; + +import com.kt.event.common.dto.PageResponse; +import com.kt.event.participation.application.dto.ParticipationRequest; +import com.kt.event.participation.application.dto.ParticipationResponse; +import com.kt.event.participation.application.service.ParticipationService; +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import com.kt.event.participation.exception.ParticipationException.*; +import com.kt.event.participation.infrastructure.kafka.KafkaProducerService; +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.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +/** + * ParticipationService 단위 테스트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("ParticipationService 단위 테스트") +class ParticipationServiceUnitTest { + + @Mock + private ParticipantRepository participantRepository; + + @Mock + private KafkaProducerService kafkaProducerService; + + @InjectMocks + private ParticipationService participationService; + + // 테스트 데이터 상수 + private static final String VALID_EVENT_ID = "evt_20250124_001"; + private static final String VALID_PARTICIPANT_ID = "prt_20250124_001"; + private static final String VALID_NAME = "홍길동"; + private static final String VALID_PHONE = "010-1234-5678"; + private static final String VALID_EMAIL = "hong@test.com"; + + @Test + @DisplayName("정상적인 참여 요청이면 참여자가 저장되고 Kafka 이벤트가 발행된다") + void givenValidRequest_whenParticipate_thenSaveAndPublishEvent() { + // Given + ParticipationRequest request = createValidRequest(); + Participant savedParticipant = createValidParticipant(); + + given(participantRepository.existsByEventIdAndPhoneNumber(VALID_EVENT_ID, VALID_PHONE)) + .willReturn(false); + given(participantRepository.findMaxIdByEventId(VALID_EVENT_ID)) + .willReturn(Optional.of(0L)); + given(participantRepository.save(any(Participant.class))) + .willReturn(savedParticipant); + willDoNothing().given(kafkaProducerService) + .publishParticipantRegistered(any()); + + // When + ParticipationResponse response = participationService.participate(VALID_EVENT_ID, request); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getParticipantId()).isEqualTo(VALID_PARTICIPANT_ID); + assertThat(response.getName()).isEqualTo(VALID_NAME); + assertThat(response.getPhoneNumber()).isEqualTo(VALID_PHONE); + + then(participantRepository).should(times(1)).save(any(Participant.class)); + then(kafkaProducerService).should(times(1)).publishParticipantRegistered(any()); + } + + @Test + @DisplayName("중복 참여 시 DuplicateParticipationException이 발생한다") + void givenDuplicatePhone_whenParticipate_thenThrowException() { + // Given + ParticipationRequest request = createValidRequest(); + + given(participantRepository.existsByEventIdAndPhoneNumber(VALID_EVENT_ID, VALID_PHONE)) + .willReturn(true); + + // When & Then + assertThatThrownBy(() -> participationService.participate(VALID_EVENT_ID, request)) + .isInstanceOf(DuplicateParticipationException.class) + .hasMessageContaining("이미 참여하신 이벤트입니다"); + + then(participantRepository).should(never()).save(any()); + then(kafkaProducerService).should(never()).publishParticipantRegistered(any()); + } + + @Test + @DisplayName("매장 방문 참여자는 보너스 응모권이 2개가 된다") + void givenStoreVisited_whenParticipate_thenBonusEntriesIsTwo() { + // Given + ParticipationRequest request = ParticipationRequest.builder() + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .email(VALID_EMAIL) + .storeVisited(true) + .agreeMarketing(true) + .agreePrivacy(true) + .build(); + + Participant savedParticipant = Participant.builder() + .participantId(VALID_PARTICIPANT_ID) + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .email(VALID_EMAIL) + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + + given(participantRepository.existsByEventIdAndPhoneNumber(VALID_EVENT_ID, VALID_PHONE)) + .willReturn(false); + given(participantRepository.findMaxIdByEventId(VALID_EVENT_ID)) + .willReturn(Optional.of(0L)); + given(participantRepository.save(any(Participant.class))) + .willReturn(savedParticipant); + + // When + ParticipationResponse response = participationService.participate(VALID_EVENT_ID, request); + + // Then + assertThat(response.getBonusEntries()).isEqualTo(2); + assertThat(response.getStoreVisited()).isTrue(); + } + + @Test + @DisplayName("참여자 목록 조회 시 페이징이 적용된다") + void givenPageable_whenGetParticipants_thenReturnPagedList() { + // Given + Pageable pageable = PageRequest.of(0, 10); + List participants = List.of( + createValidParticipant(), + createAnotherParticipant() + ); + Page participantPage = new PageImpl<>(participants, pageable, 2); + + given(participantRepository.findByEventIdOrderByCreatedAtDesc(VALID_EVENT_ID, pageable)) + .willReturn(participantPage); + + // When + PageResponse response = participationService + .getParticipants(VALID_EVENT_ID, null, pageable); + + // Then + assertThat(response.getContent()).hasSize(2); + assertThat(response.getTotalElements()).isEqualTo(2); + assertThat(response.getTotalPages()).isEqualTo(1); + assertThat(response.isFirst()).isTrue(); + assertThat(response.isLast()).isTrue(); + } + + @Test + @DisplayName("매장 방문 필터 적용 시 필터링된 참여자 목록이 조회된다") + void givenStoreVisitedFilter_whenGetParticipants_thenReturnFilteredList() { + // Given + Boolean storeVisited = true; + Pageable pageable = PageRequest.of(0, 10); + List participants = List.of(createValidParticipant()); + Page participantPage = new PageImpl<>(participants, pageable, 1); + + given(participantRepository.findByEventIdAndStoreVisitedOrderByCreatedAtDesc( + VALID_EVENT_ID, storeVisited, pageable)) + .willReturn(participantPage); + + // When + PageResponse response = participationService + .getParticipants(VALID_EVENT_ID, storeVisited, pageable); + + // Then + assertThat(response.getContent()).hasSize(1); + assertThat(response.getContent().get(0).getStoreVisited()).isTrue(); + + then(participantRepository).should(times(1)) + .findByEventIdAndStoreVisitedOrderByCreatedAtDesc(VALID_EVENT_ID, storeVisited, pageable); + } + + @Test + @DisplayName("참여자 상세 조회 시 정상적으로 반환된다") + void givenValidParticipantId_whenGetParticipant_thenReturnParticipant() { + // Given + Participant participant = createValidParticipant(); + + given(participantRepository.findByEventIdAndParticipantId(VALID_EVENT_ID, VALID_PARTICIPANT_ID)) + .willReturn(Optional.of(participant)); + + // When + ParticipationResponse response = participationService + .getParticipant(VALID_EVENT_ID, VALID_PARTICIPANT_ID); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getParticipantId()).isEqualTo(VALID_PARTICIPANT_ID); + assertThat(response.getName()).isEqualTo(VALID_NAME); + } + + @Test + @DisplayName("존재하지 않는 참여자 조회 시 ParticipantNotFoundException이 발생한다") + void givenInvalidParticipantId_whenGetParticipant_thenThrowException() { + // Given + String invalidParticipantId = "prt_20250124_999"; + + given(participantRepository.findByEventIdAndParticipantId(VALID_EVENT_ID, invalidParticipantId)) + .willReturn(Optional.empty()); + given(participantRepository.countByEventId(VALID_EVENT_ID)) + .willReturn(1L); // 이벤트에 다른 참여자가 있음을 나타냄 + + // When & Then + assertThatThrownBy(() -> participationService.getParticipant(VALID_EVENT_ID, invalidParticipantId)) + .isInstanceOf(ParticipantNotFoundException.class) + .hasMessageContaining("참여자를 찾을 수 없습니다"); + } + + // 헬퍼 메서드 + private ParticipationRequest createValidRequest() { + return ParticipationRequest.builder() + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .email(VALID_EMAIL) + .storeVisited(true) + .agreeMarketing(true) + .agreePrivacy(true) + .build(); + } + + private Participant createValidParticipant() { + return Participant.builder() + .participantId(VALID_PARTICIPANT_ID) + .eventId(VALID_EVENT_ID) + .name(VALID_NAME) + .phoneNumber(VALID_PHONE) + .email(VALID_EMAIL) + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build(); + } + + private Participant createAnotherParticipant() { + return Participant.builder() + .participantId("prt_20250124_002") + .eventId(VALID_EVENT_ID) + .name("김철수") + .phoneNumber("010-9876-5432") + .email("kim@test.com") + .storeVisited(false) + .bonusEntries(1) + .agreeMarketing(false) + .agreePrivacy(true) + .isWinner(false) + .build(); + } +} diff --git a/participation-service/src/test/java/com/kt/event/participation/test/unit/WinnerDrawServiceUnitTest.java b/participation-service/src/test/java/com/kt/event/participation/test/unit/WinnerDrawServiceUnitTest.java new file mode 100644 index 0000000..eca7e3d --- /dev/null +++ b/participation-service/src/test/java/com/kt/event/participation/test/unit/WinnerDrawServiceUnitTest.java @@ -0,0 +1,245 @@ +package com.kt.event.participation.test.unit; + +import com.kt.event.common.dto.PageResponse; +import com.kt.event.participation.application.dto.DrawWinnersRequest; +import com.kt.event.participation.application.dto.DrawWinnersResponse; +import com.kt.event.participation.application.dto.ParticipationResponse; +import com.kt.event.participation.application.service.WinnerDrawService; +import com.kt.event.participation.domain.draw.DrawLog; +import com.kt.event.participation.domain.draw.DrawLogRepository; +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import com.kt.event.participation.exception.ParticipationException.*; +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.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +/** + * WinnerDrawService 단위 테스트 + * + * @author Digital Garage Team + * @since 2025-01-24 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("WinnerDrawService 단위 테스트") +class WinnerDrawServiceUnitTest { + + @Mock + private ParticipantRepository participantRepository; + + @Mock + private DrawLogRepository drawLogRepository; + + @InjectMocks + private WinnerDrawService winnerDrawService; + + // 테스트 데이터 상수 + private static final String VALID_EVENT_ID = "evt_20250124_001"; + private static final Integer WINNER_COUNT = 2; + + @Test + @DisplayName("정상적인 추첨 요청이면 당첨자가 선정되고 로그가 저장된다") + void givenValidRequest_whenDrawWinners_thenWinnersSelectedAndLogSaved() { + // Given + DrawWinnersRequest request = createDrawRequest(WINNER_COUNT, false); + List participants = createParticipantList(5); + + given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false); + given(participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID)) + .willReturn(participants); + given(participantRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); + given(drawLogRepository.save(any(DrawLog.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // When + DrawWinnersResponse response = winnerDrawService.drawWinners(VALID_EVENT_ID, request); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getEventId()).isEqualTo(VALID_EVENT_ID); + assertThat(response.getTotalParticipants()).isEqualTo(5); + assertThat(response.getWinnerCount()).isEqualTo(WINNER_COUNT); + assertThat(response.getWinners()).hasSize(WINNER_COUNT); + assertThat(response.getDrawnAt()).isNotNull(); + + then(participantRepository).should(times(1)).saveAll(anyList()); + then(drawLogRepository).should(times(1)).save(any(DrawLog.class)); + } + + @Test + @DisplayName("이미 추첨이 완료된 이벤트면 AlreadyDrawnException이 발생한다") + void givenAlreadyDrawn_whenDrawWinners_thenThrowException() { + // Given + DrawWinnersRequest request = createDrawRequest(WINNER_COUNT, false); + + given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(true); + + // When & Then + assertThatThrownBy(() -> winnerDrawService.drawWinners(VALID_EVENT_ID, request)) + .isInstanceOf(AlreadyDrawnException.class); + + then(participantRepository).should(never()).findByEventIdAndIsWinnerFalse(anyString()); + } + + @Test + @DisplayName("참여자 수가 당첨자 수보다 적으면 InsufficientParticipantsException이 발생한다") + void givenInsufficientParticipants_whenDrawWinners_thenThrowException() { + // Given + DrawWinnersRequest request = createDrawRequest(10, false); + List participants = createParticipantList(5); + + given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false); + given(participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID)) + .willReturn(participants); + + // When & Then + assertThatThrownBy(() -> winnerDrawService.drawWinners(VALID_EVENT_ID, request)) + .isInstanceOf(InsufficientParticipantsException.class); + + then(participantRepository).should(never()).saveAll(anyList()); + then(drawLogRepository).should(never()).save(any(DrawLog.class)); + } + + @Test + @DisplayName("매장 방문 보너스 적용 시 가중치가 반영된 추첨이 이루어진다") + void givenApplyBonus_whenDrawWinners_thenWeightedDraw() { + // Given + DrawWinnersRequest request = createDrawRequest(WINNER_COUNT, true); + List participants = createParticipantList(5); + + given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false); + given(participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID)) + .willReturn(participants); + given(participantRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); + given(drawLogRepository.save(any(DrawLog.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // When + DrawWinnersResponse response = winnerDrawService.drawWinners(VALID_EVENT_ID, request); + + // Then + assertThat(response.getWinnerCount()).isEqualTo(WINNER_COUNT); + then(drawLogRepository).should(times(1)).save(argThat(log -> + log.getApplyStoreVisitBonus().equals(true) + )); + } + + @Test + @DisplayName("당첨자 목록 조회 시 순위 순으로 정렬되어 반환된다") + void givenWinnersExist_whenGetWinners_thenReturnSortedByRank() { + // Given + Pageable pageable = PageRequest.of(0, 10); + List winners = createWinnerList(3); + Page winnerPage = new PageImpl<>(winners, pageable, 3); + + given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(true); + given(participantRepository.findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(VALID_EVENT_ID, pageable)) + .willReturn(winnerPage); + + // When + PageResponse response = winnerDrawService.getWinners(VALID_EVENT_ID, pageable); + + // Then + assertThat(response.getContent()).hasSize(3); + assertThat(response.getTotalElements()).isEqualTo(3); + } + + @Test + @DisplayName("추첨이 완료되지 않은 이벤트의 당첨자 조회 시 NoWinnersYetException이 발생한다") + void givenNoDrawYet_whenGetWinners_thenThrowException() { + // Given + Pageable pageable = PageRequest.of(0, 10); + + given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false); + + // When & Then + assertThatThrownBy(() -> winnerDrawService.getWinners(VALID_EVENT_ID, pageable)) + .isInstanceOf(NoWinnersYetException.class); + + then(participantRepository).should(never()) + .findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(anyString(), any(Pageable.class)); + } + + @Test + @DisplayName("당첨자 추첨 시 모든 참여자에게 순위가 할당된다") + void givenParticipants_whenDrawWinners_thenAllWinnersHaveRank() { + // Given + DrawWinnersRequest request = createDrawRequest(3, false); + List participants = createParticipantList(5); + + given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false); + given(participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID)) + .willReturn(participants); + given(participantRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); + given(drawLogRepository.save(any(DrawLog.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // When + DrawWinnersResponse response = winnerDrawService.drawWinners(VALID_EVENT_ID, request); + + // Then + assertThat(response.getWinners()).allSatisfy(winner -> { + assertThat(winner.getRank()).isNotNull(); + assertThat(winner.getRank()).isBetween(1, 3); + }); + } + + // 헬퍼 메서드 + private DrawWinnersRequest createDrawRequest(Integer winnerCount, Boolean applyBonus) { + return DrawWinnersRequest.builder() + .winnerCount(winnerCount) + .applyStoreVisitBonus(applyBonus) + .build(); + } + + private List createParticipantList(int count) { + List participants = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + participants.add(Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("참여자" + i) + .phoneNumber("010-" + String.format("%04d", 1000 + i) + "-" + String.format("%04d", i)) + .email("participant" + i + "@test.com") + .storeVisited(i % 2 == 0) + .bonusEntries(i % 2 == 0 ? 2 : 1) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(false) + .build()); + } + return participants; + } + + private List createWinnerList(int count) { + List winners = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + Participant winner = Participant.builder() + .participantId("prt_20250124_" + String.format("%03d", i)) + .eventId(VALID_EVENT_ID) + .name("당첨자" + i) + .phoneNumber("010-" + String.format("%04d", 1000 + i) + "-" + String.format("%04d", i)) + .email("winner" + i + "@test.com") + .storeVisited(true) + .bonusEntries(2) + .agreeMarketing(true) + .agreePrivacy(true) + .isWinner(true) + .build(); + winner.markAsWinner(i); + winners.add(winner); + } + return winners; + } +} diff --git a/participation-service/src/test/resources/application.yml b/participation-service/src/test/resources/application.yml new file mode 100644 index 0000000..895ba9e --- /dev/null +++ b/participation-service/src/test/resources/application.yml @@ -0,0 +1,46 @@ +spring: + # JPA 설정 + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + + # H2 인메모리 데이터베이스 설정 + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + + # Kafka 설정 (통합 테스트용) + kafka: + bootstrap-servers: 20.249.182.13:9095 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: all + retries: 3 + + # H2 콘솔 활성화 (디버깅용) + h2: + console: + enabled: true + path: /h2-console + +# JWT 설정 (테스트용) +jwt: + secret: test-secret-key-for-testing-only-minimum-256-bits + expiration: 86400000 + +# 로깅 레벨 +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + com.kt.event.participation: DEBUG + org.springframework.kafka: DEBUG + org.apache.kafka: DEBUG diff --git a/run-event-service.ps1 b/run-event-service.ps1 new file mode 100644 index 0000000..087d337 --- /dev/null +++ b/run-event-service.ps1 @@ -0,0 +1,22 @@ +# Event Service 실행 스크립트 + +$env:SERVER_PORT="8081" +$env:DB_HOST="20.249.177.232" +$env:DB_PORT="5432" +$env:DB_NAME="eventdb" +$env:DB_USERNAME="eventuser" +$env:DB_PASSWORD="Hi5Jessica!" +$env:REDIS_HOST="localhost" +$env:REDIS_PORT="6379" +$env:REDIS_PASSWORD="" +$env:KAFKA_BOOTSTRAP_SERVERS="20.249.182.13:9095,4.217.131.59:9095" +$env:DDL_AUTO="update" +$env:LOG_LEVEL="DEBUG" +$env:SQL_LOG_LEVEL="DEBUG" +$env:CONTENT_SERVICE_URL="http://localhost:8082" +$env:DISTRIBUTION_SERVICE_URL="http://localhost:8084" +$env:JWT_SECRET="kt-event-marketing-jwt-secret-key-for-development-only-minimum-256-bits-required" + +Write-Host "Starting Event Service on port 8081..." -ForegroundColor Green +Write-Host "Logs will be saved to logs/event-service.log" -ForegroundColor Yellow +./gradlew event-service:bootRun 2>&1 | Tee-Object -FilePath logs/event-service.log diff --git a/test-existing-phone-other-event.json b/test-existing-phone-other-event.json new file mode 100644 index 0000000..92c9a0a --- /dev/null +++ b/test-existing-phone-other-event.json @@ -0,0 +1,9 @@ +{ + "name": "기존전화번호테스트", + "phoneNumber": "010-2044-4103", + "email": "test@example.com", + "channel": "SNS", + "storeVisited": false, + "agreeMarketing": true, + "agreePrivacy": true +} \ No newline at end of file diff --git a/test-new-phone.json b/test-new-phone.json new file mode 100644 index 0000000..53efd85 --- /dev/null +++ b/test-new-phone.json @@ -0,0 +1,9 @@ +{ + "name": "새로운테스트", + "phoneNumber": "010-8888-8888", + "email": "newtest@example.com", + "channel": "SNS", + "storeVisited": false, + "agreeMarketing": true, + "agreePrivacy": true +} \ No newline at end of file diff --git a/test-participate-new.json b/test-participate-new.json new file mode 100644 index 0000000..eea92a6 --- /dev/null +++ b/test-participate-new.json @@ -0,0 +1,9 @@ +{ + "name": "새로운테스트", + "phoneNumber": "010-9999-9999", + "email": "newtest@example.com", + "channel": "SNS", + "storeVisited": false, + "agreeMarketing": true, + "agreePrivacy": true +} \ No newline at end of file diff --git a/test-participate.json b/test-participate.json new file mode 100644 index 0000000..db17851 --- /dev/null +++ b/test-participate.json @@ -0,0 +1,9 @@ +{ + "name": "테스트", + "phoneNumber": "010-2044-4103", + "email": "test@example.com", + "channel": "SNS", + "storeVisited": false, + "agreeMarketing": true, + "agreePrivacy": true +} \ No newline at end of file diff --git a/test-token.txt b/test-token.txt new file mode 100644 index 0000000..9e5b876 --- /dev/null +++ b/test-token.txt @@ -0,0 +1,22 @@ +C:\Users\KTDS\home\workspace\kt-event-marketing\generate-test-token.py:26: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + now = datetime.datetime.utcnow() +================================================================================ +JWT ׽Ʈ ū +================================================================================ + +User ID: 6db043d0-b303-4577-b9dd-6d366cc59fa0 +Store ID: 34000028-01fd-4ed1-975c-35f7c88b6547 +Email: test@example.com +Name: Test User +Roles: ['ROLE_USER'] + +================================================================================ +Access Token: +================================================================================ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZGIwNDNkMC1iMzAzLTQ1NzctYjlkZC02ZDM2NmNjNTlmYTAiLCJzdG9yZUlkIjoiMzQwMDAwMjgtMDFmZC00ZWQxLTk3NWMtMzVmN2M4OGI2NTQ3IiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNTQ5MjkxLCJleHAiOjE3OTMwODUyOTF9.PfQ_NhXRjdfsmQn0NcAKgxcje2XaIL-TlQk_f_DVU38 + +================================================================================ + : +================================================================================ +curl -H "Authorization: Bearer " http://localhost:8081/api/v1/events + diff --git a/tools/check-kafka-messages.ps1 b/tools/check-kafka-messages.ps1 new file mode 100644 index 0000000..2a9129b --- /dev/null +++ b/tools/check-kafka-messages.ps1 @@ -0,0 +1,63 @@ +# Kafka 메시지 확인 스크립트 (Windows PowerShell) +# +# 사용법: .\check-kafka-messages.ps1 + +$KAFKA_SERVER = "4.230.50.63:9092" + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "📊 Kafka 토픽 메시지 확인" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Kafka 설치 확인 +$kafkaPath = Read-Host "Kafka 설치 경로를 입력하세요 (예: C:\kafka)" + +if (-not (Test-Path "$kafkaPath\bin\windows\kafka-console-consumer.bat")) { + Write-Host "❌ Kafka가 해당 경로에 설치되어 있지 않습니다." -ForegroundColor Red + exit 1 +} + +Write-Host "✅ Kafka 경로 확인: $kafkaPath" -ForegroundColor Green +Write-Host "" + +# 토픽 선택 +Write-Host "확인할 토픽을 선택하세요:" -ForegroundColor Yellow +Write-Host " 1. event.created (이벤트 생성)" +Write-Host " 2. participant.registered (참여자 등록)" +Write-Host " 3. distribution.completed (배포 완료)" +Write-Host " 4. 모두 확인" +Write-Host "" + +$choice = Read-Host "선택 (1-4)" + +$topics = @() +switch ($choice) { + "1" { $topics = @("event.created") } + "2" { $topics = @("participant.registered") } + "3" { $topics = @("distribution.completed") } + "4" { $topics = @("event.created", "participant.registered", "distribution.completed") } + default { + Write-Host "❌ 잘못된 선택입니다." -ForegroundColor Red + exit 1 + } +} + +# 각 토픽별 메시지 확인 +foreach ($topic in $topics) { + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "📩 토픽: $topic" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + + # 최근 5개 메시지만 확인 + & "$kafkaPath\bin\windows\kafka-console-consumer.bat" ` + --bootstrap-server $KAFKA_SERVER ` + --topic $topic ` + --from-beginning ` + --max-messages 5 ` + --timeout-ms 5000 2>&1 | Out-String | Write-Host + + Write-Host "" +} + +Write-Host "✅ 확인 완료!" -ForegroundColor Green diff --git a/tools/check-mermaid.sh b/tools/check-mermaid.sh old mode 100755 new mode 100644 diff --git a/tools/check-plantuml.sh b/tools/check-plantuml.sh old mode 100755 new mode 100644 diff --git a/tools/kafka-comprehensive-test.bat b/tools/kafka-comprehensive-test.bat new file mode 100644 index 0000000..70f9e13 --- /dev/null +++ b/tools/kafka-comprehensive-test.bat @@ -0,0 +1,101 @@ +@echo off +REM ============================================ +REM Kafka/Redis 통합 테스트 스크립트 +REM ============================================ + +echo ============================================ +echo Kafka/Redis 통합 테스트 시작 +echo ============================================ +echo. + +REM 현재 디렉토리 확인 +cd /d "%~dp0\.." +echo 현재 디렉토리: %CD% +echo. + +REM 로그 디렉토리 확인 및 생성 +if not exist "logs" mkdir logs +echo 로그 디렉토리: %CD%\logs +echo. + +REM 테스트 타임스탬프 +set TEST_TIMESTAMP=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2% +set TEST_TIMESTAMP=%TEST_TIMESTAMP: =0% +set TEST_LOG=logs\kafka-redis-test_%TEST_TIMESTAMP%.log + +echo ============================================ +echo 1단계: Kafka 수동 테스트 메시지 전송 +echo ============================================ +echo. +echo Kafka 메시지 전송 중... +gradlew ai-service:runKafkaManualTest > %TEST_LOG% 2>&1 +if %ERRORLEVEL% EQU 0 ( + echo ✓ Kafka 메시지 전송 완료 +) else ( + echo ✗ Kafka 메시지 전송 실패 ^(Error Code: %ERRORLEVEL%^) + echo 로그 파일을 확인하세요: %TEST_LOG% +) +echo. + +echo ============================================ +echo 2단계: AI 서비스 Consumer 처리 대기 +echo ============================================ +echo. +echo AI 서비스가 Kafka 메시지를 처리할 때까지 60초 대기... +timeout /t 60 /nobreak > nul +echo ✓ 대기 완료 +echo. + +echo ============================================ +echo 3단계: Job 상태 확인 ^(Redis^) +echo ============================================ +echo. +echo Job 상태 조회 중... +curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status" >> %TEST_LOG% 2>&1 +if %ERRORLEVEL% EQU 0 ( + echo ✓ Job 상태 조회 성공 + curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status" +) else ( + echo ✗ Job 상태 조회 실패 +) +echo. + +echo ============================================ +echo 4단계: AI 추천 결과 확인 ^(Redis^) +echo ============================================ +echo. +echo AI 추천 결과 조회 중... +curl -s "http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001" >> %TEST_LOG% 2>&1 +if %ERRORLEVEL% EQU 0 ( + echo ✓ AI 추천 결과 조회 성공 + curl -s "http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001" +) else ( + echo ✗ AI 추천 결과 조회 실패 +) +echo. + +echo ============================================ +echo 5단계: 모든 테스트 메시지 상태 확인 +echo ============================================ +echo. +echo [Job 001] 상태 확인: +curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status" | findstr "status" +echo. +echo [Job 002] 상태 확인: +curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-002/status" | findstr "status" +echo. +echo [Job 003] 상태 확인: +curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-003/status" | findstr "status" +echo. + +echo ============================================ +echo 테스트 완료 +echo ============================================ +echo. +echo 상세 로그 파일: %TEST_LOG% +echo. +echo 수동 확인 명령어: +echo - Job 상태: curl http://localhost:8083/api/v1/ai-service/internal/jobs/{jobId}/status +echo - AI 추천: curl http://localhost:8083/api/v1/ai-service/internal/recommendations/{eventId} +echo. +pause diff --git a/tools/kafka-manual-test.bat b/tools/kafka-manual-test.bat new file mode 100644 index 0000000..bacadf4 --- /dev/null +++ b/tools/kafka-manual-test.bat @@ -0,0 +1,37 @@ +@echo off +REM Kafka 수동 테스트 실행 스크립트 (Windows) + +cd /d %~dp0\.. + +echo ================================================ +echo Kafka Manual Test - AI Service +echo ================================================ +echo. +echo 이 스크립트는 Kafka에 테스트 메시지를 전송합니다. +echo ai-service가 실행 중이어야 메시지를 처리할 수 있습니다. +echo. +echo Kafka Brokers: 20.249.182.13:9095, 4.217.131.59:9095 +echo Topic: ai-event-generation-job +echo. +echo ================================================ +echo. + +REM 테스트 클래스 실행 +.\gradlew ai-service:test --tests "com.kt.ai.test.manual.KafkaManualTest" --info + +echo. +echo ================================================ +echo 테스트 완료! +echo. +echo 결과 확인: +echo 1. Job 상태 조회: +echo curl http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status +echo. +echo 2. AI 추천 결과 조회: +echo curl http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001 +echo. +echo 3. Redis 키 확인: +echo curl http://localhost:8083/api/v1/ai-service/internal/recommendations/debug/redis-keys +echo ================================================ + +pause diff --git a/user-service/.run/user-service.run.xml b/user-service/.run/user-service.run.xml new file mode 100644 index 0000000..07dfd36 --- /dev/null +++ b/user-service/.run/user-service.run.xml @@ -0,0 +1,87 @@ + + + + + + + + true + true + + + + + false + false + + + diff --git a/user-service/build.gradle b/user-service/build.gradle index 63a1c78..ad1b873 100644 --- a/user-service/build.gradle +++ b/user-service/build.gradle @@ -7,4 +7,10 @@ dependencies { // OpenFeign for external API calls (사업자번호 검증) implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // H2 Database for development + runtimeOnly 'com.h2database:h2' + + // PostgreSQL Database for production + runtimeOnly 'org.postgresql:postgresql' } diff --git a/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java b/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java new file mode 100644 index 0000000..007a47d --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java @@ -0,0 +1,30 @@ +package com.kt.event.user; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * User Service Application + * + * KT AI 기반 소상공인 이벤트 자동 생성 서비스 - User Service + * + * @author Backend Developer + * @since 1.0 + */ +@SpringBootApplication(scanBasePackages = { + "com.kt.event.user", + "com.kt.event.common" +}) +@EntityScan(basePackages = { + "com.kt.event.user.entity", + "com.kt.event.common.entity" +}) +@EnableJpaAuditing +public class UserServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(UserServiceApplication.class, args); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java b/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java new file mode 100644 index 0000000..782145c --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java @@ -0,0 +1,32 @@ +package com.kt.event.user.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.context.annotation.Bean; + +import java.util.concurrent.Executor; + +/** + * 비동기 처리 설정 + * + * @Async 어노테이션 활성화 및 스레드 풀 설정 + * + * @author Backend Developer + * @since 1.0 + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(500); + executor.setThreadNamePrefix("async-"); + executor.initialize(); + return executor; + } +} diff --git a/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java b/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java new file mode 100644 index 0000000..c4c48d6 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java @@ -0,0 +1,59 @@ +package com.kt.event.user.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 설정 + * + * Redis 연결 및 템플릿 설정 + * + * @author Backend Developer + * @since 1.0 + */ +@Configuration +@ConditionalOnProperty(name = "spring.data.redis.enabled", havingValue = "true", matchIfMissing = false) +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password:}") + private String password; + + @Value("${spring.data.redis.database:0}") + private int database; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(host); + config.setPort(port); + config.setDatabase(database); + if (password != null && !password.isEmpty()) { + config.setPassword(password); + } + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory()); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + return template; + } +} diff --git a/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java b/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java new file mode 100644 index 0000000..9e891c3 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java @@ -0,0 +1,96 @@ +package com.kt.event.user.config; + +import com.kt.event.common.security.JwtAuthenticationFilter; +import com.kt.event.common.security.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +/** + * Spring Security 설정 + * + * JWT 기반 인증 및 API 보안 설정 + * + * @author Backend Developer + * @since 1.0 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + + @Value("${cors.allowed-origins:http://localhost:*}") + private String allowedOrigins; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // Public endpoints + .requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll() + // Actuator endpoints + .requestMatchers("/actuator/**").permitAll() + // Swagger UI endpoints + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll() + // Health check + .requestMatchers("/health").permitAll() + // All other requests require authentication + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 환경변수에서 허용할 Origin 패턴 설정 + String[] origins = allowedOrigins.split(","); + configuration.setAllowedOriginPatterns(Arrays.asList(origins)); + + // 허용할 HTTP 메소드 + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + // 허용할 헤더 + configuration.setAllowedHeaders(Arrays.asList( + "Authorization", "Content-Type", "X-Requested-With", "Accept", + "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers" + )); + + // 자격 증명 허용 + configuration.setAllowCredentials(true); + + // Pre-flight 요청 캐시 시간 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java b/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java new file mode 100644 index 0000000..60ab414 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java @@ -0,0 +1,67 @@ +package com.kt.event.user.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger/OpenAPI 설정 + * + * User Service API 문서화를 위한 설정 + * + * @author Backend Developer + * @since 1.0 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(apiInfo()) + .addServersItem(new Server() + .url("http://localhost:8081") + .description("Local Development")) + .addServersItem(new Server() + .url("{protocol}://{host}:{port}") + .description("Custom Server") + .variables(new io.swagger.v3.oas.models.servers.ServerVariables() + .addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("http") + .description("Protocol (http or https)") + .addEnumItem("http") + .addEnumItem("https")) + .addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("localhost") + .description("Server host")) + .addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable() + ._default("8081") + .description("Server port")))) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new Components() + .addSecuritySchemes("Bearer Authentication", createAPIKeyScheme())); + } + + private Info apiInfo() { + return new Info() + .title("User Service API") + .description("KT AI 기반 소상공인 이벤트 자동 생성 서비스 - User Service API") + .version("1.0.0") + .contact(new Contact() + .name("Digital Garage Team") + .email("support@kt-event-marketing.com")); + } + + private SecurityScheme createAPIKeyScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .bearerFormat("JWT") + .scheme("bearer"); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/controller/UserController.java b/user-service/src/main/java/com/kt/event/user/controller/UserController.java new file mode 100644 index 0000000..4b93daf --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/controller/UserController.java @@ -0,0 +1,132 @@ +package com.kt.event.user.controller; + +import com.kt.event.common.security.UserPrincipal; +import com.kt.event.user.dto.request.ChangePasswordRequest; +import com.kt.event.user.dto.request.LoginRequest; +import com.kt.event.user.dto.request.RegisterRequest; +import com.kt.event.user.dto.request.UpdateProfileRequest; +import com.kt.event.user.dto.response.LoginResponse; +import com.kt.event.user.dto.response.LogoutResponse; +import com.kt.event.user.dto.response.ProfileResponse; +import com.kt.event.user.dto.response.RegisterResponse; +import com.kt.event.user.service.AuthenticationService; +import com.kt.event.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * User Controller + * + * 사용자 인증 및 프로필 관리 API + * + * @author Backend Developer + * @since 1.0 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +@Tag(name = "User", description = "사용자 인증 및 프로필 관리 API") +public class UserController { + + private final UserService userService; + private final AuthenticationService authenticationService; + + /** + * 회원가입 + * + * UFR-USER-010: 회원가입 + */ + @PostMapping("/register") + @Operation(summary = "회원가입", description = "소상공인 회원가입 API") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + log.info("회원가입 요청: phoneNumber={}, email={}", request.getPhoneNumber(), request.getEmail()); + RegisterResponse response = userService.register(request); + log.info("회원가입 성공: userId={}", response.getUserId()); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 로그인 + * + * UFR-USER-020: 로그인 + */ + @PostMapping("/login") + @Operation(summary = "로그인", description = "소상공인 로그인 API") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + log.info("로그인 요청: email={}", request.getEmail()); + LoginResponse response = authenticationService.login(request); + log.info("로그인 성공: userId={}", response.getUserId()); + return ResponseEntity.ok(response); + } + + /** + * 로그아웃 + * + * UFR-USER-040: 로그아웃 + */ + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "로그아웃 API") + public ResponseEntity logout(@RequestHeader("Authorization") String authHeader) { + String token = authHeader.substring(7); // "Bearer " 제거 + log.info("로그아웃 요청"); + LogoutResponse response = authenticationService.logout(token); + log.info("로그아웃 성공"); + return ResponseEntity.ok(response); + } + + /** + * 프로필 조회 + * + * UFR-USER-030: 프로필 관리 + */ + @GetMapping("/profile") + @Operation(summary = "프로필 조회", description = "사용자 프로필 조회 API") + public ResponseEntity getProfile(@AuthenticationPrincipal UserPrincipal principal) { + Long userId = principal.getUserId(); + log.info("프로필 조회 요청: userId={}", userId); + ProfileResponse response = userService.getProfile(userId); + return ResponseEntity.ok(response); + } + + /** + * 프로필 수정 + * + * UFR-USER-030: 프로필 관리 + */ + @PutMapping("/profile") + @Operation(summary = "프로필 수정", description = "사용자 프로필 수정 API") + public ResponseEntity updateProfile( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody UpdateProfileRequest request) { + Long userId = principal.getUserId(); + log.info("프로필 수정 요청: userId={}", userId); + ProfileResponse response = userService.updateProfile(userId, request); + log.info("프로필 수정 성공: userId={}", userId); + return ResponseEntity.ok(response); + } + + /** + * 비밀번호 변경 + * + * UFR-USER-030: 프로필 관리 + */ + @PutMapping("/password") + @Operation(summary = "비밀번호 변경", description = "비밀번호 변경 API") + public ResponseEntity changePassword( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody ChangePasswordRequest request) { + Long userId = principal.getUserId(); + log.info("비밀번호 변경 요청: userId={}", userId); + userService.changePassword(userId, request); + log.info("비밀번호 변경 성공: userId={}", userId); + return ResponseEntity.ok().build(); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..b141321 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java @@ -0,0 +1,36 @@ +package com.kt.event.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 비밀번호 변경 요청 DTO + * + * UFR-USER-030: 프로필 관리 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChangePasswordRequest { + + /** + * 현재 비밀번호 + */ + @NotBlank(message = "현재 비밀번호는 필수입니다") + private String currentPassword; + + /** + * 새 비밀번호 (8자 이상) + */ + @NotBlank(message = "새 비밀번호는 필수입니다") + @Size(min = 8, max = 100, message = "새 비밀번호는 8자 이상 100자 이하여야 합니다") + private String newPassword; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java new file mode 100644 index 0000000..b743595 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java @@ -0,0 +1,38 @@ +package com.kt.event.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 로그인 요청 DTO + * + * UFR-USER-020: 로그인 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LoginRequest { + + /** + * 이메일 주소 + */ + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다") + private String email; + + /** + * 비밀번호 + */ + @NotBlank(message = "비밀번호는 필수입니다") + private String password; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java new file mode 100644 index 0000000..95db436 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java @@ -0,0 +1,80 @@ +package com.kt.event.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 회원가입 요청 DTO + * + * UFR-USER-010: 회원가입 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RegisterRequest { + + /** + * 사용자 이름 (2자 이상) + */ + @NotBlank(message = "이름은 필수입니다") + @Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다") + private String name; + + /** + * 전화번호 (010XXXXXXXX 형식) + */ + @NotBlank(message = "전화번호는 필수입니다") + @Pattern(regexp = "^010\\d{8}$", message = "전화번호는 010XXXXXXXX 형식이어야 합니다") + private String phoneNumber; + + /** + * 이메일 주소 + */ + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + private String email; + + /** + * 비밀번호 (8자 이상) + */ + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, max = 100, message = "비밀번호는 8자 이상 100자 이하여야 합니다") + private String password; + + /** + * 매장명 + */ + @NotBlank(message = "매장명은 필수입니다") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") + private String storeName; + + /** + * 업종 + */ + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String industry; + + /** + * 주소 + */ + @NotBlank(message = "주소는 필수입니다") + @Size(max = 255, message = "주소는 255자 이하여야 합니다") + private String address; + + /** + * 영업시간 + */ + @Size(max = 255, message = "영업시간은 255자 이하여야 합니다") + private String businessHours; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java new file mode 100644 index 0000000..6ca8ea9 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java @@ -0,0 +1,67 @@ +package com.kt.event.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 프로필 수정 요청 DTO + * + * UFR-USER-030: 프로필 관리 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UpdateProfileRequest { + + /** + * 사용자 이름 + */ + @Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다") + private String name; + + /** + * 전화번호 (010XXXXXXXX 형식) + */ + @Pattern(regexp = "^010\\d{8}$", message = "전화번호는 010XXXXXXXX 형식이어야 합니다") + private String phoneNumber; + + /** + * 이메일 주소 + */ + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + private String email; + + /** + * 매장명 + */ + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") + private String storeName; + + /** + * 업종 + */ + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String industry; + + /** + * 주소 + */ + @Size(max = 255, message = "주소는 255자 이하여야 합니다") + private String address; + + /** + * 영업시간 + */ + @Size(max = 255, message = "영업시간은 255자 이하여야 합니다") + private String businessHours; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java new file mode 100644 index 0000000..9fc930b --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java @@ -0,0 +1,46 @@ +package com.kt.event.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 로그인 응답 DTO + * + * UFR-USER-020: 로그인 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LoginResponse { + + /** + * JWT 토큰 + */ + private String token; + + /** + * 사용자 ID + */ + private Long userId; + + /** + * 사용자 이름 + */ + private String userName; + + /** + * 역할 + */ + private String role; + + /** + * 이메일 + */ + private String email; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java new file mode 100644 index 0000000..cebfb57 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java @@ -0,0 +1,31 @@ +package com.kt.event.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 로그아웃 응답 DTO + * + * UFR-USER-040: 로그아웃 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LogoutResponse { + + /** + * 성공 여부 + */ + private boolean success; + + /** + * 메시지 + */ + private String message; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java new file mode 100644 index 0000000..334e2cb --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java @@ -0,0 +1,83 @@ +package com.kt.event.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 프로필 응답 DTO + * + * UFR-USER-030: 프로필 관리 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProfileResponse { + + /** + * 사용자 ID + */ + private Long userId; + + /** + * 사용자 이름 + */ + private String userName; + + /** + * 전화번호 + */ + private String phoneNumber; + + /** + * 이메일 + */ + private String email; + + /** + * 역할 + */ + private String role; + + /** + * 매장 ID + */ + private Long storeId; + + /** + * 매장명 + */ + private String storeName; + + /** + * 업종 + */ + private String industry; + + /** + * 주소 + */ + private String address; + + /** + * 영업시간 + */ + private String businessHours; + + /** + * 생성일시 + */ + private LocalDateTime createdAt; + + /** + * 최종 로그인 일시 + */ + private LocalDateTime lastLoginAt; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java new file mode 100644 index 0000000..6f01cdd --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java @@ -0,0 +1,46 @@ +package com.kt.event.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 회원가입 응답 DTO + * + * UFR-USER-010: 회원가입 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RegisterResponse { + + /** + * JWT 토큰 + */ + private String token; + + /** + * 사용자 ID + */ + private Long userId; + + /** + * 사용자 이름 + */ + private String userName; + + /** + * 매장 ID + */ + private Long storeId; + + /** + * 매장명 + */ + private String storeName; +} diff --git a/user-service/src/main/java/com/kt/event/user/entity/Store.java b/user-service/src/main/java/com/kt/event/user/entity/Store.java new file mode 100644 index 0000000..75917db --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/entity/Store.java @@ -0,0 +1,93 @@ +package com.kt.event.user.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +/** + * 매장 엔티티 + * + * 소상공인 매장 정보를 저장하는 엔티티 + * + * @author Backend Developer + * @since 1.0 + */ +@Entity +@Table(name = "stores") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Store extends BaseTimeEntity { + + /** + * 매장 ID (Primary Key) + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "store_id") + private Long id; + + /** + * 매장명 + */ + @Column(name = "name", nullable = false, length = 100) + private String name; + + /** + * 업종 + */ + @Column(name = "industry", length = 50) + private String industry; + + /** + * 주소 + */ + @Column(name = "address", nullable = false, length = 255) + private String address; + + /** + * 영업시간 + */ + @Column(name = "business_hours", length = 255) + private String businessHours; + + /** + * 사용자 정보 (One-to-One) + */ + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /** + * 사용자 연결 (내부용) + * + * @param user 사용자 + */ + void setUser(User user) { + this.user = user; + } + + /** + * 매장 정보 수정 + * + * @param name 매장명 + * @param industry 업종 + * @param address 주소 + * @param businessHours 영업시간 + */ + public void updateInfo(String name, String industry, String address, String businessHours) { + if (name != null) { + this.name = name; + } + if (industry != null) { + this.industry = industry; + } + if (address != null) { + this.address = address; + } + if (businessHours != null) { + this.businessHours = businessHours; + } + } +} diff --git a/user-service/src/main/java/com/kt/event/user/entity/User.java b/user-service/src/main/java/com/kt/event/user/entity/User.java new file mode 100644 index 0000000..89ec86e --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/entity/User.java @@ -0,0 +1,174 @@ +package com.kt.event.user.entity; + +import com.kt.event.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 사용자 엔티티 + * + * 소상공인 사용자 정보를 저장하는 엔티티 + * + * @author Backend Developer + * @since 1.0 + */ +@Entity +@Table(name = "users", indexes = { + @Index(name = "idx_user_phone", columnList = "phone_number", unique = true), + @Index(name = "idx_user_email", columnList = "email", unique = true) +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class User extends BaseTimeEntity { + + /** + * 사용자 ID (Primary Key) + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + /** + * 사용자 이름 + */ + @Column(name = "name", nullable = false, length = 50) + private String name; + + /** + * 전화번호 (로그인 ID로도 사용) + */ + @Column(name = "phone_number", nullable = false, unique = true, length = 20) + private String phoneNumber; + + /** + * 이메일 주소 + */ + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + /** + * 비밀번호 (bcrypt 해시) + */ + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + /** + * 사용자 역할 (기본값: OWNER) + */ + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false, length = 20) + @Builder.Default + private UserRole role = UserRole.OWNER; + + /** + * 계정 상태 (기본값: ACTIVE) + */ + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private UserStatus status = UserStatus.ACTIVE; + + /** + * 최종 로그인 일시 + */ + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; + + /** + * 매장 정보 (One-to-One) + */ + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Store store; + + /** + * 최종 로그인 시각 업데이트 + */ + public void updateLastLoginAt() { + this.lastLoginAt = LocalDateTime.now(); + } + + /** + * 비밀번호 변경 + * + * @param newPasswordHash 새 비밀번호 해시 + */ + public void changePassword(String newPasswordHash) { + this.passwordHash = newPasswordHash; + } + + /** + * 프로필 정보 수정 + * + * @param name 이름 + * @param email 이메일 + * @param phoneNumber 전화번호 + */ + public void updateProfile(String name, String email, String phoneNumber) { + if (name != null) { + this.name = name; + } + if (email != null) { + this.email = email; + } + if (phoneNumber != null) { + this.phoneNumber = phoneNumber; + } + } + + /** + * 매장 정보 연결 + * + * @param store 매장 정보 + */ + public void setStore(Store store) { + this.store = store; + if (store != null) { + store.setUser(this); + } + } + + /** + * 사용자 역할 Enum + */ + public enum UserRole { + /** + * 매장 소유주 + */ + OWNER, + + /** + * 시스템 관리자 + */ + ADMIN + } + + /** + * 사용자 계정 상태 Enum + */ + public enum UserStatus { + /** + * 활성 상태 + */ + ACTIVE, + + /** + * 비활성 상태 + */ + INACTIVE, + + /** + * 잠금 상태 (보안상 이유) + */ + LOCKED, + + /** + * 탈퇴 상태 + */ + WITHDRAWN + } +} diff --git a/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java b/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java new file mode 100644 index 0000000..3f82060 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java @@ -0,0 +1,44 @@ +package com.kt.event.user.exception; + +import com.kt.event.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * User Service 에러 코드 + * + * Common 모듈의 ErrorCode enum을 사용 + * User Service에서 사용하는 에러 코드만 열거 + * + * @author Backend Developer + * @since 1.0 + */ +@Getter +@RequiredArgsConstructor +public enum UserErrorCode { + + // User 관련 에러 - Common ErrorCode 사용 + USER_DUPLICATE_EMAIL(ErrorCode.USER_001), + USER_DUPLICATE_PHONE(ErrorCode.USER_001), // 중복 사용자로 처리 + USER_NOT_FOUND(ErrorCode.USER_003), + + // Authentication 관련 에러 - Common ErrorCode 사용 + AUTH_FAILED(ErrorCode.AUTH_001), + AUTH_INVALID_TOKEN(ErrorCode.AUTH_002), + AUTH_TOKEN_EXPIRED(ErrorCode.AUTH_003), + AUTH_UNAUTHORIZED(ErrorCode.AUTH_001), + + // Password 관련 에러 - Common ErrorCode 사용 + PWD_INVALID_CURRENT(ErrorCode.USER_004), + PWD_SAME_AS_CURRENT(ErrorCode.USER_004); + + private final ErrorCode errorCode; + + public String getCode() { + return errorCode.getCode(); + } + + public String getMessage() { + return errorCode.getMessage(); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java b/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java new file mode 100644 index 0000000..dfab0ef --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java @@ -0,0 +1,27 @@ +package com.kt.event.user.repository; + +import com.kt.event.user.entity.Store; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 매장 Repository + * + * 매장 데이터 액세스 인터페이스 + * + * @author Backend Developer + * @since 1.0 + */ +@Repository +public interface StoreRepository extends JpaRepository { + + /** + * 사용자 ID로 매장 조회 + * + * @param userId 사용자 ID + * @return 매장 Optional + */ + Optional findByUserId(Long userId); +} diff --git a/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java b/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java new file mode 100644 index 0000000..91b6606 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java @@ -0,0 +1,65 @@ +package com.kt.event.user.repository; + +import com.kt.event.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * 사용자 Repository + * + * 사용자 데이터 액세스 인터페이스 + * + * @author Backend Developer + * @since 1.0 + */ +@Repository +public interface UserRepository extends JpaRepository { + + /** + * 이메일로 사용자 조회 + * + * @param email 이메일 + * @return 사용자 Optional + */ + Optional findByEmail(String email); + + /** + * 전화번호로 사용자 조회 + * + * @param phoneNumber 전화번호 + * @return 사용자 Optional + */ + Optional findByPhoneNumber(String phoneNumber); + + /** + * 이메일 존재 여부 확인 + * + * @param email 이메일 + * @return 존재 여부 + */ + boolean existsByEmail(String email); + + /** + * 전화번호 존재 여부 확인 + * + * @param phoneNumber 전화번호 + * @return 존재 여부 + */ + boolean existsByPhoneNumber(String phoneNumber); + + /** + * 최종 로그인 시각 업데이트 + * + * @param userId 사용자 ID + * @param lastLoginAt 최종 로그인 시각 + */ + @Modifying + @Query("UPDATE User u SET u.lastLoginAt = :lastLoginAt WHERE u.id = :userId") + void updateLastLoginAt(@Param("userId") Long userId, @Param("lastLoginAt") LocalDateTime lastLoginAt); +} diff --git a/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java b/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java new file mode 100644 index 0000000..f014196 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java @@ -0,0 +1,32 @@ +package com.kt.event.user.service; + +import com.kt.event.user.dto.request.LoginRequest; +import com.kt.event.user.dto.response.LoginResponse; +import com.kt.event.user.dto.response.LogoutResponse; + +/** + * Authentication Service Interface + * + * 인증 관련 비즈니스 로직 인터페이스 + * + * @author Backend Developer + * @since 1.0 + */ +public interface AuthenticationService { + + /** + * 로그인 + * + * @param request 로그인 요청 + * @return 로그인 응답 + */ + LoginResponse login(LoginRequest request); + + /** + * 로그아웃 + * + * @param token JWT 토큰 + * @return 로그아웃 응답 + */ + LogoutResponse logout(String token); +} diff --git a/user-service/src/main/java/com/kt/event/user/service/UserService.java b/user-service/src/main/java/com/kt/event/user/service/UserService.java new file mode 100644 index 0000000..da171a5 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/service/UserService.java @@ -0,0 +1,58 @@ +package com.kt.event.user.service; + +import com.kt.event.user.dto.request.ChangePasswordRequest; +import com.kt.event.user.dto.request.UpdateProfileRequest; +import com.kt.event.user.dto.request.RegisterRequest; +import com.kt.event.user.dto.response.ProfileResponse; +import com.kt.event.user.dto.response.RegisterResponse; + +/** + * User Service Interface + * + * 사용자 관리 비즈니스 로직 인터페이스 + * + * @author Backend Developer + * @since 1.0 + */ +public interface UserService { + + /** + * 회원가입 + * + * @param request 회원가입 요청 + * @return 회원가입 응답 + */ + RegisterResponse register(RegisterRequest request); + + /** + * 프로필 조회 + * + * @param userId 사용자 ID + * @return 프로필 응답 + */ + ProfileResponse getProfile(Long userId); + + /** + * 프로필 수정 + * + * @param userId 사용자 ID + * @param request 프로필 수정 요청 + * @return 프로필 응답 + */ + ProfileResponse updateProfile(Long userId, UpdateProfileRequest request); + + /** + * 비밀번호 변경 + * + * @param userId 사용자 ID + * @param request 비밀번호 변경 요청 + */ + void changePassword(Long userId, ChangePasswordRequest request); + + /** + * 최종 로그인 시각 업데이트 (비동기) + * + * @param userId 사용자 ID + */ + void updateLastLoginAt(Long userId); +} diff --git a/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java b/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java new file mode 100644 index 0000000..0694c81 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java @@ -0,0 +1,157 @@ +package com.kt.event.user.service.impl; + +import com.kt.event.common.exception.BusinessException; +import com.kt.event.common.security.JwtTokenProvider; +import com.kt.event.user.dto.request.LoginRequest; +import com.kt.event.user.dto.response.LoginResponse; +import com.kt.event.user.dto.response.LogoutResponse; +import com.kt.event.user.entity.Store; +import com.kt.event.user.entity.User; +import com.kt.event.user.exception.UserErrorCode; +import com.kt.event.user.repository.StoreRepository; +import com.kt.event.user.repository.UserRepository; +import com.kt.event.user.service.AuthenticationService; +import com.kt.event.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Authentication Service 구현체 + * + * 인증 관련 비즈니스 로직 구현 + * + * @author Backend Developer + * @since 1.0 + */ +@Slf4j +@Service +@Transactional(readOnly = true) +public class AuthenticationServiceImpl implements AuthenticationService { + + private final UserRepository userRepository; + private final StoreRepository storeRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final UserService userService; + + @Autowired(required = false) + private RedisTemplate redisTemplate; + + public AuthenticationServiceImpl(UserRepository userRepository, + StoreRepository storeRepository, + PasswordEncoder passwordEncoder, + JwtTokenProvider jwtTokenProvider, + UserService userService) { + this.userRepository = userRepository; + this.storeRepository = storeRepository; + this.passwordEncoder = passwordEncoder; + this.jwtTokenProvider = jwtTokenProvider; + this.userService = userService; + } + + /** + * 로그인 + * + * UFR-USER-020: 로그인 + */ + @Override + @Transactional(readOnly = false) + public LoginResponse login(LoginRequest request) { + // 1. 사용자 조회 (이메일 기반) + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new BusinessException(UserErrorCode.AUTH_FAILED.getErrorCode())); + + // 2. 비밀번호 검증 + if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { + throw new BusinessException(UserErrorCode.AUTH_FAILED.getErrorCode()); + } + + // 3. 매장 정보 조회 + Store store = storeRepository.findByUserId(user.getId()).orElse(null); + Long storeId = store != null ? store.getId() : null; + + // 4. JWT 토큰 생성 + String token = jwtTokenProvider.createAccessToken( + user.getId(), + storeId, + user.getEmail(), + user.getName(), + List.of(user.getRole().name()) + ); + + // 5. Redis 세션 저장 (TTL 7일) + saveSession(token, user.getId(), user.getRole().name()); + + // 6. 최종 로그인 시각 업데이트 (비동기) + userService.updateLastLoginAt(user.getId()); + + // 7. 응답 반환 + return LoginResponse.builder() + .token(token) + .userId(user.getId()) + .userName(user.getName()) + .role(user.getRole().name()) + .email(user.getEmail()) + .build(); + } + + /** + * 로그아웃 + * + * UFR-USER-040: 로그아웃 + */ + @Override + public LogoutResponse logout(String token) { + // 1. JWT 토큰 검증 + if (!jwtTokenProvider.validateToken(token)) { + throw new BusinessException(UserErrorCode.AUTH_INVALID_TOKEN.getErrorCode()); + } + + // 2. Redis 세션 삭제 (Redis가 활성화된 경우에만) + if (redisTemplate != null) { + String sessionKey = "user:session:" + token; + redisTemplate.delete(sessionKey); + + // 3. JWT Blacklist 추가 (남은 만료 시간만큼 TTL 설정) + String blacklistKey = "jwt:blacklist:" + token; + long remainingTime = jwtTokenProvider.getExpirationFromToken(token).getTime() - System.currentTimeMillis(); + if (remainingTime > 0) { + redisTemplate.opsForValue().set(blacklistKey, "true", remainingTime, TimeUnit.MILLISECONDS); + } + log.debug("Redis session and blacklist updated for logout"); + } else { + log.warn("Redis is disabled. Session not cleared from Redis."); + } + + // 4. 응답 반환 + return LogoutResponse.builder() + .success(true) + .message("안전하게 로그아웃되었습니다") + .build(); + } + + /** + * Redis 세션 저장 (Redis가 활성화된 경우에만) + * + * @param token JWT 토큰 + * @param userId 사용자 ID + * @param role 역할 + */ + private void saveSession(String token, Long userId, String role) { + if (redisTemplate != null) { + String key = "user:session:" + token; + String value = userId + ":" + role; + redisTemplate.opsForValue().set(key, value, 7, TimeUnit.DAYS); + log.debug("Redis session saved: userId={}", userId); + } else { + log.warn("Redis is disabled. Session not saved to Redis."); + } + } +} diff --git a/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java b/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..7cae408 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java @@ -0,0 +1,237 @@ +package com.kt.event.user.service.impl; + +import com.kt.event.common.exception.BusinessException; +import com.kt.event.common.security.JwtTokenProvider; +import com.kt.event.user.dto.request.ChangePasswordRequest; +import com.kt.event.user.dto.request.RegisterRequest; +import com.kt.event.user.dto.request.UpdateProfileRequest; +import com.kt.event.user.dto.response.ProfileResponse; +import com.kt.event.user.dto.response.RegisterResponse; +import com.kt.event.user.entity.Store; +import com.kt.event.user.entity.User; +import com.kt.event.user.exception.UserErrorCode; +import com.kt.event.user.repository.StoreRepository; +import com.kt.event.user.repository.UserRepository; +import com.kt.event.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * User Service 구현체 + * + * 사용자 관리 비즈니스 로직 구현 + * + * @author Backend Developer + * @since 1.0 + */ +@Slf4j +@Service +@Transactional(readOnly = true) +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final StoreRepository storeRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + @Autowired(required = false) + private RedisTemplate redisTemplate; + + public UserServiceImpl(UserRepository userRepository, + StoreRepository storeRepository, + PasswordEncoder passwordEncoder, + JwtTokenProvider jwtTokenProvider) { + this.userRepository = userRepository; + this.storeRepository = storeRepository; + this.passwordEncoder = passwordEncoder; + this.jwtTokenProvider = jwtTokenProvider; + } + + /** + * 회원가입 + * + * UFR-USER-010: 회원가입 + */ + @Override + @Transactional + public RegisterResponse register(RegisterRequest request) { + // 1. 이메일 중복 확인 + if (userRepository.existsByEmail(request.getEmail())) { + throw new BusinessException(UserErrorCode.USER_DUPLICATE_EMAIL.getErrorCode()); + } + + // 2. 전화번호 중복 확인 + if (userRepository.existsByPhoneNumber(request.getPhoneNumber())) { + throw new BusinessException(UserErrorCode.USER_DUPLICATE_PHONE.getErrorCode()); + } + + // 3. 비밀번호 해싱 + String passwordHash = passwordEncoder.encode(request.getPassword()); + + // 4. User 엔티티 생성 및 저장 + User user = User.builder() + .name(request.getName()) + .phoneNumber(request.getPhoneNumber()) + .email(request.getEmail()) + .passwordHash(passwordHash) + .role(User.UserRole.OWNER) + .status(User.UserStatus.ACTIVE) + .build(); + + User savedUser = userRepository.save(user); + + // 5. Store 엔티티 생성 및 저장 + Store store = Store.builder() + .name(request.getStoreName()) + .industry(request.getIndustry()) + .address(request.getAddress()) + .businessHours(request.getBusinessHours()) + .user(savedUser) + .build(); + + Store savedStore = storeRepository.save(store); + + // 6. JWT 토큰 생성 + String token = jwtTokenProvider.createAccessToken( + savedUser.getId(), + savedStore.getId(), + savedUser.getEmail(), + savedUser.getName(), + List.of(savedUser.getRole().name()) + ); + + // 7. Redis 세션 저장 (TTL 7일) + saveSession(token, savedUser.getId(), savedUser.getRole().name()); + + // 8. 응답 반환 + return RegisterResponse.builder() + .token(token) + .userId(savedUser.getId()) + .userName(savedUser.getName()) + .storeId(savedStore.getId()) + .storeName(savedStore.getName()) + .build(); + } + + /** + * 프로필 조회 + * + * UFR-USER-030: 프로필 관리 + */ + @Override + public ProfileResponse getProfile(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode())); + + Store store = storeRepository.findByUserId(userId) + .orElse(null); + + return ProfileResponse.builder() + .userId(user.getId()) + .userName(user.getName()) + .phoneNumber(user.getPhoneNumber()) + .email(user.getEmail()) + .role(user.getRole().name()) + .storeId(store != null ? store.getId() : null) + .storeName(store != null ? store.getName() : null) + .industry(store != null ? store.getIndustry() : null) + .address(store != null ? store.getAddress() : null) + .businessHours(store != null ? store.getBusinessHours() : null) + .createdAt(user.getCreatedAt()) + .lastLoginAt(user.getLastLoginAt()) + .build(); + } + + /** + * 프로필 수정 + * + * UFR-USER-030: 프로필 관리 + */ + @Override + @Transactional + public ProfileResponse updateProfile(Long userId, UpdateProfileRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode())); + + // User 정보 수정 + user.updateProfile(request.getName(), request.getEmail(), request.getPhoneNumber()); + + // Store 정보 수정 + Store store = storeRepository.findByUserId(userId).orElse(null); + if (store != null) { + store.updateInfo( + request.getStoreName(), + request.getIndustry(), + request.getAddress(), + request.getBusinessHours() + ); + } + + return getProfile(userId); + } + + /** + * 비밀번호 변경 + * + * UFR-USER-030: 프로필 관리 + */ + @Override + @Transactional + public void changePassword(Long userId, ChangePasswordRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode())); + + // 현재 비밀번호 검증 + if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPasswordHash())) { + throw new BusinessException(UserErrorCode.PWD_INVALID_CURRENT.getErrorCode()); + } + + // 새 비밀번호가 현재 비밀번호와 동일한지 확인 + if (passwordEncoder.matches(request.getNewPassword(), user.getPasswordHash())) { + throw new BusinessException(UserErrorCode.PWD_SAME_AS_CURRENT.getErrorCode()); + } + + // 새 비밀번호 해싱 및 저장 + String newPasswordHash = passwordEncoder.encode(request.getNewPassword()); + user.changePassword(newPasswordHash); + } + + /** + * 최종 로그인 시각 업데이트 (비동기) + * + * UFR-USER-020: 로그인 + */ + @Override + @Async + @Transactional + public void updateLastLoginAt(Long userId) { + userRepository.updateLastLoginAt(userId, LocalDateTime.now()); + } + + /** + * Redis 세션 저장 (Redis가 활성화된 경우에만) + * + * @param token JWT 토큰 + * @param userId 사용자 ID + * @param role 역할 + */ + private void saveSession(String token, Long userId, String role) { + if (redisTemplate != null) { + String key = "user:session:" + token; + String value = userId + ":" + role; + redisTemplate.opsForValue().set(key, value, 7, TimeUnit.DAYS); + log.debug("Redis session saved: userId={}", userId); + } else { + log.warn("Redis is disabled. Session not saved to Redis."); + } + } +} diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml new file mode 100644 index 0000000..4637dd2 --- /dev/null +++ b/user-service/src/main/resources/application.yml @@ -0,0 +1,123 @@ +spring: + application: + name: user-service + + # Database Configuration (PostgreSQL) + datasource: + url: ${DB_URL:jdbc:postgresql://20.249.125.115:5432/userdb} + username: ${DB_USERNAME:eventuser} + password: ${DB_PASSWORD:Hi5Jessica!} + driver-class-name: ${DB_DRIVER:org.postgresql.Driver} + hikari: + maximum-pool-size: ${DB_POOL_MAX:20} + minimum-idle: ${DB_POOL_MIN:5} + connection-timeout: ${DB_CONN_TIMEOUT:30000} + idle-timeout: ${DB_IDLE_TIMEOUT:600000} + max-lifetime: ${DB_MAX_LIFETIME:1800000} + leak-detection-threshold: ${DB_LEAK_THRESHOLD:60000} + + # H2 Console (개발용 - PostgreSQL 사용 시 비활성화) + h2: + console: + enabled: ${H2_CONSOLE_ENABLED:false} + path: /h2-console + + # JPA Configuration + jpa: + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + format_sql: true + use_sql_comments: true + dialect: ${JPA_DIALECT:org.hibernate.dialect.PostgreSQLDialect} + hibernate: + ddl-auto: ${DDL_AUTO:update} + + # Auto-configuration exclusions for development without external services + autoconfigure: + exclude: + - ${EXCLUDE_KAFKA:} + - ${EXCLUDE_REDIS:} + + # Redis Configuration + data: + redis: + enabled: ${REDIS_ENABLED:true} + host: ${REDIS_HOST:20.214.210.71} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:Hi5Jessica!} + timeout: ${REDIS_TIMEOUT:2000ms} + lettuce: + pool: + max-active: ${REDIS_POOL_MAX:8} + max-idle: ${REDIS_POOL_IDLE:8} + min-idle: ${REDIS_POOL_MIN:0} + max-wait: ${REDIS_POOL_WAIT:-1ms} + database: ${REDIS_DATABASE:0} + + # Kafka Configuration + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092} + consumer: + group-id: ${KAFKA_CONSUMER_GROUP:user-service-consumers} + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + +# JWT Configuration +jwt: + secret: ${JWT_SECRET:kt-event-marketing-secret-key-for-development-only-please-change-in-production} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:604800000} # 7 days in milliseconds + +# CORS Configuration +cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*} + +# Actuator +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + base-path: /actuator + endpoint: + health: + show-details: always + show-components: always + health: + livenessState: + enabled: true + readinessState: + enabled: true + +# OpenAPI Documentation +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + show-actuator: false + +# Logging +logging: + level: + com.kt.event.user: ${LOG_LEVEL_APP:DEBUG} + org.springframework.web: ${LOG_LEVEL_WEB:INFO} + org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG} + org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE} + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: ${LOG_FILE_PATH:logs/user-service.log} + +# Server Configuration +server: + port: ${SERVER_PORT:8081}