store register add
This commit is contained in:
parent
b25c2edcc0
commit
17a68d3cdb
362
.github/workflows/member-ci.yml
vendored
362
.github/workflows/member-ci.yml
vendored
@ -1,182 +1,182 @@
|
||||
name: Member CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'member/**'
|
||||
- 'common/**'
|
||||
- 'build.gradle'
|
||||
- 'settings.gradle'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'member/**'
|
||||
- 'common/**'
|
||||
- 'build.gradle'
|
||||
- 'settings.gradle'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ACR_NAME: acrdigitalgarage03
|
||||
IMAGE_NAME: hiorder/member
|
||||
MANIFEST_REPO: dg04-hi/hi-manifest
|
||||
MANIFEST_FILE_PATH: member/deployment.yml
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
gradle-version: '8.13'
|
||||
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Generate Gradle Wrapper
|
||||
run: |
|
||||
echo "Generating gradle wrapper..."
|
||||
gradle wrapper --gradle-version 8.13
|
||||
chmod +x gradlew
|
||||
echo "Testing gradle wrapper..."
|
||||
./gradlew --version
|
||||
|
||||
- name: Build analytics module with dependencies
|
||||
run: ./gradlew member:build -x test
|
||||
|
||||
- name: Run analytics tests
|
||||
run: ./gradlew member:test
|
||||
|
||||
- name: Generate build timestamp
|
||||
id: timestamp
|
||||
run: echo "BUILD_TIME=$(date +'%y%m%d%H%M')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Azure Container Registry
|
||||
uses: azure/docker-login@v1
|
||||
with:
|
||||
login-server: ${{ env.ACR_NAME }}.azurecr.io
|
||||
username: ${{ secrets.ACR_USERNAME }}
|
||||
password: ${{ secrets.ACR_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./member/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.timestamp.outputs.BUILD_TIME }}
|
||||
${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Output image tags
|
||||
run: |
|
||||
echo "🎉 Image pushed successfully!"
|
||||
echo "📦 Image: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}"
|
||||
echo "🏷️ Tags: ${{ steps.timestamp.outputs.BUILD_TIME }}, latest"
|
||||
|
||||
# 🚀 Manifest 레포지토리 업데이트 단계 추가
|
||||
- name: Checkout manifest repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.MANIFEST_REPO }}
|
||||
token: ${{ secrets.MANIFEST_REPO_TOKEN }}
|
||||
path: manifest-repo
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||
sudo chmod +x /usr/local/bin/yq
|
||||
|
||||
- name: Update deployment image tag
|
||||
run: |
|
||||
cd manifest-repo
|
||||
NEW_IMAGE="${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.timestamp.outputs.BUILD_TIME }}"
|
||||
echo "Updating image tag to: $NEW_IMAGE"
|
||||
|
||||
# deployment.yml에서 이미지 태그 업데이트
|
||||
yq eval '.spec.template.spec.containers[0].image = "'$NEW_IMAGE'"' -i ${{ env.MANIFEST_FILE_PATH }}
|
||||
|
||||
# 변경사항 확인
|
||||
echo "Updated deployment.yml:"
|
||||
cat ${{ env.MANIFEST_FILE_PATH }}
|
||||
|
||||
- name: Commit and push changes
|
||||
run: |
|
||||
cd manifest-repo
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
git add ${{ env.MANIFEST_FILE_PATH }}
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "🚀 Update analytics image tag to ${{ steps.timestamp.outputs.BUILD_TIME }}
|
||||
|
||||
- Updated by: ${{ github.actor }}
|
||||
- Triggered by: ${{ github.event_name }}
|
||||
- Source commit: ${{ github.sha }}
|
||||
- Build time: ${{ steps.timestamp.outputs.BUILD_TIME }}"
|
||||
|
||||
git push
|
||||
echo "✅ Successfully updated manifest repository"
|
||||
fi
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: member-test-results
|
||||
path: member/build/reports/tests/test/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: success()
|
||||
with:
|
||||
name: member-jar
|
||||
path: member/build/libs/*.jar
|
||||
|
||||
# 🎯 배포 완료 알림
|
||||
- name: Deployment summary
|
||||
if: success()
|
||||
run: |
|
||||
echo "## 🚀 Analytics Service Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Container Image" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Registry**: ${{ env.ACR_NAME }}.azurecr.io" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Image**: ${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tag**: ${{ steps.timestamp.outputs.BUILD_TIME }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🔄 ArgoCD Sync" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Manifest Repo**: https://github.com/${{ env.MANIFEST_REPO }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Updated File**: ${{ env.MANIFEST_FILE_PATH }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **ArgoCD will automatically sync the new image**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ⏱️ Build Info" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Build Time**: $(date)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Triggered By**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
||||
name: Member CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'member/**'
|
||||
- 'common/**'
|
||||
- 'build.gradle'
|
||||
- 'settings.gradle'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'member/**'
|
||||
- 'common/**'
|
||||
- 'build.gradle'
|
||||
- 'settings.gradle'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ACR_NAME: acrdigitalgarage03
|
||||
IMAGE_NAME: hiorder/member
|
||||
MANIFEST_REPO: dg04-hi/hi-manifest
|
||||
MANIFEST_FILE_PATH: member/deployment.yml
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
gradle-version: '8.13'
|
||||
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Generate Gradle Wrapper
|
||||
run: |
|
||||
echo "Generating gradle wrapper..."
|
||||
gradle wrapper --gradle-version 8.13
|
||||
chmod +x gradlew
|
||||
echo "Testing gradle wrapper..."
|
||||
./gradlew --version
|
||||
|
||||
- name: Build analytics module with dependencies
|
||||
run: ./gradlew member:build -x test
|
||||
|
||||
- name: Run analytics tests
|
||||
run: ./gradlew member:test
|
||||
|
||||
- name: Generate build timestamp
|
||||
id: timestamp
|
||||
run: echo "BUILD_TIME=$(date +'%y%m%d%H%M')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Azure Container Registry
|
||||
uses: azure/docker-login@v1
|
||||
with:
|
||||
login-server: ${{ env.ACR_NAME }}.azurecr.io
|
||||
username: ${{ secrets.ACR_USERNAME }}
|
||||
password: ${{ secrets.ACR_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./member/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.timestamp.outputs.BUILD_TIME }}
|
||||
${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Output image tags
|
||||
run: |
|
||||
echo "🎉 Image pushed successfully!"
|
||||
echo "📦 Image: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}"
|
||||
echo "🏷️ Tags: ${{ steps.timestamp.outputs.BUILD_TIME }}, latest"
|
||||
|
||||
# 🚀 Manifest 레포지토리 업데이트 단계 추가
|
||||
- name: Checkout manifest repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.MANIFEST_REPO }}
|
||||
token: ${{ secrets.MANIFEST_REPO_TOKEN }}
|
||||
path: manifest-repo
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||
sudo chmod +x /usr/local/bin/yq
|
||||
|
||||
- name: Update deployment image tag
|
||||
run: |
|
||||
cd manifest-repo
|
||||
NEW_IMAGE="${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.timestamp.outputs.BUILD_TIME }}"
|
||||
echo "Updating image tag to: $NEW_IMAGE"
|
||||
|
||||
# deployment.yml에서 이미지 태그 업데이트
|
||||
yq eval '.spec.template.spec.containers[0].image = "'$NEW_IMAGE'"' -i ${{ env.MANIFEST_FILE_PATH }}
|
||||
|
||||
# 변경사항 확인
|
||||
echo "Updated deployment.yml:"
|
||||
cat ${{ env.MANIFEST_FILE_PATH }}
|
||||
|
||||
- name: Commit and push changes
|
||||
run: |
|
||||
cd manifest-repo
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
git add ${{ env.MANIFEST_FILE_PATH }}
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "🚀 Update analytics image tag to ${{ steps.timestamp.outputs.BUILD_TIME }}
|
||||
|
||||
- Updated by: ${{ github.actor }}
|
||||
- Triggered by: ${{ github.event_name }}
|
||||
- Source commit: ${{ github.sha }}
|
||||
- Build time: ${{ steps.timestamp.outputs.BUILD_TIME }}"
|
||||
|
||||
git push
|
||||
echo "✅ Successfully updated manifest repository"
|
||||
fi
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: member-test-results
|
||||
path: member/build/reports/tests/test/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: success()
|
||||
with:
|
||||
name: member-jar
|
||||
path: member/build/libs/*.jar
|
||||
|
||||
# 🎯 배포 완료 알림
|
||||
- name: Deployment summary
|
||||
if: success()
|
||||
run: |
|
||||
echo "## 🚀 Analytics Service Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Container Image" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Registry**: ${{ env.ACR_NAME }}.azurecr.io" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Image**: ${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tag**: ${{ steps.timestamp.outputs.BUILD_TIME }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🔄 ArgoCD Sync" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Manifest Repo**: https://github.com/${{ env.MANIFEST_REPO }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Updated File**: ${{ env.MANIFEST_FILE_PATH }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **ArgoCD will automatically sync the new image**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ⏱️ Build Info" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Build Time**: $(date)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Triggered By**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Event**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
@ -1,402 +1,402 @@
|
||||
package com.ktds.hi.analytics.biz.service;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.Analytics;
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.*;
|
||||
import com.ktds.hi.analytics.infra.dto.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 분석 서비스 구현 클래스 (수정버전)
|
||||
* Clean Architecture의 UseCase를 구현하여 비즈니스 로직을 처리
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class AnalyticsService implements AnalyticsUseCase {
|
||||
|
||||
private final AnalyticsPort analyticsPort;
|
||||
private final AIServicePort aiServicePort;
|
||||
private final ExternalReviewPort externalReviewPort;
|
||||
private final OrderDataPort orderDataPort;
|
||||
private final CachePort cachePort;
|
||||
private final EventPort eventPort;
|
||||
|
||||
@Override
|
||||
@Cacheable(value = "storeAnalytics", key = "#storeId")
|
||||
public StoreAnalyticsResponse getStoreAnalytics(Long storeId) {
|
||||
log.info("매장 분석 데이터 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 캐시에서 먼저 확인
|
||||
String cacheKey = "analytics:store:" + storeId;
|
||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||
if (cachedResult.isPresent()) {
|
||||
log.info("캐시에서 분석 데이터 반환: storeId={}", storeId);
|
||||
return (StoreAnalyticsResponse) cachedResult.get();
|
||||
}
|
||||
|
||||
// 2. 데이터베이스에서 기존 분석 데이터 조회
|
||||
var analytics = analyticsPort.findAnalyticsByStoreId(storeId);
|
||||
|
||||
if (analytics.isEmpty()) {
|
||||
// 3. 분석 데이터가 없으면 새로 생성
|
||||
analytics = Optional.of(generateNewAnalytics(storeId));
|
||||
}
|
||||
|
||||
// 4. 응답 생성
|
||||
StoreAnalyticsResponse response = StoreAnalyticsResponse.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(analytics.get().getTotalReviews())
|
||||
.averageRating(analytics.get().getAverageRating())
|
||||
.sentimentScore(analytics.get().getSentimentScore())
|
||||
.positiveReviewRate(analytics.get().getPositiveReviewRate())
|
||||
.negativeReviewRate(analytics.get().getNegativeReviewRate())
|
||||
.lastAnalysisDate(analytics.get().getLastAnalysisDate())
|
||||
.build();
|
||||
|
||||
// 5. 캐시에 저장
|
||||
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(1));
|
||||
|
||||
log.info("매장 분석 데이터 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("매장 분석 데이터 조회 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("분석 데이터 조회에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ... 나머지 메서드들은 이전과 동일 ...
|
||||
|
||||
@Override
|
||||
@Cacheable(value = "aiFeedback", key = "#storeId")
|
||||
public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) {
|
||||
log.info("AI 피드백 상세 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 기존 AI 피드백 조회
|
||||
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
|
||||
|
||||
if (aiFeedback.isEmpty()) {
|
||||
// 2. AI 피드백이 없으면 새로 생성
|
||||
aiFeedback = Optional.of(generateAIFeedback(storeId));
|
||||
}
|
||||
|
||||
// 3. 응답 생성
|
||||
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
|
||||
.storeId(storeId)
|
||||
.summary(aiFeedback.get().getSummary())
|
||||
.positivePoints(aiFeedback.get().getPositivePoints())
|
||||
.improvementPoints(aiFeedback.get().getImprovementPoints())
|
||||
.recommendations(aiFeedback.get().getRecommendations())
|
||||
.sentimentAnalysis(aiFeedback.get().getSentimentAnalysis())
|
||||
.confidenceScore(aiFeedback.get().getConfidenceScore())
|
||||
.generatedAt(aiFeedback.get().getGeneratedAt())
|
||||
.build();
|
||||
|
||||
log.info("AI 피드백 상세 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 피드백 조회 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("AI 피드백 조회에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 나머지 메서드들과 private 메서드들은 이전과 동일하게 구현
|
||||
// ... (getStoreStatistics, getAIFeedbackSummary, getReviewAnalysis 등)
|
||||
|
||||
@Override
|
||||
public StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate) {
|
||||
log.info("매장 통계 조회 시작: storeId={}, startDate={}, endDate={}", storeId, startDate, endDate);
|
||||
|
||||
try {
|
||||
// 1. 캐시 키 생성
|
||||
String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate);
|
||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||
if (cachedResult.isPresent()) {
|
||||
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
|
||||
return (StoreStatisticsResponse) cachedResult.get();
|
||||
}
|
||||
|
||||
// 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용)
|
||||
var orderStatistics = orderDataPort.getOrderStatistics(storeId, startDate, endDate);
|
||||
|
||||
// 3. 응답 생성
|
||||
StoreStatisticsResponse response = StoreStatisticsResponse.builder()
|
||||
.storeId(storeId)
|
||||
.startDate(startDate)
|
||||
.endDate(endDate)
|
||||
.totalOrders(orderStatistics.getTotalOrders())
|
||||
.totalRevenue(orderStatistics.getTotalRevenue())
|
||||
.averageOrderValue(orderStatistics.getAverageOrderValue())
|
||||
.peakHour(orderStatistics.getPeakHour())
|
||||
.popularMenus(orderStatistics.getPopularMenus())
|
||||
.customerAgeDistribution(orderStatistics.getCustomerAgeDistribution())
|
||||
.build();
|
||||
|
||||
// 4. 캐시에 저장
|
||||
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofMinutes(30));
|
||||
|
||||
log.info("매장 통계 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("매장 통계 조회 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("매장 통계 조회에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId) {
|
||||
log.info("AI 피드백 요약 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 캐시에서 확인
|
||||
String cacheKey = "ai_feedback_summary:store:" + storeId;
|
||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||
if (cachedResult.isPresent()) {
|
||||
return (AiFeedbackSummaryResponse) cachedResult.get();
|
||||
}
|
||||
|
||||
// 2. AI 피드백 조회
|
||||
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
|
||||
|
||||
if (aiFeedback.isEmpty()) {
|
||||
// 3. 피드백이 없으면 기본 응답 생성
|
||||
AiFeedbackSummaryResponse emptyResponse = AiFeedbackSummaryResponse.builder()
|
||||
.storeId(storeId)
|
||||
.hasData(false)
|
||||
.message("분석할 데이터가 부족합니다.")
|
||||
.lastUpdated(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
// 4. 응답 생성
|
||||
AiFeedbackSummaryResponse response = AiFeedbackSummaryResponse.builder()
|
||||
.storeId(storeId)
|
||||
.hasData(true)
|
||||
.message("AI 분석이 완료되었습니다.")
|
||||
.overallScore(aiFeedback.get().getConfidenceScore())
|
||||
.keyInsight(aiFeedback.get().getSummary())
|
||||
.priorityRecommendation(getFirstRecommendation(aiFeedback.get()))
|
||||
.lastUpdated(aiFeedback.get().getUpdatedAt())
|
||||
.build();
|
||||
|
||||
// 5. 캐시에 저장
|
||||
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(2));
|
||||
|
||||
log.info("AI 피드백 요약 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 피드백 요약 조회 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("AI 피드백 요약 조회에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReviewAnalysisResponse getReviewAnalysis(Long storeId) {
|
||||
log.info("리뷰 분석 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 캐시에서 확인
|
||||
String cacheKey = "review_analysis:store:" + storeId;
|
||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||
if (cachedResult.isPresent()) {
|
||||
return (ReviewAnalysisResponse) cachedResult.get();
|
||||
}
|
||||
|
||||
// 2. 최근 리뷰 데이터 조회 (30일)
|
||||
List<String> recentReviews = externalReviewPort.getRecentReviews(storeId, 30);
|
||||
|
||||
if (recentReviews.isEmpty()) {
|
||||
ReviewAnalysisResponse emptyResponse = ReviewAnalysisResponse.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(0)
|
||||
.positiveReviewCount(0)
|
||||
.negativeReviewCount(0)
|
||||
.positiveRate(0.0)
|
||||
.negativeRate(0.0)
|
||||
.analysisDate(LocalDate.now())
|
||||
.build();
|
||||
|
||||
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
// 3. 응답 생성
|
||||
int positiveCount = countPositiveReviews(recentReviews);
|
||||
int negativeCount = countNegativeReviews(recentReviews);
|
||||
int totalCount = recentReviews.size();
|
||||
|
||||
ReviewAnalysisResponse response = ReviewAnalysisResponse.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(totalCount)
|
||||
.positiveReviewCount(positiveCount)
|
||||
.negativeReviewCount(negativeCount)
|
||||
.positiveRate((double) positiveCount / totalCount * 100)
|
||||
.negativeRate((double) negativeCount / totalCount * 100)
|
||||
.analysisDate(LocalDate.now())
|
||||
.build();
|
||||
|
||||
// 4. 캐시에 저장
|
||||
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(4));
|
||||
|
||||
log.info("리뷰 분석 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("리뷰 분석 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("리뷰 분석에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// private 메서드들
|
||||
@Transactional
|
||||
public Analytics generateNewAnalytics(Long storeId) {
|
||||
log.info("새로운 분석 데이터 생성 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 리뷰 데이터 수집
|
||||
List<String> reviewData = externalReviewPort.getReviewData(storeId);
|
||||
int totalReviews = reviewData.size();
|
||||
|
||||
if (totalReviews == 0) {
|
||||
log.warn("리뷰 데이터가 없어 기본값으로 분석 데이터 생성: storeId={}", storeId);
|
||||
return createDefaultAnalytics(storeId);
|
||||
}
|
||||
|
||||
// 2. 기본 통계 계산
|
||||
double averageRating = 4.0; // 기본값
|
||||
double sentimentScore = 0.5; // 중립
|
||||
double positiveRate = 60.0;
|
||||
double negativeRate = 20.0;
|
||||
|
||||
// 3. Analytics 도메인 객체 생성
|
||||
Analytics analytics = Analytics.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(totalReviews)
|
||||
.averageRating(averageRating)
|
||||
.sentimentScore(sentimentScore)
|
||||
.positiveReviewRate(positiveRate)
|
||||
.negativeReviewRate(negativeRate)
|
||||
.lastAnalysisDate(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 4. 데이터베이스에 저장
|
||||
Analytics saved = analyticsPort.saveAnalytics(analytics);
|
||||
|
||||
log.info("새로운 분석 데이터 생성 완료: storeId={}", storeId);
|
||||
return saved;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("분석 데이터 생성 중 오류 발생: storeId={}", storeId, e);
|
||||
return createDefaultAnalytics(storeId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AiFeedback generateAIFeedback(Long storeId) {
|
||||
log.info("AI 피드백 생성 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 최근 30일 리뷰 데이터 수집
|
||||
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, 30);
|
||||
|
||||
if (reviewData.isEmpty()) {
|
||||
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
|
||||
return createDefaultAIFeedback(storeId);
|
||||
}
|
||||
|
||||
// 2. AI 피드백 생성 (실제로는 AI 서비스 호출)
|
||||
AiFeedback aiFeedback = AiFeedback.builder()
|
||||
.storeId(storeId)
|
||||
.summary("고객들의 전반적인 만족도가 높습니다.")
|
||||
.positivePoints(List.of("맛이 좋다", "서비스가 친절하다", "분위기가 좋다"))
|
||||
.improvementPoints(List.of("대기시간 단축", "가격 경쟁력", "메뉴 다양성"))
|
||||
.recommendations(List.of("특별 메뉴 개발", "예약 시스템 도입", "고객 서비스 교육"))
|
||||
.sentimentAnalysis("POSITIVE")
|
||||
.confidenceScore(0.85)
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 3. 데이터베이스에 저장
|
||||
AiFeedback saved = analyticsPort.saveAIFeedback(aiFeedback);
|
||||
|
||||
log.info("AI 피드백 생성 완료: storeId={}", storeId);
|
||||
return saved;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 피드백 생성 중 오류 발생: storeId={}", storeId, e);
|
||||
return createDefaultAIFeedback(storeId);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private Analytics createDefaultAnalytics(Long storeId) {
|
||||
return Analytics.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(0)
|
||||
.averageRating(0.0)
|
||||
.sentimentScore(0.0)
|
||||
.positiveReviewRate(0.0)
|
||||
.negativeReviewRate(0.0)
|
||||
.lastAnalysisDate(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private AiFeedback createDefaultAIFeedback(Long storeId) {
|
||||
return AiFeedback.builder()
|
||||
.storeId(storeId)
|
||||
.summary("분석할 리뷰 데이터가 부족합니다.")
|
||||
.positivePoints(List.of("데이터 부족으로 분석 불가"))
|
||||
.improvementPoints(List.of("리뷰 데이터 수집 필요"))
|
||||
.recommendations(List.of("고객들의 리뷰 작성을 유도해보세요"))
|
||||
.sentimentAnalysis("NEUTRAL")
|
||||
.confidenceScore(0.0)
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String getFirstRecommendation(AiFeedback feedback) {
|
||||
if (feedback.getRecommendations() != null && !feedback.getRecommendations().isEmpty()) {
|
||||
return feedback.getRecommendations().get(0);
|
||||
}
|
||||
return "추천사항이 없습니다.";
|
||||
}
|
||||
|
||||
private int countPositiveReviews(List<String> reviews) {
|
||||
// 실제로는 AI 서비스를 통한 감정 분석 필요
|
||||
return (int) (reviews.size() * 0.6); // 60% 가정
|
||||
}
|
||||
|
||||
private int countNegativeReviews(List<String> reviews) {
|
||||
// 실제로는 AI 서비스를 통한 감정 분석 필요
|
||||
return (int) (reviews.size() * 0.2); // 20% 가정
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.analytics.biz.service;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.Analytics;
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.*;
|
||||
import com.ktds.hi.analytics.infra.dto.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 분석 서비스 구현 클래스 (수정버전)
|
||||
* Clean Architecture의 UseCase를 구현하여 비즈니스 로직을 처리
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class AnalyticsService implements AnalyticsUseCase {
|
||||
|
||||
private final AnalyticsPort analyticsPort;
|
||||
private final AIServicePort aiServicePort;
|
||||
private final ExternalReviewPort externalReviewPort;
|
||||
private final OrderDataPort orderDataPort;
|
||||
private final CachePort cachePort;
|
||||
private final EventPort eventPort;
|
||||
|
||||
@Override
|
||||
@Cacheable(value = "storeAnalytics", key = "#storeId")
|
||||
public StoreAnalyticsResponse getStoreAnalytics(Long storeId) {
|
||||
log.info("매장 분석 데이터 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 캐시에서 먼저 확인
|
||||
String cacheKey = "analytics:store:" + storeId;
|
||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||
if (cachedResult.isPresent()) {
|
||||
log.info("캐시에서 분석 데이터 반환: storeId={}", storeId);
|
||||
return (StoreAnalyticsResponse) cachedResult.get();
|
||||
}
|
||||
|
||||
// 2. 데이터베이스에서 기존 분석 데이터 조회
|
||||
var analytics = analyticsPort.findAnalyticsByStoreId(storeId);
|
||||
|
||||
if (analytics.isEmpty()) {
|
||||
// 3. 분석 데이터가 없으면 새로 생성
|
||||
analytics = Optional.of(generateNewAnalytics(storeId));
|
||||
}
|
||||
|
||||
// 4. 응답 생성
|
||||
StoreAnalyticsResponse response = StoreAnalyticsResponse.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(analytics.get().getTotalReviews())
|
||||
.averageRating(analytics.get().getAverageRating())
|
||||
.sentimentScore(analytics.get().getSentimentScore())
|
||||
.positiveReviewRate(analytics.get().getPositiveReviewRate())
|
||||
.negativeReviewRate(analytics.get().getNegativeReviewRate())
|
||||
.lastAnalysisDate(analytics.get().getLastAnalysisDate())
|
||||
.build();
|
||||
|
||||
// 5. 캐시에 저장
|
||||
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(1));
|
||||
|
||||
log.info("매장 분석 데이터 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("매장 분석 데이터 조회 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("분석 데이터 조회에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ... 나머지 메서드들은 이전과 동일 ...
|
||||
|
||||
@Override
|
||||
@Cacheable(value = "aiFeedback", key = "#storeId")
|
||||
public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) {
|
||||
log.info("AI 피드백 상세 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 기존 AI 피드백 조회
|
||||
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
|
||||
|
||||
if (aiFeedback.isEmpty()) {
|
||||
// 2. AI 피드백이 없으면 새로 생성
|
||||
aiFeedback = Optional.of(generateAIFeedback(storeId));
|
||||
}
|
||||
|
||||
// 3. 응답 생성
|
||||
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
|
||||
.storeId(storeId)
|
||||
.summary(aiFeedback.get().getSummary())
|
||||
.positivePoints(aiFeedback.get().getPositivePoints())
|
||||
.improvementPoints(aiFeedback.get().getImprovementPoints())
|
||||
.recommendations(aiFeedback.get().getRecommendations())
|
||||
.sentimentAnalysis(aiFeedback.get().getSentimentAnalysis())
|
||||
.confidenceScore(aiFeedback.get().getConfidenceScore())
|
||||
.generatedAt(aiFeedback.get().getGeneratedAt())
|
||||
.build();
|
||||
|
||||
log.info("AI 피드백 상세 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 피드백 조회 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("AI 피드백 조회에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 나머지 메서드들과 private 메서드들은 이전과 동일하게 구현
|
||||
// ... (getStoreStatistics, getAIFeedbackSummary, getReviewAnalysis 등)
|
||||
|
||||
@Override
|
||||
public StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate) {
|
||||
log.info("매장 통계 조회 시작: storeId={}, startDate={}, endDate={}", storeId, startDate, endDate);
|
||||
|
||||
try {
|
||||
// 1. 캐시 키 생성
|
||||
String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate);
|
||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||
if (cachedResult.isPresent()) {
|
||||
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
|
||||
return (StoreStatisticsResponse) cachedResult.get();
|
||||
}
|
||||
|
||||
// 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용)
|
||||
var orderStatistics = orderDataPort.getOrderStatistics(storeId, startDate, endDate);
|
||||
|
||||
// 3. 응답 생성
|
||||
StoreStatisticsResponse response = StoreStatisticsResponse.builder()
|
||||
.storeId(storeId)
|
||||
.startDate(startDate)
|
||||
.endDate(endDate)
|
||||
.totalOrders(orderStatistics.getTotalOrders())
|
||||
.totalRevenue(orderStatistics.getTotalRevenue())
|
||||
.averageOrderValue(orderStatistics.getAverageOrderValue())
|
||||
.peakHour(orderStatistics.getPeakHour())
|
||||
.popularMenus(orderStatistics.getPopularMenus())
|
||||
.customerAgeDistribution(orderStatistics.getCustomerAgeDistribution())
|
||||
.build();
|
||||
|
||||
// 4. 캐시에 저장
|
||||
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofMinutes(30));
|
||||
|
||||
log.info("매장 통계 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("매장 통계 조회 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("매장 통계 조회에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId) {
|
||||
log.info("AI 피드백 요약 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 캐시에서 확인
|
||||
String cacheKey = "ai_feedback_summary:store:" + storeId;
|
||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||
if (cachedResult.isPresent()) {
|
||||
return (AiFeedbackSummaryResponse) cachedResult.get();
|
||||
}
|
||||
|
||||
// 2. AI 피드백 조회
|
||||
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
|
||||
|
||||
if (aiFeedback.isEmpty()) {
|
||||
// 3. 피드백이 없으면 기본 응답 생성
|
||||
AiFeedbackSummaryResponse emptyResponse = AiFeedbackSummaryResponse.builder()
|
||||
.storeId(storeId)
|
||||
.hasData(false)
|
||||
.message("분석할 데이터가 부족합니다.")
|
||||
.lastUpdated(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
// 4. 응답 생성
|
||||
AiFeedbackSummaryResponse response = AiFeedbackSummaryResponse.builder()
|
||||
.storeId(storeId)
|
||||
.hasData(true)
|
||||
.message("AI 분석이 완료되었습니다.")
|
||||
.overallScore(aiFeedback.get().getConfidenceScore())
|
||||
.keyInsight(aiFeedback.get().getSummary())
|
||||
.priorityRecommendation(getFirstRecommendation(aiFeedback.get()))
|
||||
.lastUpdated(aiFeedback.get().getUpdatedAt())
|
||||
.build();
|
||||
|
||||
// 5. 캐시에 저장
|
||||
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(2));
|
||||
|
||||
log.info("AI 피드백 요약 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 피드백 요약 조회 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("AI 피드백 요약 조회에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReviewAnalysisResponse getReviewAnalysis(Long storeId) {
|
||||
log.info("리뷰 분석 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 캐시에서 확인
|
||||
String cacheKey = "review_analysis:store:" + storeId;
|
||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||
if (cachedResult.isPresent()) {
|
||||
return (ReviewAnalysisResponse) cachedResult.get();
|
||||
}
|
||||
|
||||
// 2. 최근 리뷰 데이터 조회 (30일)
|
||||
List<String> recentReviews = externalReviewPort.getRecentReviews(storeId, 30);
|
||||
|
||||
if (recentReviews.isEmpty()) {
|
||||
ReviewAnalysisResponse emptyResponse = ReviewAnalysisResponse.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(0)
|
||||
.positiveReviewCount(0)
|
||||
.negativeReviewCount(0)
|
||||
.positiveRate(0.0)
|
||||
.negativeRate(0.0)
|
||||
.analysisDate(LocalDate.now())
|
||||
.build();
|
||||
|
||||
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
// 3. 응답 생성
|
||||
int positiveCount = countPositiveReviews(recentReviews);
|
||||
int negativeCount = countNegativeReviews(recentReviews);
|
||||
int totalCount = recentReviews.size();
|
||||
|
||||
ReviewAnalysisResponse response = ReviewAnalysisResponse.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(totalCount)
|
||||
.positiveReviewCount(positiveCount)
|
||||
.negativeReviewCount(negativeCount)
|
||||
.positiveRate((double) positiveCount / totalCount * 100)
|
||||
.negativeRate((double) negativeCount / totalCount * 100)
|
||||
.analysisDate(LocalDate.now())
|
||||
.build();
|
||||
|
||||
// 4. 캐시에 저장
|
||||
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(4));
|
||||
|
||||
log.info("리뷰 분석 조회 완료: storeId={}", storeId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("리뷰 분석 중 오류 발생: storeId={}", storeId, e);
|
||||
throw new RuntimeException("리뷰 분석에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// private 메서드들
|
||||
@Transactional
|
||||
public Analytics generateNewAnalytics(Long storeId) {
|
||||
log.info("새로운 분석 데이터 생성 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 리뷰 데이터 수집
|
||||
List<String> reviewData = externalReviewPort.getReviewData(storeId);
|
||||
int totalReviews = reviewData.size();
|
||||
|
||||
if (totalReviews == 0) {
|
||||
log.warn("리뷰 데이터가 없어 기본값으로 분석 데이터 생성: storeId={}", storeId);
|
||||
return createDefaultAnalytics(storeId);
|
||||
}
|
||||
|
||||
// 2. 기본 통계 계산
|
||||
double averageRating = 4.0; // 기본값
|
||||
double sentimentScore = 0.5; // 중립
|
||||
double positiveRate = 60.0;
|
||||
double negativeRate = 20.0;
|
||||
|
||||
// 3. Analytics 도메인 객체 생성
|
||||
Analytics analytics = Analytics.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(totalReviews)
|
||||
.averageRating(averageRating)
|
||||
.sentimentScore(sentimentScore)
|
||||
.positiveReviewRate(positiveRate)
|
||||
.negativeReviewRate(negativeRate)
|
||||
.lastAnalysisDate(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 4. 데이터베이스에 저장
|
||||
Analytics saved = analyticsPort.saveAnalytics(analytics);
|
||||
|
||||
log.info("새로운 분석 데이터 생성 완료: storeId={}", storeId);
|
||||
return saved;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("분석 데이터 생성 중 오류 발생: storeId={}", storeId, e);
|
||||
return createDefaultAnalytics(storeId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AiFeedback generateAIFeedback(Long storeId) {
|
||||
log.info("AI 피드백 생성 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 1. 최근 30일 리뷰 데이터 수집
|
||||
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, 30);
|
||||
|
||||
if (reviewData.isEmpty()) {
|
||||
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
|
||||
return createDefaultAIFeedback(storeId);
|
||||
}
|
||||
|
||||
// 2. AI 피드백 생성 (실제로는 AI 서비스 호출)
|
||||
AiFeedback aiFeedback = AiFeedback.builder()
|
||||
.storeId(storeId)
|
||||
.summary("고객들의 전반적인 만족도가 높습니다.")
|
||||
.positivePoints(List.of("맛이 좋다", "서비스가 친절하다", "분위기가 좋다"))
|
||||
.improvementPoints(List.of("대기시간 단축", "가격 경쟁력", "메뉴 다양성"))
|
||||
.recommendations(List.of("특별 메뉴 개발", "예약 시스템 도입", "고객 서비스 교육"))
|
||||
.sentimentAnalysis("POSITIVE")
|
||||
.confidenceScore(0.85)
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 3. 데이터베이스에 저장
|
||||
AiFeedback saved = analyticsPort.saveAIFeedback(aiFeedback);
|
||||
|
||||
log.info("AI 피드백 생성 완료: storeId={}", storeId);
|
||||
return saved;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 피드백 생성 중 오류 발생: storeId={}", storeId, e);
|
||||
return createDefaultAIFeedback(storeId);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private Analytics createDefaultAnalytics(Long storeId) {
|
||||
return Analytics.builder()
|
||||
.storeId(storeId)
|
||||
.totalReviews(0)
|
||||
.averageRating(0.0)
|
||||
.sentimentScore(0.0)
|
||||
.positiveReviewRate(0.0)
|
||||
.negativeReviewRate(0.0)
|
||||
.lastAnalysisDate(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private AiFeedback createDefaultAIFeedback(Long storeId) {
|
||||
return AiFeedback.builder()
|
||||
.storeId(storeId)
|
||||
.summary("분석할 리뷰 데이터가 부족합니다.")
|
||||
.positivePoints(List.of("데이터 부족으로 분석 불가"))
|
||||
.improvementPoints(List.of("리뷰 데이터 수집 필요"))
|
||||
.recommendations(List.of("고객들의 리뷰 작성을 유도해보세요"))
|
||||
.sentimentAnalysis("NEUTRAL")
|
||||
.confidenceScore(0.0)
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String getFirstRecommendation(AiFeedback feedback) {
|
||||
if (feedback.getRecommendations() != null && !feedback.getRecommendations().isEmpty()) {
|
||||
return feedback.getRecommendations().get(0);
|
||||
}
|
||||
return "추천사항이 없습니다.";
|
||||
}
|
||||
|
||||
private int countPositiveReviews(List<String> reviews) {
|
||||
// 실제로는 AI 서비스를 통한 감정 분석 필요
|
||||
return (int) (reviews.size() * 0.6); // 60% 가정
|
||||
}
|
||||
|
||||
private int countNegativeReviews(List<String> reviews) {
|
||||
// 실제로는 AI 서비스를 통한 감정 분석 필요
|
||||
return (int) (reviews.size() * 0.2); // 20% 가정
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,51 +1,51 @@
|
||||
package com.ktds.hi.analytics.infra.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.CorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Analytics 서비스 보안 설정 클래스
|
||||
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
@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
|
||||
// Swagger 관련 경로 모두 허용
|
||||
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// Analytics API 모두 허용 (테스트용)
|
||||
.requestMatchers("/api/analytics/**").permitAll()
|
||||
.requestMatchers("/api/action-plans/**").permitAll()
|
||||
|
||||
// Actuator 엔드포인트 허용
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// 기타 모든 요청 허용 (테스트용)
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.analytics.infra.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.CorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Analytics 서비스 보안 설정 클래스
|
||||
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
@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
|
||||
// Swagger 관련 경로 모두 허용
|
||||
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// Analytics API 모두 허용 (테스트용)
|
||||
.requestMatchers("/api/analytics/**").permitAll()
|
||||
.requestMatchers("/api/action-plans/**").permitAll()
|
||||
|
||||
// Actuator 엔드포인트 허용
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// 기타 모든 요청 허용 (테스트용)
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,45 +1,45 @@
|
||||
package com.ktds.hi.analytics.infra.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
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 org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Swagger 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("Analytics Service API")
|
||||
.description("하이오더 분석 서비스 API 문서")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
/**
|
||||
* JWT Bearer 토큰을 위한 Security Scheme 생성
|
||||
*/
|
||||
private SecurityScheme createAPIKeyScheme() {
|
||||
return new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name("Authorization")
|
||||
.description("""
|
||||
JWT 토큰을 입력하세요
|
||||
|
||||
사용법:
|
||||
1. 로그인 API로 토큰 발급
|
||||
2. Bearer 접두사 없이 토큰만 입력
|
||||
3. 예: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOi...
|
||||
""");
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.analytics.infra.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
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 org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Swagger 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("Analytics Service API")
|
||||
.description("하이오더 분석 서비스 API 문서")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
/**
|
||||
* JWT Bearer 토큰을 위한 Security Scheme 생성
|
||||
*/
|
||||
private SecurityScheme createAPIKeyScheme() {
|
||||
return new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name("Authorization")
|
||||
.description("""
|
||||
JWT 토큰을 입력하세요
|
||||
|
||||
사용법:
|
||||
1. 로그인 API로 토큰 발급
|
||||
2. Bearer 접두사 없이 토큰만 입력
|
||||
3. 예: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOi...
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,95 +1,95 @@
|
||||
server:
|
||||
port: ${ANALYTICS_SERVICE_PORT:8084}
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.web.servlet.resource.ResourceHttpRequestHandler: ERROR
|
||||
org.springframework.web.servlet.DispatcherServlet: WARN
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: analytics-service
|
||||
|
||||
datasource:
|
||||
url: ${ANALYTICS_DB_URL:jdbc:postgresql://20.249.162.125:5432/hiorder_analytics}
|
||||
username: ${ANALYTICS_DB_USERNAME:hiorder_user}
|
||||
password: ${ANALYTICS_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
ai-api:
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
base-url: https://api.openai.com/v1
|
||||
model: gpt-4o-mini
|
||||
claude:
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
base-url: https://api.anthropic.com
|
||||
model: claude-3-sonnet-20240229
|
||||
|
||||
#external-api:
|
||||
# openai:
|
||||
# api-key: ${OPENAI_API_KEY:}
|
||||
# base-url: https://api.openai.com
|
||||
# claude:
|
||||
# api-key: ${CLAUDE_API_KEY:}
|
||||
# base-url: https://api.anthropic.com
|
||||
|
||||
# 외부 서비스 설정
|
||||
external:
|
||||
services:
|
||||
review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082}
|
||||
store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081}
|
||||
member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080}
|
||||
|
||||
#springdoc:
|
||||
# api-docs:
|
||||
# path: /api-docs
|
||||
# swagger-ui:
|
||||
# path: /swagger-ui.html
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
try-it-out-enabled: true
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
|
||||
# AI 서비스 설정
|
||||
ai:
|
||||
azure:
|
||||
cognitive:
|
||||
endpoint: ${AI_AZURE_COGNITIVE_ENDPOINT:https://your-cognitive-service.cognitiveservices.azure.com}
|
||||
key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key}
|
||||
openai:
|
||||
api-key: ${AI_OPENAI_API_KEY:your-openai-api-key}
|
||||
|
||||
# Azure Event Hub 설정
|
||||
azure:
|
||||
eventhub:
|
||||
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:Endpoint=sb://your-eventhub.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=your-key}
|
||||
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:analytics-consumer}
|
||||
event-hubs:
|
||||
review-events: ${AZURE_EVENTHUB_REVIEW_EVENTS:review-events}
|
||||
ai-analysis-events: ${AZURE_EVENTHUB_AI_ANALYSIS_EVENTS:ai-analysis-events}
|
||||
storage:
|
||||
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=yourstorageaccount;AccountKey=your-storage-key;EndpointSuffix=core.windows.net}
|
||||
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}
|
||||
server:
|
||||
port: ${ANALYTICS_SERVICE_PORT:8084}
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.web.servlet.resource.ResourceHttpRequestHandler: ERROR
|
||||
org.springframework.web.servlet.DispatcherServlet: WARN
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: analytics-service
|
||||
|
||||
datasource:
|
||||
url: ${ANALYTICS_DB_URL:jdbc:postgresql://20.249.162.125:5432/hiorder_analytics}
|
||||
username: ${ANALYTICS_DB_USERNAME:hiorder_user}
|
||||
password: ${ANALYTICS_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
ai-api:
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
base-url: https://api.openai.com/v1
|
||||
model: gpt-4o-mini
|
||||
claude:
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
base-url: https://api.anthropic.com
|
||||
model: claude-3-sonnet-20240229
|
||||
|
||||
#external-api:
|
||||
# openai:
|
||||
# api-key: ${OPENAI_API_KEY:}
|
||||
# base-url: https://api.openai.com
|
||||
# claude:
|
||||
# api-key: ${CLAUDE_API_KEY:}
|
||||
# base-url: https://api.anthropic.com
|
||||
|
||||
# 외부 서비스 설정
|
||||
external:
|
||||
services:
|
||||
review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082}
|
||||
store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081}
|
||||
member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080}
|
||||
|
||||
#springdoc:
|
||||
# api-docs:
|
||||
# path: /api-docs
|
||||
# swagger-ui:
|
||||
# path: /swagger-ui.html
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
try-it-out-enabled: true
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
|
||||
# AI 서비스 설정
|
||||
ai:
|
||||
azure:
|
||||
cognitive:
|
||||
endpoint: ${AI_AZURE_COGNITIVE_ENDPOINT:https://your-cognitive-service.cognitiveservices.azure.com}
|
||||
key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key}
|
||||
openai:
|
||||
api-key: ${AI_OPENAI_API_KEY:your-openai-api-key}
|
||||
|
||||
# Azure Event Hub 설정
|
||||
azure:
|
||||
eventhub:
|
||||
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:Endpoint=sb://your-eventhub.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=your-key}
|
||||
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:analytics-consumer}
|
||||
event-hubs:
|
||||
review-events: ${AZURE_EVENTHUB_REVIEW_EVENTS:review-events}
|
||||
ai-analysis-events: ${AZURE_EVENTHUB_AI_ANALYSIS_EVENTS:ai-analysis-events}
|
||||
storage:
|
||||
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=yourstorageaccount;AccountKey=your-storage-key;EndpointSuffix=core.windows.net}
|
||||
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}
|
||||
|
||||
@ -1,102 +1,102 @@
|
||||
package com.ktds.hi.common.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 전체 서비스 통합 CORS 설정 클래스
|
||||
* 모든 마이크로서비스에서 공통으로 사용되는 CORS 정책을 정의
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.cors.allowed-origins:http://20.214.126.84,http://localhost:3000}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Value("${app.cors.allowed-methods:GET,POST,PUT,DELETE,OPTIONS}")
|
||||
private String allowedMethods;
|
||||
|
||||
@Value("${app.cors.allowed-headers:*}")
|
||||
private String allowedHeaders;
|
||||
|
||||
@Value("${app.cors.exposed-headers:Authorization,X-Total-Count}")
|
||||
private String exposedHeaders;
|
||||
|
||||
@Value("${app.cors.allow-credentials:true}")
|
||||
private boolean allowCredentials;
|
||||
|
||||
@Value("${app.cors.max-age:3600}")
|
||||
private long maxAge;
|
||||
|
||||
/**
|
||||
* WebMvcConfigurer를 통한 CORS 설정
|
||||
*/
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns(allowedOrigins.split(","))
|
||||
.allowedMethods(allowedMethods.split(","))
|
||||
.allowedHeaders(allowedHeaders.split(","))
|
||||
.exposedHeaders(exposedHeaders.split(","))
|
||||
.allowCredentials(allowCredentials)
|
||||
.maxAge(maxAge);
|
||||
}
|
||||
|
||||
/**
|
||||
* CorsConfigurationSource Bean 생성
|
||||
* Spring Security와 함께 사용되는 CORS 설정
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// Origin 설정
|
||||
List<String> origins = Arrays.asList(allowedOrigins.split(","));
|
||||
configuration.setAllowedOriginPatterns(origins);
|
||||
|
||||
// Method 설정
|
||||
List<String> methods = Arrays.asList(allowedMethods.split(","));
|
||||
configuration.setAllowedMethods(methods);
|
||||
|
||||
// Header 설정
|
||||
if ("*".equals(allowedHeaders)) {
|
||||
configuration.addAllowedHeader("*");
|
||||
} else {
|
||||
List<String> headers = Arrays.asList(allowedHeaders.split(","));
|
||||
configuration.setAllowedHeaders(headers);
|
||||
}
|
||||
|
||||
// Exposed Headers 설정
|
||||
List<String> exposed = Arrays.asList(exposedHeaders.split(","));
|
||||
configuration.setExposedHeaders(exposed);
|
||||
|
||||
// Credentials 설정
|
||||
configuration.setAllowCredentials(allowCredentials);
|
||||
|
||||
// Max Age 설정
|
||||
configuration.setMaxAge(maxAge);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* CorsFilter Bean 생성
|
||||
* 글로벌 CORS 필터로 사용
|
||||
*/
|
||||
@Bean
|
||||
public CorsFilter corsFilter() {
|
||||
return new CorsFilter(corsConfigurationSource());
|
||||
}
|
||||
package com.ktds.hi.common.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 전체 서비스 통합 CORS 설정 클래스
|
||||
* 모든 마이크로서비스에서 공통으로 사용되는 CORS 정책을 정의
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.cors.allowed-origins:http://20.214.126.84,http://localhost:3000}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Value("${app.cors.allowed-methods:GET,POST,PUT,DELETE,OPTIONS}")
|
||||
private String allowedMethods;
|
||||
|
||||
@Value("${app.cors.allowed-headers:*}")
|
||||
private String allowedHeaders;
|
||||
|
||||
@Value("${app.cors.exposed-headers:Authorization,X-Total-Count}")
|
||||
private String exposedHeaders;
|
||||
|
||||
@Value("${app.cors.allow-credentials:true}")
|
||||
private boolean allowCredentials;
|
||||
|
||||
@Value("${app.cors.max-age:3600}")
|
||||
private long maxAge;
|
||||
|
||||
/**
|
||||
* WebMvcConfigurer를 통한 CORS 설정
|
||||
*/
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns(allowedOrigins.split(","))
|
||||
.allowedMethods(allowedMethods.split(","))
|
||||
.allowedHeaders(allowedHeaders.split(","))
|
||||
.exposedHeaders(exposedHeaders.split(","))
|
||||
.allowCredentials(allowCredentials)
|
||||
.maxAge(maxAge);
|
||||
}
|
||||
|
||||
/**
|
||||
* CorsConfigurationSource Bean 생성
|
||||
* Spring Security와 함께 사용되는 CORS 설정
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// Origin 설정
|
||||
List<String> origins = Arrays.asList(allowedOrigins.split(","));
|
||||
configuration.setAllowedOriginPatterns(origins);
|
||||
|
||||
// Method 설정
|
||||
List<String> methods = Arrays.asList(allowedMethods.split(","));
|
||||
configuration.setAllowedMethods(methods);
|
||||
|
||||
// Header 설정
|
||||
if ("*".equals(allowedHeaders)) {
|
||||
configuration.addAllowedHeader("*");
|
||||
} else {
|
||||
List<String> headers = Arrays.asList(allowedHeaders.split(","));
|
||||
configuration.setAllowedHeaders(headers);
|
||||
}
|
||||
|
||||
// Exposed Headers 설정
|
||||
List<String> exposed = Arrays.asList(exposedHeaders.split(","));
|
||||
configuration.setExposedHeaders(exposed);
|
||||
|
||||
// Credentials 설정
|
||||
configuration.setAllowCredentials(allowCredentials);
|
||||
|
||||
// Max Age 설정
|
||||
configuration.setMaxAge(maxAge);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* CorsFilter Bean 생성
|
||||
* 글로벌 CORS 필터로 사용
|
||||
*/
|
||||
@Bean
|
||||
public CorsFilter corsFilter() {
|
||||
return new CorsFilter(corsConfigurationSource());
|
||||
}
|
||||
}
|
||||
@ -1,93 +1,93 @@
|
||||
spring:
|
||||
# JPA 설정
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
naming:
|
||||
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
|
||||
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
jdbc:
|
||||
batch_size: 20
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
batch_versioned_data: true
|
||||
|
||||
# Redis 설정
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-wait: -1ms
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
# Jackson 설정
|
||||
jackson:
|
||||
time-zone: Asia/Seoul
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
|
||||
# 트랜잭션 설정
|
||||
transaction:
|
||||
default-timeout: 30
|
||||
|
||||
# 애플리케이션 설정
|
||||
app:
|
||||
# JWT 설정
|
||||
jwt:
|
||||
secret-key: ${JWT_SECRET_KEY:hiorder-secret-key-for-jwt-token-generation-2024-very-long-secret-key}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} # 1시간
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} # 7일
|
||||
# CORS 설정
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://20.214.126.84,http://localhost:8080}
|
||||
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS}
|
||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||
exposed-headers: ${CORS_EXPOSED_HEADERS:Authorization, X-Total-Count}
|
||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||
max-age: ${CORS_MAX_AGE:3600}
|
||||
|
||||
# 캐시 설정
|
||||
cache:
|
||||
default-ttl: ${CACHE_DEFAULT_TTL:3600} # 1시간
|
||||
|
||||
# Swagger 설정
|
||||
swagger:
|
||||
title: ${SWAGGER_TITLE:하이오더 API}
|
||||
description: ${SWAGGER_DESCRIPTION:하이오더 백엔드 API 문서}
|
||||
version: ${SWAGGER_VERSION:1.0.0}
|
||||
server-url: ${SWAGGER_SERVER_URL:http://localhost:8080}
|
||||
|
||||
# 로깅 설정
|
||||
logging:
|
||||
level:
|
||||
com.ktds.hi: ${LOG_LEVEL:INFO}
|
||||
org.springframework.security: ${SECURITY_LOG_LEVEL:INFO}
|
||||
org.hibernate.SQL: ${SQL_LOG_LEVEL:INFO}
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: ${SQL_PARAM_LOG_LEVEL:INFO}
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
|
||||
# 관리 엔드포인트 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
spring:
|
||||
# JPA 설정
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
naming:
|
||||
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
|
||||
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
jdbc:
|
||||
batch_size: 20
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
batch_versioned_data: true
|
||||
|
||||
# Redis 설정
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-wait: -1ms
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
# Jackson 설정
|
||||
jackson:
|
||||
time-zone: Asia/Seoul
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
|
||||
# 트랜잭션 설정
|
||||
transaction:
|
||||
default-timeout: 30
|
||||
|
||||
# 애플리케이션 설정
|
||||
app:
|
||||
# JWT 설정
|
||||
jwt:
|
||||
secret-key: ${JWT_SECRET_KEY:hiorder-secret-key-for-jwt-token-generation-2024-very-long-secret-key}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} # 1시간
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} # 7일
|
||||
# CORS 설정
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://20.214.126.84,http://localhost:8080}
|
||||
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS}
|
||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||
exposed-headers: ${CORS_EXPOSED_HEADERS:Authorization, X-Total-Count}
|
||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||
max-age: ${CORS_MAX_AGE:3600}
|
||||
|
||||
# 캐시 설정
|
||||
cache:
|
||||
default-ttl: ${CACHE_DEFAULT_TTL:3600} # 1시간
|
||||
|
||||
# Swagger 설정
|
||||
swagger:
|
||||
title: ${SWAGGER_TITLE:하이오더 API}
|
||||
description: ${SWAGGER_DESCRIPTION:하이오더 백엔드 API 문서}
|
||||
version: ${SWAGGER_VERSION:1.0.0}
|
||||
server-url: ${SWAGGER_SERVER_URL:http://localhost:8080}
|
||||
|
||||
# 로깅 설정
|
||||
logging:
|
||||
level:
|
||||
com.ktds.hi: ${LOG_LEVEL:INFO}
|
||||
org.springframework.security: ${SECURITY_LOG_LEVEL:INFO}
|
||||
org.hibernate.SQL: ${SQL_LOG_LEVEL:INFO}
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: ${SQL_PARAM_LOG_LEVEL:INFO}
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
|
||||
# 관리 엔드포인트 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
|
||||
@ -1,121 +1,121 @@
|
||||
package com.ktds.hi.member.config;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.common.security.JwtTokenProvider;
|
||||
import com.ktds.hi.common.security.JwtAuthenticationFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.condition.ConditionsReportEndpoint;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
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.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.CorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Spring Security 설정 클래스
|
||||
* JWT 기반 인증 및 권한 관리 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
/**
|
||||
* 보안 필터 체인 설정
|
||||
* JWT 인증 방식을 사용하고 세션은 무상태로 관리
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.requestMatchers("/api/auth/**", "/api/members/register", "/api/auth/login").permitAll()
|
||||
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 인증 필터 빈
|
||||
*/
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
return new JwtAuthenticationFilter(jwtTokenProvider,new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 암호화 빈
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 매니저 빈
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
|
||||
// @Qualifier("memberJwtTokenProvider")
|
||||
// private final JwtTokenProvider jwtTokenProvider;
|
||||
// private final AuthService authService;
|
||||
//
|
||||
// /**
|
||||
// * 보안 필터 체인 설정
|
||||
// * JWT 인증 방식을 사용하고 세션은 무상태로 관리
|
||||
// */
|
||||
// @Bean
|
||||
// public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// http
|
||||
// .csrf(csrf -> csrf.disable())
|
||||
// .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
// .authorizeHttpRequests(authz -> authz
|
||||
// .requestMatchers("/api/auth/**", "/api/members/register").permitAll()
|
||||
// .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
|
||||
// .requestMatchers("/actuator/**").permitAll()
|
||||
// .anyRequest().authenticated()
|
||||
// )
|
||||
// .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, authService),
|
||||
// UsernamePasswordAuthenticationFilter.class);
|
||||
//
|
||||
// return http.build();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 비밀번호 암호화 빈
|
||||
// */
|
||||
// @Bean
|
||||
// public PasswordEncoder passwordEncoder() {
|
||||
// return new BCryptPasswordEncoder();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 인증 매니저 빈
|
||||
// */
|
||||
// @Bean
|
||||
// public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
// return config.getAuthenticationManager();
|
||||
// }
|
||||
}
|
||||
package com.ktds.hi.member.config;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.common.security.JwtTokenProvider;
|
||||
import com.ktds.hi.common.security.JwtAuthenticationFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.condition.ConditionsReportEndpoint;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
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.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.CorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* Spring Security 설정 클래스
|
||||
* JWT 기반 인증 및 권한 관리 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
/**
|
||||
* 보안 필터 체인 설정
|
||||
* JWT 인증 방식을 사용하고 세션은 무상태로 관리
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.requestMatchers("/api/auth/**", "/api/members/register", "/api/auth/login").permitAll()
|
||||
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 인증 필터 빈
|
||||
*/
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
return new JwtAuthenticationFilter(jwtTokenProvider,new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 암호화 빈
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 매니저 빈
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
|
||||
// @Qualifier("memberJwtTokenProvider")
|
||||
// private final JwtTokenProvider jwtTokenProvider;
|
||||
// private final AuthService authService;
|
||||
//
|
||||
// /**
|
||||
// * 보안 필터 체인 설정
|
||||
// * JWT 인증 방식을 사용하고 세션은 무상태로 관리
|
||||
// */
|
||||
// @Bean
|
||||
// public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// http
|
||||
// .csrf(csrf -> csrf.disable())
|
||||
// .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
// .authorizeHttpRequests(authz -> authz
|
||||
// .requestMatchers("/api/auth/**", "/api/members/register").permitAll()
|
||||
// .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
|
||||
// .requestMatchers("/actuator/**").permitAll()
|
||||
// .anyRequest().authenticated()
|
||||
// )
|
||||
// .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, authService),
|
||||
// UsernamePasswordAuthenticationFilter.class);
|
||||
//
|
||||
// return http.build();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 비밀번호 암호화 빈
|
||||
// */
|
||||
// @Bean
|
||||
// public PasswordEncoder passwordEncoder() {
|
||||
// return new BCryptPasswordEncoder();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 인증 매니저 빈
|
||||
// */
|
||||
// @Bean
|
||||
// public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
// return config.getAuthenticationManager();
|
||||
// }
|
||||
}
|
||||
|
||||
@ -1,39 +1,39 @@
|
||||
package com.ktds.hi.member.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
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 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
// .addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 회원 관리 서비스 API")
|
||||
.description("회원 가입, 로그인, 취향 관리 등 회원 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Bearer 토큰을 위한 Security Scheme 생성
|
||||
*/
|
||||
private SecurityScheme createAPIKeyScheme() {
|
||||
return new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name("Authorization")
|
||||
.description("JWT 토큰을 입력하세요 (Bearer 접두사 제외)");
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.member.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
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 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
// .addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 회원 관리 서비스 API")
|
||||
.description("회원 가입, 로그인, 취향 관리 등 회원 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Bearer 토큰을 위한 Security Scheme 생성
|
||||
*/
|
||||
private SecurityScheme createAPIKeyScheme() {
|
||||
return new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name("Authorization")
|
||||
.description("JWT 토큰을 입력하세요 (Bearer 접두사 제외)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,56 +1,56 @@
|
||||
server:
|
||||
port: ${MEMBER_SERVICE_PORT:8081}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: member-service
|
||||
|
||||
datasource:
|
||||
url: ${MEMBER_DB_URL:jdbc:postgresql://20.249.152.184:5432/hiorder_member}
|
||||
username: ${MEMBER_DB_USERNAME:hiorder_user}
|
||||
password: ${MEMBER_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-wait: -1ms
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:hiorder-secret-key-for-jwt-token-generation-must-be-long-enough}
|
||||
access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600000} # 1시간
|
||||
refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일
|
||||
|
||||
sms:
|
||||
api-key: ${SMS_API_KEY:}
|
||||
api-secret: ${SMS_API_SECRET:}
|
||||
from-number: ${SMS_FROM_NUMBER:}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
try-it-out-enabled: true
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
server:
|
||||
port: ${MEMBER_SERVICE_PORT:8081}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: member-service
|
||||
|
||||
datasource:
|
||||
url: ${MEMBER_DB_URL:jdbc:postgresql://20.249.152.184:5432/hiorder_member}
|
||||
username: ${MEMBER_DB_USERNAME:hiorder_user}
|
||||
password: ${MEMBER_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-wait: -1ms
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:hiorder-secret-key-for-jwt-token-generation-must-be-long-enough}
|
||||
access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600000} # 1시간
|
||||
refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일
|
||||
|
||||
sms:
|
||||
api-key: ${SMS_API_KEY:}
|
||||
api-secret: ${SMS_API_SECRET:}
|
||||
from-number: ${SMS_FROM_NUMBER:}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
try-it-out-enabled: true
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
|
||||
@ -1,52 +1,52 @@
|
||||
package com.ktds.hi.recommend.infra.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.CorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Analytics 서비스 보안 설정 클래스
|
||||
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
@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
|
||||
// Swagger 관련 경로 모두 허용
|
||||
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// Analytics API 모두 허용 (테스트용)
|
||||
.requestMatchers("/api/analytics/**").permitAll()
|
||||
.requestMatchers("/api/action-plans/**").permitAll()
|
||||
|
||||
// Actuator 엔드포인트 허용
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// 기타 모든 요청 허용 (테스트용)
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.recommend.infra.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.CorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Analytics 서비스 보안 설정 클래스
|
||||
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
@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
|
||||
// Swagger 관련 경로 모두 허용
|
||||
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// Analytics API 모두 허용 (테스트용)
|
||||
.requestMatchers("/api/analytics/**").permitAll()
|
||||
.requestMatchers("/api/action-plans/**").permitAll()
|
||||
|
||||
// Actuator 엔드포인트 허용
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// 기타 모든 요청 허용 (테스트용)
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,47 +1,47 @@
|
||||
package com.ktds.hi.recommend.infra.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
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 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
// .addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 추천 서비스 API")
|
||||
.description("사용자 취향 기반 매장 추천 및 취향 분석 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Bearer 토큰을 위한 Security Scheme 생성
|
||||
*/
|
||||
private SecurityScheme createAPIKeyScheme() {
|
||||
return new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name("Authorization")
|
||||
.description("""
|
||||
JWT 토큰을 입력하세요
|
||||
|
||||
사용법:
|
||||
1. 로그인 API로 토큰 발급
|
||||
2. Bearer 접두사 없이 토큰만 입력
|
||||
3. 예: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOi...
|
||||
""");
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.recommend.infra.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
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 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
// .addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 추천 서비스 API")
|
||||
.description("사용자 취향 기반 매장 추천 및 취향 분석 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Bearer 토큰을 위한 Security Scheme 생성
|
||||
*/
|
||||
private SecurityScheme createAPIKeyScheme() {
|
||||
return new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name("Authorization")
|
||||
.description("""
|
||||
JWT 토큰을 입력하세요
|
||||
|
||||
사용법:
|
||||
1. 로그인 API로 토큰 발급
|
||||
2. Bearer 접두사 없이 토큰만 입력
|
||||
3. 예: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOi...
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,226 +1,228 @@
|
||||
# recommend/src/main/resources/application.yml
|
||||
server:
|
||||
port: ${RECOMMEND_SERVICE_PORT:8085}
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
compatibility-verifier:
|
||||
enabled: false
|
||||
application:
|
||||
name: recommend-service
|
||||
|
||||
# 프로필 설정
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||
|
||||
# 데이터베이스 설정
|
||||
datasource:
|
||||
url: ${RECOMMEND_DB_URL:jdbc:postgresql://20.249.162.245:5432/hiorder_recommend}
|
||||
username: ${RECOMMEND_DB_USERNAME:hiorder_user}
|
||||
password: ${RECOMMEND_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||
minimum-idle: ${DB_POOL_MIN_IDLE:5}
|
||||
connection-timeout: ${DB_CONNECTION_TIMEOUT:30000}
|
||||
idle-timeout: ${DB_IDLE_TIMEOUT:600000}
|
||||
max-lifetime: ${DB_MAX_LIFETIME:1800000}
|
||||
pool-name: RecommendHikariCP
|
||||
|
||||
# JPA 설정
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
format_sql: ${JPA_FORMAT_SQL:true}
|
||||
show_sql: ${JPA_SHOW_SQL:false}
|
||||
use_sql_comments: ${JPA_USE_SQL_COMMENTS:true}
|
||||
jdbc:
|
||||
batch_size: 20
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
open-in-view: false
|
||||
|
||||
# Redis 설정 (올바른 구조)
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
database: ${REDIS_DATABASE:0}
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: ${REDIS_POOL_MAX_ACTIVE:8}
|
||||
max-idle: ${REDIS_POOL_MAX_IDLE:8}
|
||||
min-idle: ${REDIS_POOL_MIN_IDLE:2}
|
||||
max-wait: -1ms
|
||||
shutdown-timeout: 100ms
|
||||
|
||||
# 외부 서비스 URL 설정
|
||||
services:
|
||||
store:
|
||||
url: ${STORE_SERVICE_URL:http://store-service:8082}
|
||||
review:
|
||||
url: ${REVIEW_SERVICE_URL:http://review-service:8083}
|
||||
member:
|
||||
url: ${MEMBER_SERVICE_URL:http://member-service:8081}
|
||||
|
||||
# Feign 설정
|
||||
feign:
|
||||
client:
|
||||
config:
|
||||
default:
|
||||
connectTimeout: 5000
|
||||
readTimeout: 10000
|
||||
loggerLevel: basic
|
||||
store-service:
|
||||
connectTimeout: 3000
|
||||
readTimeout: 8000
|
||||
review-service:
|
||||
connectTimeout: 3000
|
||||
readTimeout: 8000
|
||||
circuitbreaker:
|
||||
enabled: true
|
||||
compression:
|
||||
request:
|
||||
enabled: true
|
||||
response:
|
||||
enabled: true
|
||||
|
||||
# Circuit Breaker 설정
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
instances:
|
||||
store-service:
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 30000
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
review-service:
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 30000
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
retry:
|
||||
instances:
|
||||
store-service:
|
||||
max-attempts: 3
|
||||
wait-duration: 1000
|
||||
review-service:
|
||||
max-attempts: 3
|
||||
wait-duration: 1000
|
||||
|
||||
|
||||
# Actuator 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
|
||||
# Swagger/OpenAPI 설정
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
tags-sorter: alpha
|
||||
operations-sorter: alpha
|
||||
display-request-duration: true
|
||||
display-operation-id: true
|
||||
show-actuator: false
|
||||
|
||||
# 로깅 설정
|
||||
logging:
|
||||
level:
|
||||
root: ${LOG_LEVEL_ROOT:INFO}
|
||||
com.ktds.hi.recommend: ${LOG_LEVEL:INFO}
|
||||
org.springframework.cloud.openfeign: ${LOG_LEVEL_FEIGN:DEBUG}
|
||||
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
|
||||
org.springframework.data.redis: ${LOG_LEVEL_REDIS:INFO}
|
||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:INFO}
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_LEVEL_SQL_PARAM:INFO}
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n"
|
||||
file:
|
||||
name: ${LOG_FILE_PATH:./logs/recommend-service.log}
|
||||
max-size: 100MB
|
||||
max-history: 30
|
||||
|
||||
# Security 설정
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:hiorder-recommend-secret-key-2024}
|
||||
expiration: ${JWT_EXPIRATION:86400000} # 24시간
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
|
||||
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
allowed-headers: "*"
|
||||
allow-credentials: true
|
||||
|
||||
recommend:
|
||||
cache:
|
||||
recommendation-ttl: ${RECOMMENDATION_CACHE_TTL:1800}
|
||||
user-preference-ttl: ${USER_PREFERENCE_CACHE_TTL:3600}
|
||||
algorithm:
|
||||
max-recommendations: ${MAX_RECOMMENDATIONS:20}
|
||||
default-radius: ${DEFAULT_SEARCH_RADIUS:5000}
|
||||
max-radius: ${MAX_SEARCH_RADIUS:10000}
|
||||
---
|
||||
# Local 환경 설정
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: local
|
||||
jpa:
|
||||
show-sql: true
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.ktds.hi.recommend: DEBUG
|
||||
org.springframework.web: DEBUG
|
||||
|
||||
---
|
||||
# Development 환경 설정
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
jpa:
|
||||
show-sql: true
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.ktds.hi.recommend: DEBUG
|
||||
|
||||
---
|
||||
# Production 환경 설정
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
jpa:
|
||||
show-sql: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.ktds.hi.recommend: INFO
|
||||
# recommend/src/main/resources/application.yml
|
||||
server:
|
||||
port: ${RECOMMEND_SERVICE_PORT:8085}
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
compatibility-verifier:
|
||||
enabled: false
|
||||
application:
|
||||
name: recommend-service
|
||||
|
||||
# 프로필 설정
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||
|
||||
# 데이터베이스 설정
|
||||
datasource:
|
||||
url: ${RECOMMEND_DB_URL:jdbc:postgresql://20.249.162.245:5432/hiorder_recommend}
|
||||
username: ${RECOMMEND_DB_USERNAME:hiorder_user}
|
||||
password: ${RECOMMEND_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||
minimum-idle: ${DB_POOL_MIN_IDLE:5}
|
||||
connection-timeout: ${DB_CONNECTION_TIMEOUT:30000}
|
||||
idle-timeout: ${DB_IDLE_TIMEOUT:600000}
|
||||
max-lifetime: ${DB_MAX_LIFETIME:1800000}
|
||||
pool-name: RecommendHikariCP
|
||||
|
||||
# JPA 설정
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
format_sql: ${JPA_FORMAT_SQL:true}
|
||||
show_sql: ${JPA_SHOW_SQL:false}
|
||||
use_sql_comments: ${JPA_USE_SQL_COMMENTS:true}
|
||||
jdbc:
|
||||
batch_size: 20
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
open-in-view: false
|
||||
|
||||
|
||||
|
||||
# Redis 설정 (올바른 구조)
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
database: ${REDIS_DATABASE:0}
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: ${REDIS_POOL_MAX_ACTIVE:8}
|
||||
max-idle: ${REDIS_POOL_MAX_IDLE:8}
|
||||
min-idle: ${REDIS_POOL_MIN_IDLE:2}
|
||||
max-wait: -1ms
|
||||
shutdown-timeout: 100ms
|
||||
|
||||
# 외부 서비스 URL 설정
|
||||
services:
|
||||
store:
|
||||
url: ${STORE_SERVICE_URL:http://store-service:8082}
|
||||
review:
|
||||
url: ${REVIEW_SERVICE_URL:http://review-service:8083}
|
||||
member:
|
||||
url: ${MEMBER_SERVICE_URL:http://member-service:8081}
|
||||
|
||||
# Feign 설정
|
||||
feign:
|
||||
client:
|
||||
config:
|
||||
default:
|
||||
connectTimeout: 5000
|
||||
readTimeout: 10000
|
||||
loggerLevel: basic
|
||||
store-service:
|
||||
connectTimeout: 3000
|
||||
readTimeout: 8000
|
||||
review-service:
|
||||
connectTimeout: 3000
|
||||
readTimeout: 8000
|
||||
circuitbreaker:
|
||||
enabled: true
|
||||
compression:
|
||||
request:
|
||||
enabled: true
|
||||
response:
|
||||
enabled: true
|
||||
|
||||
# Circuit Breaker 설정
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
instances:
|
||||
store-service:
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 30000
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
review-service:
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 30000
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
retry:
|
||||
instances:
|
||||
store-service:
|
||||
max-attempts: 3
|
||||
wait-duration: 1000
|
||||
review-service:
|
||||
max-attempts: 3
|
||||
wait-duration: 1000
|
||||
|
||||
|
||||
# Actuator 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
|
||||
# Swagger/OpenAPI 설정
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
tags-sorter: alpha
|
||||
operations-sorter: alpha
|
||||
display-request-duration: true
|
||||
display-operation-id: true
|
||||
show-actuator: false
|
||||
|
||||
# 로깅 설정
|
||||
logging:
|
||||
level:
|
||||
root: ${LOG_LEVEL_ROOT:INFO}
|
||||
com.ktds.hi.recommend: ${LOG_LEVEL:INFO}
|
||||
org.springframework.cloud.openfeign: ${LOG_LEVEL_FEIGN:DEBUG}
|
||||
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
|
||||
org.springframework.data.redis: ${LOG_LEVEL_REDIS:INFO}
|
||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:INFO}
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_LEVEL_SQL_PARAM:INFO}
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n"
|
||||
file:
|
||||
name: ${LOG_FILE_PATH:./logs/recommend-service.log}
|
||||
max-size: 100MB
|
||||
max-history: 30
|
||||
|
||||
# Security 설정
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:hiorder-recommend-secret-key-2024}
|
||||
expiration: ${JWT_EXPIRATION:86400000} # 24시간
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
|
||||
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
allowed-headers: "*"
|
||||
allow-credentials: true
|
||||
|
||||
recommend:
|
||||
cache:
|
||||
recommendation-ttl: ${RECOMMENDATION_CACHE_TTL:1800}
|
||||
user-preference-ttl: ${USER_PREFERENCE_CACHE_TTL:3600}
|
||||
algorithm:
|
||||
max-recommendations: ${MAX_RECOMMENDATIONS:20}
|
||||
default-radius: ${DEFAULT_SEARCH_RADIUS:5000}
|
||||
max-radius: ${MAX_SEARCH_RADIUS:10000}
|
||||
---
|
||||
# Local 환경 설정
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: local
|
||||
jpa:
|
||||
show-sql: true
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.ktds.hi.recommend: DEBUG
|
||||
org.springframework.web: DEBUG
|
||||
|
||||
---
|
||||
# Development 환경 설정
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
jpa:
|
||||
show-sql: true
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.ktds.hi.recommend: DEBUG
|
||||
|
||||
---
|
||||
# Production 환경 설정
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
jpa:
|
||||
show-sql: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.ktds.hi.recommend: INFO
|
||||
org.springframework.cloud.openfeign: INFO
|
||||
@ -1,143 +1,143 @@
|
||||
package com.ktds.hi.review.biz.service;
|
||||
|
||||
import com.ktds.hi.review.biz.usecase.in.CreateReviewUseCase;
|
||||
import com.ktds.hi.review.biz.usecase.in.DeleteReviewUseCase;
|
||||
import com.ktds.hi.review.biz.usecase.in.GetReviewUseCase;
|
||||
import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
|
||||
import com.ktds.hi.review.biz.domain.Review;
|
||||
import com.ktds.hi.review.biz.domain.ReviewStatus;
|
||||
import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest;
|
||||
import com.ktds.hi.review.infra.dto.response.*;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 리뷰 인터랙터 클래스
|
||||
* 리뷰 생성, 조회, 삭제 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class ReviewInteractor implements CreateReviewUseCase, DeleteReviewUseCase, GetReviewUseCase {
|
||||
|
||||
private final ReviewRepository reviewRepository;
|
||||
|
||||
@Override
|
||||
public ReviewCreateResponse createReview(Long memberId, ReviewCreateRequest request) {
|
||||
// 리뷰 생성
|
||||
Review review = Review.builder()
|
||||
.storeId(request.getStoreId())
|
||||
.memberId(memberId)
|
||||
.memberNickname("회원" + memberId) // TODO: 회원 서비스에서 닉네임 조회
|
||||
.rating(request.getRating())
|
||||
.content(request.getContent())
|
||||
.imageUrls(request.getImageUrls())
|
||||
.status(ReviewStatus.ACTIVE)
|
||||
.likeCount(0)
|
||||
.dislikeCount(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
Review savedReview = reviewRepository.saveReview(review);
|
||||
|
||||
log.info("리뷰 생성 완료: reviewId={}, storeId={}, memberId={}",
|
||||
savedReview.getId(), savedReview.getStoreId(), savedReview.getMemberId());
|
||||
|
||||
return ReviewCreateResponse.builder()
|
||||
.reviewId(savedReview.getId())
|
||||
.message("리뷰가 성공적으로 등록되었습니다")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReviewDeleteResponse deleteReview(Long reviewId, Long memberId) {
|
||||
Review review = reviewRepository.findReviewByIdAndMemberId(reviewId, memberId)
|
||||
.orElseThrow(() -> new BusinessException("리뷰를 찾을 수 없거나 권한이 없습니다"));
|
||||
|
||||
reviewRepository.deleteReview(reviewId);
|
||||
|
||||
log.info("리뷰 삭제 완료: reviewId={}, memberId={}", reviewId, memberId);
|
||||
|
||||
return ReviewDeleteResponse.builder()
|
||||
.success(true)
|
||||
.message("리뷰가 삭제되었습니다")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size) {
|
||||
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
|
||||
Page<Review> reviews = reviewRepository.findReviewsByStoreId(storeId, pageable);
|
||||
|
||||
return reviews.stream()
|
||||
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE)
|
||||
.map(review -> ReviewListResponse.builder()
|
||||
.reviewId(review.getId())
|
||||
.memberNickname(review.getMemberNickname())
|
||||
.rating(review.getRating())
|
||||
.content(review.getContent())
|
||||
.imageUrls(review.getImageUrls())
|
||||
.likeCount(review.getLikeCount())
|
||||
.dislikeCount(review.getDislikeCount())
|
||||
.createdAt(review.getCreatedAt())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public ReviewDetailResponse getReviewDetail(Long reviewId) {
|
||||
Review review = reviewRepository.findReviewById(reviewId)
|
||||
.orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다"));
|
||||
|
||||
if (review.getStatus() != ReviewStatus.ACTIVE) {
|
||||
throw new BusinessException("삭제되었거나 숨겨진 리뷰입니다");
|
||||
}
|
||||
|
||||
return ReviewDetailResponse.builder()
|
||||
.reviewId(review.getId())
|
||||
.storeId(review.getStoreId())
|
||||
.memberNickname(review.getMemberNickname())
|
||||
.rating(review.getRating())
|
||||
.content(review.getContent())
|
||||
.imageUrls(review.getImageUrls())
|
||||
.likeCount(review.getLikeCount())
|
||||
.dislikeCount(review.getDislikeCount())
|
||||
.createdAt(review.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ReviewListResponse> getMyReviews(Long memberId, Integer page, Integer size) {
|
||||
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
|
||||
Page<Review> reviews = reviewRepository.findReviewsByMemberId(memberId, pageable);
|
||||
|
||||
return reviews.stream()
|
||||
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE)
|
||||
.map(review -> ReviewListResponse.builder()
|
||||
.reviewId(review.getId())
|
||||
.memberNickname(review.getMemberNickname())
|
||||
.rating(review.getRating())
|
||||
.content(review.getContent())
|
||||
.imageUrls(review.getImageUrls())
|
||||
.likeCount(review.getLikeCount())
|
||||
.dislikeCount(review.getDislikeCount())
|
||||
.createdAt(review.getCreatedAt())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.review.biz.service;
|
||||
|
||||
import com.ktds.hi.review.biz.usecase.in.CreateReviewUseCase;
|
||||
import com.ktds.hi.review.biz.usecase.in.DeleteReviewUseCase;
|
||||
import com.ktds.hi.review.biz.usecase.in.GetReviewUseCase;
|
||||
import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
|
||||
import com.ktds.hi.review.biz.domain.Review;
|
||||
import com.ktds.hi.review.biz.domain.ReviewStatus;
|
||||
import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest;
|
||||
import com.ktds.hi.review.infra.dto.response.*;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 리뷰 인터랙터 클래스
|
||||
* 리뷰 생성, 조회, 삭제 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class ReviewInteractor implements CreateReviewUseCase, DeleteReviewUseCase, GetReviewUseCase {
|
||||
|
||||
private final ReviewRepository reviewRepository;
|
||||
|
||||
@Override
|
||||
public ReviewCreateResponse createReview(Long memberId, ReviewCreateRequest request) {
|
||||
// 리뷰 생성
|
||||
Review review = Review.builder()
|
||||
.storeId(request.getStoreId())
|
||||
.memberId(memberId)
|
||||
.memberNickname("회원" + memberId) // TODO: 회원 서비스에서 닉네임 조회
|
||||
.rating(request.getRating())
|
||||
.content(request.getContent())
|
||||
.imageUrls(request.getImageUrls())
|
||||
.status(ReviewStatus.ACTIVE)
|
||||
.likeCount(0)
|
||||
.dislikeCount(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
Review savedReview = reviewRepository.saveReview(review);
|
||||
|
||||
log.info("리뷰 생성 완료: reviewId={}, storeId={}, memberId={}",
|
||||
savedReview.getId(), savedReview.getStoreId(), savedReview.getMemberId());
|
||||
|
||||
return ReviewCreateResponse.builder()
|
||||
.reviewId(savedReview.getId())
|
||||
.message("리뷰가 성공적으로 등록되었습니다")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReviewDeleteResponse deleteReview(Long reviewId, Long memberId) {
|
||||
Review review = reviewRepository.findReviewByIdAndMemberId(reviewId, memberId)
|
||||
.orElseThrow(() -> new BusinessException("리뷰를 찾을 수 없거나 권한이 없습니다"));
|
||||
|
||||
reviewRepository.deleteReview(reviewId);
|
||||
|
||||
log.info("리뷰 삭제 완료: reviewId={}, memberId={}", reviewId, memberId);
|
||||
|
||||
return ReviewDeleteResponse.builder()
|
||||
.success(true)
|
||||
.message("리뷰가 삭제되었습니다")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size) {
|
||||
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
|
||||
Page<Review> reviews = reviewRepository.findReviewsByStoreId(storeId, pageable);
|
||||
|
||||
return reviews.stream()
|
||||
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE)
|
||||
.map(review -> ReviewListResponse.builder()
|
||||
.reviewId(review.getId())
|
||||
.memberNickname(review.getMemberNickname())
|
||||
.rating(review.getRating())
|
||||
.content(review.getContent())
|
||||
.imageUrls(review.getImageUrls())
|
||||
.likeCount(review.getLikeCount())
|
||||
.dislikeCount(review.getDislikeCount())
|
||||
.createdAt(review.getCreatedAt())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public ReviewDetailResponse getReviewDetail(Long reviewId) {
|
||||
Review review = reviewRepository.findReviewById(reviewId)
|
||||
.orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다"));
|
||||
|
||||
if (review.getStatus() != ReviewStatus.ACTIVE) {
|
||||
throw new BusinessException("삭제되었거나 숨겨진 리뷰입니다");
|
||||
}
|
||||
|
||||
return ReviewDetailResponse.builder()
|
||||
.reviewId(review.getId())
|
||||
.storeId(review.getStoreId())
|
||||
.memberNickname(review.getMemberNickname())
|
||||
.rating(review.getRating())
|
||||
.content(review.getContent())
|
||||
.imageUrls(review.getImageUrls())
|
||||
.likeCount(review.getLikeCount())
|
||||
.dislikeCount(review.getDislikeCount())
|
||||
.createdAt(review.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ReviewListResponse> getMyReviews(Long memberId, Integer page, Integer size) {
|
||||
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
|
||||
Page<Review> reviews = reviewRepository.findReviewsByMemberId(memberId, pageable);
|
||||
|
||||
return reviews.stream()
|
||||
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE)
|
||||
.map(review -> ReviewListResponse.builder()
|
||||
.reviewId(review.getId())
|
||||
.memberNickname(review.getMemberNickname())
|
||||
.rating(review.getRating())
|
||||
.content(review.getContent())
|
||||
.imageUrls(review.getImageUrls())
|
||||
.likeCount(review.getLikeCount())
|
||||
.dislikeCount(review.getDislikeCount())
|
||||
.createdAt(review.getCreatedAt())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +1,50 @@
|
||||
package com.ktds.hi.review.infra.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.CorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Analytics 서비스 보안 설정 클래스
|
||||
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
@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
|
||||
// Swagger 관련 경로 모두 허용
|
||||
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// Analytics API 모두 허용 (테스트용)
|
||||
.requestMatchers("/api/analytics/**").permitAll()
|
||||
.requestMatchers("/api/action-plans/**").permitAll()
|
||||
|
||||
// Actuator 엔드포인트 허용
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// 기타 모든 요청 허용 (테스트용)
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.review.infra.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.CorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Analytics 서비스 보안 설정 클래스
|
||||
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
@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
|
||||
// Swagger 관련 경로 모두 허용
|
||||
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// Analytics API 모두 허용 (테스트용)
|
||||
.requestMatchers("/api/analytics/**").permitAll()
|
||||
.requestMatchers("/api/action-plans/**").permitAll()
|
||||
|
||||
// Actuator 엔드포인트 허용
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// 기타 모든 요청 허용 (테스트용)
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +1,34 @@
|
||||
package com.ktds.hi.review.infra.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
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;
|
||||
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
final String securitySchemeName = "Bearer Authentication";
|
||||
|
||||
return new OpenAPI()
|
||||
.addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 리뷰 관리 서비스 API")
|
||||
.description("리뷰 작성, 조회, 삭제, 반응, 댓글 등 리뷰 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"))
|
||||
.addSecurityItem(new SecurityRequirement()
|
||||
.addList(securitySchemeName))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes(securitySchemeName, new SecurityScheme()
|
||||
.name(securitySchemeName)
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")));
|
||||
}
|
||||
package com.ktds.hi.review.infra.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
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;
|
||||
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
final String securitySchemeName = "Bearer Authentication";
|
||||
|
||||
return new OpenAPI()
|
||||
.addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 리뷰 관리 서비스 API")
|
||||
.description("리뷰 작성, 조회, 삭제, 반응, 댓글 등 리뷰 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"))
|
||||
.addSecurityItem(new SecurityRequirement()
|
||||
.addList(securitySchemeName))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes(securitySchemeName, new SecurityScheme()
|
||||
.name(securitySchemeName)
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")));
|
||||
}
|
||||
}
|
||||
@ -1,42 +1,42 @@
|
||||
server:
|
||||
port: ${REVIEW_SERVICE_PORT:8083}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: review-service
|
||||
|
||||
datasource:
|
||||
url: ${REVIEW_DB_URL:jdbc:postgresql://20.214.91.15:5432/hiorder_review}
|
||||
username: ${REVIEW_DB_USERNAME:hiorder_user}
|
||||
password: ${REVIEW_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: ${MAX_FILE_SIZE:10MB}
|
||||
max-request-size: ${MAX_REQUEST_SIZE:50MB}
|
||||
|
||||
file-storage:
|
||||
base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads}
|
||||
allowed-extensions: jpg,jpeg,png,gif,webp
|
||||
max-file-size: 10485760 # 10MB
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
server:
|
||||
port: ${REVIEW_SERVICE_PORT:8083}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: review-service
|
||||
|
||||
datasource:
|
||||
url: ${REVIEW_DB_URL:jdbc:postgresql://20.214.91.15:5432/hiorder_review}
|
||||
username: ${REVIEW_DB_USERNAME:hiorder_user}
|
||||
password: ${REVIEW_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: ${MAX_FILE_SIZE:10MB}
|
||||
max-request-size: ${MAX_REQUEST_SIZE:50MB}
|
||||
|
||||
file-storage:
|
||||
base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads}
|
||||
allowed-extensions: jpg,jpeg,png,gif,webp
|
||||
max-file-size: 10485760 # 10MB
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
|
||||
@ -1,4 +1,342 @@
|
||||
package com.ktds.hi.store.biz.service;
|
||||
|
||||
public class StoreService {
|
||||
}
|
||||
// store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java
|
||||
package com.ktds.hi.store.biz.service;
|
||||
|
||||
import com.ktds.hi.store.biz.usecase.in.StoreUseCase;
|
||||
import com.ktds.hi.store.biz.usecase.out.*;
|
||||
import com.ktds.hi.store.biz.domain.Store;
|
||||
import com.ktds.hi.store.biz.domain.StoreStatus;
|
||||
import com.ktds.hi.store.infra.dto.*;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
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.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 매장 서비스 구현체
|
||||
* Clean Architecture의 Application Service Layer
|
||||
*
|
||||
* @author 하이오더 개발팀
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class StoreService implements StoreUseCase {
|
||||
|
||||
private final StoreRepositoryPort storeRepositoryPort;
|
||||
private final MenuRepositoryPort menuRepositoryPort;
|
||||
private final StoreTagRepositoryPort storeTagRepositoryPort;
|
||||
private final GeocodingPort geocodingPort;
|
||||
private final CachePort cachePort;
|
||||
private final EventPort eventPort;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public StoreCreateResponse createStore(Long ownerId, StoreCreateRequest request) {
|
||||
log.info("매장 등록 시작: ownerId={}, storeName={}", ownerId, request.getStoreName());
|
||||
|
||||
try {
|
||||
// 1. 입력값 검증
|
||||
validateStoreCreateRequest(request);
|
||||
|
||||
// 2. 점주 매장 개수 제한 확인 (예: 최대 10개)
|
||||
validateOwnerStoreLimit(ownerId);
|
||||
|
||||
// 3. 주소 지오코딩 (좌표 변환)
|
||||
Coordinates coordinates = geocodingPort.getCoordinates(request.getAddress());
|
||||
|
||||
// 4. Store 도메인 객체 생성
|
||||
Store store = Store.builder()
|
||||
.ownerId(ownerId)
|
||||
.storeName(request.getStoreName())
|
||||
.address(request.getAddress())
|
||||
.latitude(coordinates.getLatitude())
|
||||
.longitude(coordinates.getLongitude())
|
||||
.description(request.getDescription())
|
||||
.phone(request.getPhone())
|
||||
.operatingHours(request.getOperatingHours())
|
||||
.category(request.getCategory())
|
||||
.status(StoreStatus.ACTIVE)
|
||||
.rating(0.0)
|
||||
.reviewCount(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 5. 매장 저장
|
||||
Store savedStore = storeRepositoryPort.saveStore(store);
|
||||
|
||||
// 6. 매장 태그 저장
|
||||
if (request.getTags() != null && !request.getTags().isEmpty()) {
|
||||
storeTagRepositoryPort.saveStoreTags(savedStore.getId(), request.getTags());
|
||||
}
|
||||
|
||||
// 7. 메뉴 정보 저장
|
||||
if (request.getMenus() != null && !request.getMenus().isEmpty()) {
|
||||
menuRepositoryPort.saveMenus(savedStore.getId(),
|
||||
request.getMenus().stream()
|
||||
.map(menuReq -> menuReq.toDomain(savedStore.getId()))
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
// 8. 매장 생성 이벤트 발행
|
||||
eventPort.publishStoreCreatedEvent(savedStore);
|
||||
|
||||
// 9. 캐시 무효화
|
||||
cachePort.invalidateStoreCache(ownerId);
|
||||
|
||||
log.info("매장 등록 완료: storeId={}", savedStore.getId());
|
||||
|
||||
return StoreCreateResponse.builder()
|
||||
.storeId(savedStore.getId())
|
||||
.storeName(savedStore.getStoreName())
|
||||
.message("매장이 성공적으로 등록되었습니다.")
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("매장 등록 실패: ownerId={}, error={}", ownerId, e.getMessage(), e);
|
||||
throw new BusinessException("STORE_CREATE_FAILED", "매장 등록에 실패했습니다: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MyStoreListResponse> getMyStores(Long ownerId) {
|
||||
log.info("내 매장 목록 조회: ownerId={}", ownerId);
|
||||
|
||||
// 1. 캐시 확인
|
||||
String cacheKey = "stores:owner:" + ownerId;
|
||||
List<MyStoreListResponse> cachedStores = cachePort.getStoreCache(cacheKey);
|
||||
if (cachedStores != null) {
|
||||
log.info("캐시에서 매장 목록 반환: ownerId={}, count={}", ownerId, cachedStores.size());
|
||||
return cachedStores;
|
||||
}
|
||||
|
||||
// 2. DB에서 매장 목록 조회
|
||||
List<Store> stores = storeRepositoryPort.findStoresByOwnerId(ownerId);
|
||||
|
||||
// 3. 응답 DTO 변환
|
||||
List<MyStoreListResponse> responses = stores.stream()
|
||||
.map(store -> {
|
||||
String status = calculateStoreStatus(store);
|
||||
return MyStoreListResponse.builder()
|
||||
.storeId(store.getId())
|
||||
.storeName(store.getStoreName())
|
||||
.address(store.getAddress())
|
||||
.category(store.getCategory())
|
||||
.rating(store.getRating())
|
||||
.reviewCount(store.getReviewCount())
|
||||
.status(status)
|
||||
.operatingHours(store.getOperatingHours())
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 4. 캐시 저장 (1시간)
|
||||
cachePort.putStoreCache(cacheKey, responses, 3600);
|
||||
|
||||
log.info("내 매장 목록 조회 완료: ownerId={}, count={}", ownerId, responses.size());
|
||||
return responses;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StoreDetailResponse getStoreDetail(Long storeId) {
|
||||
log.info("매장 상세 조회: storeId={}", storeId);
|
||||
|
||||
// 1. 매장 기본 정보 조회
|
||||
Store store = storeRepositoryPort.findStoreById(storeId)
|
||||
.orElseThrow(() -> new BusinessException("STORE_NOT_FOUND", "매장을 찾을 수 없습니다."));
|
||||
|
||||
// 2. 매장 태그 조회
|
||||
List<String> tags = storeTagRepositoryPort.findTagsByStoreId(storeId);
|
||||
|
||||
// 3. 메뉴 정보 조회
|
||||
List<MenuResponse> menus = menuRepositoryPort.findMenusByStoreId(storeId)
|
||||
.stream()
|
||||
.map(MenuResponse::from)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 4. AI 요약 정보 조회 (외부 서비스)
|
||||
String aiSummary = getAISummary(storeId);
|
||||
|
||||
return StoreDetailResponse.builder()
|
||||
.storeId(store.getId())
|
||||
.storeName(store.getStoreName())
|
||||
.address(store.getAddress())
|
||||
.latitude(store.getLatitude())
|
||||
.longitude(store.getLongitude())
|
||||
.description(store.getDescription())
|
||||
.phone(store.getPhone())
|
||||
.operatingHours(store.getOperatingHours())
|
||||
.category(store.getCategory())
|
||||
.rating(store.getRating())
|
||||
.reviewCount(store.getReviewCount())
|
||||
.status(store.getStatus().name())
|
||||
.tags(tags)
|
||||
.menus(menus)
|
||||
.aiSummary(aiSummary)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public StoreUpdateResponse updateStore(Long storeId, Long ownerId, StoreUpdateRequest request) {
|
||||
log.info("매장 정보 수정: storeId={}, ownerId={}", storeId, ownerId);
|
||||
|
||||
// 1. 매장 소유권 확인
|
||||
Store store = validateStoreOwnership(storeId, ownerId);
|
||||
|
||||
// 2. 주소 변경 시 지오코딩
|
||||
Coordinates coordinates = null;
|
||||
if (!store.getAddress().equals(request.getAddress())) {
|
||||
coordinates = geocodingPort.getCoordinates(request.getAddress());
|
||||
}
|
||||
|
||||
// 3. 매장 정보 업데이트
|
||||
store.updateBasicInfo(
|
||||
request.getStoreName(),
|
||||
request.getAddress(),
|
||||
request.getDescription(),
|
||||
request.getPhone(),
|
||||
request.getOperatingHours()
|
||||
);
|
||||
|
||||
if (coordinates != null) {
|
||||
store.updateLocation(coordinates);
|
||||
}
|
||||
|
||||
Store updatedStore = storeRepositoryPort.saveStore(store);
|
||||
|
||||
// 4. 태그 업데이트
|
||||
if (request.getTags() != null) {
|
||||
storeTagRepositoryPort.deleteTagsByStoreId(storeId);
|
||||
storeTagRepositoryPort.saveStoreTags(storeId, request.getTags());
|
||||
}
|
||||
|
||||
// 5. 매장 수정 이벤트 발행
|
||||
eventPort.publishStoreUpdatedEvent(updatedStore);
|
||||
|
||||
// 6. 캐시 무효화
|
||||
cachePort.invalidateStoreCache(storeId);
|
||||
cachePort.invalidateStoreCache(ownerId);
|
||||
|
||||
return StoreUpdateResponse.builder()
|
||||
.storeId(storeId)
|
||||
.message("매장 정보가 성공적으로 수정되었습니다.")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public StoreDeleteResponse deleteStore(Long storeId, Long ownerId) {
|
||||
log.info("매장 삭제: storeId={}, ownerId={}", storeId, ownerId);
|
||||
|
||||
// 1. 매장 소유권 확인
|
||||
Store store = validateStoreOwnership(storeId, ownerId);
|
||||
|
||||
// 2. 소프트 삭제 (상태 변경)
|
||||
store.delete();
|
||||
storeRepositoryPort.saveStore(store);
|
||||
|
||||
// 3. 매장 삭제 이벤트 발행
|
||||
eventPort.publishStoreDeletedEvent(storeId);
|
||||
|
||||
// 4. 캐시 무효화
|
||||
cachePort.invalidateStoreCache(storeId);
|
||||
cachePort.invalidateStoreCache(ownerId);
|
||||
|
||||
return StoreDeleteResponse.builder()
|
||||
.storeId(storeId)
|
||||
.message("매장이 성공적으로 삭제되었습니다.")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StoreSearchResponse> searchStores(String keyword, String category, String tags,
|
||||
Double latitude, Double longitude, Integer radius,
|
||||
Integer page, Integer size) {
|
||||
log.info("매장 검색: keyword={}, category={}, location=({}, {})", keyword, category, latitude, longitude);
|
||||
|
||||
StoreSearchCriteria criteria = StoreSearchCriteria.builder()
|
||||
.keyword(keyword)
|
||||
.category(category)
|
||||
.tags(tags)
|
||||
.latitude(latitude)
|
||||
.longitude(longitude)
|
||||
.radius(radius)
|
||||
.page(page)
|
||||
.size(size)
|
||||
.build();
|
||||
|
||||
List<Store> stores = storeRepositoryPort.searchStores(criteria);
|
||||
|
||||
return stores.stream()
|
||||
.map(store -> StoreSearchResponse.builder()
|
||||
.storeId(store.getId())
|
||||
.storeName(store.getStoreName())
|
||||
.address(store.getAddress())
|
||||
.category(store.getCategory())
|
||||
.rating(store.getRating())
|
||||
.reviewCount(store.getReviewCount())
|
||||
.distance(calculateDistance(latitude, longitude, store.getLatitude(), store.getLongitude()))
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// === Private Helper Methods ===
|
||||
|
||||
private void validateStoreCreateRequest(StoreCreateRequest request) {
|
||||
if (request.getStoreName() == null || request.getStoreName().trim().isEmpty()) {
|
||||
throw new BusinessException("INVALID_STORE_NAME", "매장명은 필수입니다.");
|
||||
}
|
||||
if (request.getStoreName().length() > 100) {
|
||||
throw new BusinessException("INVALID_STORE_NAME", "매장명은 100자를 초과할 수 없습니다.");
|
||||
}
|
||||
if (request.getAddress() == null || request.getAddress().trim().isEmpty()) {
|
||||
throw new BusinessException("INVALID_ADDRESS", "주소는 필수입니다.");
|
||||
}
|
||||
if (request.getPhone() != null && !request.getPhone().matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
throw new BusinessException("INVALID_PHONE", "전화번호 형식이 올바르지 않습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOwnerStoreLimit(Long ownerId) {
|
||||
Long storeCount = storeRepositoryPort.countStoresByOwnerId(ownerId);
|
||||
if (storeCount >= 10) {
|
||||
throw new BusinessException("STORE_LIMIT_EXCEEDED", "매장은 최대 10개까지 등록할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private Store validateStoreOwnership(Long storeId, Long ownerId) {
|
||||
return storeRepositoryPort.findStoreByIdAndOwnerId(storeId, ownerId)
|
||||
.orElseThrow(() -> new BusinessException("STORE_ACCESS_DENIED", "매장에 대한 권한이 없습니다."));
|
||||
}
|
||||
|
||||
private String calculateStoreStatus(Store store) {
|
||||
if (!store.isActive()) {
|
||||
return "비활성";
|
||||
}
|
||||
// 운영시간 기반 현재 상태 계산 로직
|
||||
return "운영중";
|
||||
}
|
||||
|
||||
private String getAISummary(Long storeId) {
|
||||
// TODO: AI 분석 서비스 연동 구현
|
||||
return "AI 요약 정보가 준비 중입니다.";
|
||||
}
|
||||
|
||||
private Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2) {
|
||||
if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) {
|
||||
return null;
|
||||
}
|
||||
return geocodingPort.calculateDistance(
|
||||
new Coordinates(lat1, lon1),
|
||||
new Coordinates(lat2, lon2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,26 +1,24 @@
|
||||
package com.ktds.hi.store.biz.usecase.out;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 캐시 포트 인터페이스
|
||||
* 캐시 기능을 정의
|
||||
*/
|
||||
public interface CachePort {
|
||||
|
||||
/**
|
||||
* 캐시에서 매장 데이터 조회
|
||||
*/
|
||||
Optional<Object> getStoreCache(String key);
|
||||
|
||||
/**
|
||||
* 캐시에 매장 데이터 저장
|
||||
*/
|
||||
void putStoreCache(String key, Object value, Duration ttl);
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
*/
|
||||
void invalidateStoreCache(Long storeId);
|
||||
}
|
||||
package com.ktds.hi.store.biz.usecase.out;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 캐시 포트 인터페이스
|
||||
*/
|
||||
public interface CachePort {
|
||||
|
||||
/**
|
||||
* 캐시에서 매장 정보 조회
|
||||
*/
|
||||
<T> T getStoreCache(String key);
|
||||
|
||||
/**
|
||||
* 캐시에 매장 정보 저장
|
||||
*/
|
||||
void putStoreCache(String key, Object value, long ttlSeconds);
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
*/
|
||||
void invalidateStoreCache(Object key);
|
||||
}
|
||||
@ -1,4 +1,16 @@
|
||||
package com.ktds.hi.store.biz.usecase.out;
|
||||
|
||||
public class Coordinates {
|
||||
}
|
||||
package com.ktds.hi.store.biz.usecase.out;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 좌표 정보 값 객체
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Coordinates {
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
}
|
||||
|
||||
@ -1,4 +1,17 @@
|
||||
package com.ktds.hi.store.biz.usecase.out;
|
||||
|
||||
public interface GeocodingPort {
|
||||
}
|
||||
package com.ktds.hi.store.biz.usecase.out;
|
||||
|
||||
/**
|
||||
* 지오코딩 포트 인터페이스
|
||||
*/
|
||||
public interface GeocodingPort {
|
||||
|
||||
/**
|
||||
* 주소를 좌표로 변환
|
||||
*/
|
||||
Coordinates getCoordinates(String address);
|
||||
|
||||
/**
|
||||
* 두 좌표 간 거리 계산 (km)
|
||||
*/
|
||||
Double calculateDistance(Coordinates coord1, Coordinates coord2);
|
||||
}
|
||||
@ -1,4 +1,24 @@
|
||||
package com.ktds.hi.store.biz.usecase.out;
|
||||
|
||||
public interface StoreTagRepositoryPort {
|
||||
}
|
||||
package com.ktds.hi.store.biz.usecase.out;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 태그 리포지토리 포트 인터페이스
|
||||
*/
|
||||
public interface StoreTagRepositoryPort {
|
||||
|
||||
/**
|
||||
* 매장 ID로 태그 목록 조회
|
||||
*/
|
||||
List<String> findTagsByStoreId(Long storeId);
|
||||
|
||||
/**
|
||||
* 매장 태그 저장
|
||||
*/
|
||||
void saveStoreTags(Long storeId, List<String> tags);
|
||||
|
||||
/**
|
||||
* 매장 태그 삭제
|
||||
*/
|
||||
void deleteTagsByStoreId(Long storeId);
|
||||
}
|
||||
@ -1,50 +1,50 @@
|
||||
package com.ktds.hi.store.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.CorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Analytics 서비스 보안 설정 클래스
|
||||
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
@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
|
||||
// Swagger 관련 경로 모두 허용
|
||||
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// Analytics API 모두 허용 (테스트용)
|
||||
.requestMatchers("/api/analytics/**").permitAll()
|
||||
.requestMatchers("/api/action-plans/**").permitAll()
|
||||
|
||||
// Actuator 엔드포인트 허용
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// 기타 모든 요청 허용 (테스트용)
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
package com.ktds.hi.store.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.CorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Analytics 서비스 보안 설정 클래스
|
||||
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
@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
|
||||
// Swagger 관련 경로 모두 허용
|
||||
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// Analytics API 모두 허용 (테스트용)
|
||||
.requestMatchers("/api/analytics/**").permitAll()
|
||||
.requestMatchers("/api/action-plans/**").permitAll()
|
||||
|
||||
// Actuator 엔드포인트 허용
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// 기타 모든 요청 허용 (테스트용)
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +1,13 @@
|
||||
package com.ktds.hi.store.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 메뉴 도메인 클래스
|
||||
* 메뉴 정보를 담는 도메인 객체
|
||||
*
|
||||
* @author 하이오더 개발팀
|
||||
* @version 1.0.0
|
||||
* 메뉴 도메인 엔티티
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Menu {
|
||||
|
||||
private Long id;
|
||||
@ -27,164 +17,31 @@ public class Menu {
|
||||
private Integer price;
|
||||
private String category;
|
||||
private String imageUrl;
|
||||
private Boolean isAvailable;
|
||||
private Integer orderCount;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private Boolean available;
|
||||
|
||||
/**
|
||||
* 메뉴 기본 정보 업데이트
|
||||
* 메뉴 정보 업데이트
|
||||
*/
|
||||
public Menu updateInfo(String menuName, String description, Integer price) {
|
||||
return Menu.builder()
|
||||
.id(this.id)
|
||||
.storeId(this.storeId)
|
||||
.menuName(menuName)
|
||||
.description(description)
|
||||
.price(price)
|
||||
.category(this.category)
|
||||
.imageUrl(this.imageUrl)
|
||||
.isAvailable(this.isAvailable)
|
||||
.orderCount(this.orderCount)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
public void updateMenuInfo(String menuName, String description, Integer price,
|
||||
String category, String imageUrl) {
|
||||
this.menuName = menuName;
|
||||
this.description = description;
|
||||
this.price = price;
|
||||
this.category = category;
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 이미지 업데이트
|
||||
* 메뉴 판매 상태 변경
|
||||
*/
|
||||
public Menu updateImage(String imageUrl) {
|
||||
return Menu.builder()
|
||||
.id(this.id)
|
||||
.storeId(this.storeId)
|
||||
.menuName(this.menuName)
|
||||
.description(this.description)
|
||||
.price(this.price)
|
||||
.category(this.category)
|
||||
.imageUrl(imageUrl)
|
||||
.isAvailable(this.isAvailable)
|
||||
.orderCount(this.orderCount)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
public void setAvailable(Boolean available) {
|
||||
this.available = available;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 판매 가능 상태 설정
|
||||
* 메뉴 이용 가능 여부 확인
|
||||
*/
|
||||
public Menu setAvailable(Boolean available) {
|
||||
return Menu.builder()
|
||||
.id(this.id)
|
||||
.storeId(this.storeId)
|
||||
.menuName(this.menuName)
|
||||
.description(this.description)
|
||||
.price(this.price)
|
||||
.category(this.category)
|
||||
.imageUrl(this.imageUrl)
|
||||
.isAvailable(available)
|
||||
.orderCount(this.orderCount)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문 수 증가
|
||||
*/
|
||||
public Menu incrementOrderCount() {
|
||||
return Menu.builder()
|
||||
.id(this.id)
|
||||
.storeId(this.storeId)
|
||||
.menuName(this.menuName)
|
||||
.description(this.description)
|
||||
.price(this.price)
|
||||
.category(this.category)
|
||||
.imageUrl(this.imageUrl)
|
||||
.isAvailable(this.isAvailable)
|
||||
.orderCount(this.orderCount != null ? this.orderCount + 1 : 1)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 판매 가능 여부 확인
|
||||
*/
|
||||
public Boolean isAvailable() {
|
||||
return this.isAvailable != null && this.isAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 매장에 속하는지 확인
|
||||
*/
|
||||
public boolean belongsToStore(Long storeId) {
|
||||
return this.storeId != null && this.storeId.equals(storeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴가 유효한지 확인
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return this.menuName != null && !this.menuName.trim().isEmpty() &&
|
||||
this.price != null && this.price > 0 &&
|
||||
this.storeId != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 카테고리 업데이트
|
||||
*/
|
||||
public Menu updateCategory(String category) {
|
||||
return Menu.builder()
|
||||
.id(this.id)
|
||||
.storeId(this.storeId)
|
||||
.menuName(this.menuName)
|
||||
.description(this.description)
|
||||
.price(this.price)
|
||||
.category(category)
|
||||
.imageUrl(this.imageUrl)
|
||||
.isAvailable(this.isAvailable)
|
||||
.orderCount(this.orderCount)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 가격 할인 적용
|
||||
*/
|
||||
public Menu applyDiscount(double discountRate) {
|
||||
if (discountRate < 0 || discountRate > 1) {
|
||||
throw new IllegalArgumentException("할인율은 0~1 사이의 값이어야 합니다.");
|
||||
}
|
||||
|
||||
int discountedPrice = (int) (this.price * (1 - discountRate));
|
||||
|
||||
return Menu.builder()
|
||||
.id(this.id)
|
||||
.storeId(this.storeId)
|
||||
.menuName(this.menuName)
|
||||
.description(this.description)
|
||||
.price(discountedPrice)
|
||||
.category(this.category)
|
||||
.imageUrl(this.imageUrl)
|
||||
.isAvailable(this.isAvailable)
|
||||
.orderCount(this.orderCount)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보가 변경되었는지 확인
|
||||
*/
|
||||
public boolean hasChanges(Menu other) {
|
||||
if (other == null) return true;
|
||||
|
||||
return !this.menuName.equals(other.menuName) ||
|
||||
!this.description.equals(other.description) ||
|
||||
!this.price.equals(other.price) ||
|
||||
!this.category.equals(other.category) ||
|
||||
!this.isAvailable.equals(other.isAvailable);
|
||||
public boolean isAvailable() {
|
||||
return this.available != null && this.available;
|
||||
}
|
||||
}
|
||||
@ -1,24 +1,19 @@
|
||||
// store/src/main/java/com/ktds/hi/store/biz/domain/Store.java
|
||||
package com.ktds.hi.store.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 도메인 클래스
|
||||
* 매장 정보를 담는 도메인 객체
|
||||
* 매장 도메인 엔티티
|
||||
* Clean Architecture의 Domain Layer
|
||||
*
|
||||
* @author 하이오더 개발팀
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Store {
|
||||
|
||||
private Long id;
|
||||
@ -27,160 +22,87 @@ public class Store {
|
||||
private String address;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
private String category;
|
||||
private String description;
|
||||
private String phone;
|
||||
private String operatingHours;
|
||||
private List<String> tags;
|
||||
private StoreStatus status;
|
||||
private String category;
|
||||
private Double rating;
|
||||
private Integer reviewCount;
|
||||
private String imageUrl;
|
||||
private StoreStatus status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 매장 기본 정보 업데이트
|
||||
*/
|
||||
public Store updateBasicInfo(String storeName, String address, String description,
|
||||
String phone, String operatingHours) {
|
||||
return Store.builder()
|
||||
.id(this.id)
|
||||
.ownerId(this.ownerId)
|
||||
.storeName(storeName)
|
||||
.address(address)
|
||||
.latitude(this.latitude)
|
||||
.longitude(this.longitude)
|
||||
.category(this.category)
|
||||
.description(description)
|
||||
.phone(phone)
|
||||
.operatingHours(operatingHours)
|
||||
.tags(this.tags)
|
||||
.status(this.status)
|
||||
.rating(this.rating)
|
||||
.reviewCount(this.reviewCount)
|
||||
.imageUrl(this.imageUrl)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
public void updateBasicInfo(String storeName, String address, String description,
|
||||
String phone, String operatingHours) {
|
||||
this.storeName = storeName;
|
||||
this.address = address;
|
||||
this.description = description;
|
||||
this.phone = phone;
|
||||
this.operatingHours = operatingHours;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 위치 정보 업데이트
|
||||
* 매장 위치 업데이트
|
||||
*/
|
||||
public Store updateLocation(Double latitude, Double longitude) {
|
||||
return Store.builder()
|
||||
.id(this.id)
|
||||
.ownerId(this.ownerId)
|
||||
.storeName(this.storeName)
|
||||
.address(this.address)
|
||||
.latitude(latitude)
|
||||
.longitude(longitude)
|
||||
.category(this.category)
|
||||
.description(this.description)
|
||||
.phone(this.phone)
|
||||
.operatingHours(this.operatingHours)
|
||||
.tags(this.tags)
|
||||
.status(this.status)
|
||||
.rating(this.rating)
|
||||
.reviewCount(this.reviewCount)
|
||||
.imageUrl(this.imageUrl)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
public void updateLocation(Coordinates coordinates) {
|
||||
this.latitude = coordinates.getLatitude();
|
||||
this.longitude = coordinates.getLongitude();
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 평점 및 리뷰 수 업데이트
|
||||
* 매장 평점 업데이트
|
||||
*/
|
||||
public Store updateRating(Double rating, Integer reviewCount) {
|
||||
return Store.builder()
|
||||
.id(this.id)
|
||||
.ownerId(this.ownerId)
|
||||
.storeName(this.storeName)
|
||||
.address(this.address)
|
||||
.latitude(this.latitude)
|
||||
.longitude(this.longitude)
|
||||
.category(this.category)
|
||||
.description(this.description)
|
||||
.phone(this.phone)
|
||||
.operatingHours(this.operatingHours)
|
||||
.tags(this.tags)
|
||||
.status(this.status)
|
||||
.rating(rating)
|
||||
.reviewCount(reviewCount)
|
||||
.imageUrl(this.imageUrl)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
public void updateRating(Double rating, Integer reviewCount) {
|
||||
this.rating = rating;
|
||||
this.reviewCount = reviewCount;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 활성화
|
||||
*/
|
||||
public Store activate() {
|
||||
return Store.builder()
|
||||
.id(this.id)
|
||||
.ownerId(this.ownerId)
|
||||
.storeName(this.storeName)
|
||||
.address(this.address)
|
||||
.latitude(this.latitude)
|
||||
.longitude(this.longitude)
|
||||
.category(this.category)
|
||||
.description(this.description)
|
||||
.phone(this.phone)
|
||||
.operatingHours(this.operatingHours)
|
||||
.tags(this.tags)
|
||||
.status(StoreStatus.ACTIVE)
|
||||
.rating(this.rating)
|
||||
.reviewCount(this.reviewCount)
|
||||
.imageUrl(this.imageUrl)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
public void activate() {
|
||||
this.status = StoreStatus.ACTIVE;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 비활성화
|
||||
*/
|
||||
public Store deactivate() {
|
||||
return Store.builder()
|
||||
.id(this.id)
|
||||
.ownerId(this.ownerId)
|
||||
.storeName(this.storeName)
|
||||
.address(this.address)
|
||||
.latitude(this.latitude)
|
||||
.longitude(this.longitude)
|
||||
.category(this.category)
|
||||
.description(this.description)
|
||||
.phone(this.phone)
|
||||
.operatingHours(this.operatingHours)
|
||||
.tags(this.tags)
|
||||
.status(StoreStatus.INACTIVE)
|
||||
.rating(this.rating)
|
||||
.reviewCount(this.reviewCount)
|
||||
.imageUrl(this.imageUrl)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
public void deactivate() {
|
||||
this.status = StoreStatus.INACTIVE;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 활성 상태 확인
|
||||
* 매장 삭제 (소프트 삭제)
|
||||
*/
|
||||
public void delete() {
|
||||
this.status = StoreStatus.DELETED;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 상태 확인
|
||||
*/
|
||||
public boolean isActive() {
|
||||
return StoreStatus.ACTIVE.equals(this.status);
|
||||
return this.status == StoreStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 소유권 확인
|
||||
* 점주 소유 확인
|
||||
*/
|
||||
public boolean isOwnedBy(Long ownerId) {
|
||||
return this.ownerId != null && this.ownerId.equals(ownerId);
|
||||
return this.ownerId.equals(ownerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 좌표 간의 거리 계산 (킬로미터)
|
||||
* 거리 계산
|
||||
*/
|
||||
public Double calculateDistance(Double targetLatitude, Double targetLongitude) {
|
||||
if (this.latitude == null || this.longitude == null ||
|
||||
@ -188,17 +110,18 @@ public class Store {
|
||||
return null;
|
||||
}
|
||||
|
||||
final int EARTH_RADIUS = 6371; // 지구 반지름 (킬로미터)
|
||||
// Haversine 공식을 사용한 거리 계산
|
||||
double earthRadius = 6371; // 지구 반지름 (km)
|
||||
|
||||
double latDistance = Math.toRadians(targetLatitude - this.latitude);
|
||||
double lonDistance = Math.toRadians(targetLongitude - this.longitude);
|
||||
double dLat = Math.toRadians(targetLatitude - this.latitude);
|
||||
double dLon = Math.toRadians(targetLongitude - this.longitude);
|
||||
|
||||
double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
|
||||
+ Math.cos(Math.toRadians(this.latitude)) * Math.cos(Math.toRadians(targetLatitude))
|
||||
* Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);
|
||||
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(Math.toRadians(this.latitude)) * Math.cos(Math.toRadians(targetLatitude)) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return EARTH_RADIUS * c;
|
||||
return earthRadius * c;
|
||||
}
|
||||
}
|
||||
@ -2,32 +2,12 @@ package com.ktds.hi.store.domain;
|
||||
|
||||
/**
|
||||
* 매장 상태 열거형
|
||||
* 매장의 운영 상태를 정의
|
||||
*
|
||||
* @author 하이오더 개발팀
|
||||
* @version 1.0.0
|
||||
*/
|
||||
public enum StoreStatus {
|
||||
|
||||
/**
|
||||
* 활성 상태 - 정상 운영 중
|
||||
*/
|
||||
ACTIVE("활성"),
|
||||
|
||||
/**
|
||||
* 비활성 상태 - 임시 휴업
|
||||
*/
|
||||
INACTIVE("비활성"),
|
||||
|
||||
/**
|
||||
* 일시 정지 상태 - 관리자에 의한 일시 정지
|
||||
*/
|
||||
SUSPENDED("일시정지"),
|
||||
|
||||
/**
|
||||
* 삭제 상태 - 영구 삭제 (소프트 삭제)
|
||||
*/
|
||||
DELETED("삭제");
|
||||
DELETED("삭제됨"),
|
||||
PENDING("승인대기");
|
||||
|
||||
private final String description;
|
||||
|
||||
@ -38,33 +18,4 @@ public enum StoreStatus {
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열로부터 StoreStatus 변환
|
||||
*/
|
||||
public static StoreStatus fromString(String status) {
|
||||
if (status == null) {
|
||||
return INACTIVE;
|
||||
}
|
||||
|
||||
try {
|
||||
return StoreStatus.valueOf(status.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return INACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장이 서비스 가능한 상태인지 확인
|
||||
*/
|
||||
public boolean isServiceable() {
|
||||
return this == ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장이 삭제된 상태인지 확인
|
||||
*/
|
||||
public boolean isDeleted() {
|
||||
return this == DELETED;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,120 @@
|
||||
package com.ktds.hi.store.infra.controller;
|
||||
|
||||
public class StoreController {
|
||||
}
|
||||
// store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java
|
||||
package com.ktds.hi.store.infra.controller;
|
||||
|
||||
import com.ktds.hi.store.biz.usecase.in.StoreUseCase;
|
||||
import com.ktds.hi.store.infra.dto.*;
|
||||
import com.ktds.hi.common.dto.ApiResponse;
|
||||
import com.ktds.hi.common.security.JwtTokenProvider;
|
||||
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.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 관리 컨트롤러
|
||||
* 매장 등록, 수정, 삭제, 조회 기능 제공
|
||||
*
|
||||
* @author 하이오더 개발팀
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@Tag(name = "매장 관리", description = "매장 등록, 수정, 삭제, 조회 API")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/stores")
|
||||
@RequiredArgsConstructor
|
||||
@Validated
|
||||
public class StoreController {
|
||||
|
||||
private final StoreUseCase storeUseCase;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Operation(summary = "매장 등록", description = "새로운 매장을 등록합니다.")
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('OWNER')")
|
||||
public ResponseEntity<ApiResponse<StoreCreateResponse>> createStore(
|
||||
@Valid @RequestBody StoreCreateRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
|
||||
Long ownerId = jwtTokenProvider.extractOwnerInfo(httpRequest);
|
||||
StoreCreateResponse response = storeUseCase.createStore(ownerId, request);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(response, "매장이 성공적으로 등록되었습니다."));
|
||||
}
|
||||
|
||||
@Operation(summary = "내 매장 목록 조회", description = "점주가 등록한 매장 목록을 조회합니다.")
|
||||
@GetMapping("/my")
|
||||
@PreAuthorize("hasRole('OWNER')")
|
||||
public ResponseEntity<ApiResponse<List<MyStoreListResponse>>> getMyStores(
|
||||
HttpServletRequest httpRequest) {
|
||||
|
||||
Long ownerId = jwtTokenProvider.extractOwnerInfo(httpRequest);
|
||||
List<MyStoreListResponse> responses = storeUseCase.getMyStores(ownerId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(responses, "내 매장 목록 조회 완료"));
|
||||
}
|
||||
|
||||
@Operation(summary = "매장 상세 조회", description = "매장의 상세 정보를 조회합니다.")
|
||||
@GetMapping("/{storeId}")
|
||||
public ResponseEntity<ApiResponse<StoreDetailResponse>> getStoreDetail(
|
||||
@Parameter(description = "매장 ID") @PathVariable Long storeId) {
|
||||
|
||||
StoreDetailResponse response = storeUseCase.getStoreDetail(storeId);
|
||||
return ResponseEntity.ok(ApiResponse.success(response, "매장 상세 정보 조회 완료"));
|
||||
}
|
||||
|
||||
@Operation(summary = "매장 정보 수정", description = "매장 정보를 수정합니다.")
|
||||
@PutMapping("/{storeId}")
|
||||
@PreAuthorize("hasRole('OWNER')")
|
||||
public ResponseEntity<ApiResponse<StoreUpdateResponse>> updateStore(
|
||||
@Parameter(description = "매장 ID") @PathVariable Long storeId,
|
||||
@Valid @RequestBody StoreUpdateRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
|
||||
Long ownerId = jwtTokenProvider.extractOwnerInfo(httpRequest);
|
||||
StoreUpdateResponse response = storeUseCase.updateStore(storeId, ownerId, request);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response, "매장 정보 수정 완료"));
|
||||
}
|
||||
|
||||
@Operation(summary = "매장 삭제", description = "매장을 삭제합니다.")
|
||||
@DeleteMapping("/{storeId}")
|
||||
@PreAuthorize("hasRole('OWNER')")
|
||||
public ResponseEntity<ApiResponse<StoreDeleteResponse>> deleteStore(
|
||||
@Parameter(description = "매장 ID") @PathVariable Long storeId,
|
||||
HttpServletRequest httpRequest) {
|
||||
|
||||
Long ownerId = jwtTokenProvider.extractOwnerInfo(httpRequest);
|
||||
StoreDeleteResponse response = storeUseCase.deleteStore(storeId, ownerId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response, "매장 삭제 완료"));
|
||||
}
|
||||
|
||||
@Operation(summary = "매장 검색", description = "조건에 따라 매장을 검색합니다.")
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<ApiResponse<List<StoreSearchResponse>>> searchStores(
|
||||
@Parameter(description = "검색 키워드") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "카테고리") @RequestParam(required = false) String category,
|
||||
@Parameter(description = "태그") @RequestParam(required = false) String tags,
|
||||
@Parameter(description = "위도") @RequestParam(required = false) Double latitude,
|
||||
@Parameter(description = "경도") @RequestParam(required = false) Double longitude,
|
||||
@Parameter(description = "검색 반경(km)") @RequestParam(defaultValue = "5") Integer radius,
|
||||
@Parameter(description = "페이지 번호") @RequestParam(defaultValue = "0") Integer page,
|
||||
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") Integer size) {
|
||||
|
||||
List<StoreSearchResponse> responses = storeUseCase.searchStores(
|
||||
keyword, category, tags, latitude, longitude, radius, page, size);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(responses, "매장 검색 완료"));
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,54 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
public class MenuCreateRequest {
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import com.ktds.hi.store.biz.domain.Menu;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 메뉴 등록 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "메뉴 등록 요청")
|
||||
public class MenuCreateRequest {
|
||||
|
||||
@NotBlank(message = "메뉴명은 필수입니다.")
|
||||
@Schema(description = "메뉴명", example = "김치찌개")
|
||||
private String menuName;
|
||||
|
||||
@Schema(description = "메뉴 설명", example = "얼큰한 김치찌개")
|
||||
private String description;
|
||||
|
||||
@Min(value = 0, message = "가격은 0원 이상이어야 합니다.")
|
||||
@Schema(description = "가격", example = "8000")
|
||||
private Integer price;
|
||||
|
||||
@Schema(description = "메뉴 카테고리", example = "메인")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "이미지 URL", example = "https://example.com/kimchi.jpg")
|
||||
private String imageUrl;
|
||||
|
||||
@Schema(description = "이용 가능 여부", example = "true")
|
||||
private Boolean available = true;
|
||||
|
||||
/**
|
||||
* 도메인 객체로 변환
|
||||
*/
|
||||
public Menu toDomain(Long storeId) {
|
||||
return Menu.builder()
|
||||
.storeId(storeId)
|
||||
.menuName(this.menuName)
|
||||
.description(this.description)
|
||||
.price(this.price)
|
||||
.category(this.category)
|
||||
.imageUrl(this.imageUrl)
|
||||
.available(this.available != null ? this.available : true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,55 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
public class MenuResponse {
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import com.ktds.hi.store.biz.domain.Menu;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 메뉴 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "메뉴 응답")
|
||||
public class MenuResponse {
|
||||
|
||||
@Schema(description = "메뉴 ID", example = "1")
|
||||
private Long menuId;
|
||||
|
||||
@Schema(description = "메뉴명", example = "김치찌개")
|
||||
private String menuName;
|
||||
|
||||
@Schema(description = "메뉴 설명", example = "얼큰한 김치찌개")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "가격", example = "8000")
|
||||
private Integer price;
|
||||
|
||||
@Schema(description = "메뉴 카테고리", example = "메인")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "이미지 URL")
|
||||
private String imageUrl;
|
||||
|
||||
@Schema(description = "이용 가능 여부", example = "true")
|
||||
private Boolean available;
|
||||
|
||||
/**
|
||||
* 도메인 객체로부터 생성
|
||||
*/
|
||||
public static MenuResponse from(Menu menu) {
|
||||
return MenuResponse.builder()
|
||||
.menuId(menu.getId())
|
||||
.menuName(menu.getMenuName())
|
||||
.description(menu.getDescription())
|
||||
.price(menu.getPrice())
|
||||
.category(menu.getCategory())
|
||||
.imageUrl(menu.getImageUrl())
|
||||
.available(menu.getAvailable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,42 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
public class MyStoreListResponse {
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 내 매장 목록 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "내 매장 목록 응답")
|
||||
public class MyStoreListResponse {
|
||||
|
||||
@Schema(description = "매장 ID", example = "1")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
|
||||
private String storeName;
|
||||
|
||||
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "카테고리", example = "한식")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "평점", example = "4.5")
|
||||
private Double rating;
|
||||
|
||||
@Schema(description = "리뷰 수", example = "127")
|
||||
private Integer reviewCount;
|
||||
|
||||
@Schema(description = "운영 상태", example = "운영중")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "운영시간", example = "월-금 09:00-21:00")
|
||||
private String operatingHours;
|
||||
}
|
||||
@ -1,4 +1,48 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
public class StoreCreateRequest {
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 등록 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 등록 요청")
|
||||
public class StoreCreateRequest {
|
||||
|
||||
@NotBlank(message = "매장명은 필수입니다.")
|
||||
@Size(max = 100, message = "매장명은 100자를 초과할 수 없습니다.")
|
||||
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
|
||||
private String storeName;
|
||||
|
||||
@NotBlank(message = "주소는 필수입니다.")
|
||||
@Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "매장 설명", example = "맛있는 한식당입니다.")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "전화번호", example = "02-1234-5678")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "운영시간", example = "월-금 09:00-21:00, 토-일 10:00-20:00")
|
||||
private String operatingHours;
|
||||
|
||||
@NotBlank(message = "카테고리는 필수입니다.")
|
||||
@Schema(description = "카테고리", example = "한식")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "매장 태그 목록", example = "[\"맛집\", \"혼밥\", \"가성비\"]")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "메뉴 목록")
|
||||
private List<MenuCreateRequest> menus;
|
||||
}
|
||||
|
||||
@ -1,4 +1,27 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
public class StoreCreateResponse {
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 매장 등록 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 등록 응답")
|
||||
public class StoreCreateResponse {
|
||||
|
||||
@Schema(description = "생성된 매장 ID", example = "1")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
|
||||
private String storeName;
|
||||
|
||||
@Schema(description = "응답 메시지", example = "매장이 성공적으로 등록되었습니다.")
|
||||
private String message;
|
||||
}
|
||||
@ -1,19 +1,24 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 매장 삭제 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StoreDeleteResponse {
|
||||
|
||||
private Boolean success;
|
||||
private String message;
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 매장 삭제 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 삭제 응답")
|
||||
public class StoreDeleteResponse {
|
||||
|
||||
@Schema(description = "삭제된 매장 ID", example = "1")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "응답 메시지", example = "매장이 성공적으로 삭제되었습니다.")
|
||||
private String message;
|
||||
}
|
||||
@ -1,4 +1,65 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
public class StoreDetailResponse {
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 상세 조회 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 상세 조회 응답")
|
||||
public class StoreDetailResponse {
|
||||
|
||||
@Schema(description = "매장 ID", example = "1")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
|
||||
private String storeName;
|
||||
|
||||
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "위도", example = "37.5665")
|
||||
private Double latitude;
|
||||
|
||||
@Schema(description = "경도", example = "126.9780")
|
||||
private Double longitude;
|
||||
|
||||
@Schema(description = "매장 설명", example = "맛있는 한식당입니다.")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "전화번호", example = "02-1234-5678")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "운영시간", example = "월-금 09:00-21:00")
|
||||
private String operatingHours;
|
||||
|
||||
@Schema(description = "카테고리", example = "한식")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "평점", example = "4.5")
|
||||
private Double rating;
|
||||
|
||||
@Schema(description = "리뷰 수", example = "127")
|
||||
private Integer reviewCount;
|
||||
|
||||
@Schema(description = "매장 상태", example = "ACTIVE")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "매장 태그 목록")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "메뉴 목록")
|
||||
private List<MenuResponse> menus;
|
||||
|
||||
@Schema(description = "AI 요약 정보")
|
||||
private String aiSummary;
|
||||
}
|
||||
@ -1,4 +1,39 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
public class StoreSearchResponse {
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 매장 검색 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 검색 응답")
|
||||
public class StoreSearchResponse {
|
||||
|
||||
@Schema(description = "매장 ID", example = "1")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
|
||||
private String storeName;
|
||||
|
||||
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "카테고리", example = "한식")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "평점", example = "4.5")
|
||||
private Double rating;
|
||||
|
||||
@Schema(description = "리뷰 수", example = "127")
|
||||
private Integer reviewCount;
|
||||
|
||||
@Schema(description = "거리(km)", example = "1.2")
|
||||
private Double distance;
|
||||
}
|
||||
@ -1,4 +1,41 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
public class StoreUpdateRequest {
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 수정 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 수정 요청")
|
||||
public class StoreUpdateRequest {
|
||||
|
||||
@NotBlank(message = "매장명은 필수입니다.")
|
||||
@Size(max = 100, message = "매장명은 100자를 초과할 수 없습니다.")
|
||||
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
|
||||
private String storeName;
|
||||
|
||||
@NotBlank(message = "주소는 필수입니다.")
|
||||
@Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "매장 설명", example = "맛있는 한식당입니다.")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "전화번호", example = "02-1234-5678")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "운영시간", example = "월-금 09:00-21:00, 토-일 10:00-20:00")
|
||||
private String operatingHours;
|
||||
|
||||
@Schema(description = "매장 태그 목록", example = "[\"맛집\", \"혼밥\", \"가성비\"]")
|
||||
private List<String> tags;
|
||||
}
|
||||
|
||||
@ -1,19 +1,24 @@
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 매장 수정 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StoreUpdateResponse {
|
||||
|
||||
private Boolean success;
|
||||
private String message;
|
||||
}
|
||||
package com.ktds.hi.store.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 매장 수정 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 수정 응답")
|
||||
public class StoreUpdateResponse {
|
||||
|
||||
@Schema(description = "매장 ID", example = "1")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "응답 메시지", example = "매장 정보가 성공적으로 수정되었습니다.")
|
||||
private String message;
|
||||
}
|
||||
@ -1,48 +1,48 @@
|
||||
server:
|
||||
port: ${STORE_SERVICE_PORT:8082}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: store-service
|
||||
|
||||
datasource:
|
||||
url: ${STORE_DB_URL:jdbc:postgresql://20.249.154.116:5432/hiorder_store}
|
||||
username: ${STORE_DB_USERNAME:hiorder_user}
|
||||
password: ${STORE_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
external-api:
|
||||
naver:
|
||||
client-id: ${NAVER_CLIENT_ID:}
|
||||
client-secret: ${NAVER_CLIENT_SECRET:}
|
||||
base-url: https://openapi.naver.com
|
||||
kakao:
|
||||
api-key: ${KAKAO_API_KEY:}
|
||||
base-url: https://dapi.kakao.com
|
||||
google:
|
||||
api-key: ${GOOGLE_API_KEY:}
|
||||
base-url: https://maps.googleapis.com
|
||||
hiorder:
|
||||
api-key: ${HIORDER_API_KEY:}
|
||||
base-url: ${HIORDER_BASE_URL:https://api.hiorder.com}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
server:
|
||||
port: ${STORE_SERVICE_PORT:8082}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: store-service
|
||||
|
||||
datasource:
|
||||
url: ${STORE_DB_URL:jdbc:postgresql://20.249.154.116:5432/hiorder_store}
|
||||
username: ${STORE_DB_USERNAME:hiorder_user}
|
||||
password: ${STORE_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
external-api:
|
||||
naver:
|
||||
client-id: ${NAVER_CLIENT_ID:}
|
||||
client-secret: ${NAVER_CLIENT_SECRET:}
|
||||
base-url: https://openapi.naver.com
|
||||
kakao:
|
||||
api-key: ${KAKAO_API_KEY:}
|
||||
base-url: https://dapi.kakao.com
|
||||
google:
|
||||
api-key: ${GOOGLE_API_KEY:}
|
||||
base-url: https://maps.googleapis.com
|
||||
hiorder:
|
||||
api-key: ${HIORDER_API_KEY:}
|
||||
base-url: ${HIORDER_BASE_URL:https://api.hiorder.com}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user