From 2f8270be5b4968954592931e3934fc652fba48e1 Mon Sep 17 00:00:00 2001 From: dfdf0202 Date: Fri, 17 Apr 2026 14:34:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=2010=EC=A3=BC=20=EA=B3=BC=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 39 ++- .../ranking/dto/FindRankingItemResDto.java | 4 +- .../product/service/ProductService.java | 7 + .../domain/ranking/model/RankingEntry.java | 8 + .../domain/ranking/model/RankingPeriod.java | 23 ++ .../domain/ranking/model/RankingQuery.java | 35 ++ .../domain/ranking/model/RankingSlice.java | 9 + .../repository/MonthlyRankingRepository.java | 10 + .../repository/WeeklyRankingRepository.java | 10 + .../service/MonthlyRankingService.java | 25 ++ .../ranking/service/RankingService.java | 13 +- .../ranking/service/WeeklyRankingService.java | 25 ++ .../ranking/entity/MonthlyRankingEntity.java | 50 +++ .../ranking/entity/WeeklyRankingEntity.java | 50 +++ .../MonthlyRankingJpaRepository.java | 12 + .../MonthlyRankingRepositoryImpl.java | 29 ++ .../WeeklyRankingJpaRepository.java | 12 + .../WeeklyRankingRepositoryImpl.java | 29 ++ .../api/ranking/RankingV1ApiSpec.java | 3 +- .../api/ranking/RankingV1Controller.java | 14 +- .../support/util/RankingPeriodKeyFactory.java | 26 ++ .../product/ProductFacadeTest.java | 21 +- .../domain/ranking/RankingPeriodTest.java | 34 ++ .../interfaces/api/RankingV1ApiE2ETest.java | 308 ++++++++++++++++++ .../domain/ranking/AggregatedRankingRow.java | 11 + .../ranking/RankingPeriodKeyFactory.java | 38 +++ .../entity/ProductMetricsDailyEntity.java | 51 +++ .../ranking/entity/MonthlyRankingEntity.java | 64 ++++ .../ranking/entity/WeeklyRankingEntity.java | 64 ++++ .../MonthlyRankingJpaRepository.java | 13 + .../WeeklyRankingJpaRepository.java | 13 + .../job/ranking/MonthlyRankingJobConfig.java | 51 +++ .../job/ranking/WeeklyRankingJobConfig.java | 51 +++ .../tasklet/RefreshMonthlyRankingTasklet.java | 74 +++++ .../tasklet/RefreshWeeklyRankingTasklet.java | 74 +++++ .../ranking/RankingPeriodKeyFactoryTest.java | 25 ++ .../job/ranking/MonthlyRankingJobE2ETest.java | 93 ++++++ .../job/ranking/WeeklyRankingJobE2ETest.java | 94 ++++++ .../ProductMetricsDailyRepository.java | 14 + .../repository/ProductMetricsRepository.java | 16 + .../metrics/service/MetricsService.java | 32 +- .../entity/ProductMetricsDailyEntity.java | 51 +++ .../ProductMetricsDailyJpaRepository.java | 68 ++++ .../ProductMetricsDailyRepositoryImpl.java | 34 ++ .../ProductMetricsRepositoryImpl.java | 32 ++ .../consumer/CatalogEventConsumer.java | 6 +- .../metrics/service/MetricsServiceTest.java | 80 +++++ .../ProductMetricsDailyJpaRepositoryTest.java | 75 +++++ 48 files changed, 1875 insertions(+), 45 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingEntry.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingQuery.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingSlice.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/repository/MonthlyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/repository/WeeklyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/MonthlyRankingService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/WeeklyRankingService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/MonthlyRankingEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/WeeklyRankingEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/MonthlyRankingJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/MonthlyRankingRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/WeeklyRankingJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/WeeklyRankingRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/util/RankingPeriodKeyFactory.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingPeriodTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/AggregatedRankingRow.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriodKeyFactory.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/metrics/entity/ProductMetricsDailyEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/entity/MonthlyRankingEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/entity/WeeklyRankingEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/repository/MonthlyRankingJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/repository/WeeklyRankingJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/tasklet/RefreshMonthlyRankingTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/tasklet/RefreshWeeklyRankingTasklet.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingPeriodKeyFactoryTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/ProductMetricsDailyRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/ProductMetricsRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/entity/ProductMetricsDailyEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsDailyJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsDailyRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/service/MetricsServiceTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepositoryTest.java 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 31fb7ba40e..3cd92ea4e1 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 @@ -4,8 +4,13 @@ import com.loopers.application.ranking.dto.FindRankingListResDto; import com.loopers.domain.product.model.Product; import com.loopers.domain.product.service.ProductService; -import com.loopers.domain.ranking.repository.RankingRepository; +import com.loopers.domain.ranking.model.RankingEntry; +import com.loopers.domain.ranking.model.RankingQuery; +import com.loopers.domain.ranking.model.RankingSlice; +import com.loopers.domain.ranking.model.RankingPeriod; +import com.loopers.domain.ranking.service.MonthlyRankingService; import com.loopers.domain.ranking.service.RankingService; +import com.loopers.domain.ranking.service.WeeklyRankingService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -20,28 +25,36 @@ @Transactional(readOnly = true) public class RankingFacade { - private final RankingService rankingService; + private final RankingService dailyRankingService; + private final WeeklyRankingService weeklyRankingService; + private final MonthlyRankingService monthlyRankingService; private final ProductService productService; - public FindRankingListResDto getRankings(String date, int page, int size) { - List entries = rankingService.getTopRankings(date, page, size); - long totalCount = rankingService.getTotalCount(date); - - if (entries.isEmpty()) { - return new FindRankingListResDto(List.of(), totalCount, page, size); + public FindRankingListResDto getRankings(RankingQuery query) { + RankingSlice slice = switch (query.period()) { + case DAILY -> new RankingSlice( + dailyRankingService.getTopRankings(query.date(), query.page(), query.size()), + dailyRankingService.getTotalCount(query.date()) + ); + case WEEKLY -> weeklyRankingService.getRankings(query.date(), query.page(), query.size()); + case MONTHLY -> monthlyRankingService.getRankings(query.date(), query.page(), query.size()); + }; + + if (slice.entries().isEmpty()) { + return new FindRankingListResDto(List.of(), slice.totalCount(), query.page(), query.size()); } - List productIds = entries.stream() - .map(RankingRepository.RankingEntry::productId) + List productIds = slice.entries().stream() + .map(RankingEntry::productId) .toList(); - Map productMap = productService.getProductsByIds(productIds).stream() + Map productMap = productService.findAllByIds(productIds).stream() .collect(Collectors.toMap(Product::getId, Function.identity())); - List items = entries.stream() + List items = slice.entries().stream() .filter(entry -> productMap.containsKey(entry.productId())) .map(entry -> FindRankingItemResDto.from(entry, productMap.get(entry.productId()))) .toList(); - return new FindRankingListResDto(items, totalCount, page, size); + return new FindRankingListResDto(items, slice.totalCount(), query.page(), query.size()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/dto/FindRankingItemResDto.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/dto/FindRankingItemResDto.java index a01dfbd6d9..b7474847fe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/dto/FindRankingItemResDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/dto/FindRankingItemResDto.java @@ -1,7 +1,7 @@ package com.loopers.application.ranking.dto; import com.loopers.domain.product.model.Product; -import com.loopers.domain.ranking.repository.RankingRepository; +import com.loopers.domain.ranking.model.RankingEntry; public record FindRankingItemResDto( long rank, @@ -10,7 +10,7 @@ public record FindRankingItemResDto( String productName, int price ) { - public static FindRankingItemResDto from(RankingRepository.RankingEntry entry, Product product) { + public static FindRankingItemResDto from(RankingEntry entry, Product product) { return new FindRankingItemResDto( entry.rank(), entry.score(), diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java index 0a4260705a..f6fa8ac854 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java @@ -158,6 +158,13 @@ public List getProductsByIds(List productIds) { return products; } + public List findAllByIds(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return List.of(); + } + return productRepository.findByIds(productIds); + } + private boolean isFirstPageLatest(Long brandId, SortFilter sortFilter, Pageable pageable) { return brandId == null && sortFilter == SortFilter.LATEST && pageable.getPageNumber() == 0; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingEntry.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingEntry.java new file mode 100644 index 0000000000..d982fe0444 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingEntry.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking.model; + +public record RankingEntry( + Long productId, + double score, + long rank +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingPeriod.java new file mode 100644 index 0000000000..e60f839665 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingPeriod.java @@ -0,0 +1,23 @@ +package com.loopers.domain.ranking.model; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.Locale; + +public enum RankingPeriod { + DAILY, + WEEKLY, + MONTHLY; + + public static RankingPeriod from(String value) { + if (value == null || value.isBlank()) { + return DAILY; + } + try { + return RankingPeriod.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 랭킹 기간입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingQuery.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingQuery.java new file mode 100644 index 0000000000..dd5b699d80 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingQuery.java @@ -0,0 +1,35 @@ +package com.loopers.domain.ranking.model; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +public record RankingQuery( + RankingPeriod period, + LocalDate date, + int page, + int size +) { + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + + public static RankingQuery of(String period, String date, int page, int size) { + validate(page, size); + try { + return new RankingQuery(RankingPeriod.from(period), LocalDate.parse(date, DATE_FORMAT), page, size); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); + } + } + + private static void validate(int page, int size) { + if (page < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "페이지 번호는 1 이상이어야 합니다."); + } + if (size < 1 || size > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "페이지 크기는 1 이상 100 이하여야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingSlice.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingSlice.java new file mode 100644 index 0000000000..f1bc232444 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingSlice.java @@ -0,0 +1,9 @@ +package com.loopers.domain.ranking.model; + +import java.util.List; + +public record RankingSlice( + List entries, + long totalCount +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/repository/MonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/repository/MonthlyRankingRepository.java new file mode 100644 index 0000000000..3c50b70d20 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/repository/MonthlyRankingRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.ranking.repository; + +import com.loopers.domain.ranking.model.RankingEntry; + +import java.util.List; + +public interface MonthlyRankingRepository { + List getTopRankings(String periodKey, int offset, int size); + long getTotalCount(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/repository/WeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/repository/WeeklyRankingRepository.java new file mode 100644 index 0000000000..0e167432c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/repository/WeeklyRankingRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.ranking.repository; + +import com.loopers.domain.ranking.model.RankingEntry; + +import java.util.List; + +public interface WeeklyRankingRepository { + List getTopRankings(String periodKey, int offset, int size); + long getTotalCount(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/MonthlyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/MonthlyRankingService.java new file mode 100644 index 0000000000..83cbe1becc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/MonthlyRankingService.java @@ -0,0 +1,25 @@ +package com.loopers.domain.ranking.service; + +import com.loopers.domain.ranking.model.RankingSlice; +import com.loopers.domain.ranking.repository.MonthlyRankingRepository; +import com.loopers.support.util.RankingPeriodKeyFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MonthlyRankingService { + + private final MonthlyRankingRepository repository; + + public RankingSlice getRankings(LocalDate date, int page, int size) { + int offset = (page - 1) * size; + String periodKey = RankingPeriodKeyFactory.toMonthlyKey(date); + return new RankingSlice( + repository.getTopRankings(periodKey, offset, size), + repository.getTotalCount(periodKey) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/RankingService.java index bf8312baa0..837c36a3bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/RankingService.java @@ -1,9 +1,12 @@ package com.loopers.domain.ranking.service; +import com.loopers.domain.ranking.model.RankingEntry; import com.loopers.domain.ranking.repository.RankingRepository; +import com.loopers.support.util.RankingPeriodKeyFactory; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.LocalDate; import java.util.List; @RequiredArgsConstructor @@ -12,13 +15,15 @@ public class RankingService { private final RankingRepository rankingRepository; - public List getTopRankings(String date, int page, int size) { + public List getTopRankings(LocalDate date, int page, int size) { int offset = (page - 1) * size; - return rankingRepository.getTopRankings(date, offset, size); + return rankingRepository.getTopRankings(RankingPeriodKeyFactory.toDailyKey(date), offset, size).stream() + .map(entry -> new RankingEntry(entry.productId(), entry.score(), entry.rank())) + .toList(); } - public long getTotalCount(String date) { - return rankingRepository.getTotalCount(date); + public long getTotalCount(LocalDate date) { + return rankingRepository.getTotalCount(RankingPeriodKeyFactory.toDailyKey(date)); } public RankingRepository.RankingEntry getProductRanking(String date, Long productId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/WeeklyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/WeeklyRankingService.java new file mode 100644 index 0000000000..8938e76d09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/service/WeeklyRankingService.java @@ -0,0 +1,25 @@ +package com.loopers.domain.ranking.service; + +import com.loopers.domain.ranking.model.RankingSlice; +import com.loopers.domain.ranking.repository.WeeklyRankingRepository; +import com.loopers.support.util.RankingPeriodKeyFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class WeeklyRankingService { + + private final WeeklyRankingRepository repository; + + public RankingSlice getRankings(LocalDate date, int page, int size) { + int offset = (page - 1) * size; + String periodKey = RankingPeriodKeyFactory.toWeeklyKey(date); + return new RankingSlice( + repository.getTopRankings(periodKey, offset, size), + repository.getTotalCount(periodKey) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/MonthlyRankingEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/MonthlyRankingEntity.java new file mode 100644 index 0000000000..8a4d75979e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/MonthlyRankingEntity.java @@ -0,0 +1,50 @@ +package com.loopers.infrastructure.ranking.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_monthly") +@IdClass(MonthlyRankingEntity.MonthlyRankingId.class) +public class MonthlyRankingEntity { + + @Id + @Column(nullable = false, length = 16) + private String periodKey; + + @Id + @Column(nullable = false) + private int rankNo; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public record MonthlyRankingId(String periodKey, int rankNo) implements Serializable { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/WeeklyRankingEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/WeeklyRankingEntity.java new file mode 100644 index 0000000000..563e295988 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/WeeklyRankingEntity.java @@ -0,0 +1,50 @@ +package com.loopers.infrastructure.ranking.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_weekly") +@IdClass(WeeklyRankingEntity.WeeklyRankingId.class) +public class WeeklyRankingEntity { + + @Id + @Column(nullable = false, length = 16) + private String periodKey; + + @Id + @Column(nullable = false) + private int rankNo; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public record WeeklyRankingId(String periodKey, int rankNo) implements Serializable { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/MonthlyRankingJpaRepository.java new file mode 100644 index 0000000000..0e01a14a3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/MonthlyRankingJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.ranking.repository; + +import com.loopers.infrastructure.ranking.entity.MonthlyRankingEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + List findByPeriodKeyOrderByRankNoAsc(String periodKey, Pageable pageable); + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/MonthlyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/MonthlyRankingRepositoryImpl.java new file mode 100644 index 0000000000..dfbf400e3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/MonthlyRankingRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.ranking.repository; + +import com.loopers.domain.ranking.model.RankingEntry; +import com.loopers.domain.ranking.repository.MonthlyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { + + private final MonthlyRankingJpaRepository repository; + + @Override + public List getTopRankings(String periodKey, int offset, int size) { + int page = offset / size; + return repository.findByPeriodKeyOrderByRankNoAsc(periodKey, PageRequest.of(page, size)).stream() + .map(entity -> new RankingEntry(entity.getProductId(), entity.getScore(), entity.getRankNo())) + .toList(); + } + + @Override + public long getTotalCount(String periodKey) { + return repository.countByPeriodKey(periodKey); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/WeeklyRankingJpaRepository.java new file mode 100644 index 0000000000..c885cdc195 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/WeeklyRankingJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.ranking.repository; + +import com.loopers.infrastructure.ranking.entity.WeeklyRankingEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + List findByPeriodKeyOrderByRankNoAsc(String periodKey, Pageable pageable); + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/WeeklyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/WeeklyRankingRepositoryImpl.java new file mode 100644 index 0000000000..c5185664f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/repository/WeeklyRankingRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.ranking.repository; + +import com.loopers.domain.ranking.model.RankingEntry; +import com.loopers.domain.ranking.repository.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { + + private final WeeklyRankingJpaRepository repository; + + @Override + public List getTopRankings(String periodKey, int offset, int size) { + int page = offset / size; + return repository.findByPeriodKeyOrderByRankNoAsc(periodKey, PageRequest.of(page, size)).stream() + .map(entity -> new RankingEntry(entity.getProductId(), entity.getScore(), entity.getRankNo())) + .toList(); + } + + @Override + public long getTotalCount(String periodKey) { + return repository.countByPeriodKey(periodKey); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index 73bcb8a4c0..9873e96109 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -9,8 +9,9 @@ @Tag(name = "Ranking V1 API", description = "상품 랭킹 조회 API 입니다.") public interface RankingV1ApiSpec { - @Operation(summary = "일간 랭킹 조회", description = "일간 상품 랭킹을 조회합니다.") + @Operation(summary = "상품 랭킹 조회", description = "일간, 주간, 월간 상품 랭킹을 조회합니다.") ApiResponse getRankings( + @Parameter(description = "조회 기간 (daily|weekly|monthly)") String period, @Parameter(description = "조회 날짜 (yyyyMMdd)", required = true) String date, @Parameter(description = "페이지 크기") int size, @Parameter(description = "페이지 번호 (1부터)") int page 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 38629ccb72..daeaf5bd9c 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 @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.ranking; import com.loopers.application.ranking.RankingFacade; +import com.loopers.domain.ranking.model.RankingQuery; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.ranking.dto.FindRankingListApiResDto; import lombok.RequiredArgsConstructor; @@ -18,13 +19,10 @@ public class RankingV1Controller implements RankingV1ApiSpec { @Override @GetMapping - public ApiResponse getRankings( - @RequestParam String date, - @RequestParam(defaultValue = "20") int size, - @RequestParam(defaultValue = "1") int page - ) { - return ApiResponse.success( - FindRankingListApiResDto.from(rankingFacade.getRankings(date, page, size)) - ); + public ApiResponse getRankings(@RequestParam(defaultValue = "daily") String period, + @RequestParam String date, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "1") int page) { + return ApiResponse.success(FindRankingListApiResDto.from(rankingFacade.getRankings(RankingQuery.of(period, date, page, size)))); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/util/RankingPeriodKeyFactory.java b/apps/commerce-api/src/main/java/com/loopers/support/util/RankingPeriodKeyFactory.java new file mode 100644 index 0000000000..bdeadff057 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/util/RankingPeriodKeyFactory.java @@ -0,0 +1,26 @@ +package com.loopers.support.util; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; + +public final class RankingPeriodKeyFactory { + + private RankingPeriodKeyFactory() { + } + + public static String toDailyKey(LocalDate date) { + return date.format(DateTimeFormatter.BASIC_ISO_DATE); + } + + public static String toWeeklyKey(LocalDate date) { + WeekFields weekFields = WeekFields.ISO; + int weekBasedYear = date.get(weekFields.weekBasedYear()); + int week = date.get(weekFields.weekOfWeekBasedYear()); + return "%d-W%02d".formatted(weekBasedYear, week); + } + + public static String toMonthlyKey(LocalDate date) { + return date.format(DateTimeFormatter.ofPattern("yyyy-MM")); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index 972d13958d..09eb58510f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -13,6 +13,7 @@ import com.loopers.domain.product.vo.DisplayStatus; import com.loopers.domain.ranking.repository.RankingRepository; import com.loopers.domain.ranking.service.RankingService; +import com.loopers.interfaces.api.product.dto.FindProductApiReqDto; import com.loopers.support.enums.SortFilter; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -28,6 +29,8 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import java.time.LocalDate; import java.util.List; @@ -68,6 +71,12 @@ class ProductFacadeTest { @Mock private RankingService rankingService; + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + private static Member createTestMember() { return Member.reconstruct(1L, "testuser", "encodedPw", "홍길동", LocalDate.of(1990, 1, 1), "test@test.com"); } @@ -177,7 +186,7 @@ void throwsException_whenProductNotFound() { .thenThrow(new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); // act & assert - assertThatThrownBy(() -> productFacade.findProduct(null, null, 999L)) + assertThatThrownBy(() -> productFacade.findProduct(new FindProductApiReqDto(null, null, 999L, "127.0.0.1"))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); } @@ -188,9 +197,12 @@ void returnsProduct_withoutLogin() { // arrange ProductItem item = createTestProductItem(false); when(productService.findProductDetail(1L)).thenReturn(item); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(any(), any(), any())).thenReturn(true); + when(rankingService.getProductRank(any(), eq(1L))).thenReturn(null); // act - FindProductResDto result = productFacade.findProduct(null, null, 1L); + FindProductResDto result = productFacade.findProduct(new FindProductApiReqDto(null, null, 1L, "127.0.0.1")); // assert assertAll( @@ -211,9 +223,12 @@ void returnsProduct_withFavoriteInfo_whenLoggedIn() { when(productService.findProductDetail(1L)).thenReturn(item); when(memberService.findMember("testuser", "password")).thenReturn(member); when(favoriteService.existsByMemberIdAndProductId(1L, 1L)).thenReturn(true); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(any(), any(), any())).thenReturn(true); + when(rankingService.getProductRank(any(), eq(1L))).thenReturn(null); // act - FindProductResDto result = productFacade.findProduct("testuser", "password", 1L); + FindProductResDto result = productFacade.findProduct(new FindProductApiReqDto("testuser", "password", 1L, "127.0.0.1")); // assert assertAll( diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingPeriodTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingPeriodTest.java new file mode 100644 index 0000000000..96238061c7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingPeriodTest.java @@ -0,0 +1,34 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.ranking.model.RankingPeriod; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RankingPeriodTest { + + @DisplayName("period 문자열을 대소문자 구분 없이 enum으로 변환한다") + @Test + void from() { + assertThat(RankingPeriod.from("daily")).isEqualTo(RankingPeriod.DAILY); + assertThat(RankingPeriod.from("WEEKLY")).isEqualTo(RankingPeriod.WEEKLY); + assertThat(RankingPeriod.from("Monthly")).isEqualTo(RankingPeriod.MONTHLY); + } + + @DisplayName("period가 비어있으면 daily를 기본값으로 사용한다") + @Test + void defaultDaily() { + assertThat(RankingPeriod.from(null)).isEqualTo(RankingPeriod.DAILY); + assertThat(RankingPeriod.from("")).isEqualTo(RankingPeriod.DAILY); + } + + @DisplayName("지원하지 않는 period는 예외가 발생한다") + @Test + void invalid() { + assertThatThrownBy(() -> RankingPeriod.from("yearly")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java new file mode 100644 index 0000000000..2a83f93e46 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java @@ -0,0 +1,308 @@ +package com.loopers.interfaces.api; + +import com.loopers.CommerceApiApplication; +import com.loopers.domain.brand.model.Brand; +import com.loopers.domain.brand.model.BrandCommand; +import com.loopers.domain.product.model.Product; +import com.loopers.domain.product.model.ProductCommand; +import com.loopers.infrastructure.brand.entity.BrandEntity; +import com.loopers.infrastructure.brand.repository.BrandJpaRepository; +import com.loopers.infrastructure.product.entity.ProductEntity; +import com.loopers.infrastructure.product.repository.ProductJpaRepository; +import com.loopers.interfaces.api.ranking.dto.FindRankingListApiResDto; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.HashMap; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest( + classes = CommerceApiApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.autoconfigure.exclude=", + "spring.kafka.listener.auto-startup=false" + } +) +@ActiveProfiles("test") +@Import({ + MySqlTestContainersConfig.class, + RedisTestContainersConfig.class, + RankingV1ApiE2ETest.TestJpaConfig.class +}) +class RankingV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/rankings"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final RedisTemplate redisTemplate; + private final JdbcTemplate jdbcTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + + @Autowired + RankingV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + RedisTemplate redisTemplate, + JdbcTemplate jdbcTemplate, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.redisTemplate = redisTemplate; + this.jdbcTemplate = jdbcTemplate; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + } + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); + } + + private ProductEntity saveProduct(String brandName, String productName, int price) { + Brand brand = Brand.create(new BrandCommand.Create(brandName, "랭킹 테스트 브랜드")); + BrandEntity brandEntity = brandJpaRepository.save(BrandEntity.toEntity(brand)); + Product product = Product.create(brandEntity.getId(), new ProductCommand.Create(brandEntity.getId(), productName, price, 100)); + return productJpaRepository.save(ProductEntity.toEntity(product)); + } + + @DisplayName("GET /api/v1/rankings") + @Nested + class GetRankings { + + @DisplayName("period=daily 조회 시 Redis 일간 랭킹을 반환한다") + @Test + void dailyRankings() { + ProductEntity first = saveProduct("나이키", "에어맥스", 120000); + ProductEntity second = saveProduct("아디다스", "삼바", 90000); + + redisTemplate.opsForZSet().add("ranking:all:20260415", String.valueOf(first.getId()), 99.9d); + redisTemplate.opsForZSet().add("ranking:all:20260415", String.valueOf(second.getId()), 88.8d); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=daily&date=20260415&size=10&page=1", + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference<>() { + } + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).hasSize(2), + () -> assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(first.getId()) + ); + } + + @DisplayName("period=weekly 조회 시 weekly MV를 반환한다") + @Test + void weeklyRankings() { + ProductEntity first = saveProduct("나이키", "에어맥스", 120000); + ProductEntity second = saveProduct("아디다스", "삼바", 90000); + + insertWeeklyRanking("2026-W16", 1, first.getId(), 120.0d, 10, 20, 30); + insertWeeklyRanking("2026-W16", 2, second.getId(), 110.0d, 8, 18, 28); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=weekly&date=20260415&size=10&page=1", + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference<>() { + } + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).hasSize(2), + () -> assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(first.getId()) + ); + } + + @DisplayName("period=monthly 조회 시 monthly MV를 반환한다") + @Test + void monthlyRankings() { + ProductEntity first = saveProduct("나이키", "에어맥스", 120000); + ProductEntity second = saveProduct("아디다스", "삼바", 90000); + + insertMonthlyRanking("2026-04", 1, first.getId(), 220.0d, 20, 30, 40); + insertMonthlyRanking("2026-04", 2, second.getId(), 210.0d, 18, 28, 38); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=monthly&date=20260415&size=10&page=1", + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference<>() { + } + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).hasSize(2), + () -> assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(first.getId()) + ); + } + + @DisplayName("지원하지 않는 period는 400 BAD_REQUEST를 반환한다") + @Test + void invalidPeriod() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=yearly&date=20260415&size=10&page=1", + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("page가 1 미만이면 400 BAD_REQUEST를 반환한다") + @Test + void invalidPage() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=weekly&date=20260415&size=10&page=0", + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("size가 1 미만이면 400 BAD_REQUEST를 반환한다") + @Test + void invalidSize() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=weekly&date=20260415&size=0&page=1", + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("데이터가 없으면 빈 rankings를 반환한다") + @Test + void emptyRankings() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=weekly&date=20260415&size=10&page=1", + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference<>() { + } + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).isEmpty(), + () -> assertThat(response.getBody().data().totalCount()).isZero() + ); + } + } + + private void insertWeeklyRanking(String periodKey, int rankNo, Long productId, double score, long viewCount, long likeCount, long orderCount) { + jdbcTemplate.update(""" + INSERT INTO mv_product_rank_weekly + (period_key, rank_no, product_id, score, view_count, like_count, order_count, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + periodKey, rankNo, productId, score, viewCount, likeCount, orderCount, LocalDateTime.now()); + } + + private void insertMonthlyRanking(String periodKey, int rankNo, Long productId, double score, long viewCount, long likeCount, long orderCount) { + jdbcTemplate.update(""" + INSERT INTO mv_product_rank_monthly + (period_key, rank_no, product_id, score, view_count, like_count, order_count, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + periodKey, rankNo, productId, score, viewCount, likeCount, orderCount, LocalDateTime.now()); + } + + @TestConfiguration + @EntityScan("com.loopers") + @EnableJpaRepositories(basePackages = {"com.loopers.infrastructure", "com.loopers.batch.infrastructure"}) + static class TestJpaConfig { + + @Bean(name = "entityManagerFactory") + LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); + vendorAdapter.setGenerateDdl(true); + vendorAdapter.setShowSql(true); + + LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); + factoryBean.setDataSource(dataSource); + factoryBean.setPackagesToScan("com.loopers"); + factoryBean.setJpaVendorAdapter(vendorAdapter); + + HashMap properties = new HashMap<>(); + properties.put("hibernate.hbm2ddl.auto", "create"); + properties.put("hibernate.show_sql", "true"); + properties.put("hibernate.format_sql", "true"); + properties.put("hibernate.jdbc.time_zone", "UTC"); + properties.put("hibernate.default_batch_fetch_size", "100"); + properties.put("hibernate.physical_naming_strategy", "org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy"); + factoryBean.setJpaPropertyMap(properties); + return factoryBean; + } + + @Bean + PlatformTransactionManager transactionManager(jakarta.persistence.EntityManagerFactory entityManagerFactory) { + return new JpaTransactionManager(entityManagerFactory); + } + + @Bean + JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/AggregatedRankingRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/AggregatedRankingRow.java new file mode 100644 index 0000000000..c65e127868 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/AggregatedRankingRow.java @@ -0,0 +1,11 @@ +package com.loopers.batch.domain.ranking; + +public record AggregatedRankingRow( + int rankNo, + Long productId, + long viewCount, + long likeCount, + long orderCount, + double score +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriodKeyFactory.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriodKeyFactory.java new file mode 100644 index 0000000000..ef728ddb10 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriodKeyFactory.java @@ -0,0 +1,38 @@ +package com.loopers.batch.domain.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; + +public final class RankingPeriodKeyFactory { + + private RankingPeriodKeyFactory() { + } + + public static String toWeeklyKey(LocalDate date) { + WeekFields weekFields = WeekFields.ISO; + int weekBasedYear = date.get(weekFields.weekBasedYear()); + int week = date.get(weekFields.weekOfWeekBasedYear()); + return "%d-W%02d".formatted(weekBasedYear, week); + } + + public static String toMonthlyKey(LocalDate date) { + return date.format(DateTimeFormatter.ofPattern("yyyy-MM")); + } + + public static LocalDate weeklyStart(LocalDate date) { + return date.with(WeekFields.ISO.dayOfWeek(), 1); + } + + public static LocalDate weeklyEnd(LocalDate date) { + return weeklyStart(date).plusDays(6); + } + + public static LocalDate monthlyStart(LocalDate date) { + return date.withDayOfMonth(1); + } + + public static LocalDate monthlyEnd(LocalDate date) { + return date.withDayOfMonth(date.lengthOfMonth()); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/metrics/entity/ProductMetricsDailyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/metrics/entity/ProductMetricsDailyEntity.java new file mode 100644 index 0000000000..08e5223aac --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/metrics/entity/ProductMetricsDailyEntity.java @@ -0,0 +1,51 @@ +package com.loopers.batch.infrastructure.metrics.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "product_metrics_daily") +@IdClass(ProductMetricsDailyEntity.ProductMetricsDailyId.class) +public class ProductMetricsDailyEntity { + + @Id + @Column(nullable = false) + private LocalDate metricDate; + + @Id + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public record ProductMetricsDailyId(LocalDate metricDate, Long productId) implements Serializable { + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/entity/MonthlyRankingEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/entity/MonthlyRankingEntity.java new file mode 100644 index 0000000000..ccfe959aa6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/entity/MonthlyRankingEntity.java @@ -0,0 +1,64 @@ +package com.loopers.batch.infrastructure.ranking.entity; + +import com.loopers.batch.domain.ranking.AggregatedRankingRow; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_monthly") +@IdClass(MonthlyRankingEntity.MonthlyRankingId.class) +public class MonthlyRankingEntity { + + @Id + @Column(nullable = false, length = 16) + private String periodKey; + + @Id + @Column(nullable = false) + private int rankNo; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public static MonthlyRankingEntity of(String periodKey, AggregatedRankingRow row) { + MonthlyRankingEntity entity = new MonthlyRankingEntity(); + entity.periodKey = periodKey; + entity.rankNo = row.rankNo(); + entity.productId = row.productId(); + entity.score = row.score(); + entity.viewCount = row.viewCount(); + entity.likeCount = row.likeCount(); + entity.orderCount = row.orderCount(); + entity.updatedAt = LocalDateTime.now(); + return entity; + } + + public record MonthlyRankingId(String periodKey, int rankNo) implements Serializable { + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/entity/WeeklyRankingEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/entity/WeeklyRankingEntity.java new file mode 100644 index 0000000000..b339fc7ad4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/entity/WeeklyRankingEntity.java @@ -0,0 +1,64 @@ +package com.loopers.batch.infrastructure.ranking.entity; + +import com.loopers.batch.domain.ranking.AggregatedRankingRow; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_weekly") +@IdClass(WeeklyRankingEntity.WeeklyRankingId.class) +public class WeeklyRankingEntity { + + @Id + @Column(nullable = false, length = 16) + private String periodKey; + + @Id + @Column(nullable = false) + private int rankNo; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public static WeeklyRankingEntity of(String periodKey, AggregatedRankingRow row) { + WeeklyRankingEntity entity = new WeeklyRankingEntity(); + entity.periodKey = periodKey; + entity.rankNo = row.rankNo(); + entity.productId = row.productId(); + entity.score = row.score(); + entity.viewCount = row.viewCount(); + entity.likeCount = row.likeCount(); + entity.orderCount = row.orderCount(); + entity.updatedAt = LocalDateTime.now(); + return entity; + } + + public record WeeklyRankingId(String periodKey, int rankNo) implements Serializable { + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/repository/MonthlyRankingJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/repository/MonthlyRankingJpaRepository.java new file mode 100644 index 0000000000..508deb84fb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/repository/MonthlyRankingJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.batch.infrastructure.ranking.repository; + +import com.loopers.batch.infrastructure.ranking.entity.MonthlyRankingEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + void deleteByPeriodKey(String periodKey); + + List findByPeriodKeyOrderByRankNoAsc(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/repository/WeeklyRankingJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/repository/WeeklyRankingJpaRepository.java new file mode 100644 index 0000000000..ab1b2e2620 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/ranking/repository/WeeklyRankingJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.batch.infrastructure.ranking.repository; + +import com.loopers.batch.infrastructure.ranking.entity.WeeklyRankingEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + void deleteByPeriodKey(String periodKey); + + List findByPeriodKeyOrderByRankNoAsc(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..a5143409b9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,51 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.tasklet.RefreshMonthlyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +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.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + public static final int TOP_N = 100; + private static final String REFRESH_STEP = "refreshMonthlyRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final RefreshMonthlyRankingTasklet refreshMonthlyRankingTasklet; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(refreshMonthlyRankingStep()) + .listener(jobListener) + .build(); + } + + @Bean(REFRESH_STEP) + @JobScope + public Step refreshMonthlyRankingStep() { + return new StepBuilder(REFRESH_STEP, jobRepository) + .tasklet(refreshMonthlyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..154dd7294f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,51 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.tasklet.RefreshWeeklyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +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.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + public static final int TOP_N = 100; + private static final String REFRESH_STEP = "refreshWeeklyRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final RefreshWeeklyRankingTasklet refreshWeeklyRankingTasklet; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(refreshWeeklyRankingStep()) + .listener(jobListener) + .build(); + } + + @Bean(REFRESH_STEP) + @JobScope + public Step refreshWeeklyRankingStep() { + return new StepBuilder(REFRESH_STEP, jobRepository) + .tasklet(refreshWeeklyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/tasklet/RefreshMonthlyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/tasklet/RefreshMonthlyRankingTasklet.java new file mode 100644 index 0000000000..360b3e8c55 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/tasklet/RefreshMonthlyRankingTasklet.java @@ -0,0 +1,74 @@ +package com.loopers.batch.job.ranking.tasklet; + +import com.loopers.batch.domain.ranking.AggregatedRankingRow; +import com.loopers.batch.domain.ranking.RankingPeriodKeyFactory; +import com.loopers.batch.infrastructure.ranking.entity.MonthlyRankingEntity; +import com.loopers.batch.infrastructure.ranking.repository.MonthlyRankingJpaRepository; +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@StepScope +@RequiredArgsConstructor +@Component +public class RefreshMonthlyRankingTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + private final MonthlyRankingJpaRepository repository; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + LocalDate date = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + LocalDate startDate = RankingPeriodKeyFactory.monthlyStart(date); + LocalDate endDate = RankingPeriodKeyFactory.monthlyEnd(date); + String periodKey = RankingPeriodKeyFactory.toMonthlyKey(date); + + List rows = jdbcTemplate.query( + """ + select + product_id, + sum(view_count) as view_count, + sum(like_count) as like_count, + sum(order_count) as order_count, + sum(score) as score + from product_metrics_daily + where metric_date between ? and ? + group by product_id + order by score desc, product_id asc + limit %d + """.formatted(MonthlyRankingJobConfig.TOP_N), + (rs, rowNum) -> new AggregatedRankingRow( + rowNum + 1, + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("order_count"), + rs.getDouble("score") + ), + startDate, + endDate + ); + + repository.deleteByPeriodKey(periodKey); + if (!rows.isEmpty()) { + repository.saveAll(rows.stream() + .map(row -> MonthlyRankingEntity.of(periodKey, row)) + .toList()); + } + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/tasklet/RefreshWeeklyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/tasklet/RefreshWeeklyRankingTasklet.java new file mode 100644 index 0000000000..6cb5969712 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/tasklet/RefreshWeeklyRankingTasklet.java @@ -0,0 +1,74 @@ +package com.loopers.batch.job.ranking.tasklet; + +import com.loopers.batch.domain.ranking.AggregatedRankingRow; +import com.loopers.batch.domain.ranking.RankingPeriodKeyFactory; +import com.loopers.batch.infrastructure.ranking.entity.WeeklyRankingEntity; +import com.loopers.batch.infrastructure.ranking.repository.WeeklyRankingJpaRepository; +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@StepScope +@RequiredArgsConstructor +@Component +public class RefreshWeeklyRankingTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + private final WeeklyRankingJpaRepository repository; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + LocalDate date = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + LocalDate startDate = RankingPeriodKeyFactory.weeklyStart(date); + LocalDate endDate = RankingPeriodKeyFactory.weeklyEnd(date); + String periodKey = RankingPeriodKeyFactory.toWeeklyKey(date); + + List rows = jdbcTemplate.query( + """ + select + product_id, + sum(view_count) as view_count, + sum(like_count) as like_count, + sum(order_count) as order_count, + sum(score) as score + from product_metrics_daily + where metric_date between ? and ? + group by product_id + order by score desc, product_id asc + limit %d + """.formatted(WeeklyRankingJobConfig.TOP_N), + (rs, rowNum) -> new AggregatedRankingRow( + rowNum + 1, + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("order_count"), + rs.getDouble("score") + ), + startDate, + endDate + ); + + repository.deleteByPeriodKey(periodKey); + if (!rows.isEmpty()) { + repository.saveAll(rows.stream() + .map(row -> WeeklyRankingEntity.of(periodKey, row)) + .toList()); + } + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingPeriodKeyFactoryTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingPeriodKeyFactoryTest.java new file mode 100644 index 0000000000..42f58ccddb --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingPeriodKeyFactoryTest.java @@ -0,0 +1,25 @@ +package com.loopers.domain.ranking; + +import com.loopers.batch.domain.ranking.RankingPeriodKeyFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class RankingPeriodKeyFactoryTest { + + @DisplayName("ISO week 경계를 기준으로 weekly key를 계산한다") + @Test + void weeklyKey() { + assertThat(RankingPeriodKeyFactory.toWeeklyKey(LocalDate.of(2025, 12, 29))).isEqualTo("2026-W01"); + assertThat(RankingPeriodKeyFactory.toWeeklyKey(LocalDate.of(2026, 4, 15))).isEqualTo("2026-W16"); + } + + @DisplayName("monthly key를 계산한다") + @Test + void monthlyKey() { + assertThat(RankingPeriodKeyFactory.toMonthlyKey(LocalDate.of(2026, 4, 15))).isEqualTo("2026-04"); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java new file mode 100644 index 0000000000..057a331221 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,93 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.infrastructure.ranking.repository.MonthlyRankingJpaRepository; +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = { + "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class MonthlyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + + @BeforeEach + void setUp() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics_daily"); + } + + @DisplayName("대상 month의 top100을 적재한다") + @Test + void success() throws Exception { + seedMonth(LocalDate.of(2026, 4, 15)); + + jobLauncherTestUtils.setJob(job); + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addString("targetDate", "20260415") + .toJobParameters()); + + assertThat(jobExecution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + + var rows = monthlyRankingJpaRepository.findByPeriodKeyOrderByRankNoAsc("2026-04"); + assertThat(rows).hasSize(100); + assertThat(rows.get(0).getRankNo()).isEqualTo(1); + assertThat(rows.get(99).getRankNo()).isEqualTo(100); + assertThat(rows.get(0).getScore()).isGreaterThanOrEqualTo(rows.get(1).getScore()); + } + + private void seedMonth(LocalDate baseDate) { + List productIds = IntStream.rangeClosed(1, 150).boxed().toList(); + for (LocalDate date = baseDate.withDayOfMonth(1); !date.isAfter(baseDate.withDayOfMonth(baseDate.lengthOfMonth())); date = date.plusDays(1)) { + for (Integer productId : productIds) { + long viewCount = 200 - productId; + long likeCount = 150 - productId; + long orderCount = 100 - productId; + double score = (viewCount * 0.1d) + (likeCount * 0.2d) + (orderCount * 0.7d); + jdbcTemplate.update(""" + INSERT INTO product_metrics_daily + (metric_date, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + date, productId.longValue(), viewCount, likeCount, orderCount, score, + LocalDateTime.now(), LocalDateTime.now()); + } + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java new file mode 100644 index 0000000000..ede0f2c686 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,94 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.infrastructure.ranking.repository.WeeklyRankingJpaRepository; +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = { + "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + @BeforeEach + void setUp() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM product_metrics_daily"); + } + + @DisplayName("대상 ISO week의 top100을 적재한다") + @Test + void success() throws Exception { + seedWeek(LocalDate.of(2026, 4, 15)); + + jobLauncherTestUtils.setJob(job); + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addString("targetDate", "20260415") + .toJobParameters()); + + assertThat(jobExecution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + + var rows = weeklyRankingJpaRepository.findByPeriodKeyOrderByRankNoAsc("2026-W16"); + assertThat(rows).hasSize(100); + assertThat(rows.get(0).getRankNo()).isEqualTo(1); + assertThat(rows.get(99).getRankNo()).isEqualTo(100); + assertThat(rows.get(0).getScore()).isGreaterThanOrEqualTo(rows.get(1).getScore()); + } + + private void seedWeek(LocalDate baseDate) { + LocalDate start = baseDate.minusDays(2); + List productIds = IntStream.rangeClosed(1, 150).boxed().toList(); + for (LocalDate date = start.minusDays(2); !date.isAfter(start.plusDays(4)); date = date.plusDays(1)) { + for (Integer productId : productIds) { + long viewCount = 200 - productId; + long likeCount = 150 - productId; + long orderCount = 100 - productId; + double score = (viewCount * 0.1d) + (likeCount * 0.2d) + (orderCount * 0.7d); + jdbcTemplate.update(""" + INSERT INTO product_metrics_daily + (metric_date, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + date, productId.longValue(), viewCount, likeCount, orderCount, score, + LocalDateTime.now(), LocalDateTime.now()); + } + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/ProductMetricsDailyRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/ProductMetricsDailyRepository.java new file mode 100644 index 0000000000..818c49ae02 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/ProductMetricsDailyRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.metrics.repository; + +import java.time.LocalDate; + +public interface ProductMetricsDailyRepository { + + void incrementViewCount(LocalDate metricDate, Long productId, double scoreDelta); + + void incrementLikeCount(LocalDate metricDate, Long productId, double scoreDelta); + + void decrementLikeCount(LocalDate metricDate, Long productId, double scoreDelta); + + void incrementOrderCount(LocalDate metricDate, Long productId, double scoreDelta); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/ProductMetricsRepository.java new file mode 100644 index 0000000000..eb837dea6e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/ProductMetricsRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.metrics.repository; + +import com.loopers.infrastructure.metrics.entity.ProductMetricsEntity; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsRepository { + + Optional findById(Long productId); + + ProductMetricsEntity save(ProductMetricsEntity metrics); + + List findByUpdatedAtAfter(LocalDateTime since); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/service/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/service/MetricsService.java index da910cad48..a49cf35b4c 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/service/MetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/service/MetricsService.java @@ -1,11 +1,14 @@ package com.loopers.domain.metrics.service; +import com.loopers.domain.metrics.repository.ProductMetricsDailyRepository; +import com.loopers.domain.metrics.repository.ProductMetricsRepository; import com.loopers.domain.ranking.model.ProductMetrics; import com.loopers.infrastructure.metrics.entity.ProductMetricsEntity; -import com.loopers.infrastructure.metrics.repository.ProductMetricsJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -13,36 +16,43 @@ @Component public class MetricsService { - private final ProductMetricsJpaRepository metricsRepository; + private static final double VIEW_WEIGHT = 0.1; + private static final double LIKE_WEIGHT = 0.2; + private static final double ORDER_WEIGHT = 0.7; - public void incrementViewCount(Long productId, long version) { + private final ProductMetricsRepository metricsRepository; + private final ProductMetricsDailyRepository dailyMetricsRepository; + + @Transactional + public void incrementViewCount(Long productId) { ProductMetricsEntity metrics = findOrCreate(productId); - if (version <= metrics.getVersion()) return; metrics.incrementViewCount(); - metrics.updateVersion(version); metricsRepository.save(metrics); + dailyMetricsRepository.incrementViewCount(LocalDate.now(), productId, VIEW_WEIGHT); } - public void incrementLikeCount(Long productId, long version) { + @Transactional + public void incrementLikeCount(Long productId) { ProductMetricsEntity metrics = findOrCreate(productId); - if (version <= metrics.getVersion()) return; metrics.incrementLikeCount(); - metrics.updateVersion(version); metricsRepository.save(metrics); + dailyMetricsRepository.incrementLikeCount(LocalDate.now(), productId, LIKE_WEIGHT); } - public void decrementLikeCount(Long productId, long version) { + @Transactional + public void decrementLikeCount(Long productId) { ProductMetricsEntity metrics = findOrCreate(productId); - if (version <= metrics.getVersion()) return; metrics.decrementLikeCount(); - metrics.updateVersion(version); metricsRepository.save(metrics); + dailyMetricsRepository.decrementLikeCount(LocalDate.now(), productId, -LIKE_WEIGHT); } + @Transactional public void incrementOrderCount(Long productId) { ProductMetricsEntity metrics = findOrCreate(productId); metrics.incrementOrderCount(); metricsRepository.save(metrics); + dailyMetricsRepository.incrementOrderCount(LocalDate.now(), productId, ORDER_WEIGHT); } public List findChangedAfter(LocalDateTime since) { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/entity/ProductMetricsDailyEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/entity/ProductMetricsDailyEntity.java new file mode 100644 index 0000000000..fe76c31c84 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/entity/ProductMetricsDailyEntity.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.metrics.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "product_metrics_daily") +@IdClass(ProductMetricsDailyEntity.ProductMetricsDailyId.class) +public class ProductMetricsDailyEntity { + + @Id + @Column(nullable = false) + private LocalDate metricDate; + + @Id + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public record ProductMetricsDailyId(LocalDate metricDate, Long productId) implements Serializable { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsDailyJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsDailyJpaRepository.java new file mode 100644 index 0000000000..775719560c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsDailyJpaRepository.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure.metrics.repository; + +import com.loopers.infrastructure.metrics.entity.ProductMetricsDailyEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +public interface ProductMetricsDailyJpaRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO product_metrics_daily + (metric_date, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (:metricDate, :productId, 1, 0, 0, :scoreDelta, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + view_count = view_count + 1, + score = score + :scoreDelta, + updated_at = NOW(6) + """, nativeQuery = true) + void incrementViewCount(@Param("metricDate") LocalDate metricDate, + @Param("productId") Long productId, + @Param("scoreDelta") double scoreDelta); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics_daily + (metric_date, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (:metricDate, :productId, 0, 1, 0, :scoreDelta, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + like_count = like_count + 1, + score = score + :scoreDelta, + updated_at = NOW(6) + """, nativeQuery = true) + void incrementLikeCount(@Param("metricDate") LocalDate metricDate, + @Param("productId") Long productId, + @Param("scoreDelta") double scoreDelta); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics_daily + (metric_date, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (:metricDate, :productId, 0, 0, 0, 0, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + score = CASE WHEN like_count > 0 THEN score + :scoreDelta ELSE score END, + like_count = GREATEST(like_count - 1, 0), + updated_at = NOW(6) + """, nativeQuery = true) + void decrementLikeCount(@Param("metricDate") LocalDate metricDate, + @Param("productId") Long productId, + @Param("scoreDelta") double scoreDelta); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics_daily + (metric_date, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (:metricDate, :productId, 0, 0, 1, :scoreDelta, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + order_count = order_count + 1, + score = score + :scoreDelta, + updated_at = NOW(6) + """, nativeQuery = true) + void incrementOrderCount(@Param("metricDate") LocalDate metricDate, + @Param("productId") Long productId, + @Param("scoreDelta") double scoreDelta); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsDailyRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsDailyRepositoryImpl.java new file mode 100644 index 0000000000..49b815873b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsDailyRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.metrics.repository; + +import com.loopers.domain.metrics.repository.ProductMetricsDailyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class ProductMetricsDailyRepositoryImpl implements ProductMetricsDailyRepository { + + private final ProductMetricsDailyJpaRepository productMetricsDailyJpaRepository; + + @Override + public void incrementViewCount(LocalDate metricDate, Long productId, double scoreDelta) { + productMetricsDailyJpaRepository.incrementViewCount(metricDate, productId, scoreDelta); + } + + @Override + public void incrementLikeCount(LocalDate metricDate, Long productId, double scoreDelta) { + productMetricsDailyJpaRepository.incrementLikeCount(metricDate, productId, scoreDelta); + } + + @Override + public void decrementLikeCount(LocalDate metricDate, Long productId, double scoreDelta) { + productMetricsDailyJpaRepository.decrementLikeCount(metricDate, productId, scoreDelta); + } + + @Override + public void incrementOrderCount(LocalDate metricDate, Long productId, double scoreDelta) { + productMetricsDailyJpaRepository.incrementOrderCount(metricDate, productId, scoreDelta); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsRepositoryImpl.java new file mode 100644 index 0000000000..9dc2370957 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.metrics.repository; + +import com.loopers.domain.metrics.repository.ProductMetricsRepository; +import com.loopers.infrastructure.metrics.entity.ProductMetricsEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Override + public Optional findById(Long productId) { + return productMetricsJpaRepository.findById(productId); + } + + @Override + public ProductMetricsEntity save(ProductMetricsEntity metrics) { + return productMetricsJpaRepository.save(metrics); + } + + @Override + public List findByUpdatedAtAfter(LocalDateTime since) { + return productMetricsJpaRepository.findByUpdatedAtAfter(since); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java index b5e028f7d1..23488d7fc8 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -48,9 +48,9 @@ private void processRecord(ConsumerRecord record) { } switch (event.eventType()) { - case PRODUCT_VIEWED -> metricsService.incrementViewCount(event.productId(), event.version()); - case FAVORITE_ADDED -> metricsService.incrementLikeCount(event.productId(), event.version()); - case FAVORITE_REMOVED -> metricsService.decrementLikeCount(event.productId(), event.version()); + case PRODUCT_VIEWED -> metricsService.incrementViewCount(event.productId()); + case FAVORITE_ADDED -> metricsService.incrementLikeCount(event.productId()); + case FAVORITE_REMOVED -> metricsService.decrementLikeCount(event.productId()); default -> {} } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/service/MetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/service/MetricsServiceTest.java new file mode 100644 index 0000000000..8de976141c --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/service/MetricsServiceTest.java @@ -0,0 +1,80 @@ +package com.loopers.domain.metrics.service; + +import com.loopers.domain.metrics.repository.ProductMetricsDailyRepository; +import com.loopers.domain.metrics.repository.ProductMetricsRepository; +import com.loopers.infrastructure.metrics.entity.ProductMetricsEntity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MetricsServiceTest { + + @Mock + private ProductMetricsRepository metricsRepository; + + @Mock + private ProductMetricsDailyRepository dailyMetricsRepository; + + @InjectMocks + private MetricsService metricsService; + + @DisplayName("조회 수 증가") + @Nested + class IncrementViewCount { + + @DisplayName("기존 version 값과 무관하게 조회 수를 증가시킨다") + @Test + void incrementWithoutVersionGuard() { + ProductMetricsEntity metrics = ProductMetricsEntity.createNew(1L); + metrics.updateVersion(100L); + when(metricsRepository.findById(1L)).thenReturn(Optional.of(metrics)); + + metricsService.incrementViewCount(1L); + + assertThat(metrics.getViewCount()).isEqualTo(1L); + verify(metricsRepository).save(metrics); + verify(dailyMetricsRepository).incrementViewCount(any(), eq(1L), eq(0.1d)); + } + } + + @DisplayName("메트릭 조회") + @Nested + class FindChangedAfter { + + @DisplayName("변경된 엔티티를 도메인 모델로 변환한다") + @Test + void mapChangedMetrics() { + ProductMetricsEntity first = ProductMetricsEntity.createNew(1L); + first.incrementViewCount(); + ProductMetricsEntity second = ProductMetricsEntity.createNew(2L); + second.incrementLikeCount(); + + when(metricsRepository.findByUpdatedAtAfter(any(LocalDateTime.class))) + .thenReturn(List.of(first, second)); + + var result = metricsService.findChangedAfter(LocalDateTime.now().minusMinutes(5)); + + assertThat(result) + .extracting("productId", "viewCount", "likeCount", "orderCount") + .containsExactly( + org.assertj.core.groups.Tuple.tuple(1L, 1L, 0L, 0L), + org.assertj.core.groups.Tuple.tuple(2L, 0L, 1L, 0L) + ); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepositoryTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepositoryTest.java new file mode 100644 index 0000000000..1e451b37fa --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepositoryTest.java @@ -0,0 +1,75 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.infrastructure.metrics.repository.ProductMetricsDailyJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(properties = { + "spring.profiles.active=test", + "spring.kafka.listener.auto-startup=false" +}) +@Transactional +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +class ProductMetricsDailyJpaRepositoryTest { + + @Autowired + private ProductMetricsDailyJpaRepository productMetricsDailyJpaRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요 감소") + @Nested + class DecrementLikeCount { + + @DisplayName("좋아요 수가 1일 때 감소하면 score도 함께 차감된다") + @Test + void decrementScoreBeforeLikeCount() { + LocalDate metricDate = LocalDate.of(2026, 4, 15); + + productMetricsDailyJpaRepository.incrementLikeCount(metricDate, 1L, 0.2d); + productMetricsDailyJpaRepository.decrementLikeCount(metricDate, 1L, -0.2d); + + Long likeCount = jdbcTemplate.queryForObject( + "select like_count from product_metrics_daily where metric_date = ? and product_id = ?", + Long.class, + metricDate, + 1L + ); + Double score = jdbcTemplate.queryForObject( + "select score from product_metrics_daily where metric_date = ? and product_id = ?", + Double.class, + metricDate, + 1L + ); + + assertAll( + () -> assertThat(likeCount).isEqualTo(0L), + () -> assertThat(score).isEqualTo(0.0d) + ); + } + } +} From 3d5c8004ef90be98c12b4069a5ad6852fa1d391b Mon Sep 17 00:00:00 2001 From: dfdf0202 Date: Fri, 17 Apr 2026 14:38:15 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat=20:=2010=EC=A3=BC=20=EA=B3=BC=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k6/scripts/ranking-api-load-test.js | 71 +++++++++ scripts/batch/run-ranking-batch-benchmark.sh | 156 +++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 k6/scripts/ranking-api-load-test.js create mode 100755 scripts/batch/run-ranking-batch-benchmark.sh diff --git a/k6/scripts/ranking-api-load-test.js b/k6/scripts/ranking-api-load-test.js new file mode 100644 index 0000000000..e066e406ef --- /dev/null +++ b/k6/scripts/ranking-api-load-test.js @@ -0,0 +1,71 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + scenarios: { + daily_rankings: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 0 }, + ], + exec: 'dailyRankings', + }, + weekly_rankings: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 0 }, + ], + exec: 'weeklyRankings', + startTime: '10s', + }, + monthly_rankings: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 0 }, + ], + exec: 'monthlyRankings', + startTime: '20s', + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<500'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const TARGET_DATE = __ENV.TARGET_DATE || '20260415'; +const PAGE_SIZE = __ENV.PAGE_SIZE || '20'; + +function request(period) { + const url = `${BASE_URL}/api/v1/rankings?period=${period}&date=${TARGET_DATE}&size=${PAGE_SIZE}&page=1`; + const res = http.get(url); + + check(res, { + [`${period} status is 200`]: (r) => r.status === 200, + [`${period} has rankings array`]: (r) => { + const body = r.json(); + return !!body && !!body.data && Array.isArray(body.data.rankings); + }, + }); + + sleep(1); +} + +export function dailyRankings() { + request('daily'); +} + +export function weeklyRankings() { + request('weekly'); +} + +export function monthlyRankings() { + request('monthly'); +} diff --git a/scripts/batch/run-ranking-batch-benchmark.sh b/scripts/batch/run-ranking-batch-benchmark.sh new file mode 100755 index 0000000000..b02912a53f --- /dev/null +++ b/scripts/batch/run-ranking-batch-benchmark.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TARGET_DATE="${TARGET_DATE:-20260415}" +ROW_COUNT="${ROW_COUNT:-${PRODUCT_COUNT:-10000}}" +MYSQL_HOST="${MYSQL_HOST:-localhost}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_DB="${MYSQL_DB:-loopers}" +MYSQL_USER="${MYSQL_USER:-application}" +MYSQL_PWD="${MYSQL_PWD:-application}" +RESULT_FILE="${RESULT_FILE:-$ROOT_DIR/build/ranking-batch-benchmark-$(date +%Y%m%d-%H%M%S).md}" + +start_of_week() { + python3 - <<'PY' +from datetime import datetime, timedelta +import os +d = datetime.strptime(os.environ["TARGET_DATE"], "%Y%m%d").date() +print((d - timedelta(days=d.isoweekday()-1)).isoformat()) +PY +} + +start_of_month() { + python3 - <<'PY' +from datetime import datetime +import os +d = datetime.strptime(os.environ["TARGET_DATE"], "%Y%m%d").date() +print(d.replace(day=1).isoformat()) +PY +} + +run_mysql() { + mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PWD" "$MYSQL_DB" "$@" +} + +seed_metrics() { + local start_date="$1" + local end_date="$2" + run_mysql </tmp/${job_name}.log 2>&1) + end_ts=$(python3 - <<'PY' +import time +print(int(time.time() * 1000)) +PY +) + duration_ms=$((end_ts - start_ts)) + echo "$duration_ms" +} + +write_report() { + local weekly_ms="$1" + local monthly_ms="$2" + mkdir -p "$(dirname "$RESULT_FILE")" + cat > "$RESULT_FILE" </dev/null + echo "[6/6] Seeding TOP 100 products for API load test..." + seed_products + + write_report "$weekly_ms" "$monthly_ms" +} + +main "$@"