store update

This commit is contained in:
youbeen
2025-06-18 09:52:51 +09:00
parent 569404a73d
commit 96bbc3d83c
15 changed files with 1527 additions and 1523 deletions
@@ -1,33 +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();
}
}
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();
}
}
@@ -1,240 +1,240 @@
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.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Azure Event Hub 어댑터 클래스 (단순화)
* 외부 리뷰 이벤트 수신 및 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();
}
/**
* 외부 리뷰 이벤트 수신 처리
*/
private void listenToExternalReviewEvents() {
log.info("외부 리뷰 이벤트 수신 시작");
try {
while (isRunning) {
Iterable<PartitionEvent> events = externalReviewEventConsumer.receiveFromPartition(
"4", // 파티션 ID (0으로 수정)
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);
}
}
/**
* 외부 리뷰 이벤트 처리
*/
private void handleExternalReviewEvent(PartitionEvent partitionEvent) {
try {
EventData eventData = partitionEvent.getData();
String eventBody = eventData.getBodyAsString();
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={}", eventType, storeId);
if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) {
handleExternalReviewSyncEvent(storeId, event);
} else {
log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType);
}
} catch (Exception e) {
log.error("외부 리뷰 이벤트 처리 중 오류 발생", e);
}
}
/**
* 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리
*/
private void handleExternalReviewSyncEvent(Long storeId, Map<String, Object> event) {
try {
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 || reviews.isEmpty()) {
log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId);
return;
}
log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}",
platform, storeId, reviews.size());
int savedCount = 0;
for (Map<String, Object> reviewData : reviews) {
try {
Review savedReview = saveExternalReview(storeId, platform, reviewData);
if (savedReview != null) {
savedCount++;
}
} catch (Exception e) {
log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}",
platform, storeId, e.getMessage());
}
}
log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}",
platform, storeId, reviews.size(), savedCount);
} catch (Exception e) {
log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e);
}
}
/**
* 개별 외부 리뷰 저장 (단순화)
*/
private Review saveExternalReview(Long storeId, String platform, Map<String, Object> reviewData) {
try {
// ✅ 단순화된 매핑
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) // ✅ 고정값 0
.dislikeCount(0)
.build();
// Review 테이블에 저장
Review savedReview = reviewRepository.saveReview(review);
log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}",
savedReview.getId(), platform, storeId, savedReview.getMemberNickname());
return savedReview;
} catch (Exception e) {
log.error("외부 리뷰 저장 실패: platform={}, storeId={}, error={}",
platform, storeId, e.getMessage(), e);
return null;
}
}
/**
* 플랫폼별 회원 닉네임 생성 (카카오 API 필드명 수정)
*/
private String createMemberNickname(String platform, Map<String, Object> reviewData) {
String authorName = null;
// ✅ 카카오 API 구조에 맞춰 수정
if ("KAKAO".equalsIgnoreCase(platform)) {
authorName = (String) reviewData.get("reviewer_name");
} else {
// 다른 플랫폼 대비
authorName = (String) reviewData.get("author_name");
if (authorName == null) {
authorName = (String) reviewData.get("authorName");
}
}
if (authorName == null || authorName.trim().isEmpty()) {
return platform.toUpperCase() + " 사용자";
}
return authorName + "(" + platform.toUpperCase() + ")";
}
/**
* 평점 추출 (기본값: 5)
*/
private Integer extractRating(Map<String, Object> 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<String, Object> reviewData) {
String content = (String) reviewData.get("content");
if (content == null || content.trim().isEmpty()) {
return "외부 플랫폼 리뷰";
}
// 내용이 너무 길면 자르기 (reviews 테이블 length 제한 대비)
if (content.length() > 1900) {
content = content.substring(0, 1900) + "...";
}
return content;
}
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.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Azure Event Hub 어댑터 클래스 (단순화)
* 외부 리뷰 이벤트 수신 및 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();
}
/**
* 외부 리뷰 이벤트 수신 처리
*/
private void listenToExternalReviewEvents() {
log.info("외부 리뷰 이벤트 수신 시작");
try {
while (isRunning) {
Iterable<PartitionEvent> events = externalReviewEventConsumer.receiveFromPartition(
"4", // 파티션 ID (0으로 수정)
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);
}
}
/**
* 외부 리뷰 이벤트 처리
*/
private void handleExternalReviewEvent(PartitionEvent partitionEvent) {
try {
EventData eventData = partitionEvent.getData();
String eventBody = eventData.getBodyAsString();
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={}", eventType, storeId);
if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) {
handleExternalReviewSyncEvent(storeId, event);
} else {
log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType);
}
} catch (Exception e) {
log.error("외부 리뷰 이벤트 처리 중 오류 발생", e);
}
}
/**
* 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리
*/
private void handleExternalReviewSyncEvent(Long storeId, Map<String, Object> event) {
try {
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 || reviews.isEmpty()) {
log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId);
return;
}
log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}",
platform, storeId, reviews.size());
int savedCount = 0;
for (Map<String, Object> reviewData : reviews) {
try {
Review savedReview = saveExternalReview(storeId, platform, reviewData);
if (savedReview != null) {
savedCount++;
}
} catch (Exception e) {
log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}",
platform, storeId, e.getMessage());
}
}
log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}",
platform, storeId, reviews.size(), savedCount);
} catch (Exception e) {
log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e);
}
}
/**
* 개별 외부 리뷰 저장 (단순화)
*/
private Review saveExternalReview(Long storeId, String platform, Map<String, Object> reviewData) {
try {
// ✅ 단순화된 매핑
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) // ✅ 고정값 0
.dislikeCount(0)
.build();
// Review 테이블에 저장
Review savedReview = reviewRepository.saveReview(review);
log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}",
savedReview.getId(), platform, storeId, savedReview.getMemberNickname());
return savedReview;
} catch (Exception e) {
log.error("외부 리뷰 저장 실패: platform={}, storeId={}, error={}",
platform, storeId, e.getMessage(), e);
return null;
}
}
/**
* 플랫폼별 회원 닉네임 생성 (카카오 API 필드명 수정)
*/
private String createMemberNickname(String platform, Map<String, Object> reviewData) {
String authorName = null;
// ✅ 카카오 API 구조에 맞춰 수정
if ("KAKAO".equalsIgnoreCase(platform)) {
authorName = (String) reviewData.get("reviewer_name");
} else {
// 다른 플랫폼 대비
authorName = (String) reviewData.get("author_name");
if (authorName == null) {
authorName = (String) reviewData.get("authorName");
}
}
if (authorName == null || authorName.trim().isEmpty()) {
return platform.toUpperCase() + " 사용자";
}
return authorName + "(" + platform.toUpperCase() + ")";
}
/**
* 평점 추출 (기본값: 5)
*/
private Integer extractRating(Map<String, Object> 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<String, Object> reviewData) {
String content = (String) reviewData.get("content");
if (content == null || content.trim().isEmpty()) {
return "외부 플랫폼 리뷰";
}
// 내용이 너무 길면 자르기 (reviews 테이블 length 제한 대비)
if (content.length() > 1900) {
content = content.substring(0, 1900) + "...";
}
return content;
}
}
@@ -1,74 +1,74 @@
package com.ktds.hi.review.infra.gateway.entity;
import com.ktds.hi.review.biz.domain.ReviewStatus;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.List;
/**
* 리뷰 엔티티 클래스
* 데이터베이스 reviews 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "reviews")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ReviewEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "member_id", nullable = true)
private Long memberId;
@Column(name = "member_nickname", nullable = false, length = 50)
private String memberNickname;
@Column(nullable = false)
private Integer rating;
@Column(nullable = false, length = 1000)
private String content;
@ElementCollection
@CollectionTable(name = "review_images",
joinColumns = @JoinColumn(name = "review_id"))
@Column(name = "image_url")
private List<String> imageUrls;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private ReviewStatus status = ReviewStatus.ACTIVE;
@Column(name = "like_count")
@Builder.Default
private Integer likeCount = 0;
@Column(name = "dislike_count")
@Builder.Default
private Integer dislikeCount = 0;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
package com.ktds.hi.review.infra.gateway.entity;
import com.ktds.hi.review.biz.domain.ReviewStatus;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.List;
/**
* 리뷰 엔티티 클래스
* 데이터베이스 reviews 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "reviews")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ReviewEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "member_id", nullable = true)
private Long memberId;
@Column(name = "member_nickname", nullable = false, length = 50)
private String memberNickname;
@Column(nullable = false)
private Integer rating;
@Column(nullable = false, length = 1000)
private String content;
@ElementCollection
@CollectionTable(name = "review_images",
joinColumns = @JoinColumn(name = "review_id"))
@Column(name = "image_url")
private List<String> imageUrls;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private ReviewStatus status = ReviewStatus.ACTIVE;
@Column(name = "like_count")
@Builder.Default
private Integer likeCount = 0;
@Column(name = "dislike_count")
@Builder.Default
private Integer dislikeCount = 0;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
+48 -48
View File
@@ -1,48 +1,48 @@
server:
port: ${REVIEW_SERVICE_PORT:8083}
spring:
application:
name: review-service
datasource:
url: ${REVIEW_DB_URL:jdbc:postgresql://20.214.91.15:5432/hiorder_review}
username: ${REVIEW_DB_USERNAME:hiorder_user}
password: ${REVIEW_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
servlet:
multipart:
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
max-file-size: 10485760 # 10MB
springdoc:
api-docs:
path: /docs/review/api-docs
swagger-ui:
enabled: true
path: /docs/review/swagger-ui.html
server:
port: ${REVIEW_SERVICE_PORT:8083}
spring:
application:
name: review-service
datasource:
url: ${REVIEW_DB_URL:jdbc:postgresql://20.214.91.15:5432/hiorder_review}
username: ${REVIEW_DB_USERNAME:hiorder_user}
password: ${REVIEW_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
servlet:
multipart:
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
max-file-size: 10485760 # 10MB
springdoc:
api-docs:
path: /docs/review/api-docs
swagger-ui:
enabled: true
path: /docs/review/swagger-ui.html