Update: KAKAO 리뷰 중복 필터링 설정

This commit is contained in:
UNGGU0704 2025-06-18 14:44:21 +09:00
parent bbee6565b8
commit bf3427112c
2 changed files with 255 additions and 208 deletions

View File

@ -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<String> 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<String> 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<PartitionEvent> 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<String, Object> 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<String, Object> event) {
try {
String platform = (String) event.get("platform");
Integer syncedCount = (Integer) event.get("syncedCount");
// Store에서 발행하는 reviews 배열 처리
@SuppressWarnings("unchecked")
List<Map<String, Object>> reviews = (List<Map<String, Object>>) event.get("reviews");
if (reviews != null) {
for (int i = 0; i < reviews.size(); i++) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> event) {
try {
String platform = (String) event.get("platform");
Integer syncedCount = (Integer) event.get("syncedCount");
// Store에서 발행하는 reviews 배열 처리
@SuppressWarnings("unchecked")
List<Map<String, Object>> reviews = (List<Map<String, Object>>) 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String> extractImageUrls(Map<String, Object> reviewData) {
try {
Object imageUrlsObj = reviewData.get("imageUrls");
if (imageUrlsObj instanceof List) {
return (List<String>) 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<String> recentIds = new HashSet<>();
Set<String> 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());
}

View File

@ -40,6 +40,47 @@ public interface ReviewJpaRepository extends JpaRepository<ReviewEntity, Long> {
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로 리뷰 조회
*/