diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java index 8cb938c04..b0ec6aac8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java @@ -4,8 +4,15 @@ public class RankingCommand { - public record GetDailyRankingCommand( + public enum RankingType { + DAILY, + WEEKLY, + MONTHLY + } + + public record GetRankingCommand( LocalDate date, + RankingType type, int page, int size ) { 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 3c7235fc6..40851d615 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 @@ -5,6 +5,8 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.ranking.Ranking; import com.loopers.domain.ranking.RankingService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,12 +25,18 @@ public class RankingFacade { private final ProductService productService; private final BrandService brandService; - public RankingInfo getDailyRanking(RankingCommand.GetDailyRankingCommand command) { - List rankings = rankingService.getRanking( - command.date(), - command.page(), - command.size() - ); + public RankingInfo getRanking(RankingCommand.GetRankingCommand command) { + List rankings; + + if (command.type() == RankingCommand.RankingType.DAILY) { + rankings = rankingService.getDailyRanking(command.date(), command.page(), command.size()); + } else if (command.type() == RankingCommand.RankingType.WEEKLY) { + rankings = rankingService.getWeeklyRanking(command.date(), command.page(), command.size()); + } else if (command.type() == RankingCommand.RankingType.MONTHLY) { + rankings = rankingService.getMonthlyRanking(command.date(), command.page(), command.size()); + } else { + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 랭킹 타입입니다: " + command.type()); + } if (rankings.isEmpty()) { return new RankingInfo(List.of()); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 000000000..648bc4807 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,77 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "period_start_date", "period_end_date"}) + } +) +@Getter +public class MvProductRankMonthly extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(nullable = true) + private Integer ranking; + + @Column(nullable = false) + private Double score; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Builder + private MvProductRankMonthly(Long productId, Integer ranking, Double score, LocalDate periodStartDate, + LocalDate periodEndDate, Long likeCount, Long viewCount, Long salesCount) { + this.productId = productId; + this.ranking = ranking; + this.score = score; + this.periodStartDate = periodStartDate; + this.periodEndDate = periodEndDate; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + } + + public MvProductRankMonthly() { + } + + public static MvProductRankMonthly create(Long productId, Integer ranking, Double score, LocalDate periodStartDate, + LocalDate periodEndDate, Long likeCount, Long viewCount, Long salesCount) { + return MvProductRankMonthly.builder() + .productId(productId) + .ranking(ranking) + .score(score) + .periodStartDate(periodStartDate) + .periodEndDate(periodEndDate) + .likeCount(likeCount) + .viewCount(viewCount) + .salesCount(salesCount) + .build(); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyRepository.java new file mode 100644 index 000000000..9d25878ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankMonthlyRepository { + List findByPeriodOrderByRankingAsc(LocalDate periodStartDate, LocalDate periodEndDate, int page, int size); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 000000000..466570a25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,77 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "period_start_date", "period_end_date"}) + } +) +@Getter +public class MvProductRankWeekly extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(nullable = true) + private Integer ranking; + + @Column(nullable = false) + private Double score; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Builder + private MvProductRankWeekly(Long productId, Integer ranking, Double score, LocalDate periodStartDate, LocalDate periodEndDate, + Long likeCount, Long viewCount, Long salesCount) { + this.productId = productId; + this.ranking = ranking; + this.score = score; + this.periodStartDate = periodStartDate; + this.periodEndDate = periodEndDate; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + } + + public MvProductRankWeekly() { + } + + public static MvProductRankWeekly create(Long productId, Integer ranking, Double score, LocalDate periodStartDate, + LocalDate periodEndDate, Long likeCount, Long viewCount, Long salesCount) { + return MvProductRankWeekly.builder() + .productId(productId) + .ranking(ranking) + .score(score) + .periodStartDate(periodStartDate) + .periodEndDate(periodEndDate) + .likeCount(likeCount) + .viewCount(viewCount) + .salesCount(salesCount) + .build(); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyRepository.java new file mode 100644 index 000000000..c680cd98a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankWeeklyRepository { + List findByPeriodOrderByRankingAsc(LocalDate periodStartDate, LocalDate periodEndDate, int page, int size); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java new file mode 100644 index 000000000..6ba2a828a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -0,0 +1,25 @@ +package com.loopers.domain.ranking; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.TemporalAdjusters; + +public record RankingPeriod( + LocalDate startDate, + LocalDate endDate +) { + public static RankingPeriod ofWeek(LocalDate date) { + LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate weekEnd = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + return new RankingPeriod(weekStart, weekEnd); + } + + public static RankingPeriod ofMonth(LocalDate date) { + YearMonth yearMonth = YearMonth.from(date); + LocalDate monthStart = yearMonth.atDay(1); + LocalDate monthEnd = yearMonth.atEndOfMonth(); + return new RankingPeriod(monthStart, monthEnd); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java index a90fe17c5..89bc8a558 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -14,8 +14,10 @@ public class RankingService { private final RankingCacheService rankingCacheService; + private final MvProductRankWeeklyRepository mvProductRankWeeklyRepository; + private final MvProductRankMonthlyRepository mvProductRankMonthlyRepository; - public List getRanking(LocalDate date, int page, int size) { + public List getDailyRanking(LocalDate date, int page, int size) { long start = (long) (page - 1) * size; long end = start + size - 1; @@ -33,5 +35,53 @@ public List getRanking(LocalDate date, int page, int size) { }) .toList(); } + + public List getWeeklyRanking(LocalDate date, int page, int size) { + RankingPeriod period = RankingPeriod.ofWeek(date); + List weeklyRanks = mvProductRankWeeklyRepository + .findByPeriodOrderByRankingAsc( + period.startDate(), + period.endDate(), + page, + size + ); + + if (weeklyRanks.isEmpty()) { + return new ArrayList<>(); + } + + return weeklyRanks.stream() + .filter(rank -> rank.getRanking() != null) + .map(rank -> new Ranking( + rank.getProductId(), + rank.getRanking().longValue(), + rank.getScore() + )) + .toList(); + } + + public List getMonthlyRanking(LocalDate date, int page, int size) { + RankingPeriod period = RankingPeriod.ofMonth(date); + List monthlyRanks = mvProductRankMonthlyRepository + .findByPeriodOrderByRankingAsc( + period.startDate(), + period.endDate(), + page, + size + ); + + if (monthlyRanks.isEmpty()) { + return new ArrayList<>(); + } + + return monthlyRanks.stream() + .filter(rank -> rank.getRanking() != null) + .map(rank -> new Ranking( + rank.getProductId(), + rank.getRanking().longValue(), + rank.getScore() + )) + .toList(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 000000000..1873984de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findByPeriodStartDateAndPeriodEndDateOrderByRankingAsc( + LocalDate periodStartDate, + LocalDate periodEndDate, + Pageable pageable + ); + +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepositoryImpl.java new file mode 100644 index 000000000..17a22c8fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankMonthlyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class MvProductRankMonthlyRepositoryImpl implements MvProductRankMonthlyRepository { + + private final MvProductRankMonthlyJpaRepository mvProductRankMonthlyJpaRepository; + + @Override + public List findByPeriodOrderByRankingAsc(LocalDate periodStartDate, LocalDate periodEndDate, int page, int size) { + Pageable pageable = PageRequest.of(page - 1, size); + return mvProductRankMonthlyJpaRepository.findByPeriodStartDateAndPeriodEndDateOrderByRankingAsc(periodStartDate, periodEndDate, pageable); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 000000000..f3b0a323d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findByPeriodStartDateAndPeriodEndDateOrderByRankingAsc( + LocalDate periodStartDate, + LocalDate periodEndDate, + Pageable pageable + ); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepositoryImpl.java new file mode 100644 index 000000000..123b1f452 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.MvProductRankWeeklyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class MvProductRankWeeklyRepositoryImpl implements MvProductRankWeeklyRepository { + + private final MvProductRankWeeklyJpaRepository mvProductRankWeeklyJpaRepository; + + @Override + public List findByPeriodOrderByRankingAsc(LocalDate periodStartDate, LocalDate periodEndDate, int page, int size) { + Pageable pageable = PageRequest.of(page - 1, size); + return mvProductRankWeeklyJpaRepository.findByPeriodStartDateAndPeriodEndDateOrderByRankingAsc(periodStartDate, periodEndDate, pageable); + } +} + 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 00022020d..0da9bc580 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 @@ -11,11 +11,13 @@ public interface RankingV1ApiSpec { @Operation( summary = "상품 랭킹 조회", - description = "상품의 일간 랭킹 정보를 조회합니다." + description = "상품의 일간/주간/월간 랭킹 정보를 조회합니다." ) - ApiResponse getDailyRanking( + ApiResponse getRanking( @Parameter(name = "date", description = "조회할 날짜 (yyyyMMdd 형식)", required = true) LocalDate date, + @Parameter(name = "type", description = "랭킹 타입 (DAILY, WEEKLY, MONTHLY)", required = true) + String type, @Parameter(name = "page", description = "페이지 번호 (기본값: 1)") Integer page, @Parameter(name = "size", description = "페이지당 상품 수 (기본값: 20)") 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 9150013ce..62ac85053 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 @@ -4,6 +4,8 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -21,19 +23,23 @@ public class RankingV1Controller implements RankingV1ApiSpec { @GetMapping @Override - public ApiResponse getDailyRanking( + public ApiResponse getRanking( @RequestParam @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, + @RequestParam String type, @RequestParam(required = false, defaultValue = "1") Integer page, @RequestParam(required = false, defaultValue = "20") Integer size ) { - RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( - date, - page, - size - ); - - RankingInfo rankingInfo = rankingFacade.getDailyRanking(command); - RankingV1Dto.DailyRankingListResponse response = RankingV1Dto.DailyRankingListResponse.from(rankingInfo); + RankingCommand.RankingType rankingType; + try { + rankingType = RankingCommand.RankingType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 랭킹 타입입니다."); + } + + RankingCommand.GetRankingCommand command = new RankingCommand.GetRankingCommand(date, rankingType, page, size); + + RankingInfo rankingInfo = rankingFacade.getRanking(command); + RankingV1Dto.RankingListResponse response = RankingV1Dto.RankingListResponse.from(rankingInfo); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java index 5d0bd8503..eb4b1ba17 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -6,12 +6,12 @@ public class RankingV1Dto { - public record DailyRankingListResponse( - List rankings + public record RankingListResponse( + List rankings ) { - public static DailyRankingListResponse from(RankingInfo rankingInfo) { - List items = rankingInfo.items().stream() - .map(item -> new DailyRankingItem( + public static RankingListResponse from(RankingInfo rankingInfo) { + List items = rankingInfo.items().stream() + .map(item -> new RankingItem( item.productId(), item.productName(), item.brandName(), @@ -21,11 +21,11 @@ public static DailyRankingListResponse from(RankingInfo rankingInfo) { )) .collect(Collectors.toList()); - return new DailyRankingListResponse(items); + return new RankingListResponse(items); } } - public record DailyRankingItem( + public record RankingItem( Long productId, String productName, String brandName, diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index d1564c9dd..4a307b426 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -21,6 +21,7 @@ spring: import: - jpa.yml - redis.yml + - kafka.yml - logging.yml - monitoring.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java index b43c730fb..5171ef305 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java @@ -1,5 +1,7 @@ package com.loopers.application.ranking; +import com.loopers.application.ranking.RankingCommand.GetRankingCommand; +import com.loopers.application.ranking.RankingCommand.RankingType; import com.loopers.domain.brand.Brand; import com.loopers.domain.product.LikeCount; import com.loopers.domain.product.Price; @@ -80,12 +82,12 @@ void returnsRankingInfo_whenDataExists() { zSetOps.add(key, String.valueOf(product1.getId()), 100.0); zSetOps.add(key, String.valueOf(product2.getId()), 50.0); - RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( - date, 1, 20 + GetRankingCommand command = new GetRankingCommand( + date, RankingType.DAILY, 1, 20 ); // act - RankingInfo result = rankingFacade.getDailyRanking(command); + RankingInfo result = rankingFacade.getRanking(command); // assert assertThat(result.items()).hasSize(2); @@ -124,12 +126,12 @@ void excludesProducts_notInDatabase() { zSetOps.add(key, String.valueOf(product.getId()), 100.0); zSetOps.add(key, "999", 50.0); // DB에 없는 상품 - RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( - date, 1, 20 + GetRankingCommand command = new GetRankingCommand( + date, RankingType.DAILY, 1, 20 ); // act - RankingInfo result = rankingFacade.getDailyRanking(command); + RankingInfo result = rankingFacade.getRanking(command); // assert assertThat(result.items()).hasSize(1); @@ -140,12 +142,12 @@ void excludesProducts_notInDatabase() { @Test void returnsEmptyList_whenNoRankingData() { // arrange - RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( - LocalDate.now(), 1, 20 + GetRankingCommand command = new GetRankingCommand( + LocalDate.now(), RankingType.DAILY, 1, 20 ); // act - RankingInfo result = rankingFacade.getDailyRanking(command); + RankingInfo result = rankingFacade.getRanking(command); // assert assertThat(result.items()).isEmpty(); @@ -173,8 +175,8 @@ void returnsPaginatedResults() { zSetOps.add(key, String.valueOf(product3.getId()), 30.0); // act - 첫 페이지 - RankingInfo page1 = rankingFacade.getDailyRanking( - new RankingCommand.GetDailyRankingCommand(date, 1, 2) + RankingInfo page1 = rankingFacade.getRanking( + new GetRankingCommand(date, RankingType.DAILY, 1, 2) ); // assert @@ -183,8 +185,8 @@ void returnsPaginatedResults() { assertThat(page1.items().get(1).productId()).isEqualTo(product2.getId()); // act - 두 번째 페이지 - RankingInfo page2 = rankingFacade.getDailyRanking( - new RankingCommand.GetDailyRankingCommand(date, 2, 2) + RankingInfo page2 = rankingFacade.getRanking( + new GetRankingCommand(date, RankingType.DAILY, 2, 2) ); // assert diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java index b3623777b..14efedcc0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java @@ -1,6 +1,9 @@ package com.loopers.domain.ranking; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; import com.loopers.support.IntegrationTest; +import com.loopers.utils.DatabaseCleanUp; import com.loopers.utils.RedisCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -32,17 +35,28 @@ class RankingServiceIntegrationTest extends IntegrationTest { @Autowired private RedisCleanUp redisCleanUp; + @Autowired + private MvProductRankWeeklyJpaRepository mvProductRankWeeklyJpaRepository; + + @Autowired + private MvProductRankMonthlyJpaRepository mvProductRankMonthlyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + private static final String RANKING_KEY_PREFIX = "ranking:all:"; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @BeforeEach void setUp() { redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); } @AfterEach void tearDown() { redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); } @DisplayName("랭킹 조회") @@ -54,7 +68,7 @@ class GetRanking { void returnsRankings_whenDataExists() { // arrange LocalDate date = LocalDate.now(); - String key = getRankingKey(date); + String key = getDailyRankingKey(date); ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); zSetOps.add(key, "1", 100.0); @@ -62,7 +76,7 @@ void returnsRankings_whenDataExists() { zSetOps.add(key, "3", 30.0); // act - List result = rankingService.getRanking(date, 1, 20); + List result = rankingService.getDailyRanking(date, 1, 20); // assert assertThat(result).hasSize(3); @@ -82,7 +96,7 @@ void returnsRankings_whenDataExists() { void returnsPaginatedResults() { // arrange LocalDate date = LocalDate.now(); - String key = getRankingKey(date); + String key = getDailyRankingKey(date); ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); zSetOps.add(key, "1", 100.0); @@ -92,7 +106,7 @@ void returnsPaginatedResults() { zSetOps.add(key, "5", 60.0); // act - 첫 페이지 - List page1 = rankingService.getRanking(date, 1, 2); + List page1 = rankingService.getDailyRanking(date, 1, 2); // assert assertThat(page1).hasSize(2); @@ -102,7 +116,7 @@ void returnsPaginatedResults() { assertThat(page1.get(1).rank()).isEqualTo(2L); // act - 두 번째 페이지 - List page2 = rankingService.getRanking(date, 2, 2); + List page2 = rankingService.getDailyRanking(date, 2, 2); // assert assertThat(page2).hasSize(2); @@ -112,7 +126,7 @@ void returnsPaginatedResults() { assertThat(page2.get(1).rank()).isEqualTo(4L); // act - 세 번째 페이지 - List page3 = rankingService.getRanking(date, 3, 2); + List page3 = rankingService.getDailyRanking(date, 3, 2); // assert assertThat(page3).hasSize(1); @@ -127,7 +141,7 @@ void returnsEmptyList_whenNoDataExists() { LocalDate date = LocalDate.now(); // act - List result = rankingService.getRanking(date, 1, 20); + List result = rankingService.getDailyRanking(date, 1, 20); // assert assertThat(result).isEmpty(); @@ -138,21 +152,255 @@ void returnsEmptyList_whenNoDataExists() { void startsRankFromOne() { // arrange LocalDate date = LocalDate.now(); - String key = getRankingKey(date); + String key = getDailyRankingKey(date); ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); zSetOps.add(key, "1", 100.0); // act - List result = rankingService.getRanking(date, 1, 20); + List result = rankingService.getDailyRanking(date, 1, 20); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).rank()).isEqualTo(1L); + } + } + + @DisplayName("주간 랭킹 조회") + @Nested + class GetWeeklyRanking { + + @DisplayName("주간 랭킹 데이터가 있으면 정상적으로 조회된다.") + @Test + void returnsRankings_whenDataExists() { + // arrange + LocalDate date = LocalDate.of(2024, 1, 10); // 수요일 + RankingPeriod period = RankingPeriod.ofWeek(date); + + MvProductRankWeekly rank1 = MvProductRankWeekly.create( + 1L, 1, 100.0, period.startDate(), period.endDate(), 10L, 100L, 5L + ); + MvProductRankWeekly rank2 = MvProductRankWeekly.create( + 2L, 2, 50.0, period.startDate(), period.endDate(), 5L, 50L, 3L + ); + MvProductRankWeekly rank3 = MvProductRankWeekly.create( + 3L, 3, 30.0, period.startDate(), period.endDate(), 3L, 30L, 2L + ); + + mvProductRankWeeklyJpaRepository.save(rank1); + mvProductRankWeeklyJpaRepository.save(rank2); + mvProductRankWeeklyJpaRepository.save(rank3); + + // act + List result = rankingService.getWeeklyRanking(date, 1, 20); + + // assert + assertThat(result).hasSize(3); + assertThat(result.get(0).productId()).isEqualTo(1L); + assertThat(result.get(0).rank()).isEqualTo(1L); + assertThat(result.get(0).score()).isEqualTo(100.0); + assertThat(result.get(1).productId()).isEqualTo(2L); + assertThat(result.get(1).rank()).isEqualTo(2L); + assertThat(result.get(1).score()).isEqualTo(50.0); + assertThat(result.get(2).productId()).isEqualTo(3L); + assertThat(result.get(2).rank()).isEqualTo(3L); + assertThat(result.get(2).score()).isEqualTo(30.0); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + LocalDate date = LocalDate.of(2024, 1, 10); + RankingPeriod period = RankingPeriod.ofWeek(date); + + for (int i = 1; i <= 5; i++) { + MvProductRankWeekly rank = MvProductRankWeekly.create( + (long) i, i, (double) (100 - i * 10), period.startDate(), period.endDate(), + 10L, 100L, 5L + ); + mvProductRankWeeklyJpaRepository.save(rank); + } + + // act - 첫 페이지 + List page1 = rankingService.getWeeklyRanking(date, 1, 2); + + // assert + assertThat(page1).hasSize(2); + assertThat(page1.get(0).productId()).isEqualTo(1L); + assertThat(page1.get(0).rank()).isEqualTo(1L); + assertThat(page1.get(1).productId()).isEqualTo(2L); + assertThat(page1.get(1).rank()).isEqualTo(2L); + + // act - 두 번째 페이지 + List page2 = rankingService.getWeeklyRanking(date, 2, 2); + + // assert + assertThat(page2).hasSize(2); + assertThat(page2.get(0).productId()).isEqualTo(3L); + assertThat(page2.get(0).rank()).isEqualTo(3L); + assertThat(page2.get(1).productId()).isEqualTo(4L); + assertThat(page2.get(1).rank()).isEqualTo(4L); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoDataExists() { + // arrange + LocalDate date = LocalDate.of(2024, 1, 10); + + // act + List result = rankingService.getWeeklyRanking(date, 1, 20); + + // assert + assertThat(result).isEmpty(); + } + + @DisplayName("ranking이 null인 경우 필터링된다.") + @Test + void filtersNullRanking() { + // arrange + LocalDate date = LocalDate.of(2024, 1, 10); + RankingPeriod period = RankingPeriod.ofWeek(date); + + MvProductRankWeekly rank1 = MvProductRankWeekly.create( + 1L, 1, 100.0, period.startDate(), period.endDate(), 10L, 100L, 5L + ); + MvProductRankWeekly rank2 = MvProductRankWeekly.create( + 2L, null, 50.0, period.startDate(), period.endDate(), 5L, 50L, 3L + ); + + mvProductRankWeeklyJpaRepository.save(rank1); + mvProductRankWeeklyJpaRepository.save(rank2); + + // act + List result = rankingService.getWeeklyRanking(date, 1, 20); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(1L); + assertThat(result.get(0).rank()).isEqualTo(1L); + } + } + + @DisplayName("월간 랭킹 조회") + @Nested + class GetMonthlyRanking { + + @DisplayName("월간 랭킹 데이터가 있으면 정상적으로 조회된다.") + @Test + void returnsRankings_whenDataExists() { + // arrange + LocalDate date = LocalDate.of(2024, 1, 15); + RankingPeriod period = RankingPeriod.ofMonth(date); + + MvProductRankMonthly rank1 = MvProductRankMonthly.create( + 1L, 1, 100.0, period.startDate(), period.endDate(), 10L, 100L, 5L + ); + MvProductRankMonthly rank2 = MvProductRankMonthly.create( + 2L, 2, 50.0, period.startDate(), period.endDate(), 5L, 50L, 3L + ); + MvProductRankMonthly rank3 = MvProductRankMonthly.create( + 3L, 3, 30.0, period.startDate(), period.endDate(), 3L, 30L, 2L + ); + + mvProductRankMonthlyJpaRepository.save(rank1); + mvProductRankMonthlyJpaRepository.save(rank2); + mvProductRankMonthlyJpaRepository.save(rank3); + + // act + List result = rankingService.getMonthlyRanking(date, 1, 20); + + // assert + assertThat(result).hasSize(3); + assertThat(result.get(0).productId()).isEqualTo(1L); + assertThat(result.get(0).rank()).isEqualTo(1L); + assertThat(result.get(0).score()).isEqualTo(100.0); + assertThat(result.get(1).productId()).isEqualTo(2L); + assertThat(result.get(1).rank()).isEqualTo(2L); + assertThat(result.get(1).score()).isEqualTo(50.0); + assertThat(result.get(2).productId()).isEqualTo(3L); + assertThat(result.get(2).rank()).isEqualTo(3L); + assertThat(result.get(2).score()).isEqualTo(30.0); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + LocalDate date = LocalDate.of(2024, 1, 15); + RankingPeriod period = RankingPeriod.ofMonth(date); + + for (int i = 1; i <= 5; i++) { + MvProductRankMonthly rank = MvProductRankMonthly.create( + (long) i, i, (double) (100 - i * 10), period.startDate(), period.endDate(), + 10L, 100L, 5L + ); + mvProductRankMonthlyJpaRepository.save(rank); + } + + // act - 첫 페이지 + List page1 = rankingService.getMonthlyRanking(date, 1, 2); + + // assert + assertThat(page1).hasSize(2); + assertThat(page1.get(0).productId()).isEqualTo(1L); + assertThat(page1.get(0).rank()).isEqualTo(1L); + assertThat(page1.get(1).productId()).isEqualTo(2L); + assertThat(page1.get(1).rank()).isEqualTo(2L); + + // act - 두 번째 페이지 + List page2 = rankingService.getMonthlyRanking(date, 2, 2); + + // assert + assertThat(page2).hasSize(2); + assertThat(page2.get(0).productId()).isEqualTo(3L); + assertThat(page2.get(0).rank()).isEqualTo(3L); + assertThat(page2.get(1).productId()).isEqualTo(4L); + assertThat(page2.get(1).rank()).isEqualTo(4L); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoDataExists() { + // arrange + LocalDate date = LocalDate.of(2024, 1, 15); + + // act + List result = rankingService.getMonthlyRanking(date, 1, 20); + + // assert + assertThat(result).isEmpty(); + } + + @DisplayName("ranking이 null인 경우 필터링된다.") + @Test + void filtersNullRanking() { + // arrange + LocalDate date = LocalDate.of(2024, 1, 15); + RankingPeriod period = RankingPeriod.ofMonth(date); + + MvProductRankMonthly rank1 = MvProductRankMonthly.create( + 1L, 1, 100.0, period.startDate(), period.endDate(), 10L, 100L, 5L + ); + MvProductRankMonthly rank2 = MvProductRankMonthly.create( + 2L, null, 50.0, period.startDate(), period.endDate(), 5L, 50L, 3L + ); + + mvProductRankMonthlyJpaRepository.save(rank1); + mvProductRankMonthlyJpaRepository.save(rank2); + + // act + List result = rankingService.getMonthlyRanking(date, 1, 20); // assert assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(1L); assertThat(result.get(0).rank()).isEqualTo(1L); } } - private String getRankingKey(LocalDate date) { + private String getDailyRankingKey(LocalDate date) { return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java index 61685253b..d214e6911 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -8,6 +8,8 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.Stock; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingItem; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingListResponse; import com.loopers.support.IntegrationTest; import com.loopers.utils.DatabaseCleanUp; import com.loopers.utils.RedisCleanUp; @@ -94,10 +96,10 @@ void returnsRanking_whenDataExists() { zSetOps.add(key, String.valueOf(product1.getId()), 100.0); zSetOps.add(key, String.valueOf(product2.getId()), 50.0); - String url = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=20"; + String url = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&type=DAILY&page=1&size=20"; // act - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( url, HttpMethod.GET, null, @@ -109,10 +111,10 @@ void returnsRanking_whenDataExists() { assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); - RankingV1Dto.DailyRankingListResponse rankingResponse = response.getBody().data(); + RankingListResponse rankingResponse = response.getBody().data(); assertThat(rankingResponse.rankings()).hasSize(2); - RankingV1Dto.DailyRankingItem item1 = rankingResponse.rankings().get(0); + RankingItem item1 = rankingResponse.rankings().get(0); assertAll( () -> assertThat(item1.productId()).isEqualTo(product1.getId()), () -> assertThat(item1.productName()).isEqualTo("상품1"), @@ -122,7 +124,7 @@ void returnsRanking_whenDataExists() { () -> assertThat(item1.rank()).isEqualTo(1) ); - RankingV1Dto.DailyRankingItem item2 = rankingResponse.rankings().get(1); + RankingItem item2 = rankingResponse.rankings().get(1); assertAll( () -> assertThat(item2.productId()).isEqualTo(product2.getId()), () -> assertThat(item2.productName()).isEqualTo("상품2"), @@ -138,10 +140,10 @@ void returnsRanking_whenDataExists() { void returnsEmptyList_whenNoDataExists() { // arrange LocalDate date = LocalDate.now(); - String url = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=20"; + String url = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&type=DAILY&page=1&size=20"; // act - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( url, HttpMethod.GET, null, @@ -153,7 +155,7 @@ void returnsEmptyList_whenNoDataExists() { assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); - RankingV1Dto.DailyRankingListResponse rankingResponse = response.getBody().data(); + RankingListResponse rankingResponse = response.getBody().data(); assertThat(rankingResponse.rankings()).isEmpty(); } @@ -179,8 +181,8 @@ void returnsPaginatedResults() { zSetOps.add(key, String.valueOf(product3.getId()), 30.0); // act - 첫 페이지 - String url1 = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=2"; - ResponseEntity> response1 = testRestTemplate.exchange( + String url1 = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&type=DAILY&page=1&size=2"; + ResponseEntity> response1 = testRestTemplate.exchange( url1, HttpMethod.GET, null, @@ -189,14 +191,14 @@ void returnsPaginatedResults() { // assert assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.OK); - RankingV1Dto.DailyRankingListResponse page1 = response1.getBody().data(); + RankingListResponse page1 = response1.getBody().data(); assertThat(page1.rankings()).hasSize(2); assertThat(page1.rankings().get(0).productId()).isEqualTo(product1.getId()); assertThat(page1.rankings().get(1).productId()).isEqualTo(product2.getId()); // act - 두 번째 페이지 - String url2 = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=2&size=2"; - ResponseEntity> response2 = testRestTemplate.exchange( + String url2 = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&type=DAILY&page=2&size=2"; + ResponseEntity> response2 = testRestTemplate.exchange( url2, HttpMethod.GET, null, @@ -205,7 +207,7 @@ void returnsPaginatedResults() { // assert assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK); - RankingV1Dto.DailyRankingListResponse page2 = response2.getBody().data(); + RankingListResponse page2 = response2.getBody().data(); assertThat(page2.rankings()).hasSize(1); assertThat(page2.rankings().get(0).productId()).isEqualTo(product3.getId()); } diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..587d3ac7e --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,24 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..dd52e7377 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.TimeZone; + +@SpringBootApplication +@EnableScheduling +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + SpringApplication.run(CommerceBatchApplication.class, args); + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/JobParameterUtils.java b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/JobParameterUtils.java new file mode 100644 index 000000000..c22b672b2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/JobParameterUtils.java @@ -0,0 +1,47 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +public class JobParameterUtils { + + private static final String TARGET_DATE_PARAM = "targetDate"; + private static final String RANKING_TYPE_PARAM = "rankingType"; + + public static LocalDate getTargetDate(StepExecution stepExecution) { + if (stepExecution == null) { + return LocalDate.now().minusDays(1); + } + + String targetDateStr = stepExecution.getJobParameters().getString(TARGET_DATE_PARAM); + if (targetDateStr == null || targetDateStr.isEmpty()) { + return LocalDate.now().minusDays(1); + } + + try { + return LocalDate.parse(targetDateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (Exception e) { + log.warn("targetDate 형식이 올바르지 않습니다: {}", targetDateStr); + return LocalDate.now().minusDays(1); + } + } + + public static RankingType getRankingType(StepExecution stepExecution) { + if (stepExecution == null) { + return null; + } + + String rankingTypeStr = stepExecution.getJobParameters().getString(RANKING_TYPE_PARAM); + if (rankingTypeStr == null || rankingTypeStr.isEmpty()) { + return RankingType.WEEKLY; + } + + return RankingType.valueOf(rankingTypeStr.toUpperCase()); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductMetricsItemReader.java b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductMetricsItemReader.java new file mode 100644 index 000000000..a7a5a1680 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductMetricsItemReader.java @@ -0,0 +1,66 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemStream; +import org.springframework.batch.item.ItemStreamException; + +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +public class ProductMetricsItemReader implements ItemReader, ItemStream { + + private static final String CURRENT_INDEX_KEY = "current.index"; + + private final ProductMetricsService productMetricsService; + private StepExecution stepExecution; + private List productMetricsList; + private int currentIndex = 0; + + @BeforeStep + public void beforeStep(StepExecution stepExecution) { + this.stepExecution = stepExecution; + } + + @Override + public ProductMetrics read() { + if (productMetricsList == null || currentIndex >= productMetricsList.size()) { + return null; + } + return productMetricsList.get(currentIndex++); + } + + @Override + public void open(ExecutionContext executionContext) throws ItemStreamException { + if (executionContext.containsKey(CURRENT_INDEX_KEY)) { + currentIndex = executionContext.getInt(CURRENT_INDEX_KEY); + } else { + currentIndex = 0; + } + + LocalDate targetDate = JobParameterUtils.getTargetDate(stepExecution); + log.info("ProductMetrics 조회 시작: targetDate={}", targetDate); + productMetricsList = productMetricsService.findByMetricsDate(targetDate); + log.info("ProductMetrics 조회 완료: size={}", productMetricsList.size()); + } + + @Override + public void update(ExecutionContext executionContext) throws ItemStreamException { + executionContext.putInt(CURRENT_INDEX_KEY, currentIndex); + } + + @Override + public void close() throws ItemStreamException { + productMetricsList = null; + currentIndex = 0; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingCalculationTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingCalculationTasklet.java new file mode 100644 index 000000000..8f8d28cc9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingCalculationTasklet.java @@ -0,0 +1,48 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MonthlyRankingService; +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.domain.ranking.RankingType; +import com.loopers.domain.ranking.WeeklyRankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +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.stereotype.Component; + +import java.time.LocalDate; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingCalculationTasklet implements Tasklet { + + private final WeeklyRankingService weeklyRankingService; + private final MonthlyRankingService monthlyRankingService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + var stepExecution = chunkContext.getStepContext().getStepExecution(); + + LocalDate targetDate = JobParameterUtils.getTargetDate(stepExecution); + RankingType rankingType = JobParameterUtils.getRankingType(stepExecution); + RankingPeriod period; + + log.info("랭킹 계산 시작: rankingType={}, targetDate={}", rankingType, targetDate); + + if (rankingType == RankingType.WEEKLY) { + period = RankingPeriod.ofWeek(targetDate); + weeklyRankingService.calculateAndUpdateRanking(period); + log.info("주간 랭킹 계산 완료: targetDate={}, period={} ~ {}", targetDate, period.startDate(), period.endDate()); + } else { + period = RankingPeriod.ofMonth(targetDate); + monthlyRankingService.calculateAndUpdateRanking(period); + log.info("월간 랭킹 계산 완료: targetDate={}, period={} ~ {}", targetDate, period.startDate(), period.endDate()); + } + + return RepeatStatus.FINISHED; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingJobConfig.java new file mode 100644 index 000000000..bfab72917 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingJobConfig.java @@ -0,0 +1,78 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.domain.ranking.MonthlyRankingService; +import com.loopers.domain.ranking.ProductRankingAggregate; +import com.loopers.domain.ranking.RankingCalculator; +import com.loopers.domain.ranking.WeeklyRankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class RankingJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductMetricsService productMetricsService; + private final RankingCalculator rankingCalculator; + private final RankingCalculationTasklet rankingCalculationTasklet; + private final WeeklyRankingService weeklyRankingService; + private final MonthlyRankingService monthlyRankingService; + + private static final int CHUNK_SIZE = 100; + + @Bean + public Job rankingJob() { + return new JobBuilder("rankingJob", jobRepository) + .start(rankingStep()) + .next(rankingCalculationStep()) + .build(); + } + + @Bean + public Step rankingStep() { + return new StepBuilder("rankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(rankingReader()) + .processor(rankingProcessor()) + .writer(rankingWriter()) + .build(); + } + + @Bean + public Step rankingCalculationStep() { + return new StepBuilder("rankingCalculationStep", jobRepository) + .tasklet(rankingCalculationTasklet, transactionManager) + .build(); + } + + @Bean + public ItemReader rankingReader() { + return new ProductMetricsItemReader(productMetricsService); + } + + @Bean + public ItemProcessor rankingProcessor() { + return new RankingProcessor(rankingCalculator); + } + + @Bean + public ItemWriter rankingWriter() { + return new RankingWriter(weeklyRankingService, monthlyRankingService); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingProcessor.java new file mode 100644 index 000000000..929d85490 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingProcessor.java @@ -0,0 +1,22 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankingAggregate; +import com.loopers.domain.ranking.RankingCalculator; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.ItemProcessor; + +@RequiredArgsConstructor +public class RankingProcessor implements ItemProcessor { + + private final RankingCalculator rankingCalculator; + + @Override + public ProductRankingAggregate process(ProductMetrics item) throws Exception { + ProductRankingAggregate aggregate = new ProductRankingAggregate(item.getProductId()); + aggregate.addMetrics(item.getLikeCount(), item.getViewCount(), item.getSalesCount()); + aggregate.calculateScore(rankingCalculator); + return aggregate; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingWriter.java new file mode 100644 index 000000000..15431f148 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/RankingWriter.java @@ -0,0 +1,65 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MonthlyRankingService; +import com.loopers.domain.ranking.ProductRankingAggregate; +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.domain.ranking.RankingType; +import com.loopers.domain.ranking.WeeklyRankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.time.LocalDate; + +@Slf4j +@RequiredArgsConstructor +public class RankingWriter implements ItemWriter { + + private final WeeklyRankingService weeklyRankingService; + private final MonthlyRankingService monthlyRankingService; + + private RankingPeriod period; + private RankingType rankingType; + + @BeforeStep + public void beforeStep(StepExecution stepExecution) { + LocalDate targetDate = JobParameterUtils.getTargetDate(stepExecution); + this.rankingType = JobParameterUtils.getRankingType(stepExecution); + + if (rankingType == RankingType.WEEKLY) { + this.period = RankingPeriod.ofWeek(targetDate); + } else { + this.period = RankingPeriod.ofMonth(targetDate); + } + } + + @Override + public void write(Chunk items) throws Exception { + for (ProductRankingAggregate aggregate : items) { + Long productId = aggregate.getProductId(); + + if (rankingType == RankingType.WEEKLY) { + weeklyRankingService.upsertMetrics( + productId, + period, + aggregate.getScore(), + aggregate.getTotalLikeCount(), + aggregate.getTotalViewCount(), + aggregate.getTotalSalesCount() + ); + } else { + monthlyRankingService.upsertMetrics( + productId, + period, + aggregate.getScore(), + aggregate.getTotalLikeCount(), + aggregate.getTotalViewCount(), + aggregate.getTotalSalesCount() + ); + } + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/BatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/BatchConfig.java new file mode 100644 index 000000000..5b0104800 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/BatchConfig.java @@ -0,0 +1,20 @@ +package com.loopers.batch.job; + +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; + +@Configuration +@EnableBatchProcessing +public class BatchConfig { + + @Bean + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor(); + taskExecutor.setConcurrencyLimit(10); + return taskExecutor; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..412c7c999 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,66 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Entity +@Table( + name = "product_metrics", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "metrics_date"}) + } +) +@Getter +public class ProductMetrics extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(name = "metrics_date", nullable = false) + private LocalDate metricsDate; + + @Column(nullable = false) + private Long likeCount; + + @Column(nullable = false) + private Long salesCount; + + @Column(nullable = false) + private Long viewCount; + + @Version + @Column(nullable = false) + private Long version; + + @Builder + private ProductMetrics(Long productId, LocalDate metricsDate, Long likeCount, Long salesCount, Long viewCount) { + this.productId = productId; + this.metricsDate = metricsDate; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + } + + public ProductMetrics() { + } + + public static ProductMetrics create(Long productId, LocalDate metricsDate) { + return ProductMetrics.builder() + .productId(productId) + .metricsDate(metricsDate) + .likeCount(0L) + .salesCount(0L) + .viewCount(0L) + .build(); + } +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..1c115c859 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.metrics; + +import java.time.LocalDate; +import java.util.List; + +public interface ProductMetricsRepository { + List findByMetricsDate(LocalDate metricsDate); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java new file mode 100644 index 000000000..d836019bd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -0,0 +1,19 @@ +package com.loopers.domain.metrics; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProductMetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + public List findByMetricsDate(LocalDate metricsDate) { + return productMetricsRepository.findByMetricsDate(metricsDate); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MonthlyRankingService.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MonthlyRankingService.java new file mode 100644 index 000000000..e389d3caa --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MonthlyRankingService.java @@ -0,0 +1,86 @@ +package com.loopers.domain.ranking; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MonthlyRankingService { + + private static final int TOP_RANKING_SIZE = 100; + + private final MvProductRankMonthlyRepository mvProductRankMonthlyRepository; + + @Transactional + public void upsertMetrics(Long productId, RankingPeriod period, Double score, Long likeCount, Long viewCount, Long salesCount) { + mvProductRankMonthlyRepository.findByProductIdAndPeriod(productId, period.startDate(), period.endDate()) + .ifPresentOrElse( + existing -> { + existing.updateMetrics(score, likeCount, viewCount, salesCount); + mvProductRankMonthlyRepository.save(existing); + }, + () -> { + MvProductRankMonthly newRank = MvProductRankMonthly.create( + productId, + null, + score, + period.startDate(), + period.endDate(), + likeCount, + viewCount, + salesCount + ); + mvProductRankMonthlyRepository.save(newRank); + } + ); + } + + @Transactional + public void calculateAndUpdateRanking(RankingPeriod period) { + List allRanks = mvProductRankMonthlyRepository.findByPeriodOrderByRankingAsc( + period.startDate(), period.endDate()); + + if (allRanks.isEmpty()) { + log.info("월간 랭킹 데이터가 없습니다: period={} ~ {}", period.startDate(), period.endDate()); + return; + } + + // score 기준으로 정렬하여 TOP 100 추출 + List top100 = allRanks.stream() + .sorted((a, b) -> Double.compare(b.getScore(), a.getScore())) + .limit(TOP_RANKING_SIZE) + .toList(); + + // TOP 100 상품의 랭킹만 업데이트 + int updatedCount = 0; + for (int i = 0; i < top100.size(); i++) { + MvProductRankMonthly rank = top100.get(i); + rank.updateRanking(i + 1); + mvProductRankMonthlyRepository.save(rank); + updatedCount++; + } + + // TOP 100에서 밀려난 기존 데이터 삭제 + Set top100ProductIds = top100.stream() + .map(MvProductRankMonthly::getProductId) + .collect(Collectors.toSet()); + + int deletedCount = 0; + for (MvProductRankMonthly existing : allRanks) { + if (!top100ProductIds.contains(existing.getProductId())) { + mvProductRankMonthlyRepository.delete(existing); + deletedCount++; + } + } + + log.info("월간 랭킹 계산 완료: period={} ~ {}, updated={}, deleted={}", + period.startDate(), period.endDate(), updatedCount, deletedCount); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 000000000..71c78ef6b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,97 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "period_start_date", "period_end_date"}) + } +) +@Getter +public class MvProductRankMonthly extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(nullable = true) + private Integer ranking; + + @Column(nullable = false) + private Double score; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Builder + private MvProductRankMonthly(Long productId, Integer ranking, Double score, LocalDate periodStartDate, + LocalDate periodEndDate, Long likeCount, Long viewCount, Long salesCount) { + this.productId = productId; + this.ranking = ranking; + this.score = score; + this.periodStartDate = periodStartDate; + this.periodEndDate = periodEndDate; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + } + + public MvProductRankMonthly() { + } + + public static MvProductRankMonthly create(Long productId, Integer ranking, Double score, LocalDate periodStartDate, + LocalDate periodEndDate, Long likeCount, Long viewCount, Long salesCount) { + return MvProductRankMonthly.builder() + .productId(productId) + .ranking(ranking) + .score(score) + .periodStartDate(periodStartDate) + .periodEndDate(periodEndDate) + .likeCount(likeCount) + .viewCount(viewCount) + .salesCount(salesCount) + .build(); + } + + public void update(Integer ranking, Double score, Long likeCount, Long viewCount, Long salesCount) { + this.ranking = ranking; + this.score = score; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + } + + public void updateMetrics(Double score, Long likeCount, Long viewCount, Long salesCount) { + this.score = score; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + } + + public void updateRanking(Integer ranking) { + this.ranking = ranking; + } +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyRepository.java new file mode 100644 index 000000000..f17064898 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface MvProductRankMonthlyRepository { + Optional findByProductIdAndPeriod(Long productId, LocalDate periodStartDate, LocalDate periodEndDate); + + List findByPeriodOrderByRankingAsc(LocalDate periodStartDate, LocalDate periodEndDate); + + void save(MvProductRankMonthly rank); + + void delete(MvProductRankMonthly rank); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 000000000..22d5a2ac0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,97 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "period_start_date", "period_end_date"}) + } +) +@Getter +public class MvProductRankWeekly extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(nullable = true) + private Integer ranking; + + @Column(nullable = false) + private Double score; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Builder + private MvProductRankWeekly(Long productId, Integer ranking, Double score, LocalDate periodStartDate, LocalDate periodEndDate, + Long likeCount, Long viewCount, Long salesCount) { + this.productId = productId; + this.ranking = ranking; + this.score = score; + this.periodStartDate = periodStartDate; + this.periodEndDate = periodEndDate; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + } + + public MvProductRankWeekly() { + } + + public static MvProductRankWeekly create(Long productId, Integer ranking, Double score, LocalDate periodStartDate, + LocalDate periodEndDate, Long likeCount, Long viewCount, Long salesCount) { + return MvProductRankWeekly.builder() + .productId(productId) + .ranking(ranking) + .score(score) + .periodStartDate(periodStartDate) + .periodEndDate(periodEndDate) + .likeCount(likeCount) + .viewCount(viewCount) + .salesCount(salesCount) + .build(); + } + + public void update(Integer ranking, Double score, Long likeCount, Long viewCount, Long salesCount) { + this.ranking = ranking; + this.score = score; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + } + + public void updateMetrics(Double score, Long likeCount, Long viewCount, Long salesCount) { + this.score = score; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + } + + public void updateRanking(Integer ranking) { + this.ranking = ranking; + } +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyRepository.java new file mode 100644 index 000000000..adff43c23 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface MvProductRankWeeklyRepository { + Optional findByProductIdAndPeriod(Long productId, LocalDate periodStartDate, LocalDate periodEndDate); + + List findByPeriodOrderByRankingAsc(LocalDate periodStartDate, LocalDate periodEndDate); + + void save(MvProductRankWeekly rank); + + void delete(MvProductRankWeekly rank); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingAggregate.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingAggregate.java new file mode 100644 index 000000000..977d72c50 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingAggregate.java @@ -0,0 +1,32 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; + +@Getter +public class ProductRankingAggregate { + private final Long productId; + private Long totalLikeCount; + private Long totalViewCount; + private Long totalSalesCount; + private Double score; + + public ProductRankingAggregate(Long productId) { + this.productId = productId; + this.totalLikeCount = 0L; + this.totalViewCount = 0L; + this.totalSalesCount = 0L; + this.score = 0.0; + } + + public void addMetrics(Long likeCount, Long viewCount, Long salesCount) { + this.totalLikeCount += (likeCount != null ? likeCount : 0L); + this.totalViewCount += (viewCount != null ? viewCount : 0L); + this.totalSalesCount += (salesCount != null ? salesCount : 0L); + } + + public void calculateScore(RankingCalculator calculator) { + this.score = calculator.calculateScore(totalLikeCount, totalViewCount, totalSalesCount); + } +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingCalculator.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingCalculator.java new file mode 100644 index 000000000..51a29376f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingCalculator.java @@ -0,0 +1,27 @@ +package com.loopers.domain.ranking; + +import org.springframework.stereotype.Component; + +@Component +public class RankingCalculator { + + public double calculateScore(Long likeCount, Long viewCount, Long salesCount) { + double score = 0.0; + + if (viewCount != null && viewCount > 0) { + score += viewCount * RankingWeight.VIEW.getWeight(); + } + + if (likeCount != null && likeCount > 0) { + score += likeCount * RankingWeight.LIKE.getWeight(); + } + + if (salesCount != null && salesCount > 0) { + score += salesCount * RankingWeight.ORDER_CREATED.getWeight(); + } + + return score; + } +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingPeriod.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingPeriod.java new file mode 100644 index 000000000..6ba2a828a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -0,0 +1,25 @@ +package com.loopers.domain.ranking; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.TemporalAdjusters; + +public record RankingPeriod( + LocalDate startDate, + LocalDate endDate +) { + public static RankingPeriod ofWeek(LocalDate date) { + LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate weekEnd = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + return new RankingPeriod(weekStart, weekEnd); + } + + public static RankingPeriod ofMonth(LocalDate date) { + YearMonth yearMonth = YearMonth.from(date); + LocalDate monthStart = yearMonth.atDay(1); + LocalDate monthEnd = yearMonth.atEndOfMonth(); + return new RankingPeriod(monthStart, monthEnd); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingType.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingType.java new file mode 100644 index 000000000..381c13bb9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum RankingType { + WEEKLY, + MONTHLY +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingWeight.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingWeight.java new file mode 100644 index 000000000..f5a8c054d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingWeight.java @@ -0,0 +1,20 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; + +@Getter +public enum RankingWeight { + VIEW(0.1, "조회수"), + LIKE(0.2, "좋아요"), + ORDER_CREATED(0.7, "주문 생성"); + + private final double weight; + private final String description; + + RankingWeight(double weight, String description) { + this.weight = weight; + this.description = description; + } +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/WeeklyRankingService.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/WeeklyRankingService.java new file mode 100644 index 000000000..fce852ee4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/WeeklyRankingService.java @@ -0,0 +1,87 @@ +package com.loopers.domain.ranking; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeeklyRankingService { + + private static final int TOP_RANKING_SIZE = 100; + + private final MvProductRankWeeklyRepository mvProductRankWeeklyRepository; + + @Transactional + public void upsertMetrics(Long productId, RankingPeriod period, Double score, Long likeCount, Long viewCount, Long salesCount) { + mvProductRankWeeklyRepository.findByProductIdAndPeriod(productId, period.startDate(), period.endDate()) + .ifPresentOrElse( + existing -> { + existing.updateMetrics(score, likeCount, viewCount, salesCount); + mvProductRankWeeklyRepository.save(existing); + }, + () -> { + MvProductRankWeekly newRank = MvProductRankWeekly.create( + productId, + null, + score, + period.startDate(), + period.endDate(), + likeCount, + viewCount, + salesCount + ); + mvProductRankWeeklyRepository.save(newRank); + } + ); + } + + @Transactional + public void calculateAndUpdateRanking(RankingPeriod period) { + // MV 테이블에서 period에 해당하는 모든 데이터 조회 + List allRanks = mvProductRankWeeklyRepository.findByPeriodOrderByRankingAsc( + period.startDate(), period.endDate()); + + if (allRanks.isEmpty()) { + log.info("주간 랭킹 데이터가 없습니다: period={} ~ {}", period.startDate(), period.endDate()); + return; + } + + // score 기준으로 정렬하여 TOP 100 추출 + List top100 = allRanks.stream() + .sorted((a, b) -> Double.compare(b.getScore(), a.getScore())) + .limit(TOP_RANKING_SIZE) + .toList(); + + // TOP 100 상품의 랭킹만 업데이트 + int updatedCount = 0; + for (int i = 0; i < top100.size(); i++) { + MvProductRankWeekly rank = top100.get(i); + rank.updateRanking(i + 1); + mvProductRankWeeklyRepository.save(rank); + updatedCount++; + } + + // TOP 100에서 밀려난 기존 데이터 삭제 + Set top100ProductIds = top100.stream() + .map(MvProductRankWeekly::getProductId) + .collect(Collectors.toSet()); + + int deletedCount = 0; + for (MvProductRankWeekly existing : allRanks) { + if (!top100ProductIds.contains(existing.getProductId())) { + mvProductRankWeeklyRepository.delete(existing); + deletedCount++; + } + } + + log.info("주간 랭킹 계산 완료: period={} ~ {}, updated={}, deleted={}", + period.startDate(), period.endDate(), updatedCount, deletedCount); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..0215c00f4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface ProductMetricsJpaRepository extends JpaRepository { + @Query("SELECT pm FROM ProductMetrics pm WHERE pm.metricsDate = :date ORDER BY pm.productId") + List findByMetricsDate(@Param("date") LocalDate date); +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..51cb4de06 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository jpaRepository; + + @Override + public List findByMetricsDate(LocalDate metricsDate) { + return jpaRepository.findByMetricsDate(metricsDate); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 000000000..bfa6b2215 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + Optional findByProductIdAndPeriodStartDateAndPeriodEndDate( + Long productId, + LocalDate periodStartDate, + LocalDate periodEndDate + ); + + List findByPeriodStartDateAndPeriodEndDateOrderByRankingAsc( + LocalDate periodStartDate, + LocalDate periodEndDate + ); +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepositoryImpl.java new file mode 100644 index 000000000..a4c3671b1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankMonthlyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class MvProductRankMonthlyRepositoryImpl implements MvProductRankMonthlyRepository { + + private final MvProductRankMonthlyJpaRepository jpaRepository; + + @Override + public Optional findByProductIdAndPeriod(Long productId, LocalDate periodStartDate, LocalDate periodEndDate) { + return jpaRepository.findByProductIdAndPeriodStartDateAndPeriodEndDate(productId, periodStartDate, periodEndDate); + } + + @Override + public List findByPeriodOrderByRankingAsc(LocalDate periodStartDate, LocalDate periodEndDate) { + return jpaRepository.findByPeriodStartDateAndPeriodEndDateOrderByRankingAsc(periodStartDate, periodEndDate); + } + + @Override + public void save(MvProductRankMonthly rank) { + jpaRepository.save(rank); + } + + @Override + public void delete(MvProductRankMonthly rank) { + jpaRepository.delete(rank); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 000000000..94a8b0c97 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + Optional findByProductIdAndPeriodStartDateAndPeriodEndDate( + Long productId, + LocalDate periodStartDate, + LocalDate periodEndDate + ); + + List findByPeriodStartDateAndPeriodEndDateOrderByRankingAsc( + LocalDate periodStartDate, + LocalDate periodEndDate + ); +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepositoryImpl.java new file mode 100644 index 000000000..30285f98d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.MvProductRankWeeklyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class MvProductRankWeeklyRepositoryImpl implements MvProductRankWeeklyRepository { + + private final MvProductRankWeeklyJpaRepository jpaRepository; + + @Override + public Optional findByProductIdAndPeriod(Long productId, LocalDate periodStartDate, LocalDate periodEndDate) { + return jpaRepository.findByProductIdAndPeriodStartDateAndPeriodEndDate(productId, periodStartDate, periodEndDate); + } + + @Override + public List findByPeriodOrderByRankingAsc(LocalDate periodStartDate, LocalDate periodEndDate) { + return jpaRepository.findByPeriodStartDateAndPeriodEndDateOrderByRankingAsc(periodStartDate, periodEndDate); + } + + @Override + public void save(MvProductRankWeekly rank) { + jpaRepository.save(rank); + } + + @Override + public void delete(MvProductRankWeekly rank) { + jpaRepository.delete(rank); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/interfaces/scheduler/RankingBatchScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/interfaces/scheduler/RankingBatchScheduler.java new file mode 100644 index 000000000..c9ed890b4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/interfaces/scheduler/RankingBatchScheduler.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.scheduler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Component +public class RankingBatchScheduler { + + private final JobLauncher jobLauncher; + private final Job rankingJob; + + public RankingBatchScheduler( + JobLauncher jobLauncher, + @Qualifier("rankingJob") Job rankingJob + ) { + this.jobLauncher = jobLauncher; + this.rankingJob = rankingJob; + } + + @Scheduled(cron = "0 0 3 * * *") + public void runWeeklyRankingJob() { + try { + LocalDate yesterday = LocalDate.now().minusDays(1); + String targetDate = yesterday.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + log.info("주간 랭킹 배치 작업 시작: targetDate={}, time={}", targetDate, LocalDateTime.now()); + + JobParameters jobParameters = new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addString("targetDate", targetDate) + .addString("executionTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + .toJobParameters(); + + jobLauncher.run(rankingJob, jobParameters); + + log.info("주간 랭킹 배치 작업 완료: targetDate={}, time={}", targetDate, LocalDateTime.now()); + } catch (Exception e) { + log.error("주간 랭킹 배치 작업 실패", e); + } + } + + @Scheduled(cron = "0 30 3 * * *") + public void runMonthlyRankingJob() { + try { + LocalDate yesterday = LocalDate.now().minusDays(1); + String targetDate = yesterday.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + log.info("월간 랭킹 배치 작업 시작: targetDate={}, time={}", targetDate, LocalDateTime.now()); + + JobParameters jobParameters = new JobParametersBuilder() + .addString("rankingType", "MONTHLY") + .addString("targetDate", targetDate) + .addString("executionTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + .toJobParameters(); + + jobLauncher.run(rankingJob, jobParameters); + + log.info("월간 랭킹 배치 작업 완료: targetDate={}, time={}", targetDate, LocalDateTime.now()); + } catch (Exception e) { + log.error("월간 랭킹 배치 작업 실패", e); + } + } +} + diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..4c5d5c9dd --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,49 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # ?? ?? ??? ? (default : 200) + min-spare: 10 # ?? ?? ??? ? (default : 10) + connection-timeout: 1m # ?? ???? (ms) (default : 60000ms = 1m) + max-connections: 8192 # ?? ?? ?? ? (default : 8192) + accept-count: 100 # ?? ? ?? (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTests.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTests.java new file mode 100644 index 000000000..b0fd9d64a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTests.java @@ -0,0 +1,13 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CommerceBatchApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index c7abb9ee7..15a57440f 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -4,18 +4,29 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import jakarta.persistence.Version; import lombok.Builder; import lombok.Getter; +import java.time.LocalDate; + @Entity -@Table(name = "product_metrics") +@Table( + name = "product_metrics", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "metrics_date"}) + } +) @Getter public class ProductMetrics extends BaseEntity { - @Column(nullable = false, unique = true) + @Column(nullable = false) private Long productId; + @Column(name = "metrics_date", nullable = false) + private LocalDate metricsDate; + @Column(nullable = false) private Long likeCount; @@ -30,8 +41,9 @@ public class ProductMetrics extends BaseEntity { private Long version; @Builder - private ProductMetrics(Long productId, Long likeCount, Long salesCount, Long viewCount) { + private ProductMetrics(Long productId, LocalDate metricsDate, Long likeCount, Long salesCount, Long viewCount) { this.productId = productId; + this.metricsDate = metricsDate; this.likeCount = likeCount; this.salesCount = salesCount; this.viewCount = viewCount; @@ -40,9 +52,10 @@ private ProductMetrics(Long productId, Long likeCount, Long salesCount, Long vie public ProductMetrics() { } - public static ProductMetrics create(Long productId) { + public static ProductMetrics create(Long productId, LocalDate metricsDate) { return ProductMetrics.builder() .productId(productId) + .metricsDate(metricsDate) .likeCount(0L) .salesCount(0L) .viewCount(0L) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java index 75f82673b..186f0a469 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.metrics; +import java.time.LocalDate; import java.util.Optional; public interface ProductMetricsRepository { @@ -7,6 +8,8 @@ public interface ProductMetricsRepository { Optional findByProductId(Long productId); - int incrementLikeCount(Long productId); + Optional findByProductIdAndMetricsDate(Long productId, LocalDate metricsDate); + + int incrementLikeCount(Long productId, LocalDate metricsDate); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java index 8494ce6dd..25d84612e 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -6,6 +6,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; + @RequiredArgsConstructor @Service @Slf4j @@ -15,25 +17,27 @@ public class ProductMetricsService { @Transactional public void incrementLikeCount(Long productId) { - int updatedRows = productMetricsRepository.incrementLikeCount(productId); + LocalDate today = LocalDate.now(); + int updatedRows = productMetricsRepository.incrementLikeCount(productId, today); if (updatedRows == 0) { try { - ProductMetrics newMetrics = ProductMetrics.create(productId); + ProductMetrics newMetrics = ProductMetrics.create(productId, today); newMetrics.incrementLikeCount(); productMetricsRepository.saveProductMetrics(newMetrics); } catch (DataIntegrityViolationException e) { - productMetricsRepository.incrementLikeCount(productId); + productMetricsRepository.incrementLikeCount(productId, today); } } } @Transactional public void decrementLikeCount(Long productId) { - ProductMetrics productMetrics = productMetricsRepository.findByProductId(productId) + LocalDate today = LocalDate.now(); + ProductMetrics productMetrics = productMetricsRepository.findByProductIdAndMetricsDate(productId, today) .orElseGet(() -> { - log.warn("좋아요 취소 시 메트릭이 존재하지 않음: productId={}", productId); - return ProductMetrics.create(productId); + log.warn("좋아요 취소 시 메트릭이 존재하지 않음: productId={}, metricsDate={}", productId, today); + return ProductMetrics.create(productId, today); }); productMetrics.decrementLikeCount(); productMetricsRepository.saveProductMetrics(productMetrics); @@ -41,9 +45,10 @@ public void decrementLikeCount(Long productId) { @Transactional public void incrementViewCount(Long productId) { - ProductMetrics metrics = productMetricsRepository.findByProductId(productId) + LocalDate today = LocalDate.now(); + ProductMetrics metrics = productMetricsRepository.findByProductIdAndMetricsDate(productId, today) .orElseGet(() -> { - ProductMetrics newMetrics = ProductMetrics.create(productId); + ProductMetrics newMetrics = ProductMetrics.create(productId, today); productMetricsRepository.saveProductMetrics(newMetrics); return newMetrics; }); @@ -53,9 +58,10 @@ public void incrementViewCount(Long productId) { @Transactional public void incrementSalesCount(Long productId, Integer quantity) { - ProductMetrics metrics = productMetricsRepository.findByProductId(productId) + LocalDate today = LocalDate.now(); + ProductMetrics metrics = productMetricsRepository.findByProductIdAndMetricsDate(productId, today) .orElseGet(() -> { - ProductMetrics newMetrics = ProductMetrics.create(productId); + ProductMetrics newMetrics = ProductMetrics.create(productId, today); productMetricsRepository.saveProductMetrics(newMetrics); return newMetrics; }); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java index 500354e3d..72c4edbe8 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.metrics; import com.loopers.domain.metrics.ProductMetrics; +import java.time.LocalDate; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -10,8 +11,10 @@ public interface ProductMetricsJpaRepository extends JpaRepository { Optional findByProductId(Long productId); + Optional findByProductIdAndMetricsDate(Long productId, LocalDate metricsDate); + @Modifying - @Query("UPDATE ProductMetrics m SET m.likeCount = m.likeCount + 1 WHERE m.productId = :productId") - int incrementLikeCount(@Param("productId") Long productId); + @Query("UPDATE ProductMetrics m SET m.likeCount = m.likeCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate") + int incrementLikeCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index 6f1af77d3..8f4e4fc54 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -2,6 +2,7 @@ import com.loopers.domain.metrics.ProductMetrics; import com.loopers.domain.metrics.ProductMetricsRepository; +import java.time.LocalDate; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -23,8 +24,13 @@ public Optional findByProductId(Long productId) { } @Override - public int incrementLikeCount(Long productId) { - return productMetricsJpaRepository.incrementLikeCount(productId); + public Optional findByProductIdAndMetricsDate(Long productId, LocalDate metricsDate) { + return productMetricsJpaRepository.findByProductIdAndMetricsDate(productId, metricsDate); + } + + @Override + public int incrementLikeCount(Long productId, LocalDate metricsDate) { + return productMetricsJpaRepository.incrementLikeCount(productId, metricsDate); } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsConsumerIdempotencyTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsConsumerIdempotencyTest.java index 8e5918acc..54cbbdc7a 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsConsumerIdempotencyTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsConsumerIdempotencyTest.java @@ -15,6 +15,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.kafka.support.Acknowledgment; +import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.List; @@ -74,7 +75,8 @@ void incrementsLikeCountOnlyOnce_whenDuplicateEventReceived() { ); // assert - 첫 번째 처리 결과 확인 - ProductMetrics metrics1 = productMetricsJpaRepository.findByProductId(productId).orElseThrow(); + LocalDate today = LocalDate.now(); + ProductMetrics metrics1 = productMetricsJpaRepository.findByProductIdAndMetricsDate(productId, today).orElseThrow(); assertThat(metrics1.getLikeCount()).isEqualTo(1L); assertThat(eventHandledJpaRepository.existsByEventId(eventId)).isTrue(); @@ -85,7 +87,7 @@ void incrementsLikeCountOnlyOnce_whenDuplicateEventReceived() { ); // assert - 두 번째 처리 후에도 좋아요 수가 증가하지 않음 - ProductMetrics metrics2 = productMetricsJpaRepository.findByProductId(productId).orElseThrow(); + ProductMetrics metrics2 = productMetricsJpaRepository.findByProductIdAndMetricsDate(productId, today).orElseThrow(); assertThat(metrics2.getLikeCount()).isEqualTo(1L); // act - 세 번째 처리 (중복) @@ -95,7 +97,7 @@ void incrementsLikeCountOnlyOnce_whenDuplicateEventReceived() { ); // assert - 세 번째 처리 후에도 좋아요 수가 증가하지 않음 - ProductMetrics metrics3 = productMetricsJpaRepository.findByProductId(productId).orElseThrow(); + ProductMetrics metrics3 = productMetricsJpaRepository.findByProductIdAndMetricsDate(productId, today).orElseThrow(); assertThat(metrics3.getLikeCount()).isEqualTo(1L); // verify - EventHandled는 한 번만 저장됨 @@ -136,7 +138,8 @@ void incrementsViewCountOnlyOnce_whenDuplicateEventReceived() { ); // assert - 첫 번째 처리 결과 확인 - ProductMetrics metrics1 = productMetricsJpaRepository.findByProductId(productId).orElseThrow(); + LocalDate today = LocalDate.now(); + ProductMetrics metrics1 = productMetricsJpaRepository.findByProductIdAndMetricsDate(productId, today).orElseThrow(); assertThat(metrics1.getViewCount()).isEqualTo(1L); // act - 두 번째 처리 (중복) @@ -146,7 +149,7 @@ void incrementsViewCountOnlyOnce_whenDuplicateEventReceived() { ); // assert - 두 번째 처리 후에도 조회 수가 증가하지 않음 - ProductMetrics metrics2 = productMetricsJpaRepository.findByProductId(productId).orElseThrow(); + ProductMetrics metrics2 = productMetricsJpaRepository.findByProductIdAndMetricsDate(productId, today).orElseThrow(); assertThat(metrics2.getViewCount()).isEqualTo(1L); } } @@ -194,8 +197,9 @@ void incrementsSalesCountOnlyOnce_whenDuplicateEventReceived() { ); // assert - 첫 번째 처리 결과 확인 - ProductMetrics metrics1_product1 = productMetricsJpaRepository.findByProductId(10L).orElseThrow(); - ProductMetrics metrics1_product2 = productMetricsJpaRepository.findByProductId(20L).orElseThrow(); + LocalDate today = LocalDate.now(); + ProductMetrics metrics1_product1 = productMetricsJpaRepository.findByProductIdAndMetricsDate(10L, today).orElseThrow(); + ProductMetrics metrics1_product2 = productMetricsJpaRepository.findByProductIdAndMetricsDate(20L, today).orElseThrow(); assertThat(metrics1_product1.getSalesCount()).isEqualTo(2L); assertThat(metrics1_product2.getSalesCount()).isEqualTo(1L); @@ -206,8 +210,8 @@ void incrementsSalesCountOnlyOnce_whenDuplicateEventReceived() { ); // assert - 두 번째 처리 후에도 판매량이 증가하지 않음 - ProductMetrics metrics2_product1 = productMetricsJpaRepository.findByProductId(10L).orElseThrow(); - ProductMetrics metrics2_product2 = productMetricsJpaRepository.findByProductId(20L).orElseThrow(); + ProductMetrics metrics2_product1 = productMetricsJpaRepository.findByProductIdAndMetricsDate(10L, today).orElseThrow(); + ProductMetrics metrics2_product2 = productMetricsJpaRepository.findByProductIdAndMetricsDate(20L, today).orElseThrow(); assertThat(metrics2_product1.getSalesCount()).isEqualTo(2L); assertThat(metrics2_product2.getSalesCount()).isEqualTo(1L); } @@ -253,7 +257,8 @@ void processesEachEventIdempotently_whenMixedEventsReceived() { metricsConsumer.handleProductViewedEvents(List.of(viewedRecord1), acknowledgment); // assert - 첫 번째 처리 결과 확인 - ProductMetrics metrics1 = productMetricsJpaRepository.findByProductId(productId).orElseThrow(); + LocalDate today = LocalDate.now(); + ProductMetrics metrics1 = productMetricsJpaRepository.findByProductIdAndMetricsDate(productId, today).orElseThrow(); assertThat(metrics1.getLikeCount()).isEqualTo(1L); assertThat(metrics1.getViewCount()).isEqualTo(1L); @@ -262,7 +267,7 @@ void processesEachEventIdempotently_whenMixedEventsReceived() { metricsConsumer.handleProductViewedEvents(List.of(viewedRecord2), acknowledgment); // assert - 두 번째 처리 후에도 증가하지 않음 - ProductMetrics metrics2 = productMetricsJpaRepository.findByProductId(productId).orElseThrow(); + ProductMetrics metrics2 = productMetricsJpaRepository.findByProductIdAndMetricsDate(productId, today).orElseThrow(); assertThat(metrics2.getLikeCount()).isEqualTo(1L); assertThat(metrics2.getViewCount()).isEqualTo(1L); diff --git a/modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java index ce5b10871..d2442c716 100644 --- a/modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java @@ -46,6 +46,17 @@ public KafkaTemplate kafkaTemplate(ProducerFactory(producerFactory); } + @Bean + public ProducerFactory stringProducerFactory(KafkaProperties kafkaProperties) { + Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); + return new DefaultKafkaProducerFactory<>(props); + } + + @Bean + public KafkaTemplate stringKafkaTemplate(ProducerFactory producerFactory) { + return new KafkaTemplate<>(producerFactory); + } + @Bean public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMapper) { return new ByteArrayJsonMessageConverter(objectMapper); diff --git a/settings.gradle.kts b/settings.gradle.kts index 161a1ba24..63eb9d805 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ include( ":apps:commerce-api", ":apps:pg-simulator", ":apps:commerce-streamer", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka",