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 e1343e2..c295630 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 @@ -4,6 +4,7 @@ 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.domain.SentimentType; import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase; import com.ktds.hi.analytics.biz.usecase.out.*; import com.ktds.hi.analytics.infra.dto.*; @@ -13,10 +14,14 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; /** * 분석 서비스 구현 클래스 (수정버전) @@ -35,6 +40,7 @@ public class AnalyticsService implements AnalyticsUseCase { private final CachePort cachePort; private final EventPort eventPort; private final ActionPlanPort actionPlanPort; // 추가된 의존성 + @Override // @Cacheable(value = "storeAnalytics", key = "#storeId") @@ -270,8 +276,10 @@ public class AnalyticsService implements AnalyticsUseCase { .totalReviews(0) .positiveReviewCount(0) .negativeReviewCount(0) + .neutralReviewCount(0) .positiveRate(0.0) .negativeRate(0.0) + .neutralRate(0.0) .analysisDate(LocalDate.now()) .build(); @@ -280,8 +288,10 @@ public class AnalyticsService implements AnalyticsUseCase { } // 3. 응답 생성 - int positiveCount = countPositiveReviews(recentReviews); - int negativeCount = countNegativeReviews(recentReviews); + ReviewSentimentCount sentimentCount = analyzeReviewSentiments(recentReviews); + int positiveCount = sentimentCount.getPositiveCount(); + int negativeCount = sentimentCount.getNegativeCount(); + int neutralCount = sentimentCount.getNeutralCount(); int totalCount = recentReviews.size(); ReviewAnalysisResponse response = ReviewAnalysisResponse.builder() @@ -289,8 +299,10 @@ public class AnalyticsService implements AnalyticsUseCase { .totalReviews(totalCount) .positiveReviewCount(positiveCount) .negativeReviewCount(negativeCount) - .positiveRate(Math.floor((double) positiveCount / totalCount * 100) / 10.0) - .negativeRate(Math.floor((double) negativeCount / totalCount * 100) / 10.0) + .neutralReviewCount(neutralCount) + .positiveRate(Math.floor((double) positiveCount / totalCount * 1000) / 10.0) + .negativeRate(Math.floor((double) negativeCount / totalCount * 1000) / 10.0) + .neutralRate(Math.floor((double) neutralCount / totalCount * 1000) / 10.0) .analysisDate(LocalDate.now()) .build(); @@ -305,7 +317,75 @@ public class AnalyticsService implements AnalyticsUseCase { throw new RuntimeException("리뷰 분석에 실패했습니다.", e); } } - + + /** + * 기존 analyzeReviewSentiments 메서드를 대량 분석 방식으로 개선 + * 개별 AI 호출 대신 한 번의 호출로 모든 리뷰 분석 + */ + private ReviewSentimentCount analyzeReviewSentiments(List reviews) { + log.info("LLM 기반 리뷰 감정 분석 시작: 총 리뷰 수={}", reviews.size()); + + try { + if (reviews.isEmpty()) { + return new ReviewSentimentCount(0, 0, 0); + } + + // 유효한 리뷰만 필터링 + List validReviews = reviews.stream() + .filter(review -> review != null && !review.trim().isEmpty()) + .collect(Collectors.toList()); + + if (validReviews.isEmpty()) { + return new ReviewSentimentCount(0, 0, 0); + } + + // 기존 개별 분석 대신 대량 분석 사용 + Map sentimentCounts = aiServicePort.analyzeBulkSentiments(validReviews); + + int positiveCount = sentimentCounts.get(SentimentType.POSITIVE); + int negativeCount = sentimentCounts.get(SentimentType.NEGATIVE); + int neutralCount = sentimentCounts.get(SentimentType.NEUTRAL); + + ReviewSentimentCount result = new ReviewSentimentCount(positiveCount, negativeCount, neutralCount); + + log.info("리뷰 감정 분석 완료: 긍정={}, 부정={}, 중립={}, 전체={}", + positiveCount, negativeCount, neutralCount, validReviews.size()); + + return result; + + } catch (Exception e) { + log.error("리뷰 감정 분석 중 전체 오류 발생, fallback 사용", e); + // 오류 시 기존 가정값 사용 + int total = reviews.size(); + return new ReviewSentimentCount( + (int) (total * 0.6), // 60% 긍정 + (int) (total * 0.2), // 20% 부정 + total - (int) (total * 0.6) - (int) (total * 0.2) // 나머지 중립 + ); + } + } + + /** + * 리뷰 감정 분석 결과를 담는 내부 클래스 + */ + public static class ReviewSentimentCount { + private final int positiveCount; + private final int negativeCount; + private final int neutralCount; + + public ReviewSentimentCount(int positiveCount, int negativeCount, int neutralCount) { + this.positiveCount = positiveCount; + this.negativeCount = negativeCount; + this.neutralCount = neutralCount; + } + + public int getPositiveCount() { return positiveCount; } + public int getNegativeCount() { return negativeCount; } + public int getNeutralCount() { return neutralCount; } + public int getTotalCount() { return positiveCount + negativeCount + neutralCount; } + } + + // private 메서드들 @Transactional public Analytics generateNewAnalytics(Long storeId) { @@ -429,15 +509,6 @@ public class AnalyticsService implements AnalyticsUseCase { return "추천사항이 없습니다."; } - private int countPositiveReviews(List reviews) { - // 실제로는 AI 서비스를 통한 감정 분석 필요 - return (int) (reviews.size() * 0.6); // 60% 가정 - } - - private int countNegativeReviews(List reviews) { - // 실제로는 AI 서비스를 통한 감정 분석 필요 - return (int) (reviews.size() * 0.2); // 20% 가정 - } @Override @Transactional 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 30d4264..e003514 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 @@ -4,6 +4,7 @@ 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 서비스 포트 인터페이스 @@ -20,6 +21,15 @@ public interface AIServicePort { * 감정 분석 */ SentimentType analyzeSentiment(String content); + + /** + * 대량 리뷰 감정 분석 (새로 추가) + * 여러 리뷰를 한 번에 분석하여 긍정/부정/중립 개수 반환 + * + * @param reviews 분석할 리뷰 목록 + * @return 감정 타입별 개수 맵 + */ + Map analyzeBulkSentiments(List reviews); /** * 실행 계획 생성 diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ReviewAnalysisResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ReviewAnalysisResponse.java index c815614..dfc5197 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ReviewAnalysisResponse.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ReviewAnalysisResponse.java @@ -20,7 +20,9 @@ public class ReviewAnalysisResponse { private Integer totalReviews; private Integer positiveReviewCount; private Integer negativeReviewCount; + private Integer neutralReviewCount; private Double positiveRate; private Double negativeRate; + private Double neutralRate; private LocalDate analysisDate; } 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 5c167b0..0de96fa 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 @@ -30,8 +30,10 @@ 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 서비스 어댑터 클래스 @@ -99,6 +101,116 @@ public class AIServiceAdapter implements AIServicePort { } } + @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) { diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/EventHubAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/EventHubAdapter.java index e3eceb4..b856f12 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/EventHubAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/EventHubAdapter.java @@ -43,7 +43,7 @@ public class EventHubAdapter { private final ExecutorService executorService = Executors.newFixedThreadPool(3); private volatile boolean isRunning = false; - @PostConstruct + // @PostConstruct public void startEventListening() { log.info("Event Hub 리스너 시작"); isRunning = true; @@ -52,7 +52,7 @@ public class EventHubAdapter { executorService.submit(this::listenToReviewEvents); } - @PreDestroy + // @PreDestroy public void stopEventListening() { log.info("Event Hub 리스너 종료"); isRunning = false; diff --git a/dump.rdb b/dump.rdb index 1d341a2..607a2d5 100644 Binary files a/dump.rdb and b/dump.rdb differ diff --git a/logs/recommend-service.log.2025-06-17.0.gz b/logs/recommend-service.log.2025-06-17.0.gz new file mode 100644 index 0000000..cc1fa56 Binary files /dev/null and b/logs/recommend-service.log.2025-06-17.0.gz differ diff --git a/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java b/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java index c8f0e7c..5e00998 100644 --- a/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java +++ b/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java @@ -3,6 +3,7 @@ package com.ktds.hi.store.biz.service; import com.ktds.hi.store.biz.usecase.in.StoreUseCase; import com.ktds.hi.store.infra.dto.*; +import com.ktds.hi.store.infra.dto.response.StoreListResponse; import com.ktds.hi.store.infra.gateway.entity.StoreEntity; import com.ktds.hi.store.infra.gateway.entity.TagEntity; import com.ktds.hi.store.infra.gateway.repository.StoreJpaRepository; @@ -93,6 +94,27 @@ public class StoreService implements StoreUseCase { .collect(Collectors.toList()); } + + @Override + public List getAllStores() { + + List stores = storeJpaRepository.findAll(); + + return stores.stream() + .map(store -> StoreListResponse.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .address(store.getAddress()) + .category(store.getCategory()) + .rating(store.getRating()) + .reviewCount(store.getReviewCount()) + .status("운영중") + .imageUrl(store.getImageUrl()) + .operatingHours(store.getOperatingHours()) + .build()) + .collect(Collectors.toList()); + } + @Override public StoreDetailResponse getStoreDetail(Long storeId) { @@ -157,6 +179,10 @@ public class StoreService implements StoreUseCase { .build(); } + + + + @Override public List searchStores(String keyword, String category, String tags, Double latitude, Double longitude, Integer radius, 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 c402215..3165a10 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 @@ -1,6 +1,7 @@ package com.ktds.hi.store.biz.usecase.in; import com.ktds.hi.store.infra.dto.*; +import com.ktds.hi.store.infra.dto.response.StoreListResponse; import java.util.List; @@ -30,6 +31,8 @@ public interface StoreUseCase { */ List getMyStores(Long ownerId); + List getAllStores(); + /** * 매장 상세 조회 * @@ -73,4 +76,6 @@ public interface StoreUseCase { List searchStores(String keyword, String category, String tags, Double latitude, Double longitude, Integer radius, Integer page, Integer size); + + } 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 35bffa7..78081c7 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 @@ -1,11 +1,14 @@ // store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java package com.ktds.hi.store.infra.controller; +import com.ktds.hi.store.biz.service.StoreService; import com.ktds.hi.store.biz.usecase.in.MenuUseCase; import com.ktds.hi.store.biz.usecase.in.StoreUseCase; +import com.ktds.hi.store.domain.Store; import com.ktds.hi.store.infra.dto.*; import com.ktds.hi.common.dto.ApiResponse; import com.ktds.hi.common.security.JwtTokenProvider; +import com.ktds.hi.store.infra.dto.response.StoreListResponse; import com.ktds.hi.store.infra.dto.response.StoreMenuListResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -38,6 +41,7 @@ import java.util.List; public class StoreController { private final StoreUseCase storeUseCase; + private final StoreService storeService; private final JwtTokenProvider jwtTokenProvider; private final MenuUseCase menuUseCase; @@ -68,6 +72,14 @@ public class StoreController { return ResponseEntity.ok(ApiResponse.success(responses, "내 매장 목록 조회 완료")); } + @GetMapping("/stores/all") + @Operation(summary = "매장 전체 리스트") + public ResponseEntity>> getAllStores() { + + List responses = storeUseCase.getAllStores(); + return ResponseEntity.ok(ApiResponse.success(responses)); + } + @Operation(summary = "매장 상세 조회", description = "매장의 상세 정보를 조회합니다.") @GetMapping("/{storeId}") public ResponseEntity> getStoreDetail( @@ -133,6 +145,8 @@ public class StoreController { + + @GetMapping("/{storeId}/menus/popular") @Operation(summary = "매장 인기 메뉴 조회", description = "매장의 인기 메뉴(주문 횟수 기준)를 조회합니다.") public ResponseEntity>> getPopularMenus( 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 new file mode 100644 index 0000000..35cfa0f --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreListResponse.java @@ -0,0 +1,42 @@ +package com.ktds.hi.store.infra.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "내 매장 목록 응답") +public class StoreListResponse { + + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "매장명", example = "맛집 한번 가볼래?") + private String storeName; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + private String address; + + @Schema(description = "카테고리", example = "한식") + private String category; + + @Schema(description = "평점", example = "4.5") + private Double rating; + + @Schema(description = "리뷰 수", example = "127") + private Integer reviewCount; + + @Schema(description = "운영 상태", example = "운영중") + private String status; + + @Schema(description = "운영시간", example = "월-금 09:00-21:00") + private String operatingHours; + @Schema(description = "매장 이미지") + private String imageUrl; +} 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 8f14021..387cfe9 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 @@ -24,6 +24,7 @@ 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로 매장 목록 조회 */