Fix : analytis 수정

This commit is contained in:
lsh9672 2025-06-12 14:30:23 +09:00
parent cb1ab34a39
commit a288fc9e0c
58 changed files with 2963 additions and 447 deletions

View File

@ -1,6 +1,26 @@
dependencies { dependencies {
implementation project(':common') implementation project(':common')
// AI APIs // Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
// Azure Event Hub
implementation 'com.azure:azure-messaging-eventhubs:5.15.0'
implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.16.0'
implementation 'com.azure:azure-storage-blob:12.22.1'
// AI Services
implementation 'com.azure:azure-ai-textanalytics:5.3.0'
implementation 'com.theokanning.openai-gpt3-java:service:0.18.2'
// Monitoring
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-azure-monitor'
// Database
implementation 'org.postgresql:postgresql'
} }

View File

@ -0,0 +1,17 @@
package com.ktds.hi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
/**
* Analytics 서비스 메인 애플리케이션 클래스
*/
@SpringBootApplication(scanBasePackages = {"com.ktds.hi.analytics", "com.ktds.hi.common"})
@EntityScan(basePackages = "com.ktds.hi.analytics.infra.gateway.entity")
public class AnalyticsApplication {
public static void main(String[] args) {
SpringApplication.run(AnalyticsApplication.class, args);
}
}

View File

@ -0,0 +1,33 @@
package com.ktds.hi.analytics.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 실행 계획 도메인 클래스
* 점주가 수립한 개선 실행 계획을 나타냄
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActionPlan {
private Long id;
private Long storeId;
private Long userId;
private String title;
private String description;
private String period;
private PlanStatus status;
private List<String> tasks;
private String note;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,32 @@
package com.ktds.hi.analytics.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* AI 피드백 도메인 클래스
* AI가 생성한 피드백 정보를 나타냄
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiFeedback {
private Long id;
private Long storeId;
private String summary;
private List<String> positivePoints;
private List<String> improvementPoints;
private List<String> recommendations;
private String sentimentAnalysis;
private Double confidenceScore;
private LocalDateTime generatedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,21 @@
package com.ktds.hi.analytics.biz.domain;
/**
* 분석 유형 열거형
*/
public enum AnalysisType {
FULL_ANALYSIS("전체 분석"),
INCREMENTAL_ANALYSIS("증분 분석"),
SENTIMENT_ANALYSIS("감정 분석"),
TREND_ANALYSIS("트렌드 분석");
private final String description;
AnalysisType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}

View File

@ -0,0 +1,30 @@
package com.ktds.hi.analytics.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 분석 도메인 클래스
* 매장의 전반적인 분석 데이터를 나타냄
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Analytics {
private Long id;
private Long storeId;
private Integer totalReviews;
private Double averageRating;
private Double sentimentScore;
private Double positiveReviewRate;
private Double negativeReviewRate;
private LocalDateTime lastAnalysisDate;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,26 @@
package com.ktds.hi.analytics.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 주문 통계 도메인 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderStatistics {
private Integer totalOrders;
private Long totalRevenue;
private Double averageOrderValue;
private Integer peakHour;
private List<String> popularMenus;
private Map<String, Integer> customerAgeDistribution;
}

View File

@ -0,0 +1,21 @@
package com.ktds.hi.analytics.biz.domain;
/**
* 실행 계획 상태 열거형
*/
public enum PlanStatus {
PLANNED("계획됨"),
IN_PROGRESS("진행중"),
COMPLETED("완료됨"),
CANCELLED("취소됨");
private final String description;
PlanStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}

View File

@ -0,0 +1,20 @@
package com.ktds.hi.analytics.biz.domain;
/**
* 감정 분석 결과 열거형
*/
public enum SentimentType {
POSITIVE("긍정"),
NEGATIVE("부정"),
NEUTRAL("중립");
private final String description;
SentimentType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}

View File

@ -0,0 +1,214 @@
package com.ktds.hi.analytics.biz.service;
import com.ktds.hi.analytics.biz.domain.ActionPlan;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import com.ktds.hi.analytics.biz.usecase.in.ActionPlanUseCase;
import com.ktds.hi.analytics.biz.usecase.out.ActionPlanPort;
import com.ktds.hi.analytics.biz.usecase.out.AnalyticsPort;
import com.ktds.hi.analytics.biz.usecase.out.EventPort;
import com.ktds.hi.analytics.infra.dto.*;
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 ActionPlanService implements ActionPlanUseCase {
private final ActionPlanPort actionPlanPort;
private final AnalyticsPort analyticsPort;
private final EventPort eventPort;
@Override
public List<ActionPlanListResponse> getActionPlans(Long storeId) {
log.info("실행 계획 목록 조회: storeId={}", storeId);
try {
List<ActionPlan> actionPlans = actionPlanPort.findActionPlansByStoreId(storeId);
return actionPlans.stream()
.map(plan -> ActionPlanListResponse.builder()
.id(plan.getId())
.title(plan.getTitle())
.status(plan.getStatus())
.period(plan.getPeriod())
.createdAt(plan.getCreatedAt())
.completedAt(plan.getCompletedAt())
.build())
.collect(Collectors.toList());
} catch (Exception e) {
log.error("실행 계획 목록 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("실행 계획 조회에 실패했습니다.", e);
}
}
@Override
public ActionPlanDetailResponse getActionPlanDetail(Long planId) {
log.info("실행 계획 상세 조회: planId={}", planId);
try {
ActionPlan actionPlan = actionPlanPort.findActionPlanById(planId)
.orElseThrow(() -> new RuntimeException("실행 계획을 찾을 수 없습니다: " + planId));
return ActionPlanDetailResponse.builder()
.id(actionPlan.getId())
.storeId(actionPlan.getStoreId())
.title(actionPlan.getTitle())
.description(actionPlan.getDescription())
.period(actionPlan.getPeriod())
.status(actionPlan.getStatus())
.tasks(actionPlan.getTasks())
.note(actionPlan.getNote())
.createdAt(actionPlan.getCreatedAt())
.completedAt(actionPlan.getCompletedAt())
.build();
} catch (Exception e) {
log.error("실행 계획 상세 조회 중 오류 발생: planId={}", planId, e);
throw new RuntimeException("실행 계획 상세 조회에 실패했습니다.", e);
}
}
@Override
@Transactional
public ActionPlanSaveResponse saveActionPlan(ActionPlanSaveRequest request) {
log.info("실행 계획 저장: storeId={}, title={}", request.getStoreId(), request.getTitle());
try {
// 1. AI 피드백 존재 여부 확인
if (request.getFeedbackIds() != null && !request.getFeedbackIds().isEmpty()) {
validateFeedbackIds(request.getFeedbackIds());
}
// 2. 실행 계획 생성
ActionPlan actionPlan = ActionPlan.builder()
.storeId(request.getStoreId())
.userId(request.getUserId())
.title(request.getTitle())
.description(request.getDescription())
.period(request.getPeriod())
.status(PlanStatus.PLANNED)
.tasks(request.getTasks() != null ? request.getTasks() : List.of())
.createdAt(LocalDateTime.now())
.build();
// 3. 저장
ActionPlan savedPlan = actionPlanPort.saveActionPlan(actionPlan);
// 4. 이벤트 발행
eventPort.publishActionPlanCreatedEvent(savedPlan);
// 5. 응답 생성
ActionPlanSaveResponse response = ActionPlanSaveResponse.builder()
.id(savedPlan.getId())
.title(savedPlan.getTitle())
.status(savedPlan.getStatus())
.createdAt(savedPlan.getCreatedAt())
.build();
log.info("실행 계획 저장 완료: planId={}", savedPlan.getId());
return response;
} catch (Exception e) {
log.error("실행 계획 저장 중 오류 발생: storeId={}", request.getStoreId(), e);
throw new RuntimeException("실행 계획 저장에 실패했습니다.", e);
}
}
@Override
@Transactional
public ActionPlanCompleteResponse completeActionPlan(Long planId, ActionPlanCompleteRequest request) {
log.info("실행 계획 완료 처리: planId={}", planId);
try {
// 1. 실행 계획 조회
ActionPlan actionPlan = actionPlanPort.findActionPlanById(planId)
.orElseThrow(() -> new RuntimeException("실행 계획을 찾을 수 없습니다: " + planId));
// 2. 상태 업데이트
ActionPlan updatedPlan = ActionPlan.builder()
.id(actionPlan.getId())
.storeId(actionPlan.getStoreId())
.userId(actionPlan.getUserId())
.title(actionPlan.getTitle())
.description(actionPlan.getDescription())
.period(actionPlan.getPeriod())
.status(PlanStatus.COMPLETED)
.tasks(actionPlan.getTasks())
.note(request.getNote())
.createdAt(actionPlan.getCreatedAt())
.completedAt(LocalDateTime.now())
.build();
// 3. 저장
ActionPlan savedPlan = actionPlanPort.saveActionPlan(updatedPlan);
// 4. 응답 생성
ActionPlanCompleteResponse response = ActionPlanCompleteResponse.builder()
.id(savedPlan.getId())
.status(savedPlan.getStatus())
.completedAt(savedPlan.getCompletedAt())
.note(savedPlan.getNote())
.build();
log.info("실행 계획 완료 처리 완료: planId={}", planId);
return response;
} catch (Exception e) {
log.error("실행 계획 완료 처리 중 오류 발생: planId={}", planId, e);
throw new RuntimeException("실행 계획 완료 처리에 실패했습니다.", e);
}
}
@Override
@Transactional
public ActionPlanDeleteResponse deleteActionPlan(Long planId) {
log.info("실행 계획 삭제: planId={}", planId);
try {
// 1. 실행 계획 존재 여부 확인
ActionPlan actionPlan = actionPlanPort.findActionPlanById(planId)
.orElseThrow(() -> new RuntimeException("실행 계획을 찾을 수 없습니다: " + planId));
// 2. 삭제
actionPlanPort.deleteActionPlan(planId);
// 3. 응답 생성
ActionPlanDeleteResponse response = ActionPlanDeleteResponse.builder()
.planId(planId)
.deleted(true)
.deletedAt(LocalDateTime.now())
.build();
log.info("실행 계획 삭제 완료: planId={}", planId);
return response;
} catch (Exception e) {
log.error("실행 계획 삭제 중 오류 발생: planId={}", planId, e);
throw new RuntimeException("실행 계획 삭제에 실패했습니다.", e);
}
}
/**
* 피드백 ID 검증
*/
private void validateFeedbackIds(List<Long> feedbackIds) {
for (Long feedbackId : feedbackIds) {
// AI 피드백 존재 여부 확인 로직
// 실제로는 AI 피드백 리포지토리에서 확인해야
log.debug("피드백 ID 검증: feedbackId={}", feedbackId);
}
}
}

View File

@ -0,0 +1,303 @@
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.util.List;
/**
* 분석 서비스 구현 클래스
* 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);
}
}
@Override
public StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate) {
log.info("매장 통계 조회 시작: storeId={}, period={} ~ {}", storeId, startDate, endDate);
try {
// 1. 주문 통계 데이터 조회
var orderStats = orderDataPort.getOrderStatistics(storeId, startDate, endDate);
// 2. 리뷰 데이터 조회
var reviewCount = externalReviewPort.getReviewCount(storeId);
// 3. 통계 응답 생성
StoreStatisticsResponse response = StoreStatisticsResponse.builder()
.storeId(storeId)
.startDate(startDate)
.endDate(endDate)
.totalOrders(orderStats.getTotalOrders())
.totalRevenue(orderStats.getTotalRevenue())
.averageOrderValue(orderStats.getAverageOrderValue())
.peakHour(orderStats.getPeakHour())
.popularMenus(orderStats.getPopularMenus())
.customerAgeDistribution(orderStats.getCustomerAgeDistribution())
.totalReviews(reviewCount)
.generatedAt(java.time.LocalDateTime.now())
.build();
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 {
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
if (aiFeedback.isEmpty()) {
return AiFeedbackSummaryResponse.builder()
.storeId(storeId)
.hasData(false)
.message("AI 분석 데이터가 없습니다. 리뷰 데이터를 수집한 후 다시 시도해주세요.")
.build();
}
return AiFeedbackSummaryResponse.builder()
.storeId(storeId)
.hasData(true)
.overallScore(aiFeedback.get().getConfidenceScore())
.keyInsight(aiFeedback.get().getSummary())
.priorityRecommendation(aiFeedback.get().getRecommendations().get(0))
.lastUpdated(aiFeedback.get().getGeneratedAt())
.build();
} 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. 최근 리뷰 데이터 조회
var recentReviews = externalReviewPort.getRecentReviews(storeId, 30);
// 2. 감정 분석 수행
var sentimentResults = recentReviews.stream()
.map(review -> aiServicePort.analyzeSentiment(review))
.toList();
// 3. 분석 결과 집계
long positiveCount = sentimentResults.stream()
.mapToLong(sentiment -> sentiment.name().equals("POSITIVE") ? 1 : 0)
.sum();
long negativeCount = sentimentResults.stream()
.mapToLong(sentiment -> sentiment.name().equals("NEGATIVE") ? 1 : 0)
.sum();
double positiveRate = recentReviews.isEmpty() ? 0.0 :
(double) positiveCount / recentReviews.size() * 100;
double negativeRate = recentReviews.isEmpty() ? 0.0 :
(double) negativeCount / recentReviews.size() * 100;
// 4. 응답 생성
ReviewAnalysisResponse response = ReviewAnalysisResponse.builder()
.storeId(storeId)
.totalReviews(recentReviews.size())
.positiveReviewCount((int) positiveCount)
.negativeReviewCount((int) negativeCount)
.positiveRate(positiveRate)
.negativeRate(negativeRate)
.analysisDate(LocalDate.now())
.build();
log.info("리뷰 분석 조회 완료: storeId={}", storeId);
return response;
} catch (Exception e) {
log.error("리뷰 분석 조회 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("리뷰 분석에 실패했습니다.", e);
}
}
/**
* 새로운 분석 데이터 생성
*/
@Transactional
private Analytics generateNewAnalytics(Long storeId) {
log.info("새로운 분석 데이터 생성 시작: storeId={}", storeId);
try {
// 1. 리뷰 데이터 수집
var reviewData = externalReviewPort.getReviewData(storeId);
// 2. AI 분석 수행
var aiFeedback = aiServicePort.generateFeedback(reviewData);
// 3. 분석 데이터 생성
Analytics analytics = Analytics.builder()
.storeId(storeId)
.totalReviews(reviewData.size())
.averageRating(calculateAverageRating(reviewData))
.sentimentScore(aiFeedback.getConfidenceScore())
.positiveReviewRate(calculatePositiveRate(reviewData))
.negativeReviewRate(calculateNegativeRate(reviewData))
.lastAnalysisDate(java.time.LocalDateTime.now())
.build();
// 4. 저장
Analytics savedAnalytics = analyticsPort.saveAnalytics(analytics);
analyticsPort.saveAIFeedback(aiFeedback);
// 5. 분석 완료 이벤트 발행
eventPort.publishAnalysisCompletedEvent(storeId,
com.ktds.hi.analytics.biz.domain.AnalysisType.FULL_ANALYSIS);
log.info("새로운 분석 데이터 생성 완료: storeId={}", storeId);
return savedAnalytics;
} catch (Exception e) {
log.error("분석 데이터 생성 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("분석 데이터 생성에 실패했습니다.", e);
}
}
/**
* AI 피드백 생성
*/
@Transactional
private AiFeedback generateAIFeedback(Long storeId) {
log.info("AI 피드백 생성 시작: storeId={}", storeId);
try {
var reviewData = externalReviewPort.getReviewData(storeId);
var aiFeedback = aiServicePort.generateFeedback(reviewData);
return analyticsPort.saveAIFeedback(aiFeedback);
} catch (Exception e) {
log.error("AI 피드백 생성 중 오류 발생: storeId={}", storeId, e);
throw new RuntimeException("AI 피드백 생성에 실패했습니다.", e);
}
}
// 유틸리티 메서드들
private double calculateAverageRating(List<String> reviewData) {
// 리뷰 데이터에서 평점 추출 평균 계산 로직
return 4.2; // 임시
}
private double calculatePositiveRate(List<String> reviewData) {
// 긍정 리뷰 비율 계산 로직
return 75.5; // 임시
}
private double calculateNegativeRate(List<String> reviewData) {
// 부정 리뷰 비율 계산 로직
return 15.2; // 임시
}
}

View File

@ -0,0 +1,150 @@
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.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) {
// 이전 구현과 동일
return null; // 구현 생략
}
@Override
public AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId) {
// 이전 구현과 동일
return null; // 구현 생략
}
@Override
public ReviewAnalysisResponse getReviewAnalysis(Long storeId) {
// 이전 구현과 동일
return null; // 구현 생략
}
// private 메서드들
@Transactional
private Analytics generateNewAnalytics(Long storeId) {
// 이전 구현과 동일
return null; // 구현 생략
}
@Transactional
private AiFeedback generateAIFeedback(Long storeId) {
// 이전 구현과 동일
return null; // 구현 생략
}
}

View File

@ -0,0 +1,37 @@
package com.ktds.hi.analytics.biz.usecase.in;
import com.ktds.hi.analytics.infra.dto.*;
import java.util.List;
/**
* 실행 계획 UseCase 인터페이스
* Clean Architecture의 입력 포트 정의
*/
public interface ActionPlanUseCase {
/**
* 실행 계획 목록 조회
*/
List<ActionPlanListResponse> getActionPlans(Long storeId);
/**
* 실행 계획 상세 조회
*/
ActionPlanDetailResponse getActionPlanDetail(Long planId);
/**
* 실행 계획 저장
*/
ActionPlanSaveResponse saveActionPlan(ActionPlanSaveRequest request);
/**
* 실행 계획 완료 처리
*/
ActionPlanCompleteResponse completeActionPlan(Long planId, ActionPlanCompleteRequest request);
/**
* 실행 계획 삭제
*/
ActionPlanDeleteResponse deleteActionPlan(Long planId);
}

View File

@ -0,0 +1,37 @@
package com.ktds.hi.analytics.biz.usecase.in;
import com.ktds.hi.analytics.infra.dto.*;
import java.time.LocalDate;
/**
* 분석 서비스 UseCase 인터페이스
* Clean Architecture의 입력 포트 정의
*/
public interface AnalyticsUseCase {
/**
* 매장 분석 데이터 조회
*/
StoreAnalyticsResponse getStoreAnalytics(Long storeId);
/**
* AI 피드백 상세 조회
*/
AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId);
/**
* 매장 통계 조회
*/
StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate);
/**
* AI 피드백 요약 조회
*/
AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId);
/**
* 리뷰 분석 조회
*/
ReviewAnalysisResponse getReviewAnalysis(Long storeId);
}

View File

@ -0,0 +1,28 @@
package com.ktds.hi.analytics.biz.usecase.out;
import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.domain.SentimentType;
import java.util.List;
/**
* AI 서비스 포트 인터페이스
* 외부 AI API 연동을 위한 출력 포트
*/
public interface AIServicePort {
/**
* AI 피드백 생성
*/
AiFeedback generateFeedback(List<String> reviewData);
/**
* 감정 분석
*/
SentimentType analyzeSentiment(String content);
/**
* 실행 계획 생성
*/
List<String> generateActionPlan(AiFeedback feedback);
}

View File

@ -0,0 +1,33 @@
package com.ktds.hi.analytics.biz.usecase.out;
import com.ktds.hi.analytics.biz.domain.ActionPlan;
import java.util.List;
import java.util.Optional;
/**
* 실행 계획 포트 인터페이스
* Clean Architecture의 출력 포트 정의
*/
public interface ActionPlanPort {
/**
* 매장 ID로 실행 계획 목록 조회
*/
List<ActionPlan> findActionPlansByStoreId(Long storeId);
/**
* 실행 계획 ID로 조회
*/
Optional<ActionPlan> findActionPlanById(Long planId);
/**
* 실행 계획 저장
*/
ActionPlan saveActionPlan(ActionPlan actionPlan);
/**
* 실행 계획 삭제
*/
void deleteActionPlan(Long planId);
}

View File

@ -0,0 +1,33 @@
package com.ktds.hi.analytics.biz.usecase.out;
import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.domain.Analytics;
import java.util.Optional;
/**
* 분석 데이터 포트 인터페이스
* Clean Architecture의 출력 포트 정의
*/
public interface AnalyticsPort {
/**
* 매장 ID로 분석 데이터 조회
*/
Optional<Analytics> findAnalyticsByStoreId(Long storeId);
/**
* 분석 데이터 저장
*/
Analytics saveAnalytics(Analytics analytics);
/**
* 매장 ID로 AI 피드백 조회
*/
Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId);
/**
* AI 피드백 저장
*/
AiFeedback saveAIFeedback(AiFeedback feedback);
}

View File

@ -0,0 +1,31 @@
package com.ktds.hi.analytics.biz.usecase.out;
import java.time.Duration;
import java.util.Optional;
/**
* 캐시 포트 인터페이스
* Redis 캐시 연동을 위한 출력 포트
*/
public interface CachePort {
/**
* 캐시에서 데이터 조회
*/
Optional<Object> getAnalyticsCache(String key);
/**
* 캐시에 데이터 저장
*/
void putAnalyticsCache(String key, Object value, Duration ttl);
/**
* 캐시 무효화
*/
void invalidateCache(String key);
/**
* 매장별 캐시 무효화
*/
void invalidateStoreCache(Long storeId);
}

View File

@ -0,0 +1,21 @@
package com.ktds.hi.analytics.biz.usecase.out;
import com.ktds.hi.analytics.biz.domain.ActionPlan;
import com.ktds.hi.analytics.biz.domain.AnalysisType;
/**
* 이벤트 포트 인터페이스
* 이벤트 발행을 위한 출력 포트
*/
public interface EventPort {
/**
* 분석 완료 이벤트 발행
*/
void publishAnalysisCompletedEvent(Long storeId, AnalysisType analysisType);
/**
* 실행 계획 생성 이벤트 발행
*/
void publishActionPlanCreatedEvent(ActionPlan actionPlan);
}

View File

@ -0,0 +1,30 @@
package com.ktds.hi.analytics.biz.usecase.out;
import java.util.List;
/**
* 외부 리뷰 데이터 포트 인터페이스
* 리뷰 서비스와의 연동을 위한 출력 포트
*/
public interface ExternalReviewPort {
/**
* 매장의 리뷰 데이터 조회
*/
List<String> getReviewData(Long storeId);
/**
* 최근 리뷰 데이터 조회
*/
List<String> getRecentReviews(Long storeId, Integer days);
/**
* 리뷰 개수 조회
*/
Integer getReviewCount(Long storeId);
/**
* 평균 평점 조회
*/
Double getAverageRating(Long storeId);
}

View File

@ -0,0 +1,27 @@
package com.ktds.hi.analytics.biz.usecase.out;
import com.ktds.hi.analytics.biz.domain.OrderStatistics;
import java.time.LocalDate;
/**
* 주문 데이터 포트 인터페이스
* 주문 통계 조회를 위한 출력 포트
*/
public interface OrderDataPort {
/**
* 기간별 주문 통계 조회
*/
OrderStatistics getOrderStatistics(Long storeId, LocalDate startDate, LocalDate endDate);
/**
* 실시간 주문 현황 조회
*/
Integer getCurrentOrderCount(Long storeId);
/**
* 월별 매출 조회
*/
Long getMonthlyRevenue(Long storeId, int year, int month);
}

View File

@ -0,0 +1,74 @@
package com.ktds.hi.analytics.infra.config;
import com.azure.messaging.eventhubs.EventHubClientBuilder;
import com.azure.messaging.eventhubs.EventHubConsumerClient;
import com.azure.messaging.eventhubs.EventHubProducerClient;
import com.azure.messaging.eventhubs.checkpointstore.blob.BlobCheckpointStore;
import com.azure.storage.blob.BlobContainerClient;
import com.azure.storage.blob.BlobServiceClientBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Azure Event Hub 설정 클래스
* Event Hub 클라이언트와 체크포인트 스토어를 구성
*/
@Slf4j
@Configuration
public class EventHubConfig {
@Value("${azure.eventhub.connection-string}")
private String connectionString;
@Value("${azure.eventhub.consumer-group}")
private String consumerGroup;
@Value("${azure.storage.connection-string}")
private String storageConnectionString;
@Value("${azure.storage.container-name}")
private String containerName;
@Value("${azure.eventhub.event-hubs.review-events}")
private String reviewEventsHub;
@Value("${azure.eventhub.event-hubs.ai-analysis-events}")
private String aiAnalysisEventsHub;
/**
* 리뷰 이벤트 수신용 Consumer 클라이언트
*/
@Bean("reviewEventConsumer")
public EventHubConsumerClient reviewEventConsumer() {
BlobContainerClient blobContainerClient = createBlobContainerClient();
BlobCheckpointStore checkpointStore = new BlobCheckpointStore(blobContainerClient);
return new EventHubClientBuilder()
.connectionString(connectionString, reviewEventsHub)
.consumerGroup(consumerGroup)
.checkpointStore(checkpointStore)
.buildConsumerClient();
}
/**
* AI 분석 결과 발행용 Producer 클라이언트
*/
@Bean("aiAnalysisEventProducer")
public EventHubProducerClient aiAnalysisEventProducer() {
return new EventHubClientBuilder()
.connectionString(connectionString, aiAnalysisEventsHub)
.buildProducerClient();
}
/**
* Blob 컨테이너 클라이언트 생성
*/
private BlobContainerClient createBlobContainerClient() {
return new BlobServiceClientBuilder()
.connectionString(storageConnectionString)
.buildClient()
.getBlobContainerClient(containerName);
}
}

View File

@ -0,0 +1,15 @@
package com.ktds.hi.analytics.infra.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* JPA 설정 클래스
* JPA Auditing Repository 스캔 설정
*/
@Configuration
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "com.ktds.hi.analytics.infra.gateway.repository")
public class JpaConfig {
}

View File

@ -0,0 +1,115 @@
package com.ktds.hi.analytics.infra.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* Redis 설정 클래스
* Redis 연결 캐시 설정을 담당
*/
@Configuration
@EnableCaching
@RequiredArgsConstructor
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Value("${spring.redis.password:}")
private String redisPassword;
private final ObjectMapper objectMapper;
/**
* Redis 연결 팩토리 설정
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
if (redisPassword != null && !redisPassword.trim().isEmpty()) {
config.setPassword(redisPassword);
}
return new LettuceConnectionFactory(config);
}
/**
* RedisTemplate 설정
*/
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
// Key Serializer
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value Serializer
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.setDefaultSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
/**
* 캐시 매니저 설정
*/
@Bean
public CacheManager cacheManager() {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 기본 TTL 1시간
.serializeKeysWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)))
.disableCachingNullValues();
// 캐시별 TTL 설정
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// 매장 분석 데이터 - 1시간
cacheConfigurations.put("storeAnalytics", defaultConfig.entryTtl(Duration.ofHours(1)));
// AI 피드백 - 6시간
cacheConfigurations.put("aiFeedback", defaultConfig.entryTtl(Duration.ofHours(6)));
// 실행 계획 - 30분
cacheConfigurations.put("actionPlans", defaultConfig.entryTtl(Duration.ofMinutes(30)));
// 통계 데이터 - 24시간
cacheConfigurations.put("statistics", defaultConfig.entryTtl(Duration.ofHours(24)));
return RedisCacheManager.builder(redisConnectionFactory())
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}

View File

@ -0,0 +1,30 @@
package com.ktds.hi.analytics.infra.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate 설정 클래스
* 외부 API 호출을 위한 RestTemplate 구성
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setRequestFactory(clientHttpRequestFactory());
return restTemplate;
}
@Bean
public ClientHttpRequestFactory clientHttpRequestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(10000); // 10초
factory.setReadTimeout(30000); // 30초
return factory;
}
}

View File

@ -0,0 +1,33 @@
package com.ktds.hi.analytics.infra.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger 설정 클래스
* API 문서화를 위한 OpenAPI 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Analytics Service API")
.description("하이오더 분석 서비스 API 문서")
.version("1.0.0"))
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
.components(new Components()
.addSecuritySchemes("Bearer Authentication",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}

View File

@ -0,0 +1,115 @@
package com.ktds.hi.analytics.infra.controller;
import com.ktds.hi.analytics.biz.usecase.in.ActionPlanUseCase;
import com.ktds.hi.analytics.infra.dto.*;
import com.ktds.hi.common.dto.SuccessResponse;
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.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 실행 계획 컨트롤러 클래스
* 실행 계획 관련 API를 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/action-plans")
@RequiredArgsConstructor
@Tag(name = "Action Plan API", description = "실행 계획 관리 API")
public class ActionPlanController {
private final ActionPlanUseCase actionPlanUseCase;
/**
* 실행 계획 목록 조회
*/
@Operation(summary = "실행 계획 목록 조회", description = "매장의 실행 계획 목록을 조회합니다.")
@GetMapping("/stores/{storeId}")
public ResponseEntity<SuccessResponse<List<ActionPlanListResponse>>> getActionPlans(
@Parameter(description = "매장 ID", required = true)
@PathVariable @NotNull Long storeId) {
log.info("실행 계획 목록 조회 요청: storeId={}", storeId);
List<ActionPlanListResponse> response = actionPlanUseCase.getActionPlans(storeId);
return ResponseEntity.ok(SuccessResponse.of(response, "실행 계획 목록 조회 성공"));
}
/**
* 실행 계획 상세 조회
*/
@Operation(summary = "실행 계획 상세 조회", description = "실행 계획의 상세 정보를 조회합니다.")
@GetMapping("/{planId}")
public ResponseEntity<SuccessResponse<ActionPlanDetailResponse>> getActionPlanDetail(
@Parameter(description = "실행 계획 ID", required = true)
@PathVariable @NotNull Long planId) {
log.info("실행 계획 상세 조회 요청: planId={}", planId);
ActionPlanDetailResponse response = actionPlanUseCase.getActionPlanDetail(planId);
return ResponseEntity.ok(SuccessResponse.of(response, "실행 계획 상세 조회 성공"));
}
/**
* 실행 계획 저장
*/
@Operation(summary = "실행 계획 저장", description = "새로운 실행 계획을 저장합니다.")
@PostMapping
public ResponseEntity<SuccessResponse<ActionPlanSaveResponse>> saveActionPlan(
@Parameter(description = "실행 계획 저장 요청", required = true)
@RequestBody @Valid ActionPlanSaveRequest request) {
log.info("실행 계획 저장 요청: storeId={}, title={}", request.getStoreId(), request.getTitle());
ActionPlanSaveResponse response = actionPlanUseCase.saveActionPlan(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(SuccessResponse.of(response, "실행 계획 저장 성공"));
}
/**
* 실행 계획 완료 처리
*/
@Operation(summary = "실행 계획 완료 처리", description = "실행 계획을 완료 상태로 변경합니다.")
@PutMapping("/{planId}/complete")
public ResponseEntity<SuccessResponse<ActionPlanCompleteResponse>> completeActionPlan(
@Parameter(description = "실행 계획 ID", required = true)
@PathVariable @NotNull Long planId,
@Parameter(description = "실행 계획 완료 요청", required = true)
@RequestBody @Valid ActionPlanCompleteRequest request) {
log.info("실행 계획 완료 처리 요청: planId={}", planId);
ActionPlanCompleteResponse response = actionPlanUseCase.completeActionPlan(planId, request);
return ResponseEntity.ok(SuccessResponse.of(response, "실행 계획 완료 처리 성공"));
}
/**
* 실행 계획 삭제
*/
@Operation(summary = "실행 계획 삭제", description = "실행 계획을 삭제합니다.")
@DeleteMapping("/{planId}")
public ResponseEntity<SuccessResponse<ActionPlanDeleteResponse>> deleteActionPlan(
@Parameter(description = "실행 계획 ID", required = true)
@PathVariable @NotNull Long planId) {
log.info("실행 계획 삭제 요청: planId={}", planId);
ActionPlanDeleteResponse response = actionPlanUseCase.deleteActionPlan(planId);
return ResponseEntity.ok(SuccessResponse.of(response, "실행 계획 삭제 성공"));
}
}

View File

@ -0,0 +1,116 @@
package com.ktds.hi.analytics.infra.controller;
import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase;
import com.ktds.hi.analytics.infra.dto.*;
import com.ktds.hi.common.dto.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.NotNull;
import java.time.LocalDate;
/**
* 분석 서비스 컨트롤러 클래스
* 매장 분석, AI 피드백, 통계 조회 API를 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/analytics")
@RequiredArgsConstructor
@Tag(name = "Analytics API", description = "매장 분석 및 AI 피드백 API")
public class AnalyticsController {
private final AnalyticsUseCase analyticsUseCase;
/**
* 매장 분석 데이터 조회
*/
@Operation(summary = "매장 분석 데이터 조회", description = "매장의 전반적인 분석 데이터를 조회합니다.")
@GetMapping("/stores/{storeId}")
public ResponseEntity<SuccessResponse<StoreAnalyticsResponse>> getStoreAnalytics(
@Parameter(description = "매장 ID", required = true)
@PathVariable @NotNull Long storeId) {
log.info("매장 분석 데이터 조회 요청: storeId={}", storeId);
StoreAnalyticsResponse response = analyticsUseCase.getStoreAnalytics(storeId);
return ResponseEntity.ok(SuccessResponse.of(response, "매장 분석 데이터 조회 성공"));
}
/**
* AI 피드백 상세 조회
*/
@Operation(summary = "AI 피드백 상세 조회", description = "매장의 AI 피드백 상세 정보를 조회합니다.")
@GetMapping("/stores/{storeId}/ai-feedback")
public ResponseEntity<SuccessResponse<AiFeedbackDetailResponse>> getAIFeedbackDetail(
@Parameter(description = "매장 ID", required = true)
@PathVariable @NotNull Long storeId) {
log.info("AI 피드백 상세 조회 요청: storeId={}", storeId);
AiFeedbackDetailResponse response = analyticsUseCase.getAIFeedbackDetail(storeId);
return ResponseEntity.ok(SuccessResponse.of(response, "AI 피드백 상세 조회 성공"));
}
/**
* 매장 통계 조회
*/
@Operation(summary = "매장 통계 조회", description = "기간별 매장 주문 통계를 조회합니다.")
@GetMapping("/stores/{storeId}/statistics")
public ResponseEntity<SuccessResponse<StoreStatisticsResponse>> getStoreStatistics(
@Parameter(description = "매장 ID", required = true)
@PathVariable @NotNull Long storeId,
@Parameter(description = "시작 날짜 (YYYY-MM-DD)", required = true)
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "종료 날짜 (YYYY-MM-DD)", required = true)
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
log.info("매장 통계 조회 요청: storeId={}, period={} ~ {}", storeId, startDate, endDate);
StoreStatisticsResponse response = analyticsUseCase.getStoreStatistics(storeId, startDate, endDate);
return ResponseEntity.ok(SuccessResponse.of(response, "매장 통계 조회 성공"));
}
/**
* AI 피드백 요약 조회
*/
@Operation(summary = "AI 피드백 요약 조회", description = "매장의 AI 피드백 요약 정보를 조회합니다.")
@GetMapping("/stores/{storeId}/ai-feedback/summary")
public ResponseEntity<SuccessResponse<AiFeedbackSummaryResponse>> getAIFeedbackSummary(
@Parameter(description = "매장 ID", required = true)
@PathVariable @NotNull Long storeId) {
log.info("AI 피드백 요약 조회 요청: storeId={}", storeId);
AiFeedbackSummaryResponse response = analyticsUseCase.getAIFeedbackSummary(storeId);
return ResponseEntity.ok(SuccessResponse.of(response, "AI 피드백 요약 조회 성공"));
}
/**
* 리뷰 분석 조회
*/
@Operation(summary = "리뷰 분석 조회", description = "매장의 리뷰 감정 분석 결과를 조회합니다.")
@GetMapping("/stores/{storeId}/review-analysis")
public ResponseEntity<SuccessResponse<ReviewAnalysisResponse>> getReviewAnalysis(
@Parameter(description = "매장 ID", required = true)
@PathVariable @NotNull Long storeId) {
log.info("리뷰 분석 조회 요청: storeId={}", storeId);
ReviewAnalysisResponse response = analyticsUseCase.getReviewAnalysis(storeId);
return ResponseEntity.ok(SuccessResponse.of(response, "리뷰 분석 조회 성공"));
}
}

View File

@ -1,5 +1,6 @@
package com.ktds.hi.analytics.infra.dto; package com.ktds.hi.analytics.infra.dto;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@ -16,7 +17,8 @@ import java.time.LocalDateTime;
@AllArgsConstructor @AllArgsConstructor
public class ActionPlanCompleteResponse { public class ActionPlanCompleteResponse {
private Boolean success; private Long id;
private String message; private PlanStatus status;
private LocalDateTime completedAt; private LocalDateTime completedAt;
private String note;
} }

View File

@ -5,6 +5,8 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/** /**
* 실행 계획 삭제 응답 DTO * 실행 계획 삭제 응답 DTO
*/ */
@ -14,6 +16,7 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor @AllArgsConstructor
public class ActionPlanDeleteResponse { public class ActionPlanDeleteResponse {
private Boolean success; private Long planId;
private String message; private Boolean deleted;
private LocalDateTime deletedAt;
} }

View File

@ -0,0 +1,31 @@
package com.ktds.hi.analytics.infra.dto;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 실행 계획 상세 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActionPlanDetailResponse {
private Long id;
private Long storeId;
private String title;
private String description;
private String period;
private PlanStatus status;
private List<String> tasks;
private String note;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}

View File

@ -0,0 +1,26 @@
package com.ktds.hi.analytics.infra.dto;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 실행 계획 목록 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActionPlanListResponse {
private Long id;
private String title;
private PlanStatus status;
private String period;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}

View File

@ -0,0 +1,40 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.List;
/**
* 실행 계획 저장 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActionPlanSaveRequest {
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@NotNull(message = "사용자 ID는 필수입니다")
private Long userId;
@NotBlank(message = "제목은 필수입니다")
@Size(max = 100, message = "제목은 100자 이하여야 합니다")
private String title;
@Size(max = 1000, message = "설명은 1000자 이하여야 합니다")
private String description;
@Size(max = 50, message = "기간은 50자 이하여야 합니다")
private String period;
private List<Long> feedbackIds;
private List<String> tasks;
}

View File

@ -1,10 +1,13 @@
package com.ktds.hi.analytics.infra.dto; package com.ktds.hi.analytics.infra.dto;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/** /**
* 실행 계획 저장 응답 DTO * 실행 계획 저장 응답 DTO
*/ */
@ -14,7 +17,8 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor @AllArgsConstructor
public class ActionPlanSaveResponse { public class ActionPlanSaveResponse {
private Boolean success; private Long id;
private String message; private String title;
private Long planId; private PlanStatus status;
private LocalDateTime createdAt;
} }

View File

@ -0,0 +1,28 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* AI 피드백 상세 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiFeedbackDetailResponse {
private Long storeId;
private String summary;
private List<String> positivePoints;
private List<String> improvementPoints;
private List<String> recommendations;
private String sentimentAnalysis;
private Double confidenceScore;
private LocalDateTime generatedAt;
}

View File

@ -0,0 +1,26 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* AI 피드백 요약 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiFeedbackSummaryResponse {
private Long storeId;
private Boolean hasData;
private String message;
private Double overallScore;
private String keyInsight;
private String priorityRecommendation;
private LocalDateTime lastUpdated;
}

View File

@ -0,0 +1,26 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 리뷰 분석 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReviewAnalysisResponse {
private Long storeId;
private Integer totalReviews;
private Integer positiveReviewCount;
private Integer negativeReviewCount;
private Double positiveRate;
private Double negativeRate;
private LocalDate analysisDate;
}

View File

@ -0,0 +1,26 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 매장 분석 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StoreAnalyticsResponse {
private Long storeId;
private Integer totalReviews;
private Double averageRating;
private Double sentimentScore;
private Double positiveReviewRate;
private Double negativeReviewRate;
private LocalDateTime lastAnalysisDate;
}

View File

@ -0,0 +1,33 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 매장 통계 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StoreStatisticsResponse {
private Long storeId;
private LocalDate startDate;
private LocalDate endDate;
private Integer totalOrders;
private Long totalRevenue;
private Double averageOrderValue;
private Integer peakHour;
private List<String> popularMenus;
private Map<String, Integer> customerAgeDistribution;
private Integer totalReviews;
private LocalDateTime generatedAt;
}

View File

@ -0,0 +1,15 @@
package com.ktds.hi.analytics.infra.exception;
/**
* AI 서비스 연동 발생하는 예외
*/
public class AIServiceException extends AnalyticsException {
public AIServiceException(String message) {
super("AI_SERVICE_ERROR", message);
}
public AIServiceException(String message, Throwable cause) {
super("AI_SERVICE_ERROR", message, cause);
}
}

View File

@ -0,0 +1,11 @@
package com.ktds.hi.analytics.infra.exception;
/**
* 실행 계획을 찾을 없을 발생하는 예외
*/
public class ActionPlanNotFoundException extends AnalyticsException {
public ActionPlanNotFoundException(Long planId) {
super("ACTION_PLAN_NOT_FOUND", "실행 계획을 찾을 수 없습니다: " + planId);
}
}

View File

@ -0,0 +1,33 @@
package com.ktds.hi.analytics.infra.exception;
/**
* 분석 서비스 커스텀 예외 클래스
*/
public class AnalyticsException extends RuntimeException {
private final String errorCode;
public AnalyticsException(String message) {
super(message);
this.errorCode = "ANALYTICS_ERROR";
}
public AnalyticsException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public AnalyticsException(String message, Throwable cause) {
super(message, cause);
this.errorCode = "ANALYTICS_ERROR";
}
public AnalyticsException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@ -0,0 +1,15 @@
package com.ktds.hi.analytics.infra.exception;
/**
* 외부 서비스 연동 발생하는 예외
*/
public class ExternalServiceException extends AnalyticsException {
public ExternalServiceException(String serviceName, String message) {
super("EXTERNAL_SERVICE_ERROR", serviceName + " 서비스 오류: " + message);
}
public ExternalServiceException(String serviceName, String message, Throwable cause) {
super("EXTERNAL_SERVICE_ERROR", serviceName + " 서비스 오류: " + message, cause);
}
}

View File

@ -0,0 +1,236 @@
package com.ktds.hi.analytics.infra.exception;
import com.ktds.hi.common.dto.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.time.LocalDateTime;
import java.util.stream.Collectors;
/**
* 글로벌 예외 처리 핸들러
* 모든 컨트롤러에서 발생하는 예외를 중앙에서 처리
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 분석 서비스 커스텀 예외 처리
*/
@ExceptionHandler(AnalyticsException.class)
public ResponseEntity<ErrorResponse> handleAnalyticsException(AnalyticsException ex) {
log.error("Analytics Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Analytics Error")
.message(ex.getMessage())
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 매장 정보 없음 예외 처리
*/
@ExceptionHandler(StoreNotFoundException.class)
public ResponseEntity<ErrorResponse> handleStoreNotFoundException(StoreNotFoundException ex) {
log.error("Store Not Found: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Store Not Found")
.message(ex.getMessage())
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
/**
* 실행 계획 없음 예외 처리
*/
@ExceptionHandler(ActionPlanNotFoundException.class)
public ResponseEntity<ErrorResponse> handleActionPlanNotFoundException(ActionPlanNotFoundException ex) {
log.error("Action Plan Not Found: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Action Plan Not Found")
.message(ex.getMessage())
.path("/api/action-plans")
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
/**
* AI 서비스 예외 처리
*/
@ExceptionHandler(AIServiceException.class)
public ResponseEntity<ErrorResponse> handleAIServiceException(AIServiceException ex) {
log.error("AI Service Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
.error("AI Service Error")
.message("AI 서비스 연동 중 오류가 발생했습니다.")
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
}
/**
* 외부 서비스 예외 처리
*/
@ExceptionHandler(ExternalServiceException.class)
public ResponseEntity<ErrorResponse> handleExternalServiceException(ExternalServiceException ex) {
log.error("External Service Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
.error("External Service Error")
.message("외부 서비스 연동 중 오류가 발생했습니다.")
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
}
/**
* 입력 검증 예외 처리
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
log.error("Validation Exception: {}", ex.getMessage());
String errorMessage = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Error")
.message(errorMessage)
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 바인딩 예외 처리
*/
@ExceptionHandler(BindException.class)
public ResponseEntity<ErrorResponse> handleBindException(BindException ex) {
log.error("Bind Exception: {}", ex.getMessage());
String errorMessage = ex.getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Binding Error")
.message(errorMessage)
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 제약 조건 위반 예외 처리
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolationException(ConstraintViolationException ex) {
log.error("Constraint Violation Exception: {}", ex.getMessage());
String errorMessage = ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining(", "));
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Constraint Violation")
.message(errorMessage)
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 타입 불일치 예외 처리
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) {
log.error("Type Mismatch Exception: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Type Mismatch")
.message("잘못된 파라미터 타입입니다: " + ex.getName())
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
/**
* 일반적인 RuntimeException 처리
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) {
log.error("Runtime Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Internal Server Error")
.message("내부 서버 오류가 발생했습니다.")
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
/**
* 모든 예외의 최종 처리
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAllExceptions(Exception ex) {
log.error("Unexpected Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Unexpected Error")
.message("예상치 못한 오류가 발생했습니다.")
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}

View File

@ -0,0 +1,11 @@
package com.ktds.hi.analytics.infra.exception;
/**
* 매장 정보를 찾을 없을 발생하는 예외
*/
public class StoreNotFoundException extends AnalyticsException {
public StoreNotFoundException(Long storeId) {
super("STORE_NOT_FOUND", "매장을 찾을 수 없습니다: " + storeId);
}
}

View File

@ -1,27 +1,31 @@
package com.ktds.hi.analytics.infra.gateway; package com.ktds.hi.analytics.infra.gateway;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ktds.hi.analytics.biz.domain.ActionPlan; import com.ktds.hi.analytics.biz.domain.ActionPlan;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import com.ktds.hi.analytics.biz.usecase.out.ActionPlanPort; import com.ktds.hi.analytics.biz.usecase.out.ActionPlanPort;
import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity; import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity;
import com.ktds.hi.analytics.infra.gateway.repository.ActionPlanJpaRepository; import com.ktds.hi.analytics.infra.gateway.repository.ActionPlanJpaRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 실행 계획 리포지토리 어댑터 클래스 * 실행 계획 리포지토리 어댑터 클래스 (완성버전)
* ActionPlan Port를 구현하여 데이터 영속성 기능을 제공 * ActionPlan Port를 구현하여 데이터 영속성 기능을 제공
*/ */
@Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class ActionPlanRepositoryAdapter implements ActionPlanPort { public class ActionPlanRepositoryAdapter implements ActionPlanPort {
private final ActionPlanJpaRepository actionPlanJpaRepository; private final ActionPlanJpaRepository actionPlanJpaRepository;
private final ObjectMapper objectMapper;
@Override @Override
public List<ActionPlan> findActionPlansByStoreId(Long storeId) { public List<ActionPlan> findActionPlansByStoreId(Long storeId) {
@ -61,10 +65,11 @@ public class ActionPlanRepositoryAdapter implements ActionPlanPort {
.description(entity.getDescription()) .description(entity.getDescription())
.period(entity.getPeriod()) .period(entity.getPeriod())
.status(entity.getStatus()) .status(entity.getStatus())
.tasks(entity.getTasksJson() != null ? parseTasksJson(entity.getTasksJson()) : List.of()) .tasks(parseTasksJson(entity.getTasksJson()))
.note(entity.getNote()) .note(entity.getNote())
.createdAt(entity.getCreatedAt()) .createdAt(entity.getCreatedAt())
.completedAt(entity.getCompletedAt()) .completedAt(entity.getCompletedAt())
.updatedAt(entity.getUpdatedAt())
.build(); .build();
} }
@ -80,30 +85,41 @@ public class ActionPlanRepositoryAdapter implements ActionPlanPort {
.description(domain.getDescription()) .description(domain.getDescription())
.period(domain.getPeriod()) .period(domain.getPeriod())
.status(domain.getStatus()) .status(domain.getStatus())
.tasksJson(domain.getTasks() != null ? toTasksJsonString(domain.getTasks()) : "[]") .tasksJson(parseTasksToJson(domain.getTasks()))
.note(domain.getNote()) .note(domain.getNote())
.createdAt(domain.getCreatedAt())
.completedAt(domain.getCompletedAt()) .completedAt(domain.getCompletedAt())
.build(); .build();
} }
/** /**
* JSON 문자열을 Tasks List로 파싱 * JSON 문자열을 List로 변환
*/ */
private List<String> parseTasksJson(String json) { private List<String> parseTasksJson(String tasksJson) {
if (json == null || json.trim().isEmpty() || "[]".equals(json.trim())) { if (tasksJson == null || tasksJson.trim().isEmpty()) {
return List.of();
}
try {
return objectMapper.readValue(tasksJson, new TypeReference<List<String>>() {});
} catch (JsonProcessingException e) {
log.warn("Tasks JSON 파싱 실패: {}", tasksJson, e);
return List.of(); return List.of();
} }
return Arrays.asList(json.replace("[", "").replace("]", "").replace("\"", "").split(","));
} }
/** /**
* Tasks List를 JSON 문자열로 변환 * List를 JSON 문자열로 변환
*/ */
private String toTasksJsonString(List<String> tasks) { private String parseTasksToJson(List<String> tasks) {
if (tasks == null || tasks.isEmpty()) { if (tasks == null || tasks.isEmpty()) {
return "[]"; return "[]";
} }
return "[\"" + String.join("\",\"", tasks) + "\"]";
try {
return objectMapper.writeValueAsString(tasks);
} catch (JsonProcessingException e) {
log.warn("Tasks JSON 직렬화 실패: {}", tasks, e);
return "[]";
}
} }
} }

View File

@ -1,267 +0,0 @@
package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.domain.SentimentType;
import com.ktds.hi.analytics.biz.usecase.out.AIServicePort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
/**
* AI 서비스 어댑터 클래스
* AI Service Port를 구현하여 외부 AI API 연동 기능을 제공
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class AiServiceAdapter implements AIServicePort {
private final RestTemplate restTemplate;
@Value("${external-api.openai.api-key:}")
private String openaiApiKey;
@Value("${external-api.claude.api-key:}")
private String claudeApiKey;
@Override
public AiFeedback generateFeedback(List<String> reviewData) {
log.info("AI 피드백 생성 시작: reviewCount={}", reviewData.size());
try {
// OpenAI API를 사용한 감정 분석
String combinedReviews = String.join(" ", reviewData);
SentimentType sentiment = analyzeSentiment(combinedReviews);
// Mock AI 피드백 생성 (실제로는 OpenAI/Claude API 호출)
AiFeedback feedback = AiFeedback.builder()
.summary(generateMockSummary(reviewData, sentiment))
.sentiment(sentiment)
.positivePoints(generateMockPositivePoints())
.negativePoints(generateMockNegativePoints())
.recommendations(generateMockRecommendations())
.confidence(calculateConfidence(reviewData))
.analysisDate(LocalDate.now())
.build();
log.info("AI 피드백 생성 완료: sentiment={}, confidence={}", sentiment, feedback.getConfidence());
return feedback;
} catch (Exception e) {
log.error("AI 피드백 생성 실패: error={}", e.getMessage(), e);
return createFallbackFeedback();
}
}
@Override
public SentimentType analyzeSentiment(String content) {
log.debug("감정 분석 시작: contentLength={}", content.length());
try {
// 실제로는 OpenAI API 호출
if (openaiApiKey != null && !openaiApiKey.isEmpty()) {
return callOpenAISentimentAPI(content);
}
// Fallback: 간단한 키워드 기반 감정 분석
return performKeywordBasedSentiment(content);
} catch (Exception e) {
log.error("감정 분석 실패: error={}", e.getMessage(), e);
return SentimentType.NEUTRAL;
}
}
@Override
public List<String> generateActionPlan(AiFeedback feedback) {
log.info("실행 계획 생성 시작: sentiment={}", feedback.getSentiment());
try {
// AI 기반 실행 계획 생성 (Mock)
List<String> actionPlan = List.of(
"고객 서비스 개선을 위한 직원 교육 실시",
"주방 청결도 점검 및 개선",
"대기시간 단축을 위한 주문 시스템 개선",
"메뉴 다양성 확대 검토",
"고객 피드백 수집 시스템 구축"
);
log.info("실행 계획 생성 완료: planCount={}", actionPlan.size());
return actionPlan;
} catch (Exception e) {
log.error("실행 계획 생성 실패: error={}", e.getMessage(), e);
return List.of("AI 분석을 통한 개선사항 검토");
}
}
/**
* OpenAI API를 통한 감정 분석
*/
private SentimentType callOpenAISentimentAPI(String content) {
try {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + openaiApiKey);
headers.set("Content-Type", "application/json");
Map<String, Object> requestBody = Map.of(
"model", "gpt-3.5-turbo",
"messages", List.of(
Map.of("role", "user", "content",
"다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로 답해주세요: " + content)
),
"max_tokens", 10
);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<Map> response = restTemplate.exchange(
"https://api.openai.com/v1/chat/completions",
HttpMethod.POST,
entity,
Map.class
);
// API 응답 파싱
Map<String, Object> responseBody = response.getBody();
if (responseBody != null && responseBody.containsKey("choices")) {
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseBody.get("choices");
if (!choices.isEmpty()) {
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
String result = (String) message.get("content");
return SentimentType.valueOf(result.trim().toUpperCase());
}
}
return SentimentType.NEUTRAL;
} catch (Exception e) {
log.warn("OpenAI API 호출 실패, 키워드 분석으로 대체: error={}", e.getMessage());
return performKeywordBasedSentiment(content);
}
}
/**
* 키워드 기반 감정 분석
*/
private SentimentType performKeywordBasedSentiment(String content) {
String lowerContent = content.toLowerCase();
List<String> positiveKeywords = List.of("맛있", "", "최고", "추천", "만족", "친절", "깔끔");
List<String> negativeKeywords = List.of("맛없", "나쁘", "별로", "실망", "불친절", "더러", "느리");
long positiveCount = positiveKeywords.stream()
.mapToLong(keyword -> countOccurrences(lowerContent, keyword))
.sum();
long negativeCount = negativeKeywords.stream()
.mapToLong(keyword -> countOccurrences(lowerContent, keyword))
.sum();
if (positiveCount > negativeCount) {
return SentimentType.POSITIVE;
} else if (negativeCount > positiveCount) {
return SentimentType.NEGATIVE;
} else {
return SentimentType.NEUTRAL;
}
}
/**
* 문자열에서 특정 키워드 출현 횟수 계산
*/
private long countOccurrences(String text, String keyword) {
return (text.length() - text.replace(keyword, "").length()) / keyword.length();
}
/**
* Mock 요약 생성
*/
private String generateMockSummary(List<String> reviewData, SentimentType sentiment) {
switch (sentiment) {
case POSITIVE:
return "고객들이 음식의 맛과 서비스에 대해 전반적으로 만족하고 있습니다. 특히 음식의 품질과 직원의 친절함이 높이 평가받고 있습니다.";
case NEGATIVE:
return "일부 고객들이 음식의 맛이나 서비스에 대해 불만을 표현하고 있습니다. 주로 대기시간과 음식의 온도에 대한 개선이 필요해 보입니다.";
default:
return "고객 리뷰가 긍정적인 면과 개선이 필요한 면이 혼재되어 있습니다. 지속적인 품질 관리가 필요합니다.";
}
}
/**
* Mock 긍정 포인트 생성
*/
private List<String> generateMockPositivePoints() {
return List.of(
"음식의 맛이 좋다는 평가",
"직원들이 친절하다는 의견",
"매장이 깔끔하고 청결함",
"가격 대비 만족스러운 품질"
);
}
/**
* Mock 부정 포인트 생성
*/
private List<String> generateMockNegativePoints() {
return List.of(
"주문 후 대기시간이 다소 길음",
"일부 메뉴의 간이 짜다는 의견",
"주차 공간이 부족함"
);
}
/**
* Mock 추천사항 생성
*/
private List<String> generateMockRecommendations() {
return List.of(
"주문 처리 시간 단축을 위한 시스템 개선",
"메뉴별 간 조절에 대한 재검토",
"고객 대기 공간 개선",
"직원 서비스 교육 지속 실시",
"주차 환경 개선 방안 검토"
);
}
/**
* 신뢰도 계산
*/
private Double calculateConfidence(List<String> reviewData) {
// 리뷰 수와 내용 길이를 기반으로 신뢰도 계산
int reviewCount = reviewData.size();
double avgLength = reviewData.stream()
.mapToInt(String::length)
.average()
.orElse(0.0);
// 기본 신뢰도 계산 로직
double confidence = Math.min(0.95, 0.5 + (reviewCount * 0.05) + (avgLength * 0.001));
return Math.round(confidence * 100.0) / 100.0;
}
/**
* Fallback 피드백 생성
*/
private AiFeedback createFallbackFeedback() {
return AiFeedback.builder()
.summary("AI 분석을 수행할 수 없어 기본 분석 결과를 제공합니다.")
.sentiment(SentimentType.NEUTRAL)
.positivePoints(List.of("분석 데이터 부족"))
.negativePoints(List.of("분석 데이터 부족"))
.recommendations(List.of("더 많은 리뷰 데이터 수집 필요"))
.confidence(0.3)
.analysisDate(LocalDate.now())
.build();
}
}

View File

@ -1,5 +1,8 @@
package com.ktds.hi.analytics.infra.gateway; package com.ktds.hi.analytics.infra.gateway;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
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.out.AnalyticsPort; import com.ktds.hi.analytics.biz.usecase.out.AnalyticsPort;
@ -8,26 +11,28 @@ import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity;
import com.ktds.hi.analytics.infra.gateway.repository.AnalyticsJpaRepository; import com.ktds.hi.analytics.infra.gateway.repository.AnalyticsJpaRepository;
import com.ktds.hi.analytics.infra.gateway.repository.AiFeedbackJpaRepository; import com.ktds.hi.analytics.infra.gateway.repository.AiFeedbackJpaRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* 분석 리포지토리 어댑터 클래스 * 분석 리포지토리 어댑터 클래스 (완성버전)
* Analytics Port를 구현하여 데이터 영속성 기능을 제공 * Analytics Port를 구현하여 데이터 영속성 기능을 제공
*/ */
@Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class AnalyticsRepositoryAdapter implements AnalyticsPort { public class AnalyticsRepositoryAdapter implements AnalyticsPort {
private final AnalyticsJpaRepository analyticsJpaRepository; private final AnalyticsJpaRepository analyticsJpaRepository;
private final AiFeedbackJpaRepository aiFeedbackJpaRepository; private final AiFeedbackJpaRepository aiFeedbackJpaRepository;
private final ObjectMapper objectMapper;
@Override @Override
public Optional<Analytics> findAnalyticsByStoreId(Long storeId) { public Optional<Analytics> findAnalyticsByStoreId(Long storeId) {
return analyticsJpaRepository.findByStoreId(storeId) return analyticsJpaRepository.findLatestByStoreId(storeId)
.map(this::toDomain); .map(this::toDomain);
} }
@ -40,7 +45,7 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort {
@Override @Override
public Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId) { public Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId) {
return aiFeedbackJpaRepository.findByStoreId(storeId) return aiFeedbackJpaRepository.findLatestByStoreId(storeId)
.map(this::toAiFeedbackDomain); .map(this::toAiFeedbackDomain);
} }
@ -52,7 +57,7 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort {
} }
/** /**
* Entity를 Domain으로 변환 * Analytics Entity를 Domain으로 변환
*/ */
private Analytics toDomain(AnalyticsEntity entity) { private Analytics toDomain(AnalyticsEntity entity) {
return Analytics.builder() return Analytics.builder()
@ -61,13 +66,16 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort {
.totalReviews(entity.getTotalReviews()) .totalReviews(entity.getTotalReviews())
.averageRating(entity.getAverageRating()) .averageRating(entity.getAverageRating())
.sentimentScore(entity.getSentimentScore()) .sentimentScore(entity.getSentimentScore())
.positiveReviewRate(entity.getPositiveReviewRate())
.negativeReviewRate(entity.getNegativeReviewRate())
.lastAnalysisDate(entity.getLastAnalysisDate())
.createdAt(entity.getCreatedAt()) .createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt()) .updatedAt(entity.getUpdatedAt())
.build(); .build();
} }
/** /**
* Domain을 Entity로 변환 * Analytics Domain을 Entity로 변환
*/ */
private AnalyticsEntity toEntity(Analytics domain) { private AnalyticsEntity toEntity(Analytics domain) {
return AnalyticsEntity.builder() return AnalyticsEntity.builder()
@ -76,8 +84,9 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort {
.totalReviews(domain.getTotalReviews()) .totalReviews(domain.getTotalReviews())
.averageRating(domain.getAverageRating()) .averageRating(domain.getAverageRating())
.sentimentScore(domain.getSentimentScore()) .sentimentScore(domain.getSentimentScore())
.createdAt(domain.getCreatedAt()) .positiveReviewRate(domain.getPositiveReviewRate())
.updatedAt(domain.getUpdatedAt()) .negativeReviewRate(domain.getNegativeReviewRate())
.lastAnalysisDate(domain.getLastAnalysisDate())
.build(); .build();
} }
@ -89,16 +98,14 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort {
.id(entity.getId()) .id(entity.getId())
.storeId(entity.getStoreId()) .storeId(entity.getStoreId())
.summary(entity.getSummary()) .summary(entity.getSummary())
.sentiment(entity.getSentiment()) .positivePoints(parseJsonToList(entity.getPositivePointsJson()))
.positivePoints(entity.getPositivePointsJson() != null ? .improvementPoints(parseJsonToList(entity.getImprovementPointsJson()))
parseJsonList(entity.getPositivePointsJson()) : List.of()) .recommendations(parseJsonToList(entity.getRecommendationsJson()))
.negativePoints(entity.getNegativePointsJson() != null ? .sentimentAnalysis(entity.getSentimentAnalysis())
parseJsonList(entity.getNegativePointsJson()) : List.of()) .confidenceScore(entity.getConfidenceScore())
.recommendations(entity.getRecommendationsJson() != null ? .generatedAt(entity.getGeneratedAt())
parseJsonList(entity.getRecommendationsJson()) : List.of())
.confidence(entity.getConfidence())
.analysisDate(entity.getAnalysisDate())
.createdAt(entity.getCreatedAt()) .createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build(); .build();
} }
@ -110,34 +117,44 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort {
.id(domain.getId()) .id(domain.getId())
.storeId(domain.getStoreId()) .storeId(domain.getStoreId())
.summary(domain.getSummary()) .summary(domain.getSummary())
.sentiment(domain.getSentiment()) .positivePointsJson(parseListToJson(domain.getPositivePoints()))
.positivePointsJson(toJsonString(domain.getPositivePoints())) .improvementPointsJson(parseListToJson(domain.getImprovementPoints()))
.negativePointsJson(toJsonString(domain.getNegativePoints())) .recommendationsJson(parseListToJson(domain.getRecommendations()))
.recommendationsJson(toJsonString(domain.getRecommendations())) .sentimentAnalysis(domain.getSentimentAnalysis())
.confidence(domain.getConfidence()) .confidenceScore(domain.getConfidenceScore())
.analysisDate(domain.getAnalysisDate()) .generatedAt(domain.getGeneratedAt())
.createdAt(domain.getCreatedAt())
.build(); .build();
} }
/** /**
* JSON 문자열을 List로 파싱 * JSON 문자열을 List로 변환
*/ */
private List<String> parseJsonList(String json) { private List<String> parseJsonToList(String json) {
// 실제로는 Jackson 등을 사용하여 파싱 if (json == null || json.trim().isEmpty()) {
if (json == null || json.isEmpty()) { return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (JsonProcessingException e) {
log.warn("JSON 파싱 실패: {}", json, e);
return List.of(); return List.of();
} }
return Arrays.asList(json.replace("[", "").replace("]", "").replace("\"", "").split(","));
} }
/** /**
* List를 JSON 문자열로 변환 * List를 JSON 문자열로 변환
*/ */
private String toJsonString(List<String> list) { private String parseListToJson(List<String> list) {
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return "[]"; return "[]";
} }
return "[\"" + String.join("\",\"", list) + "\"]";
try {
return objectMapper.writeValueAsString(list);
} catch (JsonProcessingException e) {
log.warn("JSON 직렬화 실패: {}", list, e);
return "[]";
}
} }
} }

View File

@ -0,0 +1,78 @@
package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.usecase.out.CachePort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
/**
* Redis 캐시 어댑터 클래스
* CachePort를 구현하여 Redis 캐싱 기능 제공
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheAdapter implements CachePort {
private final RedisTemplate<String, Object> redisTemplate;
@Override
public Optional<Object> getAnalyticsCache(String key) {
try {
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
log.debug("캐시 히트: key={}", key);
return Optional.of(cached);
}
log.debug("캐시 미스: key={}", key);
return Optional.empty();
} catch (Exception e) {
log.warn("캐시 조회 실패: key={}", key, e);
return Optional.empty();
}
}
@Override
public void putAnalyticsCache(String key, Object value, Duration ttl) {
try {
redisTemplate.opsForValue().set(key, value, ttl);
log.debug("캐시 저장: key={}, ttl={}초", key, ttl.getSeconds());
} catch (Exception e) {
log.warn("캐시 저장 실패: key={}", key, e);
}
}
@Override
public void invalidateCache(String key) {
try {
Boolean deleted = redisTemplate.delete(key);
log.debug("캐시 삭제: key={}, deleted={}", key, deleted);
} catch (Exception e) {
log.warn("캐시 삭제 실패: key={}", key, e);
}
}
@Override
public void invalidateStoreCache(Long storeId) {
try {
String pattern = "analytics:store:" + storeId + "*";
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
Long deletedCount = redisTemplate.delete(keys);
log.info("매장 캐시 무효화: storeId={}, deleted={}", storeId, deletedCount);
}
} catch (Exception e) {
log.warn("매장 캐시 무효화 실패: storeId={}", storeId, e);
}
}
}

View File

@ -1,6 +1,7 @@
package com.ktds.hi.analytics.infra.gateway; package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.domain.ActionPlan; import com.ktds.hi.analytics.biz.domain.ActionPlan;
import com.ktds.hi.analytics.biz.domain.AnalysisType;
import com.ktds.hi.analytics.biz.usecase.out.EventPort; import com.ktds.hi.analytics.biz.usecase.out.EventPort;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;

View File

@ -0,0 +1,187 @@
package com.ktds.hi.analytics.infra.gateway;
import com.azure.messaging.eventhubs.EventData;
import com.azure.messaging.eventhubs.EventHubConsumerClient;
import com.azure.messaging.eventhubs.EventHubProducerClient;
import com.azure.messaging.eventhubs.models.EventPosition;
import com.azure.messaging.eventhubs.models.PartitionEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ktds.hi.analytics.biz.domain.ActionPlan;
import com.ktds.hi.analytics.biz.domain.AnalysisType;
import com.ktds.hi.analytics.biz.usecase.out.EventPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Azure Event Hub 어댑터 클래스
* 이벤트 발행 수신 기능을 제공
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventHubAdapter implements EventPort {
@Qualifier("reviewEventConsumer")
private final EventHubConsumerClient reviewEventConsumer;
@Qualifier("aiAnalysisEventProducer")
private final EventHubProducerClient aiAnalysisEventProducer;
private final ObjectMapper objectMapper;
private final ExecutorService executorService = Executors.newFixedThreadPool(3);
private volatile boolean isRunning = false;
@PostConstruct
public void startEventListening() {
log.info("Event Hub 리스너 시작");
isRunning = true;
// 리뷰 이벤트 수신 시작
executorService.submit(this::listenToReviewEvents);
}
@PreDestroy
public void stopEventListening() {
log.info("Event Hub 리스너 종료");
isRunning = false;
executorService.shutdown();
reviewEventConsumer.close();
aiAnalysisEventProducer.close();
}
@Override
public void publishAnalysisCompletedEvent(Long storeId, AnalysisType analysisType) {
try {
Map<String, Object> eventData = new HashMap<>();
eventData.put("eventType", "ANALYSIS_COMPLETED");
eventData.put("storeId", storeId);
eventData.put("analysisType", analysisType.name());
eventData.put("timestamp", System.currentTimeMillis());
String jsonData = objectMapper.writeValueAsString(eventData);
EventData event = new EventData(jsonData);
aiAnalysisEventProducer.send(event);
log.info("분석 완료 이벤트 발행: storeId={}, type={}", storeId, analysisType);
} catch (Exception e) {
log.error("분석 완료 이벤트 발행 실패: storeId={}", storeId, e);
}
}
@Override
public void publishActionPlanCreatedEvent(ActionPlan actionPlan) {
try {
Map<String, Object> eventData = new HashMap<>();
eventData.put("eventType", "ACTION_PLAN_CREATED");
eventData.put("planId", actionPlan.getId());
eventData.put("storeId", actionPlan.getStoreId());
eventData.put("title", actionPlan.getTitle());
eventData.put("timestamp", System.currentTimeMillis());
String jsonData = objectMapper.writeValueAsString(eventData);
EventData event = new EventData(jsonData);
aiAnalysisEventProducer.send(event);
log.info("실행계획 생성 이벤트 발행: planId={}, storeId={}",
actionPlan.getId(), actionPlan.getStoreId());
} catch (Exception e) {
log.error("실행계획 생성 이벤트 발행 실패: planId={}", actionPlan.getId(), e);
}
}
/**
* 리뷰 이벤트 수신 처리
*/
private void listenToReviewEvents() {
log.info("리뷰 이벤트 수신 시작");
try {
reviewEventConsumer.receiveFromPartition("0", EventPosition.earliest())
.timeout(Duration.ofSeconds(30))
.subscribe(this::handleReviewEvent);
} catch (Exception e) {
log.error("리뷰 이벤트 수신 중 오류 발생", e);
}
}
/**
* 리뷰 이벤트 처리
*/
private void handleReviewEvent(PartitionEvent partitionEvent) {
try {
EventData eventData = partitionEvent.getData();
String eventBody = eventData.getBodyAsString();
Map<String, Object> event = objectMapper.readValue(eventBody, Map.class);
String eventType = (String) event.get("eventType");
Long storeId = Long.valueOf(event.get("storeId").toString());
log.info("리뷰 이벤트 수신: type={}, storeId={}", eventType, storeId);
switch (eventType) {
case "REVIEW_CREATED":
handleReviewCreatedEvent(storeId, event);
break;
case "REVIEW_DELETED":
handleReviewDeletedEvent(storeId, event);
break;
case "REVIEW_COMMENT_CREATED":
handleReviewCommentCreatedEvent(storeId, event);
break;
default:
log.warn("알 수 없는 이벤트 타입: {}", eventType);
}
} catch (Exception e) {
log.error("리뷰 이벤트 처리 중 오류 발생", e);
}
}
/**
* 리뷰 생성 이벤트 처리
*/
private void handleReviewCreatedEvent(Long storeId, Map<String, Object> event) {
log.info("리뷰 생성 이벤트 처리: storeId={}", storeId);
// TODO: 리뷰 생성 AI 분석 트리거
// 1. 새로운 리뷰 데이터 수집
// 2. AI 분석 요청
// 3. 분석 결과 저장
// 4. 캐시 무효화
}
/**
* 리뷰 삭제 이벤트 처리
*/
private void handleReviewDeletedEvent(Long storeId, Map<String, Object> event) {
log.info("리뷰 삭제 이벤트 처리: storeId={}", storeId);
// TODO: 리뷰 삭제 분석 데이터 재계산
// 1. 분석 데이터 재계산
// 2. 캐시 무효화
}
/**
* 리뷰 댓글 생성 이벤트 처리
*/
private void handleReviewCommentCreatedEvent(Long storeId, Map<String, Object> event) {
log.info("리뷰 댓글 생성 이벤트 처리: storeId={}", storeId);
// TODO: 댓글 생성 고객 응답률 분석
// 1. 고객 응답률 계산
// 2. 분석 데이터 업데이트
}
}

View File

@ -3,83 +3,126 @@ package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort; import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList; import java.util.Arrays;
import java.util.List; import java.util.List;
/** /**
* 외부 리뷰 어댑터 클래스 * 외부 리뷰 서비스 어댑터 클래스
* External Review Port를 구현하여 외부 리뷰 데이터 연동 기능을 제공 * 리뷰 서비스와의 API 통신을 담당
*/ */
@Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class ExternalReviewAdapter implements ExternalReviewPort { public class ExternalReviewAdapter implements ExternalReviewPort {
private final RestTemplate restTemplate;
@Value("${external.services.review}")
private String reviewServiceUrl;
@Override @Override
public List<String> getReviewData(Long storeId) { public List<String> getReviewData(Long storeId) {
log.info("외부 리뷰 데이터 조회 시작: storeId={}", storeId); log.info("리뷰 데이터 조회: storeId={}", storeId);
try { try {
// 실제로는 Review Service와 연동하여 리뷰 데이터를 가져옴 String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content";
// Mock 데이터 반환 String[] reviewArray = restTemplate.getForObject(url, String[].class);
List<String> reviews = new ArrayList<>();
reviews.add("음식이 정말 맛있고 서비스도 친절해요. 다음에 또 올게요!"); List<String> reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of();
reviews.add("가격 대비 양이 많고 맛도 괜찮습니다. 추천해요."); log.info("리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
reviews.add("분위기가 좋고 음식도 맛있어요. 특히 김치찌개가 일품이네요.");
reviews.add("조금 대기시간이 길었지만 음식은 만족스러웠습니다.");
reviews.add("깔끔하고 맛있어요. 직원분들도 친절하시고 좋았습니다.");
reviews.add("기대보다는 평범했지만 나쁘지 않았어요.");
reviews.add("음식이 너무 짜서 별로였습니다. 개선이 필요할 것 같아요.");
reviews.add("가성비 좋고 맛도 괜찮아요. 재방문 의사 있습니다.");
reviews.add("위생 상태가 좋고 음식도 깔끔해요. 만족합니다.");
reviews.add("주차하기 어려워서 불편했지만 음식은 맛있었어요.");
log.info("외부 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
return reviews; return reviews;
} catch (Exception e) { } catch (Exception e) {
log.error("외부 리뷰 데이터 조회 실패: storeId={}, error={}", storeId, e.getMessage(), e); log.error("리뷰 데이터 조회 실패: storeId={}", storeId, e);
return List.of(); // 실패 더미 데이터 반환
return getDummyReviewData(storeId);
} }
} }
@Override @Override
public List<String> getRecentReviews(Long storeId, Integer days) { public List<String> getRecentReviews(Long storeId, Integer days) {
log.info("최근 리뷰 데이터 조회 시작: storeId={}, days={}", storeId, days); log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days);
try { try {
// 실제로는 최근 N일간의 리뷰만 필터링 String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days;
List<String> allReviews = getReviewData(storeId); String[] reviewArray = restTemplate.getForObject(url, String[].class);
// Mock: 최근 리뷰는 전체 리뷰의 70% 정도로 가정 List<String> reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of();
int recentCount = (int) (allReviews.size() * 0.7); log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
List<String> recentReviews = allReviews.subList(0, Math.min(recentCount, allReviews.size()));
log.info("최근 리뷰 데이터 조회 완료: storeId={}, days={}, count={}", storeId, days, recentReviews.size()); return reviews;
return recentReviews;
} catch (Exception e) { } catch (Exception e) {
log.error("최근 리뷰 데이터 조회 실패: storeId={}, days={}, error={}", storeId, days, e.getMessage(), e); log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e);
return List.of(); return getDummyRecentReviews(storeId);
} }
} }
@Override @Override
public Integer getReviewCount(Long storeId) { public Integer getReviewCount(Long storeId) {
log.info("리뷰 수 조회 시작: storeId={}", storeId); log.info("리뷰 수 조회: storeId={}", storeId);
try { try {
List<String> reviews = getReviewData(storeId); String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/count";
int count = reviews.size(); Integer count = restTemplate.getForObject(url, Integer.class);
log.info("리뷰 수 조회 완료: storeId={}, count={}", storeId, count); log.info("리뷰 수 조회 완료: storeId={}, count={}", storeId, count);
return count; return count != null ? count : 0;
} catch (Exception e) { } catch (Exception e) {
log.error("리뷰 수 조회 실패: storeId={}, error={}", storeId, e.getMessage(), e); log.error("리뷰 수 조회 실패: storeId={}", storeId, e);
return 0; return 25; // 더미
} }
} }
@Override
public Double getAverageRating(Long storeId) {
log.info("평균 평점 조회: storeId={}", storeId);
try {
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/average-rating";
Double rating = restTemplate.getForObject(url, Double.class);
log.info("평균 평점 조회 완료: storeId={}, rating={}", storeId, rating);
return rating != null ? rating : 0.0;
} catch (Exception e) {
log.error("평균 평점 조회 실패: storeId={}", storeId, e);
return 4.2; // 더미
}
}
/**
* 더미 리뷰 데이터 생성
*/
private List<String> getDummyReviewData(Long storeId) {
return Arrays.asList(
"음식이 정말 맛있어요! 배달도 빨랐습니다.",
"가격 대비 양이 많고 맛도 좋네요. 추천합니다.",
"배달 시간이 너무 오래 걸렸어요. 음식은 괜찮았습니다.",
"포장 상태가 별로였어요. 국물이 새어나왔습니다.",
"직원분들이 친절하고 음식도 맛있어요. 재주문 할게요!",
"메뉴가 다양하고 맛있습니다. 자주 이용할 것 같아요.",
"가격이 조금 비싸긴 하지만 맛은 좋아요.",
"배달 기사님이 친절하셨어요. 음식도 따뜻했습니다."
);
}
/**
* 더미 최근 리뷰 데이터 생성
*/
private List<String> getDummyRecentReviews(Long storeId) {
return Arrays.asList(
"어제 주문했는데 정말 맛있었어요!",
"배달이 빨라서 좋았습니다.",
"음식 온도가 적절했어요.",
"포장이 깔끔하게 되어있었습니다.",
"다음에도 주문할게요!"
);
}
} }

View File

@ -0,0 +1,109 @@
package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.domain.OrderStatistics;
import com.ktds.hi.analytics.biz.usecase.out.OrderDataPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
/**
* 주문 데이터 어댑터 클래스
* 외부 주문 서비스와의 연동을 담당
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderDataAdapter implements OrderDataPort {
private final RestTemplate restTemplate;
@Value("${external.services.store}")
private String storeServiceUrl;
@Override
public OrderStatistics getOrderStatistics(Long storeId, LocalDate startDate, LocalDate endDate) {
log.info("주문 통계 조회: storeId={}, period={} ~ {}", storeId, startDate, endDate);
try {
String url = String.format("%s/api/orders/stores/%d/statistics?startDate=%s&endDate=%s",
storeServiceUrl, storeId, startDate, endDate);
OrderStatistics statistics = restTemplate.getForObject(url, OrderStatistics.class);
if (statistics != null) {
log.info("주문 통계 조회 완료: storeId={}, totalOrders={}",
storeId, statistics.getTotalOrders());
return statistics;
}
} catch (Exception e) {
log.error("주문 통계 조회 실패: storeId={}", storeId, e);
}
// 실패 더미 데이터 반환
return createDummyOrderStatistics(storeId);
}
@Override
public Integer getCurrentOrderCount(Long storeId) {
log.info("실시간 주문 현황 조회: storeId={}", storeId);
try {
String url = storeServiceUrl + "/api/orders/stores/" + storeId + "/current-count";
Integer count = restTemplate.getForObject(url, Integer.class);
log.info("실시간 주문 현황 조회 완료: storeId={}, count={}", storeId, count);
return count != null ? count : 0;
} catch (Exception e) {
log.error("실시간 주문 현황 조회 실패: storeId={}", storeId, e);
return 3; // 더미
}
}
@Override
public Long getMonthlyRevenue(Long storeId, int year, int month) {
log.info("월별 매출 조회: storeId={}, year={}, month={}", storeId, year, month);
try {
String url = String.format("%s/api/orders/stores/%d/revenue?year=%d&month=%d",
storeServiceUrl, storeId, year, month);
Long revenue = restTemplate.getForObject(url, Long.class);
log.info("월별 매출 조회 완료: storeId={}, revenue={}", storeId, revenue);
return revenue != null ? revenue : 0L;
} catch (Exception e) {
log.error("월별 매출 조회 실패: storeId={}", storeId, e);
return 5500000L; // 더미
}
}
/**
* 더미 주문 통계 생성
*/
private OrderStatistics createDummyOrderStatistics(Long storeId) {
Map<String, Integer> ageDistribution = new HashMap<>();
ageDistribution.put("20대", 35);
ageDistribution.put("30대", 45);
ageDistribution.put("40대", 25);
ageDistribution.put("50대", 15);
return OrderStatistics.builder()
.totalOrders(156)
.totalRevenue(3280000L)
.averageOrderValue(21025.64)
.peakHour(19)
.popularMenus(Arrays.asList("치킨버거", "불고기버거", "감자튀김", "콜라"))
.customerAgeDistribution(ageDistribution)
.build();
}
}

View File

@ -1,25 +1,32 @@
package com.ktds.hi.analytics.infra.gateway.entity; package com.ktds.hi.analytics.infra.gateway.entity;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ktds.hi.analytics.biz.domain.PlanStatus; import com.ktds.hi.analytics.biz.domain.PlanStatus;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener; import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 실행 계획 엔티티 클래스 * 실행 계획 엔티티
* 데이터베이스 action_plans 테이블과 매핑되는 JPA 엔티티 * 점주의 개선 실행 계획을 저장
*/ */
@Entity @Entity
@Table(name = "action_plans") @Table(name = "action_plan")
@Getter @Getter
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@ -37,42 +44,39 @@ public class ActionPlanEntity {
@Column(name = "user_id", nullable = false) @Column(name = "user_id", nullable = false)
private Long userId; private Long userId;
@Column(nullable = false, length = 200) @Column(name = "title", nullable = false, length = 100)
private String title; private String title;
@Column(columnDefinition = "TEXT") @Column(name = "description", length = 1000)
private String description; private String description;
@Column(length = 50) @Column(name = "period", length = 50)
private String period; private String period;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20) @Column(name = "status", nullable = false)
@Builder.Default private PlanStatus status;
private PlanStatus status = PlanStatus.PLANNED;
@Column(name = "feedback_ids_json", columnDefinition = "TEXT") @Column(name = "tasks", columnDefinition = "TEXT")
private String feedbackIdsJson; private String tasksJson;
@Column(columnDefinition = "TEXT") @Column(name = "note", length = 1000)
private String note; private String note;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "completed_at") @Column(name = "completed_at")
private LocalDateTime completedAt; private LocalDateTime completedAt;
/** @CreatedDate
* JSON 문자열을 List로 변환 @Column(name = "created_at", nullable = false, updatable = false)
*/ private LocalDateTime createdAt;
public List<Long> getFeedbackIdsList() {
try { @LastModifiedDate
ObjectMapper mapper = new ObjectMapper(); @Column(name = "updated_at")
return mapper.readValue(feedbackIdsJson, new TypeReference<List<Long>>() {}); private LocalDateTime updatedAt;
} catch (Exception e) {
return List.of(); @Index(name = "idx_action_plan_store_id", columnList = "store_id")
} @Index(name = "idx_action_plan_user_id", columnList = "user_id")
@Index(name = "idx_action_plan_status", columnList = "status")
public static class Indexes {
} }
} }

View File

@ -1,24 +1,19 @@
package com.ktds.hi.analytics.infra.gateway.entity; package com.ktds.hi.analytics.infra.gateway.entity;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener; import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal; import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** /**
* AI 피드백 엔티티 클래스 * AI 피드백 엔티티
* 데이터베이스 ai_feedback 테이블과 매핑되는 JPA 엔티티 * AI가 생성한 피드백 정보를 저장
*/ */
@Entity @Entity
@Table(name = "ai_feedback") @Table(name = "ai_feedback")
@ -36,58 +31,36 @@ public class AiFeedbackEntity {
@Column(name = "store_id", nullable = false) @Column(name = "store_id", nullable = false)
private Long storeId; private Long storeId;
@Column(columnDefinition = "TEXT") @Column(name = "summary", length = 1000)
private String summary; private String summary;
@Column(length = 20) @Column(name = "positive_points", columnDefinition = "TEXT")
private String sentiment;
@Column(name = "positive_points_json", columnDefinition = "TEXT")
private String positivePointsJson; private String positivePointsJson;
@Column(name = "negative_points_json", columnDefinition = "TEXT") @Column(name = "improvement_points", columnDefinition = "TEXT")
private String negativePointsJson; private String improvementPointsJson;
@Column(name = "recommendations_json", columnDefinition = "TEXT") @Column(name = "recommendations", columnDefinition = "TEXT")
private String recommendationsJson; private String recommendationsJson;
@Column(precision = 3, scale = 2) @Column(name = "sentiment_analysis", length = 500)
private BigDecimal confidence; private String sentimentAnalysis;
@Column(name = "analysis_date") @Column(name = "confidence_score")
private LocalDate analysisDate; private Double confidenceScore;
@Column(name = "generated_at")
private LocalDateTime generatedAt;
@CreatedDate @CreatedDate
@Column(name = "created_at", updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** @LastModifiedDate
* JSON 문자열을 객체로 변환하는 메서드들 @Column(name = "updated_at")
*/ private LocalDateTime updatedAt;
public Map<String, Object> getPositivePointsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(positivePointsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public Map<String, Object> getNegativePointsMap() { @Index(name = "idx_ai_feedback_store_id", columnList = "store_id")
try { public static class Indexes {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(negativePointsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public List<String> getRecommendationsList() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(recommendationsJson, new TypeReference<List<String>>() {});
} catch (Exception e) {
return List.of();
}
} }
} }

View File

@ -0,0 +1,63 @@
package com.ktds.hi.analytics.infra.gateway.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* 분석 데이터 엔티티
* 매장의 분석 정보를 저장
*/
@Entity
@Table(name = "analytics")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class AnalyticsEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "total_reviews")
private Integer totalReviews;
@Column(name = "average_rating")
private Double averageRating;
@Column(name = "sentiment_score")
private Double sentimentScore;
@Column(name = "positive_review_rate")
private Double positiveReviewRate;
@Column(name = "negative_review_rate")
private Double negativeReviewRate;
@Column(name = "last_analysis_date")
private LocalDateTime lastAnalysisDate;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Index(name = "idx_analytics_store_id", columnList = "store_id")
public static class Indexes {
}
}

View File

@ -2,10 +2,13 @@ package com.ktds.hi.analytics.infra.gateway.repository;
import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity; import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDate; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* AI 피드백 JPA 리포지토리 인터페이스 * AI 피드백 JPA 리포지토리 인터페이스
@ -15,18 +18,31 @@ import java.util.List;
public interface AiFeedbackJpaRepository extends JpaRepository<AiFeedbackEntity, Long> { public interface AiFeedbackJpaRepository extends JpaRepository<AiFeedbackEntity, Long> {
/** /**
* 매장 ID 분석 기간으 AI 피드백 목록 조회 * 매장 ID AI 피드백 조회 (최신순)
*/ */
List<AiFeedbackEntity> findByStoreIdAndAnalysisDateBetweenOrderByAnalysisDateDesc( Optional<AiFeedbackEntity> findByStoreId(Long storeId);
Long storeId, LocalDate startDate, LocalDate endDate);
/** /**
* 매장 ID로 최신 AI 피드백 조회 * 매장 ID로 최신 AI 피드백 조회
*/ */
AiFeedbackEntity findTopByStoreIdOrderByCreatedAtDesc(Long storeId); @Query("SELECT af FROM AiFeedbackEntity af WHERE af.storeId = :storeId ORDER BY af.generatedAt DESC")
Optional<AiFeedbackEntity> findLatestByStoreId(@Param("storeId") Long storeId);
/** /**
* 특정 날짜의 AI 피드백 조회 * 특정 기간 이후 생성된 AI 피드백 조회
*/ */
List<AiFeedbackEntity> findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate); @Query("SELECT af FROM AiFeedbackEntity af WHERE af.generatedAt >= :afterDate ORDER BY af.generatedAt DESC")
List<AiFeedbackEntity> findByGeneratedAtAfter(@Param("afterDate") LocalDateTime afterDate);
/**
* 신뢰도가 특정 이상인 AI 피드백 조회
*/
@Query("SELECT af FROM AiFeedbackEntity af WHERE af.confidenceScore >= :score ORDER BY af.confidenceScore DESC")
List<AiFeedbackEntity> findByHighConfidenceScore(@Param("score") Double score);
/**
* 매장별 AI 피드백 개수 조회
*/
@Query("SELECT COUNT(af) FROM AiFeedbackEntity af WHERE af.storeId = :storeId")
Long countByStoreId(@Param("storeId") Long storeId);
} }

View File

@ -0,0 +1,48 @@
package com.ktds.hi.analytics.infra.gateway.repository;
import com.ktds.hi.analytics.infra.gateway.entity.AnalyticsEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 분석 데이터 JPA 리포지토리 인터페이스
* 분석 데이터의 CRUD 작업을 담당
*/
@Repository
public interface AnalyticsJpaRepository extends JpaRepository<AnalyticsEntity, Long> {
/**
* 매장 ID로 분석 데이터 조회
*/
Optional<AnalyticsEntity> findByStoreId(Long storeId);
/**
* 매장 ID로 최신 분석 데이터 조회
*/
@Query("SELECT a FROM AnalyticsEntity a WHERE a.storeId = :storeId ORDER BY a.lastAnalysisDate DESC")
Optional<AnalyticsEntity> findLatestByStoreId(@Param("storeId") Long storeId);
/**
* 특정 기간 이후 분석된 매장 목록 조회
*/
@Query("SELECT a FROM AnalyticsEntity a WHERE a.lastAnalysisDate >= :afterDate ORDER BY a.lastAnalysisDate DESC")
List<AnalyticsEntity> findByLastAnalysisDateAfter(@Param("afterDate") LocalDateTime afterDate);
/**
* 평균 평점이 특정 이하인 매장 조회
*/
@Query("SELECT a FROM AnalyticsEntity a WHERE a.averageRating <= :rating ORDER BY a.averageRating ASC")
List<AnalyticsEntity> findByAverageRatingLessThanEqual(@Param("rating") Double rating);
/**
* 부정 리뷰 비율이 높은 매장 조회
*/
@Query("SELECT a FROM AnalyticsEntity a WHERE a.negativeReviewRate >= :rate ORDER BY a.negativeReviewRate DESC")
List<AnalyticsEntity> findByHighNegativeReviewRate(@Param("rate") Double rate);
}