diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index da43fb576..932bf1fb6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -3,9 +3,7 @@ import com.loopers.application.product.ProductInfo; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; -import com.loopers.domain.ranking.Ranking; -import com.loopers.domain.ranking.RankingService; -import com.loopers.domain.ranking.RankingType; +import com.loopers.domain.ranking.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -20,14 +18,27 @@ public class RankingFacade { private final RankingService rankingService; + private final PeriodRankingService periodRankingService; private final ProductService productService; /** * TOP N 랭킹 조회 (상품 정보 포함) */ @Transactional(readOnly = true) - public List getTopRanking(RankingType rankingType, LocalDate date, int limit) { - List entries = rankingService.getTopRanking(rankingType, date, limit); + public List getTopRanking( + RankingType rankingType, + PeriodType periodType, + LocalDate date, + int limit + ) { + // period가 null이면 DAILY로 처리 + PeriodType period = periodType != null ? periodType : PeriodType.DAILY; + + List entries = switch (period) { + case DAILY -> rankingService.getTopRanking(rankingType, LocalDate.now(), limit); + case WEEKLY -> periodRankingService.getTopWeeklyRanking(rankingType, date, limit); + case MONTHLY -> periodRankingService.getTopMonthlyRanking(rankingType, date, limit); + }; if (entries.isEmpty()) { return List.of(); @@ -40,9 +51,21 @@ public List getTopRanking(RankingType rankingType, LocalDate date, * 페이지네이션 랭킹 조회 */ @Transactional(readOnly = true) - public List getRankingWithPaging(RankingType rankingType, LocalDate date, - int page, int size) { - List entries = rankingService.getRankingWithPaging(rankingType, date, page, size); + public List getRankingWithPaging( + RankingType rankingType, + PeriodType periodType, + LocalDate date, + int page, + int size + ) { + + PeriodType period = periodType != null ? periodType : PeriodType.DAILY; + + List entries = switch (period) { + case DAILY -> rankingService.getRankingWithPaging(rankingType, LocalDate.now(), page, size); + case WEEKLY -> periodRankingService.getWeeklyRankingWithPaging(rankingType, date, page, size); + case MONTHLY -> periodRankingService.getMonthlyRankingWithPaging(rankingType, date, page, size); + }; if (entries.isEmpty()) { return List.of(); @@ -55,7 +78,11 @@ public List getRankingWithPaging(RankingType rankingType, LocalDate * 특정 상품의 특정 랭킹 조회 */ @Transactional(readOnly = true) - public RankingInfo getProductRanking(RankingType rankingType, LocalDate date, Long productId) { + public RankingInfo getProductRanking( + RankingType rankingType, + LocalDate date, + Long productId + ) { Ranking entry = rankingService.getProductRanking(rankingType, date, productId); if (entry == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java new file mode 100644 index 000000000..22a84fd55 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java @@ -0,0 +1,66 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 월간 상품 집계 Materialized View (읽기 전용) + * commerce-collector에서 생성한 집계 데이터 조회용 + */ +@Entity +@Table(name = "mv_product_metrics_monthly") +@Getter +@NoArgsConstructor +@Immutable // 읽기 전용 +public class ProductMetricsMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "month", nullable = false) + private Integer month; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount; + + @Column(name = "total_order_count", nullable = false) + private Long totalOrderCount; + + @Column(name = "aggregated_at") + private ZonedDateTime aggregatedAt; + + @Column(name = "created_at") + private ZonedDateTime createdAt; + + @Column(name = "updated_at") + private ZonedDateTime updatedAt; + + /** + * 종합 점수 계산 (가중치 적용) + * Score = (like * 0.2) + (view * 0.1) + (order * 0.6) + */ + public double calculateCompositeScore() { + return (totalLikeCount * 0.2) + (totalViewCount * 0.1) + (totalOrderCount * 0.6); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java new file mode 100644 index 000000000..991d103ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java @@ -0,0 +1,36 @@ +package com.loopers.domain.metrics; + +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsMonthlyRepository { + /** + * 특정 년도/월의 랭킹 조회 (좋아요 기준 정렬) + */ + List findByYearAndMonthOrderByLikeCountDesc(int year, int month, int limit); + + /** + * 특정 년도/월의 랭킹 조회 (조회수 기준 정렬) + */ + List findByYearAndMonthOrderByViewCountDesc(int year, int month, int limit); + + /** + * 특정 년도/월의 랭킹 조회 (주문수 기준 정렬) + */ + List findByYearAndMonthOrderByOrderCountDesc(int year, int month, int limit); + + /** + * 특정 년도/월의 랭킹 조회 (Score 기준 정렬) + */ + List findByYearAndMonthOrderByCompositeScoreDesc(int year, int month, int limit); + + /** + * 특정 상품의 월간 랭킹 조회 + */ + Optional findByYearAndMonthAndProductId(int year, int month, Long productId); + + /** + * 특정 년도/월의 전체 상품 수 + */ + long countByYearAndMonth(int year, int month); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java new file mode 100644 index 000000000..61cf9c087 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java @@ -0,0 +1,66 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 주간 상품 집계 Materialized View (읽기 전용) + * commerce-collector에서 생성한 집계 데이터 조회용 + */ +@Entity +@Table(name = "mv_product_metrics_weekly") +@Getter +@NoArgsConstructor +@Immutable // 읽기 전용 +public class ProductMetricsWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "week", nullable = false) + private Integer week; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount; + + @Column(name = "total_order_count", nullable = false) + private Long totalOrderCount; + + @Column(name = "aggregated_at") + private ZonedDateTime aggregatedAt; + + @Column(name = "created_at") + private ZonedDateTime createdAt; + + @Column(name = "updated_at") + private ZonedDateTime updatedAt; + + /** + * 종합 점수 계산 (가중치 적용) + * Score = (like * 0.2) + (view * 0.1) + (order * 0.6) + */ + public double calculateCompositeScore() { + return (totalLikeCount * 0.2) + (totalViewCount * 0.1) + (totalOrderCount * 0.6); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java new file mode 100644 index 000000000..4380bb5a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java @@ -0,0 +1,36 @@ +package com.loopers.domain.metrics; + +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsWeeklyRepository { + /** + * 특정 년도/주차의 랭킹 조회 (좋아요 기준 정렬) + */ + List findByYearAndWeekOrderByLikeCountDesc(int year, int week, int limit); + + /** + * 특정 년도/주차의 랭킹 조회 (조회수 기준 정렬) + */ + List findByYearAndWeekOrderByViewCountDesc(int year, int week, int limit); + + /** + * 특정 년도/주차의 랭킹 조회 (주문수 기준 정렬) + */ + List findByYearAndWeekOrderByOrderCountDesc(int year, int week, int limit); + + /** + * 특정 년도/주차의 랭킹 조회 (score 기준 정렬) + */ + List findByYearAndWeekOrderByCompositeScoreDesc(int year, int week, int limit); + + /** + * 특정 상품의 주간 랭킹 조회 + */ + Optional findByYearAndWeekAndProductId(int year, int week, Long productId); + + /** + * 특정 년도/주차의 전체 상품 수 + */ + long countByYearAndWeek(int year, int week); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java new file mode 100644 index 000000000..4cfff452e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java @@ -0,0 +1,140 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.temporal.IsoFields; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class PeriodRankingService { + + private final ProductMetricsWeeklyRepository weeklyRepository; + private final ProductMetricsMonthlyRepository monthlyRepository; + + /** + * 주간 TOP N 랭킹 조회 + */ + public List getTopWeeklyRanking(RankingType type, LocalDate date, int limit) { + LocalDate targetDate = date != null ? date : LocalDate.now(); + int year = targetDate.getYear(); + int week = targetDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + + List weeklyMetrics = switch (type) { + case LIKE -> weeklyRepository.findByYearAndWeekOrderByLikeCountDesc(year, week, limit); + case VIEW -> weeklyRepository.findByYearAndWeekOrderByViewCountDesc(year, week, limit); + case ORDER -> weeklyRepository.findByYearAndWeekOrderByOrderCountDesc(year, week, limit); + case ALL -> weeklyRepository.findByYearAndWeekOrderByCompositeScoreDesc(year, week, limit); + }; + + return convertWeeklyToRanking(weeklyMetrics, type); + } + + /** + * 월간 TOP N 랭킹 조회 + */ + public List getTopMonthlyRanking(RankingType type, LocalDate date, int limit) { + LocalDate targetDate = date != null ? date : LocalDate.now(); + int year = targetDate.getYear(); + int month = targetDate.getMonthValue(); + + List monthlyMetrics = switch (type) { + case LIKE -> monthlyRepository.findByYearAndMonthOrderByLikeCountDesc(year, month, limit); + case VIEW -> monthlyRepository.findByYearAndMonthOrderByViewCountDesc(year, month, limit); + case ORDER -> monthlyRepository.findByYearAndMonthOrderByOrderCountDesc(year, month, limit); + case ALL -> monthlyRepository.findByYearAndMonthOrderByCompositeScoreDesc(year, month, limit); + }; + + return convertMonthlyToRanking(monthlyMetrics, type); + } + + /** + * 주간 페이징 랭킹 조회 + */ + public List getWeeklyRankingWithPaging(RankingType type, LocalDate date, int page, int size) { + return getTopWeeklyRanking(type, date, (page + 1) * size) + .stream() + .skip((long) page * size) + .limit(size) + .toList(); + } + + /** + * 월간 페이징 랭킹 조회 + */ + public List getMonthlyRankingWithPaging(RankingType type, LocalDate date, int page, int size) { + return getTopMonthlyRanking(type, date, (page + 1) * size) + .stream() + .skip((long) page * size) + .limit(size) + .toList(); + } + + + private List convertWeeklyToRanking( + List metrics, + RankingType type + ) { + int rank = 1; + List rankings = new ArrayList<>(); + + for (ProductMetricsWeekly metric : metrics) { + double score = calculateScore(type, + metric.getTotalLikeCount(), + metric.getTotalViewCount(), + metric.getTotalOrderCount()); + + rankings.add(Ranking.of( + rank++, + metric.getProductId(), + score, + metric.getTotalLikeCount(), + metric.getTotalViewCount(), + metric.getTotalOrderCount() + )); + } + + return rankings; + } + + private List convertMonthlyToRanking(List metrics, RankingType type) { + int rank = 1; + List rankings = new ArrayList<>(); + + for (ProductMetricsMonthly metric : metrics) { + double score = calculateScore( + type, + metric.getTotalLikeCount(), + metric.getTotalViewCount(), + metric.getTotalOrderCount() + ); + + rankings.add(Ranking.of( + rank++, + metric.getProductId(), + score, + metric.getTotalLikeCount(), + metric.getTotalViewCount(), + metric.getTotalOrderCount() + )); + } + + return rankings; + } + + private double calculateScore(RankingType type, long likeCount, long viewCount, long orderCount) { + return switch (type) { + case LIKE -> likeCount; + case VIEW -> viewCount; + case ORDER -> orderCount; + case ALL -> (likeCount * 0.2) + (viewCount * 0.1) + (orderCount * 0.6); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java new file mode 100644 index 000000000..dcadfd9d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PeriodType { + DAILY("일간", "오늘 또는 특정 날짜"), + WEEKLY("주간", "이번 주 또는 특정 주차"), + MONTHLY("월간", "이번 달 또는 특정 월"); + + private final String name; + private final String description; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java index 12d47487b..f9968b648 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java @@ -8,11 +8,42 @@ public class Ranking { private final int rank; private final Long productId; private final Double score; + private final Long totalLikeCount; + private final Long totalViewCount; + private final Long totalOrderCount; @Builder - public Ranking(int rank, Long productId, Double score) { + public Ranking( + int rank, + Long productId, + Double score, + Long totalLikeCount, + Long totalViewCount, + Long totalOrderCount + ) { this.rank = rank; this.productId = productId; this.score = score; + this.totalLikeCount = totalLikeCount; + this.totalViewCount = totalViewCount; + this.totalOrderCount = totalOrderCount; + } + + public static Ranking of( + int i, + Long productId, + double score, + Long totalLikeCount, + Long totalViewCount, + Long totalOrderCount + ) { + return new Ranking( + i, + productId, + score, + totalLikeCount, + totalViewCount, + totalOrderCount + ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java new file mode 100644 index 000000000..04bf2383b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsMonthlyJpaRepository extends JpaRepository { + + /** + * 특정 년도/월의 랭킹 조회 (좋아요 기준 정렬) + */ + List findByYearAndMonthOrderByTotalLikeCountDesc(int year, int month, Pageable pageable); + + /** + * 특정 년도/월의 랭킹 조회 (조회수 기준 정렬) + */ + List findByYearAndMonthOrderByTotalViewCountDesc(int year, int month, Pageable pageable); + + /** + * 특정 년도/월의 랭킹 조회 (주문수 기준 정렬) + */ + List findByYearAndMonthOrderByTotalOrderCountDesc(int year, int month, Pageable pageable); + + /** + * 특정 년도/월의 랭킹 조회 (종합 점수 기준 정렬) + * 종합 점수 = (like * 0.2) + (view * 0.1) + (order * 0.6) + */ + @Query(""" + SELECT m FROM ProductMetricsMonthly m + WHERE m.year = :year AND m.month = :month + ORDER BY (m.totalLikeCount * 0.2 + m.totalViewCount * 0.1 + m.totalOrderCount * 0.6) DESC + """) + List findByYearAndMonthOrderByCompositeScoreDesc( + @Param("year") int year, + @Param("month") int month, + Pageable pageable + ); + + /** + * 특정 상품의 월간 랭킹 조회 + */ + Optional findByYearAndMonthAndProductId(int year, int month, Long productId); + + /** + * 특정 년도/월의 전체 상품 수 + */ + long countByYearAndMonth(int year, int month); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java new file mode 100644 index 000000000..897f7b079 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * 월간 상품 집계 Repository 구현 (읽기 전용) + * commerce-collector에서 생성한 집계 데이터 조회 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsMonthlyRepositoryImpl implements ProductMetricsMonthlyRepository { + + private final ProductMetricsMonthlyJpaRepository monthlyJpaRepository; + + @Override + public List findByYearAndMonthOrderByLikeCountDesc(int year, int month, int limit) { + return monthlyJpaRepository.findByYearAndMonthOrderByTotalLikeCountDesc( + year, + month, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndMonthOrderByViewCountDesc(int year, int month, int limit) { + return monthlyJpaRepository.findByYearAndMonthOrderByTotalViewCountDesc( + year, + month, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndMonthOrderByOrderCountDesc(int year, int month, int limit) { + return monthlyJpaRepository.findByYearAndMonthOrderByTotalOrderCountDesc( + year, + month, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndMonthOrderByCompositeScoreDesc(int year, int month, int limit) { + return monthlyJpaRepository.findByYearAndMonthOrderByCompositeScoreDesc( + year, + month, + PageRequest.of(0, limit) + ); + } + + @Override + public Optional findByYearAndMonthAndProductId(int year, int month, Long productId) { + return monthlyJpaRepository.findByYearAndMonthAndProductId(year, month, productId); + } + + @Override + public long countByYearAndMonth(int year, int month) { + return monthlyJpaRepository.countByYearAndMonth(year, month); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java new file mode 100644 index 000000000..6fd60b011 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsWeeklyJpaRepository extends JpaRepository { + + /** + * 특정 년도/주차의 랭킹 조회 (좋아요 기준 정렬) + */ + List findByYearAndWeekOrderByTotalLikeCountDesc(int year, int week, Pageable pageable); + + /** + * 특정 년도/주차의 랭킹 조회 (조회수 기준 정렬) + */ + List findByYearAndWeekOrderByTotalViewCountDesc(int year, int week, Pageable pageable); + + /** + * 특정 년도/주차의 랭킹 조회 (주문수 기준 정렬) + */ + List findByYearAndWeekOrderByTotalOrderCountDesc(int year, int week, Pageable pageable); + + /** + * 특정 년도/주차의 랭킹 조회 (종합 점수 기준 정렬) + * 종합 점수 = (like * 0.2) + (view * 0.1) + (order * 0.6) + */ + @Query(""" + SELECT w FROM ProductMetricsWeekly w + WHERE w.year = :year AND w.week = :week + ORDER BY (w.totalLikeCount * 0.2 + w.totalViewCount * 0.1 + w.totalOrderCount * 0.6) DESC + """) + List findByYearAndWeekOrderByCompositeScoreDesc( + @Param("year") int year, + @Param("week") int week, + Pageable pageable + ); + + /** + * 특정 상품의 주간 랭킹 조회 + */ + Optional findByYearAndWeekAndProductId(int year, int week, Long productId); + + /** + * 특정 년도/주차의 전체 상품 수 + */ + long countByYearAndWeek(int year, int week); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java new file mode 100644 index 000000000..c24c0febd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * 주간 상품 집계 Repository 구현 (읽기 전용) + * commerce-collector에서 생성한 집계 데이터 조회 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsWeeklyRepositoryImpl implements ProductMetricsWeeklyRepository { + + private final ProductMetricsWeeklyJpaRepository weeklyJpaRepository; + + @Override + public List findByYearAndWeekOrderByLikeCountDesc(int year, int week, int limit) { + return weeklyJpaRepository.findByYearAndWeekOrderByTotalLikeCountDesc( + year, + week, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndWeekOrderByViewCountDesc(int year, int week, int limit) { + return weeklyJpaRepository.findByYearAndWeekOrderByTotalViewCountDesc( + year, + week, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndWeekOrderByOrderCountDesc(int year, int week, int limit) { + return weeklyJpaRepository.findByYearAndWeekOrderByTotalOrderCountDesc( + year, + week, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndWeekOrderByCompositeScoreDesc(int year, int week, int limit) { + return weeklyJpaRepository.findByYearAndWeekOrderByCompositeScoreDesc( + year, + week, + PageRequest.of(0, limit) + ); + } + + @Override + public Optional findByYearAndWeekAndProductId(int year, int week, Long productId) { + return weeklyJpaRepository.findByYearAndWeekAndProductId(year, week, productId); + } + + @Override + public long countByYearAndWeek(int year, int week) { + return weeklyJpaRepository.countByYearAndWeek(year, week); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index f392dc9d7..dfd00b85a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -28,6 +28,7 @@ public ApiResponse getRankingWithPaging( List rankings = rankingFacade.getRankingWithPaging( request.type(), + request.periodType(), date, request.page(), request.size() @@ -54,6 +55,7 @@ public ApiResponse getTopRanking( List rankings = rankingFacade.getTopRanking( request.type(), + request.periodType(), date, request.limit() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java index 848a979c5..33c845008 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -2,6 +2,7 @@ import com.loopers.application.product.ProductInfo; import com.loopers.application.ranking.RankingInfo; +import com.loopers.domain.ranking.PeriodType; import com.loopers.domain.ranking.RankingType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; @@ -16,6 +17,9 @@ public record GetTopRankingRequest( @Schema(description = "랭킹 타입", example = "ALL", allowableValues = {"LIKE", "VIEW", "ORDER", "ALL"}) RankingType type, + @Schema(description = "랭킹 조회 타입", example = "DAILY", allowableValues = {"DAILY", "WEEKLY", "MONTHLY"}) + PeriodType periodType, + @Schema(description = "조회 날짜 (yyyyMMdd)", example = "20251225") String date, @@ -35,6 +39,9 @@ public record GetRankingWithPagingRequest( @Schema(description = "랭킹 타입", example = "ALL") RankingType type, + @Schema(description = "랭킹 조회 타입", example = "DAILY", allowableValues = {"DAILY", "WEEKLY", "MONTHLY"}) + PeriodType periodType, + @Schema(description = "조회 날짜 (yyyyMMdd)", example = "20251225") String date, diff --git a/apps/commerce-collector/build.gradle.kts b/apps/commerce-collector/build.gradle.kts index 95e1d4967..a93b93a5e 100644 --- a/apps/commerce-collector/build.gradle.kts +++ b/apps/commerce-collector/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-batch") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") // resilience4j @@ -30,4 +31,7 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + + // spring-batch-test + testImplementation("org.springframework.batch:spring-batch-test") } diff --git a/apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java b/apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java index adb801c6e..4418426db 100644 --- a/apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java +++ b/apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java @@ -132,13 +132,13 @@ private Map calculateCompositeScores(Map likeDeltas /** * 오래된 일자별 데이터 정리 (매일 새벽 4시) - * - 10일 이전 데이터 삭제 + * - 30일 이전 데이터 삭제 */ @Scheduled(cron = "0 0 4 * * *") @Transactional public void cleanupOldDailyMetrics() { try { - LocalDate cutoffDate = LocalDate.now().minusDays(10); + LocalDate cutoffDate = LocalDate.now().minusDays(30); int deleted = productMetricsDailyRepository.deleteByMetricDateBefore(cutoffDate); log.info("오래된 일자별 데이터 정리 완료 - {} 건 삭제", deleted); } catch (Exception e) { diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..6401bb777 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info("청크 종료: readCount: {}, writeCount: {}", + chunkContext.getStepContext().getStepExecution().getReadCount(), + chunkContext.getStepContext().getStepExecution().getWriteCount() + ); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..4276fba54 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName()); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..2231a646b --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,45 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.error( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsProcessor.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsProcessor.java new file mode 100644 index 000000000..06a108f8d --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsProcessor.java @@ -0,0 +1,31 @@ +package com.loopers.batch.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.dto.MonthlyAggregationDto; +import org.springframework.batch.item.ItemProcessor; + +/** + * 월간 집계 DTO를 ProductMetricsMonthly 엔티티로 변환하는 Processor + */ +public class MonthlyMetricsProcessor implements ItemProcessor { + @Override + public ProductMetricsMonthly process(MonthlyAggregationDto dto) { + // DTO를 도메인 엔티티로 변환 + ProductMetricsMonthly metrics = ProductMetricsMonthly.create( + dto.getProductId(), + dto.getYear(), + dto.getMonth(), + dto.getPeriodStartDate(), + dto.getPeriodEndDate() + ); + + // 집계 메트릭 업데이트 + metrics.updateMetrics( + dto.getTotalLikeCount(), + dto.getTotalViewCount(), + dto.getTotalOrderCount() + ); + + return metrics; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java new file mode 100644 index 000000000..8670f0520 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java @@ -0,0 +1,30 @@ +package com.loopers.batch.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.util.List; + +/** + * 월간 집계 결과를 MV 테이블에 저장하는 Writer + */ +@Slf4j +@RequiredArgsConstructor +public class MonthlyMetricsWriter implements ItemWriter { + + private final ProductMetricsMonthlyRepository monthlyRepository; + + @Override + public void write(Chunk chunk) { + List items = chunk.getItems(); + + // Bulk Insert/Update (UPSERT) + monthlyRepository.saveAll((List) items); + + log.info("월간 집계 저장 완료: {} 건", items.size()); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java new file mode 100644 index 000000000..d8a80092e --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java @@ -0,0 +1,31 @@ +package com.loopers.batch.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.dto.WeeklyAggregationDto; +import org.springframework.batch.item.ItemProcessor; + +/** + * 주간 집계 DTO를 ProductMetricsWeekly 엔티티로 변환하는 Processor + */ +public class WeeklyMetricsProcessor implements ItemProcessor { + @Override + public ProductMetricsWeekly process(WeeklyAggregationDto dto) { + // DTO를 도메인 엔티티로 변환 + ProductMetricsWeekly metrics = ProductMetricsWeekly.create( + dto.getProductId(), + dto.getYear(), + dto.getWeek(), + dto.getPeriodStartDate(), + dto.getPeriodEndDate() + ); + + // 집계 메트릭 업데이트 + metrics.updateMetrics( + dto.getTotalLikeCount(), + dto.getTotalViewCount(), + dto.getTotalOrderCount() + ); + + return metrics; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java new file mode 100644 index 000000000..9789b03f4 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java @@ -0,0 +1,25 @@ +package com.loopers.batch.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +public class WeeklyMetricsWriter implements ItemWriter { + private final ProductMetricsWeeklyRepository weeklyRepository; + + @Override + public void write(Chunk chunk) { + List items = (List) chunk.getItems(); + + weeklyRepository.saveAll(items); + + log.info("주간 집계 저장 완료: {} 건", items.size()); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java new file mode 100644 index 000000000..1b3efecd8 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java @@ -0,0 +1,100 @@ +package com.loopers.batch.metrics.job; + +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.batch.metrics.MonthlyMetricsProcessor; +import com.loopers.batch.metrics.MonthlyMetricsWriter; +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import com.loopers.domain.metrics.dto.MonthlyAggregationDto; +import com.loopers.infrastructure.metrics.ProductMetricsDailyJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class ProductMetricsMonthlyJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductMetricsDailyJpaRepository dailyJpaRepository; + private final ProductMetricsMonthlyRepository monthlyRepository; + + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + + @Bean + public Job productMetricsMonthlyJob() { + return new JobBuilder("productMetricsMonthlyJob", jobRepository) + .start(aggregateMonthlyMetricsStep()) + .listener(jobListener) // Job Listener + .build(); + } + + @Bean + public Step aggregateMonthlyMetricsStep() { + return new StepBuilder("aggregateMonthlyMetricsStep", jobRepository) + .chunk(100, transactionManager) + .reader(monthlyMetricsReader(null, null)) + .processor(monthlyMetricsProcessor()) + .writer(monthlyMetricsWriter()) + .listener(stepMonitorListener) // Step Listener + .listener(chunkListener) // Chunk Listener + .build(); + } + + /** + * RepositoryItemReader를 사용하여 DB에서 페이징 집계 수행 + */ + @Bean + @StepScope + public RepositoryItemReader monthlyMetricsReader( + @Value("#{jobParameters['year']}") Integer year, + @Value("#{jobParameters['month']}") Integer month + ) { + // 월간 시작일/종료일 계산 + LocalDate startDate = LocalDate.of(year, month, 1); + LocalDate endDate = startDate.plusMonths(1).minusDays(1); // 말일 + + return new RepositoryItemReaderBuilder() + .name("monthlyMetricsReader") + .repository(dailyJpaRepository) + .methodName("findMonthlyAggregation") + .arguments(List.of(year, month, startDate, endDate)) + .pageSize(100) // Chunk Size와 일치 + .sorts(Map.of("productId", Sort.Direction.ASC)) + .build(); + } + + @Bean + @StepScope + public ItemProcessor monthlyMetricsProcessor() { + return new MonthlyMetricsProcessor(); + } + + @Bean + @StepScope + public ItemWriter monthlyMetricsWriter() { + return new MonthlyMetricsWriter(monthlyRepository); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java new file mode 100644 index 000000000..bb747453d --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java @@ -0,0 +1,104 @@ +package com.loopers.batch.metrics.job; + +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.batch.metrics.WeeklyMetricsProcessor; +import com.loopers.batch.metrics.WeeklyMetricsWriter; +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import com.loopers.domain.metrics.dto.WeeklyAggregationDto; +import com.loopers.infrastructure.metrics.ProductMetricsDailyJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.IsoFields; +import java.util.List; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class ProductMetricsWeeklyJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductMetricsDailyJpaRepository dailyJpaRepository; + private final ProductMetricsWeeklyRepository weeklyRepository; + + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + + @Bean + public Job productMetricsWeeklyJob() { + return new JobBuilder("productMetricsWeeklyJob", jobRepository) + .start(aggregateWeeklyMetricsStep()) + .listener(jobListener) // Job Listener + .build(); + } + + @Bean + public Step aggregateWeeklyMetricsStep() { + return new StepBuilder("aggregateWeeklyMetricsStep", jobRepository) + .chunk(100, transactionManager) + .reader(weeklyMetricsReader(null, null)) + .processor(weeklyMetricsProcessor()) + .writer(weeklyMetricsWriter()) + .listener(stepMonitorListener) // Step Listener + .listener(chunkListener) // Chunk Listener + .build(); + } + + /** + * RepositoryItemReader를 사용하여 DB에서 페이징 집계 수행 + */ + @Bean + @StepScope + public RepositoryItemReader weeklyMetricsReader( + @Value("#{jobParameters['year']}") Integer year, + @Value("#{jobParameters['week']}") Integer week + ) { + // 주간 시작일/종료일 계산 + LocalDate startDate = LocalDate.of(year, 1, 1) + .with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, week) + .with(DayOfWeek.MONDAY); + LocalDate endDate = startDate.plusDays(6); + + return new RepositoryItemReaderBuilder() + .name("weeklyMetricsReader") + .repository(dailyJpaRepository) + .methodName("findWeeklyAggregation") + .arguments(List.of(year, week, startDate, endDate)) + .pageSize(100) // Chunk Size와 일치 + .sorts(Map.of("productId", Sort.Direction.ASC)) + .build(); + } + + @Bean + @StepScope + public ItemProcessor weeklyMetricsProcessor() { + return new WeeklyMetricsProcessor(); + } + + @Bean + @StepScope + public ItemWriter weeklyMetricsWriter() { + return new WeeklyMetricsWriter(weeklyRepository); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java index 3202511f9..f16e095e4 100644 --- a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java @@ -1,6 +1,10 @@ package com.loopers.domain.metrics; import com.loopers.application.order.OrderMetrics; +import com.loopers.domain.metrics.dto.MonthlyAggregationDto; +import com.loopers.domain.metrics.dto.WeeklyAggregationDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.time.LocalDate; import java.util.List; @@ -20,4 +24,44 @@ public interface ProductMetricsDailyRepository { // 오래된 데이터 삭제 int deleteByMetricDateBefore(LocalDate cutoffDate); + + List findAllByMetricDateBetween(LocalDate startDate, LocalDate endDate); + + /** + * 주간 집계 데이터 조회 (페이징) + * Spring Batch Job에서 사용 + * + * @param year 집계 연도 + * @param week 집계 주차 + * @param startDate 집계 시작일 (월요일) + * @param endDate 집계 종료일 (일요일) + * @param pageable 페이징 정보 + * @return 상품별 주간 집계 결과 + */ + Page findWeeklyAggregation( + Integer year, + Integer week, + LocalDate startDate, + LocalDate endDate, + Pageable pageable + ); + + /** + * 월간 집계 데이터 조회 (페이징) + * Spring Batch Job에서 사용 + * + * @param year 집계 연도 + * @param month 집계 월 + * @param startDate 집계 시작일 (1일) + * @param endDate 집계 종료일 (말일) + * @param pageable 페이징 정보 + * @return 상품별 월간 집계 결과 + */ + Page findMonthlyAggregation( + Integer year, + Integer month, + LocalDate startDate, + LocalDate endDate, + Pageable pageable + ); } diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java new file mode 100644 index 000000000..fadc512e5 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java @@ -0,0 +1,111 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 월간 상품 집계 Materialized View + * Spring Batch를 통해 ProductMetricsDaily 데이터를 월 단위로 집계 + */ +@Entity +@Table( + name = "mv_product_metrics_monthly", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_product_year_month", + columnNames = {"product_id", "year", "month"} + ) + }, + indexes = { + @Index(name = "idx_year_month", columnList = "year, month"), + @Index(name = "idx_product_id", columnList = "product_id") + } +) +@Getter +@NoArgsConstructor +public class ProductMetricsMonthly extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "month", nullable = false) + private Integer month; + + /** + * 집계 기간 시작일 (해당 월의 1일) + */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 집계 기간 종료일 (해당 월의 말일) + */ + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount = 0L; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount = 0L; + + @Column(name = "total_order_count", nullable = false) + private Long totalOrderCount = 0L; + + /** + * 마지막 집계 시각 + */ + @Column(name = "aggregated_at") + private ZonedDateTime aggregatedAt; + + /** + * 월간 집계 생성 + */ + public static ProductMetricsMonthly create( + Long productId, + Integer year, + Integer month, + LocalDate periodStartDate, + LocalDate periodEndDate + ) { + ProductMetricsMonthly metrics = new ProductMetricsMonthly(); + metrics.productId = productId; + metrics.year = year; + metrics.month = month; + metrics.periodStartDate = periodStartDate; + metrics.periodEndDate = periodEndDate; + return metrics; + } + + /** + * 집계 메트릭 업데이트 + */ + public void updateMetrics( + Long likeCount, + Long viewCount, + Long orderCount + ) { + this.totalLikeCount = likeCount; + this.totalViewCount = viewCount; + this.totalOrderCount = orderCount; + this.aggregatedAt = ZonedDateTime.now(); + } + + /** + * 집계 데이터 초기화 (재집계 시) + */ + public void reset() { + this.totalLikeCount = 0L; + this.totalViewCount = 0L; + this.totalOrderCount = 0L; + this.aggregatedAt = null; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java new file mode 100644 index 000000000..bc86bcd1f --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.metrics; + +import java.util.List; + +public interface ProductMetricsMonthlyRepository { + ProductMetricsMonthly save(ProductMetricsMonthly metrics); + void saveAll(List metricsList); + int deleteByYearAndMonthBefore(Integer year, Integer month); + List findAll(); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java new file mode 100644 index 000000000..0daaea175 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java @@ -0,0 +1,111 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 주간 상품 집계 Materialized View + * Spring Batch를 통해 ProductMetricsDaily 데이터를 주 단위로 집계 + */ +@Entity +@Table( + name = "mv_product_metrics_weekly", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_product_year_week", + columnNames = {"product_id", "year", "week"} + ) + }, + indexes = { + @Index(name = "idx_year_week", columnList = "year, week"), + @Index(name = "idx_product_id", columnList = "product_id") + } +) +@Getter +@NoArgsConstructor +public class ProductMetricsWeekly extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "week", nullable = false) + private Integer week; + + /** + * 집계 기간 시작일 (해당 주의 월요일) + */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 집계 기간 종료일 (해당 주의 일요일) + */ + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount = 0L; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount = 0L; + + @Column(name = "total_order_count", nullable = false) + private Long totalOrderCount = 0L; + + /** + * 마지막 집계 시각 + */ + @Column(name = "aggregated_at") + private ZonedDateTime aggregatedAt; + + /** + * 주간 집계 생성 + */ + public static ProductMetricsWeekly create( + Long productId, + Integer year, + Integer week, + LocalDate periodStartDate, + LocalDate periodEndDate + ) { + ProductMetricsWeekly metrics = new ProductMetricsWeekly(); + metrics.productId = productId; + metrics.year = year; + metrics.week = week; + metrics.periodStartDate = periodStartDate; + metrics.periodEndDate = periodEndDate; + return metrics; + } + + /** + * 집계 메트릭 업데이트 + */ + public void updateMetrics( + Long likeCount, + Long viewCount, + Long orderCount + ) { + this.totalLikeCount = likeCount; + this.totalViewCount = viewCount; + this.totalOrderCount = orderCount; + this.aggregatedAt = ZonedDateTime.now(); + } + + /** + * 집계 데이터 초기화 (재집계 시) + */ + public void reset() { + this.totalLikeCount = 0L; + this.totalViewCount = 0L; + this.totalOrderCount = 0L; + this.aggregatedAt = null; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java new file mode 100644 index 000000000..2d94d0171 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.metrics; + +import java.util.List; + +public interface ProductMetricsWeeklyRepository { + + ProductMetricsWeekly save(ProductMetricsWeekly metrics); + void saveAll(List metricsList); + int deleteByYearAndWeekBefore(Integer year, Integer week); + List findAll(); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java new file mode 100644 index 000000000..cef0f8066 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java @@ -0,0 +1,26 @@ +package com.loopers.domain.metrics.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 월간 집계 데이터 DTO + * Spring Batch ItemReader에서 Repository 조회 결과로 사용됨 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MonthlyAggregationDto { + private Long productId; + private Integer year; + private Integer month; + private LocalDate periodStartDate; + private LocalDate periodEndDate; + private Long totalLikeCount; + private Long totalViewCount; + private Long totalOrderCount; + private Long totalOrderQuantity; +} \ No newline at end of file diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java new file mode 100644 index 000000000..7a0bba457 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java @@ -0,0 +1,26 @@ +package com.loopers.domain.metrics.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 주간 집계 데이터 DTO + * Spring Batch ItemReader에서 Repository 조회 결과로 사용됨 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class WeeklyAggregationDto { + private Long productId; + private Integer year; + private Integer week; + private LocalDate periodStartDate; + private LocalDate periodEndDate; + private Long totalLikeCount; + private Long totalViewCount; + private Long totalOrderCount; + private Long totalOrderQuantity; +} \ No newline at end of file diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java index ae9f86240..3f2a89acc 100644 --- a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java @@ -1,6 +1,10 @@ package com.loopers.infrastructure.metrics; import com.loopers.domain.metrics.ProductMetricsDaily; +import com.loopers.domain.metrics.dto.MonthlyAggregationDto; +import com.loopers.domain.metrics.dto.WeeklyAggregationDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -18,4 +22,62 @@ public interface ProductMetricsDailyJpaRepository extends JpaRepository findAllByMetricDateBetween(LocalDate startDate, LocalDate endDate); + + /** + * 주간 집계 쿼리 (DB에서 GROUP BY 수행) + */ + @Query(""" + SELECT new com.loopers.domain.metrics.dto.WeeklyAggregationDto( + p.productId, + :year, + :week, + :startDate, + :endDate, + SUM(p.likeDelta), + SUM(p.viewDelta), + SUM(p.orderDelta), + 0L + ) + FROM ProductMetricsDaily p + WHERE p.metricDate BETWEEN :startDate AND :endDate + GROUP BY p.productId + ORDER BY p.productId + """) + Page findWeeklyAggregation( + @Param("year") Integer year, + @Param("week") Integer week, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + Pageable pageable + ); + + /** + * 월간 집계 쿼리 (DB에서 GROUP BY 수행) + */ + @Query(""" + SELECT new com.loopers.domain.metrics.dto.MonthlyAggregationDto( + p.productId, + :year, + :month, + :startDate, + :endDate, + SUM(p.likeDelta), + SUM(p.viewDelta), + SUM(p.orderDelta), + 0L + ) + FROM ProductMetricsDaily p + WHERE p.metricDate BETWEEN :startDate AND :endDate + GROUP BY p.productId + ORDER BY p.productId + """) + Page findMonthlyAggregation( + @Param("year") Integer year, + @Param("month") Integer month, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + Pageable pageable + ); } diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java index 3c775779b..bab2c7657 100644 --- a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java @@ -154,4 +154,36 @@ public int getBatchSize() { public int deleteByMetricDateBefore(LocalDate cutoffDate) { return productMetricsDailyJpaRepository.deleteByMetricDateBefore(cutoffDate); } + + @Override + public List findAllByMetricDateBetween(LocalDate startDate, LocalDate endDate) { + return productMetricsDailyJpaRepository + .findAllByMetricDateBetween(startDate, endDate); + } + + @Override + public org.springframework.data.domain.Page findWeeklyAggregation( + Integer year, + Integer week, + LocalDate startDate, + LocalDate endDate, + org.springframework.data.domain.Pageable pageable + ) { + return productMetricsDailyJpaRepository.findWeeklyAggregation( + year, week, startDate, endDate, pageable + ); + } + + @Override + public org.springframework.data.domain.Page findMonthlyAggregation( + Integer year, + Integer month, + LocalDate startDate, + LocalDate endDate, + org.springframework.data.domain.Pageable pageable + ) { + return productMetricsDailyJpaRepository.findMonthlyAggregation( + year, month, startDate, endDate, pageable + ); + } } diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java new file mode 100644 index 000000000..dca3ce7ab --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +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; + +public interface ProductMetricsMonthlyJpaRepository extends JpaRepository { + + @Modifying + @Query(""" + DELETE FROM ProductMetricsMonthly m + WHERE (m.year < :year) + OR (m.year = :year AND m.month < :month) + """) + int deleteByYearAndMonthBefore( + @Param("year") Integer year, + @Param("month") Integer month + ); + +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java new file mode 100644 index 000000000..4fe79c655 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsMonthlyRepositoryImpl implements ProductMetricsMonthlyRepository { + + private final ProductMetricsMonthlyJpaRepository monthlyJpaRepository; + private final JdbcTemplate jdbcTemplate; + + @Override + public ProductMetricsMonthly save(ProductMetricsMonthly metrics) { + return monthlyJpaRepository.save(metrics); + } + + @Override + public void saveAll(List metricsList) { + if (metricsList.isEmpty()) { + return; + } + + // UPSERT를 위한 Bulk Insert with ON DUPLICATE KEY UPDATE + String sql = """ + INSERT INTO mv_product_metrics_monthly + (product_id, year, month, period_start_date, period_end_date, + total_like_count, total_view_count, total_order_count, + aggregated_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + total_like_count = VALUES(total_like_count), + total_view_count = VALUES(total_view_count), + total_order_count = VALUES(total_order_count), + aggregated_at = VALUES(aggregated_at), + updated_at = NOW() + """; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ProductMetricsMonthly metrics = metricsList.get(i); + ps.setLong(1, metrics.getProductId()); + ps.setInt(2, metrics.getYear()); + ps.setInt(3, metrics.getMonth()); + ps.setDate(4, Date.valueOf(metrics.getPeriodStartDate())); + ps.setDate(5, Date.valueOf(metrics.getPeriodEndDate())); + ps.setLong(6, metrics.getTotalLikeCount()); + ps.setLong(7, metrics.getTotalViewCount()); + ps.setLong(8, metrics.getTotalOrderCount()); + ps.setTimestamp(9, metrics.getAggregatedAt() != null + ? Timestamp.from(metrics.getAggregatedAt().toInstant()) + : new Timestamp(System.currentTimeMillis())); + } + + @Override + public int getBatchSize() { + return metricsList.size(); + } + }); + + log.info("월간 집계 Bulk UPSERT 완료: {} 건", metricsList.size()); + } + + @Override + public int deleteByYearAndMonthBefore(Integer year, Integer month) { + return monthlyJpaRepository.deleteByYearAndMonthBefore(year, month); + } + + @Override + public List findAll() { + return monthlyJpaRepository.findAll(); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java new file mode 100644 index 000000000..24d6a0e82 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +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; + +public interface ProductMetricsWeeklyJpaRepository extends JpaRepository { + + @Modifying + @Query(""" + DELETE FROM ProductMetricsWeekly m + WHERE (m.year < :year) + OR (m.year = :year AND m.week < :week) + """) + int deleteByYearAndWeekBefore( + @Param("year") Integer year, + @Param("week") Integer week + ); + +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java new file mode 100644 index 000000000..20d813bff --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsWeeklyRepositoryImpl implements ProductMetricsWeeklyRepository { + + private final ProductMetricsWeeklyJpaRepository weeklyJpaRepository; + private final JdbcTemplate jdbcTemplate; + + @Override + public ProductMetricsWeekly save(ProductMetricsWeekly metrics) { + return weeklyJpaRepository.save(metrics); + } + + @Override + public void saveAll(List metricsList) { + if (metricsList.isEmpty()) { + return; + } + + // UPSERT를 위한 Bulk Insert with ON DUPLICATE KEY UPDATE + String sql = """ + INSERT INTO mv_product_metrics_weekly + (product_id, year, week, period_start_date, period_end_date, + total_like_count, total_view_count, total_order_count, + aggregated_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + total_like_count = VALUES(total_like_count), + total_view_count = VALUES(total_view_count), + total_order_count = VALUES(total_order_count), + aggregated_at = VALUES(aggregated_at), + updated_at = NOW() + """; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ProductMetricsWeekly metrics = metricsList.get(i); + ps.setLong(1, metrics.getProductId()); + ps.setInt(2, metrics.getYear()); + ps.setInt(3, metrics.getWeek()); + ps.setDate(4, Date.valueOf(metrics.getPeriodStartDate())); + ps.setDate(5, Date.valueOf(metrics.getPeriodEndDate())); + ps.setLong(6, metrics.getTotalLikeCount()); + ps.setLong(7, metrics.getTotalViewCount()); + ps.setLong(8, metrics.getTotalOrderCount()); + ps.setTimestamp(9, metrics.getAggregatedAt() != null + ? Timestamp.from(metrics.getAggregatedAt().toInstant()) + : new Timestamp(System.currentTimeMillis())); + } + + @Override + public int getBatchSize() { + return metricsList.size(); + } + }); + + log.info("주간 집계 Bulk UPSERT 완료: {} 건", metricsList.size()); + } + + @Override + public int deleteByYearAndWeekBefore(Integer year, Integer week) { + return weeklyJpaRepository.deleteByYearAndWeekBefore(year, week); + } + + @Override + public List findAll() { + return weeklyJpaRepository.findAll(); + } +} diff --git a/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobTest.java b/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobTest.java new file mode 100644 index 000000000..2ffc44278 --- /dev/null +++ b/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobTest.java @@ -0,0 +1,273 @@ +package com.loopers.batch.metrics.job; + +import com.loopers.domain.metrics.ProductMetricsDaily; +import com.loopers.domain.metrics.ProductMetricsDailyRepository; +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.*; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.JobRepositoryTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.enabled=false", + "spring.batch.jdbc.initialize-schema=always" +}) +class ProductMetricsMonthlyJobTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private JobRepositoryTestUtils jobRepositoryTestUtils; + + @Autowired + private ProductMetricsDailyRepository dailyRepository; + + @Autowired + private ProductMetricsMonthlyRepository monthlyRepository; + + @Autowired + private Job productMetricsMonthlyJob; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(productMetricsMonthlyJob); + jobRepositoryTestUtils.removeJobExecutions(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("월간 집계 배치가 성공적으로 실행되고 Monthly 데이터가 생성된다") + void productMetricsMonthlyJob_Success() throws Exception { + // given: 2025년 12월 (2025-12-01 ~ 2025-12-31) Daily 데이터 생성 + int year = 2025; + int month = 12; + LocalDate startDate = LocalDate.of(2025, 12, 1); + LocalDate endDate = LocalDate.of(2025, 12, 31); + + // 상품 3개에 대해 31일치 Daily 데이터 생성 + List dailyMetrics = new ArrayList<>(); + for (long productId = 1L; productId <= 3L; productId++) { + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + ProductMetricsDaily daily = ProductMetricsDaily.create(productId, date); + daily.addLikeDelta(10); // 일일 좋아요 10개 + daily.addViewDelta(100); // 일일 조회 100개 + daily.addOrderDelta(5); // 일일 주문 5개 + dailyMetrics.add(daily); + } + } + dailyRepository.saveAll(dailyMetrics); + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job 성공 확인 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + // Step 실행 결과 확인 + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getStepName()).isEqualTo("aggregateMonthlyMetricsStep"); + assertThat(stepExecution.getReadCount()).isEqualTo(3); // 3개 상품 + assertThat(stepExecution.getWriteCount()).isEqualTo(3); + assertThat(stepExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + // 실제 DB 저장 결과 확인 + List monthlyMetrics = monthlyRepository.findAll(); + assertThat(monthlyMetrics).hasSize(3); + + // 첫 번째 상품의 월간 집계 검증 + ProductMetricsMonthly firstProduct = monthlyMetrics.stream() + .filter(m -> m.getProductId().equals(1L)) + .findFirst() + .orElseThrow(); + + assertThat(firstProduct.getYear()).isEqualTo(year); + assertThat(firstProduct.getMonth()).isEqualTo(month); + assertThat(firstProduct.getPeriodStartDate()).isEqualTo(startDate); + assertThat(firstProduct.getPeriodEndDate()).isEqualTo(endDate); + assertThat(firstProduct.getTotalLikeCount()).isEqualTo(310L); // 10 * 31일 + assertThat(firstProduct.getTotalViewCount()).isEqualTo(3100L); // 100 * 31일 + assertThat(firstProduct.getTotalOrderCount()).isEqualTo(155L); // 5 * 31일 + assertThat(firstProduct.getAggregatedAt()).isNotNull(); + } + + @Test + @DisplayName("Daily 데이터가 없으면 월간 집계가 생성되지 않는다") + void productMetricsMonthlyJob_NoData() throws Exception { + // given: Daily 데이터 없음 + int year = 2025; + int month = 12; + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job은 성공하지만 처리 데이터 없음 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getReadCount()).isEqualTo(0); + assertThat(stepExecution.getWriteCount()).isEqualTo(0); + + // Monthly 데이터 생성되지 않음 + List monthlyMetrics = monthlyRepository.findAll(); + assertThat(monthlyMetrics).isEmpty(); + } + + @Test + @DisplayName("동일한 월간 집계를 다시 실행하면 UPSERT로 업데이트된다") + void productMetricsMonthlyJob_Upsert() throws Exception { + // given: 2025년 12월 Daily 데이터 생성 + int year = 2025; + int month = 12; + LocalDate startDate = LocalDate.of(2025, 12, 1); + + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, startDate); + daily.addLikeDelta(10); + daily.addViewDelta(100); + daily.addOrderDelta(5); + dailyRepository.save(daily); + + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + // when: 첫 번째 실행 + JobExecution firstExecution = jobLauncherTestUtils.launchJob(jobParameters); + assertThat(firstExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List firstResult = monthlyRepository.findAll(); + assertThat(firstResult).hasSize(1); + assertThat(firstResult.get(0).getTotalLikeCount()).isEqualTo(10L); + + // Daily 데이터 변경 (증가) + ProductMetricsDaily updatedDaily = dailyRepository + .findByProductIdAndMetricDate(1L, startDate) + .orElseThrow(); + updatedDaily.addLikeDelta(20); // 추가로 20 증가 + dailyRepository.save(updatedDaily); + + // when: 동일한 월로 두 번째 실행 (새로운 timestamp로) + JobParameters secondJobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis() + 1000) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + JobExecution secondExecution = jobLauncherTestUtils.launchJob(secondJobParameters); + + // then: Job 성공 + assertThat(secondExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + // Monthly 데이터는 여전히 1개 (UPSERT) + List secondResult = monthlyRepository.findAll(); + assertThat(secondResult).hasSize(1); + + // 값이 업데이트됨 (10 + 20 = 30) + assertThat(secondResult.get(0).getTotalLikeCount()).isEqualTo(30L); + } + + @Test + @DisplayName("특정 Step만 실행할 수 있다") + void aggregateMonthlyMetricsStep_Success() { + // given: 테스트 데이터 + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, LocalDate.of(2025, 12, 1)); + daily.addLikeDelta(50); + dailyRepository.save(daily); + + // JobParameters 생성 (StepScope 빈에 필요) + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", "2025") + .addString("month", "12") + .toJobParameters(); + + // when: Step만 실행 + JobExecution jobExecution = jobLauncherTestUtils.launchStep("aggregateMonthlyMetricsStep", jobParameters); + + // then: Step 성공 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getStepName()).isEqualTo("aggregateMonthlyMetricsStep"); + } + + @Test + @DisplayName("2월(28일)과 12월(31일)의 일수 차이를 정확히 처리한다") + void productMetricsMonthlyJob_DifferentMonthDays() throws Exception { + // given: 2025년 2월 (평년, 28일) + int year = 2025; + int month = 2; + LocalDate startDate = LocalDate.of(2025, 2, 1); + LocalDate endDate = LocalDate.of(2025, 2, 28); // 2025년은 평년 + + // 상품 1개에 대해 2월 전체 Daily 데이터 생성 + List dailyMetrics = new ArrayList<>(); + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, date); + daily.addLikeDelta(10); + dailyMetrics.add(daily); + } + dailyRepository.saveAll(dailyMetrics); + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job 성공 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List monthlyMetrics = monthlyRepository.findAll(); + assertThat(monthlyMetrics).hasSize(1); + + ProductMetricsMonthly result = monthlyMetrics.get(0); + assertThat(result.getYear()).isEqualTo(year); + assertThat(result.getMonth()).isEqualTo(month); + assertThat(result.getPeriodStartDate()).isEqualTo(startDate); + assertThat(result.getPeriodEndDate()).isEqualTo(endDate); + assertThat(result.getTotalLikeCount()).isEqualTo(280L); // 10 * 28일 (평년) + } +} diff --git a/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java b/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java new file mode 100644 index 000000000..260f683f7 --- /dev/null +++ b/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java @@ -0,0 +1,232 @@ +package com.loopers.batch.metrics.job; + +import com.loopers.domain.metrics.ProductMetricsDaily; +import com.loopers.domain.metrics.ProductMetricsDailyRepository; +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.*; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.JobRepositoryTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.enabled=false", + "spring.batch.jdbc.initialize-schema=always" +}) +class ProductMetricsWeeklyJobTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private JobRepositoryTestUtils jobRepositoryTestUtils; + + @Autowired + private ProductMetricsDailyRepository dailyRepository; + + @Autowired + private ProductMetricsWeeklyRepository weeklyRepository; + + @Autowired + private Job productMetricsWeeklyJob; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(productMetricsWeeklyJob); + jobRepositoryTestUtils.removeJobExecutions(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("주간 집계 배치가 성공적으로 실행되고 Weekly 데이터가 생성된다") + void productMetricsWeeklyJob_Success() throws Exception { + // given: 2025년 12월 1주차 (2025-12-01 ~ 2025-12-07) Daily 데이터 생성 + int year = 2025; + int week = 49; + LocalDate startDate = LocalDate.of(2025, 12, 1); // 월요일 + LocalDate endDate = LocalDate.of(2025, 12, 7); // 일요일 + + // 상품 3개에 대해 7일치 Daily 데이터 생성 + List dailyMetrics = new ArrayList<>(); + for (long productId = 1L; productId <= 3L; productId++) { + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + ProductMetricsDaily daily = ProductMetricsDaily.create(productId, date); + daily.addLikeDelta(10); // 일일 좋아요 10개 + daily.addViewDelta(100); // 일일 조회 100개 + daily.addOrderDelta(5); // 일일 주문 5개 + dailyMetrics.add(daily); + } + } + dailyRepository.saveAll(dailyMetrics); + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("week", String.valueOf(week)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job 성공 확인 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + // Step 실행 결과 확인 + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getStepName()).isEqualTo("aggregateWeeklyMetricsStep"); + assertThat(stepExecution.getReadCount()).isEqualTo(3); // 3개 상품 + assertThat(stepExecution.getWriteCount()).isEqualTo(3); + assertThat(stepExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + // 실제 DB 저장 결과 확인 + List weeklyMetrics = weeklyRepository.findAll(); + assertThat(weeklyMetrics).hasSize(3); + + // 첫 번째 상품의 주간 집계 검증 + ProductMetricsWeekly firstProduct = weeklyMetrics.stream() + .filter(m -> m.getProductId().equals(1L)) + .findFirst() + .orElseThrow(); + + assertThat(firstProduct.getYear()).isEqualTo(year); + assertThat(firstProduct.getWeek()).isEqualTo(week); + assertThat(firstProduct.getPeriodStartDate()).isEqualTo(startDate); + assertThat(firstProduct.getPeriodEndDate()).isEqualTo(endDate); + assertThat(firstProduct.getTotalLikeCount()).isEqualTo(70L); // 10 * 7일 + assertThat(firstProduct.getTotalViewCount()).isEqualTo(700L); // 100 * 7일 + assertThat(firstProduct.getTotalOrderCount()).isEqualTo(35L); // 5 * 7일 + assertThat(firstProduct.getAggregatedAt()).isNotNull(); + } + + @Test + @DisplayName("Daily 데이터가 없으면 주간 집계가 생성되지 않는다") + void productMetricsWeeklyJob_NoData() throws Exception { + // given: Daily 데이터 없음 + int year = 2025; + int week = 49; + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("week", String.valueOf(week)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job은 성공하지만 처리 데이터 없음 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getReadCount()).isEqualTo(0); + assertThat(stepExecution.getWriteCount()).isEqualTo(0); + + // Weekly 데이터 생성되지 않음 + List weeklyMetrics = weeklyRepository.findAll(); + assertThat(weeklyMetrics).isEmpty(); + } + + @Test + @DisplayName("동일한 주간 집계를 다시 실행하면 UPSERT로 업데이트된다") + void productMetricsWeeklyJob_Upsert() throws Exception { + // given: 2025년 12월 1주차 Daily 데이터 생성 + int year = 2025; + int week = 49; + LocalDate startDate = LocalDate.of(2025, 12, 1); + + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, startDate); + daily.addLikeDelta(10); + daily.addViewDelta(100); + daily.addOrderDelta(5); + dailyRepository.save(daily); + + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("week", String.valueOf(week)) + .toJobParameters(); + + // when: 첫 번째 실행 + JobExecution firstExecution = jobLauncherTestUtils.launchJob(jobParameters); + assertThat(firstExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List firstResult = weeklyRepository.findAll(); + assertThat(firstResult).hasSize(1); + assertThat(firstResult.get(0).getTotalLikeCount()).isEqualTo(10L); + + // Daily 데이터 변경 (증가) + ProductMetricsDaily updatedDaily = dailyRepository + .findByProductIdAndMetricDate(1L, startDate) + .orElseThrow(); + updatedDaily.addLikeDelta(20); // 추가로 20 증가 + dailyRepository.save(updatedDaily); + + // when: 동일한 주차로 두 번째 실행 (새로운 timestamp로) + JobParameters secondJobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis() + 1000) + .addString("year", String.valueOf(year)) + .addString("week", String.valueOf(week)) + .toJobParameters(); + + JobExecution secondExecution = jobLauncherTestUtils.launchJob(secondJobParameters); + + // then: Job 성공 + assertThat(secondExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + // Weekly 데이터는 여전히 1개 (UPSERT) + List secondResult = weeklyRepository.findAll(); + assertThat(secondResult).hasSize(1); + + // 값이 업데이트됨 (10 + 20 = 30) + assertThat(secondResult.get(0).getTotalLikeCount()).isEqualTo(30L); + } + + @Test + @DisplayName("특정 Step만 실행할 수 있다") + void aggregateWeeklyMetricsStep_Success() { + // given: 테스트 데이터 + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, LocalDate.of(2025, 12, 1)); + daily.addLikeDelta(50); + dailyRepository.save(daily); + + // JobParameters 생성 (StepScope 빈에 필요) + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", "2025") + .addString("week", "49") + .toJobParameters(); + + // when: Step만 실행 + JobExecution jobExecution = jobLauncherTestUtils.launchStep("aggregateWeeklyMetricsStep", jobParameters); + + // then: Step 성공 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getStepName()).isEqualTo("aggregateWeeklyMetricsStep"); + } +} \ No newline at end of file