# Conflicts:
#	build.gradle
This commit is contained in:
정유빈 2025-06-18 13:11:08 +09:00
parent 7deb2ce39d
commit 6ff21dd8e0
4 changed files with 525 additions and 525 deletions

View File

@ -1,49 +1,49 @@
package com.ktds.hi.analytics.biz.usecase.in; package com.ktds.hi.analytics.biz.usecase.in;
import com.ktds.hi.analytics.infra.dto.*; import com.ktds.hi.analytics.infra.dto.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
/** /**
* 분석 서비스 UseCase 인터페이스 * 분석 서비스 UseCase 인터페이스
* Clean Architecture의 입력 포트 정의 * Clean Architecture의 입력 포트 정의
*/ */
public interface AnalyticsUseCase { public interface AnalyticsUseCase {
/** /**
* 매장 분석 데이터 조회 * 매장 분석 데이터 조회
*/ */
StoreAnalyticsResponse getStoreAnalytics(Long storeId); StoreAnalyticsResponse getStoreAnalytics(Long storeId);
/** /**
* AI 피드백 상세 조회 * AI 피드백 상세 조회
*/ */
AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId); AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId);
/** /**
* 매장 통계 조회 * 매장 통계 조회
*/ */
StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate); StoreStatisticsResponse getStoreStatistics(Long storeId, LocalDate startDate, LocalDate endDate);
/** /**
* AI 피드백 요약 조회 * AI 피드백 요약 조회
*/ */
AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId); AiFeedbackSummaryResponse getAIFeedbackSummary(Long storeId);
/** /**
* 리뷰 분석 조회 * 리뷰 분석 조회
*/ */
ReviewAnalysisResponse getReviewAnalysis(Long storeId, int days); ReviewAnalysisResponse getReviewAnalysis(Long storeId, int days);
/** /**
* AI 리뷰 분석 실행계획 생성 * AI 리뷰 분석 실행계획 생성
*/ */
AiAnalysisResponse generateAIAnalysis(Long storeId, AiAnalysisRequest request); AiAnalysisResponse generateAIAnalysis(Long storeId, AiAnalysisRequest request);
/** /**
* AI 피드백 기반 실행계획 생성 * AI 피드백 기반 실행계획 생성
*/ */
List<String> generateActionPlansFromFeedback(ActionPlanCreateRequest request,Long feedbackId); List<String> generateActionPlansFromFeedback(ActionPlanCreateRequest request,Long feedbackId);
} }

View File

@ -1,86 +1,86 @@
package com.ktds.hi.review.biz.service; package com.ktds.hi.review.biz.service;
import com.ktds.hi.review.biz.usecase.in.ManageReviewCommentUseCase; 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.ReviewCommentRepository;
import com.ktds.hi.review.biz.usecase.out.ReviewRepository; import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
import com.ktds.hi.review.biz.domain.ReviewComment; import com.ktds.hi.review.biz.domain.ReviewComment;
import com.ktds.hi.review.biz.domain.Review; import com.ktds.hi.review.biz.domain.Review;
import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest; import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest;
import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse; import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse;
import com.ktds.hi.common.exception.BusinessException; import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 리뷰 댓글 인터랙터 클래스 * 리뷰 댓글 인터랙터 클래스
* 리뷰 댓글 작성, 조회, 삭제 기능을 구현 * 리뷰 댓글 작성, 조회, 삭제 기능을 구현
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@Transactional @Transactional
public class ReviewCommentInteractor implements ManageReviewCommentUseCase { public class ReviewCommentInteractor implements ManageReviewCommentUseCase {
private final ReviewCommentRepository commentRepository; private final ReviewCommentRepository commentRepository;
private final ReviewRepository reviewRepository; private final ReviewRepository reviewRepository;
@Override @Override
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ReviewCommentResponse> getReviewComments(Long reviewId) { public List<ReviewCommentResponse> getReviewComments(Long reviewId) {
List<ReviewComment> comments = commentRepository.findCommentsByReviewId(reviewId); List<ReviewComment> comments = commentRepository.findCommentsByReviewId(reviewId);
return comments.stream() return comments.stream()
.map(comment -> ReviewCommentResponse.builder() .map(comment -> ReviewCommentResponse.builder()
.commentId(comment.getId()) .commentId(comment.getId())
.ownerNickname(comment.getOwnerNickname()) .ownerNickname(comment.getOwnerNickname())
.content(comment.getContent()) .content(comment.getContent())
.createdAt(comment.getCreatedAt()) .createdAt(comment.getCreatedAt())
.build()) .build())
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override @Override
public ReviewCommentResponse createComment(Long reviewId, Long ownerId, ReviewCommentRequest request) { public ReviewCommentResponse createComment(Long reviewId, Long ownerId, ReviewCommentRequest request) {
// 리뷰 존재 확인 // 리뷰 존재 확인
Review review = reviewRepository.findReviewById(reviewId) Review review = reviewRepository.findReviewById(reviewId)
.orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다")); .orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다"));
// 댓글 생성 // 댓글 생성
ReviewComment comment = ReviewComment.builder() ReviewComment comment = ReviewComment.builder()
.reviewId(reviewId) .reviewId(reviewId)
.ownerId(ownerId) .ownerId(ownerId)
.ownerNickname("점주" + ownerId) // TODO: 점주 서비스에서 닉네임 조회 .ownerNickname("점주" + ownerId) // TODO: 점주 서비스에서 닉네임 조회
.content(request.getContent()) .content(request.getContent())
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now())
.build(); .build();
ReviewComment savedComment = commentRepository.saveComment(comment); ReviewComment savedComment = commentRepository.saveComment(comment);
log.info("리뷰 댓글 생성 완료: commentId={}, reviewId={}, ownerId={}", log.info("리뷰 댓글 생성 완료: commentId={}, reviewId={}, ownerId={}",
savedComment.getId(), reviewId, ownerId); savedComment.getId(), reviewId, ownerId);
return ReviewCommentResponse.builder() return ReviewCommentResponse.builder()
.commentId(savedComment.getId()) .commentId(savedComment.getId())
.ownerNickname(savedComment.getOwnerNickname()) .ownerNickname(savedComment.getOwnerNickname())
.content(savedComment.getContent()) .content(savedComment.getContent())
.createdAt(savedComment.getCreatedAt()) .createdAt(savedComment.getCreatedAt())
.build(); .build();
} }
@Override @Override
public void deleteComment(Long commentId, Long ownerId) { public void deleteComment(Long commentId, Long ownerId) {
ReviewComment comment = commentRepository.findCommentByIdAndOwnerId(commentId, ownerId) ReviewComment comment = commentRepository.findCommentByIdAndOwnerId(commentId, ownerId)
.orElseThrow(() -> new BusinessException("댓글을 찾을 수 없거나 권한이 없습니다")); .orElseThrow(() -> new BusinessException("댓글을 찾을 수 없거나 권한이 없습니다"));
commentRepository.deleteComment(commentId); commentRepository.deleteComment(commentId);
log.info("리뷰 댓글 삭제 완료: commentId={}, ownerId={}", commentId, ownerId); log.info("리뷰 댓글 삭제 완료: commentId={}, ownerId={}", commentId, ownerId);
} }
} }

View File

@ -1,345 +1,345 @@
package com.ktds.hi.review.infra.gateway; package com.ktds.hi.review.infra.gateway;
import com.azure.messaging.eventhubs.EventData; import com.azure.messaging.eventhubs.EventData;
import com.azure.messaging.eventhubs.EventHubConsumerClient; import com.azure.messaging.eventhubs.EventHubConsumerClient;
import com.azure.messaging.eventhubs.models.EventPosition; import com.azure.messaging.eventhubs.models.EventPosition;
import com.azure.messaging.eventhubs.models.PartitionEvent; import com.azure.messaging.eventhubs.models.PartitionEvent;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.ktds.hi.review.biz.domain.Review; import com.ktds.hi.review.biz.domain.Review;
import com.ktds.hi.review.biz.domain.ReviewStatus; import com.ktds.hi.review.biz.domain.ReviewStatus;
import com.ktds.hi.review.biz.usecase.out.ReviewRepository; import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
import com.ktds.hi.review.infra.gateway.repository.ReviewJpaRepository; import com.ktds.hi.review.infra.gateway.repository.ReviewJpaRepository;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
/** /**
* Azure Event Hub 어댑터 클래스 - 수정된 버전 * Azure Event Hub 어댑터 클래스 - 수정된 버전
* 외부 리뷰 이벤트 수신 Review 테이블 저장 (중복 방지) * 외부 리뷰 이벤트 수신 Review 테이블 저장 (중복 방지)
*/ */
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class ExternalReviewEventHubAdapter { public class ExternalReviewEventHubAdapter {
@Qualifier("externalReviewEventConsumer") @Qualifier("externalReviewEventConsumer")
private final EventHubConsumerClient externalReviewEventConsumer; private final EventHubConsumerClient externalReviewEventConsumer;
private final ReviewJpaRepository reviewJpaRepository; private final ReviewJpaRepository reviewJpaRepository;
private final Set<String> processedEventIds = new HashSet<>(); private final Set<String> processedEventIds = new HashSet<>();
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ReviewRepository reviewRepository; private final ReviewRepository reviewRepository;
private final ExecutorService executorService = Executors.newFixedThreadPool(3); private final ExecutorService executorService = Executors.newFixedThreadPool(3);
private volatile boolean isRunning = false; private volatile boolean isRunning = false;
@PostConstruct @PostConstruct
public void startEventListening() { public void startEventListening() {
log.info("외부 리뷰 Event Hub 리스너 시작"); log.info("외부 리뷰 Event Hub 리스너 시작");
isRunning = true; isRunning = true;
// 외부 리뷰 이벤트 수신 시작 // 외부 리뷰 이벤트 수신 시작
executorService.submit(this::listenToExternalReviewEvents); executorService.submit(this::listenToExternalReviewEvents);
} }
@PreDestroy @PreDestroy
public void stopEventListening() { public void stopEventListening() {
log.info("외부 리뷰 Event Hub 리스너 종료"); log.info("외부 리뷰 Event Hub 리스너 종료");
isRunning = false; isRunning = false;
executorService.shutdown(); executorService.shutdown();
externalReviewEventConsumer.close(); externalReviewEventConsumer.close();
} }
/** /**
* 외부 리뷰 이벤트 수신 처리 - 모든 파티션 처리 * 외부 리뷰 이벤트 수신 처리 - 모든 파티션 처리
*/ */
private void listenToExternalReviewEvents() { private void listenToExternalReviewEvents() {
log.info("외부 리뷰 이벤트 수신 시작"); log.info("외부 리뷰 이벤트 수신 시작");
try { try {
// 1. 파티션 정보 조회 (하드코딩 제거) // 1. 파티션 정보 조회 (하드코딩 제거)
String[] partitionIds = {"0", "1", "2", "3"}; String[] partitionIds = {"0", "1", "2", "3"};
log.info("처리할 파티션: {}", String.join(", ", partitionIds)); log.info("처리할 파티션: {}", String.join(", ", partitionIds));
while (isRunning) { while (isRunning) {
// 2. 모든 파티션 순회 처리 // 2. 모든 파티션 순회 처리
for (String partitionId : partitionIds) { for (String partitionId : partitionIds) {
try { try {
Iterable<PartitionEvent> events = externalReviewEventConsumer.receiveFromPartition( Iterable<PartitionEvent> events = externalReviewEventConsumer.receiveFromPartition(
partitionId, // 동적 파티션 ID partitionId, // 동적 파티션 ID
50, // 배치 크기 줄임 (성능 최적화) 50, // 배치 크기 줄임 (성능 최적화)
EventPosition.latest(), // 최신 메시지만 (중복 방지) EventPosition.latest(), // 최신 메시지만 (중복 방지)
Duration.ofSeconds(10) // 타임아웃 줄임 Duration.ofSeconds(10) // 타임아웃 줄임
); );
for (PartitionEvent partitionEvent : events) { for (PartitionEvent partitionEvent : events) {
handleExternalReviewEvent(partitionEvent); handleExternalReviewEvent(partitionEvent);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("파티션 {} 처리 중 오류: {}", partitionId, e.getMessage()); log.error("파티션 {} 처리 중 오류: {}", partitionId, e.getMessage());
} }
} }
Thread.sleep(1000); Thread.sleep(1000);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
log.info("외부 리뷰 이벤트 수신 중단됨"); log.info("외부 리뷰 이벤트 수신 중단됨");
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (Exception e) { } catch (Exception e) {
log.error("외부 리뷰 이벤트 수신 중 오류 발생", e); log.error("외부 리뷰 이벤트 수신 중 오류 발생", e);
} }
} }
/** /**
* 외부 리뷰 이벤트 처리 * 외부 리뷰 이벤트 처리
*/ */
private void handleExternalReviewEvent(PartitionEvent partitionEvent) { private void handleExternalReviewEvent(PartitionEvent partitionEvent) {
try { try {
EventData eventData = partitionEvent.getData(); EventData eventData = partitionEvent.getData();
String eventBody = eventData.getBodyAsString(); String eventBody = eventData.getBodyAsString();
Map<String, Object> event = objectMapper.readValue(eventBody, Map.class); Map<String, Object> event = objectMapper.readValue(eventBody, Map.class);
String eventType = (String) event.get("eventType"); String eventType = (String) event.get("eventType");
Long storeId = Long.valueOf(event.get("storeId").toString()); Long storeId = Long.valueOf(event.get("storeId").toString());
log.info("외부 리뷰 이벤트 수신: type={}, storeId={}", eventType, storeId); log.info("외부 리뷰 이벤트 수신: type={}, storeId={}", eventType, storeId);
if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) { if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) {
handleExternalReviewSyncEvent(storeId, event); handleExternalReviewSyncEvent(storeId, event);
} else { } else {
log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); log.error("외부 리뷰 이벤트 처리 중 오류 발생", e);
} }
} }
/** /**
* 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리 * 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리
*/ */
private void handleExternalReviewSyncEvent(Long storeId, Map<String, Object> event) { private void handleExternalReviewSyncEvent(Long storeId, Map<String, Object> event) {
try { try {
String platform = (String) event.get("platform"); String platform = (String) event.get("platform");
Integer syncedCount = (Integer) event.get("syncedCount"); Integer syncedCount = (Integer) event.get("syncedCount");
// Store에서 발행하는 reviews 배열 처리 // Store에서 발행하는 reviews 배열 처리
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<Map<String, Object>> reviews = (List<Map<String, Object>>) event.get("reviews"); List<Map<String, Object>> reviews = (List<Map<String, Object>>) event.get("reviews");
if (reviews != null) { if (reviews != null) {
for (int i = 0; i < reviews.size(); i++) { for (int i = 0; i < reviews.size(); i++) {
Map<String, Object> review = reviews.get(i); Map<String, Object> review = reviews.get(i);
log.info("Review[{}]: {}", i, review); log.info("Review[{}]: {}", i, review);
} }
} else { } else {
log.info("No reviews found in event."); log.info("No reviews found in event.");
} }
if (reviews == null || reviews.isEmpty()) { if (reviews == null || reviews.isEmpty()) {
log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId); log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId);
return; return;
} }
log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}", log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}",
platform, storeId, reviews.size()); platform, storeId, reviews.size());
int savedCount = 0; int savedCount = 0;
int duplicateCount = 0; int duplicateCount = 0;
for (Map<String, Object> reviewData : reviews) { for (Map<String, Object> reviewData : reviews) {
try { try {
Review savedReview = saveExternalReview(storeId, platform, reviewData); Review savedReview = saveExternalReview(storeId, platform, reviewData);
if (savedReview != null) { if (savedReview != null) {
savedCount++; savedCount++;
} }
} catch (Exception e) { } catch (Exception e) {
log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}", log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}",
platform, storeId, e.getMessage()); platform, storeId, e.getMessage());
} }
} }
log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}", log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}",
platform, storeId, reviews.size(), savedCount); platform, storeId, reviews.size(), savedCount);
} catch (Exception e) { } catch (Exception e) {
log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e); log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e);
} }
} }
/** /**
* 개별 외부 리뷰 저장 - 중복 체크 포함 * 개별 외부 리뷰 저장 - 중복 체크 포함
*/ */
private Review saveExternalReview(Long storeId, String platform, Map<String, Object> reviewData) { private Review saveExternalReview(Long storeId, String platform, Map<String, Object> reviewData) {
try { try {
// 1. 중복 체크용 고유 식별자 생성 // 1. 중복 체크용 고유 식별자 생성
String externalNickname = createMemberNickname(platform, reviewData); String externalNickname = createMemberNickname(platform, reviewData);
String content = extractContent(reviewData); String content = extractContent(reviewData);
// 2. 중복 체크 (storeId + 닉네임 + 내용으로 중복 판단) // 2. 중복 체크 (storeId + 닉네임 + 내용으로 중복 판단)
boolean isDuplicate = reviewJpaRepository.existsByStoreIdAndMemberNicknameAndContent( boolean isDuplicate = reviewJpaRepository.existsByStoreIdAndMemberNicknameAndContent(
storeId, externalNickname, content); storeId, externalNickname, content);
if (isDuplicate) { if (isDuplicate) {
log.debug("중복 리뷰 스킵: storeId={}, nickname={}", storeId, externalNickname); log.debug("중복 리뷰 스킵: storeId={}, nickname={}", storeId, externalNickname);
return null; return null;
} }
// 3. 새로운 리뷰 저장 // 3. 새로운 리뷰 저장
Review review = Review.builder() Review review = Review.builder()
.storeId(storeId) .storeId(storeId)
.memberId(-1L) .memberId(-1L)
.memberNickname(createMemberNickname(platform, reviewData)) .memberNickname(createMemberNickname(platform, reviewData))
.rating(extractRating(reviewData)) .rating(extractRating(reviewData))
.content(extractContent(reviewData)) .content(extractContent(reviewData))
.imageUrls(new ArrayList<>()) // 외부 리뷰는 이미지 없음 .imageUrls(new ArrayList<>()) // 외부 리뷰는 이미지 없음
.status(ReviewStatus.ACTIVE) .status(ReviewStatus.ACTIVE)
.likeCount(0) .likeCount(0)
.dislikeCount(0) .dislikeCount(0)
.build(); .build();
// Review 테이블에 저장 // Review 테이블에 저장
Review savedReview = reviewRepository.saveReview(review); Review savedReview = reviewRepository.saveReview(review);
log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}", log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}",
savedReview.getId(), platform, storeId, savedReview.getMemberNickname()); savedReview.getId(), platform, storeId, savedReview.getMemberNickname());
return savedReview; return savedReview;
} catch (Exception e) { } catch (Exception e) {
log.error("외부 리뷰 저장 실패: platform={}, storeId={}, error={}", log.error("외부 리뷰 저장 실패: platform={}, storeId={}, error={}",
platform, storeId, e.getMessage(), e); platform, storeId, e.getMessage(), e);
return null; return null;
} }
} }
/** /**
* 플랫폼별 회원 닉네임 생성 * 플랫폼별 회원 닉네임 생성
*/ */
private String createMemberNickname(String platform, Map<String, Object> reviewData) { private String createMemberNickname(String platform, Map<String, Object> reviewData) {
String authorName = null; String authorName = null;
// 카카오 API 구조에 맞춰 수정 // 카카오 API 구조에 맞춰 수정
if ("KAKAO".equalsIgnoreCase(platform)) { if ("KAKAO".equalsIgnoreCase(platform)) {
authorName = (String) reviewData.get("reviewer_name"); authorName = (String) reviewData.get("reviewer_name");
} else { } else {
// 다른 플랫폼 대비 // 다른 플랫폼 대비
authorName = (String) reviewData.get("author_name"); authorName = (String) reviewData.get("author_name");
if (authorName == null) { if (authorName == null) {
authorName = (String) reviewData.get("authorName"); authorName = (String) reviewData.get("authorName");
} }
} }
if (authorName == null || authorName.trim().isEmpty()) { if (authorName == null || authorName.trim().isEmpty()) {
return platform.toUpperCase() + " 사용자"; return platform.toUpperCase() + " 사용자";
} }
return authorName + "(" + platform.toUpperCase() + ")"; return authorName + "(" + platform.toUpperCase() + ")";
} }
/** /**
* 평점 추출 (기본값: 5) * 평점 추출 (기본값: 5)
*/ */
private Integer extractRating(Map<String, Object> reviewData) { private Integer extractRating(Map<String, Object> reviewData) {
Object rating = reviewData.get("rating"); Object rating = reviewData.get("rating");
if (rating instanceof Number) { if (rating instanceof Number) {
int ratingValue = ((Number) rating).intValue(); int ratingValue = ((Number) rating).intValue();
return (ratingValue >= 1 && ratingValue <= 5) ? ratingValue : 5; return (ratingValue >= 1 && ratingValue <= 5) ? ratingValue : 5;
} }
return 5; return 5;
} }
/** /**
* 리뷰 내용 추출 * 리뷰 내용 추출
*/ */
private String extractContent(Map<String, Object> reviewData) { private String extractContent(Map<String, Object> reviewData) {
String content = (String) reviewData.get("content"); String content = (String) reviewData.get("content");
if (content == null || content.trim().isEmpty()) { if (content == null || content.trim().isEmpty()) {
return "외부 플랫폼 리뷰"; return "외부 플랫폼 리뷰";
} }
// 내용이 너무 길면 자르기 // 내용이 너무 길면 자르기
if (content.length() > 1900) { if (content.length() > 1900) {
content = content.substring(0, 1900) + "..."; content = content.substring(0, 1900) + "...";
} }
return content; return content;
} }
//추가 코드 //추가 코드
/** /**
* 외부 리뷰 이벤트 처리 (중복 방지) * 외부 리뷰 이벤트 처리 (중복 방지)
*/ */
private void handleExternalReviewEventSafely(PartitionEvent partitionEvent) { private void handleExternalReviewEventSafely(PartitionEvent partitionEvent) {
try { try {
EventData eventData = partitionEvent.getData(); EventData eventData = partitionEvent.getData();
String eventBody = eventData.getBodyAsString(); String eventBody = eventData.getBodyAsString();
// 🔥 이벤트 고유 ID 생성 (오프셋 + 시퀀스 넘버 기반) // 🔥 이벤트 고유 ID 생성 (오프셋 + 시퀀스 넘버 기반)
String eventId = String.format("%s_%s", String eventId = String.format("%s_%s",
eventData.getOffset(), eventData.getOffset(),
eventData.getSequenceNumber()); eventData.getSequenceNumber());
// 이미 처리된 이벤트인지 확인 // 이미 처리된 이벤트인지 확인
if (processedEventIds.contains(eventId)) { if (processedEventIds.contains(eventId)) {
log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId);
return; return;
} }
Map<String, Object> event = objectMapper.readValue(eventBody, Map.class); Map<String, Object> event = objectMapper.readValue(eventBody, Map.class);
String eventType = (String) event.get("eventType"); String eventType = (String) event.get("eventType");
Long storeId = Long.valueOf(event.get("storeId").toString()); Long storeId = Long.valueOf(event.get("storeId").toString());
log.info("외부 리뷰 이벤트 수신: type={}, storeId={}, eventId={}", eventType, storeId, eventId); log.info("외부 리뷰 이벤트 수신: type={}, storeId={}, eventId={}", eventType, storeId, eventId);
if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) { if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) {
// 기존 메서드 호출하여 리뷰 저장 // 기존 메서드 호출하여 리뷰 저장
handleExternalReviewSyncEvent(storeId, event); handleExternalReviewSyncEvent(storeId, event);
// 🔥 처리 완료된 이벤트 ID 저장 // 🔥 처리 완료된 이벤트 ID 저장
markEventAsProcessed(eventId); markEventAsProcessed(eventId);
log.info("이벤트 처리 완료: eventId={}, storeId={}", eventId, storeId); log.info("이벤트 처리 완료: eventId={}, storeId={}", eventId, storeId);
} else { } else {
log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); log.error("외부 리뷰 이벤트 처리 중 오류 발생", e);
} }
} }
/** /**
* 이벤트 처리 완료 표시 (메모리 관리 포함) * 이벤트 처리 완료 표시 (메모리 관리 포함)
*/ */
private void markEventAsProcessed(String eventId) { private void markEventAsProcessed(String eventId) {
processedEventIds.add(eventId); processedEventIds.add(eventId);
// 🔥 메모리 관리: 1000개 이상 쌓이면 오래된 것들 삭제 // 🔥 메모리 관리: 1000개 이상 쌓이면 오래된 것들 삭제
if (processedEventIds.size() > 1000) { if (processedEventIds.size() > 1000) {
// 앞의 500개만 삭제하고 최근 500개는 유지 // 앞의 500개만 삭제하고 최근 500개는 유지
Set<String> recentIds = new HashSet<>(); Set<String> recentIds = new HashSet<>();
processedEventIds.stream() processedEventIds.stream()
.skip(500) .skip(500)
.forEach(recentIds::add); .forEach(recentIds::add);
processedEventIds.clear(); processedEventIds.clear();
processedEventIds.addAll(recentIds); processedEventIds.addAll(recentIds);
log.info("처리된 이벤트 ID 캐시 정리 완료: 현재 크기={}", processedEventIds.size()); log.info("처리된 이벤트 ID 캐시 정리 완료: 현재 크기={}", processedEventIds.size());
} }
} }
} }

View File

@ -1,46 +1,46 @@
package com.ktds.hi.review.infra.gateway.repository; package com.ktds.hi.review.infra.gateway.repository;
import com.ktds.hi.review.biz.domain.ReviewStatus; import com.ktds.hi.review.biz.domain.ReviewStatus;
import com.ktds.hi.review.infra.gateway.entity.ReviewEntity; import com.ktds.hi.review.infra.gateway.entity.ReviewEntity;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
/** /**
* 리뷰 JPA 리포지토리 인터페이스 * 리뷰 JPA 리포지토리 인터페이스
* 리뷰 데이터의 CRUD 작업을 담당 * 리뷰 데이터의 CRUD 작업을 담당
*/ */
@Repository @Repository
public interface ReviewJpaRepository extends JpaRepository<ReviewEntity, Long> { public interface ReviewJpaRepository extends JpaRepository<ReviewEntity, Long> {
/** /**
* 매장 ID와 상태로 리뷰 목록 조회 * 매장 ID와 상태로 리뷰 목록 조회
*/ */
Page<ReviewEntity> findByStoreIdAndStatus(Long storeId, ReviewStatus status, Pageable pageable); Page<ReviewEntity> findByStoreIdAndStatus(Long storeId, ReviewStatus status, Pageable pageable);
/** /**
* 회원 ID와 상태로 리뷰 목록 조회 * 회원 ID와 상태로 리뷰 목록 조회
*/ */
Page<ReviewEntity> findByMemberIdAndStatus(Long memberId, ReviewStatus status, Pageable pageable); Page<ReviewEntity> findByMemberIdAndStatus(Long memberId, ReviewStatus status, Pageable pageable);
/** /**
* 리뷰 ID와 회원 ID로 리뷰 조회 * 리뷰 ID와 회원 ID로 리뷰 조회
*/ */
Optional<ReviewEntity> findByIdAndMemberId(Long id, Long memberId); Optional<ReviewEntity> findByIdAndMemberId(Long id, Long memberId);
/** /**
* 중복 리뷰 체크 (매장ID + 닉네임 + 내용으로 판단) * 중복 리뷰 체크 (매장ID + 닉네임 + 내용으로 판단)
*/ */
boolean existsByStoreIdAndMemberNicknameAndContent(Long storeId, String memberNickname, String content); boolean existsByStoreIdAndMemberNicknameAndContent(Long storeId, String memberNickname, String content);
/** /**
* 대안: 외부 닉네임으로만 중복 체크 ( 간단한 방법) * 대안: 외부 닉네임으로만 중복 체크 ( 간단한 방법)
*/ */
boolean existsByStoreIdAndExternalNickname(Long storeId, String externalNickname); boolean existsByStoreIdAndExternalNickname(Long storeId, String externalNickname);
} }