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

View File

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

View File

@ -110,11 +110,15 @@ public class AnalyticsController {
@GetMapping("/stores/{storeId}/review-analysis")
public ResponseEntity<SuccessResponse<ReviewAnalysisResponse>> getReviewAnalysis(
@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);
ReviewAnalysisResponse response = analyticsUseCase.getReviewAnalysis(storeId);
ReviewAnalysisResponse response = analyticsUseCase.getReviewAnalysis(storeId, days);
return ResponseEntity.ok(SuccessResponse.of(response, "리뷰 분석 조회 성공"));
}

View File

@ -188,7 +188,7 @@ public class AIServiceAdapter implements AIServicePort {
분석 다음 사항을 고려해주세요:
1. 긍정적 요소는 고객들이 자주 언급하는 좋은 점들
2. 부정적 요소는 고객들이 자주 언급하는 안좋은 점들(없는 경우에는 없음으로 표시)
2. 부정적 요소는 고객들이 자주 언급하는 안좋은 점들
2. 개선점은 부정적 피드백이나 불만사항
3. 추천사항은 매장 운영에 도움이 구체적인 제안
4. 신뢰도 점수는 0.0-1.0 사이의

View File

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

View File

@ -80,7 +80,7 @@ public class ReviewInteractor implements CreateReviewUseCase, DeleteReviewUseCas
@Transactional(readOnly = true)
public List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size) {
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()
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE)

View File

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

View File

@ -22,7 +22,8 @@ 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 테이블 저장 (중복 방지)
@ -34,7 +35,8 @@ public class ExternalReviewEventHubAdapter {
@Qualifier("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 ReviewRepository reviewRepository;
@ -132,10 +134,21 @@ public class ExternalReviewEventHubAdapter {
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;
@ -152,8 +165,6 @@ public class ExternalReviewEventHubAdapter {
Review savedReview = saveExternalReview(storeId, platform, reviewData);
if (savedReview != null) {
savedCount++;
} else {
duplicateCount++;
}
} catch (Exception e) {
log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}",
@ -161,8 +172,8 @@ public class ExternalReviewEventHubAdapter {
}
}
log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, total={}, saved={}, duplicate={}",
platform, storeId, reviews.size(), savedCount, duplicateCount);
log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}",
platform, storeId, reviews.size(), savedCount);
} catch (Exception e) {
log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e);
@ -190,16 +201,17 @@ public class ExternalReviewEventHubAdapter {
// 3. 새로운 리뷰 저장
Review review = Review.builder()
.storeId(storeId)
.memberId(null) // 외부 리뷰는 회원 ID 없음
.memberNickname(externalNickname)
.memberId(-1L)
.memberNickname(createMemberNickname(platform, reviewData))
.rating(extractRating(reviewData))
.content(content)
.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={}",
@ -266,4 +278,68 @@ public class ExternalReviewEventHubAdapter {
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

@ -41,6 +41,13 @@ public class ReviewRepositoryAdapter implements ReviewRepository {
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
public Page<Review> findReviewsByMemberId(Long memberId, Pageable pageable) {
Page<ReviewEntity> entities = reviewJpaRepository.findByMemberIdAndStatus(memberId, ReviewStatus.ACTIVE, pageable);