# Conflicts:
#	analytics/src/main/java/com/ktds/hi/analytics/infra/config/SwaggerConfig.java
#	member/src/main/java/com/ktds/hi/member/config/SwaggerConfig.java
#	recommend/src/main/java/com/ktds/hi/recommend/infra/config/SwaggerConfig.java
This commit is contained in:
UNGGU0704 2025-06-13 17:37:28 +09:00
commit 55c5845772
41 changed files with 2821 additions and 2148 deletions

View File

@ -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

View File

@ -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% 가정
}
}

View File

@ -1,50 +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();
}
}

View File

@ -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}

View File

@ -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://localhost:3000,http://localhost:8080,http://localhost:3001}")
private String allowedOrigins;
@Value("${app.cors.allowed-methods:GET,POST,PUT,DELETE,PATCH,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());
}
}

View File

@ -1,5 +1,7 @@
package com.ktds.hi.common.security;
import jakarta.servlet.http.HttpServletRequest;
import com.ktds.hi.common.exception.BusinessException;
import com.ktds.hi.common.constants.SecurityConstants;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
@ -55,6 +57,82 @@ public class JwtTokenProvider {
.verifyWith(secretKey)
.build();
}
/**
* HTTP 요청에서 점주 정보 추출
*/
public Long extractOwnerInfo(HttpServletRequest request) {
try {
// Authorization 헤더에서 토큰 추출
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new BusinessException("UNAUTHORIZED", "인증 토큰이 필요합니다.");
}
String token = authHeader.substring(7); // "Bearer " 제거
// 토큰 유효성 검증
if (!validateToken(token)) {
throw new BusinessException("UNAUTHORIZED", "유효하지 않은 토큰입니다.");
}
// 토큰에서 사용자 ID 추출
String userId = getUserIdFromToken(token);
if (userId == null) {
throw new BusinessException("UNAUTHORIZED", "토큰에서 사용자 정보를 찾을 수 없습니다.");
}
// 토큰에서 권한 정보 추출
String roles = getRolesFromToken(token);
if (roles == null || !roles.contains("OWNER")) {
throw new BusinessException("FORBIDDEN", "점주 권한이 필요합니다.");
}
log.debug("점주 정보 추출 완료: ownerId={}", userId);
return Long.parseLong(userId);
} catch (NumberFormatException e) {
log.error("사용자 ID 형변환 실패: {}", e.getMessage());
throw new BusinessException("UNAUTHORIZED", "잘못된 사용자 ID 형식입니다.");
} catch (BusinessException e) {
throw e; // 비즈니스 예외는 그대로 전파
} catch (Exception e) {
log.error("점주 정보 추출 중 오류 발생: {}", e.getMessage(), e);
throw new BusinessException("UNAUTHORIZED", "인증 처리 중 오류가 발생했습니다.");
}
}
/**
* HTTP 요청에서 사용자 정보 추출 (일반 사용자용)
*/
public Long extractUserInfo(HttpServletRequest request) {
try {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new BusinessException("UNAUTHORIZED", "인증 토큰이 필요합니다.");
}
String token = authHeader.substring(7);
if (!validateToken(token)) {
throw new BusinessException("UNAUTHORIZED", "유효하지 않은 토큰입니다.");
}
String userId = getUserIdFromToken(token);
if (userId == null) {
throw new BusinessException("UNAUTHORIZED", "토큰에서 사용자 정보를 찾을 수 없습니다.");
}
return Long.parseLong(userId);
} catch (NumberFormatException e) {
throw new BusinessException("UNAUTHORIZED", "잘못된 사용자 ID 형식입니다.");
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("사용자 정보 추출 중 오류 발생: {}", e.getMessage(), e);
throw new BusinessException("UNAUTHORIZED", "인증 처리 중 오류가 발생했습니다.");
}
}
/**
* 액세스 토큰 생성

View File

@ -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:80,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

View File

@ -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();
// }
}

View File

@ -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

View File

@ -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();
}
}

View File

@ -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

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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")));
}
}

View File

@ -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

View File

@ -0,0 +1,177 @@
// 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.infra.dto.*;
import com.ktds.hi.store.infra.gateway.entity.StoreEntity;
import com.ktds.hi.store.infra.gateway.repository.StoreJpaRepository;
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;
/**
* 매장 서비스 구현체 (간단 버전)
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StoreService implements StoreUseCase {
private final StoreJpaRepository storeJpaRepository;
@Override
@Transactional
public StoreCreateResponse createStore(Long ownerId, StoreCreateRequest request) {
log.info("매장 등록: ownerId={}, storeName={}", ownerId, request.getStoreName());
// 기본 검증
if (request.getStoreName() == null || request.getStoreName().trim().isEmpty()) {
throw new BusinessException("INVALID_STORE_NAME", "매장명은 필수입니다.");
}
if (request.getAddress() == null || request.getAddress().trim().isEmpty()) {
throw new BusinessException("INVALID_ADDRESS", "주소는 필수입니다.");
}
// 매장 엔티티 생성
StoreEntity store = StoreEntity.builder()
.ownerId(ownerId)
.storeName(request.getStoreName())
.address(request.getAddress())
.latitude(37.5665) // 기본 좌표 (서울시청)
.longitude(126.9780)
.description(request.getDescription())
.phone(request.getPhone())
.operatingHours(request.getOperatingHours())
.category(request.getCategory())
.status("ACTIVE")
.rating(0.0)
.reviewCount(0)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
StoreEntity savedStore = storeJpaRepository.save(store);
log.info("매장 등록 완료: storeId={}", savedStore.getId());
return StoreCreateResponse.builder()
.storeId(savedStore.getId())
.storeName(savedStore.getStoreName())
.message("매장이 성공적으로 등록되었습니다.")
.build();
}
@Override
public List<MyStoreListResponse> getMyStores(Long ownerId) {
log.info("내 매장 목록 조회: ownerId={}", ownerId);
List<StoreEntity> stores = storeJpaRepository.findByOwnerId(ownerId);
return stores.stream()
.map(store -> MyStoreListResponse.builder()
.storeId(store.getId())
.storeName(store.getStoreName())
.address(store.getAddress())
.category(store.getCategory())
.rating(store.getRating())
.reviewCount(store.getReviewCount())
.status("운영중")
.operatingHours(store.getOperatingHours())
.build())
.collect(Collectors.toList());
}
@Override
public StoreDetailResponse getStoreDetail(Long storeId) {
log.info("매장 상세 조회: storeId={}", storeId);
StoreEntity store = storeJpaRepository.findById(storeId)
.orElseThrow(() -> new BusinessException("STORE_NOT_FOUND", "매장을 찾을 수 없습니다."));
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())
.build();
}
@Override
@Transactional
public StoreUpdateResponse updateStore(Long storeId, Long ownerId, StoreUpdateRequest request) {
log.info("매장 수정: storeId={}, ownerId={}", storeId, ownerId);
StoreEntity store = storeJpaRepository.findByIdAndOwnerId(storeId, ownerId)
.orElseThrow(() -> new BusinessException("STORE_ACCESS_DENIED", "매장에 대한 권한이 없습니다."));
store.updateInfo(request.getStoreName(), request.getAddress(), request.getDescription(),
request.getPhone(), request.getOperatingHours());
storeJpaRepository.save(store);
return StoreUpdateResponse.builder()
.storeId(storeId)
.message("매장 정보가 수정되었습니다.")
.build();
}
@Override
@Transactional
public StoreDeleteResponse deleteStore(Long storeId, Long ownerId) {
log.info("매장 삭제: storeId={}, ownerId={}", storeId, ownerId);
StoreEntity store = storeJpaRepository.findByIdAndOwnerId(storeId, ownerId)
.orElseThrow(() -> new BusinessException("STORE_ACCESS_DENIED", "매장에 대한 권한이 없습니다."));
store.updateStatus("DELETED");
storeJpaRepository.save(store);
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={}", keyword, category);
List<StoreEntity> stores;
if (keyword != null && !keyword.trim().isEmpty()) {
stores = storeJpaRepository.findByStoreNameContainingOrAddressContaining(keyword, keyword);
} else if (category != null && !category.trim().isEmpty()) {
stores = storeJpaRepository.findByCategory(category);
} else {
stores = storeJpaRepository.findAll();
}
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(1.5) // 더미 거리
.build())
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,76 @@
package com.ktds.hi.store.biz.usecase.in;
import com.ktds.hi.store.infra.dto.*;
import java.util.List;
/**
* 매장 관리 유스케이스 인터페이스
* Clean Architecture의 Input Port
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
public interface StoreUseCase {
/**
* 매장 등록
*
* @param ownerId 점주 ID
* @param request 매장 등록 요청 정보
* @return 매장 등록 응답
*/
StoreCreateResponse createStore(Long ownerId, StoreCreateRequest request);
/**
* 매장 목록 조회
*
* @param ownerId 점주 ID
* @return 매장 목록
*/
List<MyStoreListResponse> getMyStores(Long ownerId);
/**
* 매장 상세 조회
*
* @param storeId 매장 ID
* @return 매장 상세 정보
*/
StoreDetailResponse getStoreDetail(Long storeId);
/**
* 매장 정보 수정
*
* @param storeId 매장 ID
* @param ownerId 점주 ID
* @param request 매장 수정 요청 정보
* @return 매장 수정 응답
*/
StoreUpdateResponse updateStore(Long storeId, Long ownerId, StoreUpdateRequest request);
/**
* 매장 삭제
*
* @param storeId 매장 ID
* @param ownerId 점주 ID
* @return 매장 삭제 응답
*/
StoreDeleteResponse deleteStore(Long storeId, Long ownerId);
/**
* 매장 검색
*
* @param keyword 검색 키워드
* @param category 카테고리
* @param tags 태그
* @param latitude 위도
* @param longitude 경도
* @param radius 검색 반경(km)
* @param page 페이지 번호
* @param size 페이지 크기
* @return 검색된 매장 목록
*/
List<StoreSearchResponse> searchStores(String keyword, String category, String tags,
Double latitude, Double longitude, Integer radius,
Integer page, Integer size);
}

View File

@ -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);
}

View File

@ -0,0 +1,16 @@
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;
}

View File

@ -0,0 +1,17 @@
package com.ktds.hi.store.biz.usecase.out;
/**
* 지오코딩 포트 인터페이스
*/
public interface GeocodingPort {
/**
* 주소를 좌표로 변환
*/
Coordinates getCoordinates(String address);
/**
* 좌표 거리 계산 (km)
*/
Double calculateDistance(Coordinates coord1, Coordinates coord2);
}

View File

@ -1,6 +1,6 @@
package com.ktds.hi.store.biz.usecase.out;
import com.ktds.hi.store.biz.domain.Menu;
import com.ktds.hi.store.domain.Menu;
import java.util.List;
import java.util.Optional;

View File

@ -0,0 +1,24 @@
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);
}

View File

@ -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();
}
}

View File

@ -1,23 +1,14 @@
package com.ktds.hi.store.biz.domain;
package com.ktds.hi.store.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 +18,33 @@ 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;
private LocalDateTime createdAt; // 추가
private LocalDateTime updatedAt; // 추가
/**
* 메뉴 기본 정보 업데이트
* 메뉴 정보 업데이트
*/
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;
}
}

View File

@ -1,24 +1,20 @@
// 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 +23,89 @@ 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 List<String> tags; // 추가
private String imageUrl; // 추가
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 +113,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;
}
}

View File

@ -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;
@ -44,27 +24,13 @@ public enum StoreStatus {
*/
public static StoreStatus fromString(String status) {
if (status == null) {
return INACTIVE;
return ACTIVE; // 기본값
}
try {
return StoreStatus.valueOf(status.toUpperCase());
} catch (IllegalArgumentException e) {
return INACTIVE;
return ACTIVE; // 기본값
}
}
/**
* 매장이 서비스 가능한 상태인지 확인
*/
public boolean isServiceable() {
return this == ACTIVE;
}
/**
* 매장이 삭제된 상태인지 확인
*/
public boolean isDeleted() {
return this == DELETED;
}
}

View File

@ -0,0 +1,120 @@
// 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, "매장 검색 완료"));
}
}

View File

@ -0,0 +1,54 @@
package com.ktds.hi.store.infra.dto;
import com.ktds.hi.store.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();
}
}

View File

@ -0,0 +1,55 @@
package com.ktds.hi.store.infra.dto;
import com.ktds.hi.store.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();
}
}

View File

@ -0,0 +1,42 @@
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;
}

View File

@ -0,0 +1,48 @@
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;
}

View File

@ -0,0 +1,27 @@
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;
}

View File

@ -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;
}

View File

@ -0,0 +1,65 @@
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;
}

View File

@ -0,0 +1,39 @@
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;
}

View File

@ -0,0 +1,41 @@
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;
}

View File

@ -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;
}

View File

@ -9,6 +9,8 @@ import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 캐시 어댑터 클래스
@ -32,29 +34,45 @@ public class CacheAdapter implements CachePort {
return Optional.empty();
}
}
@Override
public void putStoreCache(String key, Object value, Duration ttl) {
public void putStoreCache(String key, Object value, long ttlSeconds) {
try {
redisTemplate.opsForValue().set(key, value, ttl);
log.debug("매장 캐시 저장 완료: key={}, ttl={}분", key, ttl.toMinutes());
redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
log.debug("캐시 저장: key={}, ttl={}초", key, ttlSeconds);
} catch (Exception e) {
log.error("매장 캐시 저장 실패: key={}, error={}", key, e.getMessage());
log.warn("캐시 저장 실패: key={}, error={}", key, e.getMessage());
}
}
@Override
public void invalidateStoreCache(Long storeId) {
public void invalidateStoreCache(Object key) {
try {
// 매장 관련 모든 캐시 패턴 삭제
String storeDetailKey = "store_detail:" + storeId;
String myStoresKey = "my_stores:*";
redisTemplate.delete(storeDetailKey);
log.debug("매장 캐시 무효화 완료: storeId={}", storeId);
if (key instanceof Long) {
// 매장 ID로 특정 매장 캐시 삭제
Long storeId = (Long) key;
String storeDetailKey = "store_detail:" + storeId;
redisTemplate.delete(storeDetailKey);
log.debug("매장 캐시 무효화 완료: storeId={}", storeId);
} else if (key instanceof String) {
// 패턴으로 캐시 삭제
String pattern = key.toString();
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
log.debug("패턴 캐시 무효화 완료: pattern={}", pattern);
} else {
// 기본적으로 toString()으로 생성
String cacheKey = "stores:" + key.toString();
redisTemplate.delete(cacheKey);
log.debug("캐시 무효화 완료: key={}", key);
}
} catch (Exception e) {
log.error("매장 캐시 무효화 실패: storeId={}, error={}", storeId, e.getMessage());
log.error("캐시 무효화 실패: key={}, error={}", key, e.getMessage());
}
}
}

View File

@ -1,6 +1,6 @@
package com.ktds.hi.store.infra.gateway;
import com.ktds.hi.store.biz.domain.Menu;
import com.ktds.hi.store.domain.Menu;
import com.ktds.hi.store.biz.usecase.out.MenuRepositoryPort;
import com.ktds.hi.store.infra.gateway.entity.MenuEntity;
import com.ktds.hi.store.infra.gateway.repository.MenuJpaRepository;
@ -96,7 +96,7 @@ public class MenuRepositoryAdapter implements MenuRepositoryPort {
.price(entity.getPrice())
.category(entity.getCategory())
.imageUrl(entity.getImageUrl())
.isAvailable(entity.getIsAvailable())
.available(entity.getIsAvailable())
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();
@ -114,7 +114,7 @@ public class MenuRepositoryAdapter implements MenuRepositoryPort {
.price(domain.getPrice())
.category(domain.getCategory())
.imageUrl(domain.getImageUrl())
.isAvailable(domain.getIsAvailable())
.isAvailable(domain.isAvailable())
.createdAt(domain.getCreatedAt())
.updatedAt(domain.getUpdatedAt())
.build();

View File

@ -119,6 +119,19 @@ public class StoreEntity {
this.reviewCount = reviewCount;
}
/**
* 매장 기본 정보 업데이트
*/
public void updateInfo(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();
}
/**
* 매장 태그 업데이트
*/

View File

@ -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