From e08485aafbfc5420ba90a81c176ef191ea6cddec Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 17 Apr 2026 13:54:06 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=8B=9C=EA=B0=84=20=EA=B0=90?= =?UTF-8?q?=EC=87=A0=20=EB=9E=AD=ED=82=B9=20=EA=B3=84=EC=82=B0=20+=20WEEKL?= =?UTF-8?q?Y/MONTHLY=20RankingType=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DailyMetricSnapshot 레코드 추가 (일별 메트릭 데이터 도메인 운반용) - RankingScore.calculateWithDecay() 구현 (감쇠율 0.85^daysAgo 적용) - RankingType에 WEEKLY, MONTHLY enum 추가 (Redis key prefix + TTL 포함) - RankingDateKey.defaultKey(RankingType) 추가 (Controller 비즈니스 로직 도메인 위임) - 시간 감쇠 테스트 3개 추가 (최근 우대, 합산 일관성, 빈 스냅샷 엣지케이스) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../domain/ranking/DailyMetricSnapshot.java | 11 +++++ .../domain/ranking/RankingDateKey.java | 7 +++ .../loopers/domain/ranking/RankingScore.java | 15 ++++++ .../loopers/domain/ranking/RankingType.java | 4 +- .../domain/ranking/RankingScoreTest.java | 47 +++++++++++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 domain/src/main/java/com/loopers/domain/ranking/DailyMetricSnapshot.java 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 000000000..bc8bc84c6 --- /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 f84fcb73b..a69411026 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 12f56b791..06793f786 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 f18cb65ca..f912b9774 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 06782c4c9..57526be14 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); + } } From dba006852b505ef43ee2753ab728b648583102c1 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 17 Apr 2026 13:54:21 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20MV=20=EC=A1=B0=ED=9A=8C=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20+=20Repository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MvProductRankWeekly / MvProductRankMonthly 엔티티 (카운트만 저장, score/rank 없음) - (productId, calculatedDate) 유니크 제약 + calculatedDate 인덱스 - JPQL 벌크 DELETE (파생 deleteBy의 SELECT+개별DELETE → 유니크 제약 충돌 방지) - ProductMetricsDailyRepository.findByDateBetween() 추가 (롤링 윈도우 조회용) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProductMetricsDailyRepository.java | 2 + .../ranking/MvProductRankMonthly.java | 51 +++++++++++++++++++ .../MvProductRankMonthlyRepository.java | 17 +++++++ .../ranking/MvProductRankWeekly.java | 51 +++++++++++++++++++ .../MvProductRankWeeklyRepository.java | 17 +++++++ 5 files changed, 138 insertions(+) create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthly.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepository.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeekly.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepository.java 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 851dc09a7..f48527256 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 000000000..99c49192c --- /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 000000000..c756bd1f3 --- /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 000000000..2adf12665 --- /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 000000000..52cbf0e3a --- /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); +} From 40446f91ab364737139a1dcfdd073d17b007f074 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 17 Apr 2026 13:54:39 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20Spring=20Batch=20Job=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(Chunk-Oriented)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly/MonthlyRankingJobConfig — 2 Step 구조: - Step 1 (Tasklet): JPQL 벌크 DELETE로 기존 MV 데이터 삭제 (재실행 멱등성) - Step 2 (Chunk, size=100): - Reader: ProductMetricsDaily 7/30일치 조회 → productId별 그룹핑 - Processor: 카운트 합산(MV용) + 시간 감쇠 score 계산(Redis용) 동시 처리 - Writer: MV saveAll + Redis ZSET incrementScore (DB 조회 1번으로 최적화) E2E 테스트 10개: - Weekly 6개 (정상 실행, MV 저장, 카운트 합산×3, 재실행 멱등성) - Monthly 3개 (정상 실행, 카운트 합산×2) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../job/ranking/MonthlyRankingJobConfig.java | 177 ++++++++++++++++ .../job/ranking/WeeklyRankingJobConfig.java | 177 ++++++++++++++++ .../job/ranking/MonthlyRankingJobE2ETest.java | 130 ++++++++++++ .../job/ranking/WeeklyRankingJobE2ETest.java | 193 ++++++++++++++++++ 4 files changed, 677 insertions(+) create mode 100644 presentation/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java create mode 100644 presentation/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java create mode 100644 presentation/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java create mode 100644 presentation/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java 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 000000000..58d60a8a8 --- /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 000000000..20c5aa164 --- /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 000000000..25f6ab93c --- /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 000000000..8c32ff4de --- /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); + } +} From b587008afdd36bdf1739360732fb126cdbc52ce9 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 17 Apr 2026 13:54:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20Ranking=20API=20WEEKLY/MONTHLY?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5=20+=20=EC=A1=B0=ED=9A=8C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingController: defaultDateKey() 제거 → RankingDateKey.defaultKey(type) 위임 - RankingService: getRankings(4파라미터)에 @Transactional(readOnly=true) 추가 - HOURLY/DAILY/WEEKLY/MONTHLY 전부 동일 경로 (RankingService → Redis ZSET) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../loopers/application/service/RankingService.java | 1 + .../interfaces/api/ranking/RankingController.java | 13 ++++--------- 2 files changed, 5 insertions(+), 9 deletions(-) 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 e3e5fb98f..a76d681e3 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/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 10fafc5ca..04f443d6e 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(); - }; - } } From 01d26789480395e6c17de67936d96d4ea9623823 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 17 Apr 2026 13:55:08 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20Testcontainers=20Docker=20API=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Engine 29.3.1(최소 API v1.40)에서 docker-java 기본 API v1.32 요청 시 빈 응답 반환 → "Could not find a valid Docker environment" 에러 발생. docker-java.properties에 api.version=1.44 설정으로 해결. 참고: testcontainers/testcontainers-java#11212 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jpa/src/testFixtures/resources/docker-java.properties | 1 + .../redis/src/testFixtures/resources/docker-java.properties | 1 + 2 files changed, 2 insertions(+) create mode 100644 infrastructure/jpa/src/testFixtures/resources/docker-java.properties create mode 100644 infrastructure/redis/src/testFixtures/resources/docker-java.properties 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 000000000..d06ebb926 --- /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 000000000..d06ebb926 --- /dev/null +++ b/infrastructure/redis/src/testFixtures/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44