diff --git a/analytics/src/main/resources/application.yml b/analytics/src/main/resources/application.yml index dfdcce9..ae57912 100644 --- a/analytics/src/main/resources/application.yml +++ b/analytics/src/main/resources/application.yml @@ -24,6 +24,9 @@ spring: hibernate: format_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect + azure: + eventhub: + connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING} data: redis: diff --git a/review/src/main/java/com/ktds/hi/review/infra/config/EventHubConfig.java b/review/src/main/java/com/ktds/hi/review/infra/config/EventHubConfig.java new file mode 100644 index 0000000..8a7e072 --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/config/EventHubConfig.java @@ -0,0 +1,33 @@ +package com.ktds.hi.review.infra.config; + +import com.azure.messaging.eventhubs.EventHubClientBuilder; +import com.azure.messaging.eventhubs.EventHubConsumerClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Azure Event Hub 설정 클래스 (단일 EntityPath 포함된 connection string 사용) + */ +@Slf4j +@Configuration +public class EventHubConfig { + + @Value("${azure.eventhub.connection-string}") + private String connectionString; + + @Value("${azure.eventhub.consumer-group:$Default}") + private String consumerGroup; + + /** + * 외부 리뷰 이벤트 수신용 Consumer + */ + @Bean("externalReviewEventConsumer") + public EventHubConsumerClient externalReviewEventConsumer() { + return new EventHubClientBuilder() + .connectionString(connectionString) + .consumerGroup(consumerGroup) + .buildConsumerClient(); + } +} 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 new file mode 100644 index 0000000..dc5109a --- /dev/null +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java @@ -0,0 +1,184 @@ +package com.ktds.hi.review.infra.gateway; + +import com.azure.messaging.eventhubs.EventData; +import com.azure.messaging.eventhubs.EventHubConsumerClient; +import com.azure.messaging.eventhubs.models.EventPosition; +import com.azure.messaging.eventhubs.models.PartitionEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktds.hi.review.biz.domain.Review; +import com.ktds.hi.review.biz.domain.ReviewStatus; +import com.ktds.hi.review.biz.usecase.out.ReviewRepository; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Azure Event Hub 어댑터 클래스 (Analytics EventHubAdapter 참고) + * 외부 리뷰 이벤트 수신 및 Review 테이블 저장 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExternalReviewEventHubAdapter { + + @Qualifier("externalReviewEventConsumer") + private final EventHubConsumerClient externalReviewEventConsumer; + + private final ObjectMapper objectMapper; + private final ReviewRepository reviewRepository; + + private final ExecutorService executorService = Executors.newFixedThreadPool(3); + private volatile boolean isRunning = false; + + @PostConstruct + public void startEventListening() { + log.info("외부 리뷰 Event Hub 리스너 시작"); + isRunning = true; + + // 외부 리뷰 이벤트 수신 시작 + executorService.submit(this::listenToExternalReviewEvents); + } + + @PreDestroy + public void stopEventListening() { + log.info("외부 리뷰 Event Hub 리스너 종료"); + isRunning = false; + executorService.shutdown(); + externalReviewEventConsumer.close(); + } + + /** + * 외부 리뷰 이벤트 수신 처리 (Analytics의 listenToReviewEvents 참고) + */ + private void listenToExternalReviewEvents() { + log.info("외부 리뷰 이벤트 수신 시작"); + + try { + while (isRunning) { + Iterable events = externalReviewEventConsumer.receiveFromPartition( + "0", // 파티션 ID + 100, // 최대 이벤트 수 + EventPosition.earliest(), // 시작 위치 + Duration.ofSeconds(30) // 타임아웃 + ); + + for (PartitionEvent partitionEvent : events) { + handleExternalReviewEvent(partitionEvent); + } + + Thread.sleep(1000); + } + } catch (InterruptedException e) { + log.info("외부 리뷰 이벤트 수신 중단됨"); + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.error("외부 리뷰 이벤트 수신 중 오류 발생", e); + } + } + + /** + * 외부 리뷰 이벤트 처리 (Analytics의 handleReviewEvent 참고) + */ + 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_CREATED".equals(eventType)) { + handleExternalReviewCreatedEvent(storeId, event); + } else { + log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); + } + + } catch (Exception e) { + log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); + } + } + + /** + * 외부 리뷰 생성 이벤트 처리 - Review 테이블에 저장 + */ + private void handleExternalReviewCreatedEvent(Long storeId, Map event) { + try { + String platform = (String) event.get("platform"); + + @SuppressWarnings("unchecked") + Map reviewData = (Map) event.get("reviewData"); + + if (reviewData == null) { + log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId); + return; + } + + // Review 도메인 객체 생성 + Review review = Review.builder() + .storeId(storeId) + .memberId(null) // 외부 리뷰는 회원 ID 없음 + .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.info("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}", + savedReview.getId(), platform, storeId); + + } catch (Exception e) { + log.error("외부 리뷰 생성 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e); + } + } + + /** + * 플랫폼별 회원 닉네임 생성 + */ + private String createMemberNickname(String platform, Map reviewData) { + String authorName = (String) reviewData.get("authorName"); + + if (authorName == null || authorName.trim().isEmpty()) { + return platform + " 사용자"; + } + + return authorName + "(" + platform + ")"; + } + + /** + * 평점 추출 (기본값: 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"); + return content != null ? content : ""; + } +} \ No newline at end of file diff --git a/review/src/main/resources/application.yml b/review/src/main/resources/application.yml index 148f935..85c42fc 100644 --- a/review/src/main/resources/application.yml +++ b/review/src/main/resources/application.yml @@ -30,6 +30,11 @@ spring: max-file-size: ${MAX_FILE_SIZE:10MB} max-request-size: ${MAX_REQUEST_SIZE:50MB} + azure: + eventhub: + connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING} + consumer-group: $Default + file-storage: base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads} allowed-extensions: jpg,jpeg,png,gif,webp