# 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 name: Member CI
on: on:
push: push:
branches: [ main, develop ] branches: [ main, develop ]
paths: paths:
- 'member/**' - 'member/**'
- 'common/**' - 'common/**'
- 'build.gradle' - 'build.gradle'
- 'settings.gradle' - 'settings.gradle'
pull_request: pull_request:
branches: [ main ] branches: [ main ]
paths: paths:
- 'member/**' - 'member/**'
- 'common/**' - 'common/**'
- 'build.gradle' - 'build.gradle'
- 'settings.gradle' - 'settings.gradle'
workflow_dispatch: workflow_dispatch:
env: env:
ACR_NAME: acrdigitalgarage03 ACR_NAME: acrdigitalgarage03
IMAGE_NAME: hiorder/member IMAGE_NAME: hiorder/member
MANIFEST_REPO: dg04-hi/hi-manifest MANIFEST_REPO: dg04-hi/hi-manifest
MANIFEST_FILE_PATH: member/deployment.yml MANIFEST_FILE_PATH: member/deployment.yml
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v3
with: with:
gradle-version: '8.13' gradle-version: '8.13'
- name: Cache Gradle packages - name: Cache Gradle packages
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
~/.gradle/wrapper ~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Generate Gradle Wrapper - name: Generate Gradle Wrapper
run: | run: |
echo "Generating gradle wrapper..." echo "Generating gradle wrapper..."
gradle wrapper --gradle-version 8.13 gradle wrapper --gradle-version 8.13
chmod +x gradlew chmod +x gradlew
echo "Testing gradle wrapper..." echo "Testing gradle wrapper..."
./gradlew --version ./gradlew --version
- name: Build analytics module with dependencies - name: Build analytics module with dependencies
run: ./gradlew member:build -x test run: ./gradlew member:build -x test
- name: Run analytics tests - name: Run analytics tests
run: ./gradlew member:test run: ./gradlew member:test
- name: Generate build timestamp - name: Generate build timestamp
id: timestamp id: timestamp
run: echo "BUILD_TIME=$(date +'%y%m%d%H%M')" >> $GITHUB_OUTPUT run: echo "BUILD_TIME=$(date +'%y%m%d%H%M')" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Azure Container Registry - name: Log in to Azure Container Registry
uses: azure/docker-login@v1 uses: azure/docker-login@v1
with: with:
login-server: ${{ env.ACR_NAME }}.azurecr.io login-server: ${{ env.ACR_NAME }}.azurecr.io
username: ${{ secrets.ACR_USERNAME }} username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }} password: ${{ secrets.ACR_PASSWORD }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./member/Dockerfile file: ./member/Dockerfile
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: | tags: |
${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.timestamp.outputs.BUILD_TIME }} ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.timestamp.outputs.BUILD_TIME }}
${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:latest ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:latest
- name: Output image tags - name: Output image tags
run: | run: |
echo "🎉 Image pushed successfully!" echo "🎉 Image pushed successfully!"
echo "📦 Image: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}" echo "📦 Image: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}"
echo "🏷️ Tags: ${{ steps.timestamp.outputs.BUILD_TIME }}, latest" echo "🏷️ Tags: ${{ steps.timestamp.outputs.BUILD_TIME }}, latest"
# 🚀 Manifest 레포지토리 업데이트 단계 추가 # 🚀 Manifest 레포지토리 업데이트 단계 추가
- name: Checkout manifest repository - name: Checkout manifest repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
repository: ${{ env.MANIFEST_REPO }} repository: ${{ env.MANIFEST_REPO }}
token: ${{ secrets.MANIFEST_REPO_TOKEN }} token: ${{ secrets.MANIFEST_REPO_TOKEN }}
path: manifest-repo path: manifest-repo
- name: Install yq - name: Install yq
run: | run: |
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 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 sudo chmod +x /usr/local/bin/yq
- name: Update deployment image tag - name: Update deployment image tag
run: | run: |
cd manifest-repo cd manifest-repo
NEW_IMAGE="${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.timestamp.outputs.BUILD_TIME }}" NEW_IMAGE="${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.timestamp.outputs.BUILD_TIME }}"
echo "Updating image tag to: $NEW_IMAGE" echo "Updating image tag to: $NEW_IMAGE"
# deployment.yml에서 이미지 태그 업데이트 # deployment.yml에서 이미지 태그 업데이트
yq eval '.spec.template.spec.containers[0].image = "'$NEW_IMAGE'"' -i ${{ env.MANIFEST_FILE_PATH }} yq eval '.spec.template.spec.containers[0].image = "'$NEW_IMAGE'"' -i ${{ env.MANIFEST_FILE_PATH }}
# 변경사항 확인 # 변경사항 확인
echo "Updated deployment.yml:" echo "Updated deployment.yml:"
cat ${{ env.MANIFEST_FILE_PATH }} cat ${{ env.MANIFEST_FILE_PATH }}
- name: Commit and push changes - name: Commit and push changes
run: | run: |
cd manifest-repo cd manifest-repo
git config --local user.email "action@github.com" git config --local user.email "action@github.com"
git config --local user.name "GitHub Action" git config --local user.name "GitHub Action"
git add ${{ env.MANIFEST_FILE_PATH }} git add ${{ env.MANIFEST_FILE_PATH }}
if git diff --staged --quiet; then if git diff --staged --quiet; then
echo "No changes to commit" echo "No changes to commit"
else else
git commit -m "🚀 Update analytics image tag to ${{ steps.timestamp.outputs.BUILD_TIME }} git commit -m "🚀 Update analytics image tag to ${{ steps.timestamp.outputs.BUILD_TIME }}
- Updated by: ${{ github.actor }} - Updated by: ${{ github.actor }}
- Triggered by: ${{ github.event_name }} - Triggered by: ${{ github.event_name }}
- Source commit: ${{ github.sha }} - Source commit: ${{ github.sha }}
- Build time: ${{ steps.timestamp.outputs.BUILD_TIME }}" - Build time: ${{ steps.timestamp.outputs.BUILD_TIME }}"
git push git push
echo "✅ Successfully updated manifest repository" echo "✅ Successfully updated manifest repository"
fi fi
- name: Upload test results - name: Upload test results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: member-test-results name: member-test-results
path: member/build/reports/tests/test/ path: member/build/reports/tests/test/
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: success() if: success()
with: with:
name: member-jar name: member-jar
path: member/build/libs/*.jar path: member/build/libs/*.jar
# 🎯 배포 완료 알림 # 🎯 배포 완료 알림
- name: Deployment summary - name: Deployment summary
if: success() if: success()
run: | run: |
echo "## 🚀 Analytics Service Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "## 🚀 Analytics Service Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Container Image" >> $GITHUB_STEP_SUMMARY echo "### 📦 Container Image" >> $GITHUB_STEP_SUMMARY
echo "- **Registry**: ${{ env.ACR_NAME }}.azurecr.io" >> $GITHUB_STEP_SUMMARY echo "- **Registry**: ${{ env.ACR_NAME }}.azurecr.io" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: ${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY echo "- **Image**: ${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag**: ${{ steps.timestamp.outputs.BUILD_TIME }}" >> $GITHUB_STEP_SUMMARY echo "- **Tag**: ${{ steps.timestamp.outputs.BUILD_TIME }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🔄 ArgoCD Sync" >> $GITHUB_STEP_SUMMARY echo "### 🔄 ArgoCD Sync" >> $GITHUB_STEP_SUMMARY
echo "- **Manifest Repo**: https://github.com/${{ env.MANIFEST_REPO }}" >> $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 "- **Updated File**: ${{ env.MANIFEST_FILE_PATH }}" >> $GITHUB_STEP_SUMMARY
echo "- **ArgoCD will automatically sync the new image**" >> $GITHUB_STEP_SUMMARY echo "- **ArgoCD will automatically sync the new image**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "### ⏱️ Build Info" >> $GITHUB_STEP_SUMMARY echo "### ⏱️ Build Info" >> $GITHUB_STEP_SUMMARY
echo "- **Build Time**: $(date)" >> $GITHUB_STEP_SUMMARY echo "- **Build Time**: $(date)" >> $GITHUB_STEP_SUMMARY
echo "- **Triggered By**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY echo "- **Triggered By**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
echo "- **Event**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY echo "- **Event**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY

View File

@ -1,402 +1,402 @@
package com.ktds.hi.analytics.biz.service; package com.ktds.hi.analytics.biz.service;
import com.ktds.hi.analytics.biz.domain.Analytics; import com.ktds.hi.analytics.biz.domain.Analytics;
import com.ktds.hi.analytics.biz.domain.AiFeedback; import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase; import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase;
import com.ktds.hi.analytics.biz.usecase.out.*; import com.ktds.hi.analytics.biz.usecase.out.*;
import com.ktds.hi.analytics.infra.dto.*; import com.ktds.hi.analytics.infra.dto.*;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* 분석 서비스 구현 클래스 (수정버전) * 분석 서비스 구현 클래스 (수정버전)
* Clean Architecture의 UseCase를 구현하여 비즈니스 로직을 처리 * Clean Architecture의 UseCase를 구현하여 비즈니스 로직을 처리
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AnalyticsService implements AnalyticsUseCase { public class AnalyticsService implements AnalyticsUseCase {
private final AnalyticsPort analyticsPort; private final AnalyticsPort analyticsPort;
private final AIServicePort aiServicePort; private final AIServicePort aiServicePort;
private final ExternalReviewPort externalReviewPort; private final ExternalReviewPort externalReviewPort;
private final OrderDataPort orderDataPort; private final OrderDataPort orderDataPort;
private final CachePort cachePort; private final CachePort cachePort;
private final EventPort eventPort; private final EventPort eventPort;
@Override @Override
@Cacheable(value = "storeAnalytics", key = "#storeId") @Cacheable(value = "storeAnalytics", key = "#storeId")
public StoreAnalyticsResponse getStoreAnalytics(Long storeId) { public StoreAnalyticsResponse getStoreAnalytics(Long storeId) {
log.info("매장 분석 데이터 조회 시작: storeId={}", storeId); log.info("매장 분석 데이터 조회 시작: storeId={}", storeId);
try { try {
// 1. 캐시에서 먼저 확인 // 1. 캐시에서 먼저 확인
String cacheKey = "analytics:store:" + storeId; String cacheKey = "analytics:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey); var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) { if (cachedResult.isPresent()) {
log.info("캐시에서 분석 데이터 반환: storeId={}", storeId); log.info("캐시에서 분석 데이터 반환: storeId={}", storeId);
return (StoreAnalyticsResponse) cachedResult.get(); return (StoreAnalyticsResponse) cachedResult.get();
} }
// 2. 데이터베이스에서 기존 분석 데이터 조회 // 2. 데이터베이스에서 기존 분석 데이터 조회
var analytics = analyticsPort.findAnalyticsByStoreId(storeId); var analytics = analyticsPort.findAnalyticsByStoreId(storeId);
if (analytics.isEmpty()) { if (analytics.isEmpty()) {
// 3. 분석 데이터가 없으면 새로 생성 // 3. 분석 데이터가 없으면 새로 생성
analytics = Optional.of(generateNewAnalytics(storeId)); analytics = Optional.of(generateNewAnalytics(storeId));
} }
// 4. 응답 생성 // 4. 응답 생성
StoreAnalyticsResponse response = StoreAnalyticsResponse.builder() StoreAnalyticsResponse response = StoreAnalyticsResponse.builder()
.storeId(storeId) .storeId(storeId)
.totalReviews(analytics.get().getTotalReviews()) .totalReviews(analytics.get().getTotalReviews())
.averageRating(analytics.get().getAverageRating()) .averageRating(analytics.get().getAverageRating())
.sentimentScore(analytics.get().getSentimentScore()) .sentimentScore(analytics.get().getSentimentScore())
.positiveReviewRate(analytics.get().getPositiveReviewRate()) .positiveReviewRate(analytics.get().getPositiveReviewRate())
.negativeReviewRate(analytics.get().getNegativeReviewRate()) .negativeReviewRate(analytics.get().getNegativeReviewRate())
.lastAnalysisDate(analytics.get().getLastAnalysisDate()) .lastAnalysisDate(analytics.get().getLastAnalysisDate())
.build(); .build();
// 5. 캐시에 저장 // 5. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(1)); cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(1));
log.info("매장 분석 데이터 조회 완료: storeId={}", storeId); log.info("매장 분석 데이터 조회 완료: storeId={}", storeId);
return response; return response;
} catch (Exception e) { } catch (Exception e) {
log.error("매장 분석 데이터 조회 중 오류 발생: storeId={}", storeId, e); log.error("매장 분석 데이터 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("분석 데이터 조회에 실패했습니다.", e); throw new RuntimeException("분석 데이터 조회에 실패했습니다.", e);
} }
} }
// ... 나머지 메서드들은 이전과 동일 ... // ... 나머지 메서드들은 이전과 동일 ...
@Override @Override
@Cacheable(value = "aiFeedback", key = "#storeId") @Cacheable(value = "aiFeedback", key = "#storeId")
public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) { public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) {
log.info("AI 피드백 상세 조회 시작: storeId={}", storeId); log.info("AI 피드백 상세 조회 시작: storeId={}", storeId);
try { try {
// 1. 기존 AI 피드백 조회 // 1. 기존 AI 피드백 조회
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId); var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
if (aiFeedback.isEmpty()) { if (aiFeedback.isEmpty()) {
// 2. AI 피드백이 없으면 새로 생성 // 2. AI 피드백이 없으면 새로 생성
aiFeedback = Optional.of(generateAIFeedback(storeId)); aiFeedback = Optional.of(generateAIFeedback(storeId));
} }
// 3. 응답 생성 // 3. 응답 생성
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder() AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
.storeId(storeId) .storeId(storeId)
.summary(aiFeedback.get().getSummary()) .summary(aiFeedback.get().getSummary())
.positivePoints(aiFeedback.get().getPositivePoints()) .positivePoints(aiFeedback.get().getPositivePoints())
.improvementPoints(aiFeedback.get().getImprovementPoints()) .improvementPoints(aiFeedback.get().getImprovementPoints())
.recommendations(aiFeedback.get().getRecommendations()) .recommendations(aiFeedback.get().getRecommendations())
.sentimentAnalysis(aiFeedback.get().getSentimentAnalysis()) .sentimentAnalysis(aiFeedback.get().getSentimentAnalysis())
.confidenceScore(aiFeedback.get().getConfidenceScore()) .confidenceScore(aiFeedback.get().getConfidenceScore())
.generatedAt(aiFeedback.get().getGeneratedAt()) .generatedAt(aiFeedback.get().getGeneratedAt())
.build(); .build();
log.info("AI 피드백 상세 조회 완료: storeId={}", storeId); log.info("AI 피드백 상세 조회 완료: storeId={}", storeId);
return response; return response;
} catch (Exception e) { } catch (Exception e) {
log.error("AI 피드백 조회 중 오류 발생: storeId={}", storeId, e); log.error("AI 피드백 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("AI 피드백 조회에 실패했습니다.", e); throw new RuntimeException("AI 피드백 조회에 실패했습니다.", e);
} }
} }
// 나머지 메서드들과 private 메서드들은 이전과 동일하게 구현 // 나머지 메서드들과 private 메서드들은 이전과 동일하게 구현
// ... (getStoreStatistics, getAIFeedbackSummary, getReviewAnalysis ) // ... (getStoreStatistics, getAIFeedbackSummary, getReviewAnalysis )
@Override @Override
public StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate) { public StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate) {
log.info("매장 통계 조회 시작: storeId={}, startDate={}, endDate={}", storeId, startDate, endDate); log.info("매장 통계 조회 시작: storeId={}, startDate={}, endDate={}", storeId, startDate, endDate);
try { try {
// 1. 캐시 생성 // 1. 캐시 생성
String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate); String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate);
var cachedResult = cachePort.getAnalyticsCache(cacheKey); var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) { if (cachedResult.isPresent()) {
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId); log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
return (StoreStatisticsResponse) cachedResult.get(); return (StoreStatisticsResponse) cachedResult.get();
} }
// 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용) // 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용)
var orderStatistics = orderDataPort.getOrderStatistics(storeId, startDate, endDate); var orderStatistics = orderDataPort.getOrderStatistics(storeId, startDate, endDate);
// 3. 응답 생성 // 3. 응답 생성
StoreStatisticsResponse response = StoreStatisticsResponse.builder() StoreStatisticsResponse response = StoreStatisticsResponse.builder()
.storeId(storeId) .storeId(storeId)
.startDate(startDate) .startDate(startDate)
.endDate(endDate) .endDate(endDate)
.totalOrders(orderStatistics.getTotalOrders()) .totalOrders(orderStatistics.getTotalOrders())
.totalRevenue(orderStatistics.getTotalRevenue()) .totalRevenue(orderStatistics.getTotalRevenue())
.averageOrderValue(orderStatistics.getAverageOrderValue()) .averageOrderValue(orderStatistics.getAverageOrderValue())
.peakHour(orderStatistics.getPeakHour()) .peakHour(orderStatistics.getPeakHour())
.popularMenus(orderStatistics.getPopularMenus()) .popularMenus(orderStatistics.getPopularMenus())
.customerAgeDistribution(orderStatistics.getCustomerAgeDistribution()) .customerAgeDistribution(orderStatistics.getCustomerAgeDistribution())
.build(); .build();
// 4. 캐시에 저장 // 4. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofMinutes(30)); cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofMinutes(30));
log.info("매장 통계 조회 완료: storeId={}", storeId); log.info("매장 통계 조회 완료: storeId={}", storeId);
return response; return response;
} catch (Exception e) { } catch (Exception e) {
log.error("매장 통계 조회 중 오류 발생: storeId={}", storeId, e); log.error("매장 통계 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("매장 통계 조회에 실패했습니다.", e); throw new RuntimeException("매장 통계 조회에 실패했습니다.", e);
} }
} }
@Override @Override
public AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId) { public AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId) {
log.info("AI 피드백 요약 조회 시작: storeId={}", storeId); log.info("AI 피드백 요약 조회 시작: storeId={}", storeId);
try { try {
// 1. 캐시에서 확인 // 1. 캐시에서 확인
String cacheKey = "ai_feedback_summary:store:" + storeId; String cacheKey = "ai_feedback_summary:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey); var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) { if (cachedResult.isPresent()) {
return (AiFeedbackSummaryResponse) cachedResult.get(); return (AiFeedbackSummaryResponse) cachedResult.get();
} }
// 2. AI 피드백 조회 // 2. AI 피드백 조회
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId); var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
if (aiFeedback.isEmpty()) { if (aiFeedback.isEmpty()) {
// 3. 피드백이 없으면 기본 응답 생성 // 3. 피드백이 없으면 기본 응답 생성
AiFeedbackSummaryResponse emptyResponse = AiFeedbackSummaryResponse.builder() AiFeedbackSummaryResponse emptyResponse = AiFeedbackSummaryResponse.builder()
.storeId(storeId) .storeId(storeId)
.hasData(false) .hasData(false)
.message("분석할 데이터가 부족합니다.") .message("분석할 데이터가 부족합니다.")
.lastUpdated(LocalDateTime.now()) .lastUpdated(LocalDateTime.now())
.build(); .build();
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1)); cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
return emptyResponse; return emptyResponse;
} }
// 4. 응답 생성 // 4. 응답 생성
AiFeedbackSummaryResponse response = AiFeedbackSummaryResponse.builder() AiFeedbackSummaryResponse response = AiFeedbackSummaryResponse.builder()
.storeId(storeId) .storeId(storeId)
.hasData(true) .hasData(true)
.message("AI 분석이 완료되었습니다.") .message("AI 분석이 완료되었습니다.")
.overallScore(aiFeedback.get().getConfidenceScore()) .overallScore(aiFeedback.get().getConfidenceScore())
.keyInsight(aiFeedback.get().getSummary()) .keyInsight(aiFeedback.get().getSummary())
.priorityRecommendation(getFirstRecommendation(aiFeedback.get())) .priorityRecommendation(getFirstRecommendation(aiFeedback.get()))
.lastUpdated(aiFeedback.get().getUpdatedAt()) .lastUpdated(aiFeedback.get().getUpdatedAt())
.build(); .build();
// 5. 캐시에 저장 // 5. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(2)); cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(2));
log.info("AI 피드백 요약 조회 완료: storeId={}", storeId); log.info("AI 피드백 요약 조회 완료: storeId={}", storeId);
return response; return response;
} catch (Exception e) { } catch (Exception e) {
log.error("AI 피드백 요약 조회 중 오류 발생: storeId={}", storeId, e); log.error("AI 피드백 요약 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("AI 피드백 요약 조회에 실패했습니다.", e); throw new RuntimeException("AI 피드백 요약 조회에 실패했습니다.", e);
} }
} }
@Override @Override
public ReviewAnalysisResponse getReviewAnalysis(Long storeId) { public ReviewAnalysisResponse getReviewAnalysis(Long storeId) {
log.info("리뷰 분석 조회 시작: storeId={}", storeId); log.info("리뷰 분석 조회 시작: storeId={}", storeId);
try { try {
// 1. 캐시에서 확인 // 1. 캐시에서 확인
String cacheKey = "review_analysis:store:" + storeId; String cacheKey = "review_analysis:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey); var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) { if (cachedResult.isPresent()) {
return (ReviewAnalysisResponse) cachedResult.get(); return (ReviewAnalysisResponse) cachedResult.get();
} }
// 2. 최근 리뷰 데이터 조회 (30일) // 2. 최근 리뷰 데이터 조회 (30일)
List<String> recentReviews = externalReviewPort.getRecentReviews(storeId, 30); List<String> recentReviews = externalReviewPort.getRecentReviews(storeId, 30);
if (recentReviews.isEmpty()) { if (recentReviews.isEmpty()) {
ReviewAnalysisResponse emptyResponse = ReviewAnalysisResponse.builder() ReviewAnalysisResponse emptyResponse = ReviewAnalysisResponse.builder()
.storeId(storeId) .storeId(storeId)
.totalReviews(0) .totalReviews(0)
.positiveReviewCount(0) .positiveReviewCount(0)
.negativeReviewCount(0) .negativeReviewCount(0)
.positiveRate(0.0) .positiveRate(0.0)
.negativeRate(0.0) .negativeRate(0.0)
.analysisDate(LocalDate.now()) .analysisDate(LocalDate.now())
.build(); .build();
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1)); cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
return emptyResponse; return emptyResponse;
} }
// 3. 응답 생성 // 3. 응답 생성
int positiveCount = countPositiveReviews(recentReviews); int positiveCount = countPositiveReviews(recentReviews);
int negativeCount = countNegativeReviews(recentReviews); int negativeCount = countNegativeReviews(recentReviews);
int totalCount = recentReviews.size(); int totalCount = recentReviews.size();
ReviewAnalysisResponse response = ReviewAnalysisResponse.builder() ReviewAnalysisResponse response = ReviewAnalysisResponse.builder()
.storeId(storeId) .storeId(storeId)
.totalReviews(totalCount) .totalReviews(totalCount)
.positiveReviewCount(positiveCount) .positiveReviewCount(positiveCount)
.negativeReviewCount(negativeCount) .negativeReviewCount(negativeCount)
.positiveRate((double) positiveCount / totalCount * 100) .positiveRate((double) positiveCount / totalCount * 100)
.negativeRate((double) negativeCount / totalCount * 100) .negativeRate((double) negativeCount / totalCount * 100)
.analysisDate(LocalDate.now()) .analysisDate(LocalDate.now())
.build(); .build();
// 4. 캐시에 저장 // 4. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(4)); cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(4));
log.info("리뷰 분석 조회 완료: storeId={}", storeId); log.info("리뷰 분석 조회 완료: storeId={}", storeId);
return response; return response;
} catch (Exception e) { } catch (Exception e) {
log.error("리뷰 분석 중 오류 발생: storeId={}", storeId, e); log.error("리뷰 분석 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("리뷰 분석에 실패했습니다.", e); throw new RuntimeException("리뷰 분석에 실패했습니다.", e);
} }
} }
// private 메서드들 // private 메서드들
@Transactional @Transactional
public Analytics generateNewAnalytics(Long storeId) { public Analytics generateNewAnalytics(Long storeId) {
log.info("새로운 분석 데이터 생성 시작: storeId={}", storeId); log.info("새로운 분석 데이터 생성 시작: storeId={}", storeId);
try { try {
// 1. 리뷰 데이터 수집 // 1. 리뷰 데이터 수집
List<String> reviewData = externalReviewPort.getReviewData(storeId); List<String> reviewData = externalReviewPort.getReviewData(storeId);
int totalReviews = reviewData.size(); int totalReviews = reviewData.size();
if (totalReviews == 0) { if (totalReviews == 0) {
log.warn("리뷰 데이터가 없어 기본값으로 분석 데이터 생성: storeId={}", storeId); log.warn("리뷰 데이터가 없어 기본값으로 분석 데이터 생성: storeId={}", storeId);
return createDefaultAnalytics(storeId); return createDefaultAnalytics(storeId);
} }
// 2. 기본 통계 계산 // 2. 기본 통계 계산
double averageRating = 4.0; // 기본값 double averageRating = 4.0; // 기본값
double sentimentScore = 0.5; // 중립 double sentimentScore = 0.5; // 중립
double positiveRate = 60.0; double positiveRate = 60.0;
double negativeRate = 20.0; double negativeRate = 20.0;
// 3. Analytics 도메인 객체 생성 // 3. Analytics 도메인 객체 생성
Analytics analytics = Analytics.builder() Analytics analytics = Analytics.builder()
.storeId(storeId) .storeId(storeId)
.totalReviews(totalReviews) .totalReviews(totalReviews)
.averageRating(averageRating) .averageRating(averageRating)
.sentimentScore(sentimentScore) .sentimentScore(sentimentScore)
.positiveReviewRate(positiveRate) .positiveReviewRate(positiveRate)
.negativeReviewRate(negativeRate) .negativeReviewRate(negativeRate)
.lastAnalysisDate(LocalDateTime.now()) .lastAnalysisDate(LocalDateTime.now())
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now())
.build(); .build();
// 4. 데이터베이스에 저장 // 4. 데이터베이스에 저장
Analytics saved = analyticsPort.saveAnalytics(analytics); Analytics saved = analyticsPort.saveAnalytics(analytics);
log.info("새로운 분석 데이터 생성 완료: storeId={}", storeId); log.info("새로운 분석 데이터 생성 완료: storeId={}", storeId);
return saved; return saved;
} catch (Exception e) { } catch (Exception e) {
log.error("분석 데이터 생성 중 오류 발생: storeId={}", storeId, e); log.error("분석 데이터 생성 중 오류 발생: storeId={}", storeId, e);
return createDefaultAnalytics(storeId); return createDefaultAnalytics(storeId);
} }
} }
@Transactional @Transactional
public AiFeedback generateAIFeedback(Long storeId) { public AiFeedback generateAIFeedback(Long storeId) {
log.info("AI 피드백 생성 시작: storeId={}", storeId); log.info("AI 피드백 생성 시작: storeId={}", storeId);
try { try {
// 1. 최근 30일 리뷰 데이터 수집 // 1. 최근 30일 리뷰 데이터 수집
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, 30); List<String> reviewData = externalReviewPort.getRecentReviews(storeId, 30);
if (reviewData.isEmpty()) { if (reviewData.isEmpty()) {
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId); log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
return createDefaultAIFeedback(storeId); return createDefaultAIFeedback(storeId);
} }
// 2. AI 피드백 생성 (실제로는 AI 서비스 호출) // 2. AI 피드백 생성 (실제로는 AI 서비스 호출)
AiFeedback aiFeedback = AiFeedback.builder() AiFeedback aiFeedback = AiFeedback.builder()
.storeId(storeId) .storeId(storeId)
.summary("고객들의 전반적인 만족도가 높습니다.") .summary("고객들의 전반적인 만족도가 높습니다.")
.positivePoints(List.of("맛이 좋다", "서비스가 친절하다", "분위기가 좋다")) .positivePoints(List.of("맛이 좋다", "서비스가 친절하다", "분위기가 좋다"))
.improvementPoints(List.of("대기시간 단축", "가격 경쟁력", "메뉴 다양성")) .improvementPoints(List.of("대기시간 단축", "가격 경쟁력", "메뉴 다양성"))
.recommendations(List.of("특별 메뉴 개발", "예약 시스템 도입", "고객 서비스 교육")) .recommendations(List.of("특별 메뉴 개발", "예약 시스템 도입", "고객 서비스 교육"))
.sentimentAnalysis("POSITIVE") .sentimentAnalysis("POSITIVE")
.confidenceScore(0.85) .confidenceScore(0.85)
.generatedAt(LocalDateTime.now()) .generatedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now())
.build(); .build();
// 3. 데이터베이스에 저장 // 3. 데이터베이스에 저장
AiFeedback saved = analyticsPort.saveAIFeedback(aiFeedback); AiFeedback saved = analyticsPort.saveAIFeedback(aiFeedback);
log.info("AI 피드백 생성 완료: storeId={}", storeId); log.info("AI 피드백 생성 완료: storeId={}", storeId);
return saved; return saved;
} catch (Exception e) { } catch (Exception e) {
log.error("AI 피드백 생성 중 오류 발생: storeId={}", storeId, e); log.error("AI 피드백 생성 중 오류 발생: storeId={}", storeId, e);
return createDefaultAIFeedback(storeId); return createDefaultAIFeedback(storeId);
} }
} }
private Analytics createDefaultAnalytics(Long storeId) { private Analytics createDefaultAnalytics(Long storeId) {
return Analytics.builder() return Analytics.builder()
.storeId(storeId) .storeId(storeId)
.totalReviews(0) .totalReviews(0)
.averageRating(0.0) .averageRating(0.0)
.sentimentScore(0.0) .sentimentScore(0.0)
.positiveReviewRate(0.0) .positiveReviewRate(0.0)
.negativeReviewRate(0.0) .negativeReviewRate(0.0)
.lastAnalysisDate(LocalDateTime.now()) .lastAnalysisDate(LocalDateTime.now())
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now())
.build(); .build();
} }
private AiFeedback createDefaultAIFeedback(Long storeId) { private AiFeedback createDefaultAIFeedback(Long storeId) {
return AiFeedback.builder() return AiFeedback.builder()
.storeId(storeId) .storeId(storeId)
.summary("분석할 리뷰 데이터가 부족합니다.") .summary("분석할 리뷰 데이터가 부족합니다.")
.positivePoints(List.of("데이터 부족으로 분석 불가")) .positivePoints(List.of("데이터 부족으로 분석 불가"))
.improvementPoints(List.of("리뷰 데이터 수집 필요")) .improvementPoints(List.of("리뷰 데이터 수집 필요"))
.recommendations(List.of("고객들의 리뷰 작성을 유도해보세요")) .recommendations(List.of("고객들의 리뷰 작성을 유도해보세요"))
.sentimentAnalysis("NEUTRAL") .sentimentAnalysis("NEUTRAL")
.confidenceScore(0.0) .confidenceScore(0.0)
.generatedAt(LocalDateTime.now()) .generatedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now())
.build(); .build();
} }
private String getFirstRecommendation(AiFeedback feedback) { private String getFirstRecommendation(AiFeedback feedback) {
if (feedback.getRecommendations() != null && !feedback.getRecommendations().isEmpty()) { if (feedback.getRecommendations() != null && !feedback.getRecommendations().isEmpty()) {
return feedback.getRecommendations().get(0); return feedback.getRecommendations().get(0);
} }
return "추천사항이 없습니다."; return "추천사항이 없습니다.";
} }
private int countPositiveReviews(List<String> reviews) { private int countPositiveReviews(List<String> reviews) {
// 실제로는 AI 서비스를 통한 감정 분석 필요 // 실제로는 AI 서비스를 통한 감정 분석 필요
return (int) (reviews.size() * 0.6); // 60% 가정 return (int) (reviews.size() * 0.6); // 60% 가정
} }
private int countNegativeReviews(List<String> reviews) { private int countNegativeReviews(List<String> reviews) {
// 실제로는 AI 서비스를 통한 감정 분석 필요 // 실제로는 AI 서비스를 통한 감정 분석 필요
return (int) (reviews.size() * 0.2); // 20% 가정 return (int) (reviews.size() * 0.2); // 20% 가정
} }
} }

View File

@ -1,50 +1,51 @@
package com.ktds.hi.analytics.infra.config; package com.ktds.hi.analytics.infra.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
/** /**
* Analytics 서비스 보안 설정 클래스 * Analytics 서비스 보안 설정 클래스
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정 * 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final CorsConfigurationSource corsConfigurationSource;
private final CorsConfigurationSource corsConfigurationSource;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @Bean
http public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
.csrf(AbstractHttpConfigurer::disable) http
.cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .cors(cors -> cors.configurationSource(corsConfigurationSource))
.authorizeHttpRequests(auth -> auth .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Swagger 관련 경로 모두 허용 .authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll() // Swagger 관련 경로 모두 허용
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll() .requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
// Analytics API 모두 허용 (테스트용)
.requestMatchers("/api/analytics/**").permitAll() // Analytics API 모두 허용 (테스트용)
.requestMatchers("/api/action-plans/**").permitAll() .requestMatchers("/api/analytics/**").permitAll()
.requestMatchers("/api/action-plans/**").permitAll()
// Actuator 엔드포인트 허용
.requestMatchers("/actuator/**").permitAll() // Actuator 엔드포인트 허용
.requestMatchers("/actuator/**").permitAll()
// 기타 모든 요청 허용 (테스트용)
.anyRequest().permitAll() // 기타 모든 요청 허용 (테스트용)
); .anyRequest().permitAll()
);
return http.build();
} return http.build();
} }
}

View File

@ -1,95 +1,95 @@
server: server:
port: ${ANALYTICS_SERVICE_PORT:8084} port: ${ANALYTICS_SERVICE_PORT:8084}
logging: logging:
level: level:
org.springframework.web.servlet.resource.ResourceHttpRequestHandler: ERROR org.springframework.web.servlet.resource.ResourceHttpRequestHandler: ERROR
org.springframework.web.servlet.DispatcherServlet: WARN org.springframework.web.servlet.DispatcherServlet: WARN
spring: spring:
application: application:
name: analytics-service name: analytics-service
datasource: datasource:
url: ${ANALYTICS_DB_URL:jdbc:postgresql://20.249.162.125:5432/hiorder_analytics} url: ${ANALYTICS_DB_URL:jdbc:postgresql://20.249.162.125:5432/hiorder_analytics}
username: ${ANALYTICS_DB_USERNAME:hiorder_user} username: ${ANALYTICS_DB_USERNAME:hiorder_user}
password: ${ANALYTICS_DB_PASSWORD:hiorder_pass} password: ${ANALYTICS_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false} show-sql: ${JPA_SHOW_SQL:false}
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
data: data:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
ai-api: ai-api:
openai: openai:
api-key: ${OPENAI_API_KEY:} api-key: ${OPENAI_API_KEY:}
base-url: https://api.openai.com/v1 base-url: https://api.openai.com/v1
model: gpt-4o-mini model: gpt-4o-mini
claude: claude:
api-key: ${CLAUDE_API_KEY:} api-key: ${CLAUDE_API_KEY:}
base-url: https://api.anthropic.com base-url: https://api.anthropic.com
model: claude-3-sonnet-20240229 model: claude-3-sonnet-20240229
#external-api: #external-api:
# openai: # openai:
# api-key: ${OPENAI_API_KEY:} # api-key: ${OPENAI_API_KEY:}
# base-url: https://api.openai.com # base-url: https://api.openai.com
# claude: # claude:
# api-key: ${CLAUDE_API_KEY:} # api-key: ${CLAUDE_API_KEY:}
# base-url: https://api.anthropic.com # base-url: https://api.anthropic.com
# 외부 서비스 설정 # 외부 서비스 설정
external: external:
services: services:
review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082} review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082}
store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081} store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081}
member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080} member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080}
#springdoc: #springdoc:
# api-docs: # api-docs:
# path: /api-docs # path: /api-docs
# swagger-ui: # swagger-ui:
# path: /swagger-ui.html # path: /swagger-ui.html
springdoc: springdoc:
swagger-ui: swagger-ui:
enabled: true enabled: true
path: /swagger-ui.html path: /swagger-ui.html
try-it-out-enabled: true try-it-out-enabled: true
management: management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info,metrics include: health,info,metrics
# AI 서비스 설정 # AI 서비스 설정
ai: ai:
azure: azure:
cognitive: cognitive:
endpoint: ${AI_AZURE_COGNITIVE_ENDPOINT:https://your-cognitive-service.cognitiveservices.azure.com} endpoint: ${AI_AZURE_COGNITIVE_ENDPOINT:https://your-cognitive-service.cognitiveservices.azure.com}
key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key} key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key}
openai: openai:
api-key: ${AI_OPENAI_API_KEY:your-openai-api-key} api-key: ${AI_OPENAI_API_KEY:your-openai-api-key}
# Azure Event Hub 설정 # Azure Event Hub 설정
azure: azure:
eventhub: eventhub:
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:Endpoint=sb://your-eventhub.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=your-key} 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} consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:analytics-consumer}
event-hubs: event-hubs:
review-events: ${AZURE_EVENTHUB_REVIEW_EVENTS:review-events} review-events: ${AZURE_EVENTHUB_REVIEW_EVENTS:review-events}
ai-analysis-events: ${AZURE_EVENTHUB_AI_ANALYSIS_EVENTS:ai-analysis-events} ai-analysis-events: ${AZURE_EVENTHUB_AI_ANALYSIS_EVENTS:ai-analysis-events}
storage: storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=yourstorageaccount;AccountKey=your-storage-key;EndpointSuffix=core.windows.net} 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} container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}

View File

@ -1,102 +1,102 @@
package com.ktds.hi.common.config; package com.ktds.hi.common.config;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter; import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
/** /**
* 전체 서비스 통합 CORS 설정 클래스 * 전체 서비스 통합 CORS 설정 클래스
* 모든 마이크로서비스에서 공통으로 사용되는 CORS 정책을 정의 * 모든 마이크로서비스에서 공통으로 사용되는 CORS 정책을 정의
*/ */
@Configuration @Configuration
public class CorsConfig implements WebMvcConfigurer { public class CorsConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins:http://localhost:3000,http://localhost:8080,http://localhost:3001}") @Value("${app.cors.allowed-origins:http://20.214.126.84,http://localhost:3000}")
private String allowedOrigins; private String allowedOrigins;
@Value("${app.cors.allowed-methods:GET,POST,PUT,DELETE,PATCH,OPTIONS}") @Value("${app.cors.allowed-methods:GET,POST,PUT,DELETE,OPTIONS}")
private String allowedMethods; private String allowedMethods;
@Value("${app.cors.allowed-headers:*}") @Value("${app.cors.allowed-headers:*}")
private String allowedHeaders; private String allowedHeaders;
@Value("${app.cors.exposed-headers:Authorization,X-Total-Count}") @Value("${app.cors.exposed-headers:Authorization,X-Total-Count}")
private String exposedHeaders; private String exposedHeaders;
@Value("${app.cors.allow-credentials:true}") @Value("${app.cors.allow-credentials:true}")
private boolean allowCredentials; private boolean allowCredentials;
@Value("${app.cors.max-age:3600}") @Value("${app.cors.max-age:3600}")
private long maxAge; private long maxAge;
/** /**
* WebMvcConfigurer를 통한 CORS 설정 * WebMvcConfigurer를 통한 CORS 설정
*/ */
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") registry.addMapping("/**")
.allowedOriginPatterns(allowedOrigins.split(",")) .allowedOriginPatterns(allowedOrigins.split(","))
.allowedMethods(allowedMethods.split(",")) .allowedMethods(allowedMethods.split(","))
.allowedHeaders(allowedHeaders.split(",")) .allowedHeaders(allowedHeaders.split(","))
.exposedHeaders(exposedHeaders.split(",")) .exposedHeaders(exposedHeaders.split(","))
.allowCredentials(allowCredentials) .allowCredentials(allowCredentials)
.maxAge(maxAge); .maxAge(maxAge);
} }
/** /**
* CorsConfigurationSource Bean 생성 * CorsConfigurationSource Bean 생성
* Spring Security와 함께 사용되는 CORS 설정 * Spring Security와 함께 사용되는 CORS 설정
*/ */
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
// Origin 설정 // Origin 설정
List<String> origins = Arrays.asList(allowedOrigins.split(",")); List<String> origins = Arrays.asList(allowedOrigins.split(","));
configuration.setAllowedOriginPatterns(origins); configuration.setAllowedOriginPatterns(origins);
// Method 설정 // Method 설정
List<String> methods = Arrays.asList(allowedMethods.split(",")); List<String> methods = Arrays.asList(allowedMethods.split(","));
configuration.setAllowedMethods(methods); configuration.setAllowedMethods(methods);
// Header 설정 // Header 설정
if ("*".equals(allowedHeaders)) { if ("*".equals(allowedHeaders)) {
configuration.addAllowedHeader("*"); configuration.addAllowedHeader("*");
} else { } else {
List<String> headers = Arrays.asList(allowedHeaders.split(",")); List<String> headers = Arrays.asList(allowedHeaders.split(","));
configuration.setAllowedHeaders(headers); configuration.setAllowedHeaders(headers);
} }
// Exposed Headers 설정 // Exposed Headers 설정
List<String> exposed = Arrays.asList(exposedHeaders.split(",")); List<String> exposed = Arrays.asList(exposedHeaders.split(","));
configuration.setExposedHeaders(exposed); configuration.setExposedHeaders(exposed);
// Credentials 설정 // Credentials 설정
configuration.setAllowCredentials(allowCredentials); configuration.setAllowCredentials(allowCredentials);
// Max Age 설정 // Max Age 설정
configuration.setMaxAge(maxAge); configuration.setMaxAge(maxAge);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
/** /**
* CorsFilter Bean 생성 * CorsFilter Bean 생성
* 글로벌 CORS 필터로 사용 * 글로벌 CORS 필터로 사용
*/ */
@Bean @Bean
public CorsFilter corsFilter() { public CorsFilter corsFilter() {
return new CorsFilter(corsConfigurationSource()); return new CorsFilter(corsConfigurationSource());
} }
} }

View File

@ -1,5 +1,7 @@
package com.ktds.hi.common.security; 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 com.ktds.hi.common.constants.SecurityConstants;
import io.jsonwebtoken.*; import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
@ -55,6 +57,82 @@ public class JwtTokenProvider {
.verifyWith(secretKey) .verifyWith(secretKey)
.build(); .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: spring:
# JPA 설정 # JPA 설정
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${JPA_DDL_AUTO:update}
naming: naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
show-sql: ${JPA_SHOW_SQL:false} show-sql: ${JPA_SHOW_SQL:false}
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
use_sql_comments: true use_sql_comments: true
jdbc: jdbc:
batch_size: 20 batch_size: 20
order_inserts: true order_inserts: true
order_updates: true order_updates: true
batch_versioned_data: true batch_versioned_data: true
# Redis 설정 # Redis 설정
data: data:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
timeout: 2000ms timeout: 2000ms
lettuce: lettuce:
pool: pool:
max-active: 8 max-active: 8
max-wait: -1ms max-wait: -1ms
max-idle: 8 max-idle: 8
min-idle: 0 min-idle: 0
# Jackson 설정 # Jackson 설정
jackson: jackson:
time-zone: Asia/Seoul time-zone: Asia/Seoul
date-format: yyyy-MM-dd HH:mm:ss date-format: yyyy-MM-dd HH:mm:ss
serialization: serialization:
write-dates-as-timestamps: false write-dates-as-timestamps: false
deserialization: deserialization:
fail-on-unknown-properties: false fail-on-unknown-properties: false
# 트랜잭션 설정 # 트랜잭션 설정
transaction: transaction:
default-timeout: 30 default-timeout: 30
# 애플리케이션 설정 # 애플리케이션 설정
app: app:
# JWT 설정 # JWT 설정
jwt: jwt:
secret-key: ${JWT_SECRET_KEY:hiorder-secret-key-for-jwt-token-generation-2024-very-long-secret-key} 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시간 access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} # 1시간
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} # 7일 refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} # 7일
# CORS 설정 # CORS 설정
cors: cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://20.214.126.84:80,http://localhost:8080} allowed-origins: ${CORS_ALLOWED_ORIGINS:http://20.214.126.84,http://localhost:8080}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS} allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS}
allowed-headers: ${CORS_ALLOWED_HEADERS:*} allowed-headers: ${CORS_ALLOWED_HEADERS:*}
exposed-headers: ${CORS_EXPOSED_HEADERS:Authorization, X-Total-Count} exposed-headers: ${CORS_EXPOSED_HEADERS:Authorization, X-Total-Count}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true} allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600} max-age: ${CORS_MAX_AGE:3600}
# 캐시 설정 # 캐시 설정
cache: cache:
default-ttl: ${CACHE_DEFAULT_TTL:3600} # 1시간 default-ttl: ${CACHE_DEFAULT_TTL:3600} # 1시간
# Swagger 설정 # Swagger 설정
swagger: swagger:
title: ${SWAGGER_TITLE:하이오더 API} title: ${SWAGGER_TITLE:하이오더 API}
description: ${SWAGGER_DESCRIPTION:하이오더 백엔드 API 문서} description: ${SWAGGER_DESCRIPTION:하이오더 백엔드 API 문서}
version: ${SWAGGER_VERSION:1.0.0} version: ${SWAGGER_VERSION:1.0.0}
server-url: ${SWAGGER_SERVER_URL:http://localhost:8080} server-url: ${SWAGGER_SERVER_URL:http://localhost:8080}
# 로깅 설정 # 로깅 설정
logging: logging:
level: level:
com.ktds.hi: ${LOG_LEVEL:INFO} com.ktds.hi: ${LOG_LEVEL:INFO}
org.springframework.security: ${SECURITY_LOG_LEVEL:INFO} org.springframework.security: ${SECURITY_LOG_LEVEL:INFO}
org.hibernate.SQL: ${SQL_LOG_LEVEL:INFO} org.hibernate.SQL: ${SQL_LOG_LEVEL:INFO}
org.hibernate.type.descriptor.sql.BasicBinder: ${SQL_PARAM_LOG_LEVEL:INFO} org.hibernate.type.descriptor.sql.BasicBinder: ${SQL_PARAM_LOG_LEVEL:INFO}
pattern: pattern:
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" 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" file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
# 관리 엔드포인트 설정 # 관리 엔드포인트 설정
management: management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info,metrics,prometheus include: health,info,metrics,prometheus
endpoint: endpoint:
health: health:
show-details: when-authorized show-details: when-authorized

View File

@ -1,121 +1,121 @@
package com.ktds.hi.member.config; package com.ktds.hi.member.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.ktds.hi.common.security.JwtTokenProvider; import com.ktds.hi.common.security.JwtTokenProvider;
import com.ktds.hi.common.security.JwtAuthenticationFilter; import com.ktds.hi.common.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.boot.actuate.autoconfigure.condition.ConditionsReportEndpoint; import org.springframework.boot.actuate.autoconfigure.condition.ConditionsReportEndpoint;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
/** /**
* Spring Security 설정 클래스 * Spring Security 설정 클래스
* JWT 기반 인증 권한 관리 설정 * JWT 기반 인증 권한 관리 설정
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final CorsConfigurationSource corsConfigurationSource; private final CorsConfigurationSource corsConfigurationSource;
/** /**
* 보안 필터 체인 설정 * 보안 필터 체인 설정
* JWT 인증 방식을 사용하고 세션은 무상태로 관리 * JWT 인증 방식을 사용하고 세션은 무상태로 관리
*/ */
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource)) .cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz .authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**", "/api/members/register", "/api/auth/login").permitAll() .requestMatchers("/api/auth/**", "/api/members/register", "/api/auth/login").permitAll()
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
.requestMatchers("/actuator/**").permitAll() .requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
/** /**
* JWT 인증 필터 * JWT 인증 필터
*/ */
@Bean @Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() { public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider,new ObjectMapper()); return new JwtAuthenticationFilter(jwtTokenProvider,new ObjectMapper());
} }
/** /**
* 비밀번호 암호화 * 비밀번호 암호화
*/ */
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
/** /**
* 인증 매니저 * 인증 매니저
*/ */
@Bean @Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager(); return config.getAuthenticationManager();
} }
// @Qualifier("memberJwtTokenProvider") // @Qualifier("memberJwtTokenProvider")
// private final JwtTokenProvider jwtTokenProvider; // private final JwtTokenProvider jwtTokenProvider;
// private final AuthService authService; // private final AuthService authService;
// //
// /** // /**
// * 보안 필터 체인 설정 // * 보안 필터 체인 설정
// * JWT 인증 방식을 사용하고 세션은 무상태로 관리 // * JWT 인증 방식을 사용하고 세션은 무상태로 관리
// */ // */
// @Bean // @Bean
// public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// http // http
// .csrf(csrf -> csrf.disable()) // .csrf(csrf -> csrf.disable())
// .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// .authorizeHttpRequests(authz -> authz // .authorizeHttpRequests(authz -> authz
// .requestMatchers("/api/auth/**", "/api/members/register").permitAll() // .requestMatchers("/api/auth/**", "/api/members/register").permitAll()
// .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll() // .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
// .requestMatchers("/actuator/**").permitAll() // .requestMatchers("/actuator/**").permitAll()
// .anyRequest().authenticated() // .anyRequest().authenticated()
// ) // )
// .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, authService), // .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, authService),
// UsernamePasswordAuthenticationFilter.class); // UsernamePasswordAuthenticationFilter.class);
// //
// return http.build(); // return http.build();
// } // }
// //
// /** // /**
// * 비밀번호 암호화 // * 비밀번호 암호화
// */ // */
// @Bean // @Bean
// public PasswordEncoder passwordEncoder() { // public PasswordEncoder passwordEncoder() {
// return new BCryptPasswordEncoder(); // return new BCryptPasswordEncoder();
// } // }
// //
// /** // /**
// * 인증 매니저 // * 인증 매니저
// */ // */
// @Bean // @Bean
// public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { // public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
// return config.getAuthenticationManager(); // return config.getAuthenticationManager();
// } // }
} }

View File

@ -1,56 +1,56 @@
server: server:
port: ${MEMBER_SERVICE_PORT:8081} port: ${MEMBER_SERVICE_PORT:8081}
spring: spring:
application: application:
name: member-service name: member-service
datasource: datasource:
url: ${MEMBER_DB_URL:jdbc:postgresql://20.249.152.184:5432/hiorder_member} url: ${MEMBER_DB_URL:jdbc:postgresql://20.249.152.184:5432/hiorder_member}
username: ${MEMBER_DB_USERNAME:hiorder_user} username: ${MEMBER_DB_USERNAME:hiorder_user}
password: ${MEMBER_DB_PASSWORD:hiorder_pass} password: ${MEMBER_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false} show-sql: ${JPA_SHOW_SQL:false}
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
data: data:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
timeout: 2000ms timeout: 2000ms
lettuce: lettuce:
pool: pool:
max-active: 8 max-active: 8
max-wait: -1ms max-wait: -1ms
max-idle: 8 max-idle: 8
min-idle: 0 min-idle: 0
jwt: jwt:
secret: ${JWT_SECRET:hiorder-secret-key-for-jwt-token-generation-must-be-long-enough} secret: ${JWT_SECRET:hiorder-secret-key-for-jwt-token-generation-must-be-long-enough}
access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600000} # 1시간 access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600000} # 1시간
refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일 refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일
sms: sms:
api-key: ${SMS_API_KEY:} api-key: ${SMS_API_KEY:}
api-secret: ${SMS_API_SECRET:} api-secret: ${SMS_API_SECRET:}
from-number: ${SMS_FROM_NUMBER:} from-number: ${SMS_FROM_NUMBER:}
springdoc: springdoc:
swagger-ui: swagger-ui:
enabled: true enabled: true
path: /swagger-ui.html path: /swagger-ui.html
try-it-out-enabled: true try-it-out-enabled: true
management: management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info,metrics include: health,info,metrics

View File

@ -1,52 +1,52 @@
package com.ktds.hi.recommend.infra.config; package com.ktds.hi.recommend.infra.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
/** /**
* Analytics 서비스 보안 설정 클래스 * Analytics 서비스 보안 설정 클래스
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정 * 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final CorsConfigurationSource corsConfigurationSource; private final CorsConfigurationSource corsConfigurationSource;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource)) .cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Swagger 관련 경로 모두 허용 // Swagger 관련 경로 모두 허용
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll() .requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
// Analytics API 모두 허용 (테스트용) // Analytics API 모두 허용 (테스트용)
.requestMatchers("/api/analytics/**").permitAll() .requestMatchers("/api/analytics/**").permitAll()
.requestMatchers("/api/action-plans/**").permitAll() .requestMatchers("/api/action-plans/**").permitAll()
// Actuator 엔드포인트 허용 // Actuator 엔드포인트 허용
.requestMatchers("/actuator/**").permitAll() .requestMatchers("/actuator/**").permitAll()
// 기타 모든 요청 허용 (테스트용) // 기타 모든 요청 허용 (테스트용)
.anyRequest().permitAll() .anyRequest().permitAll()
); );
return http.build(); return http.build();
} }
} }

View File

@ -1,226 +1,228 @@
# recommend/src/main/resources/application.yml # recommend/src/main/resources/application.yml
server: server:
port: ${RECOMMEND_SERVICE_PORT:8085} port: ${RECOMMEND_SERVICE_PORT:8085}
spring: spring:
cloud: cloud:
compatibility-verifier: compatibility-verifier:
enabled: false enabled: false
application: application:
name: recommend-service name: recommend-service
# 프로필 설정 # 프로필 설정
profiles: profiles:
active: ${SPRING_PROFILES_ACTIVE:local} active: ${SPRING_PROFILES_ACTIVE:local}
# 데이터베이스 설정 # 데이터베이스 설정
datasource: datasource:
url: ${RECOMMEND_DB_URL:jdbc:postgresql://20.249.162.245:5432/hiorder_recommend} url: ${RECOMMEND_DB_URL:jdbc:postgresql://20.249.162.245:5432/hiorder_recommend}
username: ${RECOMMEND_DB_USERNAME:hiorder_user} username: ${RECOMMEND_DB_USERNAME:hiorder_user}
password: ${RECOMMEND_DB_PASSWORD:hiorder_pass} password: ${RECOMMEND_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari: hikari:
maximum-pool-size: ${DB_POOL_SIZE:20} maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: ${DB_POOL_MIN_IDLE:5} minimum-idle: ${DB_POOL_MIN_IDLE:5}
connection-timeout: ${DB_CONNECTION_TIMEOUT:30000} connection-timeout: ${DB_CONNECTION_TIMEOUT:30000}
idle-timeout: ${DB_IDLE_TIMEOUT:600000} idle-timeout: ${DB_IDLE_TIMEOUT:600000}
max-lifetime: ${DB_MAX_LIFETIME:1800000} max-lifetime: ${DB_MAX_LIFETIME:1800000}
pool-name: RecommendHikariCP pool-name: RecommendHikariCP
# JPA 설정 # JPA 설정
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false} show-sql: ${JPA_SHOW_SQL:false}
properties: properties:
hibernate: hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: ${JPA_FORMAT_SQL:true} format_sql: ${JPA_FORMAT_SQL:true}
show_sql: ${JPA_SHOW_SQL:false} show_sql: ${JPA_SHOW_SQL:false}
use_sql_comments: ${JPA_USE_SQL_COMMENTS:true} use_sql_comments: ${JPA_USE_SQL_COMMENTS:true}
jdbc: jdbc:
batch_size: 20 batch_size: 20
order_inserts: true order_inserts: true
order_updates: true order_updates: true
open-in-view: false open-in-view: false
# Redis 설정 (올바른 구조)
data:
redis: # Redis 설정 (올바른 구조)
host: ${REDIS_HOST:localhost} data:
port: ${REDIS_PORT:6379} redis:
password: ${REDIS_PASSWORD:} host: ${REDIS_HOST:localhost}
timeout: 2000ms port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0} password: ${REDIS_PASSWORD:}
lettuce: timeout: 2000ms
pool: database: ${REDIS_DATABASE:0}
max-active: ${REDIS_POOL_MAX_ACTIVE:8} lettuce:
max-idle: ${REDIS_POOL_MAX_IDLE:8} pool:
min-idle: ${REDIS_POOL_MIN_IDLE:2} max-active: ${REDIS_POOL_MAX_ACTIVE:8}
max-wait: -1ms max-idle: ${REDIS_POOL_MAX_IDLE:8}
shutdown-timeout: 100ms min-idle: ${REDIS_POOL_MIN_IDLE:2}
max-wait: -1ms
# 외부 서비스 URL 설정 shutdown-timeout: 100ms
services:
store: # 외부 서비스 URL 설정
url: ${STORE_SERVICE_URL:http://store-service:8082} services:
review: store:
url: ${REVIEW_SERVICE_URL:http://review-service:8083} url: ${STORE_SERVICE_URL:http://store-service:8082}
member: review:
url: ${MEMBER_SERVICE_URL:http://member-service:8081} url: ${REVIEW_SERVICE_URL:http://review-service:8083}
member:
# Feign 설정 url: ${MEMBER_SERVICE_URL:http://member-service:8081}
feign:
client: # Feign 설정
config: feign:
default: client:
connectTimeout: 5000 config:
readTimeout: 10000 default:
loggerLevel: basic connectTimeout: 5000
store-service: readTimeout: 10000
connectTimeout: 3000 loggerLevel: basic
readTimeout: 8000 store-service:
review-service: connectTimeout: 3000
connectTimeout: 3000 readTimeout: 8000
readTimeout: 8000 review-service:
circuitbreaker: connectTimeout: 3000
enabled: true readTimeout: 8000
compression: circuitbreaker:
request: enabled: true
enabled: true compression:
response: request:
enabled: true enabled: true
response:
# Circuit Breaker 설정 enabled: true
resilience4j:
circuitbreaker: # Circuit Breaker 설정
instances: resilience4j:
store-service: circuitbreaker:
failure-rate-threshold: 50 instances:
wait-duration-in-open-state: 30000 store-service:
sliding-window-size: 10 failure-rate-threshold: 50
minimum-number-of-calls: 5 wait-duration-in-open-state: 30000
review-service: sliding-window-size: 10
failure-rate-threshold: 50 minimum-number-of-calls: 5
wait-duration-in-open-state: 30000 review-service:
sliding-window-size: 10 failure-rate-threshold: 50
minimum-number-of-calls: 5 wait-duration-in-open-state: 30000
retry: sliding-window-size: 10
instances: minimum-number-of-calls: 5
store-service: retry:
max-attempts: 3 instances:
wait-duration: 1000 store-service:
review-service: max-attempts: 3
max-attempts: 3 wait-duration: 1000
wait-duration: 1000 review-service:
max-attempts: 3
wait-duration: 1000
# Actuator 설정
management:
endpoints: # Actuator 설정
web: management:
exposure: endpoints:
include: health,info,metrics,prometheus web:
endpoint: exposure:
health: include: health,info,metrics,prometheus
show-details: always endpoint:
metrics: health:
export: show-details: always
prometheus: metrics:
export:
# Swagger/OpenAPI 설정 prometheus:
springdoc:
api-docs: # Swagger/OpenAPI 설정
path: /api-docs springdoc:
swagger-ui: api-docs:
path: /swagger-ui.html path: /api-docs
tags-sorter: alpha swagger-ui:
operations-sorter: alpha path: /swagger-ui.html
display-request-duration: true tags-sorter: alpha
display-operation-id: true operations-sorter: alpha
show-actuator: false display-request-duration: true
display-operation-id: true
# 로깅 설정 show-actuator: false
logging:
level: # 로깅 설정
root: ${LOG_LEVEL_ROOT:INFO} logging:
com.ktds.hi.recommend: ${LOG_LEVEL:INFO} level:
org.springframework.cloud.openfeign: ${LOG_LEVEL_FEIGN:DEBUG} root: ${LOG_LEVEL_ROOT:INFO}
org.springframework.web: ${LOG_LEVEL_WEB:INFO} com.ktds.hi.recommend: ${LOG_LEVEL:INFO}
org.springframework.data.redis: ${LOG_LEVEL_REDIS:INFO} org.springframework.cloud.openfeign: ${LOG_LEVEL_FEIGN:DEBUG}
org.hibernate.SQL: ${LOG_LEVEL_SQL:INFO} org.springframework.web: ${LOG_LEVEL_WEB:INFO}
org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_LEVEL_SQL_PARAM:INFO} org.springframework.data.redis: ${LOG_LEVEL_REDIS:INFO}
pattern: org.hibernate.SQL: ${LOG_LEVEL_SQL:INFO}
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n" org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_LEVEL_SQL_PARAM:INFO}
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n" pattern:
file: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n"
name: ${LOG_FILE_PATH:./logs/recommend-service.log} file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n"
max-size: 100MB file:
max-history: 30 name: ${LOG_FILE_PATH:./logs/recommend-service.log}
max-size: 100MB
# Security 설정 max-history: 30
security:
jwt: # Security 설정
secret: ${JWT_SECRET:hiorder-recommend-secret-key-2024} security:
expiration: ${JWT_EXPIRATION:86400000} # 24시간 jwt:
cors: secret: ${JWT_SECRET:hiorder-recommend-secret-key-2024}
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080} expiration: ${JWT_EXPIRATION:86400000} # 24시간
allowed-methods: GET,POST,PUT,DELETE,OPTIONS cors:
allowed-headers: "*" allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
allow-credentials: true allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: "*"
recommend: allow-credentials: true
cache:
recommendation-ttl: ${RECOMMENDATION_CACHE_TTL:1800} recommend:
user-preference-ttl: ${USER_PREFERENCE_CACHE_TTL:3600} cache:
algorithm: recommendation-ttl: ${RECOMMENDATION_CACHE_TTL:1800}
max-recommendations: ${MAX_RECOMMENDATIONS:20} user-preference-ttl: ${USER_PREFERENCE_CACHE_TTL:3600}
default-radius: ${DEFAULT_SEARCH_RADIUS:5000} algorithm:
max-radius: ${MAX_SEARCH_RADIUS:10000} max-recommendations: ${MAX_RECOMMENDATIONS:20}
--- default-radius: ${DEFAULT_SEARCH_RADIUS:5000}
# Local 환경 설정 max-radius: ${MAX_SEARCH_RADIUS:10000}
spring: ---
config: # Local 환경 설정
activate: spring:
on-profile: local config:
jpa: activate:
show-sql: true on-profile: local
hibernate: jpa:
ddl-auto: create-drop show-sql: true
hibernate:
logging: ddl-auto: create-drop
level:
com.ktds.hi.recommend: DEBUG logging:
org.springframework.web: DEBUG level:
com.ktds.hi.recommend: DEBUG
--- org.springframework.web: DEBUG
# Development 환경 설정
spring: ---
config: # Development 환경 설정
activate: spring:
on-profile: dev config:
jpa: activate:
show-sql: true on-profile: dev
hibernate: jpa:
ddl-auto: update show-sql: true
hibernate:
logging: ddl-auto: update
level:
com.ktds.hi.recommend: DEBUG logging:
level:
--- com.ktds.hi.recommend: DEBUG
# Production 환경 설정
spring: ---
config: # Production 환경 설정
activate: spring:
on-profile: prod config:
jpa: activate:
show-sql: false on-profile: prod
hibernate: jpa:
ddl-auto: validate show-sql: false
hibernate:
logging: ddl-auto: validate
level:
root: WARN logging:
com.ktds.hi.recommend: INFO level:
root: WARN
com.ktds.hi.recommend: INFO
org.springframework.cloud.openfeign: INFO org.springframework.cloud.openfeign: INFO

View File

@ -1,143 +1,143 @@
package com.ktds.hi.review.biz.service; package com.ktds.hi.review.biz.service;
import com.ktds.hi.review.biz.usecase.in.CreateReviewUseCase; 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.DeleteReviewUseCase;
import com.ktds.hi.review.biz.usecase.in.GetReviewUseCase; import com.ktds.hi.review.biz.usecase.in.GetReviewUseCase;
import com.ktds.hi.review.biz.usecase.out.ReviewRepository; import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
import com.ktds.hi.review.biz.domain.Review; import com.ktds.hi.review.biz.domain.Review;
import com.ktds.hi.review.biz.domain.ReviewStatus; import com.ktds.hi.review.biz.domain.ReviewStatus;
import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest; import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest;
import com.ktds.hi.review.infra.dto.response.*; import com.ktds.hi.review.infra.dto.response.*;
import com.ktds.hi.common.exception.BusinessException; import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 리뷰 인터랙터 클래스 * 리뷰 인터랙터 클래스
* 리뷰 생성, 조회, 삭제 기능을 구현 * 리뷰 생성, 조회, 삭제 기능을 구현
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@Transactional @Transactional
public class ReviewInteractor implements CreateReviewUseCase, DeleteReviewUseCase, GetReviewUseCase { public class ReviewInteractor implements CreateReviewUseCase, DeleteReviewUseCase, GetReviewUseCase {
private final ReviewRepository reviewRepository; private final ReviewRepository reviewRepository;
@Override @Override
public ReviewCreateResponse createReview(Long memberId, ReviewCreateRequest request) { public ReviewCreateResponse createReview(Long memberId, ReviewCreateRequest request) {
// 리뷰 생성 // 리뷰 생성
Review review = Review.builder() Review review = Review.builder()
.storeId(request.getStoreId()) .storeId(request.getStoreId())
.memberId(memberId) .memberId(memberId)
.memberNickname("회원" + memberId) // TODO: 회원 서비스에서 닉네임 조회 .memberNickname("회원" + memberId) // TODO: 회원 서비스에서 닉네임 조회
.rating(request.getRating()) .rating(request.getRating())
.content(request.getContent()) .content(request.getContent())
.imageUrls(request.getImageUrls()) .imageUrls(request.getImageUrls())
.status(ReviewStatus.ACTIVE) .status(ReviewStatus.ACTIVE)
.likeCount(0) .likeCount(0)
.dislikeCount(0) .dislikeCount(0)
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now())
.build(); .build();
Review savedReview = reviewRepository.saveReview(review); Review savedReview = reviewRepository.saveReview(review);
log.info("리뷰 생성 완료: reviewId={}, storeId={}, memberId={}", log.info("리뷰 생성 완료: reviewId={}, storeId={}, memberId={}",
savedReview.getId(), savedReview.getStoreId(), savedReview.getMemberId()); savedReview.getId(), savedReview.getStoreId(), savedReview.getMemberId());
return ReviewCreateResponse.builder() return ReviewCreateResponse.builder()
.reviewId(savedReview.getId()) .reviewId(savedReview.getId())
.message("리뷰가 성공적으로 등록되었습니다") .message("리뷰가 성공적으로 등록되었습니다")
.build(); .build();
} }
@Override @Override
public ReviewDeleteResponse deleteReview(Long reviewId, Long memberId) { public ReviewDeleteResponse deleteReview(Long reviewId, Long memberId) {
Review review = reviewRepository.findReviewByIdAndMemberId(reviewId, memberId) Review review = reviewRepository.findReviewByIdAndMemberId(reviewId, memberId)
.orElseThrow(() -> new BusinessException("리뷰를 찾을 수 없거나 권한이 없습니다")); .orElseThrow(() -> new BusinessException("리뷰를 찾을 수 없거나 권한이 없습니다"));
reviewRepository.deleteReview(reviewId); reviewRepository.deleteReview(reviewId);
log.info("리뷰 삭제 완료: reviewId={}, memberId={}", reviewId, memberId); log.info("리뷰 삭제 완료: reviewId={}, memberId={}", reviewId, memberId);
return ReviewDeleteResponse.builder() return ReviewDeleteResponse.builder()
.success(true) .success(true)
.message("리뷰가 삭제되었습니다") .message("리뷰가 삭제되었습니다")
.build(); .build();
} }
@Override @Override
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size) { public List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size) {
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20); Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
Page<Review> reviews = reviewRepository.findReviewsByStoreId(storeId, pageable); Page<Review> reviews = reviewRepository.findReviewsByStoreId(storeId, pageable);
return reviews.stream() return reviews.stream()
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE) .filter(review -> review.getStatus() == ReviewStatus.ACTIVE)
.map(review -> ReviewListResponse.builder() .map(review -> ReviewListResponse.builder()
.reviewId(review.getId()) .reviewId(review.getId())
.memberNickname(review.getMemberNickname()) .memberNickname(review.getMemberNickname())
.rating(review.getRating()) .rating(review.getRating())
.content(review.getContent()) .content(review.getContent())
.imageUrls(review.getImageUrls()) .imageUrls(review.getImageUrls())
.likeCount(review.getLikeCount()) .likeCount(review.getLikeCount())
.dislikeCount(review.getDislikeCount()) .dislikeCount(review.getDislikeCount())
.createdAt(review.getCreatedAt()) .createdAt(review.getCreatedAt())
.build()) .build())
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override @Override
@Transactional(readOnly = true) @Transactional(readOnly = true)
public ReviewDetailResponse getReviewDetail(Long reviewId) { public ReviewDetailResponse getReviewDetail(Long reviewId) {
Review review = reviewRepository.findReviewById(reviewId) Review review = reviewRepository.findReviewById(reviewId)
.orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다")); .orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다"));
if (review.getStatus() != ReviewStatus.ACTIVE) { if (review.getStatus() != ReviewStatus.ACTIVE) {
throw new BusinessException("삭제되었거나 숨겨진 리뷰입니다"); throw new BusinessException("삭제되었거나 숨겨진 리뷰입니다");
} }
return ReviewDetailResponse.builder() return ReviewDetailResponse.builder()
.reviewId(review.getId()) .reviewId(review.getId())
.storeId(review.getStoreId()) .storeId(review.getStoreId())
.memberNickname(review.getMemberNickname()) .memberNickname(review.getMemberNickname())
.rating(review.getRating()) .rating(review.getRating())
.content(review.getContent()) .content(review.getContent())
.imageUrls(review.getImageUrls()) .imageUrls(review.getImageUrls())
.likeCount(review.getLikeCount()) .likeCount(review.getLikeCount())
.dislikeCount(review.getDislikeCount()) .dislikeCount(review.getDislikeCount())
.createdAt(review.getCreatedAt()) .createdAt(review.getCreatedAt())
.build(); .build();
} }
@Override @Override
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ReviewListResponse> getMyReviews(Long memberId, Integer page, Integer size) { public List<ReviewListResponse> getMyReviews(Long memberId, Integer page, Integer size) {
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20); Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
Page<Review> reviews = reviewRepository.findReviewsByMemberId(memberId, pageable); Page<Review> reviews = reviewRepository.findReviewsByMemberId(memberId, pageable);
return reviews.stream() return reviews.stream()
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE) .filter(review -> review.getStatus() == ReviewStatus.ACTIVE)
.map(review -> ReviewListResponse.builder() .map(review -> ReviewListResponse.builder()
.reviewId(review.getId()) .reviewId(review.getId())
.memberNickname(review.getMemberNickname()) .memberNickname(review.getMemberNickname())
.rating(review.getRating()) .rating(review.getRating())
.content(review.getContent()) .content(review.getContent())
.imageUrls(review.getImageUrls()) .imageUrls(review.getImageUrls())
.likeCount(review.getLikeCount()) .likeCount(review.getLikeCount())
.dislikeCount(review.getDislikeCount()) .dislikeCount(review.getDislikeCount())
.createdAt(review.getCreatedAt()) .createdAt(review.getCreatedAt())
.build()) .build())
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
} }

View File

@ -1,50 +1,50 @@
package com.ktds.hi.review.infra.config; package com.ktds.hi.review.infra.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
/** /**
* Analytics 서비스 보안 설정 클래스 * Analytics 서비스 보안 설정 클래스
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정 * 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final CorsConfigurationSource corsConfigurationSource; private final CorsConfigurationSource corsConfigurationSource;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource)) .cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Swagger 관련 경로 모두 허용 // Swagger 관련 경로 모두 허용
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll() .requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
// Analytics API 모두 허용 (테스트용) // Analytics API 모두 허용 (테스트용)
.requestMatchers("/api/analytics/**").permitAll() .requestMatchers("/api/analytics/**").permitAll()
.requestMatchers("/api/action-plans/**").permitAll() .requestMatchers("/api/action-plans/**").permitAll()
// Actuator 엔드포인트 허용 // Actuator 엔드포인트 허용
.requestMatchers("/actuator/**").permitAll() .requestMatchers("/actuator/**").permitAll()
// 기타 모든 요청 허용 (테스트용) // 기타 모든 요청 허용 (테스트용)
.anyRequest().permitAll() .anyRequest().permitAll()
); );
return http.build(); return http.build();
} }
} }

View File

@ -1,34 +1,34 @@
package com.ktds.hi.review.infra.config; package com.ktds.hi.review.infra.config;
import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@Configuration @Configuration
public class SwaggerConfig { public class SwaggerConfig {
@Bean @Bean
public OpenAPI openAPI() { public OpenAPI openAPI() {
final String securitySchemeName = "Bearer Authentication"; final String securitySchemeName = "Bearer Authentication";
return new OpenAPI() return new OpenAPI()
.addServersItem(new Server().url("/")) .addServersItem(new Server().url("/"))
.info(new Info() .info(new Info()
.title("하이오더 리뷰 관리 서비스 API") .title("하이오더 리뷰 관리 서비스 API")
.description("리뷰 작성, 조회, 삭제, 반응, 댓글 등 리뷰 관련 기능을 제공하는 API") .description("리뷰 작성, 조회, 삭제, 반응, 댓글 등 리뷰 관련 기능을 제공하는 API")
.version("1.0.0")) .version("1.0.0"))
.addSecurityItem(new SecurityRequirement() .addSecurityItem(new SecurityRequirement()
.addList(securitySchemeName)) .addList(securitySchemeName))
.components(new Components() .components(new Components()
.addSecuritySchemes(securitySchemeName, new SecurityScheme() .addSecuritySchemes(securitySchemeName, new SecurityScheme()
.name(securitySchemeName) .name(securitySchemeName)
.type(SecurityScheme.Type.HTTP) .type(SecurityScheme.Type.HTTP)
.scheme("bearer") .scheme("bearer")
.bearerFormat("JWT"))); .bearerFormat("JWT")));
} }
} }

View File

@ -1,42 +1,42 @@
server: server:
port: ${REVIEW_SERVICE_PORT:8083} port: ${REVIEW_SERVICE_PORT:8083}
spring: spring:
application: application:
name: review-service name: review-service
datasource: datasource:
url: ${REVIEW_DB_URL:jdbc:postgresql://20.214.91.15:5432/hiorder_review} url: ${REVIEW_DB_URL:jdbc:postgresql://20.214.91.15:5432/hiorder_review}
username: ${REVIEW_DB_USERNAME:hiorder_user} username: ${REVIEW_DB_USERNAME:hiorder_user}
password: ${REVIEW_DB_PASSWORD:hiorder_pass} password: ${REVIEW_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false} show-sql: ${JPA_SHOW_SQL:false}
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
data: data:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
servlet: servlet:
multipart: multipart:
max-file-size: ${MAX_FILE_SIZE:10MB} max-file-size: ${MAX_FILE_SIZE:10MB}
max-request-size: ${MAX_REQUEST_SIZE:50MB} max-request-size: ${MAX_REQUEST_SIZE:50MB}
file-storage: file-storage:
base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads} base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads}
allowed-extensions: jpg,jpeg,png,gif,webp allowed-extensions: jpg,jpeg,png,gif,webp
max-file-size: 10485760 # 10MB max-file-size: 10485760 # 10MB
springdoc: springdoc:
api-docs: api-docs:
path: /api-docs path: /api-docs
swagger-ui: swagger-ui:
path: /swagger-ui.html 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; package com.ktds.hi.store.biz.usecase.out;
import java.time.Duration; import java.util.List;
import java.util.Optional;
/**
/** * 캐시 포트 인터페이스
* 캐시 포트 인터페이스 */
* 캐시 기능을 정의 public interface CachePort {
*/
public interface CachePort { /**
* 캐시에서 매장 정보 조회
/** */
* 캐시에서 매장 데이터 조회 <T> T getStoreCache(String key);
*/
Optional<Object> getStoreCache(String key); /**
* 캐시에 매장 정보 저장
/** */
* 캐시에 매장 데이터 저장 void putStoreCache(String key, Object value, long ttlSeconds);
*/
void putStoreCache(String key, Object value, Duration ttl); /**
* 캐시 무효화
/** */
* 캐시 무효화 void invalidateStoreCache(Object key);
*/ }
void invalidateStoreCache(Long storeId);
}

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; 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.List;
import java.util.Optional; 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; package com.ktds.hi.store.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
/** /**
* Analytics 서비스 보안 설정 클래스 * Analytics 서비스 보안 설정 클래스
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정 * 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final CorsConfigurationSource corsConfigurationSource; private final CorsConfigurationSource corsConfigurationSource;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource)) .cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Swagger 관련 경로 모두 허용 // Swagger 관련 경로 모두 허용
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll() .requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
// Analytics API 모두 허용 (테스트용) // Analytics API 모두 허용 (테스트용)
.requestMatchers("/api/analytics/**").permitAll() .requestMatchers("/api/analytics/**").permitAll()
.requestMatchers("/api/action-plans/**").permitAll() .requestMatchers("/api/action-plans/**").permitAll()
// Actuator 엔드포인트 허용 // Actuator 엔드포인트 허용
.requestMatchers("/actuator/**").permitAll() .requestMatchers("/actuator/**").permitAll()
// 기타 모든 요청 허용 (테스트용) // 기타 모든 요청 허용 (테스트용)
.anyRequest().permitAll() .anyRequest().permitAll()
); );
return http.build(); 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.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 메뉴 도메인 클래스 * 메뉴 도메인 엔티티
* 메뉴 정보를 담는 도메인 객체
*
* @author 하이오더 개발팀
* @version 1.0.0
*/ */
@Getter @Getter
@Builder @Builder
@NoArgsConstructor
@AllArgsConstructor
public class Menu { public class Menu {
private Long id; private Long id;
@ -27,164 +18,33 @@ public class Menu {
private Integer price; private Integer price;
private String category; private String category;
private String imageUrl; private String imageUrl;
private Boolean isAvailable; private Boolean available;
private Integer orderCount; private LocalDateTime createdAt; // 추가
private LocalDateTime createdAt; private LocalDateTime updatedAt; // 추가
private LocalDateTime updatedAt;
/** /**
* 메뉴 기본 정보 업데이트 * 메뉴 정보 업데이트
*/ */
public Menu updateInfo(String menuName, String description, Integer price) { public void updateMenuInfo(String menuName, String description, Integer price,
return Menu.builder() String category, String imageUrl) {
.id(this.id) this.menuName = menuName;
.storeId(this.storeId) this.description = description;
.menuName(menuName) this.price = price;
.description(description) this.category = category;
.price(price) this.imageUrl = imageUrl;
.category(this.category)
.imageUrl(this.imageUrl)
.isAvailable(this.isAvailable)
.orderCount(this.orderCount)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
} }
/** /**
* 메뉴 이미지 업데이트 * 메뉴 판매 상태 변경
*/ */
public Menu updateImage(String imageUrl) { public void setAvailable(Boolean available) {
return Menu.builder() this.available = available;
.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 Menu setAvailable(Boolean available) { public boolean isAvailable() {
return Menu.builder() return this.available != null && this.available;
.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);
} }
} }

View File

@ -1,24 +1,20 @@
// store/src/main/java/com/ktds/hi/store/biz/domain/Store.java
package com.ktds.hi.store.domain; package com.ktds.hi.store.domain;
import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
/** /**
* 매장 도메인 클래스 * 매장 도메인 엔티티
* 매장 정보를 담는 도메인 객체 * Clean Architecture의 Domain Layer
* *
* @author 하이오더 개발팀 * @author 하이오더 개발팀
* @version 1.0.0 * @version 1.0.0
*/ */
@Getter @Getter
@Builder @Builder
@NoArgsConstructor
@AllArgsConstructor
public class Store { public class Store {
private Long id; private Long id;
@ -27,160 +23,89 @@ public class Store {
private String address; private String address;
private Double latitude; private Double latitude;
private Double longitude; private Double longitude;
private String category;
private String description; private String description;
private String phone; private String phone;
private String operatingHours; private String operatingHours;
private List<String> tags; private String category;
private StoreStatus status;
private Double rating; private Double rating;
private Integer reviewCount; private Integer reviewCount;
private String imageUrl; private StoreStatus status;
private List<String> tags; // 추가
private String imageUrl; // 추가
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** /**
* 매장 기본 정보 업데이트 * 매장 기본 정보 업데이트
*/ */
public Store updateBasicInfo(String storeName, String address, String description, public void updateBasicInfo(String storeName, String address, String description,
String phone, String operatingHours) { String phone, String operatingHours) {
return Store.builder() this.storeName = storeName;
.id(this.id) this.address = address;
.ownerId(this.ownerId) this.description = description;
.storeName(storeName) this.phone = phone;
.address(address) this.operatingHours = operatingHours;
.latitude(this.latitude) this.updatedAt = LocalDateTime.now();
.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 Store updateLocation(Double latitude, Double longitude) { public void updateLocation(Coordinates coordinates) {
return Store.builder() this.latitude = coordinates.getLatitude();
.id(this.id) this.longitude = coordinates.getLongitude();
.ownerId(this.ownerId) this.updatedAt = LocalDateTime.now();
.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 Store updateRating(Double rating, Integer reviewCount) { public void updateRating(Double rating, Integer reviewCount) {
return Store.builder() this.rating = rating;
.id(this.id) this.reviewCount = reviewCount;
.ownerId(this.ownerId) this.updatedAt = LocalDateTime.now();
.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 Store activate() { public void activate() {
return Store.builder() this.status = StoreStatus.ACTIVE;
.id(this.id) this.updatedAt = LocalDateTime.now();
.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 Store deactivate() { public void deactivate() {
return Store.builder() this.status = StoreStatus.INACTIVE;
.id(this.id) this.updatedAt = LocalDateTime.now();
.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 delete() {
this.status = StoreStatus.DELETED;
this.updatedAt = LocalDateTime.now();
}
/**
* 활성 상태 확인
*/ */
public boolean isActive() { public boolean isActive() {
return StoreStatus.ACTIVE.equals(this.status); return this.status == StoreStatus.ACTIVE;
} }
/** /**
* 매장 소유권 확인 * 점주 소유 확인
*/ */
public boolean isOwnedBy(Long ownerId) { 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) { public Double calculateDistance(Double targetLatitude, Double targetLongitude) {
if (this.latitude == null || this.longitude == null || if (this.latitude == null || this.longitude == null ||
@ -188,17 +113,18 @@ public class Store {
return null; return null;
} }
final int EARTH_RADIUS = 6371; // 지구 반지름 (킬로미터) // Haversine 공식을 사용한 거리 계산
double earthRadius = 6371; // 지구 반지름 (km)
double latDistance = Math.toRadians(targetLatitude - this.latitude); double dLat = Math.toRadians(targetLatitude - this.latitude);
double lonDistance = Math.toRadians(targetLongitude - this.longitude); double dLon = Math.toRadians(targetLongitude - this.longitude);
double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2) double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(Math.toRadians(this.latitude)) * Math.cos(Math.toRadians(targetLatitude)) Math.cos(Math.toRadians(this.latitude)) * Math.cos(Math.toRadians(targetLatitude)) *
* Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 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 { public enum StoreStatus {
/**
* 활성 상태 - 정상 운영
*/
ACTIVE("활성"), ACTIVE("활성"),
/**
* 비활성 상태 - 임시 휴업
*/
INACTIVE("비활성"), INACTIVE("비활성"),
DELETED("삭제됨"),
/** PENDING("승인대기");
* 일시 정지 상태 - 관리자에 의한 일시 정지
*/
SUSPENDED("일시정지"),
/**
* 삭제 상태 - 영구 삭제 (소프트 삭제)
*/
DELETED("삭제");
private final String description; private final String description;
@ -44,27 +24,13 @@ public enum StoreStatus {
*/ */
public static StoreStatus fromString(String status) { public static StoreStatus fromString(String status) {
if (status == null) { if (status == null) {
return INACTIVE; return ACTIVE; // 기본값
} }
try { try {
return StoreStatus.valueOf(status.toUpperCase()); return StoreStatus.valueOf(status.toUpperCase());
} catch (IllegalArgumentException e) { } 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; package com.ktds.hi.store.infra.dto;
import lombok.AllArgsConstructor; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 삭제 응답 DTO /**
*/ * 매장 삭제 응답 DTO
@Getter */
@Builder @Getter
@NoArgsConstructor @Builder
@AllArgsConstructor @NoArgsConstructor
public class StoreDeleteResponse { @AllArgsConstructor
@Schema(description = "매장 삭제 응답")
private Boolean success; public class StoreDeleteResponse {
private String message;
} @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; package com.ktds.hi.store.infra.dto;
import lombok.AllArgsConstructor; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 수정 응답 DTO /**
*/ * 매장 수정 응답 DTO
@Getter */
@Builder @Getter
@NoArgsConstructor @Builder
@AllArgsConstructor @NoArgsConstructor
public class StoreUpdateResponse { @AllArgsConstructor
@Schema(description = "매장 수정 응답")
private Boolean success; public class StoreUpdateResponse {
private String message;
} @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.time.Duration;
import java.util.Optional; 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(); return Optional.empty();
} }
} }
@Override @Override
public void putStoreCache(String key, Object value, Duration ttl) { public void putStoreCache(String key, Object value, long ttlSeconds) {
try { try {
redisTemplate.opsForValue().set(key, value, ttl); redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
log.debug("매장 캐시 저장 완료: key={}, ttl={}분", key, ttl.toMinutes()); log.debug("캐시 저장: key={}, ttl={}초", key, ttlSeconds);
} catch (Exception e) { } catch (Exception e) {
log.error("매장 캐시 저장 실패: key={}, error={}", key, e.getMessage()); log.warn("캐시 저장 실패: key={}, error={}", key, e.getMessage());
} }
} }
@Override @Override
public void invalidateStoreCache(Long storeId) { public void invalidateStoreCache(Object key) {
try { try {
// 매장 관련 모든 캐시 패턴 삭제 if (key instanceof Long) {
String storeDetailKey = "store_detail:" + storeId; // 매장 ID로 특정 매장 캐시 삭제
String myStoresKey = "my_stores:*"; Long storeId = (Long) key;
String storeDetailKey = "store_detail:" + storeId;
redisTemplate.delete(storeDetailKey); redisTemplate.delete(storeDetailKey);
log.debug("매장 캐시 무효화 완료: storeId={}", storeId);
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) { } 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; 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.biz.usecase.out.MenuRepositoryPort;
import com.ktds.hi.store.infra.gateway.entity.MenuEntity; import com.ktds.hi.store.infra.gateway.entity.MenuEntity;
import com.ktds.hi.store.infra.gateway.repository.MenuJpaRepository; import com.ktds.hi.store.infra.gateway.repository.MenuJpaRepository;
@ -96,7 +96,7 @@ public class MenuRepositoryAdapter implements MenuRepositoryPort {
.price(entity.getPrice()) .price(entity.getPrice())
.category(entity.getCategory()) .category(entity.getCategory())
.imageUrl(entity.getImageUrl()) .imageUrl(entity.getImageUrl())
.isAvailable(entity.getIsAvailable()) .available(entity.getIsAvailable())
.createdAt(entity.getCreatedAt()) .createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt()) .updatedAt(entity.getUpdatedAt())
.build(); .build();
@ -114,7 +114,7 @@ public class MenuRepositoryAdapter implements MenuRepositoryPort {
.price(domain.getPrice()) .price(domain.getPrice())
.category(domain.getCategory()) .category(domain.getCategory())
.imageUrl(domain.getImageUrl()) .imageUrl(domain.getImageUrl())
.isAvailable(domain.getIsAvailable()) .isAvailable(domain.isAvailable())
.createdAt(domain.getCreatedAt()) .createdAt(domain.getCreatedAt())
.updatedAt(domain.getUpdatedAt()) .updatedAt(domain.getUpdatedAt())
.build(); .build();

View File

@ -119,6 +119,19 @@ public class StoreEntity {
this.reviewCount = reviewCount; 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: server:
port: ${STORE_SERVICE_PORT:8082} port: ${STORE_SERVICE_PORT:8082}
spring: spring:
application: application:
name: store-service name: store-service
datasource: datasource:
url: ${STORE_DB_URL:jdbc:postgresql://20.249.154.116:5432/hiorder_store} url: ${STORE_DB_URL:jdbc:postgresql://20.249.154.116:5432/hiorder_store}
username: ${STORE_DB_USERNAME:hiorder_user} username: ${STORE_DB_USERNAME:hiorder_user}
password: ${STORE_DB_PASSWORD:hiorder_pass} password: ${STORE_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false} show-sql: ${JPA_SHOW_SQL:false}
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
data: data:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
external-api: external-api:
naver: naver:
client-id: ${NAVER_CLIENT_ID:} client-id: ${NAVER_CLIENT_ID:}
client-secret: ${NAVER_CLIENT_SECRET:} client-secret: ${NAVER_CLIENT_SECRET:}
base-url: https://openapi.naver.com base-url: https://openapi.naver.com
kakao: kakao:
api-key: ${KAKAO_API_KEY:} api-key: ${KAKAO_API_KEY:}
base-url: https://dapi.kakao.com base-url: https://dapi.kakao.com
google: google:
api-key: ${GOOGLE_API_KEY:} api-key: ${GOOGLE_API_KEY:}
base-url: https://maps.googleapis.com base-url: https://maps.googleapis.com
hiorder: hiorder:
api-key: ${HIORDER_API_KEY:} api-key: ${HIORDER_API_KEY:}
base-url: ${HIORDER_BASE_URL:https://api.hiorder.com} base-url: ${HIORDER_BASE_URL:https://api.hiorder.com}
springdoc: springdoc:
api-docs: api-docs:
path: /api-docs path: /api-docs
swagger-ui: swagger-ui:
path: /swagger-ui.html path: /swagger-ui.html