diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java index 5a151f6..3caffa1 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java @@ -1,7 +1,9 @@ package com.ktds.hi.analytics.biz.service; +import com.ktds.hi.analytics.biz.domain.ActionPlan; import com.ktds.hi.analytics.biz.domain.Analytics; import com.ktds.hi.analytics.biz.domain.AiFeedback; +import com.ktds.hi.analytics.biz.domain.PlanStatus; import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase; import com.ktds.hi.analytics.biz.usecase.out.*; import com.ktds.hi.analytics.infra.dto.*; @@ -23,7 +25,7 @@ import java.util.Optional; @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) +@Transactional public class AnalyticsService implements AnalyticsUseCase { private final AnalyticsPort analyticsPort; @@ -32,9 +34,10 @@ public class AnalyticsService implements AnalyticsUseCase { private final OrderDataPort orderDataPort; private final CachePort cachePort; private final EventPort eventPort; + private final ActionPlanPort actionPlanPort; // 추가된 의존성 @Override - @Cacheable(value = "storeAnalytics", key = "#storeId") + // @Cacheable(value = "storeAnalytics", key = "#storeId") public StoreAnalyticsResponse getStoreAnalytics(Long storeId) { log.info("매장 분석 데이터 조회 시작: storeId={}", storeId); @@ -43,8 +46,15 @@ public class AnalyticsService implements AnalyticsUseCase { String cacheKey = "analytics:store:" + storeId; var cachedResult = cachePort.getAnalyticsCache(cacheKey); if (cachedResult.isPresent()) { - log.info("캐시에서 분석 데이터 반환: storeId={}", storeId); - return (StoreAnalyticsResponse) cachedResult.get(); + Object cached = cachedResult.get(); + // StoreAnalyticsResponse 타입인지 확인 + if (cached instanceof StoreAnalyticsResponse) { + log.info("캐시에서 분석 데이터 반환: storeId={}", storeId); + return (StoreAnalyticsResponse) cached; + } + // LinkedHashMap인 경우 스킵하고 DB에서 조회 + log.debug("캐시 데이터 타입 불일치, DB에서 조회: storeId={}, type={}", + storeId, cached.getClass().getSimpleName()); } // 2. 데이터베이스에서 기존 분석 데이터 조회 @@ -81,11 +91,23 @@ public class AnalyticsService implements AnalyticsUseCase { // ... 나머지 메서드들은 이전과 동일 ... @Override - @Cacheable(value = "aiFeedback", key = "#storeId") + // @Cacheable(value = "aiFeedback", key = "#storeId") public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) { log.info("AI 피드백 상세 조회 시작: storeId={}", storeId); try { + // 1. 캐시에서 먼저 확인 (타입 안전성 보장) + String cacheKey = "ai_feedback_detail:store:" + storeId; + var cachedResult = cachePort.getAnalyticsCache(cacheKey); + if (cachedResult.isPresent()) { + Object cached = cachedResult.get(); + if (cached instanceof AiFeedbackDetailResponse) { + log.info("캐시에서 AI 피드백 반환: storeId={}", storeId); + return (AiFeedbackDetailResponse) cached; + } + log.debug("AI 피드백 캐시 데이터 타입 불일치, DB에서 조회: storeId={}", storeId); + } + // 1. 기존 AI 피드백 조회 var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId); @@ -96,6 +118,7 @@ public class AnalyticsService implements AnalyticsUseCase { // 3. 응답 생성 AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder() + .feedbackId(aiFeedback.get().getId()) .storeId(storeId) .summary(aiFeedback.get().getSummary()) .positivePoints(aiFeedback.get().getPositivePoints()) @@ -124,11 +147,15 @@ public class AnalyticsService implements AnalyticsUseCase { try { // 1. 캐시 키 생성 + // 1. 캐시 키 생성 및 확인 String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate); var cachedResult = cachePort.getAnalyticsCache(cacheKey); if (cachedResult.isPresent()) { - log.info("캐시에서 통계 데이터 반환: storeId={}", storeId); - return (StoreStatisticsResponse) cachedResult.get(); + Object cached = cachedResult.get(); + if (cached instanceof StoreStatisticsResponse) { + log.info("캐시에서 통계 데이터 반환: storeId={}", storeId); + return (StoreStatisticsResponse) cached; + } } // 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용) @@ -168,7 +195,10 @@ public class AnalyticsService implements AnalyticsUseCase { String cacheKey = "ai_feedback_summary:store:" + storeId; var cachedResult = cachePort.getAnalyticsCache(cacheKey); if (cachedResult.isPresent()) { - return (AiFeedbackSummaryResponse) cachedResult.get(); + Object cached = cachedResult.get(); + if (cached instanceof AiFeedbackSummaryResponse) { + return (AiFeedbackSummaryResponse) cached; + } } // 2. AI 피드백 조회 @@ -219,7 +249,10 @@ public class AnalyticsService implements AnalyticsUseCase { String cacheKey = "review_analysis:store:" + storeId; var cachedResult = cachePort.getAnalyticsCache(cacheKey); if (cachedResult.isPresent()) { - return (ReviewAnalysisResponse) cachedResult.get(); + Object cached = cachedResult.get(); + if (cached instanceof ReviewAnalysisResponse) { + return (ReviewAnalysisResponse) cached; + } } // 2. 최근 리뷰 데이터 조회 (30일) @@ -440,20 +473,26 @@ public class AnalyticsService implements AnalyticsUseCase { } @Override + @Transactional public List generateActionPlansFromFeedback(Long feedbackId) { log.info("실행계획 생성: feedbackId={}", feedbackId); try { // 1. AI 피드백 조회 - var aiFeedback = analyticsPort.findAIFeedbackByStoreId(feedbackId); // 실제로는 feedbackId로 조회하는 메서드 필요 + var aiFeedback = analyticsPort.findAIFeedbackById(feedbackId); if (aiFeedback.isEmpty()) { throw new RuntimeException("AI 피드백을 찾을 수 없습니다: " + feedbackId); } + AiFeedback feedback = aiFeedback.get(); // 2. 기존 AIServicePort.generateActionPlan 메서드 활용 List actionPlans = aiServicePort.generateActionPlan(aiFeedback.get()); + + // 3. DB에 실행계획 저장 + saveGeneratedActionPlansToDatabase(feedback, actionPlans); + log.info("실행계획 생성 완료: feedbackId={}, planCount={}", feedbackId, actionPlans.size()); return actionPlans; @@ -475,6 +514,8 @@ public class AnalyticsService implements AnalyticsUseCase { // 1. 리뷰 데이터 수집 List reviewData = externalReviewPort.getRecentReviews(storeId, days); + log.info("review Data check ===> {}", reviewData); + if (reviewData.isEmpty()) { log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId); return createDefaultAIFeedback(storeId); @@ -533,4 +574,49 @@ public class AnalyticsService implements AnalyticsUseCase { .build(); } + /** + * 생성된 실행계획을 데이터베이스에 저장하는 메서드 + * AI 피드백 기반으로 생성된 실행계획들을 ActionPlan 테이블에 저장 + */ + private void saveGeneratedActionPlansToDatabase(AiFeedback feedback, List actionPlans) { + if (actionPlans.isEmpty()) { + log.info("저장할 실행계획이 없습니다: storeId={}", feedback.getStoreId()); + return; + } + + log.info("실행계획 DB 저장 시작: storeId={}, feedbackId={}, planCount={}", + feedback.getStoreId(), feedback.getId(), actionPlans.size()); + + for (int i = 0; i < actionPlans.size(); i++) { + String planContent = actionPlans.get(i); + + // ActionPlan 도메인 객체 생성 (기존 ActionPlanService의 패턴과 동일하게) + ActionPlan actionPlan = ActionPlan.builder() + .storeId(feedback.getStoreId()) + .userId(1L) // AI가 생성한 계획이므로 userId는 null + .title("AI 추천 실행계획 " + (i + 1)) + .description(planContent) + .period("1개월") // 기본 실행 기간 + .status(PlanStatus.PLANNED) + .tasks(List.of(planContent)) // 생성된 계획을 tasks로 설정 + .note("AI 피드백(ID: " + feedback.getId() + ")을 기반으로 자동 생성된 실행계획") + .createdAt(LocalDateTime.now()) + .build(); + + try { + // ActionPlan 저장 (기존 ActionPlanPort 활용) + ActionPlan savedPlan = actionPlanPort.saveActionPlan(actionPlan); + log.info("실행계획 저장 완료: storeId={}, planId={}, title={}", + feedback.getStoreId(), savedPlan.getId(), savedPlan.getTitle()); + + } catch (Exception e) { + log.error("실행계획 저장 실패: storeId={}, title={}", + feedback.getStoreId(), actionPlan.getTitle(), e); + // 개별 저장 실패 시에도 다음 계획은 계속 저장 시도 + } + } + + log.info("실행계획 DB 저장 완료: storeId={}, 총 {}개 계획 저장", + feedback.getStoreId(), actionPlans.size()); + } } 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 05563dc..f8a44a0 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 @@ -25,7 +25,13 @@ public interface AnalyticsPort { * 매장 ID로 AI 피드백 조회 */ Optional findAIFeedbackByStoreId(Long storeId); - + + /** + * AI 피드백 ID로 조회 (추가된 메서드) + */ + Optional findAIFeedbackById(Long feedbackId); + + /** * AI 피드백 저장 */ diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java index f7ac3cc..ee51338 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java @@ -25,7 +25,7 @@ public class AiAnalysisRequest { @Builder.Default private Integer days = 30; - @Schema(description = "실행계획 자동 생성 여부", example = "true") + @Schema(description = "실행계획 자동 생성 여부", example = "false") @Builder.Default - private Boolean generateActionPlan = true; + private Boolean generateActionPlan = false; } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java index 4ac7af3..edefea4 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java @@ -16,7 +16,8 @@ import java.util.List; @NoArgsConstructor @AllArgsConstructor public class AiFeedbackDetailResponse { - + + private Long feedbackId; private Long storeId; private String summary; private List positivePoints; 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 3c1c69b..978354b 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 @@ -8,19 +8,30 @@ 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.List; +import java.util.Map; /** * AI 서비스 어댑터 클래스 @@ -29,135 +40,356 @@ import java.util.List; @Slf4j @Component public class AIServiceAdapter implements AIServicePort { - - @Value("${ai.azure.cognitive.endpoint}") - private String cognitiveEndpoint; - - @Value("${ai.azure.cognitive.key}") - private String cognitiveKey; - - @Value("${ai.openai.api-key}") + + + @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 서비스 클라이언트 초기화 완료"); + // 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("AI 피드백 생성 시작: 리뷰 수={}", reviewData.size()); - + + log.info("OpenAI 피드백 생성 시작: 리뷰 수={}", reviewData.size()); + try { if (reviewData.isEmpty()) { return createEmptyFeedback(); } - - // 1. 감정 분석 수행 - List sentiments = reviewData.stream() - .map(this::analyzeSentiment) - .toList(); - - // 2. 긍정/부정 비율 계산 - long positiveCount = sentiments.stream() - .mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0) - .sum(); - - long negativeCount = sentiments.stream() - .mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0) - .sum(); - - double positiveRate = (double) positiveCount / reviewData.size() * 100; - double negativeRate = (double) negativeCount / reviewData.size() * 100; - - // 3. 피드백 생성 - AiFeedback feedback = AiFeedback.builder() - .summary(generateSummary(positiveRate, negativeRate, reviewData.size())) - .positivePoints(generatePositivePoints(reviewData, sentiments)) - .improvementPoints(generateImprovementPoints(reviewData, sentiments)) - .recommendations(generateRecommendations(positiveRate, negativeRate)) - .sentimentAnalysis(String.format("긍정: %.1f%%, 부정: %.1f%%", positiveRate, negativeRate)) - .confidenceScore(calculateConfidenceScore(reviewData.size())) - .generatedAt(LocalDateTime.now()) - .build(); - - log.info("AI 피드백 생성 완료: 긍정률={}%, 부정률={}%", positiveRate, negativeRate); - return feedback; - + + // OpenAI API 호출하여 전체 리뷰 분석 + String analysisResult = callOpenAIForAnalysis(reviewData); + + // 결과 파싱 및 AiFeedback 객체 생성 + return parseAnalysisResult(analysisResult, reviewData.size()); + } catch (Exception e) { - log.error("AI 피드백 생성 중 오류 발생", e); - throw new RuntimeException("AI 피드백 생성에 실패했습니다.", e); + log.error("OpenAI 피드백 생성 중 오류 발생", e); + return createFallbackFeedback(reviewData); } } - - @Override public SentimentType analyzeSentiment(String content) { try { - DocumentSentiment documentSentiment = textAnalyticsClient.analyzeSentiment(content); - TextSentiment sentiment = documentSentiment.getSentiment(); + String prompt = String.format( + "다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로만 답변해주세요.\n\n리뷰: %s", + content + ); - if (sentiment == TextSentiment.POSITIVE) { + String result = callOpenAI(prompt); + + if (result.toUpperCase().contains("POSITIVE")) { return SentimentType.POSITIVE; - } else if (sentiment == TextSentiment.NEGATIVE) { + } else if (result.toUpperCase().contains("NEGATIVE")) { return SentimentType.NEGATIVE; - } else if (sentiment == TextSentiment.NEUTRAL) { - return SentimentType.NEUTRAL; - } else if (sentiment == TextSentiment.MIXED) { - return SentimentType.NEUTRAL; // MIXED는 NEUTRAL로 처리 } else { return SentimentType.NEUTRAL; } - + } catch (Exception e) { - log.warn("감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length()))); + log.warn("OpenAI 감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length()))); return SentimentType.NEUTRAL; } } @Override public List generateActionPlan(AiFeedback feedback) { - log.info("실행 계획 생성 시작"); - + log.info("OpenAI 실행 계획 생성 시작"); + try { - // 개선점을 기반으로 실행 계획 생성 - List actionPlans = feedback.getImprovementPoints().stream() - .map(this::convertToActionPlan) - .toList(); - - log.info("실행 계획 생성 완료: 계획 수={}", actionPlans.size()); - return actionPlans; - + String prompt = String.format( + """ + 다음 AI 피드백을 바탕으로 구체적인 실행 계획 3개를 생성해주세요. + 각 계획은 실행 가능하고 구체적이어야 합니다. + + 요약: %s + 개선점: %s + + 실행 계획을 다음 형식으로 작성해주세요: + 1. [구체적인 실행 계획 1] + 2. [구체적인 실행 계획 2] + 3. [구체적인 실행 계획 3] + """, + feedback.getSummary(), + String.join(", ", feedback.getImprovementPoints()) + ); + + String result = callOpenAI(prompt); + return parseActionPlans(result); + } catch (Exception e) { - log.error("실행 계획 생성 중 오류 발생", e); - return Arrays.asList("서비스 품질 개선을 위한 직원 교육 실시", "고객 피드백 수집 체계 구축"); + log.error("OpenAI 실행 계획 생성 중 오류 발생", e); + return Arrays.asList( + "서비스 품질 개선을 위한 직원 교육 실시", + "고객 피드백 수집 체계 구축", + "매장 운영 프로세스 개선" + ); } } - + + /** + * OpenAI API를 호출하여 전체 리뷰 분석 수행 + */ + private String callOpenAIForAnalysis(List reviewData) { + String reviewsText = String.join("\n- ", reviewData); + + String prompt = String.format( + """ + 다음은 한 매장의 고객 리뷰들입니다. 이를 분석하여 다음 JSON 형식으로 답변해주세요: + + { + "summary": "전체적인 분석 요약 (2-3문장)", + "positivePoints": ["긍정적 요소1", "긍정적 요소2", "긍정적 요소3"], + "improvementPoints": ["개선점1", "개선점2", "개선점3"], + "recommendations": ["추천사항1", "추천사항2", "추천사항3"], + "sentimentAnalysis": "전체적인 감정 분석 결과", + "confidenceScore": 0.85 + } + + 리뷰 목록: + - %s + + 분석 시 다음 사항을 고려해주세요: + 1. 긍정적 요소는 고객들이 자주 언급하는 좋은 점들 + 2. 개선점은 부정적 피드백이나 불만사항 + 3. 추천사항은 매장 운영에 도움이 될 구체적인 제안 + 4. 신뢰도 점수는 0.0-1.0 사이의 값 + """, + 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 { + // JSON 형태로 응답이 왔다고 가정하고 파싱 + Map result = objectMapper.readValue(analysisResult, Map.class); + + return AiFeedback.builder() + .summary((String) result.get("summary")) + .positivePoints((List) result.get("positivePoints")) + .improvementPoints((List) result.get("improvementPoints")) + .recommendations((List) result.get("recommendations")) + .sentimentAnalysis((String) result.get("sentimentAnalysis")) + .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(); + .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; + } + /** * 요약 생성 diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java index 565a000..0a660d0 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java @@ -55,6 +55,12 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort { AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity); return toAiFeedbackDomain(saved); } + + @Override + public Optional findAIFeedbackById(Long feedbackId) { + return aiFeedbackJpaRepository.findById(feedbackId) + .map(this::toAiFeedbackDomain); + } /** * Analytics Entity를 Domain으로 변환 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 1885877..d0e4181 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,14 +1,27 @@ 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.Arrays; import java.util.List; +import java.util.stream.Collectors; /** * 외부 리뷰 서비스 어댑터 클래스 @@ -30,11 +43,20 @@ public class ExternalReviewAdapter implements ExternalReviewPort { try { String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content"; - String[] reviewArray = restTemplate.getForObject(url, String[].class); - - List reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of(); + // 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) { @@ -49,13 +71,30 @@ public class ExternalReviewAdapter implements ExternalReviewPort { log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days); try { - String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days; - String[] reviewArray = restTemplate.getForObject(url, String[].class); + // String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days; + String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "?size=100"; + // ReviewListResponse 배열로 직접 받기 + ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class); + + if (reviewArray == null || reviewArray.length == 0) { + log.info("매장에 최근 리뷰가 없습니다: storeId={}", storeId); + return List.of(); + } + + // 최근 N일 이내의 리뷰만 필터링 + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(days); + + List recentReviews = Arrays.stream(reviewArray) + .filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate)) + .map(ReviewListResponse::getContent) + .filter(content -> content != null && !content.trim().isEmpty()) + .collect(Collectors.toList()); + + + + log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, recentReviews.size()); - List reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of(); - log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size()); - - return reviews; + return recentReviews; } catch (Exception e) { log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e); @@ -125,4 +164,74 @@ public class ExternalReviewAdapter implements ExternalReviewPort { "다음에도 주문할게요!" ); } + + + @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/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/AiFeedbackJpaRepository.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/AiFeedbackJpaRepository.java index b002e31..511dc86 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/AiFeedbackJpaRepository.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/AiFeedbackJpaRepository.java @@ -21,13 +21,13 @@ public interface AiFeedbackJpaRepository extends JpaRepository findByStoreId(Long storeId); - + /** * 매장 ID로 최신 AI 피드백 조회 */ - @Query("SELECT af FROM AiFeedbackEntity af WHERE af.storeId = :storeId ORDER BY af.generatedAt DESC") + @Query("SELECT af FROM AiFeedbackEntity af WHERE af.storeId = :storeId ORDER BY af.createdAt DESC LIMIT 1") Optional findLatestByStoreId(@Param("storeId") Long storeId); - + /** * 특정 기간 이후 생성된 AI 피드백 조회 */ diff --git a/analytics/src/main/resources/application.yml b/analytics/src/main/resources/application.yml index 3b2e861..dfdcce9 100644 --- a/analytics/src/main/resources/application.yml +++ b/analytics/src/main/resources/application.yml @@ -39,7 +39,7 @@ ai-api: claude: api-key: ${CLAUDE_API_KEY:} base-url: https://api.anthropic.com - model: claude-3-sonnet-20240229 + model: claude-sonnet-4-20250514 #external-api: # openai: diff --git a/common/src/main/resources/application-common.yml b/common/src/main/resources/application-common.yml index f7ecdf3..0ce9faa 100644 --- a/common/src/main/resources/application-common.yml +++ b/common/src/main/resources/application-common.yml @@ -20,7 +20,7 @@ spring: # Redis 설정 data: redis: - host: ${REDIS_HOST:localhost} + host: ${REDIS_HOST:localhost} //로컬 port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} timeout: 2000ms diff --git a/member/src/main/java/com/ktds/hi/member/domain/TagType.java b/member/src/main/java/com/ktds/hi/member/domain/TagType.java index ca3a99d..d21b36b 100644 --- a/member/src/main/java/com/ktds/hi/member/domain/TagType.java +++ b/member/src/main/java/com/ktds/hi/member/domain/TagType.java @@ -1,23 +1,48 @@ package com.ktds.hi.member.domain; +import java.util.Arrays; + /** * 태그 유형 열거형 * 취향 태그의 카테고리를 정의 */ public enum TagType { - CUISINE("음식 종류"), - FLAVOR("맛"), - DIETARY("식이 제한"), + TASTE("맛"), ATMOSPHERE("분위기"), - PRICE("가격대"); - + ALLERGY("알러지"), + SERVICE("서비스"), + PRICE_RANGE("가격대"), + CUISINE_TYPE("음식 종류"), + HEALTH_INFO("건강 정보"); + private final String description; - + TagType(String description) { this.description = description; } - + public String getDescription() { return description; } -} + + /** + * 설명으로 TagType 찾기 + */ + public static TagType fromDescription(String description) { + for (TagType type : TagType.values()) { + if (type.description.equals(description)) { + return type; + } + } + throw new IllegalArgumentException("Unknown tag type description: " + description); + } + + /** + * 모든 태그 타입 설명 목록 반환 + */ + public static String[] getAllDescriptions() { + return Arrays.stream(TagType.values()) + .map(TagType::getDescription) + .toArray(String[]::new); + } +} \ No newline at end of file diff --git a/member/src/main/java/com/ktds/hi/member/domain/TasteTag.java b/member/src/main/java/com/ktds/hi/member/domain/TasteTag.java index faacc90..5929e24 100644 --- a/member/src/main/java/com/ktds/hi/member/domain/TasteTag.java +++ b/member/src/main/java/com/ktds/hi/member/domain/TasteTag.java @@ -1,5 +1,6 @@ package com.ktds.hi.member.domain; +import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,11 +14,12 @@ import lombok.NoArgsConstructor; @Builder @NoArgsConstructor @AllArgsConstructor +@Table(name = "taste_tag") public class TasteTag { private Long id; private String tagName; - private TagType tagType; - private String description; + private TagType tagType; //카테고리 + private String description; //매운맛, 짠맛 private Boolean isActive; } diff --git a/member/src/main/java/com/ktds/hi/member/repository/entity/TagCategory.java b/member/src/main/java/com/ktds/hi/member/repository/entity/TagCategory.java new file mode 100644 index 0000000..9d0d2e0 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/entity/TagCategory.java @@ -0,0 +1,21 @@ +package com.ktds.hi.member.repository.entity; + +public enum TagCategory { + TASTE("맛"), // 매운맛, 단맛, 짠맛 등 + ATMOSPHERE("분위기"), // 깨끗한, 혼밥, 데이트 등 + ALLERGY("알러지"), // 유제품, 견과류, 갑각류 등 + SERVICE("서비스"), // 빠른서비스, 친절한, 조용한 등 + PRICE("가격대"), // 저렴한, 합리적인, 가성비 등 + FOOD_TYPE("음식 종류"), // 한식, 중식, 일식 등 + HEALTH("건강 정보"); // 저염, 저당, 글루텐프리 등 + + private final String description; + + TagCategory(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file diff --git a/member/src/main/java/com/ktds/hi/member/repository/entity/TagEntity.java b/member/src/main/java/com/ktds/hi/member/repository/entity/TagEntity.java new file mode 100644 index 0000000..b18a452 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/entity/TagEntity.java @@ -0,0 +1,37 @@ +package com.ktds.hi.member.repository.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "tags") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TagEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "tag_name", nullable = false, length = 50) + private String tagName; // 매운맛, 깨끗한, 유제품 등 + + @Enumerated(EnumType.STRING) + @Column(name = "tag_category", nullable = false) + private TagCategory tagCategory; // TASTE, ATMOSPHERE, ALLERGY 등 + + @Column(name = "tag_color", length = 7) + private String tagColor; // #FF5722 + + @Column(name = "sort_order") + private Integer sortOrder; + + @Column(name = "is_active") + @Builder.Default + private Boolean isActive = true; +} \ No newline at end of file diff --git a/member/src/main/java/com/ktds/hi/member/repository/entity/TasteTagEntity.java b/member/src/main/java/com/ktds/hi/member/repository/entity/TasteTagEntity.java index 23729e1..18d5c40 100644 --- a/member/src/main/java/com/ktds/hi/member/repository/entity/TasteTagEntity.java +++ b/member/src/main/java/com/ktds/hi/member/repository/entity/TasteTagEntity.java @@ -11,6 +11,7 @@ import lombok.NoArgsConstructor; * 취향 태그 엔티티 클래스 * 데이터베이스 taste_tags 테이블과 매핑되는 JPA 엔티티 */ +// TasteTagEntity.java @Entity @Table(name = "taste_tags") @Getter @@ -18,22 +19,32 @@ import lombok.NoArgsConstructor; @NoArgsConstructor @AllArgsConstructor public class TasteTagEntity { - + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - + @Column(name = "tag_name", unique = true, nullable = false, length = 50) - private String tagName; - - @Enumerated(EnumType.STRING) - @Column(name = "tag_type", nullable = false) - private TagType tagType; - - @Column(length = 200) - private String description; - + private String tagName; // 매운맛, 단맛, 짠맛 등 + + @Column(name = "tag_color", length = 7) + private String tagColor; // #FF5722 + + @Column(name = "sort_order") + private Integer sortOrder; + @Column(name = "is_active") @Builder.Default private Boolean isActive = true; -} + + @Enumerated(EnumType.STRING) + @Column(name = "tag_type", nullable = false) + private TagType tagType; + + @Column(length = 200) + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "tag_category", nullable = false) + private TagCategory tagCategory; // 추가된 필드 +} \ No newline at end of file diff --git a/member/src/main/java/com/ktds/hi/member/repository/jpa/TasteTagRepository.java b/member/src/main/java/com/ktds/hi/member/repository/jpa/TasteTagRepository.java index e44c6c5..3e43cef 100644 --- a/member/src/main/java/com/ktds/hi/member/repository/jpa/TasteTagRepository.java +++ b/member/src/main/java/com/ktds/hi/member/repository/jpa/TasteTagRepository.java @@ -1,31 +1,30 @@ +/* + */ package com.ktds.hi.member.repository.jpa; import com.ktds.hi.member.domain.TagType; +import com.ktds.hi.member.repository.entity.TagCategory; import com.ktds.hi.member.repository.entity.TasteTagEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; -/** - * 취향 태그 JPA 리포지토리 인터페이스 - * 취향 태그 데이터의 CRUD 작업을 담당 - */ @Repository public interface TasteTagRepository extends JpaRepository { - - /** - * 활성화된 태그 목록 조회 - */ - List findByIsActiveTrue(); - - /** - * 태그 유형별 태그 목록 조회 - */ + List findByTagTypeAndIsActiveTrue(TagType tagType); - - /** - * 태그명으로 태그 조회 - */ + + List findByIsActiveTrue(); + List findByTagNameIn(List tagNames); -} + + List findByTagCategoryAndIsActiveTrue(TagCategory tagCategory); + + List findByIsActiveTrueOrderBySortOrder(); + + Optional findByTagNameAndTagCategory(String tagName, TagCategory tagCategory); + + boolean existsByTagNameAndTagCategory(String tagName, TagCategory tagCategory); +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/biz/service/TagService.java b/store/src/main/java/com/ktds/hi/store/biz/service/TagService.java new file mode 100644 index 0000000..9fcf1a9 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/service/TagService.java @@ -0,0 +1,64 @@ +package com.ktds.hi.store.biz.service; + +import com.ktds.hi.store.biz.usecase.in.TagUseCase; +import com.ktds.hi.store.biz.usecase.out.TagRepositoryPort; +import com.ktds.hi.store.domain.Tag; +import com.ktds.hi.store.infra.dto.response.TopClickedTagResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +/** + * 태그 서비스 클래스 + * 태그 관련 비즈니스 로직을 구현 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TagService implements TagUseCase { + + private final TagRepositoryPort tagRepositoryPort; + + @Override + public List getTopClickedTags() { + log.info("가장 많이 클릭된 상위 5개 태그 조회 시작"); + + List topTags = tagRepositoryPort.findTopClickedTags(); + + AtomicInteger rank = new AtomicInteger(1); + + List responses = topTags.stream() + .map(tag -> TopClickedTagResponse.builder() + .tagId(tag.getId()) + .tagName(tag.getTagName()) + .tagCategory(tag.getTagCategory().name()) + .tagColor(tag.getTagColor()) + .clickCount(tag.getClickCount()) + .rank(rank.getAndIncrement()) + .build()) + .collect(Collectors.toList()); + + log.info("가장 많이 클릭된 상위 5개 태그 조회 완료: count={}", responses.size()); + return responses; + } + + @Override + @Transactional + public void recordTagClick(Long tagId) { + log.info("태그 클릭 이벤트 처리 시작: tagId={}", tagId); + + Tag updatedTag = tagRepositoryPort.incrementTagClickCount(tagId); + + log.info("태그 클릭 수 증가 완료: tagId={}, clickCount={}", + tagId, updatedTag.getClickCount()); + } +} diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/in/TagUseCase.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/in/TagUseCase.java new file mode 100644 index 0000000..ac15128 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/in/TagUseCase.java @@ -0,0 +1,25 @@ +package com.ktds.hi.store.biz.usecase.in; + +import com.ktds.hi.store.infra.dto.response.TopClickedTagResponse; + +import java.util.List; + +/** + * 태그 유스케이스 인터페이스 + * 태그 관련 비즈니스 로직을 정의 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +public interface TagUseCase { + + /** + * 가장 많이 클릭된 상위 5개 태그 조회 + */ + List getTopClickedTags(); + + /** + * 태그 클릭 이벤트 처리 + */ + void recordTagClick(Long tagId); +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreRepositoryPort.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreRepositoryPort.java index 318a12d..274d838 100644 --- a/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreRepositoryPort.java +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/StoreRepositoryPort.java @@ -14,6 +14,16 @@ import java.util.Optional; */ public interface StoreRepositoryPort { + /** + * 태그로 매장 검색 (OR 조건) + */ + List findStoresByTagNames(List tagNames); + + /** + * 모든 태그를 포함하는 매장 검색 (AND 조건) + */ + List findStoresByAllTagNames(List tagNames); + /** * 점주 ID로 매장 목록 조회 * diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/out/TagRepositoryPort.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/TagRepositoryPort.java new file mode 100644 index 0000000..c511d33 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/out/TagRepositoryPort.java @@ -0,0 +1,47 @@ +package com.ktds.hi.store.biz.usecase.out; + +// store/src/main/java/com/ktds/hi/store/biz/usecase/out/TagRepositoryPort.java +import com.ktds.hi.store.domain.Tag; + +import java.util.List; +import java.util.Optional; + +/** + * 태그 리포지토리 포트 인터페이스 + * 태그 데이터 영속성 기능을 정의 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +public interface TagRepositoryPort { + + /** + * 활성화된 모든 태그 조회 + */ + List findAllActiveTags(); + + /** + * 태그 ID로 태그 조회 + */ + Optional findTagById(Long tagId); + + /** + * 태그명으로 태그 조회 + */ + Optional findTagByName(String tagName); + + /** + * 가장 많이 클릭된 상위 5개 태그 조회 + */ + List findTopClickedTags(); + + /** + * 태그 클릭 수 증가 + */ + Tag incrementTagClickCount(Long tagId); + + /** + * 태그 저장 + */ + Tag saveTag(Tag tag); +} diff --git a/store/src/main/java/com/ktds/hi/store/domain/Tag.java b/store/src/main/java/com/ktds/hi/store/domain/Tag.java new file mode 100644 index 0000000..ff615b8 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/domain/Tag.java @@ -0,0 +1,46 @@ +package com.ktds.hi.store.domain; + + +import lombok.Builder; +import lombok.Getter; + +/** + * 태그 도메인 클래스 + * 매장 태그 정보를 나타냄 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Getter +@Builder +public class Tag { + private Long id; + private String tagName; + private TagCategory tagCategory; + private String tagColor; + private Integer sortOrder; + private Boolean isActive; + private Long clickCount; + + /** + * 클릭 수 증가 + */ + public Tag incrementClickCount() { + return Tag.builder() + .id(this.id) + .tagName(this.tagName) + .tagCategory(this.tagCategory) + .tagColor(this.tagColor) + .sortOrder(this.sortOrder) + .isActive(this.isActive) + .clickCount(this.clickCount != null ? this.clickCount + 1 : 1L) + .build(); + } + + /** + * 활성 상태 확인 + */ + public boolean isActive() { + return Boolean.TRUE.equals(this.isActive); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/domain/TagCategory.java b/store/src/main/java/com/ktds/hi/store/domain/TagCategory.java new file mode 100644 index 0000000..4edbfaa --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/domain/TagCategory.java @@ -0,0 +1,29 @@ +package com.ktds.hi.store.domain; + + +/** + * 태그 카테고리 열거형 클래스 + * 매장 태그의 분류를 정의 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +public enum TagCategory { + TASTE("맛"), // 매운맛, 단맛, 짠맛 등 + ATMOSPHERE("분위기"), // 깨끗한, 혼밥, 데이트 등 + ALLERGY("알러지"), // 유제품, 견과류, 갑각류 등 + SERVICE("서비스"), // 빠른서비스, 친절한, 조용한 등 + PRICE("가격대"), // 저렴한, 합리적인, 가성비 등 + FOOD_TYPE("음식 종류"), // 한식, 중식, 일식 등 + HEALTH("건강 정보"); // 저염, 저당, 글루텐프리 등 + + private final String description; + + TagCategory(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/controller/TagController.java b/store/src/main/java/com/ktds/hi/store/infra/controller/TagController.java new file mode 100644 index 0000000..ad467ba --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/controller/TagController.java @@ -0,0 +1,52 @@ +package com.ktds.hi.store.infra.controller; + +import com.ktds.hi.store.biz.usecase.in.TagUseCase; +import com.ktds.hi.store.infra.dto.response.TopClickedTagResponse; +import com.ktds.hi.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 태그 컨트롤러 클래스 + * 태그 관련 API 엔드포인트를 제공 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api/stores/tags") +@RequiredArgsConstructor +@Tag(name = "태그 관리 API", description = "매장 태그 조회 및 통계 관련 API") +public class TagController { + + private final TagUseCase tagUseCase; + + /** + * 가장 많이 클릭된 상위 5개 태그 조회 API + */ + @GetMapping("/top-clicked") + @Operation(summary = "인기 태그 조회", description = "가장 많이 클릭된 상위 5개 태그를 조회합니다.") + public ResponseEntity>> getTopClickedTags() { + + List topTags = tagUseCase.getTopClickedTags(); + + return ResponseEntity.ok(ApiResponse.success(topTags)); + } + + /** + * 태그 클릭 이벤트 기록 API + */ + @PostMapping("/{tagId}/click") + @Operation(summary = "태그 클릭 기록", description = "태그 클릭 이벤트를 기록하고 클릭 수를 증가시킵니다.") + public ResponseEntity> recordTagClick(@PathVariable Long tagId) { + + tagUseCase.recordTagClick(tagId); + + return ResponseEntity.ok(ApiResponse.success()); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/response/TopClickedTagResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/response/TopClickedTagResponse.java new file mode 100644 index 0000000..8105be6 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/response/TopClickedTagResponse.java @@ -0,0 +1,22 @@ +package com.ktds.hi.store.infra.dto.response; + +import lombok.Builder; +import lombok.Getter; + +/** + * 인기 태그 응답 DTO 클래스 + * 가장 많이 클릭된 태그 정보를 전달 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Getter +@Builder +public class TopClickedTagResponse { + private Long tagId; + private String tagName; + private String tagCategory; + private String tagColor; + private Long clickCount; + private Integer rank; +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java index 07939b0..b3cb313 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/StoreRepositoryAdapter.java @@ -38,6 +38,22 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort { .collect(Collectors.toList()); } + @Override + public List findStoresByTagNames(List tagNames) { + return storeJpaRepository.findByTagNamesIn(tagNames) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findStoresByAllTagNames(List tagNames) { + return storeJpaRepository.findByAllTagNames(tagNames, tagNames.size()) + .stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + @Override public Optional findStoreById(Long storeId) { return storeJpaRepository.findById(storeId) diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/TagRepositoryAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/TagRepositoryAdapter.java new file mode 100644 index 0000000..d81799d --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/TagRepositoryAdapter.java @@ -0,0 +1,124 @@ +package com.ktds.hi.store.infra.gateway; + +import com.ktds.hi.store.biz.usecase.out.TagRepositoryPort; +import com.ktds.hi.store.domain.Tag; +import com.ktds.hi.store.domain.TagCategory; +import com.ktds.hi.store.infra.gateway.entity.TagEntity; +import com.ktds.hi.store.infra.gateway.repository.TagJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 태그 리포지토리 어댑터 클래스 + * TagRepositoryPort를 구현하여 태그 데이터 액세스 기능을 제공 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TagRepositoryAdapter implements TagRepositoryPort { + + private final TagJpaRepository tagJpaRepository; + + @Override + public List findAllActiveTags() { + log.info("활성화된 모든 태그 조회"); + + List entities = tagJpaRepository.findByIsActiveTrueOrderByTagName(); + + return entities.stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional findTagById(Long tagId) { + log.info("태그 ID로 태그 조회: tagId={}", tagId); + + return tagJpaRepository.findById(tagId) + .filter(entity -> Boolean.TRUE.equals(entity.getIsActive())) + .map(this::toDomain); + } + + @Override + public Optional findTagByName(String tagName) { + log.info("태그명으로 태그 조회: tagName={}", tagName); + + return tagJpaRepository.findByTagNameAndIsActiveTrue(tagName) + .map(this::toDomain); + } + + @Override + public List findTopClickedTags() { + log.info("가장 많이 클릭된 상위 5개 태그 조회"); + + List entities = tagJpaRepository.findTop5ByOrderByClickCountDesc( + PageRequest.of(0, 5) + ); + + return entities.stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Tag incrementTagClickCount(Long tagId) { + log.info("태그 클릭 수 증가: tagId={}", tagId); + + TagEntity entity = tagJpaRepository.findById(tagId) + .orElseThrow(() -> new IllegalArgumentException("태그를 찾을 수 없습니다: " + tagId)); + + entity.incrementClickCount(); + TagEntity saved = tagJpaRepository.save(entity); + + return toDomain(saved); + } + + @Override + public Tag saveTag(Tag tag) { + log.info("태그 저장: tagName={}", tag.getTagName()); + + TagEntity entity = toEntity(tag); + TagEntity saved = tagJpaRepository.save(entity); + + return toDomain(saved); + } + + /** + * 엔티티를 도메인으로 변환 + */ + private Tag toDomain(TagEntity entity) { + return Tag.builder() + .id(entity.getId()) + .tagName(entity.getTagName()) + .tagCategory(entity.getTagCategory()) + .tagColor(entity.getTagColor()) + .sortOrder(entity.getSortOrder()) + .isActive(entity.getIsActive()) + .clickCount(entity.getClickCount()) + .build(); + } + + /** + * 도메인을 엔티티로 변환 + */ + private TagEntity toEntity(Tag domain) { + return TagEntity.builder() + .id(domain.getId()) + .tagName(domain.getTagName()) + .tagCategory(domain.getTagCategory()) + .tagColor(domain.getTagColor()) + .sortOrder(domain.getSortOrder()) + .isActive(domain.getIsActive()) + .clickCount(domain.getClickCount()) + .build(); + } +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/TagEntity.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/TagEntity.java new file mode 100644 index 0000000..3e48973 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/TagEntity.java @@ -0,0 +1,52 @@ +package com.ktds.hi.store.infra.gateway.entity; + +import jakarta.persistence.*; +import lombok.*; +import com.ktds.hi.store.domain.TagCategory; +/** + * 태그 엔티티 클래스 + * 매장 태그 정보를 저장 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Entity +@Table(name = "tags") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TagEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "tag_name", nullable = false, length = 50) + private String tagName; // 매운맛, 깨끗한, 유제품 등 + + @Enumerated(EnumType.STRING) + @Column(name = "tag_category", nullable = false) + private TagCategory tagCategory; // TASTE, ATMOSPHERE, ALLERGY 등 + + @Column(name = "tag_color", length = 7) + private String tagColor; // #FF5722 + + @Column(name = "sort_order") + private Integer sortOrder; + + @Column(name = "is_active") + @Builder.Default + private Boolean isActive = true; + + @Column(name = "click_count") + @Builder.Default + private Long clickCount = 0L; + + /** + * 클릭 수 증가 + */ + public void incrementClickCount() { + this.clickCount = this.clickCount != null ? this.clickCount + 1 : 1L; + } +} \ 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 54834d9..8f14021 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 @@ -21,6 +21,9 @@ import java.util.Optional; @Repository public interface StoreJpaRepository extends JpaRepository { + @Query("SELECT s FROM StoreEntity s WHERE s.status = 'ACTIVE' ORDER BY s.rating DESC") + Page findAllByOrderByRatingDesc(Pageable pageable); + /** * 점주 ID로 매장 목록 조회 */ @@ -49,9 +52,18 @@ public interface StoreJpaRepository extends JpaRepository { /** * 평점 기준 내림차순으로 매장 조회 */ - @Query("SELECT s FROM StoreEntity s ORDER BY s.rating DESC") - Page findAllByOrderByRatingDesc(Pageable pageable); + @Query(value = "SELECT DISTINCT s.* FROM stores s " + + "WHERE EXISTS (SELECT 1 FROM store_tags st " + + "WHERE st.store_id = s.id AND st.tag_name IN :tagNames) " + + "AND s.status = 'ACTIVE'", nativeQuery = true) + List findByTagNamesIn(@Param("tagNames") List tagNames); + @Query(value = "SELECT s.* FROM stores s " + + "WHERE (SELECT COUNT(DISTINCT st.tag_name) FROM store_tags st " + + "WHERE st.store_id = s.id AND st.tag_name IN :tagNames) = :tagCount " + + "AND s.status = 'ACTIVE'", nativeQuery = true) + List findByAllTagNames(@Param("tagNames") List tagNames, + @Param("tagCount") Integer tagCount); /** * 점주별 매장 수 조회 */ diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/TagJpaRepository.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/TagJpaRepository.java new file mode 100644 index 0000000..3bed601 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/TagJpaRepository.java @@ -0,0 +1,49 @@ +package com.ktds.hi.store.infra.gateway.repository; + +import com.ktds.hi.store.domain.TagCategory; +import com.ktds.hi.store.infra.gateway.entity.TagEntity; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 태그 JPA 리포지토리 인터페이스 + * 태그 데이터의 CRUD 작업을 담당 + * + * @author 하이오더 개발팀 + * @version 1.0.0 + */ +@Repository +public interface TagJpaRepository extends JpaRepository { + + /** + * 활성화된 태그 목록 조회 + */ + List findByIsActiveTrueOrderByTagName(); + + /** + * 태그명으로 조회 + */ + Optional findByTagNameAndIsActiveTrue(String tagName); + + /** + * 카테고리별 태그 조회 + */ + List findByTagCategoryAndIsActiveTrueOrderByTagName(TagCategory category); + + /** + * 클릭 수 기준 상위 태그 조회 + */ + @Query("SELECT t FROM TagEntity t WHERE t.isActive = true ORDER BY t.clickCount DESC") + List findTopClickedTags(PageRequest pageRequest); + + /** + * 클릭 수 기준 상위 5개 태그 조회 + */ + @Query("SELECT t FROM TagEntity t WHERE t.isActive = true ORDER BY t.clickCount DESC") + List findTop5ByOrderByClickCountDesc(PageRequest pageRequest); +}