# 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
41 changed files with 2821 additions and 2148 deletions
@@ -1,402 +1,402 @@
package com.ktds.hi.analytics.biz.service;
import com.ktds.hi.analytics.biz.domain.Analytics;
import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase;
import com.ktds.hi.analytics.biz.usecase.out.*;
import com.ktds.hi.analytics.infra.dto.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 분석 서비스 구현 클래스 (수정버전)
* Clean Architecture의 UseCase를 구현하여 비즈니스 로직을 처리
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AnalyticsService implements AnalyticsUseCase {
private final AnalyticsPort analyticsPort;
private final AIServicePort aiServicePort;
private final ExternalReviewPort externalReviewPort;
private final OrderDataPort orderDataPort;
private final CachePort cachePort;
private final EventPort eventPort;
@Override
@Cacheable(value = "storeAnalytics", key = "#storeId")
public StoreAnalyticsResponse getStoreAnalytics(Long storeId) {
log.info("매장 분석 데이터 조회 시작: storeId={}", storeId);
try {
// 1. 캐시에서 먼저 확인
String cacheKey = "analytics:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
log.info("캐시에서 분석 데이터 반환: storeId={}", storeId);
return (StoreAnalyticsResponse) cachedResult.get();
}
// 2. 데이터베이스에서 기존 분석 데이터 조회
var analytics = analyticsPort.findAnalyticsByStoreId(storeId);
if (analytics.isEmpty()) {
// 3. 분석 데이터가 없으면 새로 생성
analytics = Optional.of(generateNewAnalytics(storeId));
}
// 4. 응답 생성
StoreAnalyticsResponse response = StoreAnalyticsResponse.builder()
.storeId(storeId)
.totalReviews(analytics.get().getTotalReviews())
.averageRating(analytics.get().getAverageRating())
.sentimentScore(analytics.get().getSentimentScore())
.positiveReviewRate(analytics.get().getPositiveReviewRate())
.negativeReviewRate(analytics.get().getNegativeReviewRate())
.lastAnalysisDate(analytics.get().getLastAnalysisDate())
.build();
// 5. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(1));
log.info("매장 분석 데이터 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("매장 분석 데이터 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("분석 데이터 조회에 실패했습니다.", e);
}
}
// ... 나머지 메서드들은 이전과 동일 ...
@Override
@Cacheable(value = "aiFeedback", key = "#storeId")
public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) {
log.info("AI 피드백 상세 조회 시작: storeId={}", storeId);
try {
// 1. 기존 AI 피드백 조회
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
if (aiFeedback.isEmpty()) {
// 2. AI 피드백이 없으면 새로 생성
aiFeedback = Optional.of(generateAIFeedback(storeId));
}
// 3. 응답 생성
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
.storeId(storeId)
.summary(aiFeedback.get().getSummary())
.positivePoints(aiFeedback.get().getPositivePoints())
.improvementPoints(aiFeedback.get().getImprovementPoints())
.recommendations(aiFeedback.get().getRecommendations())
.sentimentAnalysis(aiFeedback.get().getSentimentAnalysis())
.confidenceScore(aiFeedback.get().getConfidenceScore())
.generatedAt(aiFeedback.get().getGeneratedAt())
.build();
log.info("AI 피드백 상세 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("AI 피드백 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("AI 피드백 조회에 실패했습니다.", e);
}
}
// 나머지 메서드들과 private 메서드들은 이전과 동일하게 구현
// ... (getStoreStatistics, getAIFeedbackSummary, getReviewAnalysis 등)
@Override
public StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate) {
log.info("매장 통계 조회 시작: storeId={}, startDate={}, endDate={}", storeId, startDate, endDate);
try {
// 1. 캐시 키 생성
String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate);
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
return (StoreStatisticsResponse) cachedResult.get();
}
// 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용)
var orderStatistics = orderDataPort.getOrderStatistics(storeId, startDate, endDate);
// 3. 응답 생성
StoreStatisticsResponse response = StoreStatisticsResponse.builder()
.storeId(storeId)
.startDate(startDate)
.endDate(endDate)
.totalOrders(orderStatistics.getTotalOrders())
.totalRevenue(orderStatistics.getTotalRevenue())
.averageOrderValue(orderStatistics.getAverageOrderValue())
.peakHour(orderStatistics.getPeakHour())
.popularMenus(orderStatistics.getPopularMenus())
.customerAgeDistribution(orderStatistics.getCustomerAgeDistribution())
.build();
// 4. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofMinutes(30));
log.info("매장 통계 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("매장 통계 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("매장 통계 조회에 실패했습니다.", e);
}
}
@Override
public AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId) {
log.info("AI 피드백 요약 조회 시작: storeId={}", storeId);
try {
// 1. 캐시에서 확인
String cacheKey = "ai_feedback_summary:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
return (AiFeedbackSummaryResponse) cachedResult.get();
}
// 2. AI 피드백 조회
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
if (aiFeedback.isEmpty()) {
// 3. 피드백이 없으면 기본 응답 생성
AiFeedbackSummaryResponse emptyResponse = AiFeedbackSummaryResponse.builder()
.storeId(storeId)
.hasData(false)
.message("분석할 데이터가 부족합니다.")
.lastUpdated(LocalDateTime.now())
.build();
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
return emptyResponse;
}
// 4. 응답 생성
AiFeedbackSummaryResponse response = AiFeedbackSummaryResponse.builder()
.storeId(storeId)
.hasData(true)
.message("AI 분석이 완료되었습니다.")
.overallScore(aiFeedback.get().getConfidenceScore())
.keyInsight(aiFeedback.get().getSummary())
.priorityRecommendation(getFirstRecommendation(aiFeedback.get()))
.lastUpdated(aiFeedback.get().getUpdatedAt())
.build();
// 5. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(2));
log.info("AI 피드백 요약 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("AI 피드백 요약 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("AI 피드백 요약 조회에 실패했습니다.", e);
}
}
@Override
public ReviewAnalysisResponse getReviewAnalysis(Long storeId) {
log.info("리뷰 분석 조회 시작: storeId={}", storeId);
try {
// 1. 캐시에서 확인
String cacheKey = "review_analysis:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
return (ReviewAnalysisResponse) cachedResult.get();
}
// 2. 최근 리뷰 데이터 조회 (30일)
List<String> recentReviews = externalReviewPort.getRecentReviews(storeId, 30);
if (recentReviews.isEmpty()) {
ReviewAnalysisResponse emptyResponse = ReviewAnalysisResponse.builder()
.storeId(storeId)
.totalReviews(0)
.positiveReviewCount(0)
.negativeReviewCount(0)
.positiveRate(0.0)
.negativeRate(0.0)
.analysisDate(LocalDate.now())
.build();
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
return emptyResponse;
}
// 3. 응답 생성
int positiveCount = countPositiveReviews(recentReviews);
int negativeCount = countNegativeReviews(recentReviews);
int totalCount = recentReviews.size();
ReviewAnalysisResponse response = ReviewAnalysisResponse.builder()
.storeId(storeId)
.totalReviews(totalCount)
.positiveReviewCount(positiveCount)
.negativeReviewCount(negativeCount)
.positiveRate((double) positiveCount / totalCount * 100)
.negativeRate((double) negativeCount / totalCount * 100)
.analysisDate(LocalDate.now())
.build();
// 4. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(4));
log.info("리뷰 분석 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("리뷰 분석 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("리뷰 분석에 실패했습니다.", e);
}
}
// private 메서드들
@Transactional
public Analytics generateNewAnalytics(Long storeId) {
log.info("새로운 분석 데이터 생성 시작: storeId={}", storeId);
try {
// 1. 리뷰 데이터 수집
List<String> reviewData = externalReviewPort.getReviewData(storeId);
int totalReviews = reviewData.size();
if (totalReviews == 0) {
log.warn("리뷰 데이터가 없어 기본값으로 분석 데이터 생성: storeId={}", storeId);
return createDefaultAnalytics(storeId);
}
// 2. 기본 통계 계산
double averageRating = 4.0; // 기본값
double sentimentScore = 0.5; // 중립
double positiveRate = 60.0;
double negativeRate = 20.0;
// 3. Analytics 도메인 객체 생성
Analytics analytics = Analytics.builder()
.storeId(storeId)
.totalReviews(totalReviews)
.averageRating(averageRating)
.sentimentScore(sentimentScore)
.positiveReviewRate(positiveRate)
.negativeReviewRate(negativeRate)
.lastAnalysisDate(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
// 4. 데이터베이스에 저장
Analytics saved = analyticsPort.saveAnalytics(analytics);
log.info("새로운 분석 데이터 생성 완료: storeId={}", storeId);
return saved;
} catch (Exception e) {
log.error("분석 데이터 생성 중 오류 발생: storeId={}", storeId, e);
return createDefaultAnalytics(storeId);
}
}
@Transactional
public AiFeedback generateAIFeedback(Long storeId) {
log.info("AI 피드백 생성 시작: storeId={}", storeId);
try {
// 1. 최근 30일 리뷰 데이터 수집
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, 30);
if (reviewData.isEmpty()) {
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
return createDefaultAIFeedback(storeId);
}
// 2. AI 피드백 생성 (실제로는 AI 서비스 호출)
AiFeedback aiFeedback = AiFeedback.builder()
.storeId(storeId)
.summary("고객들의 전반적인 만족도가 높습니다.")
.positivePoints(List.of("맛이 좋다", "서비스가 친절하다", "분위기가 좋다"))
.improvementPoints(List.of("대기시간 단축", "가격 경쟁력", "메뉴 다양성"))
.recommendations(List.of("특별 메뉴 개발", "예약 시스템 도입", "고객 서비스 교육"))
.sentimentAnalysis("POSITIVE")
.confidenceScore(0.85)
.generatedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
// 3. 데이터베이스에 저장
AiFeedback saved = analyticsPort.saveAIFeedback(aiFeedback);
log.info("AI 피드백 생성 완료: storeId={}", storeId);
return saved;
} catch (Exception e) {
log.error("AI 피드백 생성 중 오류 발생: storeId={}", storeId, e);
return createDefaultAIFeedback(storeId);
}
}
private Analytics createDefaultAnalytics(Long storeId) {
return Analytics.builder()
.storeId(storeId)
.totalReviews(0)
.averageRating(0.0)
.sentimentScore(0.0)
.positiveReviewRate(0.0)
.negativeReviewRate(0.0)
.lastAnalysisDate(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
private AiFeedback createDefaultAIFeedback(Long storeId) {
return AiFeedback.builder()
.storeId(storeId)
.summary("분석할 리뷰 데이터가 부족합니다.")
.positivePoints(List.of("데이터 부족으로 분석 불가"))
.improvementPoints(List.of("리뷰 데이터 수집 필요"))
.recommendations(List.of("고객들의 리뷰 작성을 유도해보세요"))
.sentimentAnalysis("NEUTRAL")
.confidenceScore(0.0)
.generatedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
private String getFirstRecommendation(AiFeedback feedback) {
if (feedback.getRecommendations() != null && !feedback.getRecommendations().isEmpty()) {
return feedback.getRecommendations().get(0);
}
return "추천사항이 없습니다.";
}
private int countPositiveReviews(List<String> reviews) {
// 실제로는 AI 서비스를 통한 감정 분석 필요
return (int) (reviews.size() * 0.6); // 60% 가정
}
private int countNegativeReviews(List<String> reviews) {
// 실제로는 AI 서비스를 통한 감정 분석 필요
return (int) (reviews.size() * 0.2); // 20% 가정
}
}
package com.ktds.hi.analytics.biz.service;
import com.ktds.hi.analytics.biz.domain.Analytics;
import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase;
import com.ktds.hi.analytics.biz.usecase.out.*;
import com.ktds.hi.analytics.infra.dto.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 분석 서비스 구현 클래스 (수정버전)
* Clean Architecture의 UseCase를 구현하여 비즈니스 로직을 처리
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AnalyticsService implements AnalyticsUseCase {
private final AnalyticsPort analyticsPort;
private final AIServicePort aiServicePort;
private final ExternalReviewPort externalReviewPort;
private final OrderDataPort orderDataPort;
private final CachePort cachePort;
private final EventPort eventPort;
@Override
@Cacheable(value = "storeAnalytics", key = "#storeId")
public StoreAnalyticsResponse getStoreAnalytics(Long storeId) {
log.info("매장 분석 데이터 조회 시작: storeId={}", storeId);
try {
// 1. 캐시에서 먼저 확인
String cacheKey = "analytics:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
log.info("캐시에서 분석 데이터 반환: storeId={}", storeId);
return (StoreAnalyticsResponse) cachedResult.get();
}
// 2. 데이터베이스에서 기존 분석 데이터 조회
var analytics = analyticsPort.findAnalyticsByStoreId(storeId);
if (analytics.isEmpty()) {
// 3. 분석 데이터가 없으면 새로 생성
analytics = Optional.of(generateNewAnalytics(storeId));
}
// 4. 응답 생성
StoreAnalyticsResponse response = StoreAnalyticsResponse.builder()
.storeId(storeId)
.totalReviews(analytics.get().getTotalReviews())
.averageRating(analytics.get().getAverageRating())
.sentimentScore(analytics.get().getSentimentScore())
.positiveReviewRate(analytics.get().getPositiveReviewRate())
.negativeReviewRate(analytics.get().getNegativeReviewRate())
.lastAnalysisDate(analytics.get().getLastAnalysisDate())
.build();
// 5. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(1));
log.info("매장 분석 데이터 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("매장 분석 데이터 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("분석 데이터 조회에 실패했습니다.", e);
}
}
// ... 나머지 메서드들은 이전과 동일 ...
@Override
@Cacheable(value = "aiFeedback", key = "#storeId")
public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) {
log.info("AI 피드백 상세 조회 시작: storeId={}", storeId);
try {
// 1. 기존 AI 피드백 조회
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
if (aiFeedback.isEmpty()) {
// 2. AI 피드백이 없으면 새로 생성
aiFeedback = Optional.of(generateAIFeedback(storeId));
}
// 3. 응답 생성
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
.storeId(storeId)
.summary(aiFeedback.get().getSummary())
.positivePoints(aiFeedback.get().getPositivePoints())
.improvementPoints(aiFeedback.get().getImprovementPoints())
.recommendations(aiFeedback.get().getRecommendations())
.sentimentAnalysis(aiFeedback.get().getSentimentAnalysis())
.confidenceScore(aiFeedback.get().getConfidenceScore())
.generatedAt(aiFeedback.get().getGeneratedAt())
.build();
log.info("AI 피드백 상세 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("AI 피드백 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("AI 피드백 조회에 실패했습니다.", e);
}
}
// 나머지 메서드들과 private 메서드들은 이전과 동일하게 구현
// ... (getStoreStatistics, getAIFeedbackSummary, getReviewAnalysis 등)
@Override
public StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate) {
log.info("매장 통계 조회 시작: storeId={}, startDate={}, endDate={}", storeId, startDate, endDate);
try {
// 1. 캐시 키 생성
String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate);
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
return (StoreStatisticsResponse) cachedResult.get();
}
// 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용)
var orderStatistics = orderDataPort.getOrderStatistics(storeId, startDate, endDate);
// 3. 응답 생성
StoreStatisticsResponse response = StoreStatisticsResponse.builder()
.storeId(storeId)
.startDate(startDate)
.endDate(endDate)
.totalOrders(orderStatistics.getTotalOrders())
.totalRevenue(orderStatistics.getTotalRevenue())
.averageOrderValue(orderStatistics.getAverageOrderValue())
.peakHour(orderStatistics.getPeakHour())
.popularMenus(orderStatistics.getPopularMenus())
.customerAgeDistribution(orderStatistics.getCustomerAgeDistribution())
.build();
// 4. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofMinutes(30));
log.info("매장 통계 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("매장 통계 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("매장 통계 조회에 실패했습니다.", e);
}
}
@Override
public AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId) {
log.info("AI 피드백 요약 조회 시작: storeId={}", storeId);
try {
// 1. 캐시에서 확인
String cacheKey = "ai_feedback_summary:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
return (AiFeedbackSummaryResponse) cachedResult.get();
}
// 2. AI 피드백 조회
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
if (aiFeedback.isEmpty()) {
// 3. 피드백이 없으면 기본 응답 생성
AiFeedbackSummaryResponse emptyResponse = AiFeedbackSummaryResponse.builder()
.storeId(storeId)
.hasData(false)
.message("분석할 데이터가 부족합니다.")
.lastUpdated(LocalDateTime.now())
.build();
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
return emptyResponse;
}
// 4. 응답 생성
AiFeedbackSummaryResponse response = AiFeedbackSummaryResponse.builder()
.storeId(storeId)
.hasData(true)
.message("AI 분석이 완료되었습니다.")
.overallScore(aiFeedback.get().getConfidenceScore())
.keyInsight(aiFeedback.get().getSummary())
.priorityRecommendation(getFirstRecommendation(aiFeedback.get()))
.lastUpdated(aiFeedback.get().getUpdatedAt())
.build();
// 5. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(2));
log.info("AI 피드백 요약 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("AI 피드백 요약 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("AI 피드백 요약 조회에 실패했습니다.", e);
}
}
@Override
public ReviewAnalysisResponse getReviewAnalysis(Long storeId) {
log.info("리뷰 분석 조회 시작: storeId={}", storeId);
try {
// 1. 캐시에서 확인
String cacheKey = "review_analysis:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
return (ReviewAnalysisResponse) cachedResult.get();
}
// 2. 최근 리뷰 데이터 조회 (30일)
List<String> recentReviews = externalReviewPort.getRecentReviews(storeId, 30);
if (recentReviews.isEmpty()) {
ReviewAnalysisResponse emptyResponse = ReviewAnalysisResponse.builder()
.storeId(storeId)
.totalReviews(0)
.positiveReviewCount(0)
.negativeReviewCount(0)
.positiveRate(0.0)
.negativeRate(0.0)
.analysisDate(LocalDate.now())
.build();
cachePort.putAnalyticsCache(cacheKey, emptyResponse, java.time.Duration.ofHours(1));
return emptyResponse;
}
// 3. 응답 생성
int positiveCount = countPositiveReviews(recentReviews);
int negativeCount = countNegativeReviews(recentReviews);
int totalCount = recentReviews.size();
ReviewAnalysisResponse response = ReviewAnalysisResponse.builder()
.storeId(storeId)
.totalReviews(totalCount)
.positiveReviewCount(positiveCount)
.negativeReviewCount(negativeCount)
.positiveRate((double) positiveCount / totalCount * 100)
.negativeRate((double) negativeCount / totalCount * 100)
.analysisDate(LocalDate.now())
.build();
// 4. 캐시에 저장
cachePort.putAnalyticsCache(cacheKey, response, java.time.Duration.ofHours(4));
log.info("리뷰 분석 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("리뷰 분석 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("리뷰 분석에 실패했습니다.", e);
}
}
// private 메서드들
@Transactional
public Analytics generateNewAnalytics(Long storeId) {
log.info("새로운 분석 데이터 생성 시작: storeId={}", storeId);
try {
// 1. 리뷰 데이터 수집
List<String> reviewData = externalReviewPort.getReviewData(storeId);
int totalReviews = reviewData.size();
if (totalReviews == 0) {
log.warn("리뷰 데이터가 없어 기본값으로 분석 데이터 생성: storeId={}", storeId);
return createDefaultAnalytics(storeId);
}
// 2. 기본 통계 계산
double averageRating = 4.0; // 기본값
double sentimentScore = 0.5; // 중립
double positiveRate = 60.0;
double negativeRate = 20.0;
// 3. Analytics 도메인 객체 생성
Analytics analytics = Analytics.builder()
.storeId(storeId)
.totalReviews(totalReviews)
.averageRating(averageRating)
.sentimentScore(sentimentScore)
.positiveReviewRate(positiveRate)
.negativeReviewRate(negativeRate)
.lastAnalysisDate(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
// 4. 데이터베이스에 저장
Analytics saved = analyticsPort.saveAnalytics(analytics);
log.info("새로운 분석 데이터 생성 완료: storeId={}", storeId);
return saved;
} catch (Exception e) {
log.error("분석 데이터 생성 중 오류 발생: storeId={}", storeId, e);
return createDefaultAnalytics(storeId);
}
}
@Transactional
public AiFeedback generateAIFeedback(Long storeId) {
log.info("AI 피드백 생성 시작: storeId={}", storeId);
try {
// 1. 최근 30일 리뷰 데이터 수집
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, 30);
if (reviewData.isEmpty()) {
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
return createDefaultAIFeedback(storeId);
}
// 2. AI 피드백 생성 (실제로는 AI 서비스 호출)
AiFeedback aiFeedback = AiFeedback.builder()
.storeId(storeId)
.summary("고객들의 전반적인 만족도가 높습니다.")
.positivePoints(List.of("맛이 좋다", "서비스가 친절하다", "분위기가 좋다"))
.improvementPoints(List.of("대기시간 단축", "가격 경쟁력", "메뉴 다양성"))
.recommendations(List.of("특별 메뉴 개발", "예약 시스템 도입", "고객 서비스 교육"))
.sentimentAnalysis("POSITIVE")
.confidenceScore(0.85)
.generatedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
// 3. 데이터베이스에 저장
AiFeedback saved = analyticsPort.saveAIFeedback(aiFeedback);
log.info("AI 피드백 생성 완료: storeId={}", storeId);
return saved;
} catch (Exception e) {
log.error("AI 피드백 생성 중 오류 발생: storeId={}", storeId, e);
return createDefaultAIFeedback(storeId);
}
}
private Analytics createDefaultAnalytics(Long storeId) {
return Analytics.builder()
.storeId(storeId)
.totalReviews(0)
.averageRating(0.0)
.sentimentScore(0.0)
.positiveReviewRate(0.0)
.negativeReviewRate(0.0)
.lastAnalysisDate(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
private AiFeedback createDefaultAIFeedback(Long storeId) {
return AiFeedback.builder()
.storeId(storeId)
.summary("분석할 리뷰 데이터가 부족합니다.")
.positivePoints(List.of("데이터 부족으로 분석 불가"))
.improvementPoints(List.of("리뷰 데이터 수집 필요"))
.recommendations(List.of("고객들의 리뷰 작성을 유도해보세요"))
.sentimentAnalysis("NEUTRAL")
.confidenceScore(0.0)
.generatedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
private String getFirstRecommendation(AiFeedback feedback) {
if (feedback.getRecommendations() != null && !feedback.getRecommendations().isEmpty()) {
return feedback.getRecommendations().get(0);
}
return "추천사항이 없습니다.";
}
private int countPositiveReviews(List<String> reviews) {
// 실제로는 AI 서비스를 통한 감정 분석 필요
return (int) (reviews.size() * 0.6); // 60% 가정
}
private int countNegativeReviews(List<String> reviews) {
// 실제로는 AI 서비스를 통한 감정 분석 필요
return (int) (reviews.size() * 0.2); // 20% 가정
}
}
@@ -1,50 +1,51 @@
package com.ktds.hi.analytics.infra.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import lombok.RequiredArgsConstructor;
/**
* Analytics 서비스 보안 설정 클래스
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsConfigurationSource corsConfigurationSource;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Swagger 관련 경로 모두 허용
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
// Analytics API 모두 허용 (테스트용)
.requestMatchers("/api/analytics/**").permitAll()
.requestMatchers("/api/action-plans/**").permitAll()
// Actuator 엔드포인트 허용
.requestMatchers("/actuator/**").permitAll()
// 기타 모든 요청 허용 (테스트용)
.anyRequest().permitAll()
);
return http.build();
}
}
package com.ktds.hi.analytics.infra.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import lombok.RequiredArgsConstructor;
/**
* Analytics 서비스 보안 설정 클래스
* 테스트를 위해 모든 엔드포인트를 인증 없이 접근 가능하도록 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsConfigurationSource corsConfigurationSource;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Swagger 관련 경로 모두 허용
.requestMatchers("/swagger-ui.html","/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
// Analytics API 모두 허용 (테스트용)
.requestMatchers("/api/analytics/**").permitAll()
.requestMatchers("/api/action-plans/**").permitAll()
// Actuator 엔드포인트 허용
.requestMatchers("/actuator/**").permitAll()
// 기타 모든 요청 허용 (테스트용)
.anyRequest().permitAll()
);
return http.build();
}
}
+95 -95
View File
@@ -1,95 +1,95 @@
server:
port: ${ANALYTICS_SERVICE_PORT:8084}
logging:
level:
org.springframework.web.servlet.resource.ResourceHttpRequestHandler: ERROR
org.springframework.web.servlet.DispatcherServlet: WARN
spring:
application:
name: analytics-service
datasource:
url: ${ANALYTICS_DB_URL:jdbc:postgresql://20.249.162.125:5432/hiorder_analytics}
username: ${ANALYTICS_DB_USERNAME:hiorder_user}
password: ${ANALYTICS_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
ai-api:
openai:
api-key: ${OPENAI_API_KEY:}
base-url: https://api.openai.com/v1
model: gpt-4o-mini
claude:
api-key: ${CLAUDE_API_KEY:}
base-url: https://api.anthropic.com
model: claude-3-sonnet-20240229
#external-api:
# openai:
# api-key: ${OPENAI_API_KEY:}
# base-url: https://api.openai.com
# claude:
# api-key: ${CLAUDE_API_KEY:}
# base-url: https://api.anthropic.com
# 외부 서비스 설정
external:
services:
review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082}
store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081}
member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080}
#springdoc:
# api-docs:
# path: /api-docs
# swagger-ui:
# path: /swagger-ui.html
springdoc:
swagger-ui:
enabled: true
path: /swagger-ui.html
try-it-out-enabled: true
management:
endpoints:
web:
exposure:
include: health,info,metrics
# AI 서비스 설정
ai:
azure:
cognitive:
endpoint: ${AI_AZURE_COGNITIVE_ENDPOINT:https://your-cognitive-service.cognitiveservices.azure.com}
key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key}
openai:
api-key: ${AI_OPENAI_API_KEY:your-openai-api-key}
# Azure Event Hub 설정
azure:
eventhub:
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:Endpoint=sb://your-eventhub.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=your-key}
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:analytics-consumer}
event-hubs:
review-events: ${AZURE_EVENTHUB_REVIEW_EVENTS:review-events}
ai-analysis-events: ${AZURE_EVENTHUB_AI_ANALYSIS_EVENTS:ai-analysis-events}
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=yourstorageaccount;AccountKey=your-storage-key;EndpointSuffix=core.windows.net}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}
server:
port: ${ANALYTICS_SERVICE_PORT:8084}
logging:
level:
org.springframework.web.servlet.resource.ResourceHttpRequestHandler: ERROR
org.springframework.web.servlet.DispatcherServlet: WARN
spring:
application:
name: analytics-service
datasource:
url: ${ANALYTICS_DB_URL:jdbc:postgresql://20.249.162.125:5432/hiorder_analytics}
username: ${ANALYTICS_DB_USERNAME:hiorder_user}
password: ${ANALYTICS_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
ai-api:
openai:
api-key: ${OPENAI_API_KEY:}
base-url: https://api.openai.com/v1
model: gpt-4o-mini
claude:
api-key: ${CLAUDE_API_KEY:}
base-url: https://api.anthropic.com
model: claude-3-sonnet-20240229
#external-api:
# openai:
# api-key: ${OPENAI_API_KEY:}
# base-url: https://api.openai.com
# claude:
# api-key: ${CLAUDE_API_KEY:}
# base-url: https://api.anthropic.com
# 외부 서비스 설정
external:
services:
review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082}
store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081}
member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080}
#springdoc:
# api-docs:
# path: /api-docs
# swagger-ui:
# path: /swagger-ui.html
springdoc:
swagger-ui:
enabled: true
path: /swagger-ui.html
try-it-out-enabled: true
management:
endpoints:
web:
exposure:
include: health,info,metrics
# AI 서비스 설정
ai:
azure:
cognitive:
endpoint: ${AI_AZURE_COGNITIVE_ENDPOINT:https://your-cognitive-service.cognitiveservices.azure.com}
key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key}
openai:
api-key: ${AI_OPENAI_API_KEY:your-openai-api-key}
# Azure Event Hub 설정
azure:
eventhub:
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:Endpoint=sb://your-eventhub.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=your-key}
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:analytics-consumer}
event-hubs:
review-events: ${AZURE_EVENTHUB_REVIEW_EVENTS:review-events}
ai-analysis-events: ${AZURE_EVENTHUB_AI_ANALYSIS_EVENTS:ai-analysis-events}
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=yourstorageaccount;AccountKey=your-storage-key;EndpointSuffix=core.windows.net}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}