From 496e11e43ca29a8510a90b9cb6b1eb6ca5412da9 Mon Sep 17 00:00:00 2001 From: youbeen Date: Thu, 19 Jun 2025 13:17:29 +0900 Subject: [PATCH] store tag insert --- .../biz/usecase/out/AIServicePort.java | 92 +- .../biz/usecase/out/AnalyticsPort.java | 90 +- .../biz/usecase/out/ExternalReviewPort.java | 78 +- .../dto/CustomerPositiveReviewResponse.java | 62 +- .../infra/gateway/AIServiceAdapter.java | 1298 +++++++-------- .../infra/gateway/ExternalReviewAdapter.java | 586 +++---- dump.rdb | Bin 493 -> 493 bytes logs/recommend-service.log.2025-06-18.0.gz | Bin 0 -> 4076 bytes nano.save | 0 nano.save.1 | 1 + .../hi/store/biz/service/StoreService.java | 6 +- .../hi/store/biz/usecase/in/StoreUseCase.java | 2 + .../infra/controller/StoreController.java | 10 +- .../infra/dto/response/StoreListResponse.java | 3 + .../gateway/ExternalPlatformAdapter.java | 1450 ++++++++--------- .../infra/gateway/entity/StoreEntity.java | 2 + .../repository/StoreJpaRepository.java | 3 + 17 files changed, 1853 insertions(+), 1830 deletions(-) create mode 100644 logs/recommend-service.log.2025-06-18.0.gz create mode 100644 nano.save create mode 100644 nano.save.1 diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AIServicePort.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AIServicePort.java index a239354..b5c7fb5 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AIServicePort.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AIServicePort.java @@ -1,46 +1,46 @@ -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; -import java.util.Map; - -/** - * AI 서비스 포트 인터페이스 - * 외부 AI API 연동을 위한 출력 포트 - */ -public interface AIServicePort { - - /** - * AI 피드백 생성 - */ - AiFeedback generateFeedback(List reviewData); - - /** - * 감정 분석 - */ - SentimentType analyzeSentiment(String content); - - /** - * 대량 리뷰 감정 분석 (새로 추가) - * 여러 리뷰를 한 번에 분석하여 긍정/부정/중립 개수 반환 - * - * @param reviews 분석할 리뷰 목록 - * @return 감정 타입별 개수 맵 - */ - Map analyzeBulkSentiments(List reviews); - - /** - * 실행 계획 생성 - */ - List generateActionPlan(List actionPlanSelect, AiFeedback feedback); - - // 🔥 고객용 긍정 리뷰 요약 생성 메서드 추가 - /** - * 긍정적인 리뷰만을 분석하여 고객용 요약 생성 - * @param positiveReviews 긍정적인 리뷰 목록 - * @return 고객에게 보여줄 긍정적인 요약 - */ - String generateCustomerPositiveSummary(List positiveReviews); -} +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; +import java.util.Map; + +/** + * AI 서비스 포트 인터페이스 + * 외부 AI API 연동을 위한 출력 포트 + */ +public interface AIServicePort { + + /** + * AI 피드백 생성 + */ + AiFeedback generateFeedback(List reviewData); + + /** + * 감정 분석 + */ + SentimentType analyzeSentiment(String content); + + /** + * 대량 리뷰 감정 분석 (새로 추가) + * 여러 리뷰를 한 번에 분석하여 긍정/부정/중립 개수 반환 + * + * @param reviews 분석할 리뷰 목록 + * @return 감정 타입별 개수 맵 + */ + Map analyzeBulkSentiments(List reviews); + + /** + * 실행 계획 생성 + */ + List generateActionPlan(List actionPlanSelect, AiFeedback feedback); + + // 🔥 고객용 긍정 리뷰 요약 생성 메서드 추가 + /** + * 긍정적인 리뷰만을 분석하여 고객용 요약 생성 + * @param positiveReviews 긍정적인 리뷰 목록 + * @return 고객에게 보여줄 긍정적인 요약 + */ + String generateCustomerPositiveSummary(List positiveReviews); +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AnalyticsPort.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AnalyticsPort.java index ce77a32..cdfed98 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AnalyticsPort.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AnalyticsPort.java @@ -1,45 +1,45 @@ -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 findAnalyticsByStoreId(Long storeId); - - /** - * 분석 데이터 저장 - */ - Analytics saveAnalytics(Analytics analytics); - - /** - * 매장 ID로 AI 피드백 조회 - */ - Optional findAIFeedbackByStoreId(Long storeId); - - /** - * 매장 ID로 AI 긍정 피드백 조회(고객용) - */ - Optional findPositiveAIFeedbackByStoreId(Long storeId); - - - /** - * AI 피드백 ID로 조회 (추가된 메서드) - */ - Optional findAIFeedbackById(Long feedbackId); - - - /** - * AI 피드백 저장 - */ - AiFeedback saveAIFeedback(AiFeedback feedback); -} +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 findAnalyticsByStoreId(Long storeId); + + /** + * 분석 데이터 저장 + */ + Analytics saveAnalytics(Analytics analytics); + + /** + * 매장 ID로 AI 피드백 조회 + */ + Optional findAIFeedbackByStoreId(Long storeId); + + /** + * 매장 ID로 AI 긍정 피드백 조회(고객용) + */ + Optional findPositiveAIFeedbackByStoreId(Long storeId); + + + /** + * AI 피드백 ID로 조회 (추가된 메서드) + */ + Optional findAIFeedbackById(Long feedbackId); + + + /** + * AI 피드백 저장 + */ + AiFeedback saveAIFeedback(AiFeedback feedback); +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/ExternalReviewPort.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/ExternalReviewPort.java index fb51da2..6be3d94 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/ExternalReviewPort.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/ExternalReviewPort.java @@ -1,39 +1,39 @@ -package com.ktds.hi.analytics.biz.usecase.out; - -import java.util.List; - -/** - * 외부 리뷰 데이터 포트 인터페이스 - * 리뷰 서비스와의 연동을 위한 출력 포트 - */ -public interface ExternalReviewPort { - - /** - * 매장의 리뷰 데이터 조회 - */ - List getReviewData(Long storeId); - - /** - * 최근 리뷰 데이터 조회 - */ - List getRecentReviews(Long storeId, Integer days); - - // 🔥 긍정적인 리뷰만 조회하는 메서드 추가 - /** - * 긍정적인 리뷰만 조회 (평점 4점 이상) - * @param storeId 매장 ID - * @param days 조회 기간 (일) - * @return 긍정적인 리뷰 목록 - */ - List getPositiveReviews(Long storeId, Integer days); - - /** - * 리뷰 개수 조회 - */ - Integer getReviewCount(Long storeId); - - /** - * 평균 평점 조회 - */ - Double getAverageRating(Long storeId); -} +package com.ktds.hi.analytics.biz.usecase.out; + +import java.util.List; + +/** + * 외부 리뷰 데이터 포트 인터페이스 + * 리뷰 서비스와의 연동을 위한 출력 포트 + */ +public interface ExternalReviewPort { + + /** + * 매장의 리뷰 데이터 조회 + */ + List getReviewData(Long storeId); + + /** + * 최근 리뷰 데이터 조회 + */ + List getRecentReviews(Long storeId, Integer days); + + // 🔥 긍정적인 리뷰만 조회하는 메서드 추가 + /** + * 긍정적인 리뷰만 조회 (평점 4점 이상) + * @param storeId 매장 ID + * @param days 조회 기간 (일) + * @return 긍정적인 리뷰 목록 + */ + List getPositiveReviews(Long storeId, Integer days); + + /** + * 리뷰 개수 조회 + */ + Integer getReviewCount(Long storeId); + + /** + * 평균 평점 조회 + */ + Double getAverageRating(Long storeId); +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/CustomerPositiveReviewResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/CustomerPositiveReviewResponse.java index b12f898..bd2b3ca 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/CustomerPositiveReviewResponse.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/CustomerPositiveReviewResponse.java @@ -1,31 +1,31 @@ -package com.ktds.hi.analytics.infra.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -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 -@Schema(description = "고객용 긍정 리뷰 응답") -public class CustomerPositiveReviewResponse { - - @Schema(description = "매장 ID") - private Long storeId; - - @Schema(description = "긍정적인 리뷰 요약") - private String positiveSummary; - - @Schema(description = "분석 일시") - private LocalDateTime analyzedAt; -} - +package com.ktds.hi.analytics.infra.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +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 +@Schema(description = "고객용 긍정 리뷰 응답") +public class CustomerPositiveReviewResponse { + + @Schema(description = "매장 ID") + private Long storeId; + + @Schema(description = "긍정적인 리뷰 요약") + private String positiveSummary; + + @Schema(description = "분석 일시") + private LocalDateTime analyzedAt; +} + diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java index f58d74d..d01c2c7 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java @@ -1,649 +1,649 @@ -package com.ktds.hi.analytics.infra.gateway; - -import static com.azure.ai.textanalytics.models.TextSentiment.*; - -import com.azure.ai.textanalytics.TextAnalyticsClient; -import com.azure.ai.textanalytics.TextAnalyticsClientBuilder; -import com.azure.ai.textanalytics.models.AnalyzeSentimentResult; -import com.azure.ai.textanalytics.models.DocumentSentiment; -import com.azure.ai.textanalytics.models.TextSentiment; -import com.azure.core.credential.AzureKeyCredential; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -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 jakarta.annotation.PostConstruct; -import lombok.Data; -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.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * AI 서비스 어댑터 클래스 - * OpenAI, Azure Cognitive Services 등 외부 AI API 연동 - */ -@Slf4j -@Component -public class AIServiceAdapter implements AIServicePort { - - - @Value("${ai-api.openai.base-url:https://api.openai.com/v1}") - private String openaiBaseUrl; - - @Value("${ai-api.openai.api-key}") - private String openaiApiKey; - - @Value("${ai-api.openai.model:gpt-4o-mini}") - private String openaiModel; - - private TextAnalyticsClient textAnalyticsClient; - - private RestTemplate restTemplate; - private ObjectMapper objectMapper; - - @PostConstruct - public void initializeClients() { - // Azure Cognitive Services 클라이언트 초기화 - // textAnalyticsClient = new TextAnalyticsClientBuilder() - // .credential(new AzureKeyCredential(cognitiveKey)) - // .endpoint(cognitiveEndpoint) - // .buildClient(); - // - // log.info("AI 서비스 클라이언트 초기화 완료"); - - // OpenAI API 클라이언트 초기화 - restTemplate = new RestTemplate(); - objectMapper = new ObjectMapper(); - - if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) { - log.warn("OpenAI API 키가 설정되지 않았습니다. AI 기능이 제한될 수 있습니다."); - } else { - log.info("OpenAI API 클라이언트 초기화 완료"); - } - } - - @Override - public AiFeedback generateFeedback(List reviewData) { - - log.info("OpenAI 피드백 생성 시작: 리뷰 수={}", reviewData.size()); - - try { - if (reviewData.isEmpty()) { - return createEmptyFeedback(); - } - - // OpenAI API 호출하여 전체 리뷰 분석 - String analysisResult = callOpenAIForAnalysis(reviewData).replace("`", ""); - - // 결과 파싱 및 AiFeedback 객체 생성 - return parseAnalysisResult(analysisResult, reviewData.size()); - - } catch (Exception e) { - log.error("OpenAI 피드백 생성 중 오류 발생", e); - return createFallbackFeedback(reviewData); - } - } - - @Override - public Map analyzeBulkSentiments(List reviews) { - log.info("대량 리뷰 감정 분석 시작: 리뷰 수={}", reviews.size()); - - try { - if (reviews.isEmpty()) { - return createEmptyResultMap(); - } - - // 유효한 리뷰만 필터링 - List validReviews = reviews.stream() - .filter(review -> review != null && !review.trim().isEmpty()) - .collect(Collectors.toList()); - - if (validReviews.isEmpty()) { - return createEmptyResultMap(); - } - - // 리뷰를 번호와 함께 포맷팅 - StringBuilder reviewsText = new StringBuilder(); - for (int i = 0; i < validReviews.size(); i++) { - reviewsText.append(String.format("%d. %s\n", i + 1, validReviews.get(i))); - } - - String prompt = String.format( - """ - 다음 리뷰들을 분석하여 긍정, 부정, 중립의 개수를 세어주세요. - - 리뷰 목록: - %s - - 결과를 다음 JSON 형식으로만 답변해주세요: - { - "positive": 긍정_개수, - "negative": 부정_개수, - "neutral": 중립_개수 - } - - 다른 설명은 하지 말고 JSON만 답변해주세요. - 긍정,부정,중립 개수를 모두 더했을때, 총 리뷰수와 동일해야 합니다. - 정확하게 세어주세요. - """, - reviewsText.toString() - ); - - // 기존 callOpenAI 메서드 활용 - String result = callOpenAI(prompt); - - // 결과 파싱 - Map sentimentMap = parseBulkSentimentResult(result, validReviews.size()); - - log.info("대량 리뷰 감정 분석 완료: 긍정={}, 부정={}, 중립={}", - sentimentMap.get(SentimentType.POSITIVE), - sentimentMap.get(SentimentType.NEGATIVE), - sentimentMap.get(SentimentType.NEUTRAL)); - - return sentimentMap; - - } catch (Exception e) { - log.error("대량 리뷰 감정 분석 중 오류 발생, fallback 사용", e); - return createFallbackResultMap(reviews.size()); - } - } - - private Map parseBulkSentimentResult(String result, int totalReviews) { - try { - // 기존 objectMapper 필드 사용 - Map jsonResult = objectMapper.readValue(result.trim(), Map.class); - - int positive = ((Number) jsonResult.getOrDefault("positive", 0)).intValue(); - int negative = ((Number) jsonResult.getOrDefault("negative", 0)).intValue(); - int neutral = ((Number) jsonResult.getOrDefault("neutral", 0)).intValue(); - - // 결과 검증 및 보정 - int totalAnalyzed = positive + negative + neutral; - if (totalAnalyzed != totalReviews) { - log.warn("분석 결과 불일치 보정: 분석된 수={}, 실제 리뷰 수={}", totalAnalyzed, totalReviews); - int difference = totalReviews - totalAnalyzed; - neutral += difference; - } - - Map resultMap = new HashMap<>(); - resultMap.put(SentimentType.POSITIVE, Math.max(0, positive)); - resultMap.put(SentimentType.NEGATIVE, Math.max(0, negative)); - resultMap.put(SentimentType.NEUTRAL, Math.max(0, neutral)); - - return resultMap; - - } catch (Exception e) { - log.error("대량 감정 분석 결과 파싱 실패: {}", result, e); - return createFallbackResultMap(totalReviews); - } - } - - private Map createEmptyResultMap() { - Map result = new HashMap<>(); - result.put(SentimentType.POSITIVE, 0); - result.put(SentimentType.NEGATIVE, 0); - result.put(SentimentType.NEUTRAL, 0); - return result; - } - - private Map createFallbackResultMap(int totalReviews) { - Map result = new HashMap<>(); - result.put(SentimentType.POSITIVE, (int) (totalReviews * 0.6)); - result.put(SentimentType.NEGATIVE, (int) (totalReviews * 0.2)); - result.put(SentimentType.NEUTRAL, totalReviews - (int) (totalReviews * 0.6) - (int) (totalReviews * 0.2)); - return result; - } - - - @Override - public SentimentType analyzeSentiment(String content) { - try { - String prompt = String.format( - "다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로만 답변해주세요.\n\n리뷰: %s", - content - ); - - String result = callOpenAI(prompt); - - if (result.toUpperCase().contains("POSITIVE")) { - return SentimentType.POSITIVE; - } else if (result.toUpperCase().contains("NEGATIVE")) { - return SentimentType.NEGATIVE; - } else { - return SentimentType.NEUTRAL; - } - - } catch (Exception e) { - log.warn("OpenAI 감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length()))); - return SentimentType.NEUTRAL; - } - } - - @Override - public List generateActionPlan(List actionPlanSelect, AiFeedback feedback) { - log.info("OpenAI 실행 계획 생성 시작"); - try { - - StringBuffer planFormat = new StringBuffer(); - for(int i = 1; i <= actionPlanSelect.size(); i++) { - planFormat.append(i).append(" [구체적인 실행 계획 ").append(i).append("]\n"); - } - String prompt = String.format( - """ - 다음 AI 피드백을 바탕으로 구체적인 실행 계획 %s개를 생성해주세요. - 각 계획은 실행 가능하고 구체적이어야 합니다. - - 요약: %s - 개선점: %s - - 실행계획 내용은 점주가 반영할 수 있도록 구체적어야 합니다. - 실행 계획을 다음 형식으로 작성해주세요: - %s - """, - actionPlanSelect.size(), - feedback.getSummary(), - String.join(", ", actionPlanSelect), - planFormat - ); - - String result = callOpenAI(prompt); - return parseActionPlans(result); - - } catch (Exception e) { - log.error("OpenAI 실행 계획 생성 중 오류 발생", e); - //TODO : 시연을 위해서 우선은 아래과 같이 처리, 추후에는 실행계획이 실패했다면 Runtime계열의 예외를 던지는게 좋을듯. - return Arrays.asList( - "실행계획 생성 실패. 재생성 필요" - ); - } - } - - @Override - public String generateCustomerPositiveSummary(List positiveReviews) { - return ""; - } - - /** - * OpenAI API를 호출하여 전체 리뷰 분석 수행 - */ - private String callOpenAIForAnalysis(List reviewData) { - String reviewsText = String.join("\n- ", reviewData); - - String prompt = String.format( - """ - 다음은 한 매장의 고객 리뷰들입니다. 이를 분석하여 다음 JSON 형식으로 답변해주세요: - - { - "summary": "전체적인 분석 요약(1-2문장)", - "positivePoints": ["긍정적 요소1", "긍정적 요소2", "긍정적 요소3"], - "negativePoints": ["부정적 요소1", "부정적 요소2", "부정적 요소3"], - "improvementPoints": ["개선점1", "개선점2", "개선점3"], - "recommendations": ["추천사항1", "추천사항2", "추천사항3"], - "sentimentAnalysis": "전체적인 감정 분석 결과", - "confidenceScore": 0.85 - "positiveSummary": "리뷰중에 긍정적인 내용만 분석 요약(1~2문장)" - } - - 리뷰 목록: - - %s - - 분석 시 다음 사항을 고려해주세요: - 1. 긍정적 요소는 고객들이 자주 언급하는 좋은 점들 - 2. 부정적 요소는 고객들이 자주 언급하는 안좋은 점들 - 2. 개선점은 부정적 피드백이나 불만사항 - 3. 추천사항은 매장 운영에 도움이 될 구체적인 제안 - 4. 신뢰도 점수는 0.0-1.0 사이의 값으로 리뷰정보를 보고 적절히 판단. - 5. summary에는 전체적인 리뷰 분석에 대한 요약이 잘 담기게 작성하고 **같은 강조하는 문자 없이 텍스트로만 나타내주세요 - 6. 분석한 내용에 `(백틱) 이 들어가지 않도록 해주세요. - 7. positiveSummary에는 긍정적인 내용만 있어야 합니다, summary에 있는 내용에서 긍정적인 부분만 작성해주세요. - """, - reviewsText - ); - - return callOpenAI(prompt); - } - - /** - * OpenAI API 호출 - */ - private String callOpenAI(String prompt) { - if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) { - throw new RuntimeException("OpenAI API 키가 설정되지 않았습니다."); - } - - try { - // 요청 헤더 설정 - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setBearerAuth(openaiApiKey); - - // 요청 바디 생성 - OpenAIRequest request = OpenAIRequest.builder() - .model(openaiModel) - .messages(List.of( - OpenAIMessage.builder() - .role("user") - .content(prompt) - .build() - )) - .maxTokens(1500) - .temperature(0.7) - .build(); - - String requestBody = objectMapper.writeValueAsString(request); - HttpEntity entity = new HttpEntity<>(requestBody, headers); - - // API 호출 - String url = openaiBaseUrl + "/chat/completions"; - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.POST, - entity, - String.class - ); - - // 응답 파싱 - return parseOpenAIResponse(response.getBody()); - - } catch (Exception e) { - log.error("OpenAI API 호출 실패", e); - throw new RuntimeException("OpenAI API 호출에 실패했습니다.", e); - } - } - - /** - * OpenAI 응답 파싱 - */ - private String parseOpenAIResponse(String responseBody) { - try { - Map response = objectMapper.readValue(responseBody, Map.class); - List> choices = (List>) response.get("choices"); - - if (choices != null && !choices.isEmpty()) { - Map message = (Map) choices.get(0).get("message"); - return (String) message.get("content"); - } - - throw new RuntimeException("OpenAI 응답에서 내용을 찾을 수 없습니다."); - - } catch (JsonProcessingException e) { - log.error("OpenAI 응답 파싱 실패", e); - throw new RuntimeException("OpenAI 응답 파싱에 실패했습니다.", e); - } - } - - /** - * 분석 결과를 AiFeedback 객체로 파싱 - */ - private AiFeedback parseAnalysisResult(String analysisResult, int totalReviews) { - try { - - if(analysisResult.contains("`")){ - log.info("tesafadsf1241241"); - } - - // JSON 형태로 응답이 왔다고 가정하고 파싱 - Map result = objectMapper.readValue(analysisResult, Map.class); - - return AiFeedback.builder() - .summary((String) result.get("summary")) - .positivePoints((List) result.get("positivePoints")) - .negativePoints((List) result.get("negativePoints")) - .improvementPoints((List) result.get("improvementPoints")) - .recommendations((List) result.get("recommendations")) - .sentimentAnalysis((String) result.get("sentimentAnalysis")) - .positiveSummary((String) result.get("positiveSummary")) - .confidenceScore(((Number) result.get("confidenceScore")).doubleValue()) - .generatedAt(LocalDateTime.now()) - .build(); - - } catch (Exception e) { - log.warn("OpenAI 분석 결과 파싱 실패, 기본 분석 수행", e); - return performBasicAnalysis(analysisResult, totalReviews); - } - } - - /** - * 기본 분석 수행 (파싱 실패 시 fallback) - */ - private AiFeedback performBasicAnalysis(String analysisResult, int totalReviews) { - return AiFeedback.builder() - .summary(String.format("총 %d개의 리뷰를 AI로 분석했습니다.", totalReviews)) - .positivePoints(Arrays.asList("고객 서비스", "음식 품질", "매장 분위기")) - .improvementPoints(Arrays.asList("대기시간 단축", "메뉴 다양성", "가격 경쟁력")) - .recommendations(Arrays.asList("고객 피드백 적극 반영", "서비스 교육 강화", "매장 환경 개선")) - .sentimentAnalysis("전반적으로 긍정적인 평가") - .confidenceScore(0.75) - .generatedAt(LocalDateTime.now()) - .build(); - } - - /** - * 실행 계획 파싱 - */ - private List parseActionPlans(String result) { - // 숫자로 시작하는 라인들을 찾아서 실행 계획으로 추출 - String[] lines = result.split("\n"); - return Arrays.stream(lines) - .filter(line -> line.matches("^\\d+\\..*")) - .map(line -> line.replaceFirst("^\\d+\\.\\s*", "").trim()) - .filter(line -> !line.isEmpty()) - .limit(5) // 최대 5개까지 - .toList(); - } - - /** - * 빈 피드백 생성 - */ - private AiFeedback createEmptyFeedback() { - return AiFeedback.builder() - .summary("분석할 리뷰 데이터가 없습니다.") - .positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가")) - .improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요")) - .recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행")) - .sentimentAnalysis("데이터 부족") - .confidenceScore(0.0) - .generatedAt(LocalDateTime.now()) - .build(); - } - - /** - * Fallback 피드백 생성 (OpenAI 호출 실패 시) - */ - private AiFeedback createFallbackFeedback(List reviewData) { - log.warn("OpenAI 호출 실패로 fallback 분석 수행"); - - // 간단한 키워드 기반 분석 - long positiveCount = reviewData.stream() - .mapToLong(review -> countPositiveKeywords(review)) - .sum(); - - long negativeCount = reviewData.stream() - .mapToLong(review -> countNegativeKeywords(review)) - .sum(); - - double positiveRate = positiveCount > 0 ? (double) positiveCount / (positiveCount + negativeCount) * 100 : 50.0; - - return AiFeedback.builder() - .summary(String.format("총 %d개의 리뷰를 분석했습니다. (간편 분석)", reviewData.size())) - .positivePoints(Arrays.asList("서비스", "맛", "분위기")) - .improvementPoints(Arrays.asList("대기시간", "가격", "청결도")) - .recommendations(Arrays.asList("고객 의견 수렴", "서비스 개선", "품질 향상")) - .sentimentAnalysis(String.format("긍정 비율: %.1f%%", positiveRate)) - .confidenceScore(0.6) - .generatedAt(LocalDateTime.now()) - .build(); - } - - private long countPositiveKeywords(String review) { - String[] positiveWords = {"좋", "맛있", "친절", "깨끗", "만족", "추천", "최고"}; - return Arrays.stream(positiveWords) - .mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0) - .sum(); - } - - private long countNegativeKeywords(String review) { - String[] negativeWords = {"나쁘", "맛없", "불친절", "더럽", "실망", "최악", "별로"}; - return Arrays.stream(negativeWords) - .mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0) - .sum(); - } - - // OpenAI API 요청/응답 DTO 클래스들 - @Data - @lombok.Builder - private static class OpenAIRequest { - private String model; - private List messages; - @JsonProperty("max_tokens") - private Integer maxTokens; - private Double temperature; - } - - @Data - @lombok.Builder - private static class OpenAIMessage { - private String role; - private String content; - } - - - /** - * 요약 생성 - */ - private String generateSummary(double positiveRate, double negativeRate, int totalReviews) { - if (positiveRate > 70) { - return String.format("총 %d개의 리뷰 중 %.1f%%가 긍정적입니다. 고객 만족도가 높은 수준입니다.", - totalReviews, positiveRate); - } else if (negativeRate > 30) { - return String.format("총 %d개의 리뷰 중 %.1f%%가 부정적입니다. 서비스 개선이 필요합니다.", - totalReviews, negativeRate); - } else { - return String.format("총 %d개의 리뷰로 분석한 결과, 전반적으로 평균적인 고객 만족도를 보입니다.", - totalReviews); - } - } - - /** - * 긍정적 요소 생성 - */ - private List generatePositivePoints(List reviewData, List sentiments) { - List positivePoints = new ArrayList<>(); - - long positiveCount = sentiments.stream().mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0).sum(); - double positiveRate = (double) positiveCount / reviewData.size() * 100; - - if (positiveRate > 70) { - positivePoints.add("고객 만족도가 매우 높습니다"); - positivePoints.add("전반적으로 긍정적인 평가를 받고 있습니다"); - positivePoints.add("재방문 의향이 높은 고객들이 많습니다"); - } else if (positiveRate > 50) { - positivePoints.add("평균 이상의 고객 만족도를 보입니다"); - positivePoints.add("많은 고객들이 만족하고 있습니다"); - } else { - positivePoints.add("일부 고객들이 긍정적으로 평가하고 있습니다"); - positivePoints.add("개선의 여지가 있습니다"); - } - - return positivePoints; - } - - /** - * 개선점 생성 - */ - private List generateImprovementPoints(List reviewData, List sentiments) { - List improvementPoints = new ArrayList<>(); - - long negativeCount = sentiments.stream().mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0).sum(); - double negativeRate = (double) negativeCount / reviewData.size() * 100; - - if (negativeRate > 30) { - improvementPoints.add("고객 서비스 품질 개선이 시급합니다"); - improvementPoints.add("부정적 피드백에 대한 체계적 대응이 필요합니다"); - improvementPoints.add("근본적인 서비스 개선 방안을 마련해야 합니다"); - } else if (negativeRate > 15) { - improvementPoints.add("일부 서비스 영역에서 개선이 필요합니다"); - improvementPoints.add("고객 만족도 향상을 위한 노력이 필요합니다"); - } else { - improvementPoints.add("현재 서비스 수준을 유지하며 세부 개선점을 찾아보세요"); - improvementPoints.add("더 높은 고객 만족을 위한 차별화 요소를 개발하세요"); - } - - return improvementPoints; - } - - /** - * 추천사항 생성 - */ - private List generateRecommendations(double positiveRate, double negativeRate) { - List recommendations = new ArrayList<>(); - - if (positiveRate > 70) { - recommendations.add("현재의 우수한 서비스를 유지하면서 브랜드 가치를 높이세요"); - recommendations.add("긍정적 리뷰를 마케팅 자료로 활용하세요"); - recommendations.add("고객 충성도 프로그램을 도입하세요"); - } else if (negativeRate > 30) { - recommendations.add("고객 불만사항에 대한 즉각적인 대응 체계를 구축하세요"); - recommendations.add("직원 교육을 통한 서비스 품질 향상에 집중하세요"); - recommendations.add("고객 피드백 수집 및 분석 프로세스를 강화하세요"); - } else { - recommendations.add("지속적인 품질 관리와 고객 만족도 모니터링을 실시하세요"); - recommendations.add("차별화된 서비스 제공을 통해 경쟁력을 강화하세요"); - recommendations.add("고객과의 소통을 늘려 관계를 강화하세요"); - } - - return recommendations; - } - - /** - * 신뢰도 점수 계산 - */ - private double calculateConfidenceScore(int reviewCount) { - if (reviewCount >= 50) { - return 0.9; - } else if (reviewCount >= 20) { - return 0.75; - } else if (reviewCount >= 10) { - return 0.6; - } else if (reviewCount >= 5) { - return 0.4; - } else { - return 0.2; - } - } - - /** - * 개선점을 실행 계획으로 변환 - */ - private String convertToActionPlan(String improvementPoint) { - // 개선점을 구체적인 실행계획으로 변환 - if (improvementPoint.contains("서비스 품질")) { - return "직원 서비스 교육 프로그램 실시 (월 1회, 2시간)"; - } else if (improvementPoint.contains("대기시간")) { - return "주문 처리 시스템 개선 및 대기열 관리 체계 도입"; - } else if (improvementPoint.contains("가격")) { - return "경쟁사 가격 분석 및 합리적 가격 정책 수립"; - } else if (improvementPoint.contains("메뉴")) { - return "고객 선호도 조사를 통한 메뉴 다양화 방안 검토"; - } else { - return "고객 피드백 기반 서비스 개선 계획 수립"; - } - } -} +package com.ktds.hi.analytics.infra.gateway; + +import static com.azure.ai.textanalytics.models.TextSentiment.*; + +import com.azure.ai.textanalytics.TextAnalyticsClient; +import com.azure.ai.textanalytics.TextAnalyticsClientBuilder; +import com.azure.ai.textanalytics.models.AnalyzeSentimentResult; +import com.azure.ai.textanalytics.models.DocumentSentiment; +import com.azure.ai.textanalytics.models.TextSentiment; +import com.azure.core.credential.AzureKeyCredential; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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 jakarta.annotation.PostConstruct; +import lombok.Data; +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.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * AI 서비스 어댑터 클래스 + * OpenAI, Azure Cognitive Services 등 외부 AI API 연동 + */ +@Slf4j +@Component +public class AIServiceAdapter implements AIServicePort { + + + @Value("${ai-api.openai.base-url:https://api.openai.com/v1}") + private String openaiBaseUrl; + + @Value("${ai-api.openai.api-key}") + private String openaiApiKey; + + @Value("${ai-api.openai.model:gpt-4o-mini}") + private String openaiModel; + + private TextAnalyticsClient textAnalyticsClient; + + private RestTemplate restTemplate; + private ObjectMapper objectMapper; + + @PostConstruct + public void initializeClients() { + // Azure Cognitive Services 클라이언트 초기화 + // textAnalyticsClient = new TextAnalyticsClientBuilder() + // .credential(new AzureKeyCredential(cognitiveKey)) + // .endpoint(cognitiveEndpoint) + // .buildClient(); + // + // log.info("AI 서비스 클라이언트 초기화 완료"); + + // OpenAI API 클라이언트 초기화 + restTemplate = new RestTemplate(); + objectMapper = new ObjectMapper(); + + if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) { + log.warn("OpenAI API 키가 설정되지 않았습니다. AI 기능이 제한될 수 있습니다."); + } else { + log.info("OpenAI API 클라이언트 초기화 완료"); + } + } + + @Override + public AiFeedback generateFeedback(List reviewData) { + + log.info("OpenAI 피드백 생성 시작: 리뷰 수={}", reviewData.size()); + + try { + if (reviewData.isEmpty()) { + return createEmptyFeedback(); + } + + // OpenAI API 호출하여 전체 리뷰 분석 + String analysisResult = callOpenAIForAnalysis(reviewData).replace("`", ""); + + // 결과 파싱 및 AiFeedback 객체 생성 + return parseAnalysisResult(analysisResult, reviewData.size()); + + } catch (Exception e) { + log.error("OpenAI 피드백 생성 중 오류 발생", e); + return createFallbackFeedback(reviewData); + } + } + + @Override + public Map analyzeBulkSentiments(List reviews) { + log.info("대량 리뷰 감정 분석 시작: 리뷰 수={}", reviews.size()); + + try { + if (reviews.isEmpty()) { + return createEmptyResultMap(); + } + + // 유효한 리뷰만 필터링 + List validReviews = reviews.stream() + .filter(review -> review != null && !review.trim().isEmpty()) + .collect(Collectors.toList()); + + if (validReviews.isEmpty()) { + return createEmptyResultMap(); + } + + // 리뷰를 번호와 함께 포맷팅 + StringBuilder reviewsText = new StringBuilder(); + for (int i = 0; i < validReviews.size(); i++) { + reviewsText.append(String.format("%d. %s\n", i + 1, validReviews.get(i))); + } + + String prompt = String.format( + """ + 다음 리뷰들을 분석하여 긍정, 부정, 중립의 개수를 세어주세요. + + 리뷰 목록: + %s + + 결과를 다음 JSON 형식으로만 답변해주세요: + { + "positive": 긍정_개수, + "negative": 부정_개수, + "neutral": 중립_개수 + } + + 다른 설명은 하지 말고 JSON만 답변해주세요. + 긍정,부정,중립 개수를 모두 더했을때, 총 리뷰수와 동일해야 합니다. + 정확하게 세어주세요. + """, + reviewsText.toString() + ); + + // 기존 callOpenAI 메서드 활용 + String result = callOpenAI(prompt); + + // 결과 파싱 + Map sentimentMap = parseBulkSentimentResult(result, validReviews.size()); + + log.info("대량 리뷰 감정 분석 완료: 긍정={}, 부정={}, 중립={}", + sentimentMap.get(SentimentType.POSITIVE), + sentimentMap.get(SentimentType.NEGATIVE), + sentimentMap.get(SentimentType.NEUTRAL)); + + return sentimentMap; + + } catch (Exception e) { + log.error("대량 리뷰 감정 분석 중 오류 발생, fallback 사용", e); + return createFallbackResultMap(reviews.size()); + } + } + + private Map parseBulkSentimentResult(String result, int totalReviews) { + try { + // 기존 objectMapper 필드 사용 + Map jsonResult = objectMapper.readValue(result.trim(), Map.class); + + int positive = ((Number) jsonResult.getOrDefault("positive", 0)).intValue(); + int negative = ((Number) jsonResult.getOrDefault("negative", 0)).intValue(); + int neutral = ((Number) jsonResult.getOrDefault("neutral", 0)).intValue(); + + // 결과 검증 및 보정 + int totalAnalyzed = positive + negative + neutral; + if (totalAnalyzed != totalReviews) { + log.warn("분석 결과 불일치 보정: 분석된 수={}, 실제 리뷰 수={}", totalAnalyzed, totalReviews); + int difference = totalReviews - totalAnalyzed; + neutral += difference; + } + + Map resultMap = new HashMap<>(); + resultMap.put(SentimentType.POSITIVE, Math.max(0, positive)); + resultMap.put(SentimentType.NEGATIVE, Math.max(0, negative)); + resultMap.put(SentimentType.NEUTRAL, Math.max(0, neutral)); + + return resultMap; + + } catch (Exception e) { + log.error("대량 감정 분석 결과 파싱 실패: {}", result, e); + return createFallbackResultMap(totalReviews); + } + } + + private Map createEmptyResultMap() { + Map result = new HashMap<>(); + result.put(SentimentType.POSITIVE, 0); + result.put(SentimentType.NEGATIVE, 0); + result.put(SentimentType.NEUTRAL, 0); + return result; + } + + private Map createFallbackResultMap(int totalReviews) { + Map result = new HashMap<>(); + result.put(SentimentType.POSITIVE, (int) (totalReviews * 0.6)); + result.put(SentimentType.NEGATIVE, (int) (totalReviews * 0.2)); + result.put(SentimentType.NEUTRAL, totalReviews - (int) (totalReviews * 0.6) - (int) (totalReviews * 0.2)); + return result; + } + + + @Override + public SentimentType analyzeSentiment(String content) { + try { + String prompt = String.format( + "다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로만 답변해주세요.\n\n리뷰: %s", + content + ); + + String result = callOpenAI(prompt); + + if (result.toUpperCase().contains("POSITIVE")) { + return SentimentType.POSITIVE; + } else if (result.toUpperCase().contains("NEGATIVE")) { + return SentimentType.NEGATIVE; + } else { + return SentimentType.NEUTRAL; + } + + } catch (Exception e) { + log.warn("OpenAI 감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length()))); + return SentimentType.NEUTRAL; + } + } + + @Override + public List generateActionPlan(List actionPlanSelect, AiFeedback feedback) { + log.info("OpenAI 실행 계획 생성 시작"); + try { + + StringBuffer planFormat = new StringBuffer(); + for(int i = 1; i <= actionPlanSelect.size(); i++) { + planFormat.append(i).append(" [구체적인 실행 계획 ").append(i).append("]\n"); + } + String prompt = String.format( + """ + 다음 AI 피드백을 바탕으로 구체적인 실행 계획 %s개를 생성해주세요. + 각 계획은 실행 가능하고 구체적이어야 합니다. + + 요약: %s + 개선점: %s + + 실행계획 내용은 점주가 반영할 수 있도록 구체적어야 합니다. + 실행 계획을 다음 형식으로 작성해주세요: + %s + """, + actionPlanSelect.size(), + feedback.getSummary(), + String.join(", ", actionPlanSelect), + planFormat + ); + + String result = callOpenAI(prompt); + return parseActionPlans(result); + + } catch (Exception e) { + log.error("OpenAI 실행 계획 생성 중 오류 발생", e); + //TODO : 시연을 위해서 우선은 아래과 같이 처리, 추후에는 실행계획이 실패했다면 Runtime계열의 예외를 던지는게 좋을듯. + return Arrays.asList( + "실행계획 생성 실패. 재생성 필요" + ); + } + } + + @Override + public String generateCustomerPositiveSummary(List positiveReviews) { + return ""; + } + + /** + * OpenAI API를 호출하여 전체 리뷰 분석 수행 + */ + private String callOpenAIForAnalysis(List reviewData) { + String reviewsText = String.join("\n- ", reviewData); + + String prompt = String.format( + """ + 다음은 한 매장의 고객 리뷰들입니다. 이를 분석하여 다음 JSON 형식으로 답변해주세요: + + { + "summary": "전체적인 분석 요약(1-2문장)", + "positivePoints": ["긍정적 요소1", "긍정적 요소2", "긍정적 요소3"], + "negativePoints": ["부정적 요소1", "부정적 요소2", "부정적 요소3"], + "improvementPoints": ["개선점1", "개선점2", "개선점3"], + "recommendations": ["추천사항1", "추천사항2", "추천사항3"], + "sentimentAnalysis": "전체적인 감정 분석 결과", + "confidenceScore": 0.85 + "positiveSummary": "리뷰중에 긍정적인 내용만 분석 요약(1~2문장)" + } + + 리뷰 목록: + - %s + + 분석 시 다음 사항을 고려해주세요: + 1. 긍정적 요소는 고객들이 자주 언급하는 좋은 점들 + 2. 부정적 요소는 고객들이 자주 언급하는 안좋은 점들 + 2. 개선점은 부정적 피드백이나 불만사항 + 3. 추천사항은 매장 운영에 도움이 될 구체적인 제안 + 4. 신뢰도 점수는 0.0-1.0 사이의 값으로 리뷰정보를 보고 적절히 판단. + 5. summary에는 전체적인 리뷰 분석에 대한 요약이 잘 담기게 작성하고 **같은 강조하는 문자 없이 텍스트로만 나타내주세요 + 6. 분석한 내용에 `(백틱) 이 들어가지 않도록 해주세요. + 7. positiveSummary에는 긍정적인 내용만 있어야 합니다, summary에 있는 내용에서 긍정적인 부분만 작성해주세요. + """, + reviewsText + ); + + return callOpenAI(prompt); + } + + /** + * OpenAI API 호출 + */ + private String callOpenAI(String prompt) { + if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) { + throw new RuntimeException("OpenAI API 키가 설정되지 않았습니다."); + } + + try { + // 요청 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(openaiApiKey); + + // 요청 바디 생성 + OpenAIRequest request = OpenAIRequest.builder() + .model(openaiModel) + .messages(List.of( + OpenAIMessage.builder() + .role("user") + .content(prompt) + .build() + )) + .maxTokens(1500) + .temperature(0.7) + .build(); + + String requestBody = objectMapper.writeValueAsString(request); + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + // API 호출 + String url = openaiBaseUrl + "/chat/completions"; + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + entity, + String.class + ); + + // 응답 파싱 + return parseOpenAIResponse(response.getBody()); + + } catch (Exception e) { + log.error("OpenAI API 호출 실패", e); + throw new RuntimeException("OpenAI API 호출에 실패했습니다.", e); + } + } + + /** + * OpenAI 응답 파싱 + */ + private String parseOpenAIResponse(String responseBody) { + try { + Map response = objectMapper.readValue(responseBody, Map.class); + List> choices = (List>) response.get("choices"); + + if (choices != null && !choices.isEmpty()) { + Map message = (Map) choices.get(0).get("message"); + return (String) message.get("content"); + } + + throw new RuntimeException("OpenAI 응답에서 내용을 찾을 수 없습니다."); + + } catch (JsonProcessingException e) { + log.error("OpenAI 응답 파싱 실패", e); + throw new RuntimeException("OpenAI 응답 파싱에 실패했습니다.", e); + } + } + + /** + * 분석 결과를 AiFeedback 객체로 파싱 + */ + private AiFeedback parseAnalysisResult(String analysisResult, int totalReviews) { + try { + + if(analysisResult.contains("`")){ + log.info("tesafadsf1241241"); + } + + // JSON 형태로 응답이 왔다고 가정하고 파싱 + Map result = objectMapper.readValue(analysisResult, Map.class); + + return AiFeedback.builder() + .summary((String) result.get("summary")) + .positivePoints((List) result.get("positivePoints")) + .negativePoints((List) result.get("negativePoints")) + .improvementPoints((List) result.get("improvementPoints")) + .recommendations((List) result.get("recommendations")) + .sentimentAnalysis((String) result.get("sentimentAnalysis")) + .positiveSummary((String) result.get("positiveSummary")) + .confidenceScore(((Number) result.get("confidenceScore")).doubleValue()) + .generatedAt(LocalDateTime.now()) + .build(); + + } catch (Exception e) { + log.warn("OpenAI 분석 결과 파싱 실패, 기본 분석 수행", e); + return performBasicAnalysis(analysisResult, totalReviews); + } + } + + /** + * 기본 분석 수행 (파싱 실패 시 fallback) + */ + private AiFeedback performBasicAnalysis(String analysisResult, int totalReviews) { + return AiFeedback.builder() + .summary(String.format("총 %d개의 리뷰를 AI로 분석했습니다.", totalReviews)) + .positivePoints(Arrays.asList("고객 서비스", "음식 품질", "매장 분위기")) + .improvementPoints(Arrays.asList("대기시간 단축", "메뉴 다양성", "가격 경쟁력")) + .recommendations(Arrays.asList("고객 피드백 적극 반영", "서비스 교육 강화", "매장 환경 개선")) + .sentimentAnalysis("전반적으로 긍정적인 평가") + .confidenceScore(0.75) + .generatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 실행 계획 파싱 + */ + private List parseActionPlans(String result) { + // 숫자로 시작하는 라인들을 찾아서 실행 계획으로 추출 + String[] lines = result.split("\n"); + return Arrays.stream(lines) + .filter(line -> line.matches("^\\d+\\..*")) + .map(line -> line.replaceFirst("^\\d+\\.\\s*", "").trim()) + .filter(line -> !line.isEmpty()) + .limit(5) // 최대 5개까지 + .toList(); + } + + /** + * 빈 피드백 생성 + */ + private AiFeedback createEmptyFeedback() { + return AiFeedback.builder() + .summary("분석할 리뷰 데이터가 없습니다.") + .positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가")) + .improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요")) + .recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행")) + .sentimentAnalysis("데이터 부족") + .confidenceScore(0.0) + .generatedAt(LocalDateTime.now()) + .build(); + } + + /** + * Fallback 피드백 생성 (OpenAI 호출 실패 시) + */ + private AiFeedback createFallbackFeedback(List reviewData) { + log.warn("OpenAI 호출 실패로 fallback 분석 수행"); + + // 간단한 키워드 기반 분석 + long positiveCount = reviewData.stream() + .mapToLong(review -> countPositiveKeywords(review)) + .sum(); + + long negativeCount = reviewData.stream() + .mapToLong(review -> countNegativeKeywords(review)) + .sum(); + + double positiveRate = positiveCount > 0 ? (double) positiveCount / (positiveCount + negativeCount) * 100 : 50.0; + + return AiFeedback.builder() + .summary(String.format("총 %d개의 리뷰를 분석했습니다. (간편 분석)", reviewData.size())) + .positivePoints(Arrays.asList("서비스", "맛", "분위기")) + .improvementPoints(Arrays.asList("대기시간", "가격", "청결도")) + .recommendations(Arrays.asList("고객 의견 수렴", "서비스 개선", "품질 향상")) + .sentimentAnalysis(String.format("긍정 비율: %.1f%%", positiveRate)) + .confidenceScore(0.6) + .generatedAt(LocalDateTime.now()) + .build(); + } + + private long countPositiveKeywords(String review) { + String[] positiveWords = {"좋", "맛있", "친절", "깨끗", "만족", "추천", "최고"}; + return Arrays.stream(positiveWords) + .mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0) + .sum(); + } + + private long countNegativeKeywords(String review) { + String[] negativeWords = {"나쁘", "맛없", "불친절", "더럽", "실망", "최악", "별로"}; + return Arrays.stream(negativeWords) + .mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0) + .sum(); + } + + // OpenAI API 요청/응답 DTO 클래스들 + @Data + @lombok.Builder + private static class OpenAIRequest { + private String model; + private List messages; + @JsonProperty("max_tokens") + private Integer maxTokens; + private Double temperature; + } + + @Data + @lombok.Builder + private static class OpenAIMessage { + private String role; + private String content; + } + + + /** + * 요약 생성 + */ + private String generateSummary(double positiveRate, double negativeRate, int totalReviews) { + if (positiveRate > 70) { + return String.format("총 %d개의 리뷰 중 %.1f%%가 긍정적입니다. 고객 만족도가 높은 수준입니다.", + totalReviews, positiveRate); + } else if (negativeRate > 30) { + return String.format("총 %d개의 리뷰 중 %.1f%%가 부정적입니다. 서비스 개선이 필요합니다.", + totalReviews, negativeRate); + } else { + return String.format("총 %d개의 리뷰로 분석한 결과, 전반적으로 평균적인 고객 만족도를 보입니다.", + totalReviews); + } + } + + /** + * 긍정적 요소 생성 + */ + private List generatePositivePoints(List reviewData, List sentiments) { + List positivePoints = new ArrayList<>(); + + long positiveCount = sentiments.stream().mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0).sum(); + double positiveRate = (double) positiveCount / reviewData.size() * 100; + + if (positiveRate > 70) { + positivePoints.add("고객 만족도가 매우 높습니다"); + positivePoints.add("전반적으로 긍정적인 평가를 받고 있습니다"); + positivePoints.add("재방문 의향이 높은 고객들이 많습니다"); + } else if (positiveRate > 50) { + positivePoints.add("평균 이상의 고객 만족도를 보입니다"); + positivePoints.add("많은 고객들이 만족하고 있습니다"); + } else { + positivePoints.add("일부 고객들이 긍정적으로 평가하고 있습니다"); + positivePoints.add("개선의 여지가 있습니다"); + } + + return positivePoints; + } + + /** + * 개선점 생성 + */ + private List generateImprovementPoints(List reviewData, List sentiments) { + List improvementPoints = new ArrayList<>(); + + long negativeCount = sentiments.stream().mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0).sum(); + double negativeRate = (double) negativeCount / reviewData.size() * 100; + + if (negativeRate > 30) { + improvementPoints.add("고객 서비스 품질 개선이 시급합니다"); + improvementPoints.add("부정적 피드백에 대한 체계적 대응이 필요합니다"); + improvementPoints.add("근본적인 서비스 개선 방안을 마련해야 합니다"); + } else if (negativeRate > 15) { + improvementPoints.add("일부 서비스 영역에서 개선이 필요합니다"); + improvementPoints.add("고객 만족도 향상을 위한 노력이 필요합니다"); + } else { + improvementPoints.add("현재 서비스 수준을 유지하며 세부 개선점을 찾아보세요"); + improvementPoints.add("더 높은 고객 만족을 위한 차별화 요소를 개발하세요"); + } + + return improvementPoints; + } + + /** + * 추천사항 생성 + */ + private List generateRecommendations(double positiveRate, double negativeRate) { + List recommendations = new ArrayList<>(); + + if (positiveRate > 70) { + recommendations.add("현재의 우수한 서비스를 유지하면서 브랜드 가치를 높이세요"); + recommendations.add("긍정적 리뷰를 마케팅 자료로 활용하세요"); + recommendations.add("고객 충성도 프로그램을 도입하세요"); + } else if (negativeRate > 30) { + recommendations.add("고객 불만사항에 대한 즉각적인 대응 체계를 구축하세요"); + recommendations.add("직원 교육을 통한 서비스 품질 향상에 집중하세요"); + recommendations.add("고객 피드백 수집 및 분석 프로세스를 강화하세요"); + } else { + recommendations.add("지속적인 품질 관리와 고객 만족도 모니터링을 실시하세요"); + recommendations.add("차별화된 서비스 제공을 통해 경쟁력을 강화하세요"); + recommendations.add("고객과의 소통을 늘려 관계를 강화하세요"); + } + + return recommendations; + } + + /** + * 신뢰도 점수 계산 + */ + private double calculateConfidenceScore(int reviewCount) { + if (reviewCount >= 50) { + return 0.9; + } else if (reviewCount >= 20) { + return 0.75; + } else if (reviewCount >= 10) { + return 0.6; + } else if (reviewCount >= 5) { + return 0.4; + } else { + return 0.2; + } + } + + /** + * 개선점을 실행 계획으로 변환 + */ + private String convertToActionPlan(String improvementPoint) { + // 개선점을 구체적인 실행계획으로 변환 + if (improvementPoint.contains("서비스 품질")) { + return "직원 서비스 교육 프로그램 실시 (월 1회, 2시간)"; + } else if (improvementPoint.contains("대기시간")) { + return "주문 처리 시스템 개선 및 대기열 관리 체계 도입"; + } else if (improvementPoint.contains("가격")) { + return "경쟁사 가격 분석 및 합리적 가격 정책 수립"; + } else if (improvementPoint.contains("메뉴")) { + return "고객 선호도 조사를 통한 메뉴 다양화 방안 검토"; + } else { + return "고객 피드백 기반 서비스 개선 계획 수립"; + } + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ExternalReviewAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ExternalReviewAdapter.java index df46ee4..96103b8 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ExternalReviewAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ExternalReviewAdapter.java @@ -1,293 +1,293 @@ -package com.ktds.hi.analytics.infra.gateway; - -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort; - -import lombok.Data; -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.io.IOException; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -/** - * 외부 리뷰 서비스 어댑터 클래스 - * 리뷰 서비스와의 API 통신을 담당 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class ExternalReviewAdapter implements ExternalReviewPort { - - private final RestTemplate restTemplate; - - @Value("${external.services.review}") - private String reviewServiceUrl; - - @Override - public List getReviewData(Long storeId) { - log.info("리뷰 데이터 조회: storeId={}", storeId); - - try { - String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content"; - // ReviewListResponse 배열로 직접 받기 (Review 서비스가 List 반환) - ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class); - - if (reviewArray == null || reviewArray.length == 0) { - log.info("매장에 리뷰가 없습니다: storeId={}", storeId); - return List.of(); - } - - // ReviewListResponse에서 content만 추출 - List reviews = Arrays.stream(reviewArray) - .map(ReviewListResponse::getContent) - .filter(content -> content != null && !content.trim().isEmpty()) - .collect(Collectors.toList()); - log.info("리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size()); - return reviews; - - } catch (Exception e) { - log.error("리뷰 데이터 조회 실패: storeId={}", storeId, e); - // 실패 시 더미 데이터 반환 - return getDummyReviewData(storeId); - } - } - - @Override - public List getRecentReviews(Long storeId, Integer days) { - log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days); - - try { - - //최근 데이터를 가져오도록 변경 - // String url = reviewServiceUrl + "/api/reviews/stores/recent/" + storeId + "?size=100&days=" + days; - // - // // ReviewListResponse 배열로 직접 받기 - // ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class); - - int totalSize = 200; - int threadCount = 4; - int pageSize = totalSize / threadCount; // 50개씩 - - // ExecutorService 생성 - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - List> futures = new ArrayList<>(); - - // 4개의 비동기 요청 생성 (limit 50, offset 0/50/100/150) - for (int i = 0; i < threadCount; i++) { - final int offset = i * pageSize; - final int limit = pageSize; - - CompletableFuture future = CompletableFuture.supplyAsync(() -> { - String url = reviewServiceUrl + "/api/reviews/stores/recent/" + storeId - + "?size=" + limit + "&offset=" + offset + "&days=" + days; - - log.debug("스레드 {}에서 URL 호출: {}", Thread.currentThread().getName(), url); - - // 기존과 동일한 방식으로 API 호출 - ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class); - - if (reviewArray == null) { - log.debug("스레드 {}에서 빈 응답 수신", Thread.currentThread().getName()); - return new ReviewListResponse[0]; - } - - log.debug("스레드 {}에서 {} 개 리뷰 수신", Thread.currentThread().getName(), reviewArray.length); - return reviewArray; - - }, executorService); - - futures.add(future); - } - - // 모든 요청 완료 대기 및 결과 합치기 - List allReviewResponses = new ArrayList<>(); - for (CompletableFuture future : futures) { - ReviewListResponse[] reviewArray = future.get(30, TimeUnit.SECONDS); // 30초 타임아웃 - allReviewResponses.addAll(Arrays.asList(reviewArray)); - } - - executorService.shutdown(); - - - // 최근 N일 이내의 리뷰만 필터링 - LocalDateTime cutoffDate = LocalDateTime.now().minusDays(days); - - List recentReviews = allReviewResponses.stream() - .filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate)) - .map(ReviewListResponse::getContent) - .filter(content -> content != null && !content.trim().isEmpty()) - .map(content -> content.replace("`", "") - .replace("\n", "") - .replace("\\", "") - .replace("\"", "")) - .collect(Collectors.toList()); - - - - log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, recentReviews.size()); - - return recentReviews; - - } catch (Exception e) { - log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e); - return getDummyRecentReviews(storeId); - } - } - - @Override - public List getPositiveReviews(Long storeId, Integer days) { - return List.of(); - } - - @Override - public Integer getReviewCount(Long storeId) { - log.info("리뷰 개수 조회: storeId={}", storeId); - - try { - String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/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 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 getDummyReviewData(Long storeId) { - return Arrays.asList( - "음식이 정말 맛있어요! 배달도 빨랐습니다.", - "가격 대비 양이 많고 맛도 좋네요. 추천합니다.", - "배달 시간이 너무 오래 걸렸어요. 음식은 괜찮았습니다.", - "포장 상태가 별로였어요. 국물이 새어나왔습니다.", - "직원분들이 친절하고 음식도 맛있어요. 재주문 할게요!", - "메뉴가 다양하고 맛있습니다. 자주 이용할 것 같아요.", - "가격이 조금 비싸긴 하지만 맛은 좋아요.", - "배달 기사님이 친절하셨어요. 음식도 따뜻했습니다." - ); - } - - /** - * 더미 최근 리뷰 데이터 생성 - */ - private List getDummyRecentReviews(Long storeId) { - return Arrays.asList( - "어제 주문했는데 정말 맛있었어요!", - "배달이 빨라서 좋았습니다.", - "음식 온도가 적절했어요.", - "포장이 깔끔하게 되어있었습니다.", - "다음에도 주문할게요!" - ); - } - - - @Data - public static class ReviewListResponse { - - @JsonProperty("reviewId") - private Long reviewId; - - @JsonProperty("memberNickname") - private String memberNickname; - - @JsonProperty("rating") - private Integer rating; - - @JsonProperty("content") - private String content; - - @JsonProperty("imageUrls") - private List imageUrls; - - @JsonProperty("likeCount") - private Integer likeCount; - - @JsonProperty("dislikeCount") - private Integer dislikeCount; - - @JsonProperty("createdAt") - @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) - private LocalDateTime createdAt; - } - - - /** - * 다양한 LocalDateTime 형식을 처리하는 커스텀 Deserializer - */ - public static class FlexibleLocalDateTimeDeserializer extends JsonDeserializer { - - private static final DateTimeFormatter[] FORMATTERS = { - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"), // 마이크로초 6자리 - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSS"), // 마이크로초 5자리 - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSS"), // 마이크로초 4자리 - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"), // 밀리초 3자리 - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SS"), // 2자리 - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.S"), // 1자리 - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), // 초까지만 - DateTimeFormatter.ISO_LOCAL_DATE_TIME // ISO 표준 - }; - - @Override - public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException { - String dateString = parser.getText(); - - if (dateString == null || dateString.trim().isEmpty()) { - return null; - } - - // 여러 형식으로 시도 - for (DateTimeFormatter formatter : FORMATTERS) { - try { - return LocalDateTime.parse(dateString, formatter); - } catch (DateTimeParseException e) { - // 다음 형식으로 시도 - } - } - - // 모든 형식이 실패하면 현재 시간 반환 (에러 로그) - System.err.println("Failed to parse LocalDateTime: " + dateString + ", using current time"); - return LocalDateTime.now(); - } - } -} +package com.ktds.hi.analytics.infra.gateway; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort; + +import lombok.Data; +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.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 외부 리뷰 서비스 어댑터 클래스 + * 리뷰 서비스와의 API 통신을 담당 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExternalReviewAdapter implements ExternalReviewPort { + + private final RestTemplate restTemplate; + + @Value("${external.services.review}") + private String reviewServiceUrl; + + @Override + public List getReviewData(Long storeId) { + log.info("리뷰 데이터 조회: storeId={}", storeId); + + try { + String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content"; + // ReviewListResponse 배열로 직접 받기 (Review 서비스가 List 반환) + ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class); + + if (reviewArray == null || reviewArray.length == 0) { + log.info("매장에 리뷰가 없습니다: storeId={}", storeId); + return List.of(); + } + + // ReviewListResponse에서 content만 추출 + List reviews = Arrays.stream(reviewArray) + .map(ReviewListResponse::getContent) + .filter(content -> content != null && !content.trim().isEmpty()) + .collect(Collectors.toList()); + log.info("리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size()); + return reviews; + + } catch (Exception e) { + log.error("리뷰 데이터 조회 실패: storeId={}", storeId, e); + // 실패 시 더미 데이터 반환 + return getDummyReviewData(storeId); + } + } + + @Override + public List getRecentReviews(Long storeId, Integer days) { + log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days); + + try { + + //최근 데이터를 가져오도록 변경 + // String url = reviewServiceUrl + "/api/reviews/stores/recent/" + storeId + "?size=100&days=" + days; + // + // // ReviewListResponse 배열로 직접 받기 + // ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class); + + int totalSize = 200; + int threadCount = 4; + int pageSize = totalSize / threadCount; // 50개씩 + + // ExecutorService 생성 + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + + // 4개의 비동기 요청 생성 (limit 50, offset 0/50/100/150) + for (int i = 0; i < threadCount; i++) { + final int offset = i * pageSize; + final int limit = pageSize; + + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + String url = reviewServiceUrl + "/api/reviews/stores/recent/" + storeId + + "?size=" + limit + "&offset=" + offset + "&days=" + days; + + log.debug("스레드 {}에서 URL 호출: {}", Thread.currentThread().getName(), url); + + // 기존과 동일한 방식으로 API 호출 + ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class); + + if (reviewArray == null) { + log.debug("스레드 {}에서 빈 응답 수신", Thread.currentThread().getName()); + return new ReviewListResponse[0]; + } + + log.debug("스레드 {}에서 {} 개 리뷰 수신", Thread.currentThread().getName(), reviewArray.length); + return reviewArray; + + }, executorService); + + futures.add(future); + } + + // 모든 요청 완료 대기 및 결과 합치기 + List allReviewResponses = new ArrayList<>(); + for (CompletableFuture future : futures) { + ReviewListResponse[] reviewArray = future.get(30, TimeUnit.SECONDS); // 30초 타임아웃 + allReviewResponses.addAll(Arrays.asList(reviewArray)); + } + + executorService.shutdown(); + + + // 최근 N일 이내의 리뷰만 필터링 + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(days); + + List recentReviews = allReviewResponses.stream() + .filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate)) + .map(ReviewListResponse::getContent) + .filter(content -> content != null && !content.trim().isEmpty()) + .map(content -> content.replace("`", "") + .replace("\n", "") + .replace("\\", "") + .replace("\"", "")) + .collect(Collectors.toList()); + + + + log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, recentReviews.size()); + + return recentReviews; + + } catch (Exception e) { + log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e); + return getDummyRecentReviews(storeId); + } + } + + @Override + public List getPositiveReviews(Long storeId, Integer days) { + return List.of(); + } + + @Override + public Integer getReviewCount(Long storeId) { + log.info("리뷰 개수 조회: storeId={}", storeId); + + try { + String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/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 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 getDummyReviewData(Long storeId) { + return Arrays.asList( + "음식이 정말 맛있어요! 배달도 빨랐습니다.", + "가격 대비 양이 많고 맛도 좋네요. 추천합니다.", + "배달 시간이 너무 오래 걸렸어요. 음식은 괜찮았습니다.", + "포장 상태가 별로였어요. 국물이 새어나왔습니다.", + "직원분들이 친절하고 음식도 맛있어요. 재주문 할게요!", + "메뉴가 다양하고 맛있습니다. 자주 이용할 것 같아요.", + "가격이 조금 비싸긴 하지만 맛은 좋아요.", + "배달 기사님이 친절하셨어요. 음식도 따뜻했습니다." + ); + } + + /** + * 더미 최근 리뷰 데이터 생성 + */ + private List getDummyRecentReviews(Long storeId) { + return Arrays.asList( + "어제 주문했는데 정말 맛있었어요!", + "배달이 빨라서 좋았습니다.", + "음식 온도가 적절했어요.", + "포장이 깔끔하게 되어있었습니다.", + "다음에도 주문할게요!" + ); + } + + + @Data + public static class ReviewListResponse { + + @JsonProperty("reviewId") + private Long reviewId; + + @JsonProperty("memberNickname") + private String memberNickname; + + @JsonProperty("rating") + private Integer rating; + + @JsonProperty("content") + private String content; + + @JsonProperty("imageUrls") + private List imageUrls; + + @JsonProperty("likeCount") + private Integer likeCount; + + @JsonProperty("dislikeCount") + private Integer dislikeCount; + + @JsonProperty("createdAt") + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) + private LocalDateTime createdAt; + } + + + /** + * 다양한 LocalDateTime 형식을 처리하는 커스텀 Deserializer + */ + public static class FlexibleLocalDateTimeDeserializer extends JsonDeserializer { + + private static final DateTimeFormatter[] FORMATTERS = { + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"), // 마이크로초 6자리 + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSS"), // 마이크로초 5자리 + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSS"), // 마이크로초 4자리 + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"), // 밀리초 3자리 + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SS"), // 2자리 + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.S"), // 1자리 + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), // 초까지만 + DateTimeFormatter.ISO_LOCAL_DATE_TIME // ISO 표준 + }; + + @Override + public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException { + String dateString = parser.getText(); + + if (dateString == null || dateString.trim().isEmpty()) { + return null; + } + + // 여러 형식으로 시도 + for (DateTimeFormatter formatter : FORMATTERS) { + try { + return LocalDateTime.parse(dateString, formatter); + } catch (DateTimeParseException e) { + // 다음 형식으로 시도 + } + } + + // 모든 형식이 실패하면 현재 시간 반환 (에러 로그) + System.err.println("Failed to parse LocalDateTime: " + dateString + ", using current time"); + return LocalDateTime.now(); + } + } +} diff --git a/dump.rdb b/dump.rdb index 607a2d51f8fe0e07b16270401d6b28a1df8f6746..53495f0b3ac9aeca92a143bc059981558bb1962f 100644 GIT binary patch delta 246 zcmaFM{FZrwfnZ!;aKkmTVhC&N2rgB zcZq+IWnN^WNo0JbV~}6Ads(WFQLb-lX0E$Qc2!~7pATC3t0u-KWmY*F`Bphr_&9sz zqRFm4<(37NmRKZvmlam}S6Ei&86_D5Ei=k?GA$}E uP4x8%tITw9^N!Cj&(BBOg}A7`(e zR1e2Y|14KyzYs^Dm~Vw&NR*|XpILHylpSR~2dKA7P+pRubqInU|EC=wjv<6;&BvoUChB8W!(u>YMKoVN~Q- up6gU>kZ0*`RPGs45^&=L2JBYZuJtSA57bekA>5G?H;}EVJv-mm6ssg}%tcBxZGX zoQo_E;{khU*;vtv_u^VG$L1Dqv70a-vctVCW;>qSWGkE9^ISb&D-RaMtpqHa@9Fx#m(Bm|tpWh`(&aPeF;MUso*>#gU+*(H7eXdzfhLZJ@ zC`xXKh|SV0>4y>Yp_uUV@LG1+k5S@_=*J4up2M4Ndy#a)6%Rn#2k|sfVV-1@%_Q!J zgK?$_Z^=jwL@vR=!8ppp6ch_Nw?r=3C<(@qRN!nJ2Ww2mxzt~(JPZAtWwTwGU*4A9 zlS}w)Sf9ry*#zW8ni3;rX5Sxf%sC3dHGFAnduQWx|3ovyt2lDGWj8;Mqn$7g)eufx zW~QVXgbYRURll4EZcl@-Xga zf|G)7#Dvec;D?#)%S^_;JQ0KUshErMSGL!u@X2ljDCfhn<|Zsp7~<-JN+)Y%X9Wo| z2G31$p|b*PlaX(PIq6&n!z7k0>0@81y=lIjK@G%qQuT70umTgaj=Nzu|2!)^kqj_y`JrKs%kK=#l6m~2K%^i^h=2e{5eci4!?y6D#?!IAf)W0tg@eo zk2|Y2hY}TMR zFH@+(tgdNCgzK@hZ%_P!Do}zM8HIze7fB5#1DWeHni?&c0$xNBTTq{3EA)cA6(c01 z-Nmg07^`ES+X*&&&2=H{k8w8R9H{1JQc!p5e<)`0j{Pr#!=FIxPrv;x{g$R)OWM^ve#0ftlaViSexm<@K4|=< zz0y2^rcvRGDEue1sVVEON83W_yE4r4v})8xR9lq;FTt@Vc8i56`j2xcN@UZaTX_Jg zibrRLk&7jCsvFA7=xoP>Fup^EC`S^$sK4p5vyD{vFt(w(Zd=^uuDgVKYbka7+T>H# z`I(*#4q;RLynX@tdsDTRTI+JulR*GI!wjYI8{X%Jn_YO&haQ<)?`|MRH%9ai zwHw(xxb_!T)Mi5v@Il})R*ORr!$YTHJ)w@l71b1WwU?!`kb1McxAd&xBDg+bHI|f2y{zy(W>l?4f$m%xb9WO zBI4sbjF4#!c4d@m>34T`Ez5GOMwb=9N&+9sOo0+jS5D!MdvQNuXE6dDq?Ua#jw;|m zbJ6Kk+{B<@IShNmK`!}kw6`Ae8`=%q>9XDF*X+a50Rg{c*TuRfrz-|KfR?C=ro*jP zt6FmM0?G}NX)Dcu0DhVgx144dkOYSd>XE#b5%V<~1)&lMltDGcRy9S%6Mo54Zs4U# zhjsQ#!YKBigd_P;3i3cD^Kpt}KZp~JN;DqT-ocR4#0u^>l36t$?M4L{QSoY(AG@pFXcbkVLh~|7TvVok-opy!Ti{#QIi3*yB_;Crf4UN1| zd@OaoqrHTp@BlIY9}&L{dOklKY|a+EZTW4_ll{d;r(v#B5gI;E7jx`gq7c`sN>T54 zJFLf%lK?5ES6kqC9ZS2OhcjmcKK^xo3z!c<=Vv{ZCe1=}qEhLHeM-CJEd`8_QZ92T zsWvASm>p_tC6l9sG8;HDQW_led_r!f3Db`Vya3COxML+DZhg&6M|MP)uBC|!!mRX^VSNtuT`jSWt@qdhEHsQb0-`nxfbRP<8^-@#|^@R-$>qsVsdN3$V@&}czvE5(yk!55^ zyJja7Sx+VI*eqHFKTQ=Wv!5!LE-Ztxe~yx#h#2fJq_obkTr4anyt?LF zw4bS5^_P@~LDmC+d^TC6S}k>RK03W$8z?@)FM(7E&aCnSbN^@;fg+Mw@d*Oh4J5?+ zgZAZG_Q3M0vjisE&DL@zhWt3BaC@3X3$%-@U9q!u)Df3BDC?`MYs~x=B)(dojp^6b zjTk@;^>K(~efnWuRcHKCJ@V4(MIoRGI8PAG;|;H~tS%J5Vyb?&;~-6dhx=iCrAh_J z_ASknA*TxjHA|>b5la9C)3J#lgVol7D`NuhB`kD0OUu6pO}r6^)YM`Gv{@4AXEUc$ zU1qVj2MB`EMxJM3Z=3_L@Q7UhU5GH1tudPwTfa+!iFw&Pr1p)H^QTp;BL=vSvc7#w zV>g_cg6rAmk|vk&Z?m`eUw=AxNC%uHaO?93f6BeQO{yLio7H!h^!pOg?4!tHRAK{D zgqn`TCbbY*c{QQuyv;`S39zS(Pq)5B+GPjGwq2pC$VRoClG^Rf~*kdQCf96Hk ziu<~5cN}CKZSEjhbDb~JdD{xfX0T>9Z3otZ`;RJBNsKn1!>DOMDYG?Sq<^2!ugKP7 zU+f{a5Zq0HR&L$mD|4wCzA?qW-;1vepOAG|3Iqb3uFJjpZ#BO$`=%bH7^@q=TD?WC{HE3yk&?A8E7Dl1z91R$Af?07 zQ2Vo*RbwNeu1qLQGlWg5VPd*!$|AEpjRw;sr_^3V5w%-l71GR4cP>6#@K1xX&C<-^ z&M*Oi!dn<0G`eo1>sY4JU2KL869oS&DN#6=^PlJT{Z57%RI3B4nK0-Zr-&L?ERrHc zQd0QbN{KL@M!9(7SffT$b*$z{c^&8f{;k@U6|2|xLciB_ovz#BZU@JkLUlb3lR8E2 zy1bQs-ThQQ`g!|={kSgDuv{?EwPLlNCh0g5+22z5`5Ws$sdWP`e_SiM)0_V_zVokY zE*lhhpTy-;3pM)5xavdqnEhF<%#TTynXTqY^nc9WIW%JGg=*zECnQ*7bQuoE`*pQo z9$mev7VJ?kBL8_ul~1(~?cfL-39sSX`ttUtG?G2pb{c(DODno%QF?VKsPdB3sY$5) z@|shJR)xK2GQSOE5?jI45Riw@BXPSQ_GKQTEH_u1mng)sUZ_GER>!g1oz^#3i2Xtp z;(EU2w*k-JS|QF0RYz68tYin$P)FozT_rF!EY+t;H1Quw09u}7|@njJ{vEKM|9 zUCXBbmeiu&`iqvvYbnR_Myh4I5XM;|OuOsYs5oAH6) getAllStores() { @@ -109,6 +112,7 @@ public class StoreService implements StoreUseCase { .rating(store.getRating()) .reviewCount(store.getReviewCount()) .status("운영중") + .tagJson(store.getTagsJson()) .imageUrl(store.getImageUrl()) .operatingHours(store.getOperatingHours()) .build()) diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/in/StoreUseCase.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/in/StoreUseCase.java index 3165a10..8ea5ddb 100644 --- a/store/src/main/java/com/ktds/hi/store/biz/usecase/in/StoreUseCase.java +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/in/StoreUseCase.java @@ -33,6 +33,8 @@ public interface StoreUseCase { List getAllStores(); + String getAllTags(Long storeId); + /** * 매장 상세 조회 * diff --git a/store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java b/store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java index 78081c7..0453256 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java +++ b/store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java @@ -41,7 +41,6 @@ import java.util.List; public class StoreController { private final StoreUseCase storeUseCase; - private final StoreService storeService; private final JwtTokenProvider jwtTokenProvider; private final MenuUseCase menuUseCase; @@ -80,6 +79,15 @@ public class StoreController { return ResponseEntity.ok(ApiResponse.success(responses)); } + @GetMapping("/stores/{storeId}/tags") + @Operation(summary = "매장 전체 리스트") + public ResponseEntity getStoreTags(@PathVariable Long storeId) { + + String tagsJson = storeUseCase.getAllTags(storeId); + return ResponseEntity.ok(tagsJson); + } + + @Operation(summary = "매장 상세 조회", description = "매장의 상세 정보를 조회합니다.") @GetMapping("/{storeId}") public ResponseEntity> getStoreDetail( diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreListResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreListResponse.java index 35cfa0f..d75d2cb 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreListResponse.java +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreListResponse.java @@ -20,6 +20,9 @@ public class StoreListResponse { @Schema(description = "매장명", example = "맛집 한번 가볼래?") private String storeName; + @Schema(description = "태그 리스트", example = "태그태그태그") + private String tagJson; + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") private String address; diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/ExternalPlatformAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/ExternalPlatformAdapter.java index 3b1ba65..63d8ea4 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/gateway/ExternalPlatformAdapter.java +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/ExternalPlatformAdapter.java @@ -1,726 +1,726 @@ -package com.ktds.hi.store.infra.gateway; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ktds.hi.store.biz.usecase.out.CachePort; -import com.ktds.hi.store.biz.usecase.out.ExternalPlatformPort; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.*; - -/** - * 외부 플랫폼 연동 어댑터 - * 각 플랫폼별 API 호출 및 Redis 저장을 담당 - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class ExternalPlatformAdapter implements ExternalPlatformPort { - - private final RestTemplate restTemplate; - private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - private final CachePort cachePort; - - @Value("${external.kakao.crawler.url:http://localhost:9001}") - private String kakaoCrawlerUrl; - - @Value("${external.naver.crawler.url:http://localhost:9002}") - private String naverCrawlerUrl; - - @Value("${external.google.crawler.url:http://localhost:9003}") - private String googleCrawlerUrl; - - @Value("${external.hiorder.api.url:http://localhost:8080}") - private String hiorderApiUrl; - - // ===== 리뷰 동기화 메서드들 ===== - - @Override - public int syncNaverReviews(Long storeId, String externalStoreId) { - log.info("네이버 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); - - try { - // 네이버 크롤링 서비스 호출 - String url = String.format("%s/api/naver/reviews?storeId=%s", naverCrawlerUrl, externalStoreId); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity entity = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange(url, org.springframework.http.HttpMethod.GET, entity, String.class); - - return processNaverResponse(storeId, "NAVER", response.getBody()); - - } catch (Exception e) { - log.error("네이버 리뷰 동기화 실패: storeId={}, externalStoreId={}, error={}", - storeId, externalStoreId, e.getMessage()); - updateSyncStatus(storeId, "NAVER", "FAILED", 0); - return 0; - } - } - - //*****// - @Override - public int syncKakaoReviews(Long storeId, String externalStoreId) { - log.info("카카오 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); - - try { - String url = "http://kakao-review-api-service.ai-review-ns.svc.cluster.local/analyze"; - - Map requestBody = new HashMap<>(); - requestBody.put("store_id", externalStoreId); - requestBody.put("days_limit", 360); - requestBody.put("max_time", 300); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> entity = new HttpEntity<>(requestBody, headers); - - ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); - - // 🔥 실제 카카오 응답 파싱 및 Redis 저장 - return parseAndStoreToRedis(storeId, "KAKAO", response.getBody()); - - } catch (Exception e) { - log.error("카카오 리뷰 동기화 실패: storeId={}, error={}", storeId, e.getMessage(), e); - updateSyncStatus(storeId, "KAKAO", "FAILED", 0); - return 0; - } - } - - @Override - public int syncGoogleReviews(Long storeId, String externalStoreId) { - log.info("구글 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); - - try { - // 구글 크롤링 서비스 호출 - String url = String.format("%s/api/google/reviews?storeId=%s", googleCrawlerUrl, externalStoreId); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity entity = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange(url, org.springframework.http.HttpMethod.GET, entity, String.class); - - return processGoogleResponse(storeId, "GOOGLE", response.getBody()); - - } catch (Exception e) { - log.error("구글 리뷰 동기화 실패: storeId={}, externalStoreId={}, error={}", - storeId, externalStoreId, e.getMessage()); - updateSyncStatus(storeId, "GOOGLE", "FAILED", 0); - return 0; - } - } - - @Override - public int syncHiorderReviews(Long storeId, String externalStoreId) { - log.info("하이오더 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); - - try { - // 하이오더 API 호출 - String url = String.format("%s/api/reviews?storeId=%s", hiorderApiUrl, externalStoreId); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity entity = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange(url, org.springframework.http.HttpMethod.GET, entity, String.class); - - return processHiorderResponse(storeId, "HIORDER", response.getBody()); - - } catch (Exception e) { - log.error("하이오더 리뷰 동기화 실패: storeId={}, externalStoreId={}, error={}", - storeId, externalStoreId, e.getMessage()); - updateSyncStatus(storeId, "HIORDER", "FAILED", 0); - return 0; - } - } - - /** - * 카카오 응답 파싱 및 Redis 저장 (CacheAdapter 활용) - */ - private int parseAndStoreToRedis(Long storeId, String platform, String responseBody) { - try { - log.info("카카오 API 응답 파싱 시작: storeId={}", storeId); - - - - if (responseBody == null || responseBody.trim().isEmpty()) { - log.warn("카카오 응답이 비어있음: storeId={}", storeId); - return 0; - } - - JsonNode root = objectMapper.readTree(responseBody); - - // 실제 카카오 응답 구조 확인 - if (!root.has("success") || !root.get("success").asBoolean()) { - log.warn("카카오 API 호출 실패:: {}", root.path("message").asText()); - updateSyncStatusWithCache(storeId, platform, "FAILED", 0); - return 0; - } - - // reviews 배열 직접 접근 - JsonNode reviewsNode = root.get("reviews"); - if (reviewsNode == null || !reviewsNode.isArray() || reviewsNode.size() == 0) { - log.warn("카카오 응답에 reviews 배열이 없거나, 리뷰 목록이 없음!"); - updateSyncStatusWithCache(storeId, platform, "SUCCESS", 0); - return 0; - } - - - // 매장 정보 파싱 (옵션) - Map storeInfo = null; - if (root.has("store_info")) { - JsonNode storeInfoNode = root.get("store_info"); - storeInfo = new HashMap<>(); - storeInfo.put("id", storeInfoNode.path("id").asText()); - storeInfo.put("name", storeInfoNode.path("name").asText()); - storeInfo.put("category", storeInfoNode.path("category").asText()); - storeInfo.put("rating", storeInfoNode.path("rating").asText()); - storeInfo.put("reviewCount", storeInfoNode.path("review_count").asText()); - storeInfo.put("status", storeInfoNode.path("status").asText()); - storeInfo.put("address", storeInfoNode.path("address").asText()); - } - - // 리뷰 데이터 파싱 - List> reviews = new ArrayList<>(); - for (JsonNode reviewNode : reviewsNode) { - Map review = new HashMap<>(); - - // 기본 정보 - review.put("reviewId", generateReviewId(reviewNode)); - review.put("reviewerName", reviewNode.path("reviewer_name").asText("")); - review.put("reviewerLevel", reviewNode.path("reviewer_level").asText("")); - review.put("rating", reviewNode.path("rating").asInt(0)); - review.put("date", reviewNode.path("date").asText("")); - review.put("content", reviewNode.path("content").asText("")); - review.put("likes", reviewNode.path("likes").asInt(0)); - review.put("photoCount", reviewNode.path("photo_count").asInt(0)); - review.put("hasPhotos", reviewNode.path("has_photos").asBoolean(false)); - review.put("platform", platform); - - // 리뷰어 통계 - if (reviewNode.has("reviewer_stats")) { - JsonNode statsNode = reviewNode.get("reviewer_stats"); - Map reviewerStats = new HashMap<>(); - reviewerStats.put("reviews", statsNode.path("reviews").asInt(0)); - reviewerStats.put("averageRating", statsNode.path("average_rating").asDouble(0.0)); - reviewerStats.put("followers", statsNode.path("followers").asInt(0)); - review.put("reviewerStats", reviewerStats); - } - - // 배지 - if (reviewNode.has("badges") && reviewNode.get("badges").isArray()) { - List badges = new ArrayList<>(); - for (JsonNode badgeNode : reviewNode.get("badges")) { - badges.add(badgeNode.asText()); - } - review.put("badges", badges); - } else { - review.put("badges", new ArrayList()); - } - - reviews.add(review); - } - - log.info("파싱된 리뷰 수: {}", reviews.size()); - - // CacheAdapter를 통한 Redis 저장 - saveToRedisWithCache(storeId, platform, reviews, storeInfo); - - // 동기화 상태 업데이트 - updateSyncStatusWithCache(storeId, platform, "SUCCESS", reviews.size()); - - return reviews.size(); - - } catch (Exception e) { - log.error("카카오 응답 파싱 실패: storeId={}, error={}", storeId, e.getMessage(), e); - updateSyncStatusWithCache(storeId, platform, "FAILED", 0); - return 0; - } - } - // ===== 계정 연동 메서드들 ===== - - @Override - public boolean connectNaverAccount(Long storeId, String username, String password) { - log.info("네이버 계정 연동: storeId={}", storeId); - try { - if (validateCredentials(username, password)) { - saveConnectionInfo(storeId, "NAVER", username); - return true; - } - return false; - } catch (Exception e) { - log.error("네이버 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage()); - return false; - } - } - - @Override - public boolean connectKakaoAccount(Long storeId, String username, String password) { - log.info("카카오 계정 연동: storeId={}", storeId); - try { - if (validateCredentials(username, password)) { - saveConnectionInfo(storeId, "KAKAO", username); - return true; - } - return false; - } catch (Exception e) { - log.error("카카오 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage()); - return false; - } - } - - @Override - public boolean connectGoogleAccount(Long storeId, String username, String password) { - log.info("구글 계정 연동: storeId={}", storeId); - try { - if (validateCredentials(username, password)) { - saveConnectionInfo(storeId, "GOOGLE", username); - return true; - } - return false; - } catch (Exception e) { - log.error("구글 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage()); - return false; - } - } - - @Override - public boolean connectHiorderAccount(Long storeId, String username, String password) { - log.info("하이오더 계정 연동: storeId={}", storeId); - try { - if (validateCredentials(username, password)) { - saveConnectionInfo(storeId, "HIORDER", username); - return true; - } - return false; - } catch (Exception e) { - log.error("하이오더 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage()); - return false; - } - } - - @Override - public boolean disconnectPlatform(Long storeId, String platform) { - log.info("플랫폼 연동 해제: storeId={}, platform={}", storeId, platform); - - try { - String connectionKey = String.format("external:connection:%d:%s", storeId, platform); - redisTemplate.delete(connectionKey); - - log.info("플랫폼 연동 해제 완료: storeId={}, platform={}", storeId, platform); - return true; - - } catch (Exception e) { - log.error("플랫폼 연동 해제 실패: storeId={}, platform={}, error={}", - storeId, platform, e.getMessage()); - return false; - } - } - - @Override - public List getConnectedPlatforms(Long storeId) { - log.info("연동된 플랫폼 조회: storeId={}", storeId); - - try { - List connectedPlatforms = new ArrayList<>(); - - String pattern = String.format("external:connection:%d:*", storeId); - Set keys = redisTemplate.keys(pattern); - - if (keys != null) { - for (String key : keys) { - String platform = key.substring(key.lastIndexOf(':') + 1); - connectedPlatforms.add(platform.toUpperCase()); - } - } - - return connectedPlatforms; - - } catch (Exception e) { - log.error("연동된 플랫폼 조회 실패: storeId={}, error={}", storeId, e.getMessage()); - return new ArrayList<>(); - } - } - - @Override - public List> getTempReviews(Long storeId, String platform) { - try { - String pattern = String.format("external:reviews:pending:%d:%s:*", storeId, platform); - Set keys = redisTemplate.keys(pattern); - - if (keys != null && !keys.isEmpty()) { - String latestKey = keys.stream() - .max(Comparator.comparing(key -> { - try { - return Long.parseLong(key.substring(key.lastIndexOf(':') + 1)); - } catch (NumberFormatException e) { - return 0L; - } - })) - .orElse(null); - - if (latestKey != null) { - Map cacheData = (Map) redisTemplate.opsForValue().get(latestKey); - if (cacheData != null && cacheData.containsKey("reviews")) { - return (List>) cacheData.get("reviews"); - } - } - } - - return new ArrayList<>(); - - } catch (Exception e) { - log.error("Redis에서 임시 리뷰 데이터 조회 실패: storeId={}, platform={}", storeId, platform); - return new ArrayList<>(); - } - } - - // ===== Private Helper Methods ===== - - private int processNaverResponse(Long storeId, String platform, String responseBody) { - try { - if (responseBody == null || responseBody.trim().isEmpty()) { - log.warn("네이버 응답이 비어있음: storeId={}", storeId); - return 0; - } - - JsonNode root = objectMapper.readTree(responseBody); - - List> parsedReviews = new ArrayList<>(); - - // 네이버 크롤링 응답 처리 - if (root.has("success") && root.get("success").asBoolean() && root.has("data")) { - JsonNode data = root.get("data"); - if (data.has("reviews")) { - JsonNode reviews = data.get("reviews"); - for (JsonNode reviewNode : reviews) { - Map review = new HashMap<>(); - review.put("reviewId", reviewNode.path("reviewId").asText()); - review.put("rating", reviewNode.path("rating").asDouble(0.0)); - review.put("content", reviewNode.path("content").asText()); - review.put("reviewerName", reviewNode.path("reviewerName").asText()); - review.put("createdAt", reviewNode.path("createdAt").asText()); - review.put("platform", platform); - parsedReviews.add(review); - } - } - } - - if (!parsedReviews.isEmpty()) { - saveToRedis(storeId, platform, parsedReviews); - updateSyncStatus(storeId, platform, "SUCCESS", parsedReviews.size()); - - log.info("Redis에 리뷰 데이터 저장 완료: key={}, count={}", - String.format("external:reviews:pending:%d:%s:%d", storeId, platform, System.currentTimeMillis()), - parsedReviews.size()); - } - - return parsedReviews.size(); - - } catch (Exception e) { - log.error("네이버 응답 파싱 및 Redis 저장 실패: {}", e.getMessage()); - updateSyncStatus(storeId, platform, "FAILED", 0); - return 0; - } - } - - private int processKakaoResponse(Long storeId, String platform, String responseBody) { - try { - if (responseBody == null || responseBody.trim().isEmpty()) { - log.warn("카카오 응답이 비어있음: storeId={}", storeId); - return 0; - } - - JsonNode root = objectMapper.readTree(responseBody); - - List> parsedReviews = new ArrayList<>(); - - // 카카오 크롤링 응답 처리 - if (root.has("success") && root.get("success").asBoolean() && root.has("data")) { - JsonNode data = root.get("data"); - if (data.has("reviews")) { - JsonNode reviews = data.get("reviews"); - for (JsonNode reviewNode : reviews) { - Map review = new HashMap<>(); - review.put("reviewId", reviewNode.path("reviewId").asText()); - review.put("rating", reviewNode.path("rating").asDouble(0.0)); - review.put("content", reviewNode.path("content").asText()); - review.put("reviewerName", reviewNode.path("reviewerName").asText()); - review.put("createdAt", reviewNode.path("createdAt").asText()); - review.put("platform", platform); - parsedReviews.add(review); - } - } - } - - if (!parsedReviews.isEmpty()) { - saveToRedis(storeId, platform, parsedReviews); - updateSyncStatus(storeId, platform, "SUCCESS", parsedReviews.size()); - - log.info("Redis에 리뷰 데이터 저장 완료: key={}, count={}", - String.format("external:reviews:pending:%d:%s:%d", storeId, platform, System.currentTimeMillis()), - parsedReviews.size()); - } - - return parsedReviews.size(); - - } catch (Exception e) { - log.error("카카오 응답 파싱 및 Redis 저장 실패: {}", e.getMessage()); - updateSyncStatus(storeId, platform, "FAILED", 0); - return 0; - } - } - - private int processGoogleResponse(Long storeId, String platform, String responseBody) { - try { - if (responseBody == null || responseBody.trim().isEmpty()) { - log.warn("구글 응답이 비어있음: storeId={}", storeId); - return 0; - } - - JsonNode root = objectMapper.readTree(responseBody); - - List> parsedReviews = new ArrayList<>(); - - // 구글 크롤링 응답 처리 - if (root.has("success") && root.get("success").asBoolean() && root.has("data")) { - JsonNode data = root.get("data"); - if (data.has("reviews")) { - JsonNode reviews = data.get("reviews"); - for (JsonNode reviewNode : reviews) { - Map review = new HashMap<>(); - review.put("reviewId", reviewNode.path("reviewId").asText()); - review.put("rating", reviewNode.path("rating").asDouble(0.0)); - review.put("content", reviewNode.path("content").asText()); - review.put("reviewerName", reviewNode.path("reviewerName").asText()); - review.put("createdAt", reviewNode.path("createdAt").asText()); - review.put("platform", platform); - parsedReviews.add(review); - } - } - } - - if (!parsedReviews.isEmpty()) { - saveToRedis(storeId, platform, parsedReviews); - updateSyncStatus(storeId, platform, "SUCCESS", parsedReviews.size()); - } - - return parsedReviews.size(); - - } catch (Exception e) { - log.error("구글 응답 파싱 실패: storeId={}, error={}", storeId, e.getMessage()); - updateSyncStatus(storeId, platform, "FAILED", 0); - return 0; - } - } - - private int processHiorderResponse(Long storeId, String platform, String responseBody) { - try { - if (responseBody == null || responseBody.trim().isEmpty()) { - log.warn("하이오더 응답이 비어있음: storeId={}", storeId); - return 0; - } - - JsonNode root = objectMapper.readTree(responseBody); - - List> parsedReviews = new ArrayList<>(); - - // 하이오더 API 응답 처리 - if (root.has("success") && root.get("success").asBoolean() && root.has("data")) { - JsonNode data = root.get("data"); - for (JsonNode reviewNode : data) { - Map review = new HashMap<>(); - review.put("reviewId", reviewNode.path("reviewId").asText()); - review.put("rating", reviewNode.path("rating").asDouble(0.0)); - review.put("content", reviewNode.path("comment").asText()); - review.put("reviewerName", reviewNode.path("customerName").asText()); - review.put("createdAt", reviewNode.path("reviewDate").asText()); - review.put("platform", platform); - parsedReviews.add(review); - } - } - - if (!parsedReviews.isEmpty()) { - saveToRedis(storeId, platform, parsedReviews); - updateSyncStatus(storeId, platform, "SUCCESS", parsedReviews.size()); - } - - return parsedReviews.size(); - - } catch (Exception e) { - log.error("하이오더 응답 파싱 실패: storeId={}, error={}", storeId, e.getMessage()); - updateSyncStatus(storeId, platform, "FAILED", 0); - return 0; - } - } - - private boolean validateCredentials(String username, String password) { - return username != null && !username.trim().isEmpty() && - password != null && !password.trim().isEmpty(); - } - - private void saveToRedis(Long storeId, String platform, List> reviews) { - try { - long timestamp = System.currentTimeMillis(); - String redisKey = String.format("external:reviews:pending:%d:%s:%d", storeId, platform, timestamp); - - Map cacheData = new HashMap<>(); - cacheData.put("storeId", storeId); - cacheData.put("platform", platform); - cacheData.put("reviews", reviews); - cacheData.put("timestamp", timestamp); - cacheData.put("status", "PENDING"); - cacheData.put("retryCount", 0); - - redisTemplate.opsForValue().set(redisKey, cacheData, Duration.ofDays(1)); - - log.info("Redis에 리뷰 데이터 저장 완료: key={}, count={}", redisKey, reviews.size()); - - } catch (Exception e) { - log.error("Redis 저장 실패: storeId={}, platform={}, error={}", - storeId, platform, e.getMessage()); - e.printStackTrace(); - } - } - - - /** - * CacheAdapter를 통한 Redis 저장 (안전한 방식) - */ - private void saveToRedisWithCache(Long storeId, String platform, List> reviews, Map storeInfo) { - try { - long timestamp = System.currentTimeMillis(); - String redisKey = String.format("external:reviews:pending:%d:%s:%d", storeId, platform, timestamp); - - Map cacheData = new HashMap<>(); - cacheData.put("storeId", storeId); - cacheData.put("platform", platform); - cacheData.put("reviews", reviews); - cacheData.put("reviewCount", reviews.size()); - cacheData.put("timestamp", timestamp); - cacheData.put("status", "PENDING"); - cacheData.put("syncTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - - if (storeInfo != null) { - cacheData.put("storeInfo", storeInfo); - } - - // 기존 CacheAdapter 활용 (TTL 1일 = 86400초) - cachePort.putStoreCache(redisKey, cacheData, 86400); - - log.info("CacheAdapter로 리뷰 데이터 저장 완료: key={}, reviewCount={}, hasStoreInfo={}", - redisKey, reviews.size(), storeInfo != null); - - } catch (Exception e) { - log.error("CacheAdapter 저장 실패: storeId={}, platform={}, error={}", storeId, platform, e.getMessage()); - } - } - - /** - * CacheAdapter를 통한 동기화 상태 업데이트 - */ - private void updateSyncStatusWithCache(Long storeId, String platform, String status, int count) { - try { - String statusKey = String.format("external:sync:status:%d:%s", storeId, platform); - - Map statusData = new HashMap<>(); - statusData.put("storeId", storeId); - statusData.put("platform", platform); - statusData.put("status", status); - statusData.put("syncedCount", count); - statusData.put("timestamp", System.currentTimeMillis()); - - // CacheAdapter 활용 (TTL 1일) - cachePort.putStoreCache(statusKey, statusData, 86400); - - log.info("CacheAdapter로 동기화 상태 저장 완료: storeId={}, platform={}, status={}, count={}", - storeId, platform, status, count); - - } catch (Exception e) { - log.warn("CacheAdapter 상태 저장 실패: storeId={}, platform={}, error={}", - storeId, platform, e.getMessage()); - } - } - - /** - * 리뷰 ID 생성 - */ - private String generateReviewId(JsonNode reviewNode) { - try { - String reviewerName = reviewNode.path("reviewer_name").asText(""); - String date = reviewNode.path("date").asText(""); - String content = reviewNode.path("content").asText(""); - - String combined = reviewerName + "|" + date + "|" + content.substring(0, Math.min(50, content.length())); - int hash = Math.abs(combined.hashCode()); - - return String.format("kakao_%s_%d_%d", - date.replaceAll("[^0-9]", ""), - hash, - System.currentTimeMillis() % 1000); - - } catch (Exception e) { - return String.format("kakao_fallback_%d_%d", - System.currentTimeMillis(), - (int)(Math.random() * 10000)); - } - } - - /** - * 동기화 상태 Redis에 저장 - */ - private void updateSyncStatus(Long storeId, String platform, String status, int count) { - try { - String statusKey = String.format("external:sync:status:%d:%s", storeId, platform); - - Map statusData = new HashMap<>(); - statusData.put("storeId", storeId); - statusData.put("platform", platform); - statusData.put("status", status); - statusData.put("syncedCount", count); - statusData.put("timestamp", System.currentTimeMillis()); - - redisTemplate.opsForValue().set(statusKey, statusData, Duration.ofDays(1)); - - } catch (Exception e) { - log.warn("동기화 상태 저장 실패: storeId={}, platform={}", storeId, platform); - } - } - - private void saveConnectionInfo(Long storeId, String platform, String username) { - try { - String connectionKey = String.format("external:connection:%d:%s", storeId, platform); - - Map connectionData = new HashMap<>(); - connectionData.put("storeId", storeId); - connectionData.put("platform", platform); - connectionData.put("username", username); - connectionData.put("connectedAt", System.currentTimeMillis()); - connectionData.put("status", "CONNECTED"); - - redisTemplate.opsForValue().set(connectionKey, connectionData, Duration.ofDays(30)); - - log.info("연동 정보 저장 완료: storeId={}, platform={}", storeId, platform); - - } catch (Exception e) { - log.error("연동 정보 저장 실패: storeId={}, platform={}, error={}", - storeId, platform, e.getMessage()); - } - } +package com.ktds.hi.store.infra.gateway; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktds.hi.store.biz.usecase.out.CachePort; +import com.ktds.hi.store.biz.usecase.out.ExternalPlatformPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 외부 플랫폼 연동 어댑터 + * 각 플랫폼별 API 호출 및 Redis 저장을 담당 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ExternalPlatformAdapter implements ExternalPlatformPort { + + private final RestTemplate restTemplate; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final CachePort cachePort; + + @Value("${external.kakao.crawler.url:http://localhost:9001}") + private String kakaoCrawlerUrl; + + @Value("${external.naver.crawler.url:http://localhost:9002}") + private String naverCrawlerUrl; + + @Value("${external.google.crawler.url:http://localhost:9003}") + private String googleCrawlerUrl; + + @Value("${external.hiorder.api.url:http://localhost:8080}") + private String hiorderApiUrl; + + // ===== 리뷰 동기화 메서드들 ===== + + @Override + public int syncNaverReviews(Long storeId, String externalStoreId) { + log.info("네이버 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); + + try { + // 네이버 크롤링 서비스 호출 + String url = String.format("%s/api/naver/reviews?storeId=%s", naverCrawlerUrl, externalStoreId); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange(url, org.springframework.http.HttpMethod.GET, entity, String.class); + + return processNaverResponse(storeId, "NAVER", response.getBody()); + + } catch (Exception e) { + log.error("네이버 리뷰 동기화 실패: storeId={}, externalStoreId={}, error={}", + storeId, externalStoreId, e.getMessage()); + updateSyncStatus(storeId, "NAVER", "FAILED", 0); + return 0; + } + } + + //*****// + @Override + public int syncKakaoReviews(Long storeId, String externalStoreId) { + log.info("카카오 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); + + try { + String url = "http://kakao-review-api-service.ai-review-ns.svc.cluster.local/analyze"; + + Map requestBody = new HashMap<>(); + requestBody.put("store_id", externalStoreId); + requestBody.put("days_limit", 1000); + requestBody.put("max_time", 300); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + // 🔥 실제 카카오 응답 파싱 및 Redis 저장 + return parseAndStoreToRedis(storeId, "KAKAO", response.getBody()); + + } catch (Exception e) { + log.error("카카오 리뷰 동기화 실패: storeId={}, error={}", storeId, e.getMessage(), e); + updateSyncStatus(storeId, "KAKAO", "FAILED", 0); + return 0; + } + } + + @Override + public int syncGoogleReviews(Long storeId, String externalStoreId) { + log.info("구글 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); + + try { + // 구글 크롤링 서비스 호출 + String url = String.format("%s/api/google/reviews?storeId=%s", googleCrawlerUrl, externalStoreId); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange(url, org.springframework.http.HttpMethod.GET, entity, String.class); + + return processGoogleResponse(storeId, "GOOGLE", response.getBody()); + + } catch (Exception e) { + log.error("구글 리뷰 동기화 실패: storeId={}, externalStoreId={}, error={}", + storeId, externalStoreId, e.getMessage()); + updateSyncStatus(storeId, "GOOGLE", "FAILED", 0); + return 0; + } + } + + @Override + public int syncHiorderReviews(Long storeId, String externalStoreId) { + log.info("하이오더 리뷰 동기화 시작: storeId={}, externalStoreId={}", storeId, externalStoreId); + + try { + // 하이오더 API 호출 + String url = String.format("%s/api/reviews?storeId=%s", hiorderApiUrl, externalStoreId); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange(url, org.springframework.http.HttpMethod.GET, entity, String.class); + + return processHiorderResponse(storeId, "HIORDER", response.getBody()); + + } catch (Exception e) { + log.error("하이오더 리뷰 동기화 실패: storeId={}, externalStoreId={}, error={}", + storeId, externalStoreId, e.getMessage()); + updateSyncStatus(storeId, "HIORDER", "FAILED", 0); + return 0; + } + } + + /** + * 카카오 응답 파싱 및 Redis 저장 (CacheAdapter 활용) + */ + private int parseAndStoreToRedis(Long storeId, String platform, String responseBody) { + try { + log.info("카카오 API 응답 파싱 시작: storeId={}", storeId); + + + + if (responseBody == null || responseBody.trim().isEmpty()) { + log.warn("카카오 응답이 비어있음: storeId={}", storeId); + return 0; + } + + JsonNode root = objectMapper.readTree(responseBody); + + // 실제 카카오 응답 구조 확인 + if (!root.has("success") || !root.get("success").asBoolean()) { + log.warn("카카오 API 호출 실패:: {}", root.path("message").asText()); + updateSyncStatusWithCache(storeId, platform, "FAILED", 0); + return 0; + } + + // reviews 배열 직접 접근 + JsonNode reviewsNode = root.get("reviews"); + if (reviewsNode == null || !reviewsNode.isArray() || reviewsNode.size() == 0) { + log.warn("카카오 응답에 reviews 배열이 없거나, 리뷰 목록이 없음!"); + updateSyncStatusWithCache(storeId, platform, "SUCCESS", 0); + return 0; + } + + + // 매장 정보 파싱 (옵션) + Map storeInfo = null; + if (root.has("store_info")) { + JsonNode storeInfoNode = root.get("store_info"); + storeInfo = new HashMap<>(); + storeInfo.put("id", storeInfoNode.path("id").asText()); + storeInfo.put("name", storeInfoNode.path("name").asText()); + storeInfo.put("category", storeInfoNode.path("category").asText()); + storeInfo.put("rating", storeInfoNode.path("rating").asText()); + storeInfo.put("reviewCount", storeInfoNode.path("review_count").asText()); + storeInfo.put("status", storeInfoNode.path("status").asText()); + storeInfo.put("address", storeInfoNode.path("address").asText()); + } + + // 리뷰 데이터 파싱 + List> reviews = new ArrayList<>(); + for (JsonNode reviewNode : reviewsNode) { + Map review = new HashMap<>(); + + // 기본 정보 + review.put("reviewId", generateReviewId(reviewNode)); + review.put("reviewerName", reviewNode.path("reviewer_name").asText("")); + review.put("reviewerLevel", reviewNode.path("reviewer_level").asText("")); + review.put("rating", reviewNode.path("rating").asInt(0)); + review.put("date", reviewNode.path("date").asText("")); + review.put("content", reviewNode.path("content").asText("")); + review.put("likes", reviewNode.path("likes").asInt(0)); + review.put("photoCount", reviewNode.path("photo_count").asInt(0)); + review.put("hasPhotos", reviewNode.path("has_photos").asBoolean(false)); + review.put("platform", platform); + + // 리뷰어 통계 + if (reviewNode.has("reviewer_stats")) { + JsonNode statsNode = reviewNode.get("reviewer_stats"); + Map reviewerStats = new HashMap<>(); + reviewerStats.put("reviews", statsNode.path("reviews").asInt(0)); + reviewerStats.put("averageRating", statsNode.path("average_rating").asDouble(0.0)); + reviewerStats.put("followers", statsNode.path("followers").asInt(0)); + review.put("reviewerStats", reviewerStats); + } + + // 배지 + if (reviewNode.has("badges") && reviewNode.get("badges").isArray()) { + List badges = new ArrayList<>(); + for (JsonNode badgeNode : reviewNode.get("badges")) { + badges.add(badgeNode.asText()); + } + review.put("badges", badges); + } else { + review.put("badges", new ArrayList()); + } + + reviews.add(review); + } + + log.info("파싱된 리뷰 수: {}", reviews.size()); + + // CacheAdapter를 통한 Redis 저장 + saveToRedisWithCache(storeId, platform, reviews, storeInfo); + + // 동기화 상태 업데이트 + updateSyncStatusWithCache(storeId, platform, "SUCCESS", reviews.size()); + + return reviews.size(); + + } catch (Exception e) { + log.error("카카오 응답 파싱 실패: storeId={}, error={}", storeId, e.getMessage(), e); + updateSyncStatusWithCache(storeId, platform, "FAILED", 0); + return 0; + } + } + // ===== 계정 연동 메서드들 ===== + + @Override + public boolean connectNaverAccount(Long storeId, String username, String password) { + log.info("네이버 계정 연동: storeId={}", storeId); + try { + if (validateCredentials(username, password)) { + saveConnectionInfo(storeId, "NAVER", username); + return true; + } + return false; + } catch (Exception e) { + log.error("네이버 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage()); + return false; + } + } + + @Override + public boolean connectKakaoAccount(Long storeId, String username, String password) { + log.info("카카오 계정 연동: storeId={}", storeId); + try { + if (validateCredentials(username, password)) { + saveConnectionInfo(storeId, "KAKAO", username); + return true; + } + return false; + } catch (Exception e) { + log.error("카카오 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage()); + return false; + } + } + + @Override + public boolean connectGoogleAccount(Long storeId, String username, String password) { + log.info("구글 계정 연동: storeId={}", storeId); + try { + if (validateCredentials(username, password)) { + saveConnectionInfo(storeId, "GOOGLE", username); + return true; + } + return false; + } catch (Exception e) { + log.error("구글 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage()); + return false; + } + } + + @Override + public boolean connectHiorderAccount(Long storeId, String username, String password) { + log.info("하이오더 계정 연동: storeId={}", storeId); + try { + if (validateCredentials(username, password)) { + saveConnectionInfo(storeId, "HIORDER", username); + return true; + } + return false; + } catch (Exception e) { + log.error("하이오더 계정 연동 실패: storeId={}, error={}", storeId, e.getMessage()); + return false; + } + } + + @Override + public boolean disconnectPlatform(Long storeId, String platform) { + log.info("플랫폼 연동 해제: storeId={}, platform={}", storeId, platform); + + try { + String connectionKey = String.format("external:connection:%d:%s", storeId, platform); + redisTemplate.delete(connectionKey); + + log.info("플랫폼 연동 해제 완료: storeId={}, platform={}", storeId, platform); + return true; + + } catch (Exception e) { + log.error("플랫폼 연동 해제 실패: storeId={}, platform={}, error={}", + storeId, platform, e.getMessage()); + return false; + } + } + + @Override + public List getConnectedPlatforms(Long storeId) { + log.info("연동된 플랫폼 조회: storeId={}", storeId); + + try { + List connectedPlatforms = new ArrayList<>(); + + String pattern = String.format("external:connection:%d:*", storeId); + Set keys = redisTemplate.keys(pattern); + + if (keys != null) { + for (String key : keys) { + String platform = key.substring(key.lastIndexOf(':') + 1); + connectedPlatforms.add(platform.toUpperCase()); + } + } + + return connectedPlatforms; + + } catch (Exception e) { + log.error("연동된 플랫폼 조회 실패: storeId={}, error={}", storeId, e.getMessage()); + return new ArrayList<>(); + } + } + + @Override + public List> getTempReviews(Long storeId, String platform) { + try { + String pattern = String.format("external:reviews:pending:%d:%s:*", storeId, platform); + Set keys = redisTemplate.keys(pattern); + + if (keys != null && !keys.isEmpty()) { + String latestKey = keys.stream() + .max(Comparator.comparing(key -> { + try { + return Long.parseLong(key.substring(key.lastIndexOf(':') + 1)); + } catch (NumberFormatException e) { + return 0L; + } + })) + .orElse(null); + + if (latestKey != null) { + Map cacheData = (Map) redisTemplate.opsForValue().get(latestKey); + if (cacheData != null && cacheData.containsKey("reviews")) { + return (List>) cacheData.get("reviews"); + } + } + } + + return new ArrayList<>(); + + } catch (Exception e) { + log.error("Redis에서 임시 리뷰 데이터 조회 실패: storeId={}, platform={}", storeId, platform); + return new ArrayList<>(); + } + } + + // ===== Private Helper Methods ===== + + private int processNaverResponse(Long storeId, String platform, String responseBody) { + try { + if (responseBody == null || responseBody.trim().isEmpty()) { + log.warn("네이버 응답이 비어있음: storeId={}", storeId); + return 0; + } + + JsonNode root = objectMapper.readTree(responseBody); + + List> parsedReviews = new ArrayList<>(); + + // 네이버 크롤링 응답 처리 + if (root.has("success") && root.get("success").asBoolean() && root.has("data")) { + JsonNode data = root.get("data"); + if (data.has("reviews")) { + JsonNode reviews = data.get("reviews"); + for (JsonNode reviewNode : reviews) { + Map review = new HashMap<>(); + review.put("reviewId", reviewNode.path("reviewId").asText()); + review.put("rating", reviewNode.path("rating").asDouble(0.0)); + review.put("content", reviewNode.path("content").asText()); + review.put("reviewerName", reviewNode.path("reviewerName").asText()); + review.put("createdAt", reviewNode.path("createdAt").asText()); + review.put("platform", platform); + parsedReviews.add(review); + } + } + } + + if (!parsedReviews.isEmpty()) { + saveToRedis(storeId, platform, parsedReviews); + updateSyncStatus(storeId, platform, "SUCCESS", parsedReviews.size()); + + log.info("Redis에 리뷰 데이터 저장 완료: key={}, count={}", + String.format("external:reviews:pending:%d:%s:%d", storeId, platform, System.currentTimeMillis()), + parsedReviews.size()); + } + + return parsedReviews.size(); + + } catch (Exception e) { + log.error("네이버 응답 파싱 및 Redis 저장 실패: {}", e.getMessage()); + updateSyncStatus(storeId, platform, "FAILED", 0); + return 0; + } + } + + private int processKakaoResponse(Long storeId, String platform, String responseBody) { + try { + if (responseBody == null || responseBody.trim().isEmpty()) { + log.warn("카카오 응답이 비어있음: storeId={}", storeId); + return 0; + } + + JsonNode root = objectMapper.readTree(responseBody); + + List> parsedReviews = new ArrayList<>(); + + // 카카오 크롤링 응답 처리 + if (root.has("success") && root.get("success").asBoolean() && root.has("data")) { + JsonNode data = root.get("data"); + if (data.has("reviews")) { + JsonNode reviews = data.get("reviews"); + for (JsonNode reviewNode : reviews) { + Map review = new HashMap<>(); + review.put("reviewId", reviewNode.path("reviewId").asText()); + review.put("rating", reviewNode.path("rating").asDouble(0.0)); + review.put("content", reviewNode.path("content").asText()); + review.put("reviewerName", reviewNode.path("reviewerName").asText()); + review.put("createdAt", reviewNode.path("createdAt").asText()); + review.put("platform", platform); + parsedReviews.add(review); + } + } + } + + if (!parsedReviews.isEmpty()) { + saveToRedis(storeId, platform, parsedReviews); + updateSyncStatus(storeId, platform, "SUCCESS", parsedReviews.size()); + + log.info("Redis에 리뷰 데이터 저장 완료: key={}, count={}", + String.format("external:reviews:pending:%d:%s:%d", storeId, platform, System.currentTimeMillis()), + parsedReviews.size()); + } + + return parsedReviews.size(); + + } catch (Exception e) { + log.error("카카오 응답 파싱 및 Redis 저장 실패: {}", e.getMessage()); + updateSyncStatus(storeId, platform, "FAILED", 0); + return 0; + } + } + + private int processGoogleResponse(Long storeId, String platform, String responseBody) { + try { + if (responseBody == null || responseBody.trim().isEmpty()) { + log.warn("구글 응답이 비어있음: storeId={}", storeId); + return 0; + } + + JsonNode root = objectMapper.readTree(responseBody); + + List> parsedReviews = new ArrayList<>(); + + // 구글 크롤링 응답 처리 + if (root.has("success") && root.get("success").asBoolean() && root.has("data")) { + JsonNode data = root.get("data"); + if (data.has("reviews")) { + JsonNode reviews = data.get("reviews"); + for (JsonNode reviewNode : reviews) { + Map review = new HashMap<>(); + review.put("reviewId", reviewNode.path("reviewId").asText()); + review.put("rating", reviewNode.path("rating").asDouble(0.0)); + review.put("content", reviewNode.path("content").asText()); + review.put("reviewerName", reviewNode.path("reviewerName").asText()); + review.put("createdAt", reviewNode.path("createdAt").asText()); + review.put("platform", platform); + parsedReviews.add(review); + } + } + } + + if (!parsedReviews.isEmpty()) { + saveToRedis(storeId, platform, parsedReviews); + updateSyncStatus(storeId, platform, "SUCCESS", parsedReviews.size()); + } + + return parsedReviews.size(); + + } catch (Exception e) { + log.error("구글 응답 파싱 실패: storeId={}, error={}", storeId, e.getMessage()); + updateSyncStatus(storeId, platform, "FAILED", 0); + return 0; + } + } + + private int processHiorderResponse(Long storeId, String platform, String responseBody) { + try { + if (responseBody == null || responseBody.trim().isEmpty()) { + log.warn("하이오더 응답이 비어있음: storeId={}", storeId); + return 0; + } + + JsonNode root = objectMapper.readTree(responseBody); + + List> parsedReviews = new ArrayList<>(); + + // 하이오더 API 응답 처리 + if (root.has("success") && root.get("success").asBoolean() && root.has("data")) { + JsonNode data = root.get("data"); + for (JsonNode reviewNode : data) { + Map review = new HashMap<>(); + review.put("reviewId", reviewNode.path("reviewId").asText()); + review.put("rating", reviewNode.path("rating").asDouble(0.0)); + review.put("content", reviewNode.path("comment").asText()); + review.put("reviewerName", reviewNode.path("customerName").asText()); + review.put("createdAt", reviewNode.path("reviewDate").asText()); + review.put("platform", platform); + parsedReviews.add(review); + } + } + + if (!parsedReviews.isEmpty()) { + saveToRedis(storeId, platform, parsedReviews); + updateSyncStatus(storeId, platform, "SUCCESS", parsedReviews.size()); + } + + return parsedReviews.size(); + + } catch (Exception e) { + log.error("하이오더 응답 파싱 실패: storeId={}, error={}", storeId, e.getMessage()); + updateSyncStatus(storeId, platform, "FAILED", 0); + return 0; + } + } + + private boolean validateCredentials(String username, String password) { + return username != null && !username.trim().isEmpty() && + password != null && !password.trim().isEmpty(); + } + + private void saveToRedis(Long storeId, String platform, List> reviews) { + try { + long timestamp = System.currentTimeMillis(); + String redisKey = String.format("external:reviews:pending:%d:%s:%d", storeId, platform, timestamp); + + Map cacheData = new HashMap<>(); + cacheData.put("storeId", storeId); + cacheData.put("platform", platform); + cacheData.put("reviews", reviews); + cacheData.put("timestamp", timestamp); + cacheData.put("status", "PENDING"); + cacheData.put("retryCount", 0); + + redisTemplate.opsForValue().set(redisKey, cacheData, Duration.ofDays(1)); + + log.info("Redis에 리뷰 데이터 저장 완료: key={}, count={}", redisKey, reviews.size()); + + } catch (Exception e) { + log.error("Redis 저장 실패: storeId={}, platform={}, error={}", + storeId, platform, e.getMessage()); + e.printStackTrace(); + } + } + + + /** + * CacheAdapter를 통한 Redis 저장 (안전한 방식) + */ + private void saveToRedisWithCache(Long storeId, String platform, List> reviews, Map storeInfo) { + try { + long timestamp = System.currentTimeMillis(); + String redisKey = String.format("external:reviews:pending:%d:%s:%d", storeId, platform, timestamp); + + Map cacheData = new HashMap<>(); + cacheData.put("storeId", storeId); + cacheData.put("platform", platform); + cacheData.put("reviews", reviews); + cacheData.put("reviewCount", reviews.size()); + cacheData.put("timestamp", timestamp); + cacheData.put("status", "PENDING"); + cacheData.put("syncTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + + if (storeInfo != null) { + cacheData.put("storeInfo", storeInfo); + } + + // 기존 CacheAdapter 활용 (TTL 1일 = 86400초) + cachePort.putStoreCache(redisKey, cacheData, 86400); + + log.info("CacheAdapter로 리뷰 데이터 저장 완료: key={}, reviewCount={}, hasStoreInfo={}", + redisKey, reviews.size(), storeInfo != null); + + } catch (Exception e) { + log.error("CacheAdapter 저장 실패: storeId={}, platform={}, error={}", storeId, platform, e.getMessage()); + } + } + + /** + * CacheAdapter를 통한 동기화 상태 업데이트 + */ + private void updateSyncStatusWithCache(Long storeId, String platform, String status, int count) { + try { + String statusKey = String.format("external:sync:status:%d:%s", storeId, platform); + + Map statusData = new HashMap<>(); + statusData.put("storeId", storeId); + statusData.put("platform", platform); + statusData.put("status", status); + statusData.put("syncedCount", count); + statusData.put("timestamp", System.currentTimeMillis()); + + // CacheAdapter 활용 (TTL 1일) + cachePort.putStoreCache(statusKey, statusData, 86400); + + log.info("CacheAdapter로 동기화 상태 저장 완료: storeId={}, platform={}, status={}, count={}", + storeId, platform, status, count); + + } catch (Exception e) { + log.warn("CacheAdapter 상태 저장 실패: storeId={}, platform={}, error={}", + storeId, platform, e.getMessage()); + } + } + + /** + * 리뷰 ID 생성 + */ + private String generateReviewId(JsonNode reviewNode) { + try { + String reviewerName = reviewNode.path("reviewer_name").asText(""); + String date = reviewNode.path("date").asText(""); + String content = reviewNode.path("content").asText(""); + + String combined = reviewerName + "|" + date + "|" + content.substring(0, Math.min(50, content.length())); + int hash = Math.abs(combined.hashCode()); + + return String.format("kakao_%s_%d_%d", + date.replaceAll("[^0-9]", ""), + hash, + System.currentTimeMillis() % 1000); + + } catch (Exception e) { + return String.format("kakao_fallback_%d_%d", + System.currentTimeMillis(), + (int)(Math.random() * 10000)); + } + } + + /** + * 동기화 상태 Redis에 저장 + */ + private void updateSyncStatus(Long storeId, String platform, String status, int count) { + try { + String statusKey = String.format("external:sync:status:%d:%s", storeId, platform); + + Map statusData = new HashMap<>(); + statusData.put("storeId", storeId); + statusData.put("platform", platform); + statusData.put("status", status); + statusData.put("syncedCount", count); + statusData.put("timestamp", System.currentTimeMillis()); + + redisTemplate.opsForValue().set(statusKey, statusData, Duration.ofDays(1)); + + } catch (Exception e) { + log.warn("동기화 상태 저장 실패: storeId={}, platform={}", storeId, platform); + } + } + + private void saveConnectionInfo(Long storeId, String platform, String username) { + try { + String connectionKey = String.format("external:connection:%d:%s", storeId, platform); + + Map connectionData = new HashMap<>(); + connectionData.put("storeId", storeId); + connectionData.put("platform", platform); + connectionData.put("username", username); + connectionData.put("connectedAt", System.currentTimeMillis()); + connectionData.put("status", "CONNECTED"); + + redisTemplate.opsForValue().set(connectionKey, connectionData, Duration.ofDays(30)); + + log.info("연동 정보 저장 완료: storeId={}, platform={}", storeId, platform); + + } catch (Exception e) { + log.error("연동 정보 저장 실패: storeId={}, platform={}, error={}", + storeId, platform, e.getMessage()); + } + } } \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java index 363dd68..70d6ceb 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java @@ -197,4 +197,6 @@ public class StoreEntity { public boolean hasReviews() { return this.reviewCount != null && this.reviewCount > 0; } + + } \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreJpaRepository.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreJpaRepository.java index 387cfe9..7e48906 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreJpaRepository.java +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreJpaRepository.java @@ -30,6 +30,7 @@ public interface StoreJpaRepository extends JpaRepository { */ List findByOwnerId(Long ownerId); + /** * 매장 ID와 점주 ID로 매장 조회 */ @@ -145,4 +146,6 @@ public interface StoreJpaRepository extends JpaRepository { @Param("minRating") Double minRating, @Param("keyword") String keyword, Pageable pageable); + + StoreEntity findById(Long id); } \ No newline at end of file