# Conflicts:
#	review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java
This commit is contained in:
UNGGU0704 2025-06-18 10:54:35 +09:00
commit 7deb2ce39d
9 changed files with 636 additions and 540 deletions

View File

@ -247,7 +247,7 @@ public class AnalyticsService implements AnalyticsUseCase {
} }
@Override @Override
public ReviewAnalysisResponse getReviewAnalysis(Long storeId) { public ReviewAnalysisResponse getReviewAnalysis(Long storeId, int days) {
log.info("리뷰 분석 조회 시작: storeId={}", storeId); log.info("리뷰 분석 조회 시작: storeId={}", storeId);
try { try {
@ -522,8 +522,6 @@ public class AnalyticsService implements AnalyticsUseCase {
// 1. 리뷰 데이터 수집 // 1. 리뷰 데이터 수집
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, days); List<String> reviewData = externalReviewPort.getRecentReviews(storeId, days);
log.info("review Data check ===> {}", reviewData);
if (reviewData.isEmpty()) { if (reviewData.isEmpty()) {
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId); log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
return createDefaultAIFeedback(storeId); return createDefaultAIFeedback(storeId);
@ -533,6 +531,7 @@ public class AnalyticsService implements AnalyticsUseCase {
AiFeedback aiFeedback = aiServicePort.generateFeedback(reviewData); AiFeedback aiFeedback = aiServicePort.generateFeedback(reviewData);
// 3. 도메인 객체 속성 설정 // 3. 도메인 객체 속성 설정
AiFeedback completeAiFeedback = AiFeedback.builder() AiFeedback completeAiFeedback = AiFeedback.builder()
.storeId(storeId) .storeId(storeId)

View File

@ -34,7 +34,7 @@ public interface AnalyticsUseCase {
/** /**
* 리뷰 분석 조회 * 리뷰 분석 조회
*/ */
ReviewAnalysisResponse getReviewAnalysis(Long storeId); ReviewAnalysisResponse getReviewAnalysis(Long storeId, int days);
/** /**
* AI 리뷰 분석 실행계획 생성 * AI 리뷰 분석 실행계획 생성

View File

@ -110,11 +110,15 @@ public class AnalyticsController {
@GetMapping("/stores/{storeId}/review-analysis") @GetMapping("/stores/{storeId}/review-analysis")
public ResponseEntity<SuccessResponse<ReviewAnalysisResponse>> getReviewAnalysis( public ResponseEntity<SuccessResponse<ReviewAnalysisResponse>> getReviewAnalysis(
@Parameter(description = "매장 ID", required = true) @Parameter(description = "매장 ID", required = true)
@PathVariable @NotNull Long storeId) { @PathVariable @NotNull Long storeId,
@Parameter(description = "분석할 최근 일수", required = true)
@RequestParam(name = "days") int days
) {
log.info("리뷰 분석 조회 요청: storeId={}", storeId); log.info("리뷰 분석 조회 요청: storeId={}", storeId);
ReviewAnalysisResponse response = analyticsUseCase.getReviewAnalysis(storeId); ReviewAnalysisResponse response = analyticsUseCase.getReviewAnalysis(storeId, days);
return ResponseEntity.ok(SuccessResponse.of(response, "리뷰 분석 조회 성공")); return ResponseEntity.ok(SuccessResponse.of(response, "리뷰 분석 조회 성공"));
} }

View File

@ -88,6 +88,10 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
.filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate)) .filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate))
.map(ReviewListResponse::getContent) .map(ReviewListResponse::getContent)
.filter(content -> content != null && !content.trim().isEmpty()) .filter(content -> content != null && !content.trim().isEmpty())
.map(content -> content.replace("`", "")
.replace("\n", "")
.replace("\\", "")
.replace("\"", ""))
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

@ -80,7 +80,7 @@ public class ReviewInteractor implements CreateReviewUseCase, DeleteReviewUseCas
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size) { public List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size) {
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20); Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
Page<Review> reviews = reviewRepository.findReviewsByStoreId(storeId, pageable); Page<Review> reviews = reviewRepository.findReviewsByStoreIdOrderByCreatedAtDesc(storeId, pageable);
return reviews.stream() return reviews.stream()
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE) .filter(review -> review.getStatus() == ReviewStatus.ACTIVE)

View File

@ -27,7 +27,13 @@ public interface ReviewRepository {
* 매장 ID로 리뷰 목록 조회 * 매장 ID로 리뷰 목록 조회
*/ */
Page<Review> findReviewsByStoreId(Long storeId, Pageable pageable); Page<Review> findReviewsByStoreId(Long storeId, Pageable pageable);
/**
* 매장 ID로 리뷰 목록 조회
*/
Page<Review> findReviewsByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable);
/** /**
* 회원 ID로 리뷰 목록 조회 * 회원 ID로 리뷰 목록 조회
*/ */

View File

@ -22,7 +22,8 @@ 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.Set;
/** /**
* Azure Event Hub 어댑터 클래스 - 수정된 버전 * Azure Event Hub 어댑터 클래스 - 수정된 버전
* 외부 리뷰 이벤트 수신 Review 테이블 저장 (중복 방지) * 외부 리뷰 이벤트 수신 Review 테이블 저장 (중복 방지)
@ -34,7 +35,8 @@ 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 ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ReviewRepository reviewRepository; private final ReviewRepository reviewRepository;
@ -132,10 +134,21 @@ public class ExternalReviewEventHubAdapter {
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) {
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()) { if (reviews == null || reviews.isEmpty()) {
log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId); log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId);
return; return;
@ -152,8 +165,6 @@ public class ExternalReviewEventHubAdapter {
Review savedReview = saveExternalReview(storeId, platform, reviewData); Review savedReview = saveExternalReview(storeId, platform, reviewData);
if (savedReview != null) { if (savedReview != null) {
savedCount++; savedCount++;
} else {
duplicateCount++;
} }
} catch (Exception e) { } catch (Exception e) {
log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}", log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}",
@ -161,8 +172,8 @@ public class ExternalReviewEventHubAdapter {
} }
} }
log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, total={}, saved={}, duplicate={}", log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}",
platform, storeId, reviews.size(), savedCount, duplicateCount); 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);
@ -190,16 +201,17 @@ public class ExternalReviewEventHubAdapter {
// 3. 새로운 리뷰 저장 // 3. 새로운 리뷰 저장
Review review = Review.builder() Review review = Review.builder()
.storeId(storeId) .storeId(storeId)
.memberId(null) // 외부 리뷰는 회원 ID 없음 .memberId(-1L)
.memberNickname(externalNickname) .memberNickname(createMemberNickname(platform, reviewData))
.rating(extractRating(reviewData)) .rating(extractRating(reviewData))
.content(content) .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 savedReview = reviewRepository.saveReview(review); Review savedReview = reviewRepository.saveReview(review);
log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}", log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}",
@ -266,4 +278,68 @@ public class ExternalReviewEventHubAdapter {
return content; 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<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={}, 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<String> recentIds = new HashSet<>();
processedEventIds.stream()
.skip(500)
.forEach(recentIds::add);
processedEventIds.clear();
processedEventIds.addAll(recentIds);
log.info("처리된 이벤트 ID 캐시 정리 완료: 현재 크기={}", processedEventIds.size());
}
}
} }

View File

@ -40,6 +40,13 @@ public class ReviewRepositoryAdapter implements ReviewRepository {
Page<ReviewEntity> entities = reviewJpaRepository.findByStoreIdAndStatus(storeId, ReviewStatus.ACTIVE, pageable); Page<ReviewEntity> entities = reviewJpaRepository.findByStoreIdAndStatus(storeId, ReviewStatus.ACTIVE, pageable);
return entities.map(this::toDomain); return entities.map(this::toDomain);
} }
@Override
public Page<Review> findReviewsByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
Page<ReviewEntity> entities = reviewJpaRepository.findByStoreIdAndStatus(storeId, ReviewStatus.ACTIVE,
pageable);
return entities.map(this::toDomain);
}
@Override @Override
public Page<Review> findReviewsByMemberId(Long memberId, Pageable pageable) { public Page<Review> findReviewsByMemberId(Long memberId, Pageable pageable) {