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 4b1c1cd28..9117bb534 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 @@ -1,12 +1,12 @@ package com.loopers.application.like; -import com.loopers.application.like.event.LikeCanceledEvent; -import com.loopers.application.like.event.LikeCreatedEvent; import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductLikeCountService; import com.loopers.domain.product.ProductService; +import com.loopers.messaging.event.LikeCanceledEvent; +import com.loopers.messaging.event.LikeCreatedEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java index 0365b408d..f3a189a6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java @@ -4,6 +4,8 @@ import com.loopers.domain.common.event.DomainEventEnvelop; import com.loopers.domain.common.event.DomainEventRepository; import com.loopers.domain.product.ProductLikeCountService; +import com.loopers.messaging.event.LikeCanceledEvent; +import com.loopers.messaging.event.LikeCreatedEvent; import com.loopers.support.json.JsonConverter; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Async; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java index 3d35a9891..799ddd99a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java @@ -3,10 +3,10 @@ import com.loopers.application.order.OrderExternalSystemSender; import com.loopers.application.order.OrderFacade; import com.loopers.application.payment.event.PaymentFailedEvent; -import com.loopers.application.payment.event.PaymentSucceededEvent; import com.loopers.domain.common.event.DomainEvent; import com.loopers.domain.common.event.DomainEventEnvelop; import com.loopers.domain.common.event.DomainEventRepository; +import com.loopers.messaging.event.OrderPaidEvent; import com.loopers.support.json.JsonConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,8 +31,8 @@ public class OrderEventHandler { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handle(PaymentSucceededEvent event) { - log.info("๐Ÿ”ฅ PaymentSucceededEvent handler ์ง„์ž…"); + public void handle(OrderPaidEvent event) { + log.info("๐Ÿ”ฅ OrderPaidEvent handler ์ง„์ž…"); orderFacade.handleOrderSucceed(event.orderId()); } @@ -48,7 +48,7 @@ public void handle(PaymentFailedEvent event) { */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async - void handleOrderCreatedExternalSend(PaymentSucceededEvent event) { + void handleOrderCreatedExternalSend(OrderPaidEvent event) { try { orderExternalSystemSender.send(event.orderId()); } catch (Exception e) { @@ -61,9 +61,9 @@ void handleOrderCreatedExternalSend(PaymentSucceededEvent event) { * ๊ฒฐ์ œ ์„ฑ๊ณต ์‹œ, outbox ํ…Œ์ด๋ธ”์— ์ด๋ฒคํŠธ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. */ @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) - public void handleOutboxEvent(PaymentSucceededEvent event) { + public void handleOutboxEvent(OrderPaidEvent event) { - DomainEventEnvelop envelop = + DomainEventEnvelop envelop = DomainEventEnvelop.of( "ORDER_PAID", "ORDER", diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java deleted file mode 100644 index b38e76181..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.application.order.event; - -import java.time.Instant; - -public record OrderPaidEvent( - Long orderId, - Instant occurredAt -) { - public static OrderPaidEvent from(Long orderId) { - return new OrderPaidEvent( - orderId, - Instant.now() - ); - } -} 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 d375f570d..b7c0a1a43 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 @@ -1,7 +1,6 @@ package com.loopers.application.payment; import com.loopers.application.order.event.OrderCreated; -import com.loopers.application.payment.event.PaymentSucceededEvent; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderService; import com.loopers.domain.payment.Payment; @@ -9,6 +8,7 @@ import com.loopers.domain.point.PointService; import com.loopers.infrastructure.ResilientPgClient; import com.loopers.interfaces.api.payment.PaymentV1Dto; +import com.loopers.messaging.event.OrderPaidEvent; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -17,6 +17,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Component @RequiredArgsConstructor @@ -51,7 +53,16 @@ public void pay(Long userId, Long orderId) { case POINT -> { try { pointService.usePoint(payment.getUserId(), payment.getAmount()); - eventPublisher.publishEvent(PaymentSucceededEvent.from(orderId)); + + List items = + order.getItems().stream() + .map(item -> new OrderPaidEvent.OrderItemData( + item.getProductId(), + item.getQuantity(), + item.getTotalPrice().getAmount() + )).toList(); + + eventPublisher.publishEvent(OrderPaidEvent.of(orderId, items)); } catch (IllegalArgumentException e) { throw new CoreException(ErrorType.BAD_REQUEST, e.getMessage()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java index b27ef9c09..a59fe0ff3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java @@ -1,19 +1,26 @@ package com.loopers.application.payment; import com.loopers.application.payment.event.PaymentFailedEvent; -import com.loopers.application.payment.event.PaymentSucceededEvent; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; +import com.loopers.messaging.event.OrderPaidEvent; +import com.loopers.messaging.event.OrderPaidEvent.OrderItemData; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.Collectors; + @Slf4j @Component @RequiredArgsConstructor public class PaymentProcessService { private final PaymentFacade paymentFacade; + private final OrderService orderService; // Added OrderService injection private final ApplicationEventPublisher eventPublisher; @@ -28,12 +35,19 @@ public void process(Long userId, Long orderId) { } @Transactional - public void processPg(Long userId, Long orderId) { + public void processPg(Long userId, Long orderId) { // Added userId parameter try { paymentFacade.payPg(orderId); - eventPublisher.publishEvent(PaymentSucceededEvent.from(orderId)); + + Order order = orderService.findOrderById(orderId) + .orElseThrow(() -> new IllegalStateException("๊ฒฐ์ œ๋œ ์ฃผ๋ฌธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. orderId: " + orderId)); + + List orderItemDataList = order.getItems().stream() + .map(item -> new OrderItemData(item.getProductId(), item.getQuantity(), item.getUnitPrice().getAmount())) + .collect(Collectors.toList()); + + eventPublisher.publishEvent(OrderPaidEvent.of(orderId, orderItemDataList)); } catch (Exception e) { - // ์ด์™ธ ์„œ๋ฒ„ ํƒ€์ž„์•„์›ƒ ๋“ฑ์€ retry -> pending์ƒํƒœ๋กœ ์Šค์ผ€์ค„๋ง ์‹œ๋„ log.error("์™ธ๋ถ€ PG ๊ฒฐ์ œ ์‹คํŒจ, ์ฃผ๋ฌธ ID: {}", orderId, e); eventPublisher.publishEvent(PaymentFailedEvent.of(userId, orderId, e)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java deleted file mode 100644 index d6559f865..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.application.payment.event; - -import java.time.Instant; - -public record PaymentSucceededEvent( - Long orderId, - Instant occurredAt -) { - public static PaymentSucceededEvent from(Long orderId) { - return new PaymentSucceededEvent( - orderId, - Instant.now() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index 9fbcc1c1c..d3cc73bee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -10,16 +10,18 @@ public record ProductDetailInfo( Long brandId, String brandName, BigDecimal priceAmount, - int likeCount + int likeCount, + Long rank ) { - public static ProductDetailInfo from(ProductDetailProjection p) { + public static ProductDetailInfo of(ProductDetailProjection p, Long rank) { return new ProductDetailInfo( p.getProductId(), p.getProductName(), p.getBrandId(), p.getBrandName(), p.getPrice().getAmount(), - p.getLikeCount() + p.getLikeCount(), + rank ); } } 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 48ede147f..08553d046 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 @@ -1,10 +1,11 @@ package com.loopers.application.product; import com.fasterxml.jackson.core.type.TypeReference; -import com.loopers.application.product.event.ProductViewedEvent; import com.loopers.cache.CacheKeyService; import com.loopers.cache.CacheService; import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.RankingService; +import com.loopers.messaging.event.ProductViewedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -14,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Duration; +import java.time.LocalDate; @RequiredArgsConstructor @Component @@ -26,6 +28,7 @@ public class ProductFacade { private final ProductQueryService productQueryService; private final ApplicationEventPublisher eventPublisher; + private final RankingService rankingService; private static final Duration TTL_LIST = Duration.ofMinutes(10); private static final Duration TTL_DETAIL = Duration.ofMinutes(5); @@ -55,9 +58,11 @@ private Page loadProducts(Long brandId, Pageable pageable, String s public ProductDetailInfo getProductDetail(Long productId) { String key = "product:v1:detail:" + productId; + Long rank = rankingService.getRanking(LocalDate.now(), productId); + ProductDetailInfo productDetailInfo = cacheService.getOrLoad( key, - () -> productService.getProductDetail(productId), + () -> ProductDetailInfo.of(productService.getProductDetail(productId), rank), TTL_DETAIL, ProductDetailInfo.class); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java new file mode 100644 index 000000000..e9f22988d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java @@ -0,0 +1,42 @@ +package com.loopers.application.product.event; + +import com.loopers.domain.common.event.DomainEvent; +import com.loopers.domain.common.event.DomainEventEnvelop; +import com.loopers.domain.common.event.DomainEventRepository; +import com.loopers.messaging.event.ProductViewedEvent; +import com.loopers.support.json.JsonConverter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductViewedEventHandler { + + private final DomainEventRepository eventRepository; + private final JsonConverter jsonConverter; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleProductViewedEvent(ProductViewedEvent event) { + log.info("ProductViewedEvent received: productId={}, occurredAt={}", event.productId(), event.occurredAt()); + + DomainEventEnvelop envelop = + DomainEventEnvelop.of( + "PRODUCT_VIEWED", + "PRODUCT", + event.productId(), + event + ); + + eventRepository.save( + DomainEvent.pending( + "catalog-events", + envelop, + jsonConverter.serialize(envelop) + ) + ); + } +} 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 new file mode 100644 index 000000000..04458f9dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,82 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductLikeCountService; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.RankingService; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingProductResponse; +import com.loopers.ranking.streamer.RankingInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingFacade { + private final RankingService rankingService; + private final ProductService productService; + private final ProductLikeCountService productLikeCountService; + + public Page getRankings(LocalDate rankingDate, Pageable pageable) { + + // 1. Redis ZSET์—์„œ ๋žญํ‚น ์ƒํ’ˆ ID์™€ ์ ์ˆ˜๋ฅผ ์กฐํšŒ + List rankedProducts = rankingService.getRankings(rankingDate, pageable); + long totalCount = rankingService.getTotalCount(rankingDate); + + // 2. ๋žญํ‚น ์ƒํ’ˆ ID๋“ค์„ ์ถ”์ถœ + List productIds = rankedProducts.stream() + .map(RankingInfo::id) + .toList(); + + // 3. ProductService ํ†ตํ•ด ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ (์ƒํ’ˆ ID ์ˆœ์„œ ์œ ์ง€๋ฅผ ์œ„ํ•ด Map ์‚ฌ์šฉ) + Map productInfos = productService.getProductsMapByIds(productIds); + + // 4. ์กฐํšŒ๋œ ์ƒํ’ˆ ์ •๋ณด์™€ ๋žญํ‚น ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒฐํ•ฉํ•˜์—ฌ ์‘๋‹ต DTO ์ƒ์„ฑ + List rankingResponses = rankedProducts.stream() + .map(ranking -> { + Product product = productInfos.get(ranking.id()); + + if (product == null) { + log.warn("์ƒํ’ˆ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ƒํ’ˆID : {}", ranking.id()); + return null; + } + + // ์ข‹์•„์š” ์ˆ˜๋ฅผ ProductLikeCountService์—์„œ ์กฐํšŒ (์—†์œผ๋ฉด 0) + int likeCount = productLikeCountService.findById(product.getId()) + .map(pc -> pc.getLikeCount()) + .orElse(0); + + return new RankingProductResponse( + product.getId(), + product.getName(), + product.getBrandId(), + product.getPrice().getAmount(), + likeCount, + ranking.score(), + ranking.rank(), + LocalDateTime.now() // createdAt? + ); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + Page responsePage = new PageImpl<>( + rankingResponses, + pageable, + totalCount + ); + + return responsePage; + }; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 3a927a0ff..6a060018f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,6 +1,5 @@ package com.loopers.domain.product; -import com.loopers.application.product.ProductDetailInfo; import com.loopers.application.product.ProductInfo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -21,19 +20,6 @@ public class ProductService { private final ProductRepository productRepository; - public Page getProductsV1(Long brandId, Pageable pageable, String sort) { - Pageable pageRequest = PageRequest.of( - pageable.getPageNumber(), - pageable.getPageSize(), - ProductSortType.toSort(sort) - ); - if (brandId != null) { - return productRepository.findAllByBrandIdWithLikeCountV1(brandId, pageRequest).map(ProductInfo::from); - } else { - return productRepository.findAllWithLikeCountV1(pageRequest).map(ProductInfo::from); - } - } - public Page getProducts(Long brandId, Pageable pageable, String sort) { Pageable pageRequest = PageRequest.of( pageable.getPageNumber(), @@ -53,10 +39,6 @@ public Product getProduct(Long id) { ); } - public List findAll(List productIds) { - return productRepository.findAllById(productIds); - } - public Map getProductsMapByIds(List productIds) { List products = productRepository.findAllById(productIds); @@ -67,10 +49,9 @@ public Map getProductsMapByIds(List productIds) { .collect(Collectors.toMap(Product::getId, product -> product)); } - public ProductDetailInfo getProductDetail(Long productId) { - ProductDetailProjection productDetailProjection = productRepository.findByIdWithBrandAndLikeCount(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); + public ProductDetailProjection getProductDetail(Long productId) { - return ProductDetailInfo.from(productDetailProjection); + return productRepository.findByIdWithBrandAndLikeCount(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..312630835 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,29 @@ +package com.loopers.domain.ranking; + +import com.loopers.ranking.streamer.RankingInfo; +import com.loopers.ranking.streamer.RankingReadRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RankingService { + private final RankingReadRepository rankingReadRepository; + + + public List getRankings(LocalDate rankingDate, Pageable pageable) { + return rankingReadRepository.findPage(rankingDate, pageable.getPageNumber(), pageable.getPageSize()); + } + + public long getTotalCount(LocalDate rankingDate) { + return rankingReadRepository.findTotalCount(rankingDate); + } + + public Long getRanking(LocalDate rankingDate, long productId) { + return rankingReadRepository.findRank(rankingDate, productId); + } +} 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 new file mode 100644 index 000000000..0e30336b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingProductResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; + +@Tag(name = "Ranking V1 API", description = "๋žญํ‚น ์กฐํšŒ API์ž…๋‹ˆ๋‹ค.") +public interface RankingV1ApiSpec { + + @Operation( + summary = "์ƒํ’ˆ ๋žญํ‚น ๋ชฉ๋ก ์กฐํšŒ", + description = "์ง€์ •๋œ ๋‚ ์งœ ๊ธฐ์ค€์˜ ์ƒํ’ˆ ๋žญํ‚น ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + @GetMapping + ApiResponse> getRankings( + Pageable pageable + ); +} 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 new file mode 100644 index 000000000..458f16d4c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingProductResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller implements RankingV1ApiSpec { + + private final RankingFacade rankingFacade; + + @Override + public ApiResponse> getRankings( + Pageable pageable + ) { + LocalDate rankingDate = LocalDate.now(); + + Page page = rankingFacade.getRankings(rankingDate, pageable); + + return ApiResponse.success(ProductV1Dto.PageResponse.from(page)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 000000000..a8d9c507b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.product.ProductInfo; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class RankingV1Dto { + + public record RankingProductResponse( + Long productId, + String productName, + Long brandId, + BigDecimal price, + int likeCount, + long score, // ๋žญํ‚น ์Šค์ฝ”์–ด ์ถ”๊ฐ€ + Long rank, // ๋žญํ‚น ์ˆœ์œ„ ์ถ”๊ฐ€ + LocalDateTime createdAt + ) { + public static RankingProductResponse of(ProductInfo info, long score, Long rank) { + return new RankingProductResponse( + info.id(), + info.name(), + info.brandId(), + info.priceAmount(), + info.likeCount(), + score, + rank, + info.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/json/JsonConverter.java b/apps/commerce-api/src/main/java/com/loopers/support/json/JsonConverter.java new file mode 100644 index 000000000..f56cff6f7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/json/JsonConverter.java @@ -0,0 +1,23 @@ +package com.loopers.support.json; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JsonConverter { + + private final ObjectMapper objectMapper; + + public String serialize(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new CoreException(ErrorType.INTERNAL_ERROR, "Event JSON serialization failed"); + } + } +} diff --git a/apps/commerce-streamer/build.gradle.kts b/apps/commerce-streamer/build.gradle.kts index ed1e48f43..b11a71ae0 100644 --- a/apps/commerce-streamer/build.gradle.kts +++ b/apps/commerce-streamer/build.gradle.kts @@ -11,7 +11,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("com.mysql:mysql-connector-j") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") @@ -22,4 +22,9 @@ dependencies { testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) testImplementation(testFixtures(project(":modules:kafka"))) + + testRuntimeOnly("com.mysql:mysql-connector-j") + + testImplementation("org.awaitility:awaitility:4.2.0") + testImplementation("org.testcontainers:kafka") } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/RankingSyncScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/RankingSyncScheduler.java new file mode 100644 index 000000000..fa868d313 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/RankingSyncScheduler.java @@ -0,0 +1,81 @@ +package com.loopers.application; + +import com.loopers.cache.CacheKeyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingSyncScheduler { + + private final CacheKeyService cacheKeyService; + private final RedisTemplate redisTemplate; + + private static final double YESTERDAY_WEIGHT = 0.5; + private static final double TODAY_WEIGHT = 1.0; + + /** + * 5๋ถ„๋งˆ๋‹ค ์žฌ์ง‘๊ณ„ + * ์–ด์ œ 0.5, ์˜ค๋Š˜ 1 ๋น„์œจ๋กœ ๋ฐ˜์˜ + */ + @Scheduled(cron = "0 */5 * * * *") + public void recalculateRanking() { + + String todayKey = cacheKeyService.rankingKey(LocalDate.now()); + String yesterdayKey = cacheKeyService.rankingKey(LocalDate.now().minusDays(1)); + + // 1. ์˜ค๋Š˜ / ์–ด์ œ ZSET ์ „์ฒด ์ฝ๊ธฐ + Set> todaySet = + redisTemplate.opsForZSet().rangeWithScores(todayKey, 0, -1); + Set> yesterdaySet = + redisTemplate.opsForZSet().rangeWithScores(yesterdayKey, 0, -1); + + if (todaySet == null) todaySet = Collections.emptySet(); + if (yesterdaySet == null) yesterdaySet = Collections.emptySet(); + + // 2. productId -> score ํ•ฉ์‚ฐ ๋งต + Map scores = new HashMap<>(); + + // 2-1. ์–ด์ œ ์ ์ˆ˜ ๋จผ์ € ๋ฐ˜์˜ (weight 0.5) + for (ZSetOperations.TypedTuple tuple : yesterdaySet) { + String productId = tuple.getValue(); + Double score = tuple.getScore(); + if (productId == null || score == null) continue; + + scores.put(productId, score * YESTERDAY_WEIGHT); + } + + // 2-2. ์˜ค๋Š˜ ์ ์ˆ˜ 1.0 ๋น„์œจ๋กœ ํ•ฉ์‚ฐ + for (ZSetOperations.TypedTuple tuple : todaySet) { + String productId = tuple.getValue(); + Double score = tuple.getScore(); + if (productId == null || score == null) continue; + + double current = scores.getOrDefault(productId, 0.0); + scores.put(productId, current + score * TODAY_WEIGHT); + } + + // 3. ์˜ค๋Š˜ ํ‚ค๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜๊ณ  ์ƒˆ ์ ์ˆ˜๋กœ ๋‹ค์‹œ ์ฑ„์šฐ๊ธฐ + redisTemplate.delete(todayKey); + + ZSetOperations zset = redisTemplate.opsForZSet(); + for (Map.Entry entry : scores.entrySet()) { + zset.add(todayKey, entry.getKey(), entry.getValue()); + } + + // 4. TTL ๋‹ค์‹œ ์„ค์ • (2์ผ) + redisTemplate.expire(todayKey, Duration.ofDays(2)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java index fa7d7dba4..49e0da472 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java @@ -1,31 +1,40 @@ package com.loopers.domain.productmetrics; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDate; import java.time.ZonedDateTime; @Getter @Entity -@Table(name = "product_metrics") +@Table(name = "product_metrics", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "metric_date"}) + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProductMetrics { @Id - @Column(name = "product_id") + @GeneratedValue + private Long id; + private Long productId; + private LocalDate metricDate; + @Column(nullable = false) private int likeCount; @Column(nullable = false) private long salesCount; + @Column(nullable = false) + private long salesAmount; + @Column(nullable = false) private long viewCount; @@ -39,6 +48,7 @@ public ProductMetrics(Long productId) { this.productId = productId; this.likeCount = 0; this.salesCount = 0; + this.salesAmount = 0; this.viewCount = 0; this.createdAt = ZonedDateTime.now(); this.updatedAt = ZonedDateTime.now(); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java index bb22ffac5..0021983bb 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java @@ -1,16 +1,18 @@ package com.loopers.domain.productmetrics; +import java.math.BigDecimal; +import java.time.LocalDate; import java.util.Optional; public interface ProductMetricsRepository { Optional findByProductId(Long productId); - void upsertLikeCount(Long productId); + void upsertLikeCount(Long productId, LocalDate metricDate); - void upsertUnlikeCount(Long productId); + void upsertUnlikeCount(Long productId, LocalDate metricDate); - void upsertSalesCount(Long productId, int quantity); + void upsertSalesCount(Long productId, int quantity, BigDecimal amount, LocalDate metricDate); - void upsertViewCount(Long productId); + void upsertViewCount(Long productId, LocalDate metricDate); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java index 7de7191f5..e537920f1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java @@ -6,6 +6,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.util.Optional; +import java.time.LocalDate; + @Slf4j @Service @RequiredArgsConstructor @@ -14,26 +18,34 @@ public class ProductMetricsService { private final ProductMetricsJpaRepository productMetricsRepository; @Transactional - public void increaseSalesCount(Long productId, int quantity) { + public void increaseSalesCount(Long productId, int quantity, BigDecimal amount, LocalDate metricDate) { if (quantity <= 0) { - log.warn("ํŒ๋งค ์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ˆ˜๋Ÿ‰:{}, ์ƒํ’ˆID:{}", quantity, productId); + log.warn("ํŒ๋งค ์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ˆ˜๋Ÿ‰:{}, ์ƒํ’ˆID:{}, ์ผ์ž:{}", quantity, productId, metricDate); + return; + } + if (amount.compareTo(BigDecimal.ZERO) < 0){ + log.warn("ํŒ๋งค ๊ธˆ์•ก์ด 0 ์ดํ•˜์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ธˆ์•ก:{}, ์ƒํ’ˆID:{}", amount, productId); return; } - productMetricsRepository.upsertSalesCount(productId, quantity); + productMetricsRepository.upsertSalesCount(productId, quantity, amount, metricDate); } @Transactional - public void increaseLikeCount(Long productId) { - productMetricsRepository.upsertLikeCount(productId); + public void increaseLikeCount(Long productId, LocalDate metricDate) { + productMetricsRepository.upsertLikeCount(productId, metricDate); } @Transactional - public void decreaseLikeCount(Long productId) { - productMetricsRepository.upsertUnlikeCount(productId); + public void decreaseLikeCount(Long productId, LocalDate metricDate) { + productMetricsRepository.upsertUnlikeCount(productId, metricDate); } @Transactional - public void increaseViewCount(Long productId) { - productMetricsRepository.upsertViewCount(productId); + public void increaseViewCount(Long productId, LocalDate metricDate) { + productMetricsRepository.upsertViewCount(productId, metricDate); + } + + public Optional findByProductId(Long productId) { + return productMetricsRepository.findByProductId(productId); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java index 3658f5bc8..9e98a5e1b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.math.BigDecimal; +import java.time.LocalDate; import java.util.Optional; public interface ProductMetricsJpaRepository extends JpaRepository { @@ -14,49 +16,50 @@ public interface ProductMetricsJpaRepository extends JpaRepository 0 THEN like_count - 1 ELSE 0 END, updated_at = NOW() """, nativeQuery = true ) - void upsertUnlikeCount(@Param("productId") Long productId); + void upsertUnlikeCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate); @Modifying @Query(value = """ - INSERT INTO product_metrics (product_id, like_count, sales_count, view_count, created_at, updated_at) - VALUES (:productId, 0, :quantity, 0, NOW(), NOW()) + INSERT INTO product_metrics (product_id, metric_date, like_count, sales_count, sales_amount, view_count, created_at, updated_at) + VALUES (:productId, :metricDate, 0, :quantity, :amount, 0, NOW(), NOW()) ON DUPLICATE KEY UPDATE sales_count = sales_count + :quantity, + sales_amount = sales_amount + :amount, updated_at = NOW() """, nativeQuery = true ) - void upsertSalesCount(@Param("productId") Long productId, @Param("quantity") int quantity); + void upsertSalesCount(@Param("productId") Long productId, @Param("quantity") int quantity, @Param("amount") BigDecimal amount, @Param("metricDate") LocalDate metricDate); @Modifying @Query(value = """ - INSERT INTO product_metrics (product_id, like_count, sales_count, view_count, created_at, updated_at) - VALUES (:productId, 0, 0, 1, NOW(), NOW()) + INSERT INTO product_metrics (product_id, metric_date, like_count, sales_count, view_count, created_at, updated_at) + VALUES (:productId, :metricDate, 0, 0, 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE view_count = view_count + 1, updated_at = NOW() """, nativeQuery = true ) - void upsertViewCount(@Param("productId") Long productId); + void upsertViewCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java index e663b78af..7ac3e430c 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java @@ -5,6 +5,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.math.BigDecimal; +import java.time.LocalDate; import java.util.Optional; @RequiredArgsConstructor @@ -18,22 +20,22 @@ public Optional findByProductId(Long productId) { } @Override - public void upsertLikeCount(Long productId) { - productMetricsJpaRepository.upsertLikeCount(productId); + public void upsertLikeCount(Long productId, LocalDate metricDate) { + productMetricsJpaRepository.upsertLikeCount(productId, metricDate); } @Override - public void upsertUnlikeCount(Long productId) { - productMetricsJpaRepository.upsertUnlikeCount(productId); + public void upsertUnlikeCount(Long productId, LocalDate metricDate) { + productMetricsJpaRepository.upsertUnlikeCount(productId, metricDate); } @Override - public void upsertSalesCount(Long productId, int quantity) { - productMetricsJpaRepository.upsertSalesCount(productId, quantity); + public void upsertSalesCount(Long productId, int quantity, BigDecimal amount, LocalDate metricDate) { + productMetricsJpaRepository.upsertSalesCount(productId, quantity, amount, metricDate); } @Override - public void upsertViewCount(Long productId) { - productMetricsJpaRepository.upsertViewCount(productId); + public void upsertViewCount(Long productId, LocalDate metricDate) { + productMetricsJpaRepository.upsertViewCount(productId, metricDate); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java index 70efe4c60..74cc4397e 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java @@ -5,10 +5,9 @@ import com.loopers.cache.CacheKeyService; import com.loopers.cache.CacheService; import com.loopers.confg.kafka.KafkaConfig; -import com.loopers.domain.event.EventHandled; -import com.loopers.domain.event.EventHandledRepository; import com.loopers.domain.productmetrics.ProductMetricsService; import com.loopers.messaging.event.KafkaEventMessage; +import com.loopers.support.event.KafkaEventProcessor; import com.loopers.support.event.OrderPaidPayload; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,7 +17,6 @@ import org.springframework.stereotype.Component; import java.util.List; -import java.util.UUID; @Slf4j @Component @@ -27,11 +25,12 @@ public class OrderMetricsConsumer { private final ObjectMapper objectMapper; private final ProductMetricsService metricsService; - private final EventHandledRepository eventHandledRepository; private final CacheService cacheService; private final CacheKeyService cacheKeyService; + private final KafkaEventProcessor eventProcessor; + @KafkaListener( topics = "order-events", containerFactory = KafkaConfig.BATCH_LISTENER @@ -41,51 +40,34 @@ public void consume( Acknowledgment acknowledgment ) { log.info("catalog-events ๋ฐฐ์น˜ ์ˆ˜์‹ . ๋ฉ”์‹œ์ง€ ์ˆ˜: {}", records.size()); - + for (ConsumerRecord record : records) { - handle(record); + String eventJson = (String) record.value(); + try { + eventProcessor.process(this::handle, eventJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } // manual ack acknowledgment.acknowledge(); } - public void handle(ConsumerRecord record) { - String eventJson = (String) record.value(); - - try { - KafkaEventMessage message = - objectMapper.readValue( - record.value().toString(), - KafkaEventMessage.class - ); - - UUID eventId = message.getEventId(); + public void handle(KafkaEventMessage message) { + OrderPaidPayload payload = objectMapper.convertValue( + message.getPayload(), + OrderPaidPayload.class + ); - if (eventHandledRepository.existsByEventId(eventId)) { - return; - } - - OrderPaidPayload payload = objectMapper.convertValue( - message.getPayload(), - OrderPaidPayload.class + for (OrderPaidPayload.OrderItem item : payload.items()) { + metricsService.increaseSalesCount( + item.productId(), + item.quantity(), + item.totalPrice() ); - - for (OrderPaidPayload.OrderItem item : payload.items()) { - metricsService.increaseSalesCount( - item.productId(), - item.quantity() - ); - if (item.soldOut()) { - invalidateProductCaches(item.productId()); - } + if (item.soldOut()) { + invalidateProductCaches(item.productId()); } - - eventHandledRepository.save(EventHandled.from(eventId)); - - } catch (JsonProcessingException e) { - log.error("๋ฉ”์‹œ์ง€ JSON ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {}", eventJson, e); - } catch (Exception e) { - log.error("์ปจ์Šˆ๋จธ ์˜ค๋ฅ˜ ๋ฐœ์ƒ. ๋ฉ”์‹œ์ง€: {}", eventJson, e); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java index dff217854..4805fe102 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -1,12 +1,10 @@ package com.loopers.interfaces.consumer; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.confg.kafka.KafkaConfig; -import com.loopers.domain.event.EventHandled; -import com.loopers.domain.event.EventHandledRepository; import com.loopers.domain.productmetrics.ProductMetricsService; import com.loopers.messaging.event.KafkaEventMessage; +import com.loopers.support.event.KafkaEventProcessor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -14,17 +12,16 @@ import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; +import java.time.LocalDate; import java.util.List; -import java.util.UUID; @Slf4j @Component @RequiredArgsConstructor public class ProductMetricsConsumer { - private final ObjectMapper objectMapper; + private final KafkaEventProcessor eventProcessor; private final ProductMetricsService productMetricsService; - private final EventHandledRepository eventHandledRepository; @KafkaListener( topics = {"catalog-events"}, // ์ด ํ† ํ”ฝ์œผ๋กœ ๋ชจ๋“  ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์ด๋ฒคํŠธ๊ฐ€ ๋“ค์–ด์˜ฌ ๊ฒƒ์„ ๊ฐ€์ • @@ -37,50 +34,30 @@ public void consume( log.info("catalog-events ๋ฐฐ์น˜ ์ˆ˜์‹ . ๋ฉ”์‹œ์ง€ ์ˆ˜: {}", records.size()); for (ConsumerRecord record : records) { - handle(record); + String eventJson = (String) record.value(); + try { + eventProcessor.process(this::handle, eventJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } // manual ack acknowledgment.acknowledge(); } - - - private void handle(ConsumerRecord record) { - String eventJson = (String) record.value(); - - try { - KafkaEventMessage message = - objectMapper.readValue( - record.value().toString(), - KafkaEventMessage.class - ); - - UUID eventId = message.getEventId(); - - // ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ - if (eventHandledRepository.existsByEventId(eventId)) { - log.info("์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์ด๋ฒคํŠธ์ž…๋‹ˆ๋‹ค. eventId={}", eventId); - return; - } - - Long productId = message.getAggregateId(); - - // ์นด์šดํŠธ ์›์ž์  ์ฆ๊ฐ€ - switch (message.getEventName()) { - case "LIKE_CREATED" -> - productMetricsService.increaseLikeCount(productId); - case "LIKE_CANCELED" -> - productMetricsService.decreaseLikeCount(productId); - case "PRODUCT_VIEWED" -> - productMetricsService.increaseViewCount(productId); - } - - eventHandledRepository.save(EventHandled.from(eventId)); - - } catch (JsonProcessingException e) { - log.error("๋ฉ”์‹œ์ง€ JSON ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {}", eventJson, e); - } catch (Exception e) { - log.error("์ปจ์Šˆ๋จธ ์˜ค๋ฅ˜ ๋ฐœ์ƒ. ๋ฉ”์‹œ์ง€: {}", eventJson, e); + private void handle(KafkaEventMessage message) { + LocalDate today = LocalDate.now(); + Long productId = message.getAggregateId(); + + // ์นด์šดํŠธ ์›์ž์  ์ฆ๊ฐ€ + switch (message.getEventName()) { + case "LIKE_CREATED" -> + productMetricsService.increaseLikeCount(productId, today); + case "LIKE_CANCELED" -> + productMetricsService.decreaseLikeCount(productId, today); + case "PRODUCT_VIEWED" -> + productMetricsService.increaseViewCount(productId, today); } } + } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java new file mode 100644 index 000000000..c1549d978 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java @@ -0,0 +1,147 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.cache.CacheKeyService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.messaging.event.KafkaEventMessage; +import com.loopers.support.event.KafkaEventProcessor; +import com.loopers.support.event.OrderPaidPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingConsumer { + + private final KafkaEventProcessor eventProcessor; + + private final RedisTemplate redisTemplate; + private final CacheKeyService cacheKeyService; + + private final ObjectMapper objectMapper; + + private static final double RANKING_VIEW_WEIGHT = 0.1; + private static final double RANKING_LIKE_WEIGHT = 0.2; + private static final double RANKING_ORDER_WEIGHT = 0.7; + + private static final Duration RANKING_TTL = Duration.ofDays(2); + + /** + * ์ฃผ๋ฌธ ์ด๋ฒคํŠธ ๊ตฌ๋… + * @param records + * @param acknowledgment + */ + @KafkaListener( + topics = "order-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeOrder( + List> records, + Acknowledgment acknowledgment + ) { + log.info("catalog-events ๋ฐฐ์น˜ ์ˆ˜์‹ . ๋ฉ”์‹œ์ง€ ์ˆ˜: {}", records.size()); + + for (ConsumerRecord record : records) { + String eventJson = (String) record.value(); + try { + eventProcessor.process(this::handle, eventJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + // manual ack + acknowledgment.acknowledge(); + } + + public void handle(KafkaEventMessage message) { + String aggregateType = message.getAggregateType(); // ORDER, PRODUCT + + switch (aggregateType) { + case "ORDER": + OrderPaidPayload payload = objectMapper.convertValue( + message.getPayload(), + OrderPaidPayload.class + ); + + for (OrderPaidPayload.OrderItem item : payload.items()) { + updateRanking(item.productId(), RANKING_ORDER_WEIGHT * Math.log1p(item.totalPrice().doubleValue())); + } + break; + case "PRODUCT": + if (message.getEventName().equals("LIKE_CREATED")) { + updateRanking(message.getAggregateId(), RANKING_LIKE_WEIGHT); + } else if (message.getEventName().equals("PRODUCT_VIEWED")) { + updateRanking(message.getAggregateId(), RANKING_VIEW_WEIGHT); + } + break; + default: + break; + } + } + + /** + * ์‹ค์‹œ๊ฐ„ ์ฆ๋ถ„์œผ๋กœ productId์˜ ๋žญํ‚น ์ ์ˆ˜ ๊ฐฑ์‹  + * @param productId + */ + public void updateRanking(Long productId, double score) { + String key = cacheKeyService.rankingKey(LocalDate.now()); + + // Redis zset ZINCRBY + redisTemplate + .opsForZSet() + .incrementScore(key, productId.toString(), score); + +// if (productMetrics.isPresent()) { +// ProductMetrics pm = productMetrics.get(); +// score = RANKING_VIEW_WEIGHT * pm.getViewCount() +// + RANKING_LIKE_WEIGHT * pm.getLikeCount() +// + RANKING_ORDER_WEIGHT * Math.log1p(pm.getSalesAmount()); +// } + +// // Redis zset ์ €์žฅ +// redisTemplate +// .opsForZSet() +// .add(key, productId.toString(), score); + + // TTL 2์ผ ์„ค์ • + redisTemplate.expire(key, RANKING_TTL); + } + + /** + * ์ข‹์•„์š”, ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์ด๋ฒคํŠธ ๊ตฌ๋… + * @param records + * @param acknowledgment + */ + @KafkaListener( + topics = "catalog-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeCatalog( + List> records, + Acknowledgment acknowledgment + ) { + log.info("catalog-events ๋ฐฐ์น˜ ์ˆ˜์‹ . ๋ฉ”์‹œ์ง€ ์ˆ˜: {}", records.size()); + + for (ConsumerRecord record : records) { + String eventJson = (String) record.value(); + try { + eventProcessor.process(this::handle, eventJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + // manual ack + acknowledgment.acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/event/KafkaEventProcessor.java b/apps/commerce-streamer/src/main/java/com/loopers/support/event/KafkaEventProcessor.java new file mode 100644 index 000000000..a436720af --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/event/KafkaEventProcessor.java @@ -0,0 +1,43 @@ +package com.loopers.support.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.EventHandled; +import com.loopers.domain.event.EventHandledRepository; +import com.loopers.messaging.event.KafkaEventMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.UUID; +import java.util.function.Consumer; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KafkaEventProcessor { + + private final ObjectMapper objectMapper; + private final EventHandledRepository eventHandledRepository; + + + public void process(Consumer> handler, String json) throws JsonProcessingException { + KafkaEventMessage message = + objectMapper.readValue( + json, + new TypeReference>() {} + ); + + UUID eventId = message.getEventId(); + + if (eventHandledRepository.existsByEventId(eventId)) { + log.info("์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์ด๋ฒคํŠธ์ž…๋‹ˆ๋‹ค. eventId={}", eventId); + return; + } + + handler.accept(message); + + eventHandledRepository.save(new EventHandled(eventId)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/event/OrderPaidPayload.java b/apps/commerce-streamer/src/main/java/com/loopers/support/event/OrderPaidPayload.java index cbf48d0a8..bfa23bf9d 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/support/event/OrderPaidPayload.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/event/OrderPaidPayload.java @@ -1,5 +1,6 @@ package com.loopers.support.event; +import java.math.BigDecimal; import java.util.List; public record OrderPaidPayload( @@ -8,6 +9,7 @@ public record OrderPaidPayload( public record OrderItem( Long productId, int quantity, + BigDecimal totalPrice, boolean soldOut ) {} } diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 0651bc2bd..bbc4cfa3e 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: main: web-application-type: servlet application: - name: commerce-api + name: commerce-streamer profiles: active: local config: @@ -25,6 +25,12 @@ spring: - logging.yml - monitoring.yml +# Kafka Topics +kafka: + topics: + catalog-events: catalog-events + order-events: order-events + demo-kafka: test: topic-name: demo.internal.topic-v1 @@ -55,4 +61,12 @@ spring: springdoc: api-docs: - enabled: false \ No newline at end of file + enabled: false + +--- +ranking: + ttl-days: 2 + weight: + view: 0.1 + like: 0.2 + order: 0.7 \ No newline at end of file diff --git a/apps/commerce-streamer/src/test/java/com/loopers/ConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/ConsumerTest.java index 2ea8d29f2..3b9208b8e 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/ConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/ConsumerTest.java @@ -14,13 +14,16 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.support.Acknowledgment; +import java.math.BigDecimal; import java.time.Instant; import java.util.List; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.mock; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ConsumerTest { @@ -49,7 +52,7 @@ void setUp() { @DisplayName("๊ฐ™์€ OrderPaid ์ด๋ฒคํŠธ๊ฐ€ 2๋ฒˆ ์™€๋„ ํŒ๋งค๋Ÿ‰์€ ํ•œ ๋ฒˆ๋งŒ ์ฆ๊ฐ€ํ•œ๋‹ค") void idempotent_consume_test() throws Exception { - // given + // arrange UUID eventId = UUID.randomUUID(); KafkaEventMessage message = @@ -60,8 +63,8 @@ void idempotent_consume_test() throws Exception { 100L, new OrderPaidPayload( List.of( - new OrderPaidPayload.OrderItem(1L, 2, false), - new OrderPaidPayload.OrderItem(2L, 1, false) + new OrderPaidPayload.OrderItem(1L, 2, BigDecimal.valueOf(1000), false), + new OrderPaidPayload.OrderItem(2L, 1, BigDecimal.valueOf(10000), false) ) ), Instant.now() @@ -71,12 +74,13 @@ void idempotent_consume_test() throws Exception { ConsumerRecord record = new ConsumerRecord<>("order-events", 0, 0L, "100", payload); + Acknowledgment acknowledgment = mock(Acknowledgment.class); - // when (์ค‘๋ณต ์ฒ˜๋ฆฌ) - orderConsumer.handle(record); - orderConsumer.handle(record); + // act + orderConsumer.consume(List.of(record), acknowledgment); + orderConsumer.consume(List.of(record), acknowledgment); - // then + // assert ProductMetrics p1 = productMetricsRepository.findByProductId(1L).orElseThrow(); ProductMetrics p2 = diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/consumer/RankingConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/consumer/RankingConsumerTest.java new file mode 100644 index 000000000..ba149ffdb --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/consumer/RankingConsumerTest.java @@ -0,0 +1,169 @@ +package com.loopers.application.consumer; + +import com.loopers.interfaces.consumer.RankingConsumer; +import com.loopers.messaging.event.KafkaEventMessage; +import com.loopers.messaging.event.OrderPaidEvent; +import com.loopers.support.event.OrderPaidPayload; +import com.loopers.testcontainers.RedisTestContainersConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@SpringBootTest +@Import(RedisTestContainersConfig.class) +class RankingConsumerTest { + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private RankingConsumer rankingConsumer; + + private final LocalDate today = LocalDate.now(); + + private static final long DAY = 60 * 60 * 24 * 1L; + + private static final double RANKING_VIEW_WEIGHT = 0.1; + private static final double RANKING_LIKE_WEIGHT = 0.2; + private static final double RANKING_ORDER_WEIGHT = 0.7; + + private static final Duration RANKING_TTL = Duration.ofDays(2); + + + private String key() { + return "ranking:all:" + today.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + @AfterEach + void tearDown() { + redisTemplate.delete(key()); + } + + @Test + @DisplayName("์กฐํšŒ ์ด๋ฒคํŠธ ์†Œ๋น„ ์‹œ ๊ฐ€์ค‘์น˜์— ๋”ฐ๋ฅธ ZSET ์ ์ˆ˜๊ฐ€ ์ ์ ˆํžˆ ๋ฐ˜์˜๋œ๋‹ค") + void rankingScoreUpdatedByViewEvents() { + + // arrange + Long productId = 100L; + List items = List.of(new OrderPaidEvent.OrderItemData(100L, 1, BigDecimal.valueOf(10000))); + + KafkaEventMessage message = + new KafkaEventMessage<>( + UUID.randomUUID(), + "PRODUCT_VIEWED", + "PRODUCT", + productId, + items, + Instant.now() + ); + + // act + rankingConsumer.handle(message); + + // assert + Double score = redisTemplate.opsForZSet().score(key(), productId.toString()); + + assertThat(score).isNotNull(); + assertThat(score).isEqualTo(RANKING_VIEW_WEIGHT); + } + + @Test + @DisplayName("์ฃผ๋ฌธ ์ด๋ฒคํŠธ ์†Œ๋น„ ์‹œ ๊ฐ€์ค‘์น˜์— ๋”ฐ๋ฅธ ZSET ์ ์ˆ˜๊ฐ€ ์ ์ ˆํžˆ ๋ฐ˜์˜๋œ๋‹ค") + void rankingScoreUpdatedByOrderEvents() { + + // arrange + Long orderId = 100L; + + KafkaEventMessage message = + new KafkaEventMessage<>( + UUID.randomUUID(), + "ORDER_PAID", + "ORDER", + orderId, + new OrderPaidPayload( + List.of( + new OrderPaidPayload.OrderItem(1L, 2, BigDecimal.valueOf(1000), false), + new OrderPaidPayload.OrderItem(2L, 1, BigDecimal.valueOf(10000), false) + ) + ), + Instant.now() + ); + + // act + rankingConsumer.handle(message); + + // assert + Double score1 = redisTemplate.opsForZSet().score(key(), "1"); + Double score2 = redisTemplate.opsForZSet().score(key(), "2"); + + assertThat(score1).isNotNull(); + assertThat(score2).isNotNull(); + assertThat(score1).isEqualTo(RANKING_ORDER_WEIGHT * Math.log1p(1000)); + assertThat(score2).isEqualTo(RANKING_ORDER_WEIGHT * Math.log1p(10000)); + } + + @Test + @DisplayName("์ข‹์•„์š” ์ด๋ฒคํŠธ ์†Œ๋น„ ์‹œ ๊ฐ€์ค‘์น˜์— ๋”ฐ๋ฅธ ZSET ์ ์ˆ˜๊ฐ€ ์ ์ ˆํžˆ ๋ฐ˜์˜๋œ๋‹ค") + void rankingScoreUpdatedByLikeEvents() { + + // arrange + Long productId = 100L; + List items = List.of(new OrderPaidEvent.OrderItemData(100L, 1, BigDecimal.valueOf(10000))); + + KafkaEventMessage message = + new KafkaEventMessage<>( + UUID.randomUUID(), + "LIKE_CREATED", + "PRODUCT", + productId, + null, + Instant.now() + ); + + // act + rankingConsumer.handle(message); + + // assert + Double score = redisTemplate.opsForZSet().score(key(), productId.toString()); + + assertThat(score).isNotNull(); + assertThat(score).isEqualTo(RANKING_LIKE_WEIGHT); + } + + @Test + @DisplayName("ZSET ํ‚ค TTL ์ด 2์ผ๋กœ ์„ค์ •๋œ๋‹ค") + void rankingKeyTtlIsTwoDays() { + + // arrange + Long productId = 1L; + KafkaEventMessage message = KafkaEventMessage.of( + UUID.randomUUID(), + "LIKE_CREATED", + "PRODUCT", + 1L, + null); + + rankingConsumer.handle(message); + + // act + Long ttlSeconds = redisTemplate.getExpire(key()); + + // assert + assertThat(ttlSeconds).isNotNull(); + assertThat(ttlSeconds).isBetween(DAY, DAY * 2 + 10); + } +} diff --git a/modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java b/modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java index 25bd74963..89b83608d 100644 --- a/modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java +++ b/modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java @@ -4,6 +4,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + @Component @RequiredArgsConstructor @@ -21,4 +24,8 @@ public String productListKey(Long brandId, Pageable pageable, String sort) { public String productDetailKey(Long productId) { return "product:v1:detail:" + productId; } + + public String rankingKey(LocalDate date) { + return "ranking:all:" + date.format(DateTimeFormatter.BASIC_ISO_DATE); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCanceledEvent.java b/modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java similarity index 86% rename from apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCanceledEvent.java rename to modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java index 5add26ecb..84dbc18b2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCanceledEvent.java +++ b/modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java @@ -1,4 +1,4 @@ -package com.loopers.application.like.event; +package com.loopers.messaging.event; import java.time.Instant; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java b/modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java similarity index 86% rename from apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java rename to modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java index 28a5b1d77..cb400c7e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java +++ b/modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java @@ -1,4 +1,4 @@ -package com.loopers.application.like.event; +package com.loopers.messaging.event; import java.time.Instant; diff --git a/modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java b/modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java new file mode 100644 index 000000000..5686a7c38 --- /dev/null +++ b/modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java @@ -0,0 +1,25 @@ +package com.loopers.messaging.event; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public record OrderPaidEvent( + Long orderId, + List items, + Instant occurredAt +) { + public static OrderPaidEvent of(Long orderId, List items) { + return new OrderPaidEvent( + orderId, + items, + Instant.now() + ); + } + + public record OrderItemData( + Long productId, + int quantity, + BigDecimal unitPrice // ์ƒํ’ˆ ๋‹จ๊ฐ€ + ) {} +} diff --git a/modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java b/modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java new file mode 100644 index 000000000..0405d69a6 --- /dev/null +++ b/modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java @@ -0,0 +1,15 @@ +package com.loopers.messaging.event; + +import java.time.Instant; + +public record ProductViewedEvent( + Long productId, + Instant occurredAt +) { + public static ProductViewedEvent from(Long productId) { + return new ProductViewedEvent( + productId, + Instant.now() + ); + } +} diff --git a/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingInfo.java b/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingInfo.java new file mode 100644 index 000000000..586c253c2 --- /dev/null +++ b/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingInfo.java @@ -0,0 +1,15 @@ +package com.loopers.ranking.streamer; + +public record RankingInfo( + Long id, + Long score, + Long rank +) { + public static RankingInfo of(Long id, Long score, Long rank) { + return new RankingInfo( + id, + score, + rank + ); + } +} diff --git a/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingReadRepository.java b/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingReadRepository.java new file mode 100644 index 000000000..8cf517493 --- /dev/null +++ b/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingReadRepository.java @@ -0,0 +1,90 @@ +package com.loopers.ranking.streamer; + +import com.loopers.cache.CacheKeyService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +@Repository +@RequiredArgsConstructor +public class RankingReadRepository { + + private final RedisTemplate redisTemplate; + private final CacheKeyService cacheKeyService; + + /** + * Top-N ์กฐํšŒ + */ + public Set> findTopN( + LocalDate date, + int size + ) { + return redisTemplate.opsForZSet() + .reverseRangeWithScores( + cacheKeyService.rankingKey(date), + 0, + size - 1 + ); + } + + /** + * ๋žญํ‚น ํŽ˜์ด์ง€ Top-N ์กฐํšŒ + */ + public List findPage( + LocalDate date, + int page, + int size + ) { + long start = (long) (page - 1) * size; + long end = start + size - 1; + long offset = (page - 1) * size; + + Set> zset = redisTemplate.opsForZSet().reverseRangeWithScores( + cacheKeyService.rankingKey(date), + start, + end + ); + + if (zset == null || zset.isEmpty()) { + return List.of(); + } + + List> zlist = List.copyOf(zset); + + return IntStream.range(0, zset.size()) + .mapToObj(i -> { + ZSetOperations.TypedTuple s = zlist.get(i); + return new RankingInfo( + Long.valueOf(s.getValue()), + s.getScore().longValue(), + offset + i + 1 + ); + }) + .toList(); + } + + /** + * ํŠน์ • ์ƒํ’ˆ ์ˆœ์œ„ ์กฐํšŒ + */ + public Long findRank( + LocalDate date, + Long productId + ) { + return redisTemplate.opsForZSet() + .reverseRank( + cacheKeyService.rankingKey(date), + productId.toString() + ); + } + + public long findTotalCount(LocalDate rankingDate) { + return redisTemplate.opsForZSet() + .zCard(rankingDate.toString()); + } +}