diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/RankingService.java b/application/commerce-service/src/main/java/com/loopers/application/service/RankingService.java index e3e5fb98f0..a76d681e31 100644 --- a/application/commerce-service/src/main/java/com/loopers/application/service/RankingService.java +++ b/application/commerce-service/src/main/java/com/loopers/application/service/RankingService.java @@ -30,6 +30,7 @@ public List getRankings(String date, int page, int size) { return getRankings(date, page, size, RankingType.DAILY); } + @Transactional(readOnly = true) public List getRankings(String date, int page, int size, RankingType type) { long offset = (long) (page - 1) * size; List rankedProducts = productRankingRepository.getTopProducts(date, offset, size, type); diff --git a/domain/src/main/java/com/loopers/domain/ranking/DailyMetricSnapshot.java b/domain/src/main/java/com/loopers/domain/ranking/DailyMetricSnapshot.java new file mode 100644 index 0000000000..bc8bc84c6c --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/ranking/DailyMetricSnapshot.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +public record DailyMetricSnapshot( + LocalDate date, + long viewCount, + long likesCount, + long salesCount +) { +} diff --git a/domain/src/main/java/com/loopers/domain/ranking/RankingDateKey.java b/domain/src/main/java/com/loopers/domain/ranking/RankingDateKey.java index f84fcb73b4..a694110265 100644 --- a/domain/src/main/java/com/loopers/domain/ranking/RankingDateKey.java +++ b/domain/src/main/java/com/loopers/domain/ranking/RankingDateKey.java @@ -27,4 +27,11 @@ public static String ofHour(LocalDateTime dateTime) { public static String currentHour() { return ofHour(LocalDateTime.now()); } + + public static String defaultKey(RankingType type) { + return switch (type) { + case DAILY, WEEKLY, MONTHLY -> today(); + case HOURLY -> currentHour(); + }; + } } diff --git a/domain/src/main/java/com/loopers/domain/ranking/RankingScore.java b/domain/src/main/java/com/loopers/domain/ranking/RankingScore.java index 12f56b7914..06793f7861 100644 --- a/domain/src/main/java/com/loopers/domain/ranking/RankingScore.java +++ b/domain/src/main/java/com/loopers/domain/ranking/RankingScore.java @@ -1,10 +1,15 @@ package com.loopers.domain.ranking; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; + public final class RankingScore { private static final double VIEW_WEIGHT = 1.0; private static final double LIKE_WEIGHT = 3.0; private static final double SALE_WEIGHT = 10.0; + private static final double DECAY_RATE = 0.85; private RankingScore() { } @@ -30,4 +35,14 @@ public static double calculateDaily(long viewCount, long likesCount, long salesC + LIKE_WEIGHT * Math.log10(likesCount + 1) + SALE_WEIGHT * Math.log10(salesCount + 1); } + + public static double calculateWithDecay(List snapshots, LocalDate baseDate) { + double total = 0.0; + for (DailyMetricSnapshot snapshot : snapshots) { + long daysAgo = ChronoUnit.DAYS.between(snapshot.date(), baseDate); + double dailyScore = calculateDaily(snapshot.viewCount(), snapshot.likesCount(), snapshot.salesCount()); + total += dailyScore * Math.pow(DECAY_RATE, daysAgo); + } + return total; + } } diff --git a/domain/src/main/java/com/loopers/domain/ranking/RankingType.java b/domain/src/main/java/com/loopers/domain/ranking/RankingType.java index f18cb65ca6..f912b97744 100644 --- a/domain/src/main/java/com/loopers/domain/ranking/RankingType.java +++ b/domain/src/main/java/com/loopers/domain/ranking/RankingType.java @@ -3,7 +3,9 @@ public enum RankingType { DAILY("ranking:daily:", 2 * 24 * 60 * 60), - HOURLY("ranking:hourly:", 24 * 60 * 60); + HOURLY("ranking:hourly:", 24 * 60 * 60), + WEEKLY("ranking:weekly:", 8 * 24 * 60 * 60), + MONTHLY("ranking:monthly:", 32 * 24 * 60 * 60); private final String keyPrefix; private final long ttlSeconds; diff --git a/domain/src/test/java/com/loopers/domain/ranking/RankingScoreTest.java b/domain/src/test/java/com/loopers/domain/ranking/RankingScoreTest.java index 06782c4c92..57526be14b 100644 --- a/domain/src/test/java/com/loopers/domain/ranking/RankingScoreTest.java +++ b/domain/src/test/java/com/loopers/domain/ranking/RankingScoreTest.java @@ -2,6 +2,9 @@ import org.junit.jupiter.api.Test; +import java.time.LocalDate; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; class RankingScoreTest { @@ -65,4 +68,48 @@ class RankingScoreTest { // then assertThat(salesOnly).isGreaterThan(viewsOnly); } + + @Test + void 주간_점수는_최근_날짜에_높은_가중치를_적용한다() { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + var recentDay = new DailyMetricSnapshot(today.minusDays(1), 100, 10, 5); + var oldDay = new DailyMetricSnapshot(today.minusDays(7), 100, 10, 5); + + // when + double recentScore = RankingScore.calculateWithDecay(List.of(recentDay), today); + double oldScore = RankingScore.calculateWithDecay(List.of(oldDay), today); + + // then + assertThat(recentScore).isGreaterThan(oldScore); + } + + @Test + void 주간_점수는_여러_날의_감쇠_점수를_합산한다() { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + var day1 = new DailyMetricSnapshot(today.minusDays(1), 100, 10, 5); + var day2 = new DailyMetricSnapshot(today.minusDays(2), 200, 20, 10); + + double score1 = RankingScore.calculateWithDecay(List.of(day1), today); + double score2 = RankingScore.calculateWithDecay(List.of(day2), today); + + // when + double combined = RankingScore.calculateWithDecay(List.of(day1, day2), today); + + // then + assertThat(combined).isEqualTo(score1 + score2); + } + + @Test + void 빈_스냅샷이면_점수는_0이다() { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + // when + double score = RankingScore.calculateWithDecay(List.of(), today); + + // then + assertThat(score).isEqualTo(0.0); + } } diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepository.java index 851dc09a7c..f48527256c 100644 --- a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepository.java +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepository.java @@ -11,4 +11,6 @@ public interface ProductMetricsDailyRepository extends JpaRepository findByProductIdAndDate(Long productId, LocalDate date); List findByDate(LocalDate date); + + List findByDateBetween(LocalDate startDate, LocalDate endDate); } diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthly.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..99c49192c1 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthly.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint(columnNames = {"productId", "calculatedDate"}), + indexes = @Index(name = "idx_monthly_calculated_date", columnList = "calculatedDate")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly extends BaseTimeEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likesCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private LocalDate calculatedDate; + + private MvProductRankMonthly(Long productId, long viewCount, long likesCount, + long salesCount, LocalDate calculatedDate) { + this.productId = productId; + this.viewCount = viewCount; + this.likesCount = likesCount; + this.salesCount = salesCount; + this.calculatedDate = calculatedDate; + } + + public static MvProductRankMonthly of(Long productId, long viewCount, long likesCount, + long salesCount, LocalDate calculatedDate) { + return new MvProductRankMonthly(productId, viewCount, likesCount, salesCount, calculatedDate); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepository.java new file mode 100644 index 0000000000..c756bd1f34 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.ranking; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankMonthlyRepository extends JpaRepository { + + List findByCalculatedDate(LocalDate calculatedDate); + + @Modifying + @Query("DELETE FROM MvProductRankMonthly m WHERE m.calculatedDate = :calculatedDate") + void deleteByCalculatedDate(LocalDate calculatedDate); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeekly.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..2adf12665a --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeekly.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint(columnNames = {"productId", "calculatedDate"}), + indexes = @Index(name = "idx_weekly_calculated_date", columnList = "calculatedDate")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly extends BaseTimeEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likesCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private LocalDate calculatedDate; + + private MvProductRankWeekly(Long productId, long viewCount, long likesCount, + long salesCount, LocalDate calculatedDate) { + this.productId = productId; + this.viewCount = viewCount; + this.likesCount = likesCount; + this.salesCount = salesCount; + this.calculatedDate = calculatedDate; + } + + public static MvProductRankWeekly of(Long productId, long viewCount, long likesCount, + long salesCount, LocalDate calculatedDate) { + return new MvProductRankWeekly(productId, viewCount, likesCount, salesCount, calculatedDate); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepository.java new file mode 100644 index 0000000000..52cbf0e3a4 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.ranking; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankWeeklyRepository extends JpaRepository { + + List findByCalculatedDate(LocalDate calculatedDate); + + @Modifying + @Query("DELETE FROM MvProductRankWeekly m WHERE m.calculatedDate = :calculatedDate") + void deleteByCalculatedDate(LocalDate calculatedDate); +} diff --git a/infrastructure/jpa/src/testFixtures/resources/docker-java.properties b/infrastructure/jpa/src/testFixtures/resources/docker-java.properties new file mode 100644 index 0000000000..d06ebb9264 --- /dev/null +++ b/infrastructure/jpa/src/testFixtures/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 diff --git a/infrastructure/redis/src/testFixtures/resources/docker-java.properties b/infrastructure/redis/src/testFixtures/resources/docker-java.properties new file mode 100644 index 0000000000..d06ebb9264 --- /dev/null +++ b/infrastructure/redis/src/testFixtures/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java index 10fafc5ca7..04f443d6ea 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java @@ -26,16 +26,11 @@ public List getRankings( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "DAILY") RankingType type ) { - String targetDate = date != null ? date : defaultDateKey(type); - return rankingService.getRankings(targetDate, page, size, type).stream() + if (date == null) { + date = RankingDateKey.defaultKey(type); + } + return rankingService.getRankings(date, page, size, type).stream() .map(RankingApiResponse::from) .toList(); } - - private String defaultDateKey(RankingType type) { - return switch (type) { - case DAILY -> RankingDateKey.today(); - case HOURLY -> RankingDateKey.currentHour(); - }; - } } diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..58d60a8a86 --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,177 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.DailyMetricSnapshot; +import com.loopers.domain.ranking.ProductRankingRepository; +import com.loopers.domain.ranking.RankingDateKey; +import com.loopers.domain.ranking.RankingScore; +import com.loopers.domain.ranking.RankingType; +import com.loopers.infrastructure.metrics.ProductMetricsDaily; +import com.loopers.infrastructure.metrics.ProductMetricsDailyRepository; +import com.loopers.infrastructure.ranking.MvProductRankMonthly; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@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 CLEANUP_STEP_NAME = "monthlyRankingCleanupStep"; + private static final String AGGREGATE_STEP_NAME = "monthlyRankingAggregateStep"; + private static final int CHUNK_SIZE = 100; + private static final int WINDOW_DAYS = 30; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ProductMetricsDailyRepository productMetricsDailyRepository; + private final MvProductRankMonthlyRepository mvProductRankMonthlyRepository; + private final ProductRankingRepository productRankingRepository; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(monthlyRankingCleanupStep()) + .next(monthlyRankingAggregateStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(CLEANUP_STEP_NAME) + public Step monthlyRankingCleanupStep() { + return new StepBuilder(CLEANUP_STEP_NAME, jobRepository) + .tasklet(monthlyRankingCleanupTasklet(null), transactionManager) + .build(); + } + + @StepScope + @Bean + public org.springframework.batch.core.step.tasklet.Tasklet monthlyRankingCleanupTasklet( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + return (contribution, chunkContext) -> { + mvProductRankMonthlyRepository.deleteByCalculatedDate(baseDate); + return RepeatStatus.FINISHED; + }; + } + + @JobScope + @Bean(AGGREGATE_STEP_NAME) + public Step monthlyRankingAggregateStep() { + return new StepBuilder(AGGREGATE_STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyRankingReader(null)) + .processor(monthlyRankingProcessor(null)) + .writer(monthlyRankingWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public ItemReader monthlyRankingReader( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + LocalDate startDate = baseDate.minusDays(WINDOW_DAYS); + LocalDate endDate = baseDate.minusDays(1); + + List dailyMetrics = + productMetricsDailyRepository.findByDateBetween(startDate, endDate); + + Map aggregateMap = new LinkedHashMap<>(); + for (ProductMetricsDaily daily : dailyMetrics) { + aggregateMap.computeIfAbsent(daily.getProductId(), + id -> new ProductMonthlyAggregate(id, new ArrayList<>())); + aggregateMap.get(daily.getProductId()).dailyMetrics().add(daily); + } + + Iterator iterator = aggregateMap.values().iterator(); + + return () -> { + if (iterator.hasNext()) { + return iterator.next(); + } + return null; + }; + } + + @StepScope + @Bean + public ItemProcessor monthlyRankingProcessor( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + return aggregate -> { + long totalViews = 0; + long totalLikes = 0; + long totalSales = 0; + List snapshots = new ArrayList<>(); + + for (ProductMetricsDaily daily : aggregate.dailyMetrics()) { + totalViews += daily.getViewCount(); + totalLikes += daily.getLikesCount(); + totalSales += daily.getSalesCount(); + snapshots.add(new DailyMetricSnapshot( + daily.getDate(), daily.getViewCount(), + daily.getLikesCount(), daily.getSalesCount())); + } + + MvProductRankMonthly mvEntity = MvProductRankMonthly.of( + aggregate.productId(), totalViews, totalLikes, totalSales, baseDate); + double score = RankingScore.calculateWithDecay(snapshots, baseDate); + + return new MonthlyRankingResult(aggregate.productId(), mvEntity, score); + }; + } + + @StepScope + @Bean + public ItemWriter monthlyRankingWriter( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + return items -> { + List mvEntities = items.getItems().stream() + .map(MonthlyRankingResult::mvEntity) + .toList(); + mvProductRankMonthlyRepository.saveAll(mvEntities); + + String dateKey = RankingDateKey.of(baseDate); + for (MonthlyRankingResult result : items) { + productRankingRepository.incrementScore( + result.productId(), result.score(), dateKey, RankingType.MONTHLY); + } + }; + } + + public record ProductMonthlyAggregate(Long productId, List dailyMetrics) { + } + + public record MonthlyRankingResult(Long productId, MvProductRankMonthly mvEntity, double score) { + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..20c5aa164d --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,177 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.DailyMetricSnapshot; +import com.loopers.domain.ranking.ProductRankingRepository; +import com.loopers.domain.ranking.RankingDateKey; +import com.loopers.domain.ranking.RankingScore; +import com.loopers.domain.ranking.RankingType; +import com.loopers.infrastructure.metrics.ProductMetricsDaily; +import com.loopers.infrastructure.metrics.ProductMetricsDailyRepository; +import com.loopers.infrastructure.ranking.MvProductRankWeekly; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@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 CLEANUP_STEP_NAME = "weeklyRankingCleanupStep"; + private static final String AGGREGATE_STEP_NAME = "weeklyRankingAggregateStep"; + private static final int CHUNK_SIZE = 100; + private static final int WINDOW_DAYS = 7; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ProductMetricsDailyRepository productMetricsDailyRepository; + private final MvProductRankWeeklyRepository mvProductRankWeeklyRepository; + private final ProductRankingRepository productRankingRepository; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(weeklyRankingCleanupStep()) + .next(weeklyRankingAggregateStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(CLEANUP_STEP_NAME) + public Step weeklyRankingCleanupStep() { + return new StepBuilder(CLEANUP_STEP_NAME, jobRepository) + .tasklet(weeklyRankingCleanupTasklet(null), transactionManager) + .build(); + } + + @StepScope + @Bean + public org.springframework.batch.core.step.tasklet.Tasklet weeklyRankingCleanupTasklet( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + return (contribution, chunkContext) -> { + mvProductRankWeeklyRepository.deleteByCalculatedDate(baseDate); + return RepeatStatus.FINISHED; + }; + } + + @JobScope + @Bean(AGGREGATE_STEP_NAME) + public Step weeklyRankingAggregateStep() { + return new StepBuilder(AGGREGATE_STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyRankingReader(null)) + .processor(weeklyRankingProcessor(null)) + .writer(weeklyRankingWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public ItemReader weeklyRankingReader( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + LocalDate startDate = baseDate.minusDays(WINDOW_DAYS); + LocalDate endDate = baseDate.minusDays(1); + + List dailyMetrics = + productMetricsDailyRepository.findByDateBetween(startDate, endDate); + + Map aggregateMap = new LinkedHashMap<>(); + for (ProductMetricsDaily daily : dailyMetrics) { + aggregateMap.computeIfAbsent(daily.getProductId(), + id -> new ProductWeeklyAggregate(id, new ArrayList<>())); + aggregateMap.get(daily.getProductId()).dailyMetrics().add(daily); + } + + Iterator iterator = aggregateMap.values().iterator(); + + return () -> { + if (iterator.hasNext()) { + return iterator.next(); + } + return null; + }; + } + + @StepScope + @Bean + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + return aggregate -> { + long totalViews = 0; + long totalLikes = 0; + long totalSales = 0; + List snapshots = new ArrayList<>(); + + for (ProductMetricsDaily daily : aggregate.dailyMetrics()) { + totalViews += daily.getViewCount(); + totalLikes += daily.getLikesCount(); + totalSales += daily.getSalesCount(); + snapshots.add(new DailyMetricSnapshot( + daily.getDate(), daily.getViewCount(), + daily.getLikesCount(), daily.getSalesCount())); + } + + MvProductRankWeekly mvEntity = MvProductRankWeekly.of( + aggregate.productId(), totalViews, totalLikes, totalSales, baseDate); + double score = RankingScore.calculateWithDecay(snapshots, baseDate); + + return new WeeklyRankingResult(aggregate.productId(), mvEntity, score); + }; + } + + @StepScope + @Bean + public ItemWriter weeklyRankingWriter( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + return items -> { + List mvEntities = items.getItems().stream() + .map(WeeklyRankingResult::mvEntity) + .toList(); + mvProductRankWeeklyRepository.saveAll(mvEntities); + + String dateKey = RankingDateKey.of(baseDate); + for (WeeklyRankingResult result : items) { + productRankingRepository.incrementScore( + result.productId(), result.score(), dateKey, RankingType.WEEKLY); + } + }; + } + + public record ProductWeeklyAggregate(Long productId, List dailyMetrics) { + } + + public record WeeklyRankingResult(Long productId, MvProductRankWeekly mvEntity, double score) { + } +} diff --git a/presentation/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/presentation/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java new file mode 100644 index 0000000000..25f6ab93cc --- /dev/null +++ b/presentation/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,130 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.infrastructure.metrics.ProductMetricsDaily; +import com.loopers.infrastructure.metrics.ProductMetricsDailyRepository; +import com.loopers.infrastructure.ranking.MvProductRankMonthly; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +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 java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class MonthlyRankingJobE2ETest { + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductMetricsDailyRepository productMetricsDailyRepository; + + @Autowired + private MvProductRankMonthlyRepository mvProductRankMonthlyRepository; + + private static final AtomicLong RUN_ID = new AtomicLong(1); + + @BeforeEach + void setUp() { + mvProductRankMonthlyRepository.deleteAllInBatch(); + productMetricsDailyRepository.deleteAllInBatch(); + } + + private JobExecution runJob(LocalDate baseDate) throws Exception { + var jobParameters = new JobParametersBuilder() + .addLocalDate("baseDate", baseDate) + .addLong("run.id", RUN_ID.getAndIncrement()) + .toJobParameters(); + return jobLauncher.run(job, jobParameters); + } + + @Test + void 월간_랭킹_배치가_정상_실행된다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily daily = ProductMetricsDaily.init(1L, today.minusDays(10)); + incrementViews(daily, 100); + productMetricsDailyRepository.save(daily); + + // when + var jobExecution = runJob(today); + + // then + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + } + + @Test + void 월간_랭킹_배치가_30일치_카운트를_합산한다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily day1 = ProductMetricsDaily.init(1L, today.minusDays(5)); + incrementViews(day1, 100); + incrementSales(day1, 10); + + ProductMetricsDaily day2 = ProductMetricsDaily.init(1L, today.minusDays(25)); + incrementViews(day2, 200); + incrementSales(day2, 20); + + productMetricsDailyRepository.saveAll(List.of(day1, day2)); + + // when + runJob(today); + + // then + List results = mvProductRankMonthlyRepository.findByCalculatedDate(today); + assertThat(results.get(0).getViewCount()).isEqualTo(300); + } + + @Test + void 월간_랭킹_배치가_판매_카운트를_합산한다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily day1 = ProductMetricsDaily.init(1L, today.minusDays(5)); + incrementSales(day1, 10); + + ProductMetricsDaily day2 = ProductMetricsDaily.init(1L, today.minusDays(25)); + incrementSales(day2, 20); + + productMetricsDailyRepository.saveAll(List.of(day1, day2)); + + // when + runJob(today); + + // then + List results = mvProductRankMonthlyRepository.findByCalculatedDate(today); + assertThat(results.get(0).getSalesCount()).isEqualTo(30); + } + + private void incrementViews(ProductMetricsDaily daily, int count) { + for (int i = 0; i < count; i++) daily.incrementViews(); + } + + private void incrementSales(ProductMetricsDaily daily, long count) { + daily.incrementSales(count); + } +} diff --git a/presentation/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/presentation/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java new file mode 100644 index 0000000000..8c32ff4dea --- /dev/null +++ b/presentation/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,193 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.infrastructure.metrics.ProductMetricsDaily; +import com.loopers.infrastructure.metrics.ProductMetricsDailyRepository; +import com.loopers.infrastructure.ranking.MvProductRankWeekly; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +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 java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductMetricsDailyRepository productMetricsDailyRepository; + + @Autowired + private MvProductRankWeeklyRepository mvProductRankWeeklyRepository; + + private static final AtomicLong RUN_ID = new AtomicLong(1); + + @BeforeEach + void setUp() { + mvProductRankWeeklyRepository.deleteAllInBatch(); + productMetricsDailyRepository.deleteAllInBatch(); + } + + private JobExecution runJob(LocalDate baseDate) throws Exception { + var jobParameters = new JobParametersBuilder() + .addLocalDate("baseDate", baseDate) + .addLong("run.id", RUN_ID.getAndIncrement()) + .toJobParameters(); + return jobLauncher.run(job, jobParameters); + } + + @Test + void 주간_랭킹_배치가_정상_실행된다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily day1 = ProductMetricsDaily.init(1L, today.minusDays(1)); + incrementViews(day1, 100); + productMetricsDailyRepository.save(day1); + + // when + var jobExecution = runJob(today); + + // then + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + } + + @Test + void 주간_랭킹_배치가_MV_테이블에_집계_결과를_저장한다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily day1 = ProductMetricsDaily.init(1L, today.minusDays(1)); + incrementViews(day1, 100); + + ProductMetricsDaily day2 = ProductMetricsDaily.init(1L, today.minusDays(3)); + incrementViews(day2, 50); + + productMetricsDailyRepository.saveAll(List.of(day1, day2)); + + // when + runJob(today); + + // then + List results = mvProductRankWeeklyRepository.findByCalculatedDate(today); + assertThat(results).hasSize(1); + } + + @Test + void 주간_랭킹_배치가_상품별_카운트를_합산한다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily day1 = ProductMetricsDaily.init(1L, today.minusDays(1)); + incrementViews(day1, 100); + + ProductMetricsDaily day2 = ProductMetricsDaily.init(1L, today.minusDays(3)); + incrementViews(day2, 50); + + productMetricsDailyRepository.saveAll(List.of(day1, day2)); + + // when + runJob(today); + + // then + List results = mvProductRankWeeklyRepository.findByCalculatedDate(today); + assertThat(results.get(0).getViewCount()).isEqualTo(150); + } + + @Test + void 주간_랭킹_배치가_좋아요_카운트를_합산한다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily day1 = ProductMetricsDaily.init(1L, today.minusDays(1)); + incrementLikes(day1, 10); + + ProductMetricsDaily day2 = ProductMetricsDaily.init(1L, today.minusDays(3)); + incrementLikes(day2, 5); + + productMetricsDailyRepository.saveAll(List.of(day1, day2)); + + // when + runJob(today); + + // then + List results = mvProductRankWeeklyRepository.findByCalculatedDate(today); + assertThat(results.get(0).getLikesCount()).isEqualTo(15); + } + + @Test + void 주간_랭킹_배치가_판매_카운트를_합산한다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily day1 = ProductMetricsDaily.init(1L, today.minusDays(1)); + incrementSales(day1, 5); + + ProductMetricsDaily day2 = ProductMetricsDaily.init(1L, today.minusDays(3)); + incrementSales(day2, 3); + + productMetricsDailyRepository.saveAll(List.of(day1, day2)); + + // when + runJob(today); + + // then + List results = mvProductRankWeeklyRepository.findByCalculatedDate(today); + assertThat(results.get(0).getSalesCount()).isEqualTo(8); + } + + @Test + void 재실행시_기존_데이터를_삭제하고_새로_생성한다() throws Exception { + // given + LocalDate today = LocalDate.of(2026, 4, 16); + + ProductMetricsDaily daily = ProductMetricsDaily.init(1L, today.minusDays(1)); + incrementViews(daily, 100); + productMetricsDailyRepository.save(daily); + + runJob(today); + + // when + runJob(today); + + // then + List results = mvProductRankWeeklyRepository.findByCalculatedDate(today); + assertThat(results).hasSize(1); + } + + private void incrementViews(ProductMetricsDaily daily, int count) { + for (int i = 0; i < count; i++) daily.incrementViews(); + } + + private void incrementLikes(ProductMetricsDaily daily, int count) { + for (int i = 0; i < count; i++) daily.incrementLikes(); + } + + private void incrementSales(ProductMetricsDaily daily, long count) { + daily.incrementSales(count); + } +}