diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index a050b22380..164cabbad4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -6,13 +6,18 @@ import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.domain.ranking.RankingRepository; +import com.loopers.domain.ranking.WeeklyRankingRepository; import com.loopers.support.page.PageResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -22,20 +27,22 @@ public class RankingFacade { private final RankingRepository rankingRepository; + private final WeeklyRankingRepository weeklyRankingRepository; + private final MonthlyRankingRepository monthlyRankingRepository; private final ProductRepository productRepository; private final BrandRepository brandRepository; private final ProductAssembler productAssembler; @Transactional(readOnly = true) - public PageResponse getPage(LocalDate date, int page, int size) { + public PageResponse getPage(LocalDate date, RankingPeriod period, int page, int size) { long offset = (long) (page - 1) * size; - List productIds = rankingRepository.findProductIdsByRank(date, offset, (long) size); + List productIds = resolveProductIds(date, period, offset, (long) size); if (productIds.isEmpty()) { return new PageResponse<>(List.of(), page, size, 0); } - long count = rankingRepository.countByDate(date); + long count = resolveCount(date, period); int totalPages = (int) Math.ceil((double) count / size); List products = productRepository.findAllByIdIn(productIds); @@ -55,4 +62,23 @@ public PageResponse getPage(LocalDate date, int page, int size) { return new PageResponse<>(rankingInfos, page, size, totalPages); } + + private List resolveProductIds(LocalDate date, RankingPeriod period, long offset, long limit) { + return switch (period) { + case DAILY -> rankingRepository.findProductIdsByRank(date, offset, limit); + case WEEKLY -> weeklyRankingRepository.findProductIdsByBaseDate( + date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)), offset, limit); + case MONTHLY -> monthlyRankingRepository.findProductIdsByBaseDate( + date.withDayOfMonth(1), offset, limit); + }; + } + + private long resolveCount(LocalDate date, RankingPeriod period) { + return switch (period) { + case DAILY -> rankingRepository.countByDate(date); + case WEEKLY -> weeklyRankingRepository.countByBaseDate( + date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))); + case MONTHLY -> monthlyRankingRepository.countByBaseDate(date.withDayOfMonth(1)); + }; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java new file mode 100644 index 0000000000..a1c22533d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface MonthlyRankingRepository { + + List findProductIdsByBaseDate(LocalDate baseDate, long offset, long limit); + + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..5cdf237dd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_monthly") +public class MvProductRankMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rank; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "total_like", nullable = false) + private long totalLike; + + @Column(name = "total_order", nullable = false) + private long totalOrder; + + @Column(name = "total_view", nullable = false) + private long totalView; + + @Column(name = "total_sales", nullable = false) + private long totalSales; + + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private MvProductRankMonthly(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + this.productId = productId; + this.rank = rank; + this.score = score; + this.totalLike = totalLike; + this.totalOrder = totalOrder; + this.totalView = totalView; + this.totalSales = totalSales; + this.baseDate = baseDate; + } + + public static MvProductRankMonthly of(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + return new MvProductRankMonthly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..d31f2d23d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_weekly") +public class MvProductRankWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rank; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "total_like", nullable = false) + private long totalLike; + + @Column(name = "total_order", nullable = false) + private long totalOrder; + + @Column(name = "total_view", nullable = false) + private long totalView; + + @Column(name = "total_sales", nullable = false) + private long totalSales; + + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private MvProductRankWeekly(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + this.productId = productId; + this.rank = rank; + this.score = score; + this.totalLike = totalLike; + this.totalOrder = totalOrder; + this.totalView = totalView; + this.totalSales = totalSales; + this.baseDate = baseDate; + } + + public static MvProductRankWeekly of(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + return new MvProductRankWeekly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java new file mode 100644 index 0000000000..f23babc174 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum RankingPeriod { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java new file mode 100644 index 0000000000..b8aecf08d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyRankingRepository { + + List findProductIdsByBaseDate(LocalDate baseDate, long offset, long limit); + + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepository.java new file mode 100644 index 0000000000..9042f8fdd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Repository +public class JpaMonthlyRankingRepository implements MonthlyRankingRepository { + + private final MonthlyRankingJpaRepository jpaRepository; + + @Override + public List findProductIdsByBaseDate(LocalDate baseDate, long offset, long limit) { + int pageNumber = (int) (offset / limit); + PageRequest pageRequest = PageRequest.of(pageNumber, (int) limit); + return jpaRepository.findProductIdsByBaseDate(baseDate, pageRequest); + } + + @Override + public long countByBaseDate(LocalDate baseDate) { + return jpaRepository.countByBaseDate(baseDate); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepository.java new file mode 100644 index 0000000000..fd058bf2d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Repository +public class JpaWeeklyRankingRepository implements WeeklyRankingRepository { + + private final WeeklyRankingJpaRepository jpaRepository; + + @Override + public List findProductIdsByBaseDate(LocalDate baseDate, long offset, long limit) { + int pageNumber = (int) (offset / limit); + PageRequest pageRequest = PageRequest.of(pageNumber, (int) limit); + return jpaRepository.findProductIdsByBaseDate(baseDate, pageRequest); + } + + @Override + public long countByBaseDate(LocalDate baseDate) { + return jpaRepository.countByBaseDate(baseDate); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java new file mode 100644 index 0000000000..671619b756 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + @Query("SELECT m.productId FROM MvProductRankMonthly m WHERE m.baseDate = :baseDate ORDER BY m.rank ASC") + List findProductIdsByBaseDate(@Param("baseDate") LocalDate baseDate, Pageable pageable); + + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java new file mode 100644 index 0000000000..2c8db66e9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + @Query("SELECT w.productId FROM MvProductRankWeekly w WHERE w.baseDate = :baseDate ORDER BY w.rank ASC") + List findProductIdsByBaseDate(@Param("baseDate") LocalDate baseDate, Pageable pageable); + + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java index 94fe4f276b..95bc219fe4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java @@ -2,6 +2,7 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.page.PageResponse; import lombok.RequiredArgsConstructor; @@ -22,11 +23,12 @@ public class RankingController { @GetMapping("/api/v1/rankings") public ApiResponse> getRankings( @RequestParam(required = false) @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, + @RequestParam(defaultValue = "DAILY") RankingPeriod period, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size ) { LocalDate targetDate = date != null ? date : LocalDate.now(ZoneOffset.UTC); - PageResponse infos = rankingFacade.getPage(targetDate, page, size); + PageResponse infos = rankingFacade.getPage(targetDate, period, page, size); return ApiResponse.success(infos.map(RankingDto.Response::from)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java index 6c9c12d6d0..b5f92e51ae 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -7,7 +7,10 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.Stock; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.domain.ranking.RankingRepository; +import com.loopers.domain.ranking.WeeklyRankingRepository; import com.loopers.support.page.PageResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -23,10 +26,12 @@ class RankingFacadeTest { RankingRepository rankingRepository = mock(RankingRepository.class); + WeeklyRankingRepository weeklyRankingRepository = mock(WeeklyRankingRepository.class); + MonthlyRankingRepository monthlyRankingRepository = mock(MonthlyRankingRepository.class); ProductRepository productRepository = mock(ProductRepository.class); BrandRepository brandRepository = mock(BrandRepository.class); ProductAssembler productAssembler = new ProductAssembler(); - RankingFacade rankingFacade = new RankingFacade(rankingRepository, productRepository, brandRepository, productAssembler); + RankingFacade rankingFacade = new RankingFacade(rankingRepository, weeklyRankingRepository, monthlyRankingRepository, productRepository, brandRepository, productAssembler); @DisplayName("getPage() 를 호출할 때, ") @Nested @@ -56,7 +61,7 @@ void returnsRankingInfosInRankingOrder() { when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); // act - PageResponse result = rankingFacade.getPage(date, page, size); + PageResponse result = rankingFacade.getPage(date, RankingPeriod.DAILY, page, size); // assert assertThat(result.content()).hasSize(2); @@ -76,7 +81,7 @@ void returnsEmptyPage_whenZSetIsEmpty() { when(rankingRepository.findProductIdsByRank(date, 0L, 20L)).thenReturn(List.of()); // act - PageResponse result = rankingFacade.getPage(date, page, size); + PageResponse result = rankingFacade.getPage(date, RankingPeriod.DAILY, page, size); // assert assertThat(result.content()).isEmpty(); @@ -104,12 +109,106 @@ void calculatesTotalPagesCorrectly() { when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); // act - PageResponse result = rankingFacade.getPage(date, page, size); + PageResponse result = rankingFacade.getPage(date, RankingPeriod.DAILY, page, size); // assert assertThat(result.totalPages()).isEqualTo(2); } + @DisplayName("period=WEEKLY 이면 date 가 속한 주의 월요일 기준으로 weeklyRankingRepository 를 호출한다.") + @Test + void getPage_weekly_returnsInfos_withMondayAsBaseDate() { + // arrange + LocalDate wednesday = LocalDate.of(2026, 4, 15); // 수요일 + LocalDate monday = LocalDate.of(2026, 4, 13); // 해당 주 월요일 + int page = 1, size = 2; + + Long brandId = 1L; + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + Product product1 = mockProduct(10L, "상품 A", null, brandId, 10, 1000, 5L); + Product product2 = mockProduct(20L, "상품 B", null, brandId, 5, 2000, 3L); + + when(weeklyRankingRepository.findProductIdsByBaseDate(monday, 0L, 2L)).thenReturn(List.of(10L, 20L)); + when(weeklyRankingRepository.countByBaseDate(monday)).thenReturn(2L); + when(productRepository.findAllByIdIn(List.of(10L, 20L))).thenReturn(List.of(product1, product2)); + when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + + // act + PageResponse result = rankingFacade.getPage(wednesday, RankingPeriod.WEEKLY, page, size); + + // assert + assertThat(result.content()).hasSize(2); + assertThat(result.content().get(0).rank()).isEqualTo(1L); + assertThat(result.content().get(0).productId()).isEqualTo(10L); + } + + @DisplayName("period=WEEKLY 이고 데이터가 없으면 빈 페이지를 반환한다.") + @Test + void getPage_weekly_returnsEmpty_whenNoData() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 13); + LocalDate monday = LocalDate.of(2026, 4, 13); + + when(weeklyRankingRepository.findProductIdsByBaseDate(monday, 0L, 20L)).thenReturn(List.of()); + + // act + PageResponse result = rankingFacade.getPage(date, RankingPeriod.WEEKLY, 1, 20); + + // assert + assertThat(result.content()).isEmpty(); + assertThat(result.totalPages()).isEqualTo(0); + } + + @DisplayName("period=MONTHLY 이면 date 가 속한 월의 1일 기준으로 monthlyRankingRepository 를 호출한다.") + @Test + void getPage_monthly_returnsInfos_withFirstDayAsBaseDate() { + // arrange + LocalDate midMonth = LocalDate.of(2026, 4, 15); // 15일 + LocalDate firstDay = LocalDate.of(2026, 4, 1); // 해당 월 1일 + int page = 1, size = 2; + + Long brandId = 1L; + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + Product product1 = mockProduct(10L, "상품 A", null, brandId, 10, 1000, 5L); + Product product2 = mockProduct(20L, "상품 B", null, brandId, 5, 2000, 3L); + + when(monthlyRankingRepository.findProductIdsByBaseDate(firstDay, 0L, 2L)).thenReturn(List.of(10L, 20L)); + when(monthlyRankingRepository.countByBaseDate(firstDay)).thenReturn(2L); + when(productRepository.findAllByIdIn(List.of(10L, 20L))).thenReturn(List.of(product1, product2)); + when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + + // act + PageResponse result = rankingFacade.getPage(midMonth, RankingPeriod.MONTHLY, page, size); + + // assert + assertThat(result.content()).hasSize(2); + assertThat(result.content().get(0).rank()).isEqualTo(1L); + assertThat(result.content().get(0).productId()).isEqualTo(10L); + } + + @DisplayName("period=MONTHLY 이고 데이터가 없으면 빈 페이지를 반환한다.") + @Test + void getPage_monthly_returnsEmpty_whenNoData() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 15); + LocalDate firstDay = LocalDate.of(2026, 4, 1); + + when(monthlyRankingRepository.findProductIdsByBaseDate(firstDay, 0L, 20L)).thenReturn(List.of()); + + // act + PageResponse result = rankingFacade.getPage(date, RankingPeriod.MONTHLY, 1, 20); + + // assert + assertThat(result.content()).isEmpty(); + assertThat(result.totalPages()).isEqualTo(0); + } + private Product mockProduct(Long id, String name, String description, Long brandId, int stockValue, int priceValue, Long likeCount) { Product product = mock(Product.class); diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepositoryTest.java new file mode 100644 index 0000000000..26f80ece42 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepositoryTest.java @@ -0,0 +1,120 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +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.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(MySqlTestContainersConfig.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class JpaMonthlyRankingRepositoryTest { + + @Autowired + private MonthlyRankingRepository monthlyRankingRepository; + + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("findProductIdsByBaseDate() 를 호출할 때,") + @Nested + class FindProductIdsByBaseDate { + + @DisplayName("rank 오름차순으로 productId 목록을 반환한다.") + @Test + void returnsInRankOrder() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(100L, 1, 300.0, 10, 5, 100, 5000, baseDate)); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(200L, 2, 200.0, 8, 3, 80, 3000, baseDate)); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(300L, 3, 100.0, 5, 1, 50, 1000, baseDate)); + + // act + List result = monthlyRankingRepository.findProductIdsByBaseDate(baseDate, 0, 3); + + // assert + assertThat(result).containsExactly(100L, 200L, 300L); + } + + @DisplayName("offset 과 limit 에 따라 해당 페이지만 반환한다.") + @Test + void withOffsetAndLimit() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + for (int rank = 1; rank <= 5; rank++) { + monthlyRankingJpaRepository.save(MvProductRankMonthly.of((long) rank * 10, rank, 100.0 - rank, 1, 1, 1, 1000, baseDate)); + } + + // act + List result = monthlyRankingRepository.findProductIdsByBaseDate(baseDate, 2, 2); + + // assert + assertThat(result).containsExactly(30L, 40L); + } + + @DisplayName("해당 baseDate 데이터가 없으면 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoData() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + + // act + List result = monthlyRankingRepository.findProductIdsByBaseDate(baseDate, 0, 10); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("countByBaseDate() 를 호출할 때,") + @Nested + class CountByBaseDate { + + @DisplayName("해당 baseDate 의 전체 row 수를 반환한다.") + @Test + void returnsTotalCount() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(1L, 1, 100.0, 1, 1, 1, 1000, baseDate)); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(2L, 2, 90.0, 1, 1, 1, 900, baseDate)); + + // act + long count = monthlyRankingRepository.countByBaseDate(baseDate); + + // assert + assertThat(count).isEqualTo(2); + } + + @DisplayName("해당 baseDate 데이터가 없으면 0을 반환한다.") + @Test + void returnsZero_whenNoData() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + + // act + long count = monthlyRankingRepository.countByBaseDate(baseDate); + + // assert + assertThat(count).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepositoryTest.java new file mode 100644 index 0000000000..573888f847 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepositoryTest.java @@ -0,0 +1,120 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +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.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(MySqlTestContainersConfig.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class JpaWeeklyRankingRepositoryTest { + + @Autowired + private WeeklyRankingRepository weeklyRankingRepository; + + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("findProductIdsByBaseDate() 를 호출할 때,") + @Nested + class FindProductIdsByBaseDate { + + @DisplayName("rank 오름차순으로 productId 목록을 반환한다.") + @Test + void returnsInRankOrder() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(100L, 1, 300.0, 10, 5, 100, 5000, baseDate)); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(200L, 2, 200.0, 8, 3, 80, 3000, baseDate)); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(300L, 3, 100.0, 5, 1, 50, 1000, baseDate)); + + // act + List result = weeklyRankingRepository.findProductIdsByBaseDate(baseDate, 0, 3); + + // assert + assertThat(result).containsExactly(100L, 200L, 300L); + } + + @DisplayName("offset 과 limit 에 따라 해당 페이지만 반환한다.") + @Test + void withOffsetAndLimit() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + for (int rank = 1; rank <= 5; rank++) { + weeklyRankingJpaRepository.save(MvProductRankWeekly.of((long) rank * 10, rank, 100.0 - rank, 1, 1, 1, 1000, baseDate)); + } + + // act + List result = weeklyRankingRepository.findProductIdsByBaseDate(baseDate, 2, 2); + + // assert + assertThat(result).containsExactly(30L, 40L); + } + + @DisplayName("해당 baseDate 데이터가 없으면 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoData() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + + // act + List result = weeklyRankingRepository.findProductIdsByBaseDate(baseDate, 0, 10); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("countByBaseDate() 를 호출할 때,") + @Nested + class CountByBaseDate { + + @DisplayName("해당 baseDate 의 전체 row 수를 반환한다.") + @Test + void returnsTotalCount() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(1L, 1, 100.0, 1, 1, 1, 1000, baseDate)); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(2L, 2, 90.0, 1, 1, 1, 900, baseDate)); + + // act + long count = weeklyRankingRepository.countByBaseDate(baseDate); + + // assert + assertThat(count).isEqualTo(2); + } + + @DisplayName("해당 baseDate 데이터가 없으면 0을 반환한다.") + @Test + void returnsZero_whenNoData() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + + // act + long count = weeklyRankingRepository.countByBaseDate(baseDate); + + // assert + assertThat(count).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java index 6f3041680b..ce76fa4f62 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java @@ -4,8 +4,12 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.Stock; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankWeekly; import com.loopers.infrastructure.brand.BrandJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.ranking.MonthlyRankingJpaRepository; +import com.loopers.infrastructure.ranking.WeeklyRankingJpaRepository; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.page.PageResponse; import com.loopers.testcontainers.RedisTestContainersConfig; @@ -47,6 +51,12 @@ class RankingApiE2ETest { @Autowired private ProductJpaRepository productJpaRepository; + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -153,5 +163,196 @@ void usesTodayDate_whenDateParamOmitted() { () -> assertThat(data.content().get(0).productId()).isEqualTo(product.getId()) ); } + + @DisplayName("period 파라미터가 잘못된 값이면 400을 반환한다.") + @Test + void period_invalidValue_returns400() { + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?period=INVALID", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/rankings?period=WEEKLY") + @Nested + class GetWeeklyRankings { + + @DisplayName("mv_product_rank_weekly 에 데이터가 있으면 rank 순서대로 상품 정보를 반환한다.") + @Test + void returnsRankingsInOrder_whenDataExists() { + // arrange + LocalDate monday = LocalDate.of(2026, 4, 13); + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + Product product1 = productJpaRepository.save(Product.of("주간 상품 A", null, Stock.from(10), Price.from(1000), brand.getId())); + Product product2 = productJpaRepository.save(Product.of("주간 상품 B", null, Stock.from(5), Price.from(2000), brand.getId())); + + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(product2.getId(), 1, 200.0, 5, 3, 50, 2000, monday)); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(product1.getId(), 2, 100.0, 3, 1, 30, 1000, monday)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + monday.format(DATE_FORMATTER) + "&period=WEEKLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).hasSize(2), + () -> assertThat(data.content().get(0).rank()).isEqualTo(1L), + () -> assertThat(data.content().get(0).productId()).isEqualTo(product2.getId()), + () -> assertThat(data.content().get(1).rank()).isEqualTo(2L), + () -> assertThat(data.content().get(1).productId()).isEqualTo(product1.getId()) + ); + } + + @DisplayName("date 가 수요일이면 해당 주 월요일 기준 데이터를 반환한다.") + @Test + void usesMondayAsBaseDate_whenDateIsWednesday() { + // arrange + LocalDate wednesday = LocalDate.of(2026, 4, 15); + LocalDate monday = LocalDate.of(2026, 4, 13); + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + Product product = productJpaRepository.save(Product.of("주간 상품", null, Stock.from(10), Price.from(1000), brand.getId())); + + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(product.getId(), 1, 100.0, 1, 1, 10, 1000, monday)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + wednesday.format(DATE_FORMATTER) + "&period=WEEKLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).hasSize(1), + () -> assertThat(data.content().get(0).productId()).isEqualTo(product.getId()) + ); + } + + @DisplayName("데이터가 없으면 빈 content 와 totalPages=0 을 반환한다.") + @Test + void returnsEmpty_whenNoWeeklyData() { + // arrange + LocalDate monday = LocalDate.of(2026, 4, 13); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + monday.format(DATE_FORMATTER) + "&period=WEEKLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).isEmpty(), + () -> assertThat(data.totalPages()).isEqualTo(0) + ); + } + } + + @DisplayName("GET /api/v1/rankings?period=MONTHLY") + @Nested + class GetMonthlyRankings { + + @DisplayName("mv_product_rank_monthly 에 데이터가 있으면 rank 순서대로 상품 정보를 반환한다.") + @Test + void returnsRankingsInOrder_whenDataExists() { + // arrange + LocalDate firstDay = LocalDate.of(2026, 4, 1); + Brand brand = brandJpaRepository.save(Brand.of("아디다스", null)); + Product product1 = productJpaRepository.save(Product.of("월간 상품 A", null, Stock.from(10), Price.from(1000), brand.getId())); + Product product2 = productJpaRepository.save(Product.of("월간 상품 B", null, Stock.from(5), Price.from(2000), brand.getId())); + + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(product2.getId(), 1, 200.0, 5, 3, 50, 2000, firstDay)); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(product1.getId(), 2, 100.0, 3, 1, 30, 1000, firstDay)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + firstDay.format(DATE_FORMATTER) + "&period=MONTHLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).hasSize(2), + () -> assertThat(data.content().get(0).rank()).isEqualTo(1L), + () -> assertThat(data.content().get(0).productId()).isEqualTo(product2.getId()), + () -> assertThat(data.content().get(1).rank()).isEqualTo(2L), + () -> assertThat(data.content().get(1).productId()).isEqualTo(product1.getId()) + ); + } + + @DisplayName("date 가 월 중간이면 해당 월 1일 기준 데이터를 반환한다.") + @Test + void usesFirstDayAsBaseDate_whenDateIsMiddleOfMonth() { + // arrange + LocalDate midMonth = LocalDate.of(2026, 4, 15); + LocalDate firstDay = LocalDate.of(2026, 4, 1); + Brand brand = brandJpaRepository.save(Brand.of("아디다스", null)); + Product product = productJpaRepository.save(Product.of("월간 상품", null, Stock.from(10), Price.from(1000), brand.getId())); + + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(product.getId(), 1, 100.0, 1, 1, 10, 1000, firstDay)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + midMonth.format(DATE_FORMATTER) + "&period=MONTHLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).hasSize(1), + () -> assertThat(data.content().get(0).productId()).isEqualTo(product.getId()) + ); + } + + @DisplayName("데이터가 없으면 빈 content 와 totalPages=0 을 반환한다.") + @Test + void returnsEmpty_whenNoMonthlyData() { + // arrange + LocalDate firstDay = LocalDate.of(2026, 4, 1); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + firstDay.format(DATE_FORMATTER) + "&period=MONTHLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).isEmpty(), + () -> assertThat(data.totalPages()).isEqualTo(0) + ); + } } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..a4cf53e37b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java @@ -0,0 +1,153 @@ +package com.loopers.batch.job.ranking.monthly; + +import com.loopers.batch.job.ranking.monthly.step.MonthlyRankingProcessor; +import com.loopers.batch.job.ranking.monthly.step.TruncateMonthlyMvTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.ProductAggregation; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +// spring.batch.job.name=monthlyRankingJob 일 때만 활성화 +// Job 흐름: truncateMonthlyMvStep → monthlyAggregateAndRankStep +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_TRUNCATE = "truncateMonthlyMvStep"; + private static final String STEP_AGGREGATE_AND_RANK = "monthlyAggregateAndRankStep"; + private static final int CHUNK_SIZE = 10; + + // score = like * 0.2 + view * 0.1 + 0.7 * LOG(1 + sales) + // 파라미터 1: 집계 시작(inclusive), 파라미터 2: 집계 종료(exclusive) + private static final String MONTHLY_METRICS_SQL = """ + SELECT product_id, + ROW_NUMBER() OVER (ORDER BY score DESC) AS `rank`, + score, total_like, total_order, total_view, total_sales + FROM ( + SELECT product_id, + SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + + 0.7 * LOG(1 + SUM(sales_amount)) AS score, + SUM(like_count) AS total_like, + SUM(order_count) AS total_order, + SUM(view_count) AS total_view, + SUM(sales_amount) AS total_sales + FROM product_metrics + WHERE metric_hour >= ? + AND metric_hour < ? + AND deleted_at IS NULL + GROUP BY product_id + ORDER BY score DESC + LIMIT 100 + ) sub + """; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final TruncateMonthlyMvTasklet truncateMonthlyMvTasklet; + private final MonthlyRankingProcessor monthlyRankingProcessor; + private final DataSource dataSource; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(truncateMonthlyMvStep()) + .next(monthlyAggregateAndRankStep()) + .listener(jobListener) + .build(); + } + + // Step 1: 이전 집계 결과 전체 삭제 (재실행 시 중복 방지) + @JobScope + @Bean(STEP_TRUNCATE) + public Step truncateMonthlyMvStep() { + return new StepBuilder(STEP_TRUNCATE, jobRepository) + .tasklet(truncateMonthlyMvTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } + + // Step 2: product_metrics 집계 → 상위 100개 랭킹 산출 → mv_product_rank_monthly 저장 + // monthlyMetricsItemReader(null): @Configuration CGLIB 가 메서드 호출을 가로채 @StepScope 프록시를 반환 + @JobScope + @Bean(STEP_AGGREGATE_AND_RANK) + public Step monthlyAggregateAndRankStep() { + return new StepBuilder(STEP_AGGREGATE_AND_RANK, jobRepository) + .chunk(CHUNK_SIZE, new DataSourceTransactionManager(dataSource)) + .reader(monthlyMetricsItemReader(null)) + .processor(monthlyRankingProcessor) + .writer(monthlyRankingItemWriter()) + .listener(stepMonitorListener) + .build(); + } + + // targetYearMonth job 파라미터 늦은 바인딩 (yyyyMM 형식) — 집계 범위: [해당 월 1일, 다음 달 1일) + @StepScope + @Bean + public JdbcCursorItemReader monthlyMetricsItemReader( + @Value("#{jobParameters['targetYearMonth']}") String targetYearMonth + ) { + YearMonth yearMonth = YearMonth.parse(targetYearMonth, DateTimeFormatter.ofPattern("yyyyMM")); + LocalDateTime startDateTime = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime endDateTime = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + return new JdbcCursorItemReaderBuilder() + .name("monthlyMetricsItemReader") + .dataSource(dataSource) + .sql(MONTHLY_METRICS_SQL) + .preparedStatementSetter(ps -> { + ps.setObject(1, startDateTime); + ps.setObject(2, endDateTime); + }) + .rowMapper((rs, rowNum) -> new ProductAggregation( + rs.getLong("product_id"), + rs.getInt("rank"), + rs.getDouble("score"), + rs.getLong("total_like"), + rs.getLong("total_order"), + rs.getLong("total_view"), + rs.getLong("total_sales") + )) + .build(); + } + + @Bean + public JdbcBatchItemWriter monthlyRankingItemWriter() { + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(""" + INSERT INTO mv_product_rank_monthly + (product_id, `rank`, score, total_like, total_order, total_view, total_sales, base_date, created_at) + VALUES (:productId, :rank, :score, :totalLike, :totalOrder, :totalView, :totalSales, :baseDate, NOW()) + """) + .beanMapped() + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java new file mode 100644 index 0000000000..bfe15a881e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java @@ -0,0 +1,37 @@ +package com.loopers.batch.job.ranking.monthly.step; + +import com.loopers.batch.job.ranking.monthly.MonthlyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.ProductAggregation; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@Component +public class MonthlyRankingProcessor implements ItemProcessor { + + // baseDate 는 해당 월의 1일로 저장 (예: 202604 → 2026-04-01) + private LocalDate baseDate; + + @Value("#{jobParameters['targetYearMonth']}") + public void setTargetYearMonth(String targetYearMonth) { + this.baseDate = YearMonth.parse(targetYearMonth, DateTimeFormatter.ofPattern("yyyyMM")).atDay(1); + } + + @Override + public MvProductRankMonthly process(ProductAggregation item) { + return MvProductRankMonthly.of( + item.productId(), item.rank(), item.score(), + item.totalLike(), item.totalOrder(), item.totalView(), + item.totalSales(), baseDate + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java new file mode 100644 index 0000000000..b43746f290 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.ranking.monthly.step; + +import com.loopers.batch.job.ranking.monthly.MonthlyRankingJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class TruncateMonthlyMvTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + + private LocalDate baseDate; + + @Value("#{jobParameters['targetYearMonth']}") + public void setTargetYearMonth(String targetYearMonth) { + this.baseDate = YearMonth.parse(targetYearMonth, DateTimeFormatter.ofPattern("yyyyMM")).atDay(1); + } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly WHERE base_date = ?", baseDate); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..8fa4a858de --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java @@ -0,0 +1,154 @@ +package com.loopers.batch.job.ranking.weekly; + +import com.loopers.batch.job.ranking.weekly.step.TruncateWeeklyMvTasklet; +import com.loopers.batch.job.ranking.weekly.step.WeeklyRankingProcessor; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductAggregation; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.LocalDateTime; + +// spring.batch.job.name=weeklyRankingJob 일 때만 활성화 +// Job 흐름: truncateWeeklyMvStep → weeklyAggregateAndRankStep +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_TRUNCATE = "truncateWeeklyMvStep"; + private static final String STEP_AGGREGATE_AND_RANK = "weeklyAggregateAndRankStep"; + private static final int CHUNK_SIZE = 10; + + // score = like * 0.2 + view * 0.1 + 0.7 * LOG(1 + sales) + // 파라미터 1: 집계 시작(inclusive), 파라미터 2: 집계 종료(exclusive) + private static final String WEEKLY_METRICS_SQL = """ + SELECT product_id, + ROW_NUMBER() OVER (ORDER BY score DESC) AS `rank`, + score, total_like, total_order, total_view, total_sales + FROM ( + SELECT product_id, + SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + + 0.7 * LOG(1 + SUM(sales_amount)) AS score, + SUM(like_count) AS total_like, + SUM(order_count) AS total_order, + SUM(view_count) AS total_view, + SUM(sales_amount) AS total_sales + FROM product_metrics + WHERE metric_hour >= ? + AND metric_hour < ? + AND deleted_at IS NULL + GROUP BY product_id + ORDER BY score DESC + LIMIT 100 + ) sub + """; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final TruncateWeeklyMvTasklet truncateWeeklyMvTasklet; + private final WeeklyRankingProcessor weeklyRankingProcessor; + private final DataSource dataSource; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(truncateWeeklyMvStep()) + .next(weeklyAggregateAndRankStep()) + .listener(jobListener) + .build(); + } + + // Step 1: 이전 집계 결과 전체 삭제 (재실행 시 중복 방지) + @JobScope + @Bean(STEP_TRUNCATE) + public Step truncateWeeklyMvStep() { + return new StepBuilder(STEP_TRUNCATE, jobRepository) + .tasklet(truncateWeeklyMvTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } + + // Step 2: product_metrics 집계 → 상위 100개 랭킹 산출 → mv_product_rank_weekly 저장 + // weeklyMetricsItemReader(null): @Configuration CGLIB 가 메서드 호출을 가로채 @StepScope 프록시를 반환 + @JobScope + @Bean(STEP_AGGREGATE_AND_RANK) + public Step weeklyAggregateAndRankStep() { + return new StepBuilder(STEP_AGGREGATE_AND_RANK, jobRepository) + .chunk(CHUNK_SIZE, new DataSourceTransactionManager(dataSource)) + .reader(weeklyMetricsItemReader(null)) + .processor(weeklyRankingProcessor) + .writer(weeklyRankingItemWriter()) + .listener(stepMonitorListener) + .build(); + } + + // targetDate job 파라미터 늦은 바인딩 — targetDate 는 해당 주 월요일, 집계 범위: [월요일, 월요일+7일) + @StepScope + @Bean + public JdbcCursorItemReader weeklyMetricsItemReader( + @Value("#{jobParameters['targetDate']}") LocalDate targetDate + ) { + if (targetDate.getDayOfWeek() != java.time.DayOfWeek.MONDAY) { + throw new IllegalArgumentException("targetDate 는 월요일이어야 합니다: " + targetDate); + } + LocalDateTime startDateTime = targetDate.atStartOfDay(); + LocalDateTime endDateTime = targetDate.plusDays(7).atStartOfDay(); + + return new JdbcCursorItemReaderBuilder() + .name("weeklyMetricsItemReader") + .dataSource(dataSource) + .sql(WEEKLY_METRICS_SQL) + .preparedStatementSetter(ps -> { + ps.setObject(1, startDateTime); + ps.setObject(2, endDateTime); + }) + .rowMapper((rs, rowNum) -> new ProductAggregation( + rs.getLong("product_id"), + rs.getInt("rank"), + rs.getDouble("score"), + rs.getLong("total_like"), + rs.getLong("total_order"), + rs.getLong("total_view"), + rs.getLong("total_sales") + )) + .build(); + } + + @Bean + public JdbcBatchItemWriter weeklyRankingItemWriter() { + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(""" + INSERT INTO mv_product_rank_weekly + (product_id, `rank`, score, total_like, total_order, total_view, total_sales, base_date, created_at) + VALUES (:productId, :rank, :score, :totalLike, :totalOrder, :totalView, :totalSales, :baseDate, NOW()) + """) + .beanMapped() + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java new file mode 100644 index 0000000000..e36dc2265a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java @@ -0,0 +1,33 @@ +package com.loopers.batch.job.ranking.weekly.step; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class TruncateWeeklyMvTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['targetDate']}") + private LocalDate baseDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly WHERE base_date = ?", baseDate); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java new file mode 100644 index 0000000000..fd074b97fd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java @@ -0,0 +1,34 @@ +package com.loopers.batch.job.ranking.weekly.step; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductAggregation; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@Component +public class WeeklyRankingProcessor implements ItemProcessor { + + private LocalDate targetDate; + + @Value("#{jobParameters['targetDate']}") + public void setTargetDate(LocalDate targetDate) { + this.targetDate = targetDate; + } + + @Override + public MvProductRankWeekly process(ProductAggregation item) { + return MvProductRankWeekly.of( + item.productId(), item.rank(), item.score(), + item.totalLike(), item.totalOrder(), item.totalView(), + item.totalSales(), targetDate + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..d6dfcd2f92 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,37 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class MvProductRankMonthly { + + private final Long productId; + private final int rank; + private final double score; + private final long totalLike; + private final long totalOrder; + private final long totalView; + private final long totalSales; + private final LocalDate baseDate; + + private MvProductRankMonthly(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + this.productId = productId; + this.rank = rank; + this.score = score; + this.totalLike = totalLike; + this.totalOrder = totalOrder; + this.totalView = totalView; + this.totalSales = totalSales; + this.baseDate = baseDate; + } + + public static MvProductRankMonthly of(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + return new MvProductRankMonthly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..e7a9875c4c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,37 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class MvProductRankWeekly { + + private final Long productId; + private final int rank; + private final double score; + private final long totalLike; + private final long totalOrder; + private final long totalView; + private final long totalSales; + private final LocalDate baseDate; + + private MvProductRankWeekly(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + this.productId = productId; + this.rank = rank; + this.score = score; + this.totalLike = totalLike; + this.totalOrder = totalOrder; + this.totalView = totalView; + this.totalSales = totalSales; + this.baseDate = baseDate; + } + + public static MvProductRankWeekly of(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + return new MvProductRankWeekly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java new file mode 100644 index 0000000000..e3d5aabdeb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking; + +public record ProductAggregation( + Long productId, + int rank, + Double score, + Long totalLike, + Long totalOrder, + Long totalView, + Long totalSales +) {} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java new file mode 100644 index 0000000000..6b38e789d0 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,134 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.monthly.MonthlyRankingJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME) +class MonthlyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + } + + @DisplayName("targetYearMonth 파라미터 없으면 Job 이 실패한다.") + @Test + void failsWithoutTargetDate() throws Exception { + // arrange & act + JobExecution execution = jobLauncherTestUtils.launchJob(); + + // assert + assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("집계 대상 메트릭이 없으면 MV 테이블은 비어 있다.") + @Test + void emptyMetrics_noRankingStored() throws Exception { + // arrange + JobParameters params = new JobParametersBuilder() + .addString("targetYearMonth", "202604") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isZero() + ); + } + + @DisplayName("상품이 100개 이하이면 모두 랭킹에 기록되고, rank 1 이 가장 높은 score 를 가진다.") + @Test + void fewProducts_allRankedInOrder() throws Exception { + // arrange + insertMetrics(1L, LocalDateTime.of(2026, 4, 16, 0, 0), 1, 1, 1, 1_000); + insertMetrics(2L, LocalDateTime.of(2026, 4, 16, 0, 0), 5, 5, 5, 50_000); + + JobParameters params = new JobParametersBuilder() + .addString("targetYearMonth", "202604") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + Long rank1ProductId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_monthly WHERE rank = 1", Long.class); + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isEqualTo(2), + () -> assertThat(rank1ProductId).isEqualTo(2L) + ); + } + + @DisplayName("상품이 100개 초과이면 상위 100개만 기록된다.") + @Test + void manyProducts_top100Only() throws Exception { + // arrange + for (long i = 1; i <= 110; i++) { + insertMetrics(i, LocalDateTime.of(2026, 4, 16, 0, 0), 1, 1, 1, i * 1_000); + } + + JobParameters params = new JobParametersBuilder() + .addString("targetYearMonth", "202604") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isEqualTo(100) + ); + } + + private void insertMetrics(long productId, LocalDateTime metricHour, + long likeCount, long orderCount, long viewCount, long salesAmount) { + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, sales_amount, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + productId, metricHour, likeCount, orderCount, viewCount, salesAmount + ); + } + + private long countMv() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM mv_product_rank_monthly", Long.class); + return count != null ? count : 0L; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java new file mode 100644 index 0000000000..b8298d554b --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,137 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + } + + @DisplayName("targetDate 파라미터 없으면 Job 이 실패한다.") + @Test + void failsWithoutTargetDate() throws Exception { + // arrange & act + JobExecution execution = jobLauncherTestUtils.launchJob(); + + // assert + assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("집계 대상 메트릭이 없으면 MV 테이블은 비어 있다.") + @Test + void emptyMetrics_noRankingStored() throws Exception { + // arrange + JobParameters params = new JobParametersBuilder() + .addLocalDate("targetDate", LocalDate.of(2026, 4, 13)) + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isZero() + ); + } + + @DisplayName("상품이 100개 이하이면 모두 랭킹에 기록되고, rank 1 이 가장 높은 score 를 가진다.") + @Test + void fewProducts_allRankedInOrder() throws Exception { + // arrange + LocalDate targetDate = LocalDate.of(2026, 4, 13); + insertMetrics(1L, LocalDateTime.of(2026, 4, 14, 0, 0), 1, 1, 1, 1_000); + insertMetrics(2L, LocalDateTime.of(2026, 4, 14, 0, 0), 5, 5, 5, 50_000); + + JobParameters params = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + Long rank1ProductId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_weekly WHERE rank = 1", Long.class); + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isEqualTo(2), + () -> assertThat(rank1ProductId).isEqualTo(2L) + ); + } + + @DisplayName("상품이 100개 초과이면 상위 100개만 기록된다.") + @Test + void manyProducts_top100Only() throws Exception { + // arrange + LocalDate targetDate = LocalDate.of(2026, 4, 13); + for (long i = 1; i <= 110; i++) { + insertMetrics(i, LocalDateTime.of(2026, 4, 14, 0, 0), 1, 1, 1, i * 1_000); + } + + JobParameters params = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isEqualTo(100) + ); + } + + private void insertMetrics(long productId, LocalDateTime metricHour, + long likeCount, long orderCount, long viewCount, long salesAmount) { + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, sales_amount, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + productId, metricHour, likeCount, orderCount, viewCount, salesAmount + ); + } + + private long countMv() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM mv_product_rank_weekly", Long.class); + return count != null ? count : 0L; + } +} diff --git a/http/commerce-api/ranking.http b/http/commerce-api/ranking.http new file mode 100644 index 0000000000..e38c7ee8a6 --- /dev/null +++ b/http/commerce-api/ranking.http @@ -0,0 +1,21 @@ +### 일간 랭킹 조회 (오늘 날짜) +GET http://localhost:8080/api/v1/rankings +Accept: application/json + +### + +### 일간 랭킹 조회 (특정 날짜) +GET http://localhost:8080/api/v1/rankings?date=20260416&period=DAILY&page=1&size=20 +Accept: application/json + +### + +### 주간 랭킹 조회 (date 가 속한 주의 월요일 기준 집계) +GET http://localhost:8080/api/v1/rankings?date=20260416&period=WEEKLY&page=1&size=20 +Accept: application/json + +### + +### 월간 랭킹 조회 (date 가 속한 월의 1일 기준 집계) +GET http://localhost:8080/api/v1/rankings?date=20260416&period=MONTHLY&page=1&size=20 +Accept: application/json