From 96d2a87b8f9f60abebfae76db0a765f201109f5e Mon Sep 17 00:00:00 2001 From: sieun0322 Date: Mon, 29 Dec 2025 00:31:33 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20outbox=20event=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/BusinessActionEvent.java | 2 +- .../application/event/CouponEventHandler.java | 11 +- .../application/event/CouponUsedEvent.java | 2 +- .../event/DataPlatformEventHandler.java | 43 ------- .../event/DataTransferEventHandler.java | 108 ++++++++++++++++++ .../event/OrderCancelledEvent.java | 4 +- .../application/event/OrderCreatedEvent.java | 16 +-- .../event/OrderDataTransferEvent.java | 5 +- .../application/event/OrderEventHandler.java | 65 ++++------- .../application/event/OrderPaidEvent.java | 10 ++ .../application/event/OutboxEventHandler.java | 70 ++++++++++++ .../event/PaymentCallbackEvent.java | 2 +- .../event/PaymentDataTransferEvent.java | 5 +- .../event/PaymentEventHandler.java | 72 ++---------- .../event/PaymentFailureEvent.java | 11 ++ .../event/PaymentSuccessEvent.java | 12 ++ .../loopers/application/like/LikeFacade.java | 24 +--- .../application/order/OrderFacade.java | 21 +--- .../payment/CreatePaymentCommand.java | 3 +- .../application/payment/PaymentFacade.java | 8 +- .../application/payment/PaymentInfo.java | 2 +- .../loopers/application/payment/PgClient.java | 2 +- .../application/payment/PgPayRequest.java | 4 +- .../payment/PgPaymentInfoResponse.java | 2 +- .../point/EarnPointFromPaymentCommand.java | 4 +- .../application/product/ProductFacade.java | 9 +- .../java/com/loopers/domain/order/Order.java | 10 ++ .../loopers/domain/order/OrderRepository.java | 2 + .../loopers/domain/order/OrderService.java | 43 ++++--- .../com/loopers/domain/payment/Payment.java | 6 +- .../domain/payment/PaymentRepository.java | 4 +- .../domain/payment/PaymentService.java | 34 +++++- .../infrastructure/feign/PgClientImpl.java | 4 +- .../order/OrderJpaRepository.java | 3 + .../order/OrderRepositoryImpl.java | 5 + .../payment/PaymentJpaRepository.java | 4 +- .../payment/PaymentRepositoryImpl.java | 4 +- .../api/client/PaymentCallbackV1Dto.java | 4 +- .../api/client/PaymentCreateV1Dto.java | 2 +- .../interfaces/api/order/OrderV1ApiSpec.java | 2 +- .../api/order/OrderV1Controller.java | 2 +- .../order/OrderFacadeIntegrationTest.java | 6 +- .../order/OrderServiceIntegrationTest.java | 4 +- .../batch/OrderEventProcessor.java | 2 +- 44 files changed, 382 insertions(+), 276 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/DataPlatformEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/DataTransferEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/OrderPaidEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/OutboxEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/PaymentFailureEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/BusinessActionEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/BusinessActionEvent.java index 94ae8c459..7abc1bed0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/BusinessActionEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/BusinessActionEvent.java @@ -23,7 +23,7 @@ public enum BusinessAction { LOYALTY_MILESTONE } - public static BusinessActionEvent couponUsed(Long userId, Long couponId, Long orderId, + public static BusinessActionEvent couponUsed(Long userId, Long couponId, String orderId, BigDecimal originalAmount, BigDecimal discountAmount) { return new BusinessActionEvent( userId, BusinessAction.COUPON_USED, couponId, "coupon", diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/CouponEventHandler.java index ac14239ff..6a0ff298f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/CouponEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/CouponEventHandler.java @@ -25,6 +25,7 @@ public class CouponEventHandler { private final OrderService orderService; private final ApplicationEventPublisher eventPublisher; + @TransactionalEventListener(phase = AFTER_COMMIT) @Async public void handleCouponUsed(CouponUsedEvent event) { @@ -37,16 +38,6 @@ public void handleCouponUsed(CouponUsedEvent event) { pgClient.requestPayment(event.orderId(), event.cardType(), event.cardNo(), finalPrice); - // 데이터 플랫폼으로 주문 생성 이벤트 전송 - Order order = orderService.getOrder(event.orderId()); - eventPublisher.publishEvent(new OrderDataTransferEvent( - event.orderId(), - event.userId(), - order.getStatus(), - finalPrice.getAmount(), - LocalDateTime.now(), - "ORDER_CREATED" - )); log.info("쿠폰 사용 및 결제 처리 완료 - orderId: {}, userId: {}", event.orderId(), event.userId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/CouponUsedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/CouponUsedEvent.java index 233e87a54..3adb1b092 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/CouponUsedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/CouponUsedEvent.java @@ -4,7 +4,7 @@ import com.loopers.domain.payment.CardType; public record CouponUsedEvent( - Long orderId, + String orderId, Long userId, Long couponIssueId, Money totalPrice, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/DataPlatformEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/DataPlatformEventHandler.java deleted file mode 100644 index 91fbf436c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/DataPlatformEventHandler.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.loopers.application.event; - -import com.loopers.application.dataplatform.DataPlatformService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; - -@Slf4j -@RequiredArgsConstructor -@Component -public class DataPlatformEventHandler { - private final DataPlatformService dataPlatformService; - - @EventListener - @Async - public void handleOrderDataTransfer(OrderDataTransferEvent event) { - log.debug("주문 데이터 플랫폼 전송 이벤트 처리 - 주문ID: {}, 이벤트타입: {}", - event.orderId(), event.eventType()); - - try { - dataPlatformService.sendOrderData(event); - } catch (Exception e) { - log.error("주문 데이터 플랫폼 전송 실패 - 주문ID: {}", event.orderId(), e); - // TODO: 실패 시 재시도 로직 또는 DLQ 처리 - } - } - - @EventListener - @Async - public void handlePaymentDataTransfer(PaymentDataTransferEvent event) { - log.debug("결제 데이터 플랫폼 전송 이벤트 처리 - 주문ID: {}, 이벤트타입: {}", - event.orderId(), event.eventType()); - - try { - dataPlatformService.sendPaymentData(event); - } catch (Exception e) { - log.error("결제 데이터 플랫폼 전송 실패 - 주문ID: {}", event.orderId(), e); - // TODO: 실패 시 재시도 로직 또는 DLQ 처리 - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/DataTransferEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/DataTransferEventHandler.java new file mode 100644 index 000000000..83eeaab0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/DataTransferEventHandler.java @@ -0,0 +1,108 @@ +package com.loopers.application.event; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.event.TransactionPhase; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor +@Component +public class DataTransferEventHandler { + private final OrderService orderService; + private final PaymentService paymentService; + private final ApplicationEventPublisher eventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentSuccess(PaymentSuccessEvent event) { + log.debug("Handling PaymentSuccessEvent for data transfer: orderId={}", event.orderId()); + + Payment payment = paymentService.findPaymentByOrderId(event.orderId()); + + // 결제 성공 데이터 전송 + eventPublisher.publishEvent(new PaymentDataTransferEvent( + event.orderId(), + event.userId(), + event.amount(), + payment.getCardType(), + payment.getStatus(), + com.loopers.application.payment.TransactionStatus.SUCCESS, + event.reason(), + LocalDateTime.now(), + "PAYMENT_SUCCESS" + )); + + // 주문 결제 완료 데이터 전송 (이벤트 데이터 활용) + eventPublisher.publishEvent(new OrderDataTransferEvent( + event.orderId(), + event.userId(), + null, // order.getStatus() 대신 OrderCompletedEvent에서 처리 + event.totalOrderAmount(), + LocalDateTime.now(), + "ORDER_PAID" + )); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentFailure(PaymentFailureEvent event) { + log.debug("Handling PaymentFailureEvent for data transfer: orderId={}", event.orderId()); + + Payment payment = paymentService.findPaymentByOrderId(event.orderId()); + + // 결제 실패 데이터 전송 (이벤트 데이터 활용) + eventPublisher.publishEvent(new PaymentDataTransferEvent( + event.orderId(), + event.userId(), + event.amount(), + payment.getCardType(), + payment.getStatus(), + com.loopers.application.payment.TransactionStatus.FAILED, + event.reason(), + LocalDateTime.now(), + "PAYMENT_FAILED" + )); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderCreatedEvent event) { + log.debug("Handling OrderCreatedEvent for data transfer: orderId={}", event.orderId()); + + Order order = orderService.getOrder(event.orderId()); + + // 주문 생성 데이터 전송 + eventPublisher.publishEvent(new OrderDataTransferEvent( + event.orderId(), + event.userId(), + order.getStatus(), + order.getTotalPrice(), + LocalDateTime.now(), + "ORDER_CREATED" + )); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCancelled(OrderCancelledEvent event) { + log.debug("Handling OrderCancelledEvent for data transfer: orderId={}", event.orderId()); + + Order order = orderService.getOrder(event.orderId()); + + // 주문 취소 데이터 전송 + eventPublisher.publishEvent(new OrderDataTransferEvent( + event.orderId(), + event.userId(), + order.getStatus(), + order.getTotalPrice(), + LocalDateTime.now(), + "ORDER_CANCELLED" + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCancelledEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCancelledEvent.java index 9b93ad302..f11eaa8ac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCancelledEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCancelledEvent.java @@ -1,7 +1,7 @@ package com.loopers.application.event; public record OrderCancelledEvent( - Long orderId, + String orderId, Long userId, String reason -) {} \ No newline at end of file +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCreatedEvent.java index 549e360de..bd3478f00 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCreatedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCreatedEvent.java @@ -1,20 +1,22 @@ package com.loopers.application.event; import com.loopers.domain.payment.CardType; + import java.util.List; public record OrderCreatedEvent( - Long orderId, + String orderId, Long userId, Long couponIssueId, CardType cardType, String cardNo, List orderItems ) { - - public record OrderItemData( - Long productId, - Long quantity, - Long unitPrice - ) {} + + public record OrderItemData( + Long productId, + Long quantity, + Long unitPrice + ) { + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderDataTransferEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderDataTransferEvent.java index a0c7494e7..3558b3e82 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderDataTransferEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderDataTransferEvent.java @@ -1,15 +1,16 @@ package com.loopers.application.event; +import com.loopers.domain.order.Money; import com.loopers.domain.order.OrderStatus; import java.math.BigDecimal; import java.time.LocalDateTime; public record OrderDataTransferEvent( - Long orderId, + String orderId, Long userId, OrderStatus status, - BigDecimal totalAmount, + Money totalAmount, LocalDateTime completedAt, String eventType // "ORDER_CREATED", "ORDER_PAID", "ORDER_CANCELLED" ) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventHandler.java index e25d5b654..e0314a865 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventHandler.java @@ -7,6 +7,7 @@ import com.loopers.domain.order.OrderService; import com.loopers.domain.stock.StockService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -16,6 +17,7 @@ import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; +@Slf4j @RequiredArgsConstructor @Component public class OrderEventHandler { @@ -28,43 +30,33 @@ public class OrderEventHandler { @TransactionalEventListener(phase = AFTER_COMMIT) @Async public void handleOrderCreated(OrderCreatedEvent event) { - Order order = orderService.getOrder(event.orderId()); + log.debug("주문 처리 시작 - orderId: {}, couponIssueId: {}", + event.orderId(), event.couponIssueId()); - if (event.couponIssueId() != null) { - handleOrderWithCoupon(event, order); - } else { - handleOrderWithoutCoupon(event, order); - } - } + try { + Order order = orderService.getOrder(event.orderId()); + Money finalPrice = order.getTotalPrice(); - private void handleOrderWithCoupon(OrderCreatedEvent event, Order order) { - // 쿠폰 사용 이벤트 발행 (별도 트랜잭션에서 처리) - eventPublisher.publishEvent(new CouponUsedEvent( - event.orderId(), - event.userId(), - event.couponIssueId(), - order.getTotalPrice(), - event.cardType(), - event.cardNo() - )); - } + // 쿠폰 처리 (있는 경우만) + if (event.couponIssueId() != null) { + log.debug("쿠폰 사용 처리 - couponIssueId: {}", event.couponIssueId()); + finalPrice = couponService.useCouponById( + event.couponIssueId(), + event.userId(), + order.getTotalPrice() + ); + } - private void handleOrderWithoutCoupon(OrderCreatedEvent event, Order order) { - try { - // 쿠폰 없이 바로 결제 처리 - pgClient.requestPayment(event.orderId(), event.cardType(), event.cardNo(), order.getTotalPrice()); + // 결제 요청 + pgClient.requestPayment(order.getOrderId(), event.cardType(), event.cardNo(), finalPrice); + + + log.info("주문 처리 완료 - orderId: {}, userId: {}, finalPrice: {}", + event.orderId(), event.userId(), finalPrice.getAmount()); - // 데이터 플랫폼으로 주문 생성 이벤트 전송 - eventPublisher.publishEvent(new OrderDataTransferEvent( - event.orderId(), - event.userId(), - order.getStatus(), - order.getTotalPrice().getAmount(), - LocalDateTime.now(), - "ORDER_CREATED" - )); } catch (Exception e) { - // 결제 요청 실패시 주문 취소 처리 + log.error("주문 처리 실패 - orderId: {}, userId: {}, error: {}", + event.orderId(), event.userId(), e.getMessage()); orderService.cancelPayment(event.orderId()); } } @@ -83,14 +75,5 @@ public void handleOrderCancelled(OrderCancelledEvent event) { couponService.rollbackCouponUsage(order.getRefCouponIssueId()); } - // 데이터 플랫폼으로 주문 취소 이벤트 전송 - eventPublisher.publishEvent(new OrderDataTransferEvent( - event.orderId(), - event.userId(), - order.getStatus(), - order.getTotalPrice().getAmount(), - LocalDateTime.now(), - "ORDER_CANCELLED" - )); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderPaidEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderPaidEvent.java new file mode 100644 index 000000000..1ff5da66e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderPaidEvent.java @@ -0,0 +1,10 @@ +package com.loopers.application.event; + +import com.loopers.domain.order.Money; + +public record OrderPaidEvent( + String orderId, + Long userId, + Money totalAmount +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxEventHandler.java new file mode 100644 index 000000000..fab40d892 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxEventHandler.java @@ -0,0 +1,70 @@ +package com.loopers.application.event; + +import com.loopers.domain.outbox.OutboxService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.event.TransactionPhase; + +@Slf4j +@RequiredArgsConstructor +@Component +public class OutboxEventHandler { + private final OutboxService outboxService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductViewedEvent(ProductViewedEvent event) { + log.debug("Handling ProductViewedEvent for outbox: userId={}, productId={}", + event.userId(), event.productId()); + + outboxService.saveEvent( + "Product", + String.valueOf(event.productId()), + "ProductViewed", + event + ); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleLikeEvent(LikeEvent event) { + log.debug("Handling LikeEvent for outbox: userId={}, productId={}, action={}", + event.userId(), event.productId(), event.action()); + + String eventType = event.action() == LikeEvent.LikeAction.LIKE ? "ProductLiked" : "ProductUnliked"; + + outboxService.saveEvent( + "Product", + event.productId().toString(), + eventType, + event + ); + } + + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderPaidEvent(OrderPaidEvent event) { + log.debug("Handling OrderPaidEvent for outbox: orderId={}, userId={}", + event.orderId(), event.userId()); + + outboxService.saveEvent( + "Order", + event.orderId().toString(), + "OrderPaid", + event + ); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCancelledEvent(OrderCancelledEvent event) { + log.debug("Handling OrderCancelledEvent for outbox: orderId={}, userId={}, reason={}", + event.orderId(), event.userId(), event.reason()); + + outboxService.saveEvent( + "Order", + event.orderId().toString(), + "OrderCancelled", + event + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCallbackEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCallbackEvent.java index de1b300ab..a3edabb80 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCallbackEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCallbackEvent.java @@ -3,7 +3,7 @@ import com.loopers.application.payment.TransactionStatus; public record PaymentCallbackEvent( - Long orderId, + String orderId, Long amount, TransactionStatus status, String reason diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentDataTransferEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentDataTransferEvent.java index 4ce96c1a2..7cc8a27e6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentDataTransferEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentDataTransferEvent.java @@ -1,6 +1,7 @@ package com.loopers.application.event; import com.loopers.application.payment.TransactionStatus; +import com.loopers.domain.order.Money; import com.loopers.domain.payment.CardType; import com.loopers.domain.payment.PaymentStatus; @@ -8,9 +9,9 @@ import java.time.LocalDateTime; public record PaymentDataTransferEvent( - Long orderId, + String orderId, Long userId, - BigDecimal amount, + Money amount, CardType cardType, PaymentStatus paymentStatus, TransactionStatus transactionStatus, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventHandler.java index 44739387a..f65117725 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventHandler.java @@ -25,76 +25,26 @@ public class PaymentEventHandler { private final OrderService orderService; private final PointService pointService; - private final PaymentService paymentService; - private final StockService stockService; - private final CouponService couponService; - private final ApplicationEventPublisher eventPublisher; + @EventListener @Transactional - public void handlePaymentCallback(PaymentCallbackEvent event) { - log.info("결제 콜백 처리 - 주문ID: {}, 상태: {}, 사유: {}", - event.orderId(), event.status(), event.reason()); - - if (event.status() == TransactionStatus.SUCCESS) { - handlePaymentSuccess(event); - } else { - handlePaymentFailure(event); - } - } - - private void handlePaymentSuccess(PaymentCallbackEvent event) { - log.info("결제 성공 처리 - 주문ID: {}", event.orderId()); - Order order = orderService.getOrder(event.orderId()); - + public void handlePaymentSuccess(PaymentSuccessEvent event) { + log.info("결제 성공 처리 - 주문ID: {}, 사용자ID: {}", event.orderId(), event.userId()); + //포인트 적립 - pointService.earnFromPayment(event.orderId(), Money.wons(event.amount())); + pointService.earnFromPayment(event.userId(), event.amount()); - //결재 완료 + //결제 완료 orderService.completePayment(event.orderId()); - - Payment payment = paymentService.findPaymentByOrderId(event.orderId()); - - eventPublisher.publishEvent(new PaymentDataTransferEvent( - event.orderId(), - order.getRefUserId(), - BigDecimal.valueOf(event.amount()), - payment.getCardType(), - payment.getStatus(), - event.status(), - event.reason(), - LocalDateTime.now(), - "PAYMENT_SUCCESS" - )); - - eventPublisher.publishEvent(new OrderDataTransferEvent( - event.orderId(), - order.getRefUserId(), - order.getStatus(), - order.getTotalPrice().getAmount(), - LocalDateTime.now(), - "ORDER_PAID" - )); } - private void handlePaymentFailure(PaymentCallbackEvent event) { - log.info("결제 실패 처리 - 주문ID: {}, 사유: {}", event.orderId(), event.reason()); + @EventListener + @Transactional + public void handlePaymentFailure(PaymentFailureEvent event) { + log.info("결제 실패 처리 - 주문ID: {}, 사용자ID: {}, 사유: {}", + event.orderId(), event.userId(), event.reason()); - Order order = orderService.getOrder(event.orderId()); orderService.cancelPayment(event.orderId()); - - Payment payment = paymentService.findPaymentByOrderId(event.orderId()); - eventPublisher.publishEvent(new PaymentDataTransferEvent( - event.orderId(), - order.getRefUserId(), - BigDecimal.valueOf(event.amount()), - payment.getCardType(), - payment.getStatus(), - event.status(), - event.reason(), - LocalDateTime.now(), - "PAYMENT_FAILED" - )); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentFailureEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentFailureEvent.java new file mode 100644 index 000000000..02c74131b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentFailureEvent.java @@ -0,0 +1,11 @@ +package com.loopers.application.event; + +import com.loopers.domain.order.Money; + +public record PaymentFailureEvent( + String orderId, + Long userId, + Money amount, + String reason +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java new file mode 100644 index 000000000..3eea22b55 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java @@ -0,0 +1,12 @@ +package com.loopers.application.event; + +import com.loopers.domain.order.Money; + +public record PaymentSuccessEvent( + String orderId, + Long userId, + Money amount, + String reason, + Money totalOrderAmount +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index cb5b945ad..f322ce126 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -2,9 +2,6 @@ import com.loopers.domain.like.LikeService; import com.loopers.application.event.LikeEvent; -import com.loopers.domain.outbox.OutboxEvent; -import com.loopers.domain.outbox.OutboxService; -import com.loopers.domain.outbox.OutboxSavedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -17,7 +14,6 @@ public class LikeFacade { private final LikeService likeService; private final LikeCacheRepository likeCacheRepository; private final ApplicationEventPublisher eventPublisher; - private final OutboxService outboxService; public LikeInfo like(Long userId, Long productId) { Boolean userLiked = likeCacheRepository.getUserLiked(userId, productId); @@ -30,16 +26,8 @@ public LikeInfo like(Long userId, Long productId) { Long newCount = likeCacheRepository.addLikeCount(productId); likeService.save(userId, productId); - // Outbox 이벤트 저장 (배치 처리용) + // 좋아요 이벤트 발행 (배치 처리용) LikeEvent likeEvent = new LikeEvent(userId, productId, LikeEvent.LikeAction.LIKE); - outboxService.saveEvent( - "Product", - productId.toString(), - "ProductLiked", - likeEvent - ); - - // 기존 동기 이벤트도 유지 (내부 처리용) eventPublisher.publishEvent(likeEvent); log.debug("좋아요 이벤트 발행 - 사용자ID: {}, 상품ID: {}", userId, productId); @@ -54,16 +42,8 @@ public LikeInfo unlike(Long userId, Long productId) { likeService.remove(userId, productId); - // Outbox 이벤트 저장 (배치 처리용) + // 좋아요 취소 이벤트 발행 (배치 처리용) LikeEvent unlikeEvent = new LikeEvent(userId, productId, LikeEvent.LikeAction.UNLIKE); - outboxService.saveEvent( - "Product", - productId.toString(), - "ProductUnliked", - unlikeEvent - ); - - // 기존 동기 이벤트도 유지 (내부 처리용) eventPublisher.publishEvent(unlikeEvent); log.debug("좋아요 취소 이벤트 발행 - 사용자ID: {}, 상품ID: {}", userId, productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 4dc3ceaa7..cc02b0414 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -7,9 +7,6 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.stock.StockService; import com.loopers.application.event.OrderCreatedEvent; -import com.loopers.domain.outbox.OutboxEvent; -import com.loopers.domain.outbox.OutboxService; -import com.loopers.domain.outbox.OutboxSavedEvent; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -26,7 +23,6 @@ public class OrderFacade { private final ProductService productService; private final StockService stockService; private final OrderService orderService; - private final OutboxService outboxService; private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) @@ -38,7 +34,7 @@ public Page getOrderList(Long userId, } @Transactional(readOnly = true) - public OrderInfo getOrderDetail(Long orderId) { + public OrderInfo getOrderDetail(String orderId) { Order order = orderService.getOrder(orderId); return OrderInfo.from(order); } @@ -55,7 +51,7 @@ public OrderInfo createOrder(CreateOrderCommand command) { Order order = Order.create(command.userId(), createOrderItems(command.orderItemRequests(), products), command.couponIssueId()); Order savedOrder = orderService.save(order); - // Outbox 패턴으로 이벤트 저장 + // 주문 생성 이벤트 발행 (배치 처리용) List orderItemsData = savedOrder.getOrderItems().stream() .map(item -> new OrderCreatedEvent.OrderItemData( item.getRefProductId(), @@ -65,7 +61,7 @@ public OrderInfo createOrder(CreateOrderCommand command) { .toList(); OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent( - savedOrder.getId(), + savedOrder.getOrderId(), command.userId(), command.couponIssueId(), command.cardType(), @@ -73,17 +69,6 @@ public OrderInfo createOrder(CreateOrderCommand command) { orderItemsData ); - OutboxEvent savedOutboxEvent = outboxService.saveEvent( - "Order", - savedOrder.getId().toString(), - "OrderCreated", - orderCreatedEvent - ); - - // 즉시 전송을 위한 이벤트 발행 (After Commit) - eventPublisher.publishEvent(new OutboxSavedEvent(savedOutboxEvent.getId())); - - // 기존 동기 이벤트도 유지 (내부 처리용) eventPublisher.publishEvent(orderCreatedEvent); return OrderInfo.from(savedOrder); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/CreatePaymentCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/CreatePaymentCommand.java index 78a7457d1..7a72c32ab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/CreatePaymentCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/CreatePaymentCommand.java @@ -3,7 +3,8 @@ import com.loopers.domain.payment.CardType; import com.loopers.interfaces.api.client.PaymentCreateV1Dto; -public record CreatePaymentCommand(Long userId, Long orderId, CardType cardType, String cardNo, Long amount, String callbackUrl) { +public record CreatePaymentCommand(Long userId, String orderId, CardType cardType, String cardNo, Long amount, + String callbackUrl) { public static CreatePaymentCommand from(Long userId, PaymentCreateV1Dto.PaymentRequest request) { return new CreatePaymentCommand( diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index 7c02bd649..6ce79e8b8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -3,11 +3,9 @@ import com.loopers.domain.order.Money; import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentService; -import com.loopers.application.event.PaymentCallbackEvent; import com.loopers.infrastructure.monitoring.PaymentMetricsService; import com.loopers.interfaces.api.client.PaymentCallbackV1Dto; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @Service @@ -16,7 +14,6 @@ public class PaymentFacade { private final PaymentService paymentService; private final PgClient pgClient; private final PaymentMetricsService paymentMetricsService; - private final ApplicationEventPublisher eventPublisher; public PaymentInfo requestPayment(CreatePaymentCommand command) { Payment finalPayment = paymentService.requestPayment(command.orderId(), command.cardType(), command.cardNo(), Money.wons(command.amount())); @@ -28,11 +25,8 @@ public PaymentInfo getPayment(Long paymentId) { } public void handlePaymentCallback(PaymentCallbackV1Dto.CallbackRequest dto) { - // 결제 상태 업데이트 + // 결제 상태 업데이트 및 이벤트 발행 paymentService.processPaymentCallback(dto.orderId(), dto.status(), dto.reason()); - - // 이벤트 발행 (후속 처리는 EventHandler에서) - eventPublisher.publishEvent(new PaymentCallbackEvent(dto.orderId(), dto.amount(), dto.status(), dto.reason())); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java index 4160aa336..2f760dbdb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java @@ -8,7 +8,7 @@ public record PaymentInfo( String paymentId, - Long orderId, + String orderId, CardType cardType, String cardNo, BigDecimal amount, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgClient.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgClient.java index cfa394dc6..d85dbdc1f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgClient.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgClient.java @@ -4,7 +4,7 @@ import com.loopers.domain.payment.CardType; public interface PgClient { - PgPayResponse requestPayment(Long orderId, CardType cardType, String cardNo, Money price); + PgPayResponse requestPayment(String orderId, CardType cardType, String cardNo, Money price); PgPaymentInfoResponse getPaymentInfo(String transactionKey); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPayRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPayRequest.java index 7434d56ac..f15a1221e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPayRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPayRequest.java @@ -3,13 +3,13 @@ import java.math.BigDecimal; public record PgPayRequest( - Long orderId, + String orderId, String cardType, String cardNo, BigDecimal amount, String callbackUrl ) { - public PgPayRequest(Long orderId, String cardType, String cardNo, BigDecimal amount) { + public PgPayRequest(String orderId, String cardType, String cardNo, BigDecimal amount) { this(orderId, cardType, cardNo, amount, "http://localhost:8080/api/v1/payments/callback"); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentInfoResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentInfoResponse.java index 2b9161a71..72bd5a75a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentInfoResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentInfoResponse.java @@ -6,7 +6,7 @@ public record PgPaymentInfoResponse( @JsonProperty("transactionKey") String transactionKey, @JsonProperty("orderId") - Long orderId, + String orderId, @JsonProperty("amount") Long amount, @JsonProperty("status") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/EarnPointFromPaymentCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/point/EarnPointFromPaymentCommand.java index 29efaeeee..3497ff431 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/EarnPointFromPaymentCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/EarnPointFromPaymentCommand.java @@ -5,6 +5,6 @@ public record EarnPointFromPaymentCommand( Long userId, Money paymentAmount, - Long orderId + String orderId ) { -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index a8f0b744f..0d4959418 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -5,7 +5,6 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; import com.loopers.domain.order.Money; -import com.loopers.domain.outbox.OutboxService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.stock.Stock; @@ -27,7 +26,6 @@ public class ProductFacade { private final ProductQueryService productQueryService; private final ProductListViewService productListViewService; private final StockService stockService; - private final OutboxService outboxService; private final ApplicationEventPublisher eventPublisher; private final RedisTemplate redisTemplate; @@ -46,12 +44,7 @@ public ProductDetailInfo getProductDetail(long userId, long productId) { // 조회수 이벤트 발행 (배치 처리용) ProductViewedEvent viewedEvent = new ProductViewedEvent(userId, productId); - outboxService.saveEvent( - "Product", - String.valueOf(productId), - "ProductViewed", - viewedEvent - ); + eventPublisher.publishEvent(viewedEvent); return productDetail; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index a82c9f67d..7da5e7be3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -9,6 +9,7 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Entity @Table(name = "orders") @@ -17,6 +18,7 @@ public class Order extends BaseEntity { private Long refUserId; + @Enumerated(EnumType.STRING) private OrderStatus status; private Money totalPrice; @@ -25,6 +27,9 @@ public class Order extends BaseEntity { private Long refCouponIssueId; + @Column(unique = true, nullable = false) + private String orderId; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "ref_order_id") private List orderItems = new ArrayList<>(); @@ -44,9 +49,14 @@ private Order(long refUserId, OrderStatus status, List orderItems, Lo this.status = status; this.orderAt = ZonedDateTime.now(); this.refCouponIssueId = refCouponIssueId; + this.orderId = generateOrderId(); setOrderItems(orderItems); } + private String generateOrderId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + } + public static Order create(long refUserId, List orderItems, Long refCouponIssueId) { if (orderItems == null || orderItems.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 상세내역이 없습니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index 4ba06bc61..af1f199b3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -7,6 +7,8 @@ public interface OrderRepository { Optional findById(Long id); + + Optional findByOrderId(String orderId); Page findByUserId(Long userId, Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 5aff6539a..2fc656525 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -2,7 +2,7 @@ package com.loopers.domain.order; import com.loopers.application.event.OrderCancelledEvent; -import com.loopers.domain.outbox.OutboxService; +import com.loopers.application.event.OrderPaidEvent; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -19,7 +19,6 @@ public class OrderService { private final OrderRepository orderRepository; private final ApplicationEventPublisher eventPublisher; - private final OutboxService outboxService; public Page getOrders( Long userId, @@ -38,11 +37,20 @@ public Page getOrders( } @Transactional(readOnly = true) - public Order getOrder(Long id) { + public Order getOrder(String id) { if (id == null) { throw new CoreException(ErrorType.BAD_REQUEST, "ID가 없습니다."); } - return orderRepository.findById(id).orElse(null); + return orderRepository.findByOrderId(id).orElse(null); + } + + @Transactional(readOnly = true) + public Order getOrderByOrderId(String orderId) { + if (orderId == null || orderId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문ID가 없습니다."); + } + return orderRepository.findByOrderId(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다: " + orderId)); } @Transactional @@ -51,37 +59,38 @@ public Order save(Order order) { } @Transactional - public void completePayment(Long orderId) { - Order order = orderRepository.findById(orderId) + public void completePayment(String orderId) { + Order order = orderRepository.findByOrderId(orderId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다: " + orderId)); order.paid(); orderRepository.save(order); + + // 주문 결제 완료 이벤트 발행 (랭킹 집계용) + OrderPaidEvent orderPaidEvent = new OrderPaidEvent( + order.getOrderId(), + order.getRefUserId(), + order.getTotalPrice() + ); + + eventPublisher.publishEvent(orderPaidEvent); } @Transactional - public void cancelPayment(Long orderId) { - Order order = orderRepository.findById(orderId) + public void cancelPayment(String orderId) { + Order order = orderRepository.findByOrderId(orderId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다: " + orderId)); order.cancel(); orderRepository.save(order); - // Outbox 패턴으로 주문 취소 이벤트 저장 + // 주문 취소 이벤트 발행 (배치 처리용) OrderCancelledEvent orderCancelledEvent = new OrderCancelledEvent( orderId, order.getRefUserId(), "결제 취소" ); - - outboxService.saveEvent( - "Order", - orderId.toString(), - "OrderCancelled", - orderCancelledEvent - ); - // 기존 동기 이벤트도 유지 (내부 처리용) eventPublisher.publishEvent(orderCancelledEvent); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java index a38431e16..e2d9039bf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -12,7 +12,7 @@ public class Payment extends BaseEntity { @Column(name = "order_id", nullable = false) - private Long orderId; + private String orderId; @Enumerated(EnumType.STRING) @Column(name = "card_type") @@ -36,7 +36,7 @@ public class Payment extends BaseEntity { protected Payment() { } - private Payment(Long orderId, CardType cardType, String cardNo, Money amount, PaymentStatus status, String transactionKey, String reason) { + private Payment(String orderId, CardType cardType, String cardNo, Money amount, PaymentStatus status, String transactionKey, String reason) { this.orderId = orderId; this.cardType = cardType; this.cardNo = cardNo; @@ -46,7 +46,7 @@ private Payment(Long orderId, CardType cardType, String cardNo, Money amount, Pa this.reason = reason; } - public static Payment create(Long orderId, CardType cardType, String cardNo, Money amount) { + public static Payment create(String orderId, CardType cardType, String cardNo, Money amount) { return new Payment(orderId, cardType, cardNo, amount, PaymentStatus.PENDING, null, null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java index 34f545a92..17ddc5c39 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -8,9 +8,9 @@ public interface PaymentRepository { Optional findById(Long id); - List findAllByOrderId(Long orderId); + List findAllByOrderId(String orderId); - Optional findByOrderId(Long orderId); + Optional findByOrderId(String orderId); List findByStatus(PaymentStatus status); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java index 1b3319182..e31f6db94 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java @@ -1,10 +1,15 @@ package com.loopers.domain.payment; import com.loopers.application.payment.TransactionStatus; +import com.loopers.application.event.PaymentSuccessEvent; +import com.loopers.application.event.PaymentFailureEvent; import com.loopers.domain.order.Money; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,9 +20,11 @@ public class PaymentService { private final PaymentRepository paymentRepository; + private final OrderService orderService; + private final ApplicationEventPublisher eventPublisher; @Transactional - public Payment requestPayment(Long orderId, CardType cardType, String cardNo, Money amount) { + public Payment requestPayment(String orderId, CardType cardType, String cardNo, Money amount) { Payment payment = Payment.create(orderId , cardType, cardNo , amount); @@ -37,10 +44,31 @@ public Payment processPaymentRequest(Payment payment, boolean isSuccess, String } @Transactional - public void processPaymentCallback(Long orderId, TransactionStatus status, String reason) { + public void processPaymentCallback(String orderId, TransactionStatus status, String reason) { Payment payment = findPaymentByOrderId(orderId); payment.processCallbackStatus(status, reason); paymentRepository.save(payment); + + // 상태에 따른 이벤트 발행 + Order order = orderService.getOrderByOrderId(orderId); + + if (status == TransactionStatus.SUCCESS) { + eventPublisher.publishEvent(new PaymentSuccessEvent( + order.getOrderId(), + order.getRefUserId(), + payment.getAmount(), + reason, + order.getTotalPrice() + )); + } else if (status == TransactionStatus.FAILED) { + eventPublisher.publishEvent(new PaymentFailureEvent( + order.getOrderId(), + order.getRefUserId(), + payment.getAmount(), + reason + )); + } + // PENDING 상태는 별도 처리하지 않음 } @Transactional(readOnly = true) @@ -54,7 +82,7 @@ public List findRecentFailedPayments(int hoursAgo) { } @Transactional(readOnly = true) - public Payment findPaymentByOrderId(Long orderId) { + public Payment findPaymentByOrderId(String orderId) { return paymentRepository.findByOrderId(orderId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "Payment not found with orderId: " + orderId)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/feign/PgClientImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/feign/PgClientImpl.java index 5ac4423e2..26cc65163 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/feign/PgClientImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/feign/PgClientImpl.java @@ -25,7 +25,7 @@ public class PgClientImpl implements PgClient { @Override @CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallbackRequest") - public PgPayResponse requestPayment(Long orderId, CardType cardType, String cardNo, Money price) { + public PgPayResponse requestPayment(String orderId, CardType cardType, String cardNo, Money price) { paymentMetricsService.recordPaymentRequest("/api/v1/payments", cardType.name()); Payment payment = paymentService.requestPayment(orderId, cardType, cardNo, price); @@ -83,7 +83,7 @@ public PgPaymentListResponse getPaymentsByOrder(String orderId) { } // fallback methods - public PgPayResponse fallbackRequest(PgPayRequest request, Throwable t) { + public PgPayResponse fallbackRequest(String orderId, CardType cardType, String cardNo, Money price, Throwable t) { return new PgPayResponse(null, "PENDING", t.getMessage()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 68e85215e..70df3cc84 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -5,7 +5,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface OrderJpaRepository extends JpaRepository { Page findByRefUserId(Long userId, Pageable pageable); + Optional findByOrderId(String orderId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 609a774e2..750290901 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -19,6 +19,11 @@ public Optional findById(Long id) { return jpaRepository.findById(id); } + @Override + public Optional findByOrderId(String orderId) { + return jpaRepository.findByOrderId(orderId); + } + @Override public Page findByUserId(Long userId, Pageable pageable) { return jpaRepository.findByRefUserId(userId, pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java index 29b1774aa..e90eaa030 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -9,10 +9,10 @@ import java.util.List; public interface PaymentJpaRepository extends JpaRepository { - List findByOrderId(Long orderId); + List findByOrderId(String orderId); List findByStatus(PaymentStatus status); @Query("SELECT p FROM Payment p WHERE p.status = :status AND p.updatedAt >= :fromDate") List findByStatusAndUpdatedAtAfter(@Param("status") PaymentStatus status, @Param("fromDate") java.time.ZonedDateTime fromDate); -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java index 7db992c06..b1f065c82 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -27,12 +27,12 @@ public Optional findById(Long id) { } @Override - public List findAllByOrderId(Long orderId) { + public List findAllByOrderId(String orderId) { return paymentJpaRepository.findByOrderId(orderId); } @Override - public Optional findByOrderId(Long orderId) { + public Optional findByOrderId(String orderId) { return paymentJpaRepository.findByOrderId(orderId).stream().findFirst(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/client/PaymentCallbackV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/client/PaymentCallbackV1Dto.java index 5fb71fdeb..501127947 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/client/PaymentCallbackV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/client/PaymentCallbackV1Dto.java @@ -6,14 +6,14 @@ public class PaymentCallbackV1Dto { public record CallbackRequest( String transactionKey, - Long orderId, + String orderId, String cardType, String cardNo, Long amount, TransactionStatus status, String reason ) { - public static CallbackRequest success(String transactionKey, Long orderId, String cardType, String cardNo, Long amount, TransactionStatus status) { + public static CallbackRequest success(String transactionKey, String orderId, String cardType, String cardNo, Long amount, TransactionStatus status) { return new CallbackRequest(transactionKey, orderId, cardType, cardNo, amount, status, "콜백 처리 성공"); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/client/PaymentCreateV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/client/PaymentCreateV1Dto.java index 90e9255a0..abd815cdc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/client/PaymentCreateV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/client/PaymentCreateV1Dto.java @@ -3,7 +3,7 @@ import com.loopers.domain.payment.CardType; public class PaymentCreateV1Dto { - public record PaymentRequest(Long orderId, CardType cardType, String cardNo, Long amount, String callbackUrl) { + public record PaymentRequest(String orderId, CardType cardType, String cardNo, Long amount, String callbackUrl) { } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java index 9474e7d9d..d2a70e32c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -47,6 +47,6 @@ ApiResponse createOrder( ApiResponse getOrderDetail( @Schema(name = "사용자 ID", description = "조회할 사용자의 ID") @RequestHeader(value = "X-USER-ID", required = false) Long userId, - @PathVariable("orderId") Long orderId + @PathVariable("orderId") String orderId ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 3df5b27e7..4d9f87e17 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -44,7 +44,7 @@ public ApiResponse createOrder(@RequestHeader(va @GetMapping("/{orderId}") @Override public ApiResponse getOrderDetail(@RequestHeader(value = "X-USER-ID", required = false) Long userId - , @PathVariable(value = "orderId") Long orderId + , @PathVariable(value = "orderId") String orderId ) { OrderInfo info = orderFacade.getOrderDetail(orderId); OrderCreateV1Dto.OrderResponse response = OrderCreateV1Dto.OrderResponse.from(info); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 70dfa8050..483f1b1b8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -119,12 +119,12 @@ class Get { @Test void 성공_존재하는_상품ID() { // arrange - Long orderId = savedOrder.getId(); + String orderId = savedOrder.getOrderId(); // act OrderInfo result = sut.getOrderDetail(orderId); // assert - assertThat(result.id()).isEqualTo(savedOrder.getId()); + assertThat(result.id()).isEqualTo(savedOrder.getOrderId()); assertThat(result.status()).isEqualTo(savedOrder.getStatus().toString()); assertThat(result.totalPrice()).isEqualByComparingTo(savedOrder.getTotalPrice().getAmount()); } @@ -133,7 +133,7 @@ class Get { @Test void 실패_존재하지_않는_상품ID() { // arrange - Long orderId = (long) -1; + String orderId = (long) -1; // act // assert assertThrows(CoreException.class, () -> { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java index 40d7bfabf..748b59e94 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -104,7 +104,7 @@ class Get { @Test void 성공_존재하는_주문ID() { // arrange - Long orderId = savedOrder.getId(); + String orderId = savedOrder.getOrderId(); // act Order result = sut.getOrder(orderId); @@ -116,7 +116,7 @@ class Get { @Test void 실패_존재하지_않는_주문ID() { // arrange - Long orderId = (long) -1; + String orderId = (long) -1; // act Order result = sut.getOrder(orderId); // assert diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java b/apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java index 6fcb8fb50..3bb0639e5 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java @@ -88,7 +88,7 @@ private void processEvent(EventHandled event) throws Exception { } private void processOrderCreated(JsonNode eventData, EventHandled event) { - Long orderId = eventData.get("orderId").asLong(); + String orderId = eventData.get("orderId").asText(); Long userId = eventData.get("userId").asLong(); ZonedDateTime eventTime = event.getEventTime(); LocalDateTime bucketTime = getBucketTime(eventTime); From 280cbf90d602ad4cec5d961bec196233b12b874f Mon Sep 17 00:00:00 2001 From: sieun0322 Date: Wed, 31 Dec 2025 01:50:38 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=96=B4?= =?UTF-8?q?=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-batch/build.gradle.kts | 22 +++ .../com/loopers/CommerceBatchApplication.java | 14 ++ .../batch/job/MonthlyRankingJobConfig.java | 123 ++++++++++++++++ .../batch/job/WeeklyRankingJobConfig.java | 132 ++++++++++++++++++ .../loopers/batch/runner/BatchJobRunner.java | 51 +++++++ .../scheduler/RankingBatchScheduler.java | 76 ++++++++++ .../java/com/loopers/domain/BaseEntity.java | 59 ++++++++ .../domain/metrics/ProductMetrics.java | 59 ++++++++ .../metrics/ProductMetricsRepository.java | 42 ++++++ .../domain/ranking/ProductMetricsMonthly.java | 45 ++++++ .../domain/ranking/ProductMetricsWeekly.java | 49 +++++++ .../src/main/resources/application.yml | 98 +++++++++++++ settings.gradle.kts | 19 +-- 13 files changed, 780 insertions(+), 9 deletions(-) create mode 100644 apps/commerce-batch/build.gradle.kts create mode 100644 apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/runner/BatchJobRunner.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/scheduler/RankingBatchScheduler.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/BaseEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsMonthly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsWeekly.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..fdd200c88 --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,22 @@ +dependencies { + // add-ons + implementation(project(":modules:kafka")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // spring batch + implementation("org.springframework.boot:spring-boot-starter-batch") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // database + runtimeOnly("com.mysql:mysql-connector-j") + + + // test + testImplementation("org.springframework.batch:spring-batch-test") + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..0543ace9c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,14 @@ +package com.loopers; + +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@EnableBatchProcessing +@SpringBootApplication +public class CommerceBatchApplication { + + public static void main(String[] args) { + SpringApplication.run(CommerceBatchApplication.class, args); + } +} \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..4e1db312d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java @@ -0,0 +1,123 @@ +package com.loopers.batch.job; + +import com.loopers.domain.ranking.ProductMetricsMonthly; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class MonthlyRankingJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + @Qualifier("mySqlMainDataSource") + private final DataSource dataSource; + private final EntityManagerFactory entityManagerFactory; + + @Bean + public Job monthlyRankingJob() { + return new JobBuilder("monthlyRankingJob", jobRepository) + .start(monthlyRankingStep()) + .build(); + } + + @Bean + public Step monthlyRankingStep() { + return new StepBuilder("monthlyRankingStep", jobRepository) + .chunk(1000, transactionManager) + .reader(monthlyMetricsReader()) + .processor(monthlyMetricsProcessor(null)) + .writer(monthlyMetricsWriter()) + .build(); + } + + @Bean + public ItemReader monthlyMetricsReader() { + MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); + queryProvider.setSelectClause("pm.product_id, COALESCE(SUM(pm.like_count), 0) as like_count, COALESCE(SUM(pm.sales_revenue), 0) as order_count, COALESCE(SUM(pm.view_count), 0) as view_count"); + queryProvider.setFromClause("product_metrics pm"); + queryProvider.setWhereClause("pm.bucket_time_key >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y%m%d%H')"); + queryProvider.setGroupClause("pm.product_id"); + queryProvider.setSortKeys(Map.of("pm.product_id", Order.ASCENDING)); + + return new JdbcPagingItemReaderBuilder() + .name("monthlyMetricsReader") + .dataSource(dataSource) + .queryProvider(queryProvider) + .pageSize(1000) + .rowMapper(new BeanPropertyRowMapper<>(MonthlyMetricsDto.class)) + .build(); + } + + @Bean + @StepScope + public ItemProcessor monthlyMetricsProcessor( + @Value("#{jobParameters['period']}") String period) { + return dto -> { + String yearMonth = period != null ? period : getCurrentYearMonth(); + log.debug("Processing monthly metrics for product: {}, month: {}", dto.getProductId(), yearMonth); + + return new ProductMetricsMonthly( + dto.getProductId(), + dto.getLikeCount(), + dto.getOrderCount(), + dto.getViewCount(), + yearMonth + ); + }; + } + + @Bean + public ItemWriter monthlyMetricsWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } + + private String getCurrentYearMonth() { + return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); + } + + public static class MonthlyMetricsDto { + private Long productId; + private Integer likeCount; + private Integer orderCount; + private Integer viewCount; + + // getters and setters + public Long getProductId() { return productId; } + public void setProductId(Long productId) { this.productId = productId; } + public Integer getLikeCount() { return likeCount; } + public void setLikeCount(Integer likeCount) { this.likeCount = likeCount; } + public Integer getOrderCount() { return orderCount; } + public void setOrderCount(Integer orderCount) { this.orderCount = orderCount; } + public Integer getViewCount() { return viewCount; } + public void setViewCount(Integer viewCount) { this.viewCount = viewCount; } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..7efce2759 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java @@ -0,0 +1,132 @@ +package com.loopers.batch.job; + +import com.loopers.domain.ranking.ProductMetricsWeekly; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.batch.item.database.Order; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.Map; +import jakarta.persistence.EntityManagerFactory; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.Locale; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class WeeklyRankingJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + @Qualifier("dataSource") + private final DataSource dataSource; + private final EntityManagerFactory entityManagerFactory; + private final ProductMetricsRepository productMetricsRepository; + + @Bean + public Job weeklyRankingJob() { + return new JobBuilder("weeklyRankingJob", jobRepository) + .start(weeklyRankingStep()) + .build(); + } + + @Bean + public Step weeklyRankingStep() { + return new StepBuilder("weeklyRankingStep", jobRepository) + .chunk(1000, transactionManager) + .reader(weeklyMetricsReader()) + .processor(weeklyMetricsProcessor(null)) + .writer(weeklyMetricsWriter()) + .build(); + } + + @Bean + public ItemReader weeklyMetricsReader() { + MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); + queryProvider.setSelectClause("pm.product_id, COALESCE(SUM(pm.like_count), 0) as like_count, COALESCE(SUM(pm.sales_revenue), 0) as order_count, COALESCE(SUM(pm.view_count), 0) as view_count"); + queryProvider.setFromClause("product_metrics pm"); + queryProvider.setWhereClause("pm.bucket_time_key >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 7 DAY), '%Y%m%d%H')"); + queryProvider.setGroupClause("pm.product_id"); + queryProvider.setSortKeys(Map.of("pm.product_id", Order.ASCENDING)); + + return new JdbcPagingItemReaderBuilder() + .name("weeklyMetricsReader") + .dataSource(dataSource) + .queryProvider(queryProvider) + .pageSize(1000) + .rowMapper(new BeanPropertyRowMapper<>(WeeklyMetricsDto.class)) + .build(); + } + + @Bean + @StepScope + public ItemProcessor weeklyMetricsProcessor( + @Value("#{jobParameters['period']}") String period) { + return dto -> { + String yearMonthWeek = period != null ? period : getCurrentYearMonthWeek(); + log.debug("Processing weekly metrics for product: {}, week: {}", dto.getProductId(), yearMonthWeek); + + return new ProductMetricsWeekly( + dto.getProductId(), + dto.getLikeCount(), + dto.getOrderCount(), + dto.getViewCount(), + yearMonthWeek + ); + }; + } + + @Bean + public ItemWriter weeklyMetricsWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } + + private String getCurrentYearMonthWeek() { + LocalDate now = LocalDate.now(); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + int weekOfYear = now.get(weekFields.weekOfYear()); + return now.format(DateTimeFormatter.ofPattern("yyyy")) + String.format("%02d", weekOfYear); + } + + public static class WeeklyMetricsDto { + private Long productId; + private Integer likeCount; + private Integer orderCount; + private Integer viewCount; + + // getters and setters + public Long getProductId() { return productId; } + public void setProductId(Long productId) { this.productId = productId; } + public Integer getLikeCount() { return likeCount; } + public void setLikeCount(Integer likeCount) { this.likeCount = likeCount; } + public Integer getOrderCount() { return orderCount; } + public void setOrderCount(Integer orderCount) { this.orderCount = orderCount; } + public Integer getViewCount() { return viewCount; } + public void setViewCount(Integer viewCount) { this.viewCount = viewCount; } + } +} \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/runner/BatchJobRunner.java b/apps/commerce-batch/src/main/java/com/loopers/batch/runner/BatchJobRunner.java new file mode 100644 index 000000000..d129e54f5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/runner/BatchJobRunner.java @@ -0,0 +1,51 @@ +package com.loopers.batch.runner; + +import com.loopers.batch.scheduler.RankingBatchScheduler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "batch") +public class BatchJobRunner implements CommandLineRunner { + + private final RankingBatchScheduler rankingBatchScheduler; + + private String jobName; + + @Override + public void run(String... args) throws Exception { + if (jobName == null || jobName.isEmpty()) { + log.info("No batch job specified. Application will exit."); + return; + } + + log.info("Starting batch job: {}", jobName); + + switch (jobName.toLowerCase()) { + case "weekly-ranking": + rankingBatchScheduler.runWeeklyRankingJob(); + break; + case "monthly-ranking": + rankingBatchScheduler.runMonthlyRankingJob(); + break; + default: + log.error("Unknown job name: {}", jobName); + throw new IllegalArgumentException("Unknown job name: " + jobName); + } + + log.info("Batch job completed: {}", jobName); + } + + public void setJobName(String jobName) { + this.jobName = jobName; + } + + public String getJobName() { + return jobName; + } +} \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/scheduler/RankingBatchScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/batch/scheduler/RankingBatchScheduler.java new file mode 100644 index 000000000..777350ac0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/scheduler/RankingBatchScheduler.java @@ -0,0 +1,76 @@ +package com.loopers.batch.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingBatchScheduler { + + private final JobLauncher jobLauncher; + + @Qualifier("weeklyRankingJob") + private final Job weeklyRankingJob; + + @Qualifier("monthlyRankingJob") + private final Job monthlyRankingJob; + + public void runWeeklyRankingJob() { + runWeeklyRankingJob(null); + } + + public void runWeeklyRankingJob(String period) { + try { + log.info("Starting weekly ranking batch job for period: {}", period); + + JobParametersBuilder builder = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()); + + if (period != null) { + builder.addString("period", period); + } + + JobParameters jobParameters = builder.toJobParameters(); + + jobLauncher.run(weeklyRankingJob, jobParameters); + log.info("Weekly ranking batch job completed successfully"); + + } catch (Exception e) { + log.error("Failed to run weekly ranking batch job", e); + throw new RuntimeException("Weekly ranking batch job failed", e); + } + } + + public void runMonthlyRankingJob() { + runMonthlyRankingJob(null); + } + + public void runMonthlyRankingJob(String period) { + try { + log.info("Starting monthly ranking batch job for period: {}", period); + + JobParametersBuilder builder = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()); + + if (period != null) { + builder.addString("period", period); + } + + JobParameters jobParameters = builder.toJobParameters(); + + jobLauncher.run(monthlyRankingJob, jobParameters); + log.info("Monthly ranking batch job completed successfully"); + + } catch (Exception e) { + log.error("Failed to run monthly ranking batch job", e); + throw new RuntimeException("Monthly ranking batch job failed", e); + } + } +} \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/BaseEntity.java b/apps/commerce-batch/src/main/java/com/loopers/domain/BaseEntity.java new file mode 100644 index 000000000..486c3e1d0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/BaseEntity.java @@ -0,0 +1,59 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import lombok.Getter; +import java.time.ZonedDateTime; + +@MappedSuperclass +@Getter +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private final Long id = 0L; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + protected void guard() {} + + @PrePersist + private void prePersist() { + guard(); + + ZonedDateTime now = ZonedDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + guard(); + + this.updatedAt = ZonedDateTime.now(); + } + + public void delete() { + if (this.deletedAt == null) { + this.deletedAt = ZonedDateTime.now(); + } + } + + public void restore() { + if (this.deletedAt != null) { + this.deletedAt = null; + } + } +} \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..3fa69dab5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,59 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics", + indexes = { + @Index(name = "idx_product_bucket", columnList = "productId, bucketTimeKey"), + @Index(name = "idx_bucket_time", columnList = "bucketTimeKey") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTimeKey"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetrics extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private String bucketTimeKey; + + @Column(nullable = false) + private Long viewCount = 0L; + + @Column(nullable = false) + private Long likeCount = 0L; + + @Column(nullable = false) + private Long salesRevenue = 0L; + + public ProductMetrics(Long productId, String bucketTime) { + this.productId = productId; + this.bucketTimeKey = bucketTime; + } + + public void incrementViewCount() { + this.viewCount++; + } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void incrementSalesRevenue(Long revenue) { + this.salesRevenue += revenue; + } +} \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..236ccb721 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,42 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.ranking.ProductMetricsWeekly; +import com.loopers.domain.ranking.ProductMetricsMonthly; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductMetricsRepository extends JpaRepository { + + @Query(value = """ + SELECT + pm.product_id as productId, + COALESCE(SUM(pm.like_count), 0) as likeCount, + COALESCE(SUM(pm.sales_revenue), 0) as orderCount, + COALESCE(SUM(pm.view_count), 0) as viewCount, + :period as yearMonthWeek + FROM product_metrics pm + WHERE pm.bucket_time_key >= :startTimeKey + GROUP BY pm.product_id + ORDER BY pm.product_id + """, nativeQuery = true) + Page findWeeklyMetrics(@Param("startTimeKey") String startTimeKey, @Param("period") String period, Pageable pageable); + + @Query(value = """ + SELECT + pm.product_id as productId, + COALESCE(SUM(pm.like_count), 0) as likeCount, + COALESCE(SUM(pm.sales_revenue), 0) as orderCount, + COALESCE(SUM(pm.view_count), 0) as viewCount, + :period as yearMonth + FROM product_metrics pm + WHERE pm.bucket_time_key >= :startTimeKey + GROUP BY pm.product_id + ORDER BY pm.product_id + """, nativeQuery = true) + Page findMonthlyMetrics(@Param("startTimeKey") String startTimeKey, @Param("period") String period, Pageable pageable); +} \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsMonthly.java new file mode 100644 index 000000000..0979ca418 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsMonthly.java @@ -0,0 +1,45 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics_monthly", + indexes = { + @Index(name = "idx_monthly_period_yyyymm", columnList = "period_yyyymm"), + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsMonthly extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Column(nullable = false) + private Integer orderCount = 0; + + @Column(nullable = false) + private Integer viewCount = 0; + + @Column(nullable = false, length = 6, name = "period_yyyymm") + private String yearMonth; + + public ProductMetricsMonthly(Long productId, String yearMonth) { + this.productId = productId; + this.yearMonth = yearMonth; + } + + public ProductMetricsMonthly(Long productId, Integer likeCount, Integer orderCount, Integer viewCount, String yearMonth) { + this.productId = productId; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.yearMonth = yearMonth; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsWeekly.java new file mode 100644 index 000000000..98df95416 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsWeekly.java @@ -0,0 +1,49 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics_weekly", + indexes = { + @Index(name = "idx_weekly_period_yyyyww", columnList = "period_yyyyww"), + @Index(name = "idx_weekly_product_week", columnList = "productId, period_yyyyww") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_product_week", columnNames = {"productId", "period_yyyyww"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsWeekly extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Column(nullable = false) + private Integer orderCount = 0; + + @Column(nullable = false) + private Integer viewCount = 0; + + @Column(nullable = false, length = 6, name = "period_yyyyww") + private String yearMonthWeek; + + public ProductMetricsWeekly(Long productId, String yearMonthWeek) { + this.productId = productId; + this.yearMonthWeek = yearMonthWeek; + } + + public ProductMetricsWeekly(Long productId, Integer likeCount, Integer orderCount, Integer viewCount, String yearMonthWeek) { + this.productId = productId; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.yearMonthWeek = yearMonthWeek; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..b35b18d59 --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,98 @@ +server: + port: 8082 + +spring: + application: + name: commerce-batch + + main: + web-application-type: none # 웹 서버 비활성화 + + batch: + job: + enabled: false # 애플리케이션 시작 시 자동 실행 방지 + jdbc: + initialize-schema: always # Batch 메타 테이블 생성 + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always + +logging: + level: + org.springframework.batch: INFO +--- +spring.config.activate.on-profile: local + +spring: + datasource: + url: jdbc:mysql://localhost:3306/loopers + username: application + password: application + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + show-sql: true + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + jdbc: + batch_size: 1000 + order_inserts: true + order_updates: true + batch_versioned_data: true +--- +spring.config.activate.on-profile: test + +spring: + datasource: + url: jdbc:mysql://localhost:3306/loopers_test + username: application + password: application + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + show-sql: true + hibernate: + ddl-auto: create-drop + properties: + hibernate: + jdbc: + batch_size: 1000 + order_inserts: true + order_updates: true + batch_versioned_data: true +--- +spring.config.activate.on-profile: dev + +spring: + datasource: + url: jdbc:mysql://localhost:3306/loopers + username: application + password: application + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: none +--- +spring.config.activate.on-profile: qa +spring: + datasource: + url: jdbc:mysql://localhost:3306/loopers + username: application + password: application +--- +spring.config.activate.on-profile: prd +spring: + datasource: + url: jdbc:mysql://localhost:3306/loopers + username: application + password: application diff --git a/settings.gradle.kts b/settings.gradle.kts index 906b49231..7248a307c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,15 +1,16 @@ rootProject.name = "loopers-java-spring-template" include( - ":apps:commerce-api", - ":apps:commerce-streamer", - ":apps:pg-simulator", - ":modules:jpa", - ":modules:redis", - ":modules:kafka", - ":supports:jackson", - ":supports:logging", - ":supports:monitoring", + ":apps:commerce-api", + ":apps:commerce-streamer", + ":apps:pg-simulator", + ":apps:commerce-batch", + ":modules:jpa", + ":modules:redis", + ":modules:kafka", + ":supports:jackson", + ":supports:logging", + ":supports:monitoring", ) // configurations From d89b33ec826b82ccf4b01d2606167c539afbcaf1 Mon Sep 17 00:00:00 2001 From: sieun0322 Date: Fri, 2 Jan 2026 02:54:54 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84,=20=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 10 - .../ranking/MvProductRankMonthlyService.java | 54 ++++ .../ranking/MvProductRankWeeklyService.java | 54 ++++ .../ranking/ProductMetricsDailyService.java | 137 ++++++++ .../ranking/RankingDateParser.java | 205 ++++++++++++ .../application/ranking/RankingFacade.java | 18 ++ .../application/ranking/RankingPeriod.java | 32 ++ .../application/ranking/RankingService.java | 295 ++++++++++++++++++ .../com/loopers/domain/like/LikeService.java | 16 + .../domain/metrics/ProductMetrics.java | 59 ++++ .../domain/metrics/ProductMetricsMonthly.java | 45 +++ .../domain/metrics/ProductMetricsWeekly.java | 49 +++ .../domain/ranking/MvProductRankMonthly.java | 66 ++++ .../domain/ranking/MvProductRankWeekly.java | 66 ++++ .../domain/ranking/ProductMetricsDaily.java | 49 +++ .../MvProductRankMonthlyRepository.java | 42 +++ .../MvProductRankWeeklyRepository.java | 42 +++ .../ProductMetricsDailyRepository.java | 71 +++++ .../api/ranking/RankingV1ApiSpec.java | 7 +- .../api/ranking/RankingV1Controller.java | 2 +- apps/commerce-batch/build.gradle.kts | 1 + .../com/loopers/CommerceBatchApplication.java | 13 +- .../batch/job/DailyRankingJobConfig.java | 249 +++++++++++++++ .../batch/job/MonthlyRankingJobConfig.java | 153 ++++----- .../batch/job/WeeklyRankingJobConfig.java | 193 ++++++------ .../loopers/batch/runner/BatchJobRunner.java | 72 +++-- .../scheduler/RankingBatchScheduler.java | 72 +---- .../loopers/config/BatchJobProperties.java | 17 + .../domain/ranking/ProductMetricsDaily.java | 49 +++ .../domain/ranking/ProductMetricsMonthly.java | 2 +- .../domain/ranking/ProductMetricsWeekly.java | 2 +- .../src/main/resources/application.yml | 15 +- argo-workflows/weekly-ranking-workflow.yaml | 145 +++++++++ 33 files changed, 1995 insertions(+), 307 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/MvProductRankMonthlyService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/MvProductRankWeeklyService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/ProductMetricsDailyService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingDateParser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductMetricsDaily.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductMetricsDailyRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/DailyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsDaily.java create mode 100644 argo-workflows/weekly-ranking-workflow.yaml diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index f322ce126..f7a76fd99 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -13,7 +13,6 @@ public class LikeFacade { private final LikeService likeService; private final LikeCacheRepository likeCacheRepository; - private final ApplicationEventPublisher eventPublisher; public LikeInfo like(Long userId, Long productId) { Boolean userLiked = likeCacheRepository.getUserLiked(userId, productId); @@ -26,11 +25,6 @@ public LikeInfo like(Long userId, Long productId) { Long newCount = likeCacheRepository.addLikeCount(productId); likeService.save(userId, productId); - // 좋아요 이벤트 발행 (배치 처리용) - LikeEvent likeEvent = new LikeEvent(userId, productId, LikeEvent.LikeAction.LIKE); - eventPublisher.publishEvent(likeEvent); - log.debug("좋아요 이벤트 발행 - 사용자ID: {}, 상품ID: {}", userId, productId); - return LikeInfo.from(newCount, true); } @@ -42,10 +36,6 @@ public LikeInfo unlike(Long userId, Long productId) { likeService.remove(userId, productId); - // 좋아요 취소 이벤트 발행 (배치 처리용) - LikeEvent unlikeEvent = new LikeEvent(userId, productId, LikeEvent.LikeAction.UNLIKE); - eventPublisher.publishEvent(unlikeEvent); - log.debug("좋아요 취소 이벤트 발행 - 사용자ID: {}, 상품ID: {}", userId, productId); return LikeInfo.from(cachedCount, false); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvProductRankMonthlyService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvProductRankMonthlyService.java new file mode 100644 index 000000000..19c852381 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvProductRankMonthlyService.java @@ -0,0 +1,54 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Service +public class MvProductRankMonthlyService { + + private final MvProductRankMonthlyRepository mvMonthlyRepository; + + /** + * 월간 MV 데이터 존재 여부 확인 + */ + public boolean existsByYearMonth(String yearMonth) { + return mvMonthlyRepository.existsByYearMonth(yearMonth); + } + + /** + * 월간 MV에서 랭킹 순서대로 상품 ID 목록 조회 + */ + public List getMonthlyRankingProductIds(String yearMonth, Pageable pageable) { + if (!existsByYearMonth(yearMonth)) { + log.debug("Monthly MV data not found for period: {}", yearMonth); + return List.of(); + } + + Page mvResult = mvMonthlyRepository + .findByYearMonthOrderByRanking(yearMonth, pageable); + + return mvResult.getContent().stream() + .map(MvProductRankMonthly::getProductId) + .toList(); + } + + /** + * 월간 MV 데이터의 총 개수 조회 + */ + public Long getMonthlyRankingCount(String yearMonth) { + if (!existsByYearMonth(yearMonth)) { + return 0L; + } + + return mvMonthlyRepository.countByYearMonth(yearMonth); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvProductRankWeeklyService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvProductRankWeeklyService.java new file mode 100644 index 000000000..4d175b07f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvProductRankWeeklyService.java @@ -0,0 +1,54 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Service +public class MvProductRankWeeklyService { + + private final MvProductRankWeeklyRepository mvWeeklyRepository; + + /** + * 주간 MV 데이터 존재 여부 확인 + */ + public boolean existsByYearMonthWeek(String yearMonthWeek) { + return mvWeeklyRepository.existsByYearMonthWeek(yearMonthWeek); + } + + /** + * 주간 MV에서 랭킹 순서대로 상품 ID 목록 조회 + */ + public List getWeeklyRankingProductIds(String yearMonthWeek, Pageable pageable) { + if (!existsByYearMonthWeek(yearMonthWeek)) { + log.debug("Weekly MV data not found for period: {}", yearMonthWeek); + return List.of(); + } + + Page mvResult = mvWeeklyRepository + .findByYearMonthWeekOrderByRanking(yearMonthWeek, pageable); + + return mvResult.getContent().stream() + .map(MvProductRankWeekly::getProductId) + .toList(); + } + + /** + * 주간 MV 데이터의 총 개수 조회 + */ + public Long getWeeklyRankingCount(String yearMonthWeek) { + if (!existsByYearMonthWeek(yearMonthWeek)) { + return 0L; + } + + return mvWeeklyRepository.countByYearMonthWeek(yearMonthWeek); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ProductMetricsDailyService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ProductMetricsDailyService.java new file mode 100644 index 000000000..64b134fc2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ProductMetricsDailyService.java @@ -0,0 +1,137 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.infrastructure.ranking.ProductMetricsDailyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Service +public class ProductMetricsDailyService { + + private final ProductMetricsDailyRepository productMetricsDailyRepository; + private final RedisTemplate redisTemplate; + + /** + * 특정 날짜의 일간 메트릭 데이터 존재 여부 확인 + */ + public boolean existsByYearMonthDay(String yearMonthDay) { + return productMetricsDailyRepository.existsByYearMonthDay(yearMonthDay); + } + + /** + * ProductMetricsDaily에서 주간 데이터를 집계하여 Redis에 저장 후 반환 + */ + public List calculateAndCacheWeeklyRanking(String date, String yearMonthWeek, Pageable pageable) { + // 주간 날짜 범위 계산 (해당 주의 월요일부터 일요일까지) + LocalDate localDate = LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")); + LocalDate weekStart = localDate.minusDays(localDate.getDayOfWeek().getValue() - 1); + LocalDate weekEnd = weekStart.plusDays(6); + + String startDate = weekStart.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String endDate = weekEnd.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + log.info("Week range: {} to {}", startDate, endDate); + System.out.print(startDate + endDate); + + // ProductMetricsDaily에서 주간 집계 데이터 조회 + Page weeklyMetrics = productMetricsDailyRepository + .findWeeklyAggregateByDateRange(startDate, endDate, yearMonthWeek, Pageable.unpaged()); + System.out.print(weeklyMetrics.getTotalElements()); + if (weeklyMetrics.hasContent()) { + // Redis에 랭킹 데이터 저장 + String rankingKey = "ranking:weekly:" + date; + cacheWeeklyRankingToRedis(rankingKey, weeklyMetrics.getContent()); + + // 요청된 페이지만큼 반환 + return weeklyMetrics.getContent().stream() + .skip((long) pageable.getPageNumber() * pageable.getPageSize()) + .limit(pageable.getPageSize()) + .map(ProductMetricsWeekly::getProductId) + .toList(); + } + + return List.of(); + } + + /** + * ProductMetricsDaily에서 월간 데이터를 집계하여 Redis에 저장 후 반환 + */ + public List calculateAndCacheMonthlyRanking(String date, String yearMonth, Pageable pageable) { + String yearMonthPattern = yearMonth + "%"; + + // ProductMetricsDaily에서 월간 집계 데이터 조회 + Page monthlyMetrics = productMetricsDailyRepository + .findMonthlyAggregateByYearMonth(yearMonthPattern, yearMonth, Pageable.unpaged()); + + if (monthlyMetrics.hasContent()) { + // Redis에 랭킹 데이터 저장 + String rankingKey = "ranking:monthly:" + date; + cacheMonthlyRankingToRedis(rankingKey, monthlyMetrics.getContent()); + + // 요청된 페이지만큼 반환 + return monthlyMetrics.getContent().stream() + .skip((long) pageable.getPageNumber() * pageable.getPageSize()) + .limit(pageable.getPageSize()) + .map(ProductMetricsMonthly::getProductId) + .toList(); + } + + return List.of(); + } + + /** + * 주간 랭킹 데이터를 Redis에 캐시 + */ + private void cacheWeeklyRankingToRedis(String rankingKey, List weeklyMetrics) { + try { + // 기존 데이터 삭제 + redisTemplate.delete(rankingKey); + + // 새로운 랭킹 데이터 저장 + for (ProductMetricsWeekly metric : weeklyMetrics) { + double score = metric.getLikeCount() * 3.0 + metric.getOrderCount() * 10.0 + metric.getViewCount() * 1.0; + redisTemplate.opsForZSet().add(rankingKey, metric.getProductId().toString(), score); + } + + // TTL 설정 (7일) + redisTemplate.expire(rankingKey, java.time.Duration.ofDays(7)); + + log.info("Cached {} weekly ranking items to Redis key: {}", weeklyMetrics.size(), rankingKey); + } catch (Exception e) { + log.error("Failed to cache weekly ranking to Redis: {}", rankingKey, e); + } + } + + /** + * 월간 랭킹 데이터를 Redis에 캐시 + */ + private void cacheMonthlyRankingToRedis(String rankingKey, List monthlyMetrics) { + try { + // 기존 데이터 삭제 + redisTemplate.delete(rankingKey); + + // 새로운 랭킹 데이터 저장 + for (ProductMetricsMonthly metric : monthlyMetrics) { + double score = metric.getLikeCount() * 3.0 + metric.getOrderCount() * 10.0 + metric.getViewCount() * 1.0; + redisTemplate.opsForZSet().add(rankingKey, metric.getProductId().toString(), score); + } + + // TTL 설정 (30일) + redisTemplate.expire(rankingKey, java.time.Duration.ofDays(30)); + + log.info("Cached {} monthly ranking items to Redis key: {}", monthlyMetrics.size(), rankingKey); + } catch (Exception e) { + log.error("Failed to cache monthly ranking to Redis: {}", rankingKey, e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingDateParser.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingDateParser.java new file mode 100644 index 000000000..5383fade8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingDateParser.java @@ -0,0 +1,205 @@ +package com.loopers.application.ranking; + +import lombok.Getter; + +@Getter +public class RankingDateParser { + private final String originalDate; + private final RankingPeriod period; + private final String convertedDate; + + private RankingDateParser(String originalDate, RankingPeriod period, String convertedDate) { + this.originalDate = originalDate; + this.period = period; + this.convertedDate = convertedDate; + } + + /** + * date 파라미터를 분석하여 기간과 변환된 날짜를 반환 + * + * @param date 입력 날짜 + * - YYYYMMDD (8자리) -> 일간 랭킹 (예: 20251225) + * - YYYYWWW (6자리 + W) -> 주간 랭킹 (예: 202552W) + * - YYYYMMM (6자리 + M) -> 월간 랭킹 (예: 202512M) + * @return RankingDateParser 객체 + */ + public static RankingDateParser parse(String date) { + if (date == null || date.trim().isEmpty()) { + throw new IllegalArgumentException("날짜가 비어있습니다."); + } + + String trimmedDate = date.trim().toUpperCase(); + + if (trimmedDate.length() == 8 && trimmedDate.matches("\\d{8}")) { + // YYYYMMDD -> 일간 랭킹 + validateDailyDate(trimmedDate); + return new RankingDateParser(trimmedDate, RankingPeriod.DAILY, trimmedDate); + } + + if (trimmedDate.length() == 7) { + char suffix = trimmedDate.charAt(6); + String datePart = trimmedDate.substring(0, 6); + + if (!datePart.matches("\\d{6}")) { + throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다: " + trimmedDate); + } + + switch (suffix) { + case 'W': + // YYYYWWW -> 주간 랭킹 + return parseWeeklyDate(trimmedDate, datePart); + + case 'M': + // YYYYMMM -> 월간 랭킹 + return parseMonthlyDate(trimmedDate, datePart); + + default: + throw new IllegalArgumentException( + "지원하지 않는 접미사입니다: " + suffix + + ". 지원 접미사: W(주간), M(월간)" + ); + } + } + + throw new IllegalArgumentException( + "지원하지 않는 날짜 형식입니다: " + trimmedDate + + ". 지원 형식: YYYYMMDD(일간), YYYYWWW(주간), YYYYMMM(월간)" + ); + } + + private static RankingDateParser parseWeeklyDate(String originalDate, String datePart) { + try { + String year = datePart.substring(0, 4); + String week = datePart.substring(4, 6); + + int yearInt = Integer.parseInt(year); + int weekInt = Integer.parseInt(week); + + validateYear(yearInt); + + if (weekInt < 1 || weekInt > 53) { + throw new IllegalArgumentException("유효하지 않은 주차입니다: " + weekInt + " (1~53 범위)"); + } + + // 주간 랭킹용 날짜로 변환 (해당 주차의 대표 날짜) + String convertedDate = convertWeekToDate(yearInt, weekInt); + return new RankingDateParser(originalDate, RankingPeriod.WEEKLY, convertedDate); + + } catch (NumberFormatException e) { + throw new IllegalArgumentException("주간 날짜 형식이 올바르지 않습니다: " + originalDate); + } + } + + private static RankingDateParser parseMonthlyDate(String originalDate, String datePart) { + try { + String year = datePart.substring(0, 4); + String month = datePart.substring(4, 6); + + int yearInt = Integer.parseInt(year); + int monthInt = Integer.parseInt(month); + + validateYear(yearInt); + + if (monthInt < 1 || monthInt > 12) { + throw new IllegalArgumentException("유효하지 않은 월입니다: " + monthInt + " (1~12 범위)"); + } + + // 월간 랭킹용 날짜로 변환 (해당 월의 마지막 날) + String convertedDate = convertMonthToDate(yearInt, monthInt); + return new RankingDateParser(originalDate, RankingPeriod.MONTHLY, convertedDate); + + } catch (NumberFormatException e) { + throw new IllegalArgumentException("월간 날짜 형식이 올바르지 않습니다: " + originalDate); + } + } + + private static void validateDailyDate(String date) { + try { + String year = date.substring(0, 4); + String month = date.substring(4, 6); + String day = date.substring(6, 8); + + int yearInt = Integer.parseInt(year); + int monthInt = Integer.parseInt(month); + int dayInt = Integer.parseInt(day); + + validateYear(yearInt); + + if (monthInt < 1 || monthInt > 12) { + throw new IllegalArgumentException("유효하지 않은 월입니다: " + monthInt); + } + + if (dayInt < 1 || dayInt > 31) { + throw new IllegalArgumentException("유효하지 않은 일입니다: " + dayInt); + } + + } catch (NumberFormatException e) { + throw new IllegalArgumentException("일간 날짜 형식이 올바르지 않습니다: " + date); + } + } + + private static void validateYear(int year) { + if (year < 2020 || year > 2030) { + throw new IllegalArgumentException("지원하지 않는 연도입니다: " + year + " (2020~2030 범위)"); + } + } + + private static String convertWeekToDate(int year, int week) { + // 해당 주차의 대표 날짜 계산 (간단한 근사치) + // 1월 첫째 주를 기준으로 계산 + int dayOfYear = (week - 1) * 7 + 4; // 해당 주의 목요일 + + // 윤년 고려 + int[] monthDays = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (isLeapYear(year)) { + monthDays[1] = 29; + } + + int month = 1; + while (month <= 12 && dayOfYear > monthDays[month - 1]) { + dayOfYear -= monthDays[month - 1]; + month++; + } + + // 범위 초과 시 12월 마지막 날로 조정 + if (month > 12) { + month = 12; + dayOfYear = monthDays[11]; + } + + return String.format("%04d%02d%02d", year, month, dayOfYear); + } + + private static String convertMonthToDate(int year, int month) { + // 해당 월의 마지막 날로 변환 + int[] monthDays = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (isLeapYear(year) && month == 2) { + monthDays[1] = 29; + } + + int lastDay = monthDays[month - 1]; + return String.format("%04d%02d%02d", year, month, lastDay); + } + + private static boolean isLeapYear(int year) { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + } + + /** + * 원본 기간 정보 반환 (MV 조회용) + */ + public String getPeriodKey() { + switch (period) { + case DAILY: + return originalDate; + case WEEKLY: + // 202552W -> 202552 (YYYYWW 형식으로 변환) + return originalDate.substring(0, 6); + case MONTHLY: + // 202512M -> 202512 (YYYYMM 형식으로 변환) + return originalDate.substring(0, 6); + default: + throw new IllegalStateException("알 수 없는 기간입니다: " + period); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index b6b99069e..d15375e56 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -48,4 +48,22 @@ public Page getProductRankings(Long userId, String date, int si return new PageImpl<>(productsWithLike, PageRequest.of(page - 1, size), totalCount); } + + @Transactional(readOnly = true) + public Page getProductRankingsByDate(Long userId, String date, int size, int page) { + PageRequest pageRequest = PageRequest.of(page - 1, size); + + // date 파라미터를 분석하여 기간별 랭킹 조회 + List productIds = rankingService.getRankingProductIdsByDate(date, pageRequest); + + if (productIds.isEmpty()) { + return new PageImpl<>(List.of(), pageRequest, 0); + } + + // 상품 정보 일괄 조회 (좋아요 정보 포함) + List productsWithLike = productQueryService.getProductListByProductIds(userId, productIds); + Long totalCount = rankingService.getTotalRankingCountByDate(date); + + return new PageImpl<>(productsWithLike, pageRequest, totalCount); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java new file mode 100644 index 000000000..112546ec9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java @@ -0,0 +1,32 @@ +package com.loopers.application.ranking; + +import lombok.Getter; + +@Getter +public enum RankingPeriod { + DAILY("daily", "ranking:all:"), + WEEKLY("weekly", "ranking:weekly:"), + MONTHLY("monthly", "ranking:monthly:"); + + private final String value; + private final String redisKeyPrefix; + + RankingPeriod(String value, String redisKeyPrefix) { + this.value = value; + this.redisKeyPrefix = redisKeyPrefix; + } + + public static RankingPeriod fromString(String period) { + if (period == null || period.trim().isEmpty()) { + return DAILY; // 기본값 + } + + for (RankingPeriod p : RankingPeriod.values()) { + if (p.value.equalsIgnoreCase(period.trim())) { + return p; + } + } + + throw new IllegalArgumentException("지원하지 않는 기간입니다: " + period + ". 지원 기간: daily, weekly, monthly"); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java index f9c17fac1..efedcaec7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -1,17 +1,29 @@ package com.loopers.application.ranking; +import com.loopers.domain.ranking.*; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.List; +import java.util.Locale; +import java.util.Set; +@Slf4j @RequiredArgsConstructor @Service public class RankingService { private final RedisTemplate redisTemplate; + private final MvProductRankWeeklyService mvWeeklyService; + private final MvProductRankMonthlyService mvMonthlyService; + private final ProductMetricsDailyService dailyService; public Integer getProductRank(Long productId) { String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); @@ -59,4 +71,287 @@ public Long getTotalRankingCount(String date) { String rankingKey = "ranking:all:" + date; return redisTemplate.opsForZSet().zCard(rankingKey); } + + + + private List getWeeklyRankingFromCache(String date, Pageable pageable) { + String rankingKey = "ranking:weekly:" + date; + return getRankingFromCache(rankingKey, pageable); + } + + + private String convertDateToYearMonthWeek(String date) { + LocalDate localDate = LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + int weekOfYear = localDate.get(weekFields.weekOfYear()); + return localDate.format(DateTimeFormatter.ofPattern("yyyy")) + String.format("%02d", weekOfYear); + } + + + /** + * date 파라미터를 분석하여 기간별 랭킹 조회 + * + * @param date 날짜 정보 (YYYYMMDD, YYYYWWW, YYYYMMM 형식) + * @param pageable 페이징 정보 + * @return 랭킹 순서대로 정렬된 상품 ID 목록 + */ + public List getRankingProductIdsByDate(String date, Pageable pageable) { + RankingDateParser parser = RankingDateParser.parse(date); + + switch (parser.getPeriod()) { + case DAILY -> { + return getDailyRankingProductIds(parser.getConvertedDate(), pageable); + } + case WEEKLY -> { + return getWeeklyRankingProductIdsWithMV(parser.getConvertedDate(), parser.getPeriodKey(), pageable); + } + case MONTHLY -> { + return getMonthlyRankingProductIdsWithMV(parser.getConvertedDate(), parser.getPeriodKey(), pageable); + } + default -> throw new IllegalArgumentException("지원하지 않는 기간입니다: " + parser.getPeriod()); + } + } + + /** + * date 파라미터를 분석하여 기간별 랭킹 총 개수 조회 + */ + public Long getTotalRankingCountByDate(String date) { + RankingDateParser parser = RankingDateParser.parse(date); + + switch (parser.getPeriod()) { + case DAILY -> { + return getTotalRankingCount(parser.getConvertedDate()); + } + case WEEKLY -> { + return getTotalWeeklyRankingCountWithMV(parser.getConvertedDate(), parser.getPeriodKey()); + } + case MONTHLY -> { + return getTotalMonthlyRankingCountWithMV(parser.getConvertedDate(), parser.getPeriodKey()); + } + default -> throw new IllegalArgumentException("지원하지 않는 기간입니다: " + parser.getPeriod()); + } + } + + /** + * 기간별 랭킹에서 상품 ID 목록을 조회 (캐시 우선, MV fallback, DB fallback) + * + * @param period 조회 기간 (daily, weekly, monthly) + * @param date 조회할 날짜 (yyyyMMdd) + * @param pageable 페이징 정보 + * @return 랭킹 순서대로 정렬된 상품 ID 목록 + */ + public List getRankingProductIdsByPeriod(RankingPeriod period, String date, Pageable pageable) { + switch (period) { + case DAILY -> { + return getDailyRankingProductIds(date, pageable); + } + case WEEKLY -> { + return getWeeklyRankingProductIdsWithMV(date, pageable); + } + case MONTHLY -> { + return getMonthlyRankingProductIdsWithMV(date, pageable); + } + default -> throw new IllegalArgumentException("지원하지 않는 기간입니다: " + period); + } + } + + /** + * 기간별 랭킹 총 개수 조회 + */ + public Long getTotalRankingCountByPeriod(RankingPeriod period, String date) { + switch (period) { + case DAILY -> { + return getTotalRankingCount(date); + } + case WEEKLY -> { + return getTotalWeeklyRankingCountWithMV(date); + } + case MONTHLY -> { + return getTotalMonthlyRankingCountWithMV(date); + } + default -> throw new IllegalArgumentException("지원하지 않는 기간입니다: " + period); + } + } + + private List getDailyRankingProductIds(String date, Pageable pageable) { + String rankingKey = "ranking:all:" + date; + + long start = (long) pageable.getPageNumber() * pageable.getPageSize(); + long end = start + pageable.getPageSize() - 1; + + Set rankedProductIds = redisTemplate.opsForZSet().reverseRange(rankingKey, start, end); + + if (rankedProductIds == null || rankedProductIds.isEmpty()) { + return List.of(); + } + + return rankedProductIds.stream() + .map(Long::parseLong) + .toList(); + } + + private List getWeeklyRankingProductIdsWithMV(String date, Pageable pageable) { + // 1. Redis 캐시에서 조회 시도 + List cachedProductIds = getWeeklyRankingFromCache(date, pageable); + if (cachedProductIds != null && !cachedProductIds.isEmpty()) { + log.debug("Weekly ranking found in cache for date: {}, size: {}", date, cachedProductIds.size()); + return cachedProductIds; + } + + // 2. MV에서 조회 시도 (캐시 miss 시) + String yearMonthWeek = convertDateToYearMonthWeek(date); + if (mvWeeklyService.existsByYearMonthWeek(yearMonthWeek)) { + log.debug("Weekly ranking found in MV for period: {}", yearMonthWeek); + return mvWeeklyService.getWeeklyRankingProductIds(yearMonthWeek, pageable); + } + + // 3. ProductMetricsDaily에서 집계하여 Redis에 저장 후 반환 (MV miss 시) + log.warn("Weekly ranking MV miss for period: {}, falling back to ProductMetricsDaily aggregation", yearMonthWeek); + return dailyService.calculateAndCacheWeeklyRanking(date, yearMonthWeek, pageable); + } + + private List getWeeklyRankingProductIdsWithMV(String date, String periodKey, Pageable pageable) { + // 1. Redis 캐시에서 조회 시도 + List cachedProductIds = getWeeklyRankingFromCache(date, pageable); + if (cachedProductIds != null && !cachedProductIds.isEmpty()) { + log.debug("Weekly ranking found in cache for date: {}, size: {}", date, cachedProductIds.size()); + return cachedProductIds; + } + + // 2. MV에서 조회 시도 (캐시 miss 시) + if (mvWeeklyService.existsByYearMonthWeek(periodKey)) { + log.debug("Weekly ranking found in MV for period: {}", periodKey); + return mvWeeklyService.getWeeklyRankingProductIds(periodKey, pageable); + } + + // 3. ProductMetricsDaily에서 집계하여 Redis에 저장 후 반환 (MV miss 시) + log.warn("Weekly ranking MV miss for period: {}, falling back to ProductMetricsDaily aggregation", periodKey); + return dailyService.calculateAndCacheWeeklyRanking(date, periodKey, pageable); + } + + private List getMonthlyRankingProductIdsWithMV(String date, Pageable pageable) { + // 1. Redis 캐시에서 조회 시도 + String rankingKey = "ranking:monthly:" + date; + List cachedProductIds = getRankingFromCache(rankingKey, pageable); + if (cachedProductIds != null && !cachedProductIds.isEmpty()) { + log.debug("Monthly ranking found in cache for date: {}, size: {}", date, cachedProductIds.size()); + return cachedProductIds; + } + + // 2. MV에서 조회 시도 (캐시 miss 시) + String yearMonth = convertDateToYearMonth(date); + if (mvMonthlyService.existsByYearMonth(yearMonth)) { + log.debug("Monthly ranking found in MV for period: {}", yearMonth); + return mvMonthlyService.getMonthlyRankingProductIds(yearMonth, pageable); + } + + // 3. ProductMetricsDaily에서 집계하여 Redis에 저장 후 반환 (MV miss 시) + log.warn("Monthly ranking MV miss for period: {}, falling back to ProductMetricsDaily aggregation", yearMonth); + return dailyService.calculateAndCacheMonthlyRanking(date, yearMonth, pageable); + } + + private List getMonthlyRankingProductIdsWithMV(String date, String periodKey, Pageable pageable) { + // 1. Redis 캐시에서 조회 시도 + String rankingKey = "ranking:monthly:" + date; + List cachedProductIds = getRankingFromCache(rankingKey, pageable); + if (cachedProductIds != null && !cachedProductIds.isEmpty()) { + log.debug("Monthly ranking found in cache for date: {}, size: {}", date, cachedProductIds.size()); + return cachedProductIds; + } + + // 2. MV에서 조회 시도 (캐시 miss 시) + if (mvMonthlyService.existsByYearMonth(periodKey)) { + log.debug("Monthly ranking found in MV for period: {}", periodKey); + return mvMonthlyService.getMonthlyRankingProductIds(periodKey, pageable); + } + + // 3. ProductMetricsDaily에서 집계하여 Redis에 저장 후 반환 (MV miss 시) + log.warn("Monthly ranking MV miss for period: {}, falling back to ProductMetricsDaily aggregation", periodKey); + return dailyService.calculateAndCacheMonthlyRanking(date, periodKey, pageable); + } + + private Long getTotalWeeklyRankingCountWithMV(String date) { + String rankingKey = "ranking:weekly:" + date; + Long cacheCount = redisTemplate.opsForZSet().zCard(rankingKey); + + if (cacheCount != null && cacheCount > 0) { + return cacheCount; + } + + String yearMonthWeek = convertDateToYearMonthWeek(date); + if (mvWeeklyService.existsByYearMonthWeek(yearMonthWeek)) { + return mvWeeklyService.getWeeklyRankingCount(yearMonthWeek); + } + + // fallback: ProductMetricsDaily에서 계산 + return 0L; // 실제 구현 시 ProductMetricsDaily 기반 카운트 로직 추가 필요 + } + + private Long getTotalWeeklyRankingCountWithMV(String date, String periodKey) { + String rankingKey = "ranking:weekly:" + date; + Long cacheCount = redisTemplate.opsForZSet().zCard(rankingKey); + + if (cacheCount != null && cacheCount > 0) { + return cacheCount; + } + + if (mvWeeklyService.existsByYearMonthWeek(periodKey)) { + return mvWeeklyService.getWeeklyRankingCount(periodKey); + } + + // fallback: ProductMetricsDaily에서 계산 + return 0L; // 실제 구현 시 ProductMetricsDaily 기반 카운트 로직 추가 필요 + } + + private Long getTotalMonthlyRankingCountWithMV(String date) { + String rankingKey = "ranking:monthly:" + date; + Long cacheCount = redisTemplate.opsForZSet().zCard(rankingKey); + + if (cacheCount != null && cacheCount > 0) { + return cacheCount; + } + + String yearMonth = convertDateToYearMonth(date); + if (mvMonthlyService.existsByYearMonth(yearMonth)) { + return mvMonthlyService.getMonthlyRankingCount(yearMonth); + } + + return 0L; // MV가 없으면 0 반환 + } + + private Long getTotalMonthlyRankingCountWithMV(String date, String periodKey) { + String rankingKey = "ranking:monthly:" + date; + Long cacheCount = redisTemplate.opsForZSet().zCard(rankingKey); + + if (cacheCount != null && cacheCount > 0) { + return cacheCount; + } + + if (mvMonthlyService.existsByYearMonth(periodKey)) { + return mvMonthlyService.getMonthlyRankingCount(periodKey); + } + + return 0L; // MV가 없으면 0 반환 + } + + private List getRankingFromCache(String rankingKey, Pageable pageable) { + long start = (long) pageable.getPageNumber() * pageable.getPageSize(); + long end = start + pageable.getPageSize() - 1; + + Set rankedProductIds = redisTemplate.opsForZSet().reverseRange(rankingKey, start, end); + + if (rankedProductIds == null || rankedProductIds.isEmpty()) { + return null; + } + + return rankedProductIds.stream() + .map(Long::parseLong) + .toList(); + } + + private String convertDateToYearMonth(String date) { + LocalDate localDate = LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")); + return localDate.format(DateTimeFormatter.ofPattern("yyyyMM")); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 5304fdb88..5488da4f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -1,9 +1,12 @@ package com.loopers.domain.like; +import com.loopers.application.event.LikeEvent; import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.*; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -11,11 +14,13 @@ import java.util.List; import java.util.Optional; +@Slf4j @RequiredArgsConstructor @Component public class LikeService { private final LikeRepository likeRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional public void save(Long userId, Long productId) { @@ -23,12 +28,23 @@ public void save(Long userId, Long productId) { if (!liked.isPresent()) { likeRepository.save(userId, productId); } + + // 좋아요 이벤트 발행 (배치 처리용) + LikeEvent likeEvent = new LikeEvent(userId, productId, LikeEvent.LikeAction.LIKE); + eventPublisher.publishEvent(likeEvent); + log.debug("좋아요 이벤트 발행 - 사용자ID: {}, 상품ID: {}", userId, productId); } @Transactional public void remove(Long userId, Long productId) { + likeRepository.remove(userId, productId); + + // 좋아요 취소 이벤트 발행 (배치 처리용) + LikeEvent unlikeEvent = new LikeEvent(userId, productId, LikeEvent.LikeAction.UNLIKE); + eventPublisher.publishEvent(unlikeEvent); + log.debug("좋아요 취소 이벤트 발행 - 사용자ID: {}, 상품ID: {}", userId, productId); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..8cf0a845b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,59 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics", + indexes = { + @Index(name = "idx_product_bucket", columnList = "productId, bucketTimeKey"), + @Index(name = "idx_bucket_time", columnList = "bucketTimeKey") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTimeKey"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetrics extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private String bucketTimeKey; + + @Column(nullable = false) + private Long viewCount = 0L; + + @Column(nullable = false) + private Long likeCount = 0L; + + @Column(nullable = false) + private Long salesRevenue = 0L; + + public ProductMetrics(Long productId, String bucketTime) { + this.productId = productId; + this.bucketTimeKey = bucketTime; + } + + public void incrementViewCount() { + this.viewCount++; + } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void incrementSalesRevenue(Long revenue) { + this.salesRevenue += revenue; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java new file mode 100644 index 000000000..0346d0044 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java @@ -0,0 +1,45 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics_monthly", + indexes = { + @Index(name = "idx_monthly_period_yyyymm", columnList = "period_yyyymm"), + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsMonthly extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Column(nullable = false) + private Integer orderCount = 0; + + @Column(nullable = false) + private Integer viewCount = 0; + + @Column(nullable = false, length = 6, name = "period_yyyymm") + private String yearMonth; + + public ProductMetricsMonthly(Long productId, String yearMonth) { + this.productId = productId; + this.yearMonth = yearMonth; + } + + public ProductMetricsMonthly(Long productId, Integer likeCount, Integer orderCount, Integer viewCount, String yearMonth) { + this.productId = productId; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.yearMonth = yearMonth; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java new file mode 100644 index 000000000..e721ba50a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java @@ -0,0 +1,49 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics_weekly", + indexes = { + @Index(name = "idx_weekly_period_yyyyww", columnList = "period_yyyyww"), + @Index(name = "idx_weekly_product_week", columnList = "productId, period_yyyyww") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_product_week", columnNames = {"productId", "period_yyyyww"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsWeekly extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Column(nullable = false) + private Integer orderCount = 0; + + @Column(nullable = false) + private Integer viewCount = 0; + + @Column(nullable = false, length = 6, name = "period_yyyyww") + private String yearMonthWeek; + + public ProductMetricsWeekly(Long productId, String yearMonthWeek) { + this.productId = productId; + this.yearMonthWeek = yearMonthWeek; + } + + public ProductMetricsWeekly(Long productId, Integer likeCount, Integer orderCount, Integer viewCount, String yearMonthWeek) { + this.productId = productId; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.yearMonthWeek = yearMonthWeek; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 000000000..e6a28656e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,66 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_monthly", + indexes = { + @Index(name = "idx_monthly_period_rank", columnList = "period_yyyymm, ranking"), + @Index(name = "idx_monthly_period", columnList = "period_yyyymm"), + @Index(name = "idx_monthly_product", columnList = "productId") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_monthly_period_product", columnNames = {"period_yyyymm", "productId"}), + @UniqueConstraint(name = "uk_monthly_period_rank", columnNames = {"period_yyyymm", "ranking"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, length = 6, name = "period_yyyymm") + private String yearMonth; + + @Column(nullable = false) + private Integer ranking; + + @Column(nullable = false) + private Double score; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Column(nullable = false) + private Integer orderCount = 0; + + @Column(nullable = false) + private Integer viewCount = 0; + + public MvProductRankMonthly(Long productId, String yearMonth, Integer ranking, Double score, + Integer likeCount, Integer orderCount, Integer viewCount) { + this.productId = productId; + this.yearMonth = yearMonth; + this.ranking = ranking; + this.score = score; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + } + + public static MvProductRankMonthly create(Long productId, String yearMonth, Integer ranking, + Integer likeCount, Integer orderCount, Integer viewCount) { + final double VIEW_WEIGHT = 0.1; + final double LIKE_WEIGHT = 0.2; + final double ORDER_WEIGHT = 0.6; + + double score = (VIEW_WEIGHT * viewCount) + (LIKE_WEIGHT * likeCount) + (ORDER_WEIGHT * orderCount); + + return new MvProductRankMonthly(productId, yearMonth, ranking, score, likeCount, orderCount, viewCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 000000000..5b3e5f256 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,66 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly", + indexes = { + @Index(name = "idx_weekly_period_rank", columnList = "period_yyyyww, ranking"), + @Index(name = "idx_weekly_period", columnList = "period_yyyyww"), + @Index(name = "idx_weekly_product", columnList = "productId") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_weekly_period_product", columnNames = {"period_yyyyww", "productId"}), + @UniqueConstraint(name = "uk_weekly_period_rank", columnNames = {"period_yyyyww", "ranking"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, length = 6, name = "period_yyyyww") + private String yearMonthWeek; + + @Column(nullable = false) + private Integer ranking; + + @Column(nullable = false) + private Double score; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Column(nullable = false) + private Integer orderCount = 0; + + @Column(nullable = false) + private Integer viewCount = 0; + + public MvProductRankWeekly(Long productId, String yearMonthWeek, Integer ranking, Double score, + Integer likeCount, Integer orderCount, Integer viewCount) { + this.productId = productId; + this.yearMonthWeek = yearMonthWeek; + this.ranking = ranking; + this.score = score; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + } + + public static MvProductRankWeekly create(Long productId, String yearMonthWeek, Integer ranking, + Integer likeCount, Integer orderCount, Integer viewCount) { + final double VIEW_WEIGHT = 0.1; + final double LIKE_WEIGHT = 0.2; + final double ORDER_WEIGHT = 0.6; + + double score = (VIEW_WEIGHT * viewCount) + (LIKE_WEIGHT * likeCount) + (ORDER_WEIGHT * orderCount); + + return new MvProductRankWeekly(productId, yearMonthWeek, ranking, score, likeCount, orderCount, viewCount); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductMetricsDaily.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductMetricsDaily.java new file mode 100644 index 000000000..8182b21b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductMetricsDaily.java @@ -0,0 +1,49 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics_daily", + indexes = { + @Index(name = "idx_daily_period_yyyymmdd", columnList = "period_yyyymmdd"), + @Index(name = "idx_daily_product_date", columnList = "productId, period_yyyymmdd") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_product_daily", columnNames = {"productId", "period_yyyymmdd"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsDaily extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Column(nullable = false) + private Integer orderCount = 0; + + @Column(nullable = false) + private Integer viewCount = 0; + + @Column(nullable = false, length = 8, name = "period_yyyymmdd") + private String yearMonthDay; + + public ProductMetricsDaily(Long productId, String yearMonthDay) { + this.productId = productId; + this.yearMonthDay = yearMonthDay; + } + + public ProductMetricsDaily(Long productId, Integer likeCount, Integer orderCount, Integer viewCount, String yearMonthDay) { + this.productId = productId; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.yearMonthDay = yearMonthDay; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepository.java new file mode 100644 index 000000000..cc0a93d8f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepository.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MvProductRankMonthlyRepository extends JpaRepository { + + /** + * 월간 랭킹 조회 (순위별 정렬) + */ + Page findByYearMonthOrderByRanking(@Param("yearMonth") String yearMonth, Pageable pageable); + + /** + * 특정 기간의 TOP 랭킹 조회 + */ + @Query("SELECT mv FROM MvProductRankMonthly mv WHERE mv.yearMonth = :yearMonth AND mv.ranking <= :topN ORDER BY mv.ranking") + List findTopNByYearMonth(@Param("yearMonth") String yearMonth, @Param("topN") int topN); + + /** + * 특정 상품의 월간 랭킹 조회 + */ + @Query("SELECT mv FROM MvProductRankMonthly mv WHERE mv.yearMonth = :yearMonth AND mv.productId = :productId") + MvProductRankMonthly findByYearMonthAndProductId(@Param("yearMonth") String yearMonth, @Param("productId") Long productId); + + /** + * 해당 기간에 랭킹 데이터가 있는지 확인 + */ + boolean existsByYearMonth(String yearMonth); + + /** + * 특정 기간의 전체 랭킹 개수 조회 + */ + long countByYearMonth(String yearMonth); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepository.java new file mode 100644 index 000000000..1946f2066 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepository.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MvProductRankWeeklyRepository extends JpaRepository { + + /** + * 주간 랭킹 조회 (순위별 정렬) + */ + Page findByYearMonthWeekOrderByRanking(@Param("yearMonthWeek") String yearMonthWeek, Pageable pageable); + + /** + * 특정 기간의 TOP 랭킹 조회 + */ + @Query("SELECT mv FROM MvProductRankWeekly mv WHERE mv.yearMonthWeek = :yearMonthWeek AND mv.ranking <= :topN ORDER BY mv.ranking") + List findTopNByYearMonthWeek(@Param("yearMonthWeek") String yearMonthWeek, @Param("topN") int topN); + + /** + * 특정 상품의 주간 랭킹 조회 + */ + @Query("SELECT mv FROM MvProductRankWeekly mv WHERE mv.yearMonthWeek = :yearMonthWeek AND mv.productId = :productId") + MvProductRankWeekly findByYearMonthWeekAndProductId(@Param("yearMonthWeek") String yearMonthWeek, @Param("productId") Long productId); + + /** + * 해당 기간에 랭킹 데이터가 있는지 확인 + */ + boolean existsByYearMonthWeek(String yearMonthWeek); + + /** + * 특정 기간의 전체 랭킹 개수 조회 + */ + long countByYearMonthWeek(String yearMonthWeek); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductMetricsDailyRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductMetricsDailyRepository.java new file mode 100644 index 000000000..14c5c0116 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductMetricsDailyRepository.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductMetricsDaily; +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsMonthly; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductMetricsDailyRepository extends JpaRepository { + + /** + * 주간 기간의 일간 데이터를 집계하여 ProductMetricsWeekly 형태로 조회 + * startDate부터 endDate까지의 데이터를 상품별로 합산하여 정렬 + */ + @Query(""" + SELECT new com.loopers.domain.metrics.ProductMetricsWeekly( + pmd.productId, + CAST(SUM(pmd.likeCount) AS INTEGER), + CAST(SUM(pmd.orderCount) AS INTEGER), + CAST(SUM(pmd.viewCount) AS INTEGER), + :yearMonthWeek + ) + FROM ProductMetricsDaily pmd + WHERE pmd.yearMonthDay BETWEEN :startDate AND :endDate + GROUP BY pmd.productId + """) + Page findWeeklyAggregateByDateRange( + @Param("startDate") String startDate, + @Param("endDate") String endDate, + @Param("yearMonthWeek") String yearMonthWeek, + Pageable pageable + ); + + /** + * 월간 기간의 일간 데이터를 집계하여 ProductMetricsMonthly 형태로 조회 + * yearMonth에 해당하는 모든 일간 데이터를 상품별로 합산하여 정렬 + */ + @Query(""" + SELECT new com.loopers.domain.metrics.ProductMetricsMonthly( + pmd.productId, + CAST(SUM(pmd.likeCount) AS INTEGER), + CAST(SUM(pmd.orderCount) AS INTEGER), + CAST(SUM(pmd.viewCount) AS INTEGER), + :yearMonth + ) + FROM ProductMetricsDaily pmd + WHERE pmd.yearMonthDay LIKE :yearMonthPattern + GROUP BY pmd.productId + """) + Page findMonthlyAggregateByYearMonth( + @Param("yearMonthPattern") String yearMonthPattern, + @Param("yearMonth") String yearMonth, + Pageable pageable + ); + + /** + * 특정 날짜의 데이터 존재 여부 확인 + */ + boolean existsByYearMonthDay(String yearMonthDay); + + /** + * 날짜 범위의 데이터 존재 여부 확인 + */ + @Query("SELECT COUNT(DISTINCT pmd.yearMonthDay) FROM ProductMetricsDaily pmd WHERE pmd.yearMonthDay BETWEEN :startDate AND :endDate") + long countDistinctDaysByDateRange(@Param("startDate") String startDate, @Param("endDate") String endDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index 5844de54b..2f0020720 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -15,12 +15,13 @@ @RequestMapping("/api/v1/rankings") public interface RankingV1ApiSpec { - @Operation(summary = "일별 상품 랭킹 조회", description = "특정 날짜의 상품 랭킹을 조회합니다.") + @Operation(summary = "기간별 상품 랭킹 조회", description = "날짜 형식에 따라 일간/주간/월간 상품 랭킹을 조회합니다.") @GetMapping ApiResponse getRankings( @RequestHeader(value = "X-USER-ID", required = false) Long userId, - @Parameter(description = "조회할 날짜 (yyyyMMdd 형식)", example = "20251225") - @Pattern(regexp = "^\\d{8}$", message = "날짜는 yyyyMMdd 형식이어야 합니다") + + @Parameter(description = "조회할 날짜 (YYYYMMDD: 일간, YYYYWWW: 주간, YYYYMMM: 월간)", + example = "20251225") @RequestParam String date, @Parameter(description = "페이지 크기", example = "20") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index d08c40487..b2de95dda 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -18,7 +18,7 @@ public class RankingV1Controller implements RankingV1ApiSpec { @Override public ApiResponse getRankings(Long userId, String date, int size, int page) { - Page productPage = rankingFacade.getProductRankings(userId, date, size, page); + Page productPage = rankingFacade.getProductRankingsByDate(userId, date, size, page); List products = productPage.getContent().stream() .map(ProductV1Dto.ProductListResponse::from) .toList(); diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts index fdd200c88..931a7a202 100644 --- a/apps/commerce-batch/build.gradle.kts +++ b/apps/commerce-batch/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { // spring batch implementation("org.springframework.boot:spring-boot-starter-batch") implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java index 0543ace9c..58d59a6ea 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -1,14 +1,15 @@ package com.loopers; -import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import com.loopers.config.BatchJobProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; -@EnableBatchProcessing +@EnableConfigurationProperties(BatchJobProperties.class) @SpringBootApplication public class CommerceBatchApplication { - public static void main(String[] args) { - SpringApplication.run(CommerceBatchApplication.class, args); - } -} \ No newline at end of file + public static void main(String[] args) { + SpringApplication.run(CommerceBatchApplication.class, args); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/DailyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/DailyRankingJobConfig.java new file mode 100644 index 000000000..677259445 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/DailyRankingJobConfig.java @@ -0,0 +1,249 @@ +package com.loopers.batch.job; + +import com.loopers.domain.ranking.ProductMetricsDaily; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class DailyRankingJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final EntityManagerFactory entityManagerFactory; + + @Bean + public Job dailyRankingDataProcessingJob() { + return new JobBuilder("dailyRankingDataProcessingJob", jobRepository) + .start(clearDailyWorkingTablesStep()) + .next(dailyRankingStep()) + .next(dailyDataValidationStep()) + .next(dailyTableSwapStep()) + .build(); + } + + // 1단계: Working 테이블 데이터 삭제 + @Bean + public Step clearDailyWorkingTablesStep() { + return new StepBuilder("clearDailyWorkingTablesStep", jobRepository) + .tasklet(clearDailyWorkingTablesTasklet(), transactionManager) + .build(); + } + + // 2단계: 일간 랭킹 데이터 적재 (특정 일자) + @Bean + public Step dailyRankingStep() { + return new StepBuilder("dailyRankingStep", jobRepository) + .chunk(1000, transactionManager) + .reader(dailyMetricsReader(null)) + .processor(dailyMetricsProcessor(null)) + .writer(dailyMetricsWriter()) + .build(); + } + + // 3단계: 데이터 검증 + @Bean + public Step dailyDataValidationStep() { + return new StepBuilder("dailyDataValidationStep", jobRepository) + .tasklet(dailyDataValidationTasklet(), transactionManager) + .build(); + } + + // 4단계: 테이블 교체 + @Bean + public Step dailyTableSwapStep() { + return new StepBuilder("dailyTableSwapStep", jobRepository) + .tasklet(dailyTableSwapTasklet(), transactionManager) + .build(); + } + + @Bean + @StepScope + public ItemReader dailyMetricsReader(@Value("#{jobParameters['date']}") String date) { + MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); + queryProvider.setSelectClause("pm.product_id, COALESCE(SUM(pm.like_count), 0) as like_count, COALESCE(SUM(pm.sales_revenue), 0) as order_count, COALESCE(SUM(pm.view_count), 0) as view_count"); + queryProvider.setFromClause("product_metrics pm"); + + // 특정 일의 데이터만 가져오기 (00:00부터 23:59까지) + String currentDate = date != null ? date : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String startKey = currentDate + "0000"; // 해당 일 00:00 + String endKey = currentDate + "2359"; // 해당 일 23:59 + + queryProvider.setWhereClause("pm.bucket_time_key >= '" + startKey + "' AND pm.bucket_time_key <= '" + endKey + "'"); + queryProvider.setGroupClause("pm.product_id"); + queryProvider.setSortKeys(Map.of("pm.product_id", Order.ASCENDING)); + + return new JdbcPagingItemReaderBuilder() + .name("dailyMetricsReader") + .dataSource(dataSource) + .queryProvider(queryProvider) + .pageSize(1000) + .rowMapper(new BeanPropertyRowMapper<>(DailyMetricsDto.class)) + .build(); + } + + @Bean + @StepScope + public ItemProcessor dailyMetricsProcessor( + @Value("#{jobParameters['date']}") String date) { + return dto -> { + String currentDate = date != null ? date : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + log.debug("Processing daily metrics for product: {}, date: {}", dto.getProductId(), currentDate); + + return new ProductMetricsDaily( + dto.getProductId(), + dto.getLikeCount(), + dto.getOrderCount(), + dto.getViewCount(), + currentDate + ); + }; + } + + @Bean + public ItemWriter dailyMetricsWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } + + public static class DailyMetricsDto { + private Long productId; + private Integer likeCount; + private Integer orderCount; + private Integer viewCount; + + // getters and setters + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public Integer getLikeCount() { + return likeCount; + } + + public void setLikeCount(Integer likeCount) { + this.likeCount = likeCount; + } + + public Integer getOrderCount() { + return orderCount; + } + + public void setOrderCount(Integer orderCount) { + this.orderCount = orderCount; + } + + public Integer getViewCount() { + return viewCount; + } + + public void setViewCount(Integer viewCount) { + this.viewCount = viewCount; + } + } + + // Tasklet 구현들 + @Bean + public Tasklet clearDailyWorkingTablesTasklet() { + return (contribution, chunkContext) -> { + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + log.info("Clearing daily working tables..."); + + jdbcTemplate.execute("TRUNCATE TABLE product_metrics_daily_working"); + log.info("Successfully cleared product_metrics_daily_working table"); + + return RepeatStatus.FINISHED; + }; + } + + @Bean + public Tasklet dailyDataValidationTasklet() { + return (contribution, chunkContext) -> { + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + log.info("Starting daily data validation..."); + + // 1. 데이터 건수 확인 + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM product_metrics_daily_working", Integer.class); + log.info("Daily working table record count: {}", count); + + if (count == 0) { + throw new RuntimeException("No data found in daily working table"); + } + + // 2. 필수 필드 NULL 체크 + Integer nullCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM product_metrics_daily_working WHERE product_id IS NULL OR period_yyyymmdd IS NULL", + Integer.class); + + if (nullCount > 0) { + throw new RuntimeException("Found " + nullCount + " records with null required fields"); + } + + log.info("Daily data validation completed successfully"); + return RepeatStatus.FINISHED; + }; + } + + @Bean + public Tasklet dailyTableSwapTasklet() { + return (contribution, chunkContext) -> { + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + log.info("Starting daily table swap..."); + + try { + // 1. 이전 백업 삭제 + jdbcTemplate.execute("DROP TABLE IF EXISTS product_metrics_daily_backup"); + + // 2. 원자적 테이블 교체 + jdbcTemplate.execute(""" + RENAME TABLE + product_metrics_daily TO product_metrics_daily_backup, + product_metrics_daily_working TO product_metrics_daily + """); + + log.info("Daily table swap completed successfully"); + return RepeatStatus.FINISHED; + + } catch (Exception e) { + log.error("Daily table swap failed, rolling back...", e); + throw e; + } + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java index 4e1db312d..42a95efb3 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java @@ -1,6 +1,5 @@ package com.loopers.batch.job; -import com.loopers.domain.ranking.ProductMetricsMonthly; import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,114 +9,94 @@ import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.batch.item.ItemReader; -import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.database.Order; -import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; -import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; -import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.batch.core.step.tasklet.Tasklet; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.BeanPropertyRowMapper; import org.springframework.transaction.PlatformTransactionManager; import javax.sql.DataSource; import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.util.Map; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.batch.repeat.RepeatStatus; @Slf4j @Configuration @RequiredArgsConstructor public class MonthlyRankingJobConfig { - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - @Qualifier("mySqlMainDataSource") - private final DataSource dataSource; - private final EntityManagerFactory entityManagerFactory; + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final EntityManagerFactory entityManagerFactory; + + @Bean + public Job monthlyRankingMVUpdateJob() { + return new JobBuilder("monthlyRankingMVUpdateJob", jobRepository) + .start(monthlyTop100MVUpdateStep()) + .build(); + } - @Bean - public Job monthlyRankingJob() { - return new JobBuilder("monthlyRankingJob", jobRepository) - .start(monthlyRankingStep()) - .build(); - } + private String getCurrentYearMonth() { + return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); + } - @Bean - public Step monthlyRankingStep() { - return new StepBuilder("monthlyRankingStep", jobRepository) - .chunk(1000, transactionManager) - .reader(monthlyMetricsReader()) - .processor(monthlyMetricsProcessor(null)) - .writer(monthlyMetricsWriter()) - .build(); - } + @Bean + @StepScope + public Tasklet monthlyTop100MVUpdateTasklet(@Value("#{jobParameters['date']}") String date) { + return (contribution, chunkContext) -> { + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + log.info("Starting monthly TOP 100 MV update..."); - @Bean - public ItemReader monthlyMetricsReader() { - MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); - queryProvider.setSelectClause("pm.product_id, COALESCE(SUM(pm.like_count), 0) as like_count, COALESCE(SUM(pm.sales_revenue), 0) as order_count, COALESCE(SUM(pm.view_count), 0) as view_count"); - queryProvider.setFromClause("product_metrics pm"); - queryProvider.setWhereClause("pm.bucket_time_key >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y%m%d%H')"); - queryProvider.setGroupClause("pm.product_id"); - queryProvider.setSortKeys(Map.of("pm.product_id", Order.ASCENDING)); + String currentDate = date != null ? date : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yearMonth = currentDate.substring(0, 6); // YYYYMM 추출 - return new JdbcPagingItemReaderBuilder() - .name("monthlyMetricsReader") - .dataSource(dataSource) - .queryProvider(queryProvider) - .pageSize(1000) - .rowMapper(new BeanPropertyRowMapper<>(MonthlyMetricsDto.class)) - .build(); - } + // 해당 월의 시작일과 마지막일 계산 + String startDate = yearMonth + "01"; // 해당 월 1일 + String endDate = yearMonth + "31"; // 해당 월 31일 (31일이 없는 달도 포함) - @Bean - @StepScope - public ItemProcessor monthlyMetricsProcessor( - @Value("#{jobParameters['period']}") String period) { - return dto -> { - String yearMonth = period != null ? period : getCurrentYearMonth(); - log.debug("Processing monthly metrics for product: {}, month: {}", dto.getProductId(), yearMonth); - - return new ProductMetricsMonthly( - dto.getProductId(), - dto.getLikeCount(), - dto.getOrderCount(), - dto.getViewCount(), - yearMonth - ); - }; - } + // 기존 MV 데이터 삭제 + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly WHERE period_yyyymm = ?", yearMonth); + log.info("Cleared existing monthly MV data for period: {}", yearMonth); - @Bean - public ItemWriter monthlyMetricsWriter() { - return new JpaItemWriterBuilder() - .entityManagerFactory(entityManagerFactory) - .build(); - } + // Daily 테이블에서 월간 집계하여 TOP 100 데이터를 MV 테이블에 삽입 + String insertSQL = """ + INSERT INTO mv_product_rank_monthly ( + product_id, period_yyyymm, ranking, score, + like_count, order_count, view_count, created_at, updated_at + ) + SELECT + product_id, + ? as period_yyyymm, + ROW_NUMBER() OVER (ORDER BY (0.1 * SUM(view_count) + 0.2 * SUM(like_count) + 0.6 * SUM(order_count)) DESC) as ranking, + (0.1 * SUM(view_count) + 0.2 * SUM(like_count) + 0.6 * SUM(order_count)) as score, + SUM(like_count) as like_count, + SUM(order_count) as order_count, + SUM(view_count) as view_count, + NOW(), + NOW() + FROM product_metrics_daily + WHERE period_yyyymmdd >= ? AND period_yyyymmdd <= ? + GROUP BY product_id + ORDER BY score DESC + LIMIT 100 + """; - private String getCurrentYearMonth() { - return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); - } + int insertedCount = jdbcTemplate.update(insertSQL, yearMonth, startDate, endDate); + log.info("Inserted {} records into mv_product_rank_monthly for period: {} (from {} to {})", + insertedCount, yearMonth, startDate, endDate); - public static class MonthlyMetricsDto { - private Long productId; - private Integer likeCount; - private Integer orderCount; - private Integer viewCount; + return RepeatStatus.FINISHED; + }; + } - // getters and setters - public Long getProductId() { return productId; } - public void setProductId(Long productId) { this.productId = productId; } - public Integer getLikeCount() { return likeCount; } - public void setLikeCount(Integer likeCount) { this.likeCount = likeCount; } - public Integer getOrderCount() { return orderCount; } - public void setOrderCount(Integer orderCount) { this.orderCount = orderCount; } - public Integer getViewCount() { return viewCount; } - public void setViewCount(Integer viewCount) { this.viewCount = viewCount; } - } + @Bean + public Step monthlyTop100MVUpdateStep() { + return new StepBuilder("monthlyTop100MVUpdateStep", jobRepository) + .tasklet(monthlyTop100MVUpdateTasklet(null), transactionManager) + .build(); + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java index 7efce2759..d0289f369 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java @@ -1,34 +1,24 @@ package com.loopers.batch.job; -import com.loopers.domain.ranking.ProductMetricsWeekly; +import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.batch.item.ItemReader; -import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.database.JdbcPagingItemReader; -import org.springframework.batch.item.database.JpaItemWriter; -import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; -import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; -import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; -import org.springframework.batch.item.database.Order; -import com.loopers.domain.metrics.ProductMetricsRepository; -import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.PlatformTransactionManager; import javax.sql.DataSource; -import java.util.Map; -import jakarta.persistence.EntityManagerFactory; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.temporal.WeekFields; @@ -39,94 +29,85 @@ @RequiredArgsConstructor public class WeeklyRankingJobConfig { - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - @Qualifier("dataSource") - private final DataSource dataSource; - private final EntityManagerFactory entityManagerFactory; - private final ProductMetricsRepository productMetricsRepository; - - @Bean - public Job weeklyRankingJob() { - return new JobBuilder("weeklyRankingJob", jobRepository) - .start(weeklyRankingStep()) - .build(); - } - - @Bean - public Step weeklyRankingStep() { - return new StepBuilder("weeklyRankingStep", jobRepository) - .chunk(1000, transactionManager) - .reader(weeklyMetricsReader()) - .processor(weeklyMetricsProcessor(null)) - .writer(weeklyMetricsWriter()) - .build(); - } - - @Bean - public ItemReader weeklyMetricsReader() { - MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); - queryProvider.setSelectClause("pm.product_id, COALESCE(SUM(pm.like_count), 0) as like_count, COALESCE(SUM(pm.sales_revenue), 0) as order_count, COALESCE(SUM(pm.view_count), 0) as view_count"); - queryProvider.setFromClause("product_metrics pm"); - queryProvider.setWhereClause("pm.bucket_time_key >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 7 DAY), '%Y%m%d%H')"); - queryProvider.setGroupClause("pm.product_id"); - queryProvider.setSortKeys(Map.of("pm.product_id", Order.ASCENDING)); - - return new JdbcPagingItemReaderBuilder() - .name("weeklyMetricsReader") - .dataSource(dataSource) - .queryProvider(queryProvider) - .pageSize(1000) - .rowMapper(new BeanPropertyRowMapper<>(WeeklyMetricsDto.class)) - .build(); - } - - @Bean - @StepScope - public ItemProcessor weeklyMetricsProcessor( - @Value("#{jobParameters['period']}") String period) { - return dto -> { - String yearMonthWeek = period != null ? period : getCurrentYearMonthWeek(); - log.debug("Processing weekly metrics for product: {}, week: {}", dto.getProductId(), yearMonthWeek); - - return new ProductMetricsWeekly( - dto.getProductId(), - dto.getLikeCount(), - dto.getOrderCount(), - dto.getViewCount(), - yearMonthWeek - ); - }; - } - - @Bean - public ItemWriter weeklyMetricsWriter() { - return new JpaItemWriterBuilder() - .entityManagerFactory(entityManagerFactory) - .build(); - } - - private String getCurrentYearMonthWeek() { - LocalDate now = LocalDate.now(); - WeekFields weekFields = WeekFields.of(Locale.getDefault()); - int weekOfYear = now.get(weekFields.weekOfYear()); - return now.format(DateTimeFormatter.ofPattern("yyyy")) + String.format("%02d", weekOfYear); - } - - public static class WeeklyMetricsDto { - private Long productId; - private Integer likeCount; - private Integer orderCount; - private Integer viewCount; - - // getters and setters - public Long getProductId() { return productId; } - public void setProductId(Long productId) { this.productId = productId; } - public Integer getLikeCount() { return likeCount; } - public void setLikeCount(Integer likeCount) { this.likeCount = likeCount; } - public Integer getOrderCount() { return orderCount; } - public void setOrderCount(Integer orderCount) { this.orderCount = orderCount; } - public Integer getViewCount() { return viewCount; } - public void setViewCount(Integer viewCount) { this.viewCount = viewCount; } - } -} \ No newline at end of file + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final EntityManagerFactory entityManagerFactory; + + @Bean + public Job weeklyRankingMVUpdateJob() { + return new JobBuilder("weeklyRankingMVUpdateJob", jobRepository) + .start(weeklyTop100MVUpdateStep()) + .build(); + } + + + private String getCurrentYearMonthWeek() { + LocalDate now = LocalDate.now(); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + int weekOfYear = now.get(weekFields.weekOfYear()); + return now.format(DateTimeFormatter.ofPattern("yyyy")) + String.format("%02d", weekOfYear); + } + + + @Bean + @StepScope + public Tasklet weeklyTop100MVUpdateTasklet(@Value("#{jobParameters['date']}") String date) { + return (contribution, chunkContext) -> { + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + log.info("Starting weekly TOP 100 MV update..."); + + String currentDate = date != null ? date : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + LocalDate targetDate = LocalDate.parse(currentDate, DateTimeFormatter.ofPattern("yyyyMMdd")); + + // 해당 주의 월요일과 일요일 계산 + LocalDate weekStart = targetDate.with(java.time.DayOfWeek.MONDAY); + LocalDate weekEnd = targetDate.with(java.time.DayOfWeek.SUNDAY); + + String startDate = weekStart.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String endDate = weekEnd.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yearMonthWeek = getCurrentYearMonthWeek(); + + // 기존 MV 데이터 삭제 + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly WHERE period_yyyyww = ?", yearMonthWeek); + log.info("Cleared existing weekly MV data for period: {}", yearMonthWeek); + + // Daily 테이블에서 주간 집계하여 TOP 100 데이터를 MV 테이블에 삽입 + String insertSQL = """ + INSERT INTO mv_product_rank_weekly ( + product_id, period_yyyyww, ranking, score, + like_count, order_count, view_count, created_at, updated_at + ) + SELECT + product_id, + ? as period_yyyyww, + ROW_NUMBER() OVER (ORDER BY (0.1 * SUM(view_count) + 0.2 * SUM(like_count) + 0.6 * SUM(order_count)) DESC) as ranking, + (0.1 * SUM(view_count) + 0.2 * SUM(like_count) + 0.6 * SUM(order_count)) as score, + SUM(like_count) as like_count, + SUM(order_count) as order_count, + SUM(view_count) as view_count, + NOW(), + NOW() + FROM product_metrics_daily + WHERE period_yyyymmdd >= ? AND period_yyyymmdd <= ? + GROUP BY product_id + ORDER BY score DESC + LIMIT 100 + """; + + int insertedCount = jdbcTemplate.update(insertSQL, yearMonthWeek, startDate, endDate); + log.info("Inserted {} records into mv_product_rank_weekly for period: {} (from {} to {})", + insertedCount, yearMonthWeek, startDate, endDate); + + return RepeatStatus.FINISHED; + }; + } + + @Bean + public Step weeklyTop100MVUpdateStep() { + return new StepBuilder("weeklyTop100MVUpdateStep", jobRepository) + .tasklet(weeklyTop100MVUpdateTasklet(null), transactionManager) + .build(); + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/runner/BatchJobRunner.java b/apps/commerce-batch/src/main/java/com/loopers/batch/runner/BatchJobRunner.java index d129e54f5..778b9c292 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/runner/BatchJobRunner.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/runner/BatchJobRunner.java @@ -3,49 +3,53 @@ import com.loopers.batch.scheduler.RankingBatchScheduler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobParametersBuilder; import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.context.properties.ConfigurationProperties; +import com.loopers.config.BatchJobProperties; import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor -@ConfigurationProperties(prefix = "batch") public class BatchJobRunner implements CommandLineRunner { - private final RankingBatchScheduler rankingBatchScheduler; - - private String jobName; - - @Override - public void run(String... args) throws Exception { - if (jobName == null || jobName.isEmpty()) { - log.info("No batch job specified. Application will exit."); - return; - } - - log.info("Starting batch job: {}", jobName); - - switch (jobName.toLowerCase()) { - case "weekly-ranking": - rankingBatchScheduler.runWeeklyRankingJob(); - break; - case "monthly-ranking": - rankingBatchScheduler.runMonthlyRankingJob(); - break; - default: - log.error("Unknown job name: {}", jobName); - throw new IllegalArgumentException("Unknown job name: " + jobName); - } - - log.info("Batch job completed: {}", jobName); - } + private final RankingBatchScheduler rankingBatchScheduler; + private final BatchJobProperties properties; + + @Override + public void run(String... args) { + String jobKey = properties.getJobName(); - public void setJobName(String jobName) { - this.jobName = jobName; + if (jobKey == null || jobKey.isBlank()) { + log.info("No batch job specified. Application will exit."); + return; } - public String getJobName() { - return jobName; + log.info("Starting batch job: {}", jobKey); + JobParametersBuilder builder = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()); // JobInstance 구분용 + + for (String arg : args) { + if (!arg.startsWith("--") || !arg.contains("=")) { + continue; + } + + String[] tokens = arg.substring(2).split("=", 2); + String key = tokens[0]; + String value = tokens[1]; + + // Spring Boot 내부 파라미터 제외 + if (key.equals("job.name")) { + continue; + } + + builder.addString(key, value); } -} \ No newline at end of file + + log.info("Batch job completed: {}", builder.toJobParameters().toString()); + rankingBatchScheduler.run(jobKey, builder.toJobParameters()); + + log.info("Batch job completed: {}", jobKey); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/scheduler/RankingBatchScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/batch/scheduler/RankingBatchScheduler.java index 777350ac0..e978b2374 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/scheduler/RankingBatchScheduler.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/scheduler/RankingBatchScheduler.java @@ -9,68 +9,28 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; +import java.util.Map; + @Slf4j @Component @RequiredArgsConstructor public class RankingBatchScheduler { - private final JobLauncher jobLauncher; - - @Qualifier("weeklyRankingJob") - private final Job weeklyRankingJob; - - @Qualifier("monthlyRankingJob") - private final Job monthlyRankingJob; + private final JobLauncher jobLauncher; + private final Map jobs; - public void runWeeklyRankingJob() { - runWeeklyRankingJob(null); - } - - public void runWeeklyRankingJob(String period) { - try { - log.info("Starting weekly ranking batch job for period: {}", period); - - JobParametersBuilder builder = new JobParametersBuilder() - .addLong("time", System.currentTimeMillis()); - - if (period != null) { - builder.addString("period", period); - } - - JobParameters jobParameters = builder.toJobParameters(); - - jobLauncher.run(weeklyRankingJob, jobParameters); - log.info("Weekly ranking batch job completed successfully"); - - } catch (Exception e) { - log.error("Failed to run weekly ranking batch job", e); - throw new RuntimeException("Weekly ranking batch job failed", e); - } - } + public void run(String jobKey, JobParameters params) { + Job job = jobs.get(jobKey); - public void runMonthlyRankingJob() { - runMonthlyRankingJob(null); + if (job == null) { + throw new IllegalArgumentException("No such job: " + jobKey); } - - public void runMonthlyRankingJob(String period) { - try { - log.info("Starting monthly ranking batch job for period: {}", period); - - JobParametersBuilder builder = new JobParametersBuilder() - .addLong("time", System.currentTimeMillis()); - - if (period != null) { - builder.addString("period", period); - } - - JobParameters jobParameters = builder.toJobParameters(); - - jobLauncher.run(monthlyRankingJob, jobParameters); - log.info("Monthly ranking batch job completed successfully"); - - } catch (Exception e) { - log.error("Failed to run monthly ranking batch job", e); - throw new RuntimeException("Monthly ranking batch job failed", e); - } + + try { + jobLauncher.run(job, params); + } catch (Exception e) { + throw new RuntimeException("Failed to run job: " + jobKey, e); } -} \ No newline at end of file + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java b/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java new file mode 100644 index 000000000..035c454d5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java @@ -0,0 +1,17 @@ +package com.loopers.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "batch") +public class BatchJobProperties { + + /** + * 실행할 배치 잡 이름 + * dailyRankingDataProcessingJob | weeklyRankingMVUpdateJob | monthlyRankingMVUpdateJob + */ + private String jobName; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsDaily.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsDaily.java new file mode 100644 index 000000000..2763539b7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsDaily.java @@ -0,0 +1,49 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics_daily_working", + indexes = { + @Index(name = "idx_daily_period_yyyymmdd", columnList = "period_yyyymmdd"), + @Index(name = "idx_daily_product_date", columnList = "productId, period_yyyymmdd") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_product_daily", columnNames = {"productId", "period_yyyymmdd"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsDaily extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Column(nullable = false) + private Integer orderCount = 0; + + @Column(nullable = false) + private Integer viewCount = 0; + + @Column(nullable = false, length = 8, name = "period_yyyymmdd") + private String yearMonthDay; + + public ProductMetricsDaily(Long productId, String yearMonthDay) { + this.productId = productId; + this.yearMonthDay = yearMonthDay; + } + + public ProductMetricsDaily(Long productId, Integer likeCount, Integer orderCount, Integer viewCount, String yearMonthDay) { + this.productId = productId; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.yearMonthDay = yearMonthDay; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsMonthly.java index 0979ca418..77b3745be 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsMonthly.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsMonthly.java @@ -7,7 +7,7 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "product_metrics_monthly", +@Table(name = "product_metrics_monthly_working", indexes = { @Index(name = "idx_monthly_period_yyyymm", columnList = "period_yyyymm"), }) diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsWeekly.java index 98df95416..ff6bc8861 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsWeekly.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsWeekly.java @@ -7,7 +7,7 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "product_metrics_weekly", +@Table(name = "product_metrics_weekly_working", indexes = { @Index(name = "idx_weekly_period_yyyyww", columnList = "period_yyyyww"), @Index(name = "idx_weekly_product_week", columnList = "productId, period_yyyyww") diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index b35b18d59..049115c5f 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -14,6 +14,17 @@ spring: jdbc: initialize-schema: always # Batch 메타 테이블 생성 + data: + redis: + host: localhost + port: 6379 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + management: endpoints: web: @@ -39,7 +50,7 @@ spring: jpa: show-sql: true hibernate: - ddl-auto: create + ddl-auto: none properties: hibernate: format_sql: true @@ -61,7 +72,7 @@ spring: jpa: show-sql: true hibernate: - ddl-auto: create-drop + ddl-auto: none properties: hibernate: jdbc: diff --git a/argo-workflows/weekly-ranking-workflow.yaml b/argo-workflows/weekly-ranking-workflow.yaml new file mode 100644 index 000000000..6a04636ef --- /dev/null +++ b/argo-workflows/weekly-ranking-workflow.yaml @@ -0,0 +1,145 @@ +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: daily-ranking-workflow + namespace: default +spec: + entrypoint: daily-ranking + arguments: + parameters: + - name: date + value: "{{ workflow.parameters.date }}" + templates: + - name: daily-ranking + dag: + tasks: + # 1. 데이터 처리 (중요한 부분 - 실패하면 전체 워크플로 실패) + - name: data-processing + template: data-processing-step + arguments: + parameters: + - name: date + value: "{{ workflow.parameters.date }}" + + # 2. 캐시 업데이트 (실패해도 워크플로 계속 진행) + - name: cache-update + template: cache-update-step + depends: data-processing + continueOn: + failed: true + arguments: + parameters: + - name: date + value: "{{ workflow.parameters.date }}" + + # 3. 캐시 재시도 (캐시가 실패했을 때만 실행) + - name: retry-cache + template: cache-retry-step + depends: cache-update.Failed + arguments: + parameters: + - name: date + value: "{{ workflow.parameters.date }}" + + # 데이터 처리 단계 (clearWorkingTables + weeklyRanking + monthlyRanking + validation + tableSwap) + - name: data-processing-step + inputs: + parameters: + - name: date + container: + image: your-registry/commerce-batch:latest + command: ["java"] + args: + - "-jar" + - "commerce-batch.jar" + - "--spring.batch.job.names=dailyRankingDataProcessingJob" + - "--date={{ inputs.parameters.date }}" + env: + - name: SPRING_PROFILES_ACTIVE + value: "k8s" + - name: JAVA_OPTS + value: "-Xmx2g -Xms1g" + resources: + requests: + memory: "2Gi" + cpu: "1000m" + limits: + memory: "4Gi" + cpu: "2000m" + + # 캐시 업데이트 단계 (주간/월간 Redis 업데이트) + - name: cache-update-step + inputs: + parameters: + - name: date + container: + image: your-registry/commerce-batch:latest + command: ["java"] + args: + - "-jar" + - "commerce-batch.jar" + - "--spring.batch.job.names=dailyRankingCacheUpdateJob" + - "--date={{ inputs.parameters.date }}" + env: + - name: SPRING_PROFILES_ACTIVE + value: "k8s" + - name: JAVA_OPTS + value: "-Xmx1g -Xms512m" + resources: + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "1000m" + + # 캐시 재시도 단계 (5분 간격으로 3번 재시도) + - name: cache-retry-step + inputs: + parameters: + - name: date + retryStrategy: + limit: 3 + backoff: + duration: "5m" + factor: 1 + maxDuration: "15m" + container: + image: your-registry/commerce-batch:latest + command: ["java"] + args: + - "-jar" + - "commerce-batch.jar" + - "--spring.batch.job.names=dailyRankingCacheUpdateJob" + - "--date={{ inputs.parameters.date }}" + env: + - name: SPRING_PROFILES_ACTIVE + value: "k8s" + - name: JAVA_OPTS + value: "-Xmx1g -Xms512m" + resources: + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "1000m" + +--- +apiVersion: argoproj.io/v1alpha1 +kind: CronWorkflow +metadata: + name: daily-ranking-cron + namespace: default +spec: + schedule: "0 1 * * *" # 매일 새벽 1시 + timezone: "Asia/Seoul" + workflowSpec: + entrypoint: daily-ranking + arguments: + parameters: + - name: date + # 현재 날짜를 YYYYMMDD 형식으로 생성 (예: 20240115) + value: "{{ workflow.creationTimestamp.strftime('%Y%m%d') }}" + workflowTemplateRef: + name: daily-ranking-workflow \ No newline at end of file From a871e7eaec21213a7d7a51638d9b486d26602b7f Mon Sep 17 00:00:00 2001 From: sieun0322 Date: Fri, 2 Jan 2026 03:05:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20mv=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EC=8B=9C=20,=20Redis=20=EC=BA=90=EC=8B=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/job/MonthlyRankingJobConfig.java | 19 +++++++++++++++++++ .../batch/job/WeeklyRankingJobConfig.java | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java index 42a95efb3..656faaf04 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/MonthlyRankingJobConfig.java @@ -22,6 +22,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.data.redis.core.RedisTemplate; @Slf4j @Configuration @@ -32,6 +33,7 @@ public class MonthlyRankingJobConfig { private final PlatformTransactionManager transactionManager; private final DataSource dataSource; private final EntityManagerFactory entityManagerFactory; + private final RedisTemplate redisTemplate; @Bean public Job monthlyRankingMVUpdateJob() { @@ -89,6 +91,11 @@ INSERT INTO mv_product_rank_monthly ( log.info("Inserted {} records into mv_product_rank_monthly for period: {} (from {} to {})", insertedCount, yearMonth, startDate, endDate); + // Redis 캐시 삭제 + String monthlyPattern = "ranking:monthly:" + yearMonth + "M:*"; + deleteRedisCacheByPattern(monthlyPattern); + log.info("Cleared Redis cache for monthly pattern: {}", monthlyPattern); + return RepeatStatus.FINISHED; }; } @@ -99,4 +106,16 @@ public Step monthlyTop100MVUpdateStep() { .tasklet(monthlyTop100MVUpdateTasklet(null), transactionManager) .build(); } + + private void deleteRedisCacheByPattern(String pattern) { + try { + var keys = redisTemplate.keys(pattern); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + log.debug("Deleted {} cache keys matching pattern: {}", keys.size(), pattern); + } + } catch (Exception e) { + log.warn("Failed to clear Redis cache for pattern: {}", pattern, e); + } + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java index d0289f369..ed1284b49 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/WeeklyRankingJobConfig.java @@ -17,6 +17,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.data.redis.core.RedisTemplate; import javax.sql.DataSource; import java.time.LocalDate; @@ -33,6 +34,7 @@ public class WeeklyRankingJobConfig { private final PlatformTransactionManager transactionManager; private final DataSource dataSource; private final EntityManagerFactory entityManagerFactory; + private final RedisTemplate redisTemplate; @Bean public Job weeklyRankingMVUpdateJob() { @@ -99,6 +101,11 @@ INSERT INTO mv_product_rank_weekly ( log.info("Inserted {} records into mv_product_rank_weekly for period: {} (from {} to {})", insertedCount, yearMonthWeek, startDate, endDate); + // Redis 캐시 삭제 + String weeklyPattern = "ranking:weekly:" + yearMonthWeek + "W:*"; + deleteRedisCacheByPattern(weeklyPattern); + log.info("Cleared Redis cache for weekly pattern: {}", weeklyPattern); + return RepeatStatus.FINISHED; }; } @@ -110,4 +117,16 @@ public Step weeklyTop100MVUpdateStep() { .build(); } + private void deleteRedisCacheByPattern(String pattern) { + try { + var keys = redisTemplate.keys(pattern); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + log.debug("Deleted {} cache keys matching pattern: {}", keys.size(), pattern); + } + } catch (Exception e) { + log.warn("Failed to clear Redis cache for pattern: {}", pattern, e); + } + } + }