From 6ff21dd8e0171bf9221407ace02ab3e0ff9888f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A0=EB=B9=88?= Date: Wed, 18 Jun 2025 13:11:08 +0900 Subject: [PATCH] Merge branch 'main' of https://github.com/dg04-hi/hi-backend into mn # Conflicts: # build.gradle --- .../biz/usecase/in/AnalyticsUseCase.java | 98 +-- .../biz/service/ReviewCommentInteractor.java | 172 ++--- .../ExternalReviewEventHubAdapter.java | 688 +++++++++--------- .../repository/ReviewJpaRepository.java | 92 +-- 4 files changed, 525 insertions(+), 525 deletions(-) diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java index 13319ae..450c156 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java @@ -1,49 +1,49 @@ -package com.ktds.hi.analytics.biz.usecase.in; - -import com.ktds.hi.analytics.infra.dto.*; - -import java.time.LocalDate; -import java.util.List; - -/** - * 분석 서비스 UseCase 인터페이스 - * Clean Architecture의 입력 포트 정의 - */ -public interface AnalyticsUseCase { - - /** - * 매장 분석 데이터 조회 - */ - StoreAnalyticsResponse getStoreAnalytics(Long storeId); - - /** - * AI 피드백 상세 조회 - */ - AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId); - - /** - * 매장 통계 조회 - */ - StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate); - - /** - * AI 피드백 요약 조회 - */ - AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId); - - /** - * 리뷰 분석 조회 - */ - ReviewAnalysisResponse getReviewAnalysis(Long storeId, int days); - - /** - * AI 리뷰 분석 및 실행계획 생성 - */ - AiAnalysisResponse generateAIAnalysis(Long storeId, AiAnalysisRequest request); - - /** - * AI 피드백 기반 실행계획 생성 - */ - List generateActionPlansFromFeedback(ActionPlanCreateRequest request,Long feedbackId); - -} +package com.ktds.hi.analytics.biz.usecase.in; + +import com.ktds.hi.analytics.infra.dto.*; + +import java.time.LocalDate; +import java.util.List; + +/** + * 분석 서비스 UseCase 인터페이스 + * Clean Architecture의 입력 포트 정의 + */ +public interface AnalyticsUseCase { + + /** + * 매장 분석 데이터 조회 + */ + StoreAnalyticsResponse getStoreAnalytics(Long storeId); + + /** + * AI 피드백 상세 조회 + */ + AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId); + + /** + * 매장 통계 조회 + */ + StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate); + + /** + * AI 피드백 요약 조회 + */ + AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId); + + /** + * 리뷰 분석 조회 + */ + ReviewAnalysisResponse getReviewAnalysis(Long storeId, int days); + + /** + * AI 리뷰 분석 및 실행계획 생성 + */ + AiAnalysisResponse generateAIAnalysis(Long storeId, AiAnalysisRequest request); + + /** + * AI 피드백 기반 실행계획 생성 + */ + List generateActionPlansFromFeedback(ActionPlanCreateRequest request,Long feedbackId); + +} diff --git a/review/src/main/java/com/ktds/hi/review/biz/service/ReviewCommentInteractor.java b/review/src/main/java/com/ktds/hi/review/biz/service/ReviewCommentInteractor.java index 90f8202..b45310e 100644 --- a/review/src/main/java/com/ktds/hi/review/biz/service/ReviewCommentInteractor.java +++ b/review/src/main/java/com/ktds/hi/review/biz/service/ReviewCommentInteractor.java @@ -1,86 +1,86 @@ -package com.ktds.hi.review.biz.service; - -import com.ktds.hi.review.biz.usecase.in.ManageReviewCommentUseCase; -import com.ktds.hi.review.biz.usecase.out.ReviewCommentRepository; -import com.ktds.hi.review.biz.usecase.out.ReviewRepository; -import com.ktds.hi.review.biz.domain.ReviewComment; -import com.ktds.hi.review.biz.domain.Review; -import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest; -import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse; -import com.ktds.hi.common.exception.BusinessException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Collectors; - -/** - * 리뷰 댓글 인터랙터 클래스 - * 리뷰 댓글 작성, 조회, 삭제 기능을 구현 - */ -@Service -@RequiredArgsConstructor -@Slf4j -@Transactional -public class ReviewCommentInteractor implements ManageReviewCommentUseCase { - - private final ReviewCommentRepository commentRepository; - private final ReviewRepository reviewRepository; - - @Override - @Transactional(readOnly = true) - public List getReviewComments(Long reviewId) { - List comments = commentRepository.findCommentsByReviewId(reviewId); - - return comments.stream() - .map(comment -> ReviewCommentResponse.builder() - .commentId(comment.getId()) - .ownerNickname(comment.getOwnerNickname()) - .content(comment.getContent()) - .createdAt(comment.getCreatedAt()) - .build()) - .collect(Collectors.toList()); - } - - @Override - public ReviewCommentResponse createComment(Long reviewId, Long ownerId, ReviewCommentRequest request) { - // 리뷰 존재 확인 - Review review = reviewRepository.findReviewById(reviewId) - .orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다")); - - // 댓글 생성 - ReviewComment comment = ReviewComment.builder() - .reviewId(reviewId) - .ownerId(ownerId) - .ownerNickname("점주" + ownerId) // TODO: 점주 서비스에서 닉네임 조회 - .content(request.getContent()) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .build(); - - ReviewComment savedComment = commentRepository.saveComment(comment); - - log.info("리뷰 댓글 생성 완료: commentId={}, reviewId={}, ownerId={}", - savedComment.getId(), reviewId, ownerId); - - return ReviewCommentResponse.builder() - .commentId(savedComment.getId()) - .ownerNickname(savedComment.getOwnerNickname()) - .content(savedComment.getContent()) - .createdAt(savedComment.getCreatedAt()) - .build(); - } - - @Override - public void deleteComment(Long commentId, Long ownerId) { - ReviewComment comment = commentRepository.findCommentByIdAndOwnerId(commentId, ownerId) - .orElseThrow(() -> new BusinessException("댓글을 찾을 수 없거나 권한이 없습니다")); - - commentRepository.deleteComment(commentId); - - log.info("리뷰 댓글 삭제 완료: commentId={}, ownerId={}", commentId, ownerId); - } -} +package com.ktds.hi.review.biz.service; + +import com.ktds.hi.review.biz.usecase.in.ManageReviewCommentUseCase; +import com.ktds.hi.review.biz.usecase.out.ReviewCommentRepository; +import com.ktds.hi.review.biz.usecase.out.ReviewRepository; +import com.ktds.hi.review.biz.domain.ReviewComment; +import com.ktds.hi.review.biz.domain.Review; +import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest; +import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 리뷰 댓글 인터랙터 클래스 + * 리뷰 댓글 작성, 조회, 삭제 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class ReviewCommentInteractor implements ManageReviewCommentUseCase { + + private final ReviewCommentRepository commentRepository; + private final ReviewRepository reviewRepository; + + @Override + @Transactional(readOnly = true) + public List getReviewComments(Long reviewId) { + List comments = commentRepository.findCommentsByReviewId(reviewId); + + return comments.stream() + .map(comment -> ReviewCommentResponse.builder() + .commentId(comment.getId()) + .ownerNickname(comment.getOwnerNickname()) + .content(comment.getContent()) + .createdAt(comment.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } + + @Override + public ReviewCommentResponse createComment(Long reviewId, Long ownerId, ReviewCommentRequest request) { + // 리뷰 존재 확인 + Review review = reviewRepository.findReviewById(reviewId) + .orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다")); + + // 댓글 생성 + ReviewComment comment = ReviewComment.builder() + .reviewId(reviewId) + .ownerId(ownerId) + .ownerNickname("점주" + ownerId) // TODO: 점주 서비스에서 닉네임 조회 + .content(request.getContent()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + ReviewComment savedComment = commentRepository.saveComment(comment); + + log.info("리뷰 댓글 생성 완료: commentId={}, reviewId={}, ownerId={}", + savedComment.getId(), reviewId, ownerId); + + return ReviewCommentResponse.builder() + .commentId(savedComment.getId()) + .ownerNickname(savedComment.getOwnerNickname()) + .content(savedComment.getContent()) + .createdAt(savedComment.getCreatedAt()) + .build(); + } + + @Override + public void deleteComment(Long commentId, Long ownerId) { + ReviewComment comment = commentRepository.findCommentByIdAndOwnerId(commentId, ownerId) + .orElseThrow(() -> new BusinessException("댓글을 찾을 수 없거나 권한이 없습니다")); + + commentRepository.deleteComment(commentId); + + log.info("리뷰 댓글 삭제 완료: commentId={}, ownerId={}", commentId, ownerId); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java index 37ab4e2..263411d 100644 --- a/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java @@ -1,345 +1,345 @@ -package com.ktds.hi.review.infra.gateway; - -import com.azure.messaging.eventhubs.EventData; -import com.azure.messaging.eventhubs.EventHubConsumerClient; -import com.azure.messaging.eventhubs.models.EventPosition; -import com.azure.messaging.eventhubs.models.PartitionEvent; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ktds.hi.review.biz.domain.Review; -import com.ktds.hi.review.biz.domain.ReviewStatus; -import com.ktds.hi.review.biz.usecase.out.ReviewRepository; -import com.ktds.hi.review.infra.gateway.repository.ReviewJpaRepository; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.HashSet; -import java.util.Set; -/** - * Azure Event Hub 어댑터 클래스 - 수정된 버전 - * 외부 리뷰 이벤트 수신 및 Review 테이블 저장 (중복 방지) - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class ExternalReviewEventHubAdapter { - - @Qualifier("externalReviewEventConsumer") - private final EventHubConsumerClient externalReviewEventConsumer; - private final ReviewJpaRepository reviewJpaRepository; - private final Set processedEventIds = new HashSet<>(); - private final ObjectMapper objectMapper; - private final ReviewRepository reviewRepository; - - private final ExecutorService executorService = Executors.newFixedThreadPool(3); - private volatile boolean isRunning = false; - - @PostConstruct - public void startEventListening() { - log.info("외부 리뷰 Event Hub 리스너 시작"); - isRunning = true; - - // 외부 리뷰 이벤트 수신 시작 - executorService.submit(this::listenToExternalReviewEvents); - } - - @PreDestroy - public void stopEventListening() { - log.info("외부 리뷰 Event Hub 리스너 종료"); - isRunning = false; - executorService.shutdown(); - externalReviewEventConsumer.close(); - } - - /** - * 외부 리뷰 이벤트 수신 처리 - 모든 파티션 처리 - */ - private void listenToExternalReviewEvents() { - log.info("외부 리뷰 이벤트 수신 시작"); - - try { - // ✅ 1. 파티션 정보 조회 (하드코딩 제거) - String[] partitionIds = {"0", "1", "2", "3"}; - log.info("처리할 파티션: {}", String.join(", ", partitionIds)); - - while (isRunning) { - // ✅ 2. 모든 파티션 순회 처리 - for (String partitionId : partitionIds) { - try { - Iterable events = externalReviewEventConsumer.receiveFromPartition( - partitionId, // ✅ 동적 파티션 ID - 50, // 배치 크기 줄임 (성능 최적화) - EventPosition.latest(), // ✅ 최신 메시지만 (중복 방지) - Duration.ofSeconds(10) // 타임아웃 줄임 - ); - - for (PartitionEvent partitionEvent : events) { - handleExternalReviewEvent(partitionEvent); - } - - } catch (Exception e) { - log.error("파티션 {} 처리 중 오류: {}", partitionId, e.getMessage()); - } - } - - Thread.sleep(1000); - } - } catch (InterruptedException e) { - log.info("외부 리뷰 이벤트 수신 중단됨"); - Thread.currentThread().interrupt(); - } catch (Exception e) { - log.error("외부 리뷰 이벤트 수신 중 오류 발생", e); - } - } - - /** - * 외부 리뷰 이벤트 처리 - */ - private void handleExternalReviewEvent(PartitionEvent partitionEvent) { - try { - EventData eventData = partitionEvent.getData(); - String eventBody = eventData.getBodyAsString(); - - Map event = objectMapper.readValue(eventBody, Map.class); - String eventType = (String) event.get("eventType"); - Long storeId = Long.valueOf(event.get("storeId").toString()); - - log.info("외부 리뷰 이벤트 수신: type={}, storeId={}", eventType, storeId); - - if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) { - handleExternalReviewSyncEvent(storeId, event); - } else { - log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); - } - - } catch (Exception e) { - log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); - } - } - - /** - * 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리 - */ - private void handleExternalReviewSyncEvent(Long storeId, Map event) { - try { - String platform = (String) event.get("platform"); - Integer syncedCount = (Integer) event.get("syncedCount"); - - - // Store에서 발행하는 reviews 배열 처리 - @SuppressWarnings("unchecked") - List> reviews = (List>) event.get("reviews"); - - if (reviews != null) { - for (int i = 0; i < reviews.size(); i++) { - Map review = reviews.get(i); - log.info("Review[{}]: {}", i, review); - } - } else { - log.info("No reviews found in event."); - } - - - if (reviews == null || reviews.isEmpty()) { - log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId); - return; - } - - log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}", - platform, storeId, reviews.size()); - - int savedCount = 0; - int duplicateCount = 0; - - for (Map reviewData : reviews) { - try { - Review savedReview = saveExternalReview(storeId, platform, reviewData); - if (savedReview != null) { - savedCount++; - } - } catch (Exception e) { - log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}", - platform, storeId, e.getMessage()); - } - } - - log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}", - platform, storeId, reviews.size(), savedCount); - - } catch (Exception e) { - log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e); - } - } - - /** - * 개별 외부 리뷰 저장 - 중복 체크 포함 - */ - private Review saveExternalReview(Long storeId, String platform, Map reviewData) { - try { - // ✅ 1. 중복 체크용 고유 식별자 생성 - String externalNickname = createMemberNickname(platform, reviewData); - String content = extractContent(reviewData); - - // ✅ 2. 중복 체크 (storeId + 닉네임 + 내용으로 중복 판단) - boolean isDuplicate = reviewJpaRepository.existsByStoreIdAndMemberNicknameAndContent( - storeId, externalNickname, content); - - if (isDuplicate) { - log.debug("중복 리뷰 스킵: storeId={}, nickname={}", storeId, externalNickname); - return null; - } - - // ✅ 3. 새로운 리뷰 저장 - Review review = Review.builder() - .storeId(storeId) - .memberId(-1L) - .memberNickname(createMemberNickname(platform, reviewData)) - .rating(extractRating(reviewData)) - .content(extractContent(reviewData)) - .imageUrls(new ArrayList<>()) // 외부 리뷰는 이미지 없음 - .status(ReviewStatus.ACTIVE) - .likeCount(0) - .dislikeCount(0) - .build(); - - // Review 테이블에 저장 - Review savedReview = reviewRepository.saveReview(review); - - log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}", - savedReview.getId(), platform, storeId, savedReview.getMemberNickname()); - - return savedReview; - - } catch (Exception e) { - log.error("외부 리뷰 저장 실패: platform={}, storeId={}, error={}", - platform, storeId, e.getMessage(), e); - return null; - } - } - - /** - * 플랫폼별 회원 닉네임 생성 - */ - private String createMemberNickname(String platform, Map reviewData) { - String authorName = null; - - // 카카오 API 구조에 맞춰 수정 - if ("KAKAO".equalsIgnoreCase(platform)) { - authorName = (String) reviewData.get("reviewer_name"); - } else { - // 다른 플랫폼 대비 - authorName = (String) reviewData.get("author_name"); - if (authorName == null) { - authorName = (String) reviewData.get("authorName"); - } - } - - if (authorName == null || authorName.trim().isEmpty()) { - return platform.toUpperCase() + " 사용자"; - } - - return authorName + "(" + platform.toUpperCase() + ")"; - } - - /** - * 평점 추출 (기본값: 5) - */ - private Integer extractRating(Map reviewData) { - Object rating = reviewData.get("rating"); - if (rating instanceof Number) { - int ratingValue = ((Number) rating).intValue(); - return (ratingValue >= 1 && ratingValue <= 5) ? ratingValue : 5; - } - return 5; - } - - /** - * 리뷰 내용 추출 - */ - private String extractContent(Map reviewData) { - String content = (String) reviewData.get("content"); - if (content == null || content.trim().isEmpty()) { - return "외부 플랫폼 리뷰"; - } - - // 내용이 너무 길면 자르기 - if (content.length() > 1900) { - content = content.substring(0, 1900) + "..."; - } - - return content; - } - - //추가 코드 - /** - * 외부 리뷰 이벤트 처리 (중복 방지) - */ - private void handleExternalReviewEventSafely(PartitionEvent partitionEvent) { - try { - EventData eventData = partitionEvent.getData(); - String eventBody = eventData.getBodyAsString(); - - // 🔥 이벤트 고유 ID 생성 (오프셋 + 시퀀스 넘버 기반) - String eventId = String.format("%s_%s", - eventData.getOffset(), - eventData.getSequenceNumber()); - - // 이미 처리된 이벤트인지 확인 - if (processedEventIds.contains(eventId)) { - log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); - return; - } - - Map event = objectMapper.readValue(eventBody, Map.class); - String eventType = (String) event.get("eventType"); - Long storeId = Long.valueOf(event.get("storeId").toString()); - - log.info("외부 리뷰 이벤트 수신: type={}, storeId={}, eventId={}", eventType, storeId, eventId); - - if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) { - // 기존 메서드 호출하여 리뷰 저장 - handleExternalReviewSyncEvent(storeId, event); - - // 🔥 처리 완료된 이벤트 ID 저장 - markEventAsProcessed(eventId); - log.info("이벤트 처리 완료: eventId={}, storeId={}", eventId, storeId); - - } else { - log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); - } - - } catch (Exception e) { - log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); - } - } - - /** - * 이벤트 처리 완료 표시 (메모리 관리 포함) - */ - private void markEventAsProcessed(String eventId) { - processedEventIds.add(eventId); - - // 🔥 메모리 관리: 1000개 이상 쌓이면 오래된 것들 삭제 - if (processedEventIds.size() > 1000) { - // 앞의 500개만 삭제하고 최근 500개는 유지 - Set recentIds = new HashSet<>(); - processedEventIds.stream() - .skip(500) - .forEach(recentIds::add); - - processedEventIds.clear(); - processedEventIds.addAll(recentIds); - - log.info("처리된 이벤트 ID 캐시 정리 완료: 현재 크기={}", processedEventIds.size()); - } - } +package com.ktds.hi.review.infra.gateway; + +import com.azure.messaging.eventhubs.EventData; +import com.azure.messaging.eventhubs.EventHubConsumerClient; +import com.azure.messaging.eventhubs.models.EventPosition; +import com.azure.messaging.eventhubs.models.PartitionEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktds.hi.review.biz.domain.Review; +import com.ktds.hi.review.biz.domain.ReviewStatus; +import com.ktds.hi.review.biz.usecase.out.ReviewRepository; +import com.ktds.hi.review.infra.gateway.repository.ReviewJpaRepository; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.HashSet; +import java.util.Set; +/** + * Azure Event Hub 어댑터 클래스 - 수정된 버전 + * 외부 리뷰 이벤트 수신 및 Review 테이블 저장 (중복 방지) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExternalReviewEventHubAdapter { + + @Qualifier("externalReviewEventConsumer") + private final EventHubConsumerClient externalReviewEventConsumer; + private final ReviewJpaRepository reviewJpaRepository; + private final Set processedEventIds = new HashSet<>(); + private final ObjectMapper objectMapper; + private final ReviewRepository reviewRepository; + + private final ExecutorService executorService = Executors.newFixedThreadPool(3); + private volatile boolean isRunning = false; + + @PostConstruct + public void startEventListening() { + log.info("외부 리뷰 Event Hub 리스너 시작"); + isRunning = true; + + // 외부 리뷰 이벤트 수신 시작 + executorService.submit(this::listenToExternalReviewEvents); + } + + @PreDestroy + public void stopEventListening() { + log.info("외부 리뷰 Event Hub 리스너 종료"); + isRunning = false; + executorService.shutdown(); + externalReviewEventConsumer.close(); + } + + /** + * 외부 리뷰 이벤트 수신 처리 - 모든 파티션 처리 + */ + private void listenToExternalReviewEvents() { + log.info("외부 리뷰 이벤트 수신 시작"); + + try { + // ✅ 1. 파티션 정보 조회 (하드코딩 제거) + String[] partitionIds = {"0", "1", "2", "3"}; + log.info("처리할 파티션: {}", String.join(", ", partitionIds)); + + while (isRunning) { + // ✅ 2. 모든 파티션 순회 처리 + for (String partitionId : partitionIds) { + try { + Iterable events = externalReviewEventConsumer.receiveFromPartition( + partitionId, // ✅ 동적 파티션 ID + 50, // 배치 크기 줄임 (성능 최적화) + EventPosition.latest(), // ✅ 최신 메시지만 (중복 방지) + Duration.ofSeconds(10) // 타임아웃 줄임 + ); + + for (PartitionEvent partitionEvent : events) { + handleExternalReviewEvent(partitionEvent); + } + + } catch (Exception e) { + log.error("파티션 {} 처리 중 오류: {}", partitionId, e.getMessage()); + } + } + + Thread.sleep(1000); + } + } catch (InterruptedException e) { + log.info("외부 리뷰 이벤트 수신 중단됨"); + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.error("외부 리뷰 이벤트 수신 중 오류 발생", e); + } + } + + /** + * 외부 리뷰 이벤트 처리 + */ + private void handleExternalReviewEvent(PartitionEvent partitionEvent) { + try { + EventData eventData = partitionEvent.getData(); + String eventBody = eventData.getBodyAsString(); + + Map event = objectMapper.readValue(eventBody, Map.class); + String eventType = (String) event.get("eventType"); + Long storeId = Long.valueOf(event.get("storeId").toString()); + + log.info("외부 리뷰 이벤트 수신: type={}, storeId={}", eventType, storeId); + + if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) { + handleExternalReviewSyncEvent(storeId, event); + } else { + log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); + } + + } catch (Exception e) { + log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); + } + } + + /** + * 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리 + */ + private void handleExternalReviewSyncEvent(Long storeId, Map event) { + try { + String platform = (String) event.get("platform"); + Integer syncedCount = (Integer) event.get("syncedCount"); + + + // Store에서 발행하는 reviews 배열 처리 + @SuppressWarnings("unchecked") + List> reviews = (List>) event.get("reviews"); + + if (reviews != null) { + for (int i = 0; i < reviews.size(); i++) { + Map review = reviews.get(i); + log.info("Review[{}]: {}", i, review); + } + } else { + log.info("No reviews found in event."); + } + + + if (reviews == null || reviews.isEmpty()) { + log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId); + return; + } + + log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}", + platform, storeId, reviews.size()); + + int savedCount = 0; + int duplicateCount = 0; + + for (Map reviewData : reviews) { + try { + Review savedReview = saveExternalReview(storeId, platform, reviewData); + if (savedReview != null) { + savedCount++; + } + } catch (Exception e) { + log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}", + platform, storeId, e.getMessage()); + } + } + + log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}", + platform, storeId, reviews.size(), savedCount); + + } catch (Exception e) { + log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e); + } + } + + /** + * 개별 외부 리뷰 저장 - 중복 체크 포함 + */ + private Review saveExternalReview(Long storeId, String platform, Map reviewData) { + try { + // ✅ 1. 중복 체크용 고유 식별자 생성 + String externalNickname = createMemberNickname(platform, reviewData); + String content = extractContent(reviewData); + + // ✅ 2. 중복 체크 (storeId + 닉네임 + 내용으로 중복 판단) + boolean isDuplicate = reviewJpaRepository.existsByStoreIdAndMemberNicknameAndContent( + storeId, externalNickname, content); + + if (isDuplicate) { + log.debug("중복 리뷰 스킵: storeId={}, nickname={}", storeId, externalNickname); + return null; + } + + // ✅ 3. 새로운 리뷰 저장 + Review review = Review.builder() + .storeId(storeId) + .memberId(-1L) + .memberNickname(createMemberNickname(platform, reviewData)) + .rating(extractRating(reviewData)) + .content(extractContent(reviewData)) + .imageUrls(new ArrayList<>()) // 외부 리뷰는 이미지 없음 + .status(ReviewStatus.ACTIVE) + .likeCount(0) + .dislikeCount(0) + .build(); + + // Review 테이블에 저장 + Review savedReview = reviewRepository.saveReview(review); + + log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}", + savedReview.getId(), platform, storeId, savedReview.getMemberNickname()); + + return savedReview; + + } catch (Exception e) { + log.error("외부 리뷰 저장 실패: platform={}, storeId={}, error={}", + platform, storeId, e.getMessage(), e); + return null; + } + } + + /** + * 플랫폼별 회원 닉네임 생성 + */ + private String createMemberNickname(String platform, Map reviewData) { + String authorName = null; + + // 카카오 API 구조에 맞춰 수정 + if ("KAKAO".equalsIgnoreCase(platform)) { + authorName = (String) reviewData.get("reviewer_name"); + } else { + // 다른 플랫폼 대비 + authorName = (String) reviewData.get("author_name"); + if (authorName == null) { + authorName = (String) reviewData.get("authorName"); + } + } + + if (authorName == null || authorName.trim().isEmpty()) { + return platform.toUpperCase() + " 사용자"; + } + + return authorName + "(" + platform.toUpperCase() + ")"; + } + + /** + * 평점 추출 (기본값: 5) + */ + private Integer extractRating(Map reviewData) { + Object rating = reviewData.get("rating"); + if (rating instanceof Number) { + int ratingValue = ((Number) rating).intValue(); + return (ratingValue >= 1 && ratingValue <= 5) ? ratingValue : 5; + } + return 5; + } + + /** + * 리뷰 내용 추출 + */ + private String extractContent(Map reviewData) { + String content = (String) reviewData.get("content"); + if (content == null || content.trim().isEmpty()) { + return "외부 플랫폼 리뷰"; + } + + // 내용이 너무 길면 자르기 + if (content.length() > 1900) { + content = content.substring(0, 1900) + "..."; + } + + return content; + } + + //추가 코드 + /** + * 외부 리뷰 이벤트 처리 (중복 방지) + */ + private void handleExternalReviewEventSafely(PartitionEvent partitionEvent) { + try { + EventData eventData = partitionEvent.getData(); + String eventBody = eventData.getBodyAsString(); + + // 🔥 이벤트 고유 ID 생성 (오프셋 + 시퀀스 넘버 기반) + String eventId = String.format("%s_%s", + eventData.getOffset(), + eventData.getSequenceNumber()); + + // 이미 처리된 이벤트인지 확인 + if (processedEventIds.contains(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + return; + } + + Map event = objectMapper.readValue(eventBody, Map.class); + String eventType = (String) event.get("eventType"); + Long storeId = Long.valueOf(event.get("storeId").toString()); + + log.info("외부 리뷰 이벤트 수신: type={}, storeId={}, eventId={}", eventType, storeId, eventId); + + if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) { + // 기존 메서드 호출하여 리뷰 저장 + handleExternalReviewSyncEvent(storeId, event); + + // 🔥 처리 완료된 이벤트 ID 저장 + markEventAsProcessed(eventId); + log.info("이벤트 처리 완료: eventId={}, storeId={}", eventId, storeId); + + } else { + log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); + } + + } catch (Exception e) { + log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); + } + } + + /** + * 이벤트 처리 완료 표시 (메모리 관리 포함) + */ + private void markEventAsProcessed(String eventId) { + processedEventIds.add(eventId); + + // 🔥 메모리 관리: 1000개 이상 쌓이면 오래된 것들 삭제 + if (processedEventIds.size() > 1000) { + // 앞의 500개만 삭제하고 최근 500개는 유지 + Set recentIds = new HashSet<>(); + processedEventIds.stream() + .skip(500) + .forEach(recentIds::add); + + processedEventIds.clear(); + processedEventIds.addAll(recentIds); + + log.info("처리된 이벤트 ID 캐시 정리 완료: 현재 크기={}", processedEventIds.size()); + } + } } \ No newline at end of file diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewJpaRepository.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewJpaRepository.java index d982552..f642be2 100644 --- a/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewJpaRepository.java +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/repository/ReviewJpaRepository.java @@ -1,46 +1,46 @@ -package com.ktds.hi.review.infra.gateway.repository; - -import com.ktds.hi.review.biz.domain.ReviewStatus; -import com.ktds.hi.review.infra.gateway.entity.ReviewEntity; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -/** - * 리뷰 JPA 리포지토리 인터페이스 - * 리뷰 데이터의 CRUD 작업을 담당 - */ -@Repository -public interface ReviewJpaRepository extends JpaRepository { - - /** - * 매장 ID와 상태로 리뷰 목록 조회 - */ - Page findByStoreIdAndStatus(Long storeId, ReviewStatus status, Pageable pageable); - - /** - * 회원 ID와 상태로 리뷰 목록 조회 - */ - Page findByMemberIdAndStatus(Long memberId, ReviewStatus status, Pageable pageable); - - /** - * 리뷰 ID와 회원 ID로 리뷰 조회 - */ - Optional findByIdAndMemberId(Long id, Long memberId); - - - /** - * 중복 리뷰 체크 (매장ID + 닉네임 + 내용으로 판단) - */ - boolean existsByStoreIdAndMemberNicknameAndContent(Long storeId, String memberNickname, String content); - - /** - * 대안: 외부 닉네임으로만 중복 체크 (더 간단한 방법) - */ - boolean existsByStoreIdAndExternalNickname(Long storeId, String externalNickname); -} +package com.ktds.hi.review.infra.gateway.repository; + +import com.ktds.hi.review.biz.domain.ReviewStatus; +import com.ktds.hi.review.infra.gateway.entity.ReviewEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 리뷰 JPA 리포지토리 인터페이스 + * 리뷰 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface ReviewJpaRepository extends JpaRepository { + + /** + * 매장 ID와 상태로 리뷰 목록 조회 + */ + Page findByStoreIdAndStatus(Long storeId, ReviewStatus status, Pageable pageable); + + /** + * 회원 ID와 상태로 리뷰 목록 조회 + */ + Page findByMemberIdAndStatus(Long memberId, ReviewStatus status, Pageable pageable); + + /** + * 리뷰 ID와 회원 ID로 리뷰 조회 + */ + Optional findByIdAndMemberId(Long id, Long memberId); + + + /** + * 중복 리뷰 체크 (매장ID + 닉네임 + 내용으로 판단) + */ + boolean existsByStoreIdAndMemberNicknameAndContent(Long storeId, String memberNickname, String content); + + /** + * 대안: 외부 닉네임으로만 중복 체크 (더 간단한 방법) + */ + boolean existsByStoreIdAndExternalNickname(Long storeId, String externalNickname); +}