From bf3427112cd7834da7fede06bf1f7cb617338688 Mon Sep 17 00:00:00 2001 From: UNGGU0704 Date: Wed, 18 Jun 2025 14:44:21 +0900 Subject: [PATCH] =?UTF-8?q?Update:=20KAKAO=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=95=84=ED=84=B0=EB=A7=81=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExternalReviewEventHubAdapter.java | 422 +++++++++--------- .../repository/ReviewJpaRepository.java | 41 ++ 2 files changed, 255 insertions(+), 208 deletions(-) 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 40712ce..9469637 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 @@ -17,16 +17,18 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import java.time.Duration; +import java.time.LocalDateTime; 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.concurrent.ConcurrentHashMap; import java.util.Set; + /** - * Azure Event Hub 어댑터 클래스 - 수정된 버전 - * 외부 리뷰 이벤트 수신 및 Review 테이블 저장 (중복 방지) + * Azure Event Hub 어댑터 클래스 (기존 로직 유지 + 중복 제거만 추가) + * 외부 리뷰 이벤트 수신 및 Review 테이블 저장 */ @Slf4j @Component @@ -36,18 +38,19 @@ 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; + // 🔥 중복 방지를 위한 이벤트 ID 캐시 (Thread-safe) + private final Set processedEventIds = ConcurrentHashMap.newKeySet(); + @PostConstruct public void startEventListening() { log.info("외부 리뷰 Event Hub 리스너 시작"); isRunning = true; - // 외부 리뷰 이벤트 수신 시작 executorService.submit(this::listenToExternalReviewEvents); } @@ -61,25 +64,25 @@ public class ExternalReviewEventHubAdapter { } /** - * 외부 리뷰 이벤트 수신 처리 - 모든 파티션 처리 + * 외부 리뷰 이벤트 수신 처리 (모든 파티션 검사 추가) */ private void listenToExternalReviewEvents() { log.info("외부 리뷰 이벤트 수신 시작"); try { - // ✅ 1. 파티션 정보 조회 (하드코딩 제거) - String[] partitionIds = {"0", "1", "2", "3"}; + // 🔥 모든 파티션 ID 설정 (기존 "4"번 포함) + String[] partitionIds = {"0", "1", "2", "3", "4"}; 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) // 타임아웃 줄임 + partitionId, // 🔥 동적 파티션 ID + 100, // 최대 이벤트 수 (기존과 동일) + EventPosition.earliest(), // 시작 위치 (기존과 동일) + Duration.ofSeconds(30) // 타임아웃 (기존과 동일) ); for (PartitionEvent partitionEvent : events) { @@ -87,11 +90,12 @@ public class ExternalReviewEventHubAdapter { } } catch (Exception e) { - log.error("파티션 {} 처리 중 오류: {}", partitionId, e.getMessage()); + log.error("파티션 {} 처리 중 오류 (계속 진행): {}", partitionId, e.getMessage()); + // 개별 파티션 오류는 무시하고 다음 파티션 계속 처리 } } - Thread.sleep(1000); + Thread.sleep(1000); // 기존과 동일 } } catch (InterruptedException e) { log.info("외부 리뷰 이벤트 수신 중단됨"); @@ -102,197 +106,20 @@ public class ExternalReviewEventHubAdapter { } /** - * 외부 리뷰 이벤트 처리 + * 외부 리뷰 이벤트 처리 (중복 체크 추가) */ 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 { - - String content = extractContent(reviewData); - - boolean isDuplicate = reviewJpaRepository.existsByStoreIdAndContent( - storeId, content); - - if (isDuplicate) { - log.debug("중복 리뷰 스킵: storeId={}, nickname={}", storeId); - 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 생성 (오프셋 + 시퀀스 넘버 기반) + // 🔥 이벤트 고유 ID 생성 (중복 방지) String eventId = String.format("%s_%s", eventData.getOffset(), eventData.getSequenceNumber()); - // 이미 처리된 이벤트인지 확인 - if (processedEventIds.contains(eventId)) { + // 🔥 이미 처리된 이벤트인지 확인 + if (isEventAlreadyProcessed(eventId)) { log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); return; } @@ -304,13 +131,9 @@ public class ExternalReviewEventHubAdapter { 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); } @@ -321,21 +144,204 @@ public class ExternalReviewEventHubAdapter { } /** - * 이벤트 처리 완료 표시 (메모리 관리 포함) + * 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리 (중복 체크 강화) + */ + 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 || reviews.isEmpty()) { + log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId); + return; + } + + log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}", + platform, storeId, reviews.size()); + + int savedCount = 0; + int duplicateCount = 0; + int errorCount = 0; + + for (Map reviewData : reviews) { + try { + // 🔥 중복 체크 포함된 저장 로직 + Review savedReview = saveExternalReviewWithDuplicateCheck(storeId, platform, reviewData); + if (savedReview != null) { + savedCount++; + } else { + duplicateCount++; + } + } catch (Exception e) { + errorCount++; + log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}", + platform, storeId, e.getMessage()); + } + } + + log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}, duplicated={}, errors={}", + platform, storeId, reviews.size(), savedCount, duplicateCount, errorCount); + + } catch (Exception e) { + log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e); + } + } + + /** + * 🔥 중복 체크 포함된 외부 리뷰 저장 + */ + private Review saveExternalReviewWithDuplicateCheck(Long storeId, String platform, Map reviewData) { + try { + String content = extractContent(reviewData); + String memberNickname = extractMemberNickname(reviewData); + + // 1차 중복 체크: storeId + content 기반 + if (reviewJpaRepository.existsByStoreIdAndContent(storeId, content)) { + log.debug("중복 리뷰 스킵 (content 기준): storeId={}, content={}", + storeId, content.substring(0, Math.min(50, content.length()))); + return null; + } + + // 2차 중복 체크: storeId + memberNickname + content 기반 (더 정확) + if (reviewJpaRepository.existsByStoreIdAndMemberNicknameAndContent(storeId, memberNickname, content)) { + log.debug("중복 리뷰 스킵 (nickname+content 기준): storeId={}, nickname={}", + storeId, memberNickname); + return null; + } + + // 3차 중복 체크: 스팸 방지 (동일 닉네임이 5개 이상 리뷰 작성 시 차단) + Long reviewCount = reviewJpaRepository.countByStoreIdAndMemberNickname(storeId, memberNickname); + if (reviewCount >= 5) { + log.debug("스팸 가능성으로 리뷰 스킵: storeId={}, nickname={}, count={}", + storeId, memberNickname, reviewCount); + return null; + } + + // 4차 중복 체크: 최근 1시간 내 동일 닉네임 리뷰 체크 + LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1); + if (reviewJpaRepository.existsByStoreIdAndMemberNicknameAfterTime(storeId, memberNickname, oneHourAgo)) { + log.debug("1시간 내 중복 리뷰 스킵: storeId={}, nickname={}", storeId, memberNickname); + return null; + } + + // 중복이 아닌 경우 새 리뷰 저장 + Review newReview = createReviewFromExternalData(storeId, platform, reviewData); + Review savedReview = reviewRepository.saveReview(newReview); + + log.debug("새 외부 리뷰 저장 완료: storeId={}, reviewId={}, platform={}", + storeId, savedReview.getId(), platform); + + return savedReview; + + } catch (Exception e) { + log.error("외부 리뷰 저장 중 오류: storeId={}, platform={}, error={}", + storeId, platform, e.getMessage()); + throw e; + } + } + + /** + * 외부 리뷰 데이터에서 Review 도메인 객체 생성 + */ + private Review createReviewFromExternalData(Long storeId, String platform, Map reviewData) { + return Review.builder() + .storeId(storeId) + .memberId(null) // 외부 리뷰는 내부 회원 ID 없음 + .memberNickname(extractMemberNickname(reviewData)) + .rating(extractRating(reviewData)) + .content(extractContent(reviewData)) + .imageUrls(extractImageUrls(reviewData)) + .status(ReviewStatus.ACTIVE) + .likeCount(0) + .dislikeCount(0) + .build(); + } + + /** + * 회원 닉네임 추출 + */ + private String extractMemberNickname(Map reviewData) { + String nickname = (String) reviewData.get("memberNickname"); + if (nickname == null || nickname.trim().isEmpty()) { + nickname = (String) reviewData.get("nickname"); + } + if (nickname == null || nickname.trim().isEmpty()) { + nickname = "외부사용자"; + } + return nickname.length() > 50 ? nickname.substring(0, 50) : nickname; + } + + /** + * 평점 추출 + */ + private Integer extractRating(Map reviewData) { + Object ratingObj = reviewData.get("rating"); + if (ratingObj instanceof Number) { + int ratingValue = ((Number) ratingObj).intValue(); + return Math.max(1, Math.min(5, ratingValue)); // 1-5 범위로 제한 + } + return 5; // 기본값 + } + + /** + * 리뷰 내용 추출 + */ + private String extractContent(Map reviewData) { + String content = (String) reviewData.get("content"); + if (content == null || content.trim().isEmpty()) { + return "외부 플랫폼 리뷰"; + } + + // 내용이 너무 길면 자르기 (1000자 제한) + if (content.length() > 1000) { + content = content.substring(0, 1000) + "..."; + } + + return content; + } + + /** + * 이미지 URL 목록 추출 + */ + @SuppressWarnings("unchecked") + private List extractImageUrls(Map reviewData) { + try { + Object imageUrlsObj = reviewData.get("imageUrls"); + if (imageUrlsObj instanceof List) { + return (List) imageUrlsObj; + } + return new ArrayList<>(); + } catch (Exception e) { + log.warn("이미지 URL 추출 실패: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * 🔥 이벤트 중복 처리 확인 + */ + private boolean isEventAlreadyProcessed(String eventId) { + return processedEventIds.contains(eventId); + } + + /** + * 🔥 이벤트 처리 완료 표시 (메모리 관리 포함) */ private void markEventAsProcessed(String eventId) { processedEventIds.add(eventId); - // 🔥 메모리 관리: 1000개 이상 쌓이면 오래된 것들 삭제 + // 메모리 관리: 1000개 이상 쌓이면 오래된 것들 삭제 if (processedEventIds.size() > 1000) { - // 앞의 500개만 삭제하고 최근 500개는 유지 - Set recentIds = new HashSet<>(); + Set oldIds = ConcurrentHashMap.newKeySet(); processedEventIds.stream() - .skip(500) - .forEach(recentIds::add); + .limit(500) + .forEach(oldIds::add); - processedEventIds.clear(); - processedEventIds.addAll(recentIds); + processedEventIds.removeAll(oldIds); log.info("처리된 이벤트 ID 캐시 정리 완료: 현재 크기={}", processedEventIds.size()); } 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 dd326f0..4d16598 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 @@ -40,6 +40,47 @@ public interface ReviewJpaRepository extends JpaRepository { Pageable pageable ); + /** + * 특정 매장의 닉네임별 리뷰 개수 조회 (스팸 체크용) + */ + @Query("SELECT COUNT(r) FROM ReviewEntity r WHERE r.storeId = :storeId AND r.memberNickname = :memberNickname") + Long countByStoreIdAndMemberNickname(@Param("storeId") Long storeId, @Param("memberNickname") String memberNickname); + + /** + * 강화된 중복 체크: 매장ID + 닉네임 + 내용으로 중복 체크 + * 외부 리뷰에서 동일한 닉네임이 같은 내용의 리뷰를 작성했는지 확인 + */ + boolean existsByStoreIdAndMemberNicknameAndContent(Long storeId, String memberNickname, String content); + + /** + * 닉네임 기반 중복 체크: 매장ID + 닉네임으로 기존 리뷰 존재 확인 + * 동일한 닉네임이 이미 리뷰를 작성했는지 체크 + */ + boolean existsByStoreIdAndMemberNickname(Long storeId, String memberNickname); + + /** + * 시간 기반 중복 체크: 특정 시간 이후 동일한 닉네임의 리뷰 존재 확인 + * 같은 닉네임이 짧은 시간 내에 여러 리뷰를 작성했는지 체크 + */ + @Query("SELECT COUNT(r) > 0 FROM ReviewEntity r WHERE r.storeId = :storeId " + + "AND r.memberNickname = :memberNickname AND r.createdAt >= :afterTime") + boolean existsByStoreIdAndMemberNicknameAfterTime( + @Param("storeId") Long storeId, + @Param("memberNickname") String memberNickname, + @Param("afterTime") LocalDateTime afterTime); + + /** + * 컨텐츠 유사도 체크: 유사한 길이의 리뷰 존재 확인 + * 동일한 닉네임이 비슷한 길이의 리뷰를 작성했는지 체크 (간단한 유사도 체크) + */ + @Query("SELECT COUNT(r) > 0 FROM ReviewEntity r WHERE r.storeId = :storeId " + + "AND r.memberNickname = :memberNickname " + + "AND LENGTH(r.content) BETWEEN :minLength AND :maxLength") + boolean existsBySimilarContentLength( + @Param("storeId") Long storeId, + @Param("memberNickname") String memberNickname, + @Param("minLength") int minLength, + @Param("maxLength") int maxLength); /** * 리뷰 ID와 회원 ID로 리뷰 조회 */