diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java index fd5031ac7..3e2664067 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java @@ -23,16 +23,27 @@ public record OrderCreated( Long orderId, Long userId, Integer originalTotalPrice, - Integer discountPrice + Integer discountPrice, + List orderItems ) { public static OrderCreated from(Order order, String loginId) { + List orderItemInfos = order.getOrderItems().stream() + .map(item -> new OrderItemInfo( + item.getProductId(), + item.getProductName(), + item.getPrice(), + item.getQuantity() + )) + .toList(); + return new OrderCreated( loginId, order.getOrderKey(), order.getId(), order.getUserId(), order.getOriginalTotalPrice(), - order.getDiscountPrice() + order.getDiscountPrice(), + orderItemInfos ); } } 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 a1240898a..36320ca7f 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 @@ -4,8 +4,10 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.infrastructure.cache.ProductCacheService; +import com.loopers.infrastructure.cache.RankingCacheService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Set; @@ -20,6 +22,7 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; private final ProductCacheService productCacheService; + private final RankingCacheService rankingCacheService; public List getProducts(ProductCommand.GetProductsCommand command) { return productCacheService.getProductList( @@ -57,12 +60,30 @@ public List getProducts(ProductCommand.GetProductsCommand command) } public ProductInfo getProduct(Long productId) { - return productCacheService.getProduct(productId, () -> { + ProductInfo productInfo = productCacheService.getProduct(productId, () -> { Product product = productService.findProductById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); String brandName = brandService.findBrandNameById(product.getBrandId()); return ProductInfo.from(product, brandName); }); + + Long rank = rankingCacheService.getProductRank(LocalDate.now(), productId); + + if (rank != null) { + return new ProductInfo( + productInfo.id(), + productInfo.name(), + productInfo.brandId(), + productInfo.brandName(), + productInfo.price(), + productInfo.likeCount(), + productInfo.stock(), + productInfo.createdAt(), + rank + ); + } + + return productInfo; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 4acf7121f..06faac7de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -11,7 +11,8 @@ public record ProductInfo( Integer price, Integer likeCount, Integer stock, - ZonedDateTime createdAt + ZonedDateTime createdAt, + Long rank ) { public static ProductInfo from(Product product, String brandName) { return new ProductInfo( @@ -22,7 +23,22 @@ public static ProductInfo from(Product product, String brandName) { product.getPrice().getPrice(), product.getLikeCount().getCount(), product.getStock().getQuantity(), - product.getCreatedAt() + product.getCreatedAt(), + null + ); + } + + public static ProductInfo from(Product product, String brandName, Long rank) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getBrandId(), + brandName, + product.getPrice().getPrice(), + product.getLikeCount().getCount(), + product.getStock().getQuantity(), + product.getCreatedAt(), + rank ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java new file mode 100644 index 000000000..8cb938c04 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java @@ -0,0 +1,14 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; + +public class RankingCommand { + + public record GetDailyRankingCommand( + LocalDate date, + int page, + int size + ) { + } + +} 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..3c7235fc6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,76 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.Ranking; +import com.loopers.domain.ranking.RankingService; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +@Slf4j +public class RankingFacade { + + private final RankingService rankingService; + private final ProductService productService; + private final BrandService brandService; + + public RankingInfo getDailyRanking(RankingCommand.GetDailyRankingCommand command) { + List rankings = rankingService.getRanking( + command.date(), + command.page(), + command.size() + ); + + if (rankings.isEmpty()) { + return new RankingInfo(List.of()); + } + + List productIds = rankings.stream() + .map(Ranking::productId) + .collect(Collectors.toList()); + + List products = productService.findProductsByIds(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .collect(Collectors.toList()); + Map brandNameMap = brandService.findBrandNamesByIds(brandIds); + + List rankingItemInfos = rankings.stream() + .map(ranking -> { + Product product = productMap.get(ranking.productId()); + if (product == null) { + log.warn("랭킹에 있는 상품이 DB에 존재하지 않음: productId={}", ranking.productId()); + return null; + } + + String brandName = brandNameMap.getOrDefault(product.getBrandId(), "알 수 없음"); + + return new RankingInfo.RankingItemInfo( + ranking.productId(), + product.getName(), + brandName, + product.getPrice().getPrice(), + product.getLikeCount().getCount(), + ranking.rank(), + ranking.score() + ); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + return new RankingInfo(rankingItemInfos); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java new file mode 100644 index 000000000..5eb1eea31 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java @@ -0,0 +1,18 @@ +package com.loopers.application.ranking; + +import java.util.List; + +public record RankingInfo( + List items +) { + public record RankingItemInfo( + Long productId, + String productName, + String brandName, + Integer price, + Integer likeCount, + Long rank, + Double score + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 670d6d1dd..11f588eea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -4,6 +4,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import lombok.Builder; import lombok.Getter; @Entity @@ -17,4 +18,19 @@ public class Brand extends BaseEntity { @Column(nullable = false) private String description; + public Brand() { + } + + @Builder + public Brand(String name, String description) { + this.name = name; + this.description = description; + } + + public static Brand createBrand(String name, String description) { + return Brand.builder() + .name(name) + .description(description) + .build(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 717f09916..5330d09c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -15,4 +15,6 @@ public interface ProductRepository { List findProductsByLikesDesc(Long brandId, int page, int size); + List findProductsByIds(List productIds); + } 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 36ecc3b9f..438125be0 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 @@ -53,6 +53,11 @@ public List findProductsByLikesDesc(Long brandId, int page, int size) { return productRepository.findProductsByLikesDesc(brandId, page, size); } + @Transactional(readOnly = true) + public List findProductsByIds(List productIds) { + return productRepository.findProductsByIds(productIds); + } + @Transactional public Product increaseLikeCount(Long productId) { Product product = productRepository.findProductById(productId) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java new file mode 100644 index 000000000..b881f25da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking; + +public record Ranking( + Long productId, + Long rank, + Double score +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java new file mode 100644 index 000000000..1c78c7af5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking; + +public record RankingItem( + Long productId, + Double score +) { +} + 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..a90fe17c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,37 @@ +package com.loopers.domain.ranking; + +import com.loopers.infrastructure.cache.RankingCacheService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +@RequiredArgsConstructor +@Service +public class RankingService { + + private final RankingCacheService rankingCacheService; + + public List getRanking(LocalDate date, int page, int size) { + long start = (long) (page - 1) * size; + long end = start + size - 1; + + List rankingItems = rankingCacheService.getRankingRange(date, start, end); + + if (rankingItems.isEmpty()) { + return new ArrayList<>(); + } + + return IntStream.range(0, rankingItems.size()) + .mapToObj(i -> { + RankingItem item = rankingItems.get(i); + long rank = start + 1 + i; + return new Ranking(item.productId(), rank, item.score()); + }) + .toList(); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java new file mode 100644 index 000000000..27999ea0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java @@ -0,0 +1,80 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.domain.ranking.RankingItem; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Slf4j +@Service +public class RankingCacheService { + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RedisTemplate redisTemplateMaster; + + public RankingCacheService(@Qualifier("redisTemplateMaster") RedisTemplate redisTemplateMaster) { + this.redisTemplateMaster = redisTemplateMaster; + } + + public List getRankingRange(LocalDate date, long start, long end) { + String key = getRankingKey(date); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + Set> entries = zSetOps.reverseRangeWithScores(key, start, end); + + if (entries == null || entries.isEmpty()) { + log.debug("랭킹 데이터가 없음: key={}, start={}, end={}", key, start, end); + return new ArrayList<>(); + } + + List rankingItems = new ArrayList<>(); + for (ZSetOperations.TypedTuple entry : entries) { + String productIdStr = entry.getValue(); + Double score = entry.getScore(); + + if (productIdStr == null || score == null) { + continue; + } + + try { + Long productId = Long.parseLong(productIdStr); + rankingItems.add(new RankingItem(productId, score)); + } catch (NumberFormatException e) { + log.warn("랭킹에서 잘못된 productId 형식: {}", productIdStr); + } + } + + log.debug("랭킹 데이터 조회 완료: key={}, start={}, end={}, count={}", key, start, end, rankingItems.size()); + return rankingItems; + } + + public Long getProductRank(LocalDate date, Long productId) { + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + String productIdStr = String.valueOf(productId); + Long rank = zSetOps.reverseRank(key, productIdStr); + + if (rank == null) { + log.debug("해당 상품이 랭킹에 존재하지 않음: key={}, productId={}", key, productId); + return null; + } + + return rank + 1; + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 672981960..527a9fbfa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -24,5 +24,8 @@ public interface ProductJpaRepository extends JpaRepository { @Query("SELECT p FROM Product p WHERE p.isDeleted = false AND (:brandId IS NULL OR p.brandId = :brandId) ORDER BY p.likeCount.count DESC") List findProductsByLikesDesc(@Param("brandId") Long brandId, Pageable pageable); + + @Query("SELECT p FROM Product p WHERE p.isDeleted = false AND p.id IN :productIds") + List findProductsByIds(@Param("productIds") List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index db0abab0d..10ad37e44 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -38,4 +38,12 @@ public List findProductsByPriceAsc(Long brandId, int page, int size) { public List findProductsByLikesDesc(Long brandId, int page, int size) { return productJpaRepository.findProductsByLikesDesc(brandId, PageRequest.of(page, size)); } + + @Override + public List findProductsByIds(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return List.of(); + } + return productJpaRepository.findProductsByIds(productIds); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 2e50ef808..365302550 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -47,7 +47,8 @@ public record ProductResponse( String brandName, Integer price, Integer likeCount, - Integer stock + Integer stock, + Long rank ) { public static ProductResponse from(ProductInfo info) { return new ProductResponse( @@ -57,7 +58,8 @@ public static ProductResponse from(ProductInfo info) { info.brandName(), info.price(), info.likeCount(), - info.stock() + info.stock(), + info.rank() ); } } 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..00022020d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; + +@Tag(name = "Ranking V1 API", description = "Ranking V1 API 입니다.") +public interface RankingV1ApiSpec { + + @Operation( + summary = "상품 랭킹 조회", + description = "상품의 일간 랭킹 정보를 조회합니다." + ) + ApiResponse getDailyRanking( + @Parameter(name = "date", description = "조회할 날짜 (yyyyMMdd 형식)", required = true) + LocalDate date, + @Parameter(name = "page", description = "페이지 번호 (기본값: 1)") + Integer page, + @Parameter(name = "size", description = "페이지당 상품 수 (기본값: 20)") + Integer size + ); + +} 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..9150013ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingCommand; +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingInfo; +import com.loopers.interfaces.api.ApiResponse; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller implements RankingV1ApiSpec { + + private final RankingFacade rankingFacade; + + @GetMapping + @Override + public ApiResponse getDailyRanking( + @RequestParam @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, + @RequestParam(required = false, defaultValue = "1") Integer page, + @RequestParam(required = false, defaultValue = "20") Integer size + ) { + RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( + date, + page, + size + ); + + RankingInfo rankingInfo = rankingFacade.getDailyRanking(command); + RankingV1Dto.DailyRankingListResponse response = RankingV1Dto.DailyRankingListResponse.from(rankingInfo); + + return ApiResponse.success(response); + } + +} 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..5d0bd8503 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingInfo; +import java.util.List; +import java.util.stream.Collectors; + +public class RankingV1Dto { + + public record DailyRankingListResponse( + List rankings + ) { + public static DailyRankingListResponse from(RankingInfo rankingInfo) { + List items = rankingInfo.items().stream() + .map(item -> new DailyRankingItem( + item.productId(), + item.productName(), + item.brandName(), + item.price(), + item.likeCount(), + item.rank().intValue() + )) + .collect(Collectors.toList()); + + return new DailyRankingListResponse(items); + } + } + + public record DailyRankingItem( + Long productId, + String productName, + String brandName, + Integer price, + Integer likeCount, + Integer rank + ) { + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java index e05d1a524..913bb4008 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java @@ -22,12 +22,22 @@ public class KafkaOutboxEventListener { @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handleOrderCreated(OrderEvent.OrderCreated event) { + List orderItemInfos = event.orderItems().stream() + .map(item -> new KafkaEvent.OrderEvent.OrderItemInfo( + item.productId(), + item.productName(), + item.price(), + item.quantity() + )) + .toList(); + KafkaEvent.OrderEvent.OrderCreated kafkaEvent = KafkaEvent.OrderEvent.OrderCreated.from( event.orderKey(), event.userId(), event.orderId(), event.originalTotalPrice(), - event.discountPrice() + event.discountPrice(), + orderItemInfos ); outboxService.saveOutbox("order-created-events", event.orderKey(), kafkaEvent); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java index 0abe9be6a..00ea1325d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; @@ -19,10 +20,12 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.cache.RankingCacheService; import com.loopers.support.IntegrationTest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.RedisCleanUp; +import java.time.LocalDate; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -50,6 +53,9 @@ class ProductFacadeIntegrationTest extends IntegrationTest { @MockitoSpyBean private BrandService brandService; + @MockitoSpyBean + private RankingCacheService rankingCacheService; + @BeforeEach void setUp() { redisCleanUp.truncateAll(); @@ -290,6 +296,41 @@ void returnsProductInfo_whenProductExists() { doReturn(Optional.of(product)).when(productService).findProductById(productId); doReturn(brandName).when(brandService).findBrandNameById(1L); + doReturn(null).when(rankingCacheService).getProductRank(any(LocalDate.class), eq(productId)); + + // act + ProductInfo result = productFacade.getProduct(productId); + + // assert + assertAll( + () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(result.name()).isEqualTo("상품1"), + () -> assertThat(result.brandId()).isEqualTo(1L), + () -> assertThat(result.brandName()).isEqualTo("브랜드1"), + () -> assertThat(result.price()).isEqualTo(10000), + () -> assertThat(result.likeCount()).isEqualTo(10), + () -> assertThat(result.stock()).isEqualTo(100), + () -> assertThat(result.rank()).isNull() + ); + + // verify + verify(productService, times(1)).findProductById(productId); + verify(brandService, times(1)).findBrandNameById(1L); + verify(rankingCacheService, times(1)).getProductRank(any(LocalDate.class), eq(productId)); + } + + @DisplayName("상품이 존재하고 랭킹에 등록되어 있으면 랭킹 순위가 포함되어 반환된다.") + @Test + void returnsProductInfoWithRank_whenProductExistsAndHasRank() { + // arrange + Long productId = 1L; + Product product = createProduct(1L, "상품1", 1L, 10000, 10, 100); + String brandName = "브랜드1"; + Long rank = 5L; + + doReturn(Optional.of(product)).when(productService).findProductById(productId); + doReturn(brandName).when(brandService).findBrandNameById(1L); + doReturn(rank).when(rankingCacheService).getProductRank(any(LocalDate.class), eq(productId)); // act ProductInfo result = productFacade.getProduct(productId); @@ -302,12 +343,14 @@ void returnsProductInfo_whenProductExists() { () -> assertThat(result.brandName()).isEqualTo("브랜드1"), () -> assertThat(result.price()).isEqualTo(10000), () -> assertThat(result.likeCount()).isEqualTo(10), - () -> assertThat(result.stock()).isEqualTo(100) + () -> assertThat(result.stock()).isEqualTo(100), + () -> assertThat(result.rank()).isEqualTo(5L) ); // verify verify(productService, times(1)).findProductById(productId); verify(brandService, times(1)).findBrandNameById(1L); + verify(rankingCacheService, times(1)).getProductRank(any(LocalDate.class), eq(productId)); } @DisplayName("상품이 존재하지 않으면 NOT_FOUND 예외가 발생한다.") diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java new file mode 100644 index 000000000..b43c730fb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java @@ -0,0 +1,214 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.LikeCount; +import com.loopers.domain.product.Price; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.support.IntegrationTest; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class RankingFacadeIntegrationTest extends IntegrationTest { + + @Autowired + private RankingFacade rankingFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("일별 랭킹 조회") + @Nested + class GetDailyRanking { + + @DisplayName("랭킹 데이터가 있고 상품 정보가 있으면 정상적으로 조회된다.") + @Test + void returnsRankingInfo_whenDataExists() { + // arrange + Brand brand1 = createBrand("브랜드1"); + Brand brand2 = createBrand("브랜드2"); + brand1 = brandJpaRepository.save(brand1); + brand2 = brandJpaRepository.save(brand2); + + Product product1 = createProduct("상품1", brand1.getId(), 10000, 10, 100); + Product product2 = createProduct("상품2", brand2.getId(), 20000, 20, 200); + productRepository.saveProduct(product1); + productRepository.saveProduct(product2); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product1.getId()), 100.0); + zSetOps.add(key, String.valueOf(product2.getId()), 50.0); + + RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( + date, 1, 20 + ); + + // act + RankingInfo result = rankingFacade.getDailyRanking(command); + + // assert + assertThat(result.items()).hasSize(2); + RankingInfo.RankingItemInfo item1 = result.items().get(0); + assertThat(item1.productId()).isEqualTo(product1.getId()); + assertThat(item1.productName()).isEqualTo("상품1"); + assertThat(item1.brandName()).isEqualTo("브랜드1"); + assertThat(item1.price()).isEqualTo(10000); + assertThat(item1.likeCount()).isEqualTo(10); + assertThat(item1.rank()).isEqualTo(1L); + assertThat(item1.score()).isEqualTo(100.0); + + RankingInfo.RankingItemInfo item2 = result.items().get(1); + assertThat(item2.productId()).isEqualTo(product2.getId()); + assertThat(item2.productName()).isEqualTo("상품2"); + assertThat(item2.brandName()).isEqualTo("브랜드2"); + assertThat(item2.price()).isEqualTo(20000); + assertThat(item2.likeCount()).isEqualTo(20); + assertThat(item2.rank()).isEqualTo(2L); + assertThat(item2.score()).isEqualTo(50.0); + } + + @DisplayName("랭킹에 있지만 DB에 없는 상품은 제외된다.") + @Test + void excludesProducts_notInDatabase() { + // arrange + Brand brand = createBrand("브랜드1"); + brand = brandJpaRepository.save(brand); + + Product product = createProduct("상품1", brand.getId(), 10000, 10, 100); + productRepository.saveProduct(product); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product.getId()), 100.0); + zSetOps.add(key, "999", 50.0); // DB에 없는 상품 + + RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( + date, 1, 20 + ); + + // act + RankingInfo result = rankingFacade.getDailyRanking(command); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productId()).isEqualTo(product.getId()); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoRankingData() { + // arrange + RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( + LocalDate.now(), 1, 20 + ); + + // act + RankingInfo result = rankingFacade.getDailyRanking(command); + + // assert + assertThat(result.items()).isEmpty(); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + Brand brand = createBrand("브랜드1"); + brand = brandJpaRepository.save(brand); + + Product product1 = createProduct("상품1", brand.getId(), 10000, 10, 100); + Product product2 = createProduct("상품2", brand.getId(), 20000, 20, 200); + Product product3 = createProduct("상품3", brand.getId(), 30000, 30, 300); + productRepository.saveProduct(product1); + productRepository.saveProduct(product2); + productRepository.saveProduct(product3); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product1.getId()), 100.0); + zSetOps.add(key, String.valueOf(product2.getId()), 50.0); + zSetOps.add(key, String.valueOf(product3.getId()), 30.0); + + // act - 첫 페이지 + RankingInfo page1 = rankingFacade.getDailyRanking( + new RankingCommand.GetDailyRankingCommand(date, 1, 2) + ); + + // assert + assertThat(page1.items()).hasSize(2); + assertThat(page1.items().get(0).productId()).isEqualTo(product1.getId()); + assertThat(page1.items().get(1).productId()).isEqualTo(product2.getId()); + + // act - 두 번째 페이지 + RankingInfo page2 = rankingFacade.getDailyRanking( + new RankingCommand.GetDailyRankingCommand(date, 2, 2) + ); + + // assert + assertThat(page2.items()).hasSize(1); + assertThat(page2.items().get(0).productId()).isEqualTo(product3.getId()); + } + } + + private Brand createBrand(String name) { + return Brand.createBrand(name, name + " 설명"); + } + + private Product createProduct(String name, Long brandId, Integer price, Integer likeCount, Integer stock) { + return Product.createProduct( + name, + brandId, + Price.createPrice(price), + LikeCount.createLikeCount(likeCount), + Stock.createStock(stock) + ); + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java new file mode 100644 index 000000000..b3623777b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java @@ -0,0 +1,159 @@ +package com.loopers.domain.ranking; + +import com.loopers.support.IntegrationTest; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class RankingServiceIntegrationTest extends IntegrationTest { + + @Autowired + private RankingService rankingService; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @BeforeEach + void setUp() { + redisCleanUp.truncateAll(); + } + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @DisplayName("랭킹 조회") + @Nested + class GetRanking { + + @DisplayName("랭킹 데이터가 있으면 정상적으로 조회된다.") + @Test + void returnsRankings_whenDataExists() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 50.0); + zSetOps.add(key, "3", 30.0); + + // act + List result = rankingService.getRanking(date, 1, 20); + + // assert + assertThat(result).hasSize(3); + assertThat(result.get(0).productId()).isEqualTo(1L); + assertThat(result.get(0).rank()).isEqualTo(1L); + assertThat(result.get(0).score()).isEqualTo(100.0); + assertThat(result.get(1).productId()).isEqualTo(2L); + assertThat(result.get(1).rank()).isEqualTo(2L); + assertThat(result.get(1).score()).isEqualTo(50.0); + assertThat(result.get(2).productId()).isEqualTo(3L); + assertThat(result.get(2).rank()).isEqualTo(3L); + assertThat(result.get(2).score()).isEqualTo(30.0); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 90.0); + zSetOps.add(key, "3", 80.0); + zSetOps.add(key, "4", 70.0); + zSetOps.add(key, "5", 60.0); + + // act - 첫 페이지 + List page1 = rankingService.getRanking(date, 1, 2); + + // assert + assertThat(page1).hasSize(2); + assertThat(page1.get(0).productId()).isEqualTo(1L); + assertThat(page1.get(0).rank()).isEqualTo(1L); + assertThat(page1.get(1).productId()).isEqualTo(2L); + assertThat(page1.get(1).rank()).isEqualTo(2L); + + // act - 두 번째 페이지 + List page2 = rankingService.getRanking(date, 2, 2); + + // assert + assertThat(page2).hasSize(2); + assertThat(page2.get(0).productId()).isEqualTo(3L); + assertThat(page2.get(0).rank()).isEqualTo(3L); + assertThat(page2.get(1).productId()).isEqualTo(4L); + assertThat(page2.get(1).rank()).isEqualTo(4L); + + // act - 세 번째 페이지 + List page3 = rankingService.getRanking(date, 3, 2); + + // assert + assertThat(page3).hasSize(1); + assertThat(page3.get(0).productId()).isEqualTo(5L); + assertThat(page3.get(0).rank()).isEqualTo(5L); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoDataExists() { + // arrange + LocalDate date = LocalDate.now(); + + // act + List result = rankingService.getRanking(date, 1, 20); + + // assert + assertThat(result).isEmpty(); + } + + @DisplayName("순위는 1부터 시작한다.") + @Test + void startsRankFromOne() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + + // act + List result = rankingService.getRanking(date, 1, 20); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).rank()).isEqualTo(1L); + } + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java index 63b9ec513..6d7551365 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java @@ -336,7 +336,8 @@ private ProductInfo createProductInfo( price, likeCount, stock, - ZonedDateTime.now() + ZonedDateTime.now(), + null ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java new file mode 100644 index 000000000..6c255cbf2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java @@ -0,0 +1,166 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.domain.ranking.RankingItem; +import com.loopers.support.IntegrationTest; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class RankingCacheServiceIntegrationTest extends IntegrationTest { + + @Autowired + private RankingCacheService rankingCacheService; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @DisplayName("랭킹 범위 조회") + @Nested + class GetRankingRange { + + @DisplayName("랭킹 데이터가 있으면 정상적으로 조회된다.") + @Test + void returnsRankingItems_whenDataExists() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 50.0); + zSetOps.add(key, "3", 30.0); + + // act + List result = rankingCacheService.getRankingRange(date, 0, 2); + + // assert + assertThat(result).hasSize(3); + assertThat(result.get(0).productId()).isEqualTo(1L); + assertThat(result.get(0).score()).isEqualTo(100.0); + assertThat(result.get(1).productId()).isEqualTo(2L); + assertThat(result.get(1).score()).isEqualTo(50.0); + assertThat(result.get(2).productId()).isEqualTo(3L); + assertThat(result.get(2).score()).isEqualTo(30.0); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 90.0); + zSetOps.add(key, "3", 80.0); + zSetOps.add(key, "4", 70.0); + zSetOps.add(key, "5", 60.0); + + // act - 첫 페이지 (0-1) + List page1 = rankingCacheService.getRankingRange(date, 0, 1); + + // assert + assertThat(page1).hasSize(2); + assertThat(page1.get(0).productId()).isEqualTo(1L); + assertThat(page1.get(1).productId()).isEqualTo(2L); + + // act - 두 번째 페이지 (2-3) + List page2 = rankingCacheService.getRankingRange(date, 2, 3); + + // assert + assertThat(page2).hasSize(2); + assertThat(page2.get(0).productId()).isEqualTo(3L); + assertThat(page2.get(1).productId()).isEqualTo(4L); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoDataExists() { + // arrange + LocalDate date = LocalDate.now(); + + // act + List result = rankingCacheService.getRankingRange(date, 0, 10); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("상품 랭킹 조회") + @Nested + class GetProductRank { + + @DisplayName("랭킹에 있는 상품의 순위를 반환한다.") + @Test + void returnsRank_whenProductExistsInRanking() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 50.0); + zSetOps.add(key, "3", 30.0); + + // act + Long rank1 = rankingCacheService.getProductRank(date, 1L); + Long rank2 = rankingCacheService.getProductRank(date, 2L); + Long rank3 = rankingCacheService.getProductRank(date, 3L); + + // assert + assertThat(rank1).isEqualTo(1L); // 1등 + assertThat(rank2).isEqualTo(2L); // 2등 + assertThat(rank3).isEqualTo(3L); // 3등 + } + + @DisplayName("랭킹에 없는 상품은 null을 반환한다.") + @Test + void returnsNull_whenProductNotInRanking() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + + // act + Long rank = rankingCacheService.getProductRank(date, 999L); + + // assert + assertThat(rank).isNull(); + } + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java new file mode 100644 index 000000000..61685253b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -0,0 +1,232 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.domain.product.LikeCount; +import com.loopers.domain.product.Price; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.IntegrationTest; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RankingV1ApiE2ETest extends IntegrationTest { + + private static final String ENDPOINT_RANKING = "/api/v1/rankings"; + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductRepository productRepository; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + public RankingV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductRepository productRepository, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productRepository = productRepository; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + } + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/rankings - 일별 랭킹 조회") + @Nested + class GetDailyRanking { + + @DisplayName("랭킹 데이터가 있으면 정상적으로 반환된다.") + @Test + void returnsRanking_whenDataExists() { + // arrange + Brand brand1 = createBrand("브랜드1"); + Brand brand2 = createBrand("브랜드2"); + brand1 = brandJpaRepository.save(brand1); + brand2 = brandJpaRepository.save(brand2); + + Product product1 = createProduct("상품1", brand1.getId(), 10000, 10, 100); + Product product2 = createProduct("상품2", brand2.getId(), 20000, 20, 200); + productRepository.saveProduct(product1); + productRepository.saveProduct(product2); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product1.getId()), 100.0); + zSetOps.add(key, String.valueOf(product2.getId()), 50.0); + + String url = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=20"; + + // act + ResponseEntity> response = testRestTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); + + RankingV1Dto.DailyRankingListResponse rankingResponse = response.getBody().data(); + assertThat(rankingResponse.rankings()).hasSize(2); + + RankingV1Dto.DailyRankingItem item1 = rankingResponse.rankings().get(0); + assertAll( + () -> assertThat(item1.productId()).isEqualTo(product1.getId()), + () -> assertThat(item1.productName()).isEqualTo("상품1"), + () -> assertThat(item1.brandName()).isEqualTo("브랜드1"), + () -> assertThat(item1.price()).isEqualTo(10000), + () -> assertThat(item1.likeCount()).isEqualTo(10), + () -> assertThat(item1.rank()).isEqualTo(1) + ); + + RankingV1Dto.DailyRankingItem item2 = rankingResponse.rankings().get(1); + assertAll( + () -> assertThat(item2.productId()).isEqualTo(product2.getId()), + () -> assertThat(item2.productName()).isEqualTo("상품2"), + () -> assertThat(item2.brandName()).isEqualTo("브랜드2"), + () -> assertThat(item2.price()).isEqualTo(20000), + () -> assertThat(item2.likeCount()).isEqualTo(20), + () -> assertThat(item2.rank()).isEqualTo(2) + ); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoDataExists() { + // arrange + LocalDate date = LocalDate.now(); + String url = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=20"; + + // act + ResponseEntity> response = testRestTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); + + RankingV1Dto.DailyRankingListResponse rankingResponse = response.getBody().data(); + assertThat(rankingResponse.rankings()).isEmpty(); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + Brand brand = createBrand("브랜드1"); + brand = brandJpaRepository.save(brand); + + Product product1 = createProduct("상품1", brand.getId(), 10000, 10, 100); + Product product2 = createProduct("상품2", brand.getId(), 20000, 20, 200); + Product product3 = createProduct("상품3", brand.getId(), 30000, 30, 300); + productRepository.saveProduct(product1); + productRepository.saveProduct(product2); + productRepository.saveProduct(product3); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product1.getId()), 100.0); + zSetOps.add(key, String.valueOf(product2.getId()), 50.0); + zSetOps.add(key, String.valueOf(product3.getId()), 30.0); + + // act - 첫 페이지 + String url1 = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=2"; + ResponseEntity> response1 = testRestTemplate.exchange( + url1, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.OK); + RankingV1Dto.DailyRankingListResponse page1 = response1.getBody().data(); + assertThat(page1.rankings()).hasSize(2); + assertThat(page1.rankings().get(0).productId()).isEqualTo(product1.getId()); + assertThat(page1.rankings().get(1).productId()).isEqualTo(product2.getId()); + + // act - 두 번째 페이지 + String url2 = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=2&size=2"; + ResponseEntity> response2 = testRestTemplate.exchange( + url2, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK); + RankingV1Dto.DailyRankingListResponse page2 = response2.getBody().data(); + assertThat(page2.rankings()).hasSize(1); + assertThat(page2.rankings().get(0).productId()).isEqualTo(product3.getId()); + } + } + + private Brand createBrand(String name) { + return Brand.createBrand(name, name + " 설명"); + } + + private Product createProduct(String name, Long brandId, Integer price, Integer likeCount, Integer stock) { + return Product.createProduct( + name, + brandId, + Price.createPrice(price), + LikeCount.createLikeCount(likeCount), + Stock.createStock(stock) + ); + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java index ea4b4d15a..eb986acdd 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -4,11 +4,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceStreamerApplication { @PostConstruct public void started() { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..59999d1a3 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,64 @@ +package com.loopers.domain.ranking; + +import java.time.Duration; +import java.util.Collections; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.connection.zset.Aggregate; +import org.springframework.data.redis.connection.zset.Weights; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Service +public class RankingService { + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final int TTL_SECONDS = 2 * 24 * 60 * 60; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RedisTemplate redisTemplateMaster; + + public RankingService(@Qualifier("redisTemplateMaster") RedisTemplate redisTemplateMaster) { + this.redisTemplateMaster = redisTemplateMaster; + } + + public void incrementScore(Long productId, RankingWeight weight) { + LocalDate date = LocalDate.now(); + + String key = getRankingKey(date); + String member = String.valueOf(productId); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double newScore = zSetOps.incrementScore(key, member, weight.getWeight()); + + redisTemplateMaster.expire(key, Duration.ofSeconds(TTL_SECONDS)); + + log.debug("랭킹 점수 증가: productId={}, weight={}, date={}, newScore={}", + productId, weight.getDescription(), date, newScore); + } + + public void carryOverScore(LocalDate fromDate, LocalDate toDate) { + String fromKey = getRankingKey(fromDate); + String toKey = getRankingKey(toDate); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + double weight = RankingWeight.CARRY_OVER.getWeight(); + zSetOps.unionAndStore(fromKey, Collections.emptyList(), toKey, + Aggregate.SUM, Weights.of(weight)); + + redisTemplateMaster.expire(toKey, Duration.ofSeconds(TTL_SECONDS)); + + log.info("랭킹 점수 carry-over 완료: fromDate={}, toDate={}", fromDate, toDate); + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java new file mode 100644 index 000000000..9a4d11916 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java @@ -0,0 +1,20 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; + +@Getter +public enum RankingWeight { + VIEW(0.1, "조회수"), + LIKE(0.2, "좋아요"), + ORDER_CREATED(0.7, "주문 생성"), + CARRY_OVER(0.1, "Carry-Over"); + + private final double weight; + private final String description; + + RankingWeight(double weight, String description) { + this.weight = weight; + this.description = description; + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java index 9f9cbdef0..6ff8546d4 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java @@ -5,6 +5,8 @@ import com.loopers.domain.cache.ProductCacheService; import com.loopers.domain.eventhandled.EventHandledService; import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.domain.ranking.RankingService; +import com.loopers.domain.ranking.RankingWeight; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -23,6 +25,7 @@ public class MetricsConsumer { private final EventHandledService eventHandledService; private final ProductMetricsService productMetricsService; private final ProductCacheService productCacheService; + private final RankingService rankingService; @KafkaListener( topics = {"product-liked-events"}, @@ -42,6 +45,7 @@ public void handleProductLikedEvents( } productMetricsService.incrementLikeCount(event.productId()); + rankingService.incrementScore(event.productId(), RankingWeight.LIKE); eventHandledService.markAsHandled( event.eventId(), "ProductLiked", @@ -100,6 +104,7 @@ public void handleProductViewedEvents( } productMetricsService.incrementViewCount(event.productId()); + rankingService.incrementScore(event.productId(), RankingWeight.VIEW); eventHandledService.markAsHandled( event.eventId(), "ProductViewed", @@ -129,6 +134,11 @@ public void handleOrderCreatedEvents( } log.info("주문 생성 이벤트 수신: orderId={}, orderKey={}", event.orderId(), event.orderKey()); + + for (KafkaEvent.OrderEvent.OrderItemInfo orderItem : event.orderItems()) { + rankingService.incrementScore(orderItem.productId(), RankingWeight.ORDER_CREATED); + } + eventHandledService.markAsHandled( event.eventId(), "OrderCreated", diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/RankingScoreCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/RankingScoreCarryOverScheduler.java new file mode 100644 index 000000000..ada86a94e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/RankingScoreCarryOverScheduler.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +@Slf4j +public class RankingScoreCarryOverScheduler { + + private final RankingService rankingService; + + @Scheduled(cron = "0 50 23 * * *", zone = "Asia/Seoul") + public void carryOverRankingScore() { + try { + LocalDate today = LocalDate.now(); + LocalDate targetDate = today.plusDays(1); + + rankingService.carryOverScore(today, targetDate); + } catch (Exception e) { + log.error("랭킹 점수 carry-over 중 오류 발생", e); + } + } +} + diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java new file mode 100644 index 000000000..b44bf6a09 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java @@ -0,0 +1,223 @@ +package com.loopers.domain.ranking; + +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +class RankingServiceIntegrationTest { + + @Autowired + private RankingService rankingService; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @DisplayName("랭킹 점수 증가") + @Nested + class IncrementScore { + + @DisplayName("좋아요 이벤트 발생 시 점수가 적절하게 증가한다.") + @Test + void incrementsScore_whenLikeEventOccurs() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + + // act + rankingService.incrementScore(productId, RankingWeight.LIKE); + + // assert + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double score = zSetOps.score(key, String.valueOf(productId)); + assertThat(score).isNotNull(); + assertThat(score).isCloseTo(RankingWeight.LIKE.getWeight(), within(0.001)); + + // TTL 확인 + Long ttl = redisTemplateMaster.getExpire(key); + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + assertThat(ttl).isLessThanOrEqualTo(2 * 24 * 60 * 60); // 2일 + } + + @DisplayName("조회수 이벤트 발생 시 점수가 적절하게 증가한다.") + @Test + void incrementsScore_whenViewEventOccurs() { + // arrange + Long productId = 2L; + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + + // act + rankingService.incrementScore(productId, RankingWeight.VIEW); + + // assert + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double score = zSetOps.score(key, String.valueOf(productId)); + assertThat(score).isNotNull(); + assertThat(score).isCloseTo(RankingWeight.VIEW.getWeight(), within(0.001)); + } + + @DisplayName("주문 생성 이벤트 발생 시 점수가 적절하게 증가한다.") + @Test + void incrementsScore_whenOrderCreatedEventOccurs() { + // arrange + Long productId = 3L; + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + + // act + rankingService.incrementScore(productId, RankingWeight.ORDER_CREATED); + + // assert + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double score = zSetOps.score(key, String.valueOf(productId)); + assertThat(score).isNotNull(); + assertThat(score).isCloseTo(RankingWeight.ORDER_CREATED.getWeight(), within(0.001)); + } + + @DisplayName("여러 번 점수를 증가시키면 누적된다.") + @Test + void accumulatesScore_whenMultipleIncrements() { + // arrange + Long productId = 4L; + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + + // act + rankingService.incrementScore(productId, RankingWeight.LIKE); + rankingService.incrementScore(productId, RankingWeight.VIEW); + rankingService.incrementScore(productId, RankingWeight.ORDER_CREATED); + + // assert + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double score = zSetOps.score(key, String.valueOf(productId)); + assertThat(score).isNotNull(); + double expectedScore = RankingWeight.LIKE.getWeight() + + RankingWeight.VIEW.getWeight() + + RankingWeight.ORDER_CREATED.getWeight(); + assertThat(score).isCloseTo(expectedScore, within(0.001)); + } + + @DisplayName("날짜별로 다른 키를 사용한다.") + @Test + void usesDifferentKeys_forDifferentDates() { + // arrange + Long productId = 5L; + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + + // act + rankingService.incrementScore(productId, RankingWeight.LIKE); + + // assert + String todayKey = getRankingKey(today); + String tomorrowKey = getRankingKey(tomorrow); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double todayScore = zSetOps.score(todayKey, String.valueOf(productId)); + Double tomorrowScore = zSetOps.score(tomorrowKey, String.valueOf(productId)); + + assertThat(todayScore).isNotNull(); + assertThat(tomorrowScore).isNull(); // 내일 키에는 데이터가 없음 + } + } + + @DisplayName("랭킹 점수 Carry-Over") + @Nested + class CarryOverScore { + + @DisplayName("전날 점수를 다음 날로 carry-over한다.") + @Test + void carriesOverScore_fromPreviousDayToNextDay() { + // arrange + Long productId1 = 10L; + Long productId2 = 20L; + LocalDate yesterday = LocalDate.now().minusDays(1); + LocalDate today = LocalDate.now(); + + String yesterdayKey = getRankingKey(yesterday); + String todayKey = getRankingKey(today); + + // 전날 데이터 설정 + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(yesterdayKey, String.valueOf(productId1), 100.0); + zSetOps.add(yesterdayKey, String.valueOf(productId2), 50.0); + + // act + rankingService.carryOverScore(yesterday, today); + + // assert + Double todayScore1 = zSetOps.score(todayKey, String.valueOf(productId1)); + Double todayScore2 = zSetOps.score(todayKey, String.valueOf(productId2)); + + assertThat(todayScore1).isNotNull(); + assertThat(todayScore2).isNotNull(); + + // carry-over weight (0.1)가 적용된 점수 + assertThat(todayScore1).isCloseTo(100.0 * RankingWeight.CARRY_OVER.getWeight(), within(0.001)); + assertThat(todayScore2).isCloseTo(50.0 * RankingWeight.CARRY_OVER.getWeight(), within(0.001)); + + // TTL 확인 + Long ttl = redisTemplateMaster.getExpire(todayKey); + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + } + + @DisplayName("carry-over 후에도 원본 데이터는 유지된다.") + @Test + void preservesOriginalData_afterCarryOver() { + // arrange + Long productId = 30L; + LocalDate yesterday = LocalDate.now().minusDays(1); + LocalDate today = LocalDate.now(); + + String yesterdayKey = getRankingKey(yesterday); + String todayKey = getRankingKey(today); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(yesterdayKey, String.valueOf(productId), 200.0); + + // act + rankingService.carryOverScore(yesterday, today); + + // assert + Double yesterdayScore = zSetOps.score(yesterdayKey, String.valueOf(productId)); + Double todayScore = zSetOps.score(todayKey, String.valueOf(productId)); + + assertThat(yesterdayScore).isCloseTo(200.0, within(0.001)); + assertThat(todayScore).isCloseTo(200.0 * RankingWeight.CARRY_OVER.getWeight(), within(0.001)); + } + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java b/modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java index 3e349faa9..c32a461fc 100644 --- a/modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java +++ b/modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java @@ -29,9 +29,10 @@ record OrderCreated( Long orderId, Integer totalPrice, Integer discountPrice, + List orderItems, ZonedDateTime timestamp ) implements OrderEvent { - public static OrderCreated from(String orderKey, Long userId, Long orderId, Integer totalPrice, Integer discountPrice) { + public static OrderCreated from(String orderKey, Long userId, Long orderId, Integer totalPrice, Integer discountPrice, List orderItems) { return new OrderCreated( KafkaEvent.generateEventId("order-created", orderKey), orderKey, @@ -39,6 +40,7 @@ public static OrderCreated from(String orderKey, Long userId, Long orderId, Inte orderId, totalPrice, discountPrice, + orderItems, ZonedDateTime.now() ); }