This commit is contained in:
lsh9672
2025-06-11 16:31:06 +09:00
commit f0fbb47c51
164 changed files with 8667 additions and 0 deletions
@@ -0,0 +1,18 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 실행 계획 완료 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActionPlanCompleteRequest {
private String note;
}
@@ -0,0 +1,22 @@
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 ActionPlanCompleteResponse {
private Boolean success;
private String message;
private LocalDateTime completedAt;
}
@@ -0,0 +1,19 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 실행 계획 삭제 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActionPlanDeleteResponse {
private Boolean success;
private String message;
}
@@ -0,0 +1,20 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 실행 계획 저장 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActionPlanSaveResponse {
private Boolean success;
private String message;
private Long planId;
}
@@ -0,0 +1,109 @@
package com.ktds.hi.analytics.infra.gateway;
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.infra.gateway.entity.ActionPlanEntity;
import com.ktds.hi.analytics.infra.gateway.repository.ActionPlanJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 실행 계획 리포지토리 어댑터 클래스
* ActionPlan Port를 구현하여 데이터 영속성 기능을 제공
*/
@Component
@RequiredArgsConstructor
public class ActionPlanRepositoryAdapter implements ActionPlanPort {
private final ActionPlanJpaRepository actionPlanJpaRepository;
@Override
public List<ActionPlan> findActionPlansByStoreId(Long storeId) {
return actionPlanJpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId)
.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<ActionPlan> findActionPlanById(Long planId) {
return actionPlanJpaRepository.findById(planId)
.map(this::toDomain);
}
@Override
public ActionPlan saveActionPlan(ActionPlan actionPlan) {
ActionPlanEntity entity = toEntity(actionPlan);
ActionPlanEntity saved = actionPlanJpaRepository.save(entity);
return toDomain(saved);
}
@Override
public void deleteActionPlan(Long planId) {
actionPlanJpaRepository.deleteById(planId);
}
/**
* Entity를 Domain으로 변환
*/
private ActionPlan toDomain(ActionPlanEntity entity) {
return ActionPlan.builder()
.id(entity.getId())
.storeId(entity.getStoreId())
.userId(entity.getUserId())
.title(entity.getTitle())
.description(entity.getDescription())
.period(entity.getPeriod())
.status(entity.getStatus())
.tasks(entity.getTasksJson() != null ? parseTasksJson(entity.getTasksJson()) : List.of())
.note(entity.getNote())
.createdAt(entity.getCreatedAt())
.completedAt(entity.getCompletedAt())
.build();
}
/**
* Domain을 Entity로 변환
*/
private ActionPlanEntity toEntity(ActionPlan domain) {
return ActionPlanEntity.builder()
.id(domain.getId())
.storeId(domain.getStoreId())
.userId(domain.getUserId())
.title(domain.getTitle())
.description(domain.getDescription())
.period(domain.getPeriod())
.status(domain.getStatus())
.tasksJson(domain.getTasks() != null ? toTasksJsonString(domain.getTasks()) : "[]")
.note(domain.getNote())
.createdAt(domain.getCreatedAt())
.completedAt(domain.getCompletedAt())
.build();
}
/**
* JSON 문자열을 Tasks List로 파싱
*/
private List<String> parseTasksJson(String json) {
if (json == null || json.trim().isEmpty() || "[]".equals(json.trim())) {
return List.of();
}
return Arrays.asList(json.replace("[", "").replace("]", "").replace("\"", "").split(","));
}
/**
* Tasks List를 JSON 문자열로 변환
*/
private String toTasksJsonString(List<String> tasks) {
if (tasks == null || tasks.isEmpty()) {
return "[]";
}
return "[\"" + String.join("\",\"", tasks) + "\"]";
}
}
@@ -0,0 +1,266 @@
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;
/**
* 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();
}
}
@@ -0,0 +1,19 @@
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; // 추가
@@ -0,0 +1,141 @@
package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.domain.Analytics;
import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.usecase.out.AnalyticsPort;
import com.ktds.hi.analytics.infra.gateway.entity.AnalyticsEntity;
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.AiFeedbackJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* 분석 리포지토리 어댑터 클래스
* Analytics Port를 구현하여 데이터 영속성 기능을 제공
*/
@Component
@RequiredArgsConstructor
public class AnalyticsRepositoryAdapter implements AnalyticsPort {
private final AnalyticsJpaRepository analyticsJpaRepository;
private final AiFeedbackJpaRepository aiFeedbackJpaRepository;
@Override
public Optional<Analytics> findAnalyticsByStoreId(Long storeId) {
return analyticsJpaRepository.findByStoreId(storeId)
.map(this::toDomain);
}
@Override
public Analytics saveAnalytics(Analytics analytics) {
AnalyticsEntity entity = toEntity(analytics);
AnalyticsEntity saved = analyticsJpaRepository.save(entity);
return toDomain(saved);
}
@Override
public Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId) {
return aiFeedbackJpaRepository.findByStoreId(storeId)
.map(this::toAiFeedbackDomain);
}
@Override
public AiFeedback saveAIFeedback(AiFeedback feedback) {
AiFeedbackEntity entity = toAiFeedbackEntity(feedback);
AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity);
return toAiFeedbackDomain(saved);
}
/**
* Entity를 Domain으로 변환
*/
private Analytics toDomain(AnalyticsEntity entity) {
return Analytics.builder()
.id(entity.getId())
.storeId(entity.getStoreId())
.totalReviews(entity.getTotalReviews())
.averageRating(entity.getAverageRating())
.sentimentScore(entity.getSentimentScore())
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();
}
/**
* Domain을 Entity로 변환
*/
private AnalyticsEntity toEntity(Analytics domain) {
return AnalyticsEntity.builder()
.id(domain.getId())
.storeId(domain.getStoreId())
.totalReviews(domain.getTotalReviews())
.averageRating(domain.getAverageRating())
.sentimentScore(domain.getSentimentScore())
.createdAt(domain.getCreatedAt())
.updatedAt(domain.getUpdatedAt())
.build();
}
/**
* AiFeedback Entity를 Domain으로 변환
*/
private AiFeedback toAiFeedbackDomain(AiFeedbackEntity entity) {
return AiFeedback.builder()
.id(entity.getId())
.storeId(entity.getStoreId())
.summary(entity.getSummary())
.sentiment(entity.getSentiment())
.positivePoints(entity.getPositivePointsJson() != null ?
parseJsonList(entity.getPositivePointsJson()) : List.of())
.negativePoints(entity.getNegativePointsJson() != null ?
parseJsonList(entity.getNegativePointsJson()) : List.of())
.recommendations(entity.getRecommendationsJson() != null ?
parseJsonList(entity.getRecommendationsJson()) : List.of())
.confidence(entity.getConfidence())
.analysisDate(entity.getAnalysisDate())
.createdAt(entity.getCreatedAt())
.build();
}
/**
* AiFeedback Domain을 Entity로 변환
*/
private AiFeedbackEntity toAiFeedbackEntity(AiFeedback domain) {
return AiFeedbackEntity.builder()
.id(domain.getId())
.storeId(domain.getStoreId())
.summary(domain.getSummary())
.sentiment(domain.getSentiment())
.positivePointsJson(toJsonString(domain.getPositivePoints()))
.negativePointsJson(toJsonString(domain.getNegativePoints()))
.recommendationsJson(toJsonString(domain.getRecommendations()))
.confidence(domain.getConfidence())
.analysisDate(domain.getAnalysisDate())
.createdAt(domain.getCreatedAt())
.build();
}
/**
* JSON 문자열을 List로 파싱
*/
private List<String> parseJsonList(String json) {
// 실제로는 Jackson 등을 사용하여 파싱
if (json == null || json.isEmpty()) {
return List.of();
}
return Arrays.asList(json.replace("[", "").replace("]", "").replace("\"", "").split(","));
}
/**
* List를 JSON 문자열로 변환
*/
private String toJsonString(List<String> list) {
if (list == null || list.isEmpty()) {
return "[]";
}
return "[\"" + String.join("\",\"", list) + "\"]";
}
}
@@ -0,0 +1,15 @@
package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.domain.Analytics;
import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.usecase.out.AnalyticsPort;
import com.ktds.hi.analytics.infra.gateway.entity.AnalyticsEntity;
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.AiFeedbackJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Arrays; // 추가
import java.util.List; // 추가
import java.util.Optional;
@@ -0,0 +1,51 @@
package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.domain.ActionPlan;
import com.ktds.hi.analytics.biz.usecase.out.EventPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
/**
* 이벤트 어댑터 클래스
* Event Port를 구현하여 이벤트 발행 기능을 제공
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class EventAdapter implements EventPort {
private final ApplicationEventPublisher eventPublisher;
@Override
public void publishActionPlanCreatedEvent(ActionPlan actionPlan) {
log.info("실행 계획 생성 이벤트 발행: planId={}, storeId={}", actionPlan.getId(), actionPlan.getStoreId());
try {
// 실행 계획 생성 이벤트 객체 생성 및 발행
ActionPlanCreatedEvent event = new ActionPlanCreatedEvent(actionPlan);
eventPublisher.publishEvent(event);
log.info("실행 계획 생성 이벤트 발행 완료: planId={}", actionPlan.getId());
} catch (Exception e) {
log.error("실행 계획 생성 이벤트 발행 실패: planId={}, error={}", actionPlan.getId(), e.getMessage(), e);
}
}
/**
* 실행 계획 생성 이벤트 클래스
*/
public static class ActionPlanCreatedEvent {
private final ActionPlan actionPlan;
public ActionPlanCreatedEvent(ActionPlan actionPlan) {
this.actionPlan = actionPlan;
}
public ActionPlan getActionPlan() {
return actionPlan;
}
}
}
@@ -0,0 +1,85 @@
package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 외부 리뷰 어댑터 클래스
* External Review Port를 구현하여 외부 리뷰 데이터 연동 기능을 제공
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ExternalReviewAdapter implements ExternalReviewPort {
@Override
public List<String> getReviewData(Long storeId) {
log.info("외부 리뷰 데이터 조회 시작: storeId={}", storeId);
try {
// 실제로는 Review Service와 연동하여 리뷰 데이터를 가져옴
// Mock 데이터 반환
List<String> reviews = new ArrayList<>();
reviews.add("음식이 정말 맛있고 서비스도 친절해요. 다음에 또 올게요!");
reviews.add("가격 대비 양이 많고 맛도 괜찮습니다. 추천해요.");
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;
} catch (Exception e) {
log.error("외부 리뷰 데이터 조회 실패: storeId={}, error={}", storeId, e.getMessage(), e);
return List.of();
}
}
@Override
public List<String> getRecentReviews(Long storeId, Integer days) {
log.info("최근 리뷰 데이터 조회 시작: storeId={}, days={}", storeId, days);
try {
// 실제로는 최근 N일간의 리뷰만 필터링
List<String> allReviews = getReviewData(storeId);
// Mock: 최근 리뷰는 전체 리뷰의 70% 정도로 가정
int recentCount = (int) (allReviews.size() * 0.7);
List<String> recentReviews = allReviews.subList(0, Math.min(recentCount, allReviews.size()));
log.info("최근 리뷰 데이터 조회 완료: storeId={}, days={}, count={}", storeId, days, recentReviews.size());
return recentReviews;
} catch (Exception e) {
log.error("최근 리뷰 데이터 조회 실패: storeId={}, days={}, error={}", storeId, days, e.getMessage(), e);
return List.of();
}
}
@Override
public Integer getReviewCount(Long storeId) {
log.info("리뷰 수 조회 시작: storeId={}", storeId);
try {
List<String> reviews = getReviewData(storeId);
int count = reviews.size();
log.info("리뷰 수 조회 완료: storeId={}, count={}", storeId, count);
return count;
} catch (Exception e) {
log.error("리뷰 수 조회 실패: storeId={}, error={}", storeId, e.getMessage(), e);
return 0;
}
}
}
@@ -0,0 +1,78 @@
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 jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.List;
/**
* 실행 계획 엔티티 클래스
* 데이터베이스 action_plans 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "action_plans")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ActionPlanEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(length = 50)
private String period;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
@Builder.Default
private PlanStatus status = PlanStatus.PLANNED;
@Column(name = "feedback_ids_json", columnDefinition = "TEXT")
private String feedbackIdsJson;
@Column(columnDefinition = "TEXT")
private String note;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
/**
* JSON 문자열을 List로 변환
*/
public List<Long> getFeedbackIdsList() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(feedbackIdsJson, new TypeReference<List<Long>>() {});
} catch (Exception e) {
return List.of();
}
}
}
@@ -0,0 +1,93 @@
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.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* AI 피드백 엔티티 클래스
* 데이터베이스 ai_feedback 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "ai_feedback")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class AiFeedbackEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(columnDefinition = "TEXT")
private String summary;
@Column(length = 20)
private String sentiment;
@Column(name = "positive_points_json", columnDefinition = "TEXT")
private String positivePointsJson;
@Column(name = "negative_points_json", columnDefinition = "TEXT")
private String negativePointsJson;
@Column(name = "recommendations_json", columnDefinition = "TEXT")
private String recommendationsJson;
@Column(precision = 3, scale = 2)
private BigDecimal confidence;
@Column(name = "analysis_date")
private LocalDate analysisDate;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
/**
* JSON 문자열을 객체로 변환하는 메서드들
*/
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() {
try {
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();
}
}
}
@@ -0,0 +1,107 @@
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.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 통계 엔티티 클래스
* 데이터베이스 order_statistics 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "order_statistics")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class StatisticsEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "analysis_date")
private LocalDate analysisDate;
@Column(name = "total_orders")
private Integer totalOrders;
@Column(name = "total_revenue", precision = 15, scale = 2)
private BigDecimal totalRevenue;
@Column(name = "avg_order_amount", precision = 10, scale = 2)
private BigDecimal avgOrderAmount;
@Column(name = "peak_hour")
private Integer peakHour;
@Column(name = "age_statistics_json", columnDefinition = "TEXT")
private String ageStatisticsJson;
@Column(name = "gender_statistics_json", columnDefinition = "TEXT")
private String genderStatisticsJson;
@Column(name = "time_statistics_json", columnDefinition = "TEXT")
private String timeStatisticsJson;
@Column(name = "menu_popularity_json", columnDefinition = "TEXT")
private String menuPopularityJson;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
/**
* JSON 문자열을 객체로 변환하는 메서드들
*/
public Map<String, Object> getAgeStatisticsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(ageStatisticsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public Map<String, Object> getGenderStatisticsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(genderStatisticsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public Map<String, Object> getTimeStatisticsMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(timeStatisticsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
public Map<String, Object> getMenuPopularityMap() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(menuPopularityJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
}
@@ -0,0 +1,36 @@
package com.ktds.hi.analytics.infra.gateway.repository;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 실행 계획 JPA 리포지토리 인터페이스
* 실행 계획 데이터의 CRUD 작업을 담당
*/
@Repository
public interface ActionPlanJpaRepository extends JpaRepository<ActionPlanEntity, Long> {
/**
* 매장 ID로 실행 계획 목록 조회 (최신순)
*/
List<ActionPlanEntity> findByStoreIdOrderByCreatedAtDesc(Long storeId);
/**
* 매장 ID와 상태로 실행 계획 목록 조회 (최신순)
*/
List<ActionPlanEntity> findByStoreIdAndStatusOrderByCreatedAtDesc(Long storeId, PlanStatus status);
/**
* 사용자 ID로 실행 계획 목록 조회
*/
List<ActionPlanEntity> findByUserIdOrderByCreatedAtDesc(Long userId);
/**
* 매장 ID와 사용자 ID로 실행 계획 목록 조회
*/
List<ActionPlanEntity> findByStoreIdAndUserIdOrderByCreatedAtDesc(Long storeId, Long userId);
}
@@ -0,0 +1,32 @@
package com.ktds.hi.analytics.infra.gateway.repository;
import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
/**
* AI 피드백 JPA 리포지토리 인터페이스
* AI 피드백 데이터의 CRUD 작업을 담당
*/
@Repository
public interface AiFeedbackJpaRepository extends JpaRepository<AiFeedbackEntity, Long> {
/**
* 매장 ID와 분석 기간으로 AI 피드백 목록 조회
*/
List<AiFeedbackEntity> findByStoreIdAndAnalysisDateBetweenOrderByAnalysisDateDesc(
Long storeId, LocalDate startDate, LocalDate endDate);
/**
* 매장 ID로 최신 AI 피드백 조회
*/
AiFeedbackEntity findTopByStoreIdOrderByCreatedAtDesc(Long storeId);
/**
* 특정 날짜의 AI 피드백 조회
*/
List<AiFeedbackEntity> findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate);
}
@@ -0,0 +1,33 @@
package com.ktds.hi.analytics.infra.gateway.repository;
import com.ktds.hi.analytics.infra.gateway.entity.StatisticsEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
/**
* 통계 JPA 리포지토리 인터페이스
* 통계 데이터의 CRUD 작업을 담당
*/
@Repository
public interface StatisticsJpaRepository extends JpaRepository<StatisticsEntity, Long> {
/**
* 매장 ID와 분석 기간으로 통계 조회
*/
List<StatisticsEntity> findByStoreIdAndAnalysisDateBetween(
Long storeId, LocalDate startDate, LocalDate endDate);
/**
* 매장 ID와 특정 날짜로 통계 조회
*/
Optional<StatisticsEntity> findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate);
/**
* 매장 ID로 최신 통계 조회
*/
Optional<StatisticsEntity> findTopByStoreIdOrderByAnalysisDateDesc(Long storeId);
}