diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java new file mode 100644 index 0000000000..b38f2e84bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java @@ -0,0 +1,54 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; + +import com.loopers.application.shared.annotation.UseCase; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.RankingItem; +import com.loopers.domain.ranking.RankingService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.support.page.PageSize; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 인기 상품 랭킹을 페이지 단위로 조회한다. + * + *

배치가 집계한 지정 scoreDate의 월간 랭킹을 반환한다.

+ */ +@UseCase +@RequiredArgsConstructor +public class ReadMonthlyRankingsUseCase { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RankingService rankingService; + private final RankingResultAssembler rankingResultAssembler; + + /** + * @param userId 사용자 ID (비로그인 시 null) + * @param date 조회 기준일 (yyyyMMdd 형식) + * @param pageSize 페이지 정보 + * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) + */ + public RankingPageResult execute(Long userId, String date, PageSize pageSize) { + LocalDate scoreDate; + try { + scoreDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT); + } + List rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size()); + List rankingItems = RankingItem.toRankingItems( + rankings, + pageSize.offset(), + ProductRankingMonthly::getProductId, + ProductRankingMonthly::getScore + ); + return rankingResultAssembler.assemble(userId, rankingItems, pageSize); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java new file mode 100644 index 0000000000..0f8503921d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java @@ -0,0 +1,56 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; + +import com.loopers.application.shared.annotation.UseCase; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.RankingItem; +import com.loopers.domain.ranking.RankingService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.support.page.PageSize; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 인기 상품 랭킹을 페이지 단위로 조회한다. + * + *

배치가 집계한 지정 scoreDate의 주간 랭킹을 반환한다.

+ */ +@UseCase +@RequiredArgsConstructor +public class ReadWeeklyRankingsUseCase { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RankingService rankingService; + private final RankingResultAssembler rankingResultAssembler; + + /** + * @param userId 사용자 ID (비로그인 시 null) + * @param date 조회 기준일 (yyyyMMdd 형식) + * @param pageSize 페이지 정보 + * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) + */ + public RankingPageResult execute(Long userId, String date, PageSize pageSize) { + LocalDate scoreDate; + try { + scoreDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT); + } + List rankings = rankingService.readWeeklyTopRanked( + scoreDate, pageSize.page(), pageSize.size()); + + List rankingItems = RankingItem.toRankingItems( + rankings, + pageSize.offset(), + ProductRankingWeekly::getProductId, + ProductRankingWeekly::getScore + ); + return rankingResultAssembler.assemble(userId, rankingItems, pageSize); + } +} 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..0716560677 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +/** + * 월간 랭킹 데이터를 조회하는 포트. + * + *

배치가 집계한 {@code mv_product_rank_monthly} 테이블에서 랭킹 데이터를 읽기 전용으로 제공한다.

+ */ +public interface MonthlyRankingRepository { + + /** + * 지정한 scoreDate의 월간 랭킹을 점수 내림차순으로 조회한다. + * + * @param scoreDate 조회 기준일 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 월간 랭킹 엔티티 목록 (점수 내림차순) + */ + List readTopRanked(LocalDate scoreDate, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java new file mode 100644 index 0000000000..550af352f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -0,0 +1,47 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 월간 인기 상품 랭킹 엔티티. + * + *

배치가 집계한 {@code mv_product_rank_monthly} 테이블의 읽기 전용 매핑이다.

+ */ +@Entity +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankingMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate scoreDate; + + @Column(nullable = false) + private Double score; + + public static ProductRankingMonthly create(Long productId, LocalDate scoreDate, Double score) { + ProductRankingMonthly ranking = new ProductRankingMonthly(); + ranking.productId = productId; + ranking.scoreDate = scoreDate; + ranking.score = score; + return ranking; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java new file mode 100644 index 0000000000..3e7cdd33b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -0,0 +1,42 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankingWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate scoreDate; + + @Column(nullable = false) + private Double score; + + public static ProductRankingWeekly create(Long productId, LocalDate scoreDate, Double score) { + ProductRankingWeekly ranking = new ProductRankingWeekly(); + ranking.productId = productId; + ranking.scoreDate = scoreDate; + ranking.score = score; + return ranking; + } +} 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 index 5c58080d37..d04f7f546c 100644 --- 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 @@ -1,11 +1,39 @@ package com.loopers.domain.ranking; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.ToDoubleFunction; + /** - * 랭킹 Sorted Set의 단일 항목. + * 랭킹 단일 항목. * * @param rank 1-based 순위 * @param productId 상품 ID * @param score 가중치 기반 누적 점수 */ public record RankingItem(int rank, Long productId, double score) { + + /** + * 엔티티 목록을 {@link RankingItem} 목록으로 변환한다. + * + * @param items 원본 목록 + * @param offset 시작 오프셋 (rank 계산용) + * @param toProductId 상품 ID 추출 함수 + * @param toScore 점수 추출 함수 + * @return 순위가 포함된 랭킹 항목 목록 + */ + public static List toRankingItems( + List items, + int offset, + Function toProductId, + ToDoubleFunction toScore + ) { + List rankingItems = new ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + T item = items.get(i); + rankingItems.add(new RankingItem(offset + i + 1, toProductId.apply(item), toScore.applyAsDouble(item))); + } + return rankingItems; + } } 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 index 8abb838f93..d430317834 100644 --- 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 @@ -1,5 +1,6 @@ package com.loopers.domain.ranking; +import java.time.LocalDate; import java.util.List; import com.loopers.domain.shared.annotation.DomainService; @@ -9,13 +10,15 @@ /** * 랭킹 조회 도메인 서비스. * - *

일간·시간 단위 Redis Sorted Set에서 랭킹 데이터를 읽기 전용으로 제공한다.

+ *

일간·시간 단위는 Redis Sorted Set, 주간·월간은 배치 집계 DB에서 랭킹 데이터를 읽기 전용으로 제공한다.

*/ @DomainService @RequiredArgsConstructor public class RankingService { private final RankingRepository rankingRepository; + private final WeeklyRankingRepository weeklyRankingRepository; + private final MonthlyRankingRepository monthlyRankingRepository; /** * 일간 상위 랭킹을 조회한다. @@ -42,4 +45,28 @@ public List readHourlyTopRanked(String datetime, int offset, int co String key = RankingKeyResolver.resolveHourly(datetime); return rankingRepository.readTopRanked(key, offset, count); } + + /** + * 주간 상위 랭킹을 조회한다. + * + * @param scoreDate 조회 기준일 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 주간 랭킹 엔티티 목록 (점수 내림차순) + */ + public List readWeeklyTopRanked(LocalDate scoreDate, int page, int size) { + return weeklyRankingRepository.readTopRanked(scoreDate, page, size); + } + + /** + * 월간 상위 랭킹을 조회한다. + * + * @param scoreDate 조회 기준일 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 월간 랭킹 엔티티 목록 (점수 내림차순) + */ + public List readMonthlyTopRanked(LocalDate scoreDate, int page, int size) { + return monthlyRankingRepository.readTopRanked(scoreDate, page, size); + } } 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..c98373d00c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +/** + * 주간 랭킹 데이터를 조회하는 포트. + * + *

배치가 집계한 {@code mv_product_rank_weekly} 테이블에서 랭킹 데이터를 읽기 전용으로 제공한다.

+ */ +public interface WeeklyRankingRepository { + + /** + * 지정한 scoreDate의 주간 랭킹을 점수 내림차순으로 조회한다. + * + * @param scoreDate 조회 기준일 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 주간 랭킹 엔티티 목록 (점수 내림차순) + */ + List readTopRanked(LocalDate scoreDate, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java new file mode 100644 index 0000000000..4a5117f48b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.ranking.ProductRankingMonthly; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + List findByScoreDateOrderByScoreDesc(LocalDate scoreDate, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java new file mode 100644 index 0000000000..3f4b8a5845 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.ProductRankingMonthly; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 랭킹 조회 구현체. + * + *

배치가 집계한 {@code mv_product_rank_monthly} 테이블에서 지정한 scoreDate의 랭킹을 조회한다.

+ */ +@Repository +@RequiredArgsConstructor +public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { + + private final MonthlyRankingJpaRepository monthlyRankingJpaRepository; + + @Override + public List readTopRanked(LocalDate scoreDate, int page, int size) { + return monthlyRankingJpaRepository.findByScoreDateOrderByScoreDesc(scoreDate, PageRequest.of(page, size)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java new file mode 100644 index 0000000000..a72a9d3dab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.ranking.ProductRankingWeekly; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + List findByScoreDateOrderByScoreDesc(LocalDate scoreDate, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java new file mode 100644 index 0000000000..46745ae765 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.WeeklyRankingRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 랭킹 조회 구현체. + * + *

배치가 집계한 {@code mv_product_rank_weekly} 테이블에서 지정한 scoreDate의 랭킹을 조회한다.

+ */ +@Repository +@RequiredArgsConstructor +public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { + + private final WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + @Override + public List readTopRanked(LocalDate scoreDate, int page, int size) { + return weeklyRankingJpaRepository.findByScoreDateOrderByScoreDesc(scoreDate, PageRequest.of(page, size)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java index f080413817..ff527f618f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java @@ -6,8 +6,10 @@ import org.springframework.web.bind.annotation.RestController; import com.loopers.application.ranking.RankingPageResult; -import com.loopers.application.ranking.ReadHourlyRankingsUseCase; import com.loopers.application.ranking.ReadDailyRankingsUseCase; +import com.loopers.application.ranking.ReadHourlyRankingsUseCase; +import com.loopers.application.ranking.ReadMonthlyRankingsUseCase; +import com.loopers.application.ranking.ReadWeeklyRankingsUseCase; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.LoginUser; import com.loopers.support.page.PageSize; @@ -21,6 +23,8 @@ public class RankingV1Api implements RankingV1ApiSpec { private final ReadDailyRankingsUseCase readRankingsUseCase; private final ReadHourlyRankingsUseCase readHourlyRankingsUseCase; + private final ReadWeeklyRankingsUseCase readWeeklyRankingsUseCase; + private final ReadMonthlyRankingsUseCase readMonthlyRankingsUseCase; @GetMapping("/daily") @Override @@ -45,4 +49,28 @@ public ApiResponse getHourlyRankings( RankingPageResult result = readHourlyRankingsUseCase.execute(userId, datetime, PageSize.withMaxSize(page, size)); return ApiResponse.success(RankingDto.RankingResponse.from(result)); } + + @GetMapping("/weekly") + @Override + public ApiResponse getWeeklyRankings( + @LoginUser Long userId, + @RequestParam String date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + RankingPageResult result = readWeeklyRankingsUseCase.execute(userId, date, PageSize.withMaxSize(page, size)); + return ApiResponse.success(RankingDto.RankingResponse.from(result)); + } + + @GetMapping("/monthly") + @Override + public ApiResponse getMonthlyRankings( + @LoginUser Long userId, + @RequestParam String date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + RankingPageResult result = readMonthlyRankingsUseCase.execute(userId, date, PageSize.withMaxSize(page, size)); + return ApiResponse.success(RankingDto.RankingResponse.from(result)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java index 660622b7e2..aefe85408d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java @@ -22,4 +22,16 @@ public interface RankingV1ApiSpec { description = "Redis Sorted Set 기반의 시간 단위 인기 상품 랭킹을 페이지 단위로 조회합니다." ) ApiResponse getHourlyRankings(Long userId, String datetime, int page, int size); + + @Operation( + summary = "주간 인기 상품 랭킹 조회 API", + description = "배치 집계 기반의 주간 인기 상품 랭킹을 페이지 단위로 조회합니다." + ) + ApiResponse getWeeklyRankings(Long userId, String date, int page, int size); + + @Operation( + summary = "월간 인기 상품 랭킹 조회 API", + description = "배치 집계 기반의 월간 인기 상품 랭킹을 페이지 단위로 조회합니다." + ) + ApiResponse getMonthlyRankings(Long userId, String date, int page, int size); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java new file mode 100644 index 0000000000..36e326fb49 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java @@ -0,0 +1,91 @@ +package com.loopers.infrastructure.ranking.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +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 com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.support.BaseIntegrationTest; + +@DisplayName("MonthlyRankingRepositoryImpl 통합 테스트") +class MonthlyRankingRepositoryImplIntegrationTest extends BaseIntegrationTest { + + @Autowired + private MonthlyRankingRepository monthlyRankingRepository; + + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + + private static final LocalDate SCORE_DATE = LocalDate.of(2026, 4, 14); + + @DisplayName("상위 랭킹을 조회할 때,") + @Nested + class ReadTopRanked { + + @DisplayName("해당 scoreDate의 데이터를 score 내림차순으로 반환한다.") + @Test + void returnsItemsByScoreDateInDescendingOrder() { + // arrange + LocalDate otherDate = LocalDate.of(2026, 4, 13); + saveRanking(1L, otherDate, 99.0); + saveRanking(10L, SCORE_DATE, 70.0); + saveRanking(20L, SCORE_DATE, 58.4); + saveRanking(30L, SCORE_DATE, 45.2); + + // act + List rankings = monthlyRankingRepository.readTopRanked(SCORE_DATE, 0, 10); + + // assert + assertAll( + () -> assertThat(rankings).hasSize(3), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(10L), + () -> assertThat(rankings.get(0).getScore()).isEqualTo(70.0), + () -> assertThat(rankings.get(1).getProductId()).isEqualTo(20L), + () -> assertThat(rankings.get(2).getProductId()).isEqualTo(30L) + ); + } + + @DisplayName("page를 지정하면, 해당 페이지의 데이터를 반환한다.") + @Test + void returnsItemsByPage() { + // arrange + saveRanking(1L, SCORE_DATE, 70.0); + saveRanking(2L, SCORE_DATE, 58.4); + saveRanking(3L, SCORE_DATE, 45.2); + saveRanking(4L, SCORE_DATE, 30.0); + saveRanking(5L, SCORE_DATE, 15.5); + + // act + List rankings = monthlyRankingRepository.readTopRanked(SCORE_DATE, 1, 2); + + // assert + assertAll( + () -> assertThat(rankings).hasSize(2), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(3L), + () -> assertThat(rankings.get(1).getProductId()).isEqualTo(4L) + ); + } + + @DisplayName("데이터가 없으면, 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoData() { + // act + List rankings = monthlyRankingRepository.readTopRanked(SCORE_DATE, 0, 10); + + // assert + assertThat(rankings).isEmpty(); + } + } + + private void saveRanking(Long productId, LocalDate scoreDate, Double score) { + monthlyRankingJpaRepository.save(ProductRankingMonthly.create(productId, scoreDate, score)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java new file mode 100644 index 0000000000..f49a0d979d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java @@ -0,0 +1,91 @@ +package com.loopers.infrastructure.ranking.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +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 com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import com.loopers.support.BaseIntegrationTest; + +@DisplayName("WeeklyRankingRepositoryImpl 통합 테스트") +class WeeklyRankingRepositoryImplIntegrationTest extends BaseIntegrationTest { + + @Autowired + private WeeklyRankingRepository weeklyRankingRepository; + + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + private static final LocalDate SCORE_DATE = LocalDate.of(2026, 4, 14); + + @DisplayName("상위 랭킹을 조회할 때,") + @Nested + class ReadTopRanked { + + @DisplayName("해당 scoreDate의 데이터를 score 내림차순으로 반환한다.") + @Test + void returnsItemsByScoreDateInDescendingOrder() { + // arrange + LocalDate otherDate = LocalDate.of(2026, 4, 13); + saveRanking(1L, otherDate, 99.0); + saveRanking(10L, SCORE_DATE, 70.0); + saveRanking(20L, SCORE_DATE, 58.4); + saveRanking(30L, SCORE_DATE, 45.2); + + // act + List rankings = weeklyRankingRepository.readTopRanked(SCORE_DATE, 0, 10); + + // assert + assertAll( + () -> assertThat(rankings).hasSize(3), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(10L), + () -> assertThat(rankings.get(0).getScore()).isEqualTo(70.0), + () -> assertThat(rankings.get(1).getProductId()).isEqualTo(20L), + () -> assertThat(rankings.get(2).getProductId()).isEqualTo(30L) + ); + } + + @DisplayName("page를 지정하면, 해당 페이지의 데이터를 반환한다.") + @Test + void returnsItemsByPage() { + // arrange + saveRanking(1L, SCORE_DATE, 70.0); + saveRanking(2L, SCORE_DATE, 58.4); + saveRanking(3L, SCORE_DATE, 45.2); + saveRanking(4L, SCORE_DATE, 30.0); + saveRanking(5L, SCORE_DATE, 15.5); + + // act + List rankings = weeklyRankingRepository.readTopRanked(SCORE_DATE, 1, 2); + + // assert + assertAll( + () -> assertThat(rankings).hasSize(2), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(3L), + () -> assertThat(rankings.get(1).getProductId()).isEqualTo(4L) + ); + } + + @DisplayName("데이터가 없으면, 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoData() { + // act + List rankings = weeklyRankingRepository.readTopRanked(SCORE_DATE, 0, 10); + + // assert + assertThat(rankings).isEmpty(); + } + } + + private void saveRanking(Long productId, LocalDate scoreDate, Double score) { + weeklyRankingJpaRepository.save(ProductRankingWeekly.create(productId, scoreDate, score)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java index 5f525a5d88..9f87f5becf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java @@ -12,6 +12,8 @@ public class RankingSteps { private static final String DAILY_ENDPOINT = "/api/v1/rankings/daily"; private static final String HOURLY_ENDPOINT = "/api/v1/rankings/hourly"; + private static final String WEEKLY_ENDPOINT = "/api/v1/rankings/weekly"; + private static final String MONTHLY_ENDPOINT = "/api/v1/rankings/monthly"; public static ResponseEntity> getDailyRankings( TestRestTemplate testRestTemplate, @@ -27,6 +29,20 @@ public static ResponseEntity> getHourlyR return doGet(testRestTemplate, HOURLY_ENDPOINT, queryParams); } + public static ResponseEntity> getWeeklyRankings( + TestRestTemplate testRestTemplate, + String queryParams + ) { + return doGet(testRestTemplate, WEEKLY_ENDPOINT, queryParams); + } + + public static ResponseEntity> getMonthlyRankings( + TestRestTemplate testRestTemplate, + String queryParams + ) { + return doGet(testRestTemplate, MONTHLY_ENDPOINT, queryParams); + } + private static ResponseEntity> doGet( TestRestTemplate testRestTemplate, String endpoint, diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java index 38a843c1ed..92f9cadae1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java @@ -2,6 +2,8 @@ import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getDailyRankings; import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getHourlyRankings; +import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getMonthlyRankings; +import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getWeeklyRankings; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -19,6 +21,10 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.infrastructure.ranking.persistence.MonthlyRankingJpaRepository; +import com.loopers.infrastructure.ranking.persistence.WeeklyRankingJpaRepository; import com.loopers.interfaces.api.brand.v1.BrandDto; import com.loopers.interfaces.api.brand.v1.BrandSteps; import com.loopers.interfaces.api.product.v1.ProductDto; @@ -31,6 +37,12 @@ class RankingV1ApiE2ETest extends BaseE2ETest { @Autowired private RedisTemplate redisTemplate; + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + @DisplayName("GET /api/v1/rankings/daily") @Nested class DailyRankings { @@ -229,4 +241,163 @@ void returnsRankingsForSpecificDatetime() { ); } } + + @DisplayName("GET /api/v1/rankings/weekly") + @Nested + class WeeklyRankings { + + private static final String DATE = "20260414"; + + @DisplayName("주간 랭킹 데이터가 있으면,") + @Nested + class WhenRankingDataExists { + + private Long productId1; + private Long productId2; + private Long productId3; + + @BeforeEach + void setUp() { + Long brandId = BrandSteps.createBrand( + testRestTemplate, + new BrandDto.CreateBrandRequest("테스트 브랜드", "https://example.com/logo.png", null) + ); + productId1 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품1", "https://example.com/1.png", 50000L, 100L, null)); + productId2 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품2", "https://example.com/2.png", 30000L, 100L, null)); + productId3 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품3", "https://example.com/3.png", 10000L, 100L, null)); + + LocalDate scoreDate = LocalDate.of(2026, 4, 14); + weeklyRankingJpaRepository.save(ProductRankingWeekly.create(productId1, scoreDate, 70.0)); + weeklyRankingJpaRepository.save(ProductRankingWeekly.create(productId2, scoreDate, 58.4)); + weeklyRankingJpaRepository.save(ProductRankingWeekly.create(productId3, scoreDate, 45.2)); + } + + @DisplayName("순위순으로 상품 정보(brandId, liked 포함)를 반환한다.") + @Test + void returnsRankedProducts() { + var response = getWeeklyRankings(testRestTemplate, "date=" + DATE); + var first = response.getBody().data().rankings().get(0); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(first.rank()).isEqualTo(1), + () -> assertThat(first.productId()).isEqualTo(productId1), + () -> assertThat(first.productName()).isEqualTo("상품1"), + () -> assertThat(first.brandId()).isNotNull(), + () -> assertThat(first.liked()).isFalse() + ); + } + + @DisplayName("페이지네이션이 적용된다.") + @Test + void supportsPagination() { + var response = getWeeklyRankings(testRestTemplate, "date=" + DATE + "&page=1&size=2"); + List rankings = response.getBody().data().rankings(); + assertAll( + () -> assertThat(rankings).hasSize(1), + () -> assertThat(rankings.get(0).rank()).isEqualTo(3), + () -> assertThat(rankings.get(0).productId()).isEqualTo(productId3), + () -> assertThat(response.getBody().data().page()).isEqualTo(1), + () -> assertThat(response.getBody().data().size()).isEqualTo(2) + ); + } + } + + @DisplayName("해당 date에 데이터가 없으면, 빈 배열을 반환한다.") + @Test + void returnsEmptyRankings_whenNoData() { + var response = getWeeklyRankings(testRestTemplate, "date=" + DATE); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).isEmpty() + ); + } + } + + @DisplayName("GET /api/v1/rankings/monthly") + @Nested + class MonthlyRankings { + + private static final String DATE = "20260414"; + + @DisplayName("월간 랭킹 데이터가 있으면,") + @Nested + class WhenRankingDataExists { + + private Long productId1; + private Long productId2; + private Long productId3; + + @BeforeEach + void setUp() { + Long brandId = BrandSteps.createBrand( + testRestTemplate, + new BrandDto.CreateBrandRequest("테스트 브랜드", "https://example.com/logo.png", null) + ); + productId1 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품1", "https://example.com/1.png", 50000L, 100L, null)); + productId2 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품2", "https://example.com/2.png", 30000L, 100L, null)); + productId3 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품3", "https://example.com/3.png", 10000L, 100L, null)); + + LocalDate scoreDate = LocalDate.of(2026, 4, 14); + monthlyRankingJpaRepository.save(ProductRankingMonthly.create(productId1, scoreDate, 70.0)); + monthlyRankingJpaRepository.save(ProductRankingMonthly.create(productId2, scoreDate, 58.4)); + monthlyRankingJpaRepository.save(ProductRankingMonthly.create(productId3, scoreDate, 45.2)); + } + + @DisplayName("순위순으로 상품 정보(brandId, liked 포함)를 반환한다.") + @Test + void returnsRankedProducts() { + // act + var response = getMonthlyRankings(testRestTemplate, "date=" + DATE); + + // assert + var first = response.getBody().data().rankings().get(0); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(first.rank()).isEqualTo(1), + () -> assertThat(first.productId()).isEqualTo(productId1), + () -> assertThat(first.productName()).isEqualTo("상품1"), + () -> assertThat(first.brandId()).isNotNull(), + () -> assertThat(first.liked()).isFalse() + ); + } + + @DisplayName("페이지네이션이 적용된다.") + @Test + void supportsPagination() { + // act + var response = getMonthlyRankings(testRestTemplate, "date=" + DATE + "&page=1&size=2"); + + // assert + List rankings = response.getBody().data().rankings(); + assertAll( + () -> assertThat(rankings).hasSize(1), + () -> assertThat(rankings.get(0).rank()).isEqualTo(3), + () -> assertThat(rankings.get(0).productId()).isEqualTo(productId3), + () -> assertThat(response.getBody().data().page()).isEqualTo(1), + () -> assertThat(response.getBody().data().size()).isEqualTo(2) + ); + } + } + + @DisplayName("해당 date에 데이터가 없으면, 빈 배열을 반환한다.") + @Test + void returnsEmptyRankings_whenNoData() { + // act + var response = getMonthlyRankings(testRestTemplate, "date=" + DATE); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).isEmpty() + ); + } + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..3984e6664e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,63 @@ +package com.loopers.batch.job.ranking; + +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.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.loopers.batch.job.ranking.step.MonthlyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 랭킹 배치 Job 설정. + * + *

{@code spring.batch.job.name=monthlyRankingJob}으로 실행하며, + * JobParameters로 {@code date}(yyyyMMdd)를 전달받는다. 해당 날짜 기준 직전 30일의 {@code product_metrics}를 집계하여 {@code mv_product_rank_monthly} + * 테이블에 upsert한다.

+ * + *

실행 예시:

+ *
+ * ./gradlew :apps:commerce-batch:bootRun \
+ *   --args="--spring.batch.job.name=monthlyRankingJob date=20260414"
+ * 
+ */ +@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_NAME = "monthlyRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final MonthlyRankingTasklet monthlyRankingTasklet; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(monthlyRankingStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step monthlyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(monthlyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..4304c14e82 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,63 @@ +package com.loopers.batch.job.ranking; + +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.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.loopers.batch.job.ranking.step.WeeklyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 랭킹 배치 Job 설정. + * + *

{@code spring.batch.job.name=weeklyRankingJob}으로 실행하며, + * JobParameters로 {@code date}(yyyyMMdd)를 전달받는다. 해당 날짜 기준 직전 7일의 {@code product_metrics}를 집계하여 {@code mv_product_rank_weekly} 테이블에 + * upsert한다.

+ * + *

실행 예시:

+ *
+ * ./gradlew :apps:commerce-batch:bootRun \
+ *   --args="--spring.batch.job.name=weeklyRankingJob date=20260413"
+ * 
+ */ +@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_NAME = "weeklyRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final WeeklyRankingTasklet weeklyRankingTasklet; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(weeklyRankingStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step weeklyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(weeklyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java new file mode 100644 index 0000000000..4159e06a1f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java @@ -0,0 +1,86 @@ +package com.loopers.batch.job.ranking.step; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Objects; + +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.stereotype.Component; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingMonthlyRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 월간 랭킹 집계 Tasklet. + * + *

JobParameters의 {@code date}(yyyyMMdd)를 기준으로 + * 직전 30일(당일 제외)의 {@code product_metrics}를 집계하여 상품별 가중 스코어 상위 100개를 {@code mv_product_rank_monthly}에 upsert한다.

+ * + *

스코어 공식: {@code view_count × 0.1 + like_count × 0.2 + order_count × 0.7}

+ * + *

집계 범위 예시 (date=20260414):

+ *
+ * start: 2026-03-15
+ * end:   2026-04-13
+ * 
+ */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class MonthlyRankingTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final int LIMIT = 100; + + private final ProductMetricsRepository productMetricsRepository; + private final ProductRankingMonthlyRepository productRankingMonthlyRepository; + + @Value("#{jobParameters['date']}") + private String date; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (Objects.isNull(date)) { + throw new IllegalArgumentException("JobParameter 'date' is required"); + } + + LocalDate baseDate; + try { + baseDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("JobParameter 'date' must be yyyyMMdd format: " + date, e); + } + LocalDate start = baseDate.minusDays(30); + LocalDate end = baseDate.minusDays(1); + + log.info("월간 랭킹 집계 시작 — 기준일: {}, 범위: {} ~ {}", baseDate, start, end); + + List topScores = productMetricsRepository.findTopScores(start, end, LIMIT); + log.info("Top {} 스코어 집계 완료 — {}건", LIMIT, topScores.size()); + + List rankings = topScores.stream() + .map(p -> ProductRankingMonthly.create(p.productId(), baseDate, p.score())) + .toList(); + + productRankingMonthlyRepository.saveAll(rankings); + log.info("월간 랭킹 집계 완료 — {}개 상품 저장", rankings.size()); + + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java new file mode 100644 index 0000000000..419865cde7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java @@ -0,0 +1,86 @@ +package com.loopers.batch.job.ranking.step; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Objects; + +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.stereotype.Component; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.ProductRankingWeeklyRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 주간 랭킹 집계 Tasklet. + * + *

JobParameters의 {@code date}(yyyyMMdd)를 기준으로 + * 직전 7일(당일 제외)의 {@code product_metrics}를 집계하여 상품별 가중 스코어 상위 100개를 {@code mv_product_rank_weekly}에 upsert한다.

+ * + *

스코어 공식: {@code view_count × 0.1 + like_count × 0.2 + order_count × 0.7}

+ * + *

집계 범위 예시 (date=20260413):

+ *
+ * start: 2026-04-06
+ * end:   2026-04-12
+ * 
+ */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class WeeklyRankingTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final int LIMIT = 100; + + private final ProductMetricsRepository productMetricsRepository; + private final ProductRankingWeeklyRepository productRankingWeeklyRepository; + + @Value("#{jobParameters['date']}") + private String date; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (Objects.isNull(date)) { + throw new IllegalArgumentException("JobParameter 'date' is required"); + } + + LocalDate baseDate; + try { + baseDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("JobParameter 'date' must be yyyyMMdd format: " + date, e); + } + LocalDate start = baseDate.minusDays(7); + LocalDate end = baseDate.minusDays(1); + + log.info("주간 랭킹 집계 시작 — 기준일: {}, 범위: {} ~ {}", baseDate, start, end); + + List topScores = productMetricsRepository.findTopScores(start, end, LIMIT); + log.info("Top {} 스코어 집계 완료 — {}건", LIMIT, topScores.size()); + + List rankings = topScores.stream() + .map(p -> ProductRankingWeekly.create(p.productId(), baseDate, p.score())) + .toList(); + + productRankingWeeklyRepository.saveAll(rankings); + log.info("주간 랭킹 집계 완료 — {}개 상품 저장", rankings.size()); + + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 0000000000..294f6a3ca7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,46 @@ +package com.loopers.domain.metrics; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics", uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "metric_date"}) +}, indexes = { + @Index(name = "idx_metric_date", columnList = "metric_date") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate metricDate; + + @Column(nullable = false) + private Long viewCount; + + @Column(nullable = false) + private Long likeCount; + + @Column(nullable = false) + private Long orderCount; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 0000000000..f12be63b13 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.metrics; + +import java.time.LocalDate; +import java.util.List; + +public interface ProductMetricsRepository { + + List findTopScores(LocalDate start, LocalDate end, int limit); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java new file mode 100644 index 0000000000..e3138be515 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java @@ -0,0 +1,4 @@ +package com.loopers.domain.metrics; + +public record ProductScoreProjection(Long productId, Double score) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java new file mode 100644 index 0000000000..34109ac3ea --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -0,0 +1,48 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_monthly", uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "score_date"}) +}, indexes = { + @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankingMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate scoreDate; + + @Column(nullable = false) + private Double score; + + public static ProductRankingMonthly create(Long productId, LocalDate scoreDate, Double score) { + ProductRankingMonthly ranking = new ProductRankingMonthly(); + ranking.productId = productId; + ranking.scoreDate = scoreDate; + ranking.score = score; + return ranking; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java new file mode 100644 index 0000000000..b3690375eb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java @@ -0,0 +1,23 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +/** + * 월간 랭킹 저장소. + * + *

배치가 집계한 월간 랭킹 스코어를 저장한다. + * 동일한 (productId, scoreDate) 조합이 이미 존재하면 score를 덮어쓴다 (upsert).

+ */ +public interface ProductRankingMonthlyRepository { + + /** + * 월간 랭킹 스코어를 일괄 저장한다. + * + *

MySQL의 {@code ON DUPLICATE KEY UPDATE}를 활용하여 + * 동일한 (productId, scoreDate) 조합이 존재하면 score를 갱신하고, + * 존재하지 않으면 새로 생성한다. 이를 통해 멱등성이 보장된다.

+ * + * @param rankings 저장할 월간 랭킹 목록 + */ + void saveAll(List rankings); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java new file mode 100644 index 0000000000..fb179b6d00 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -0,0 +1,48 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly", uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "score_date"}) +}, indexes = { + @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankingWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate scoreDate; + + @Column(nullable = false) + private Double score; + + public static ProductRankingWeekly create(Long productId, LocalDate scoreDate, Double score) { + ProductRankingWeekly ranking = new ProductRankingWeekly(); + ranking.productId = productId; + ranking.scoreDate = scoreDate; + ranking.score = score; + return ranking; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java new file mode 100644 index 0000000000..c76a698159 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java @@ -0,0 +1,23 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +/** + * 주간 랭킹 저장소. + * + *

배치가 집계한 주간 랭킹 스코어를 저장한다. + * 동일한 (productId, scoreDate) 조합이 이미 존재하면 score를 덮어쓴다 (upsert).

+ */ +public interface ProductRankingWeeklyRepository { + + /** + * 주간 랭킹 스코어를 일괄 저장한다. + * + *

MySQL의 {@code ON DUPLICATE KEY UPDATE}를 활용하여 + * 동일한 (productId, scoreDate) 조합이 존재하면 score를 갱신하고, + * 존재하지 않으면 새로 생성한다. 이를 통해 멱등성이 보장된다.

+ * + * @param rankings 저장할 주간 랭킹 목록 + */ + void saveAll(List rankings); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java new file mode 100644 index 0000000000..97665f0830 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.metrics.persistence; + +import java.time.LocalDate; +import java.util.List; + +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 com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductScoreProjection; + +public interface ProductMetricsJpaRepository extends JpaRepository { + + @Query("SELECT new com.loopers.domain.metrics.ProductScoreProjection(" + + "m.productId, " + + "CAST(SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) AS double)) " + + "FROM ProductMetrics m " + + "WHERE m.metricDate BETWEEN :start AND :end " + + "GROUP BY m.productId " + + "ORDER BY SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) DESC") + List findTopScores( + @Param("start") LocalDate start, + @Param("end") LocalDate end, + Pageable pageable + ); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java new file mode 100644 index 0000000000..61e6b856c2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.metrics.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Override + public List findTopScores(LocalDate start, LocalDate end, int limit) { + return productMetricsJpaRepository.findTopScores(start, end, PageRequest.of(0, limit)); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java new file mode 100644 index 0000000000..2aeec6e9f5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.ranking.ProductRankingMonthly; + +public interface ProductRankingMonthlyJpaRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = "INSERT INTO mv_product_rank_monthly (product_id, score_date, score) " + + "VALUES (:productId, :scoreDate, :score) " + + "ON DUPLICATE KEY UPDATE " + + "score = :score", + nativeQuery = true + ) + void upsert( + @Param("productId") Long productId, + @Param("scoreDate") LocalDate scoreDate, + @Param("score") Double score + ); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java new file mode 100644 index 0000000000..3b2d656a9e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.util.List; + +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingMonthlyRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ProductRankingMonthlyRepositoryImpl implements ProductRankingMonthlyRepository { + + private final ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository; + + @Override + @Transactional + public void saveAll(List rankings) { + rankings.forEach(r -> + productRankingMonthlyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore()) + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java new file mode 100644 index 0000000000..159e55f090 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.ranking.ProductRankingWeekly; + +public interface ProductRankingWeeklyJpaRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = "INSERT INTO mv_product_rank_weekly (product_id, score_date, score) " + + "VALUES (:productId, :scoreDate, :score) " + + "ON DUPLICATE KEY UPDATE " + + "score = :score", + nativeQuery = true + ) + void upsert( + @Param("productId") Long productId, + @Param("scoreDate") LocalDate scoreDate, + @Param("score") Double score + ); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java new file mode 100644 index 0000000000..7b36967bdb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.util.List; + +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.ProductRankingWeeklyRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ProductRankingWeeklyRepositoryImpl implements ProductRankingWeeklyRepository { + + private final ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository; + + @Override + @Transactional + public void saveAll(List rankings) { + rankings.forEach(r -> + productRankingWeeklyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore()) + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java index c5e3bc7a35..71a9071861 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") public class CommerceBatchApplicationTest { @Test void contextLoads() {} 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..cba14f8aa0 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,167 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +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.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +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.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.infrastructure.metrics.persistence.ProductMetricsJpaRepository; +import com.loopers.infrastructure.ranking.persistence.ProductRankingMonthlyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; + +@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 ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("monthlyRankingJob을 실행할 때,") + @Nested + class RunJob { + + @DisplayName("date 파라미터가 없으면, Job이 실패한다.") + @Test + void failsJob_whenDateParameterIsMissing() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLong("run.id", System.nanoTime()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("30일치 product_metrics를 집계하여 월간 랭킹을 저장한다.") + @Test + void aggregatesMonthlyRanking() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 30일 범위 내 데이터 (date=20260414 → 03/15 ~ 04/13) + saveMetrics(1L, LocalDate.of(2026, 3, 20), 100L, 10L, 5L); + saveMetrics(1L, LocalDate.of(2026, 4, 5), 200L, 20L, 10L); + saveMetrics(2L, LocalDate.of(2026, 4, 10), 50L, 5L, 3L); + + var jobParameters = new JobParametersBuilder() + .addString("date", "20260414") + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + List rankings = productRankingMonthlyJpaRepository.findAll(); + + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rankings).hasSize(2), + () -> { + // product 1: (100+200)*0.1 + (10+20)*0.2 + (5+10)*0.7 = 30 + 6 + 10.5 = 46.5 + ProductRankingMonthly product1 = rankings.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst().orElseThrow(); + assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 14)); + }, + () -> { + // product 2: 50*0.1 + 5*0.2 + 3*0.7 = 5 + 1 + 2.1 = 8.1 + ProductRankingMonthly product2 = rankings.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst().orElseThrow(); + assertThat(product2.getScore()).isEqualTo(8.1); + } + ); + } + + @DisplayName("같은 파라미터로 재실행해도 결과가 동일하다 (멱등성).") + @Test + void isIdempotent_whenRerunWithSameParameters() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + saveMetrics(1L, LocalDate.of(2026, 4, 1), 100L, 10L, 5L); + + var jobParameters1 = new JobParametersBuilder() + .addString("date", "20260414") + .addLong("run.id", 1L) + .toJobParameters(); + var jobParameters2 = new JobParametersBuilder() + .addString("date", "20260414") + .addLong("run.id", 2L) + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(jobParameters1); + jobLauncherTestUtils.launchJob(jobParameters2); + + // assert + List rankings = productRankingMonthlyJpaRepository.findAll(); + assertAll( + () -> assertThat(rankings).hasSize(1), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(1L) + ); + } + } + + private void saveMetrics(Long productId, LocalDate metricDate, + Long viewCount, Long likeCount, Long orderCount) { + try { + var constructor = ProductMetrics.class.getDeclaredConstructor(); + constructor.setAccessible(true); + ProductMetrics metrics = constructor.newInstance(); + ReflectionTestUtils.setField(metrics, "productId", productId); + ReflectionTestUtils.setField(metrics, "metricDate", metricDate); + ReflectionTestUtils.setField(metrics, "viewCount", viewCount); + ReflectionTestUtils.setField(metrics, "likeCount", likeCount); + ReflectionTestUtils.setField(metrics, "orderCount", orderCount); + productMetricsJpaRepository.save(metrics); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java new file mode 100644 index 0000000000..52218922ac --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java @@ -0,0 +1,127 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.test.util.ReflectionTestUtils; + +import com.loopers.batch.job.ranking.step.MonthlyRankingTasklet; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingMonthlyRepository; + +@ExtendWith(MockitoExtension.class) +class MonthlyRankingTaskletTest { + + @InjectMocks + private MonthlyRankingTasklet tasklet; + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private ProductRankingMonthlyRepository productRankingMonthlyRepository; + + @DisplayName("월간 랭킹 집계를 수행할 때,") + @Nested + class Execute { + + @DisplayName("date 파라미터가 null이면, 예외가 발생한다.") + @Test + void throwsException_whenDateIsNull() { + // arrange + ReflectionTestUtils.setField(tasklet, "date", null); + + // act & assert + assertThatThrownBy(() -> tasklet.execute( + mock(StepContribution.class), mock(ChunkContext.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("date"); + } + + @DisplayName("DB에서 조회한 Top N 스코어를 월간 랭킹으로 저장한다.") + @Test + @SuppressWarnings("unchecked") + void savesTopNScoresAsMonthlyRanking() throws Exception { + // arrange + ReflectionTestUtils.setField(tasklet, "date", "20260414"); + + List topScores = List.of( + new ProductScoreProjection(1L, 46.5), + new ProductScoreProjection(2L, 8.1) + ); + + given(productMetricsRepository.findTopScores(any(), any(), eq(100))) + .willReturn(topScores); + + // act + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankingMonthlyRepository).saveAll(captor.capture()); + + List rankings = captor.getValue(); + + assertAll( + () -> assertThat(rankings).hasSize(2), + () -> { + ProductRankingMonthly product1 = rankings.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst().orElseThrow(); + assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 14)); + }, + () -> { + ProductRankingMonthly product2 = rankings.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst().orElseThrow(); + assertThat(product2.getScore()).isEqualTo(8.1); + } + ); + } + + @DisplayName("집계 범위를 올바르게 계산한다 (date 기준 30일 전 ~ 전날).") + @Test + void calculatesCorrectDateRange() throws Exception { + // arrange + ReflectionTestUtils.setField(tasklet, "date", "20260414"); + + given(productMetricsRepository.findTopScores(any(), any(), eq(100))) + .willReturn(List.of()); + + // act + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // assert + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDate.class); + verify(productMetricsRepository).findTopScores(startCaptor.capture(), endCaptor.capture(), eq(100)); + + assertAll( + () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDate.of(2026, 3, 15)), + () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 13)) + ); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java new file mode 100644 index 0000000000..1961fb7f46 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java @@ -0,0 +1,87 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +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.test.context.TestPropertySource; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingMonthlyRepository; +import com.loopers.infrastructure.ranking.persistence.ProductRankingMonthlyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME) +class ProductRankingMonthlyRepositoryIntegrationTest { + + @Autowired + private ProductRankingMonthlyRepository productRankingMonthlyRepository; + + @Autowired + private ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("월간 랭킹을 저장할 때,") + @Nested + class SaveAll { + + @DisplayName("같은 (product_id, score_date)로 두 번 저장하면, score가 덮어쓰기된다.") + @Test + void updatesScore_whenDuplicateKey() { + // arrange + LocalDate scoreDate = LocalDate.of(2026, 4, 14); + var firstRanking = ProductRankingMonthly.create(1L, scoreDate, 100.0); + productRankingMonthlyRepository.saveAll(List.of(firstRanking)); + + // act + var updatedRanking = ProductRankingMonthly.create(1L, scoreDate, 200.0); + productRankingMonthlyRepository.saveAll(List.of(updatedRanking)); + + // assert + List results = productRankingMonthlyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).getScore()).isEqualTo(200.0) + ); + } + + @DisplayName("다른 score_date이면, 별도 row로 저장된다.") + @Test + void createsSeparateRows_whenDifferentScoreDate() { + // arrange + LocalDate date1 = LocalDate.of(2026, 4, 14); + LocalDate date2 = LocalDate.of(2026, 4, 15); + + // act + productRankingMonthlyRepository.saveAll( + List.of(ProductRankingMonthly.create(1L, date1, 100.0))); + productRankingMonthlyRepository.saveAll( + List.of(ProductRankingMonthly.create(1L, date2, 150.0))); + + // assert + List results = productRankingMonthlyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(2), + () -> assertThat(results).extracting(ProductRankingMonthly::getScore) + .containsExactlyInAnyOrder(100.0, 150.0) + ); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java new file mode 100644 index 0000000000..d0bbb51ce8 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java @@ -0,0 +1,87 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +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.test.context.TestPropertySource; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.ProductRankingWeeklyRepository; +import com.loopers.infrastructure.ranking.persistence.ProductRankingWeeklyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME) +class ProductRankingWeeklyRepositoryIntegrationTest { + + @Autowired + private ProductRankingWeeklyRepository productRankingWeeklyRepository; + + @Autowired + private ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주간 랭킹을 저장할 때,") + @Nested + class SaveAll { + + @DisplayName("같은 (product_id, score_date)로 두 번 저장하면, score가 덮어쓰기된다.") + @Test + void updatesScore_whenDuplicateKey() { + // arrange + LocalDate scoreDate = LocalDate.of(2026, 4, 13); + var firstRanking = ProductRankingWeekly.create(1L, scoreDate, 100.0); + productRankingWeeklyRepository.saveAll(List.of(firstRanking)); + + // act + var updatedRanking = ProductRankingWeekly.create(1L, scoreDate, 200.0); + productRankingWeeklyRepository.saveAll(List.of(updatedRanking)); + + // assert + List results = productRankingWeeklyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).getScore()).isEqualTo(200.0) + ); + } + + @DisplayName("다른 score_date이면, 별도 row로 저장된다.") + @Test + void createsSeparateRows_whenDifferentScoreDate() { + // arrange + LocalDate date1 = LocalDate.of(2026, 4, 13); + LocalDate date2 = LocalDate.of(2026, 4, 14); + + // act + productRankingWeeklyRepository.saveAll( + List.of(ProductRankingWeekly.create(1L, date1, 100.0))); + productRankingWeeklyRepository.saveAll( + List.of(ProductRankingWeekly.create(1L, date2, 150.0))); + + // assert + List results = productRankingWeeklyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(2), + () -> assertThat(results).extracting(ProductRankingWeekly::getScore) + .containsExactlyInAnyOrder(100.0, 150.0) + ); + } + } +} 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..c0500afda0 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,166 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +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.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +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.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.infrastructure.metrics.persistence.ProductMetricsJpaRepository; +import com.loopers.infrastructure.ranking.persistence.ProductRankingWeeklyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; + +@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 ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("weeklyRankingJob을 실행할 때,") + @Nested + class RunJob { + + @DisplayName("date 파라미터가 없으면, Job이 실패한다.") + @Test + void failsJob_whenDateParameterIsMissing() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLong("run.id", System.nanoTime()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("7일치 product_metrics를 집계하여 주간 랭킹을 저장한다.") + @Test + void aggregatesWeeklyRanking() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + saveMetrics(1L, LocalDate.of(2026, 4, 7), 100L, 10L, 5L); + saveMetrics(1L, LocalDate.of(2026, 4, 8), 200L, 20L, 10L); + saveMetrics(2L, LocalDate.of(2026, 4, 9), 50L, 5L, 3L); + + var jobParameters = new JobParametersBuilder() + .addString("date", "20260413") + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + List rankings = productRankingWeeklyJpaRepository.findAll(); + + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rankings).hasSize(2), + () -> { + // product 1: (100+200)*0.1 + (10+20)*0.2 + (5+10)*0.7 = 30 + 6 + 10.5 = 46.5 + ProductRankingWeekly product1 = rankings.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst().orElseThrow(); + assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 13)); + }, + () -> { + // product 2: 50*0.1 + 5*0.2 + 3*0.7 = 5 + 1 + 2.1 = 8.1 + ProductRankingWeekly product2 = rankings.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst().orElseThrow(); + assertThat(product2.getScore()).isEqualTo(8.1); + } + ); + } + + @DisplayName("같은 파라미터로 재실행해도 결과가 동일하다 (멱등성).") + @Test + void isIdempotent_whenRerunWithSameParameters() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + saveMetrics(1L, LocalDate.of(2026, 4, 10), 100L, 10L, 5L); + + var jobParameters1 = new JobParametersBuilder() + .addString("date", "20260413") + .addLong("run.id", 1L) + .toJobParameters(); + var jobParameters2 = new JobParametersBuilder() + .addString("date", "20260413") + .addLong("run.id", 2L) + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(jobParameters1); + jobLauncherTestUtils.launchJob(jobParameters2); + + // assert + List rankings = productRankingWeeklyJpaRepository.findAll(); + assertAll( + () -> assertThat(rankings).hasSize(1), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(1L) + ); + } + } + + private void saveMetrics(Long productId, LocalDate metricDate, + Long viewCount, Long likeCount, Long orderCount) { + try { + var constructor = ProductMetrics.class.getDeclaredConstructor(); + constructor.setAccessible(true); + ProductMetrics metrics = constructor.newInstance(); + ReflectionTestUtils.setField(metrics, "productId", productId); + ReflectionTestUtils.setField(metrics, "metricDate", metricDate); + ReflectionTestUtils.setField(metrics, "viewCount", viewCount); + ReflectionTestUtils.setField(metrics, "likeCount", likeCount); + ReflectionTestUtils.setField(metrics, "orderCount", orderCount); + productMetricsJpaRepository.save(metrics); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java new file mode 100644 index 0000000000..516cb8f6cb --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java @@ -0,0 +1,127 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.test.util.ReflectionTestUtils; + +import com.loopers.batch.job.ranking.step.WeeklyRankingTasklet; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.ProductRankingWeeklyRepository; + +@ExtendWith(MockitoExtension.class) +class WeeklyRankingTaskletTest { + + @InjectMocks + private WeeklyRankingTasklet tasklet; + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private ProductRankingWeeklyRepository productRankingWeeklyRepository; + + @DisplayName("주간 랭킹 집계를 수행할 때,") + @Nested + class Execute { + + @DisplayName("date 파라미터가 null이면, 예외가 발생한다.") + @Test + void throwsException_whenDateIsNull() { + // arrange + ReflectionTestUtils.setField(tasklet, "date", null); + + // act & assert + assertThatThrownBy(() -> tasklet.execute( + mock(StepContribution.class), mock(ChunkContext.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("date"); + } + + @DisplayName("DB에서 조회한 Top N 스코어를 주간 랭킹으로 저장한다.") + @Test + @SuppressWarnings("unchecked") + void savesTopNScoresAsWeeklyRanking() throws Exception { + // arrange + ReflectionTestUtils.setField(tasklet, "date", "20260413"); + + List topScores = List.of( + new ProductScoreProjection(1L, 46.5), + new ProductScoreProjection(2L, 8.1) + ); + + given(productMetricsRepository.findTopScores(any(), any(), eq(100))) + .willReturn(topScores); + + // act + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankingWeeklyRepository).saveAll(captor.capture()); + + List rankings = captor.getValue(); + + assertAll( + () -> assertThat(rankings).hasSize(2), + () -> { + ProductRankingWeekly product1 = rankings.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst().orElseThrow(); + assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 13)); + }, + () -> { + ProductRankingWeekly product2 = rankings.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst().orElseThrow(); + assertThat(product2.getScore()).isEqualTo(8.1); + } + ); + } + + @DisplayName("집계 범위를 올바르게 계산한다 (date 기준 7일 전 ~ 전날).") + @Test + void calculatesCorrectDateRange() throws Exception { + // arrange + ReflectionTestUtils.setField(tasklet, "date", "20260413"); + + given(productMetricsRepository.findTopScores(any(), any(), eq(100))) + .willReturn(List.of()); + + // act + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // assert + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDate.class); + verify(productMetricsRepository).findTopScores(startCaptor.capture(), endCaptor.capture(), eq(100)); + + assertAll( + () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 6)), + () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 12)) + ); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index fb9d2b8b84..fb566d3d96 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -1,6 +1,6 @@ package com.loopers.domain.metrics; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.time.ZonedDateTime; import jakarta.persistence.Column; @@ -8,6 +8,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; @@ -19,7 +20,9 @@ @Entity @Table(name = "product_metrics", uniqueConstraints = { - @UniqueConstraint(columnNames = {"product_id", "metric_hour"}) + @UniqueConstraint(columnNames = {"product_id", "metric_date"}) +}, indexes = { + @Index(name = "idx_metric_date", columnList = "metric_date") }) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @@ -33,7 +36,7 @@ public class ProductMetrics { private Long productId; @Column(nullable = false) - private LocalDateTime metricHour; + private LocalDate metricDate; @Column(nullable = false) private Long likeCount; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java index c0ab338104..d922a184c2 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java @@ -10,24 +10,24 @@ public interface ProductMetricsJpaRepository extends JpaRepository { @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) " - + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), GREATEST(:delta, 0), 0, 0, NOW()) " + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) " + + "VALUES (:productId, CURDATE(), GREATEST(:delta, 0), 0, 0, NOW()) " + "ON DUPLICATE KEY UPDATE " + "like_count = GREATEST(like_count + :delta, 0), updated_at = NOW()", nativeQuery = true) void upsertLikeCount(@Param("productId") Long productId, @Param("delta") Long delta); @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) " - + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), 0, :quantity, 0, NOW()) " + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) " + + "VALUES (:productId, CURDATE(), 0, :quantity, 0, NOW()) " + "ON DUPLICATE KEY UPDATE " + "order_count = order_count + :quantity, updated_at = NOW()", nativeQuery = true) void upsertOrderCount(@Param("productId") Long productId, @Param("quantity") Long quantity); @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) " - + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), 0, 0, 1, NOW()) " + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) " + + "VALUES (:productId, CURDATE(), 0, 0, 1, NOW()) " + "ON DUPLICATE KEY UPDATE " + "view_count = view_count + 1, updated_at = NOW()", nativeQuery = true) diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java index c77c4f63f3..adbe8e0c30 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java @@ -3,8 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; +import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -33,7 +32,7 @@ class ProductMetricsRepositoryImplTest { @Autowired private DatabaseCleanUp databaseCleanUp; - private static final LocalDateTime CURRENT_METRIC_HOUR = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS); + private static final LocalDate CURRENT_METRIC_DATE = LocalDate.now(); @AfterEach void tearDown() { @@ -74,7 +73,7 @@ void insertsNewRow_whenNotExists() { () -> assertThat(results).hasSize(1), () -> assertThat(results.get(0).getProductId()).isEqualTo(1L), () -> assertThat(results.get(0).getLikeCount()).isEqualTo(1L), - () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR) + () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE) ); } @@ -127,7 +126,7 @@ void insertsNewRow_whenNotExists() { () -> assertThat(results).hasSize(1), () -> assertThat(results.get(0).getProductId()).isEqualTo(1L), () -> assertThat(results.get(0).getOrderCount()).isEqualTo(3L), - () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR) + () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE) ); } @@ -165,7 +164,7 @@ void insertsNewRow_whenNotExists() { () -> assertThat(results).hasSize(1), () -> assertThat(results.get(0).getProductId()).isEqualTo(1L), () -> assertThat(results.get(0).getViewCount()).isEqualTo(1L), - () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR) + () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE) ); }