Merge branch 'main' of https://github.com/dg04-hi/hi-backend into mn
# Conflicts: # build.gradle
This commit is contained in:
parent
7deb2ce39d
commit
6ff21dd8e0
@ -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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user