diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..1f80db6bf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,13 @@ +name: PR Agent +on: + pull_request: + types: [opened, synchronize] +jobs: + pr_agent_job: + runs-on: ubuntu-latest + steps: + - name: PR Agent action step + uses: Codium-ai/pr-agent@main + env: + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + GITHUB_TOKEN: ${{ secrets.G_TOKEN }} diff --git a/README.md b/README.md index 04950f29d..f86e4dd8a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up Root ├── apps ( spring-applications ) │ ├── 📦 commerce-api +│ ├── 📦 commerce-batch │ └── 📦 commerce-streamer ├── modules ( reusable-configurations ) │ ├── 📦 jpa diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index 065177e88..3bff4bf5f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -62,7 +62,8 @@ public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Lon return of(product, brand, likeCount, isLiked, null); } - public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked, RankingItem ranking) { + public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked, + RankingItem ranking) { if (product == null) { throw new IllegalArgumentException("상품 정보는 필수입니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 1d912a891..50264d784 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -13,6 +13,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import com.loopers.application.ranking.MonthlyRankingService; +import com.loopers.application.ranking.WeeklyRankingService; import com.loopers.cache.CacheStrategy; import com.loopers.cache.RankingRedisService; import com.loopers.cache.dto.CachePayloads.RankingItem; @@ -21,6 +23,9 @@ import com.loopers.domain.like.LikeService; import com.loopers.domain.product.*; import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.domain.ranking.MonthlyRankEntity; +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.domain.ranking.WeeklyRankEntity; import com.loopers.domain.tracking.UserBehaviorTracker; import com.loopers.domain.user.UserService; @@ -46,6 +51,8 @@ public class ProductFacade { private final BrandService brandService; private final UserBehaviorTracker behaviorTracker; private final RankingRedisService rankingRedisService; + private final WeeklyRankingService weeklyRankingService; + private final MonthlyRankingService monthlyRankingService; /** * 도메인 서비스에서 MV 엔티티를 조회하고, Facade에서 DTO로 변환합니다. @@ -70,7 +77,7 @@ public Page getProducts(ProductSearchFilter productSearchFilter) { * 도메인 서비스에서 엔티티를 조회하고, Facade에서 DTO로 변환합니다. * * @param productId 상품 ID - * @param username 사용자 ID (nullable) + * @param username 사용자 ID (nullable) * @return 상품 상세 정보 */ @Transactional(readOnly = true) @@ -129,11 +136,11 @@ public ProductDetailInfo getProductDetail(Long productId, String username) { @Transactional(readOnly = true) public Page getRankingProducts(Pageable pageable, LocalDate date) { LocalDate targetDate = date != null ? date : LocalDate.now(); - + // 1. 랭킹 조회 List rankings = rankingRedisService.getRanking( targetDate, - pageable.getPageNumber() + 1, + pageable.getPageNumber() + 1, pageable.getPageSize() ); @@ -141,13 +148,13 @@ public Page getRankingProducts(Pageable pageable, LocalDate date) { if (rankings.isEmpty() && date == null) { LocalDate yesterday = targetDate.minusDays(1); log.info("콜드 스타트 Fallback: 오늘({}) 랭킹 없음, 어제({}) 랭킹 조회", targetDate, yesterday); - + rankings = rankingRedisService.getRanking( yesterday, pageable.getPageNumber() + 1, pageable.getPageSize() ); - + if (!rankings.isEmpty()) { targetDate = yesterday; // totalCount 계산을 위해 날짜 변경 } @@ -181,6 +188,119 @@ public Page getRankingProducts(Pageable pageable, LocalDate date) { return new PageImpl<>(sortedProducts, pageable, totalCount); } + /** + * 기간별 랭킹 상품 목록 조회 + * + * @param period 랭킹 기간 (DAILY, WEEKLY, MONTHLY) + * @param pageable 페이징 정보 + * @param date 조회 날짜 (DAILY용, null이면 오늘) + * @param yearWeek 조회 주차 (WEEKLY용, 예: "2024-W52") + * @param yearMonth 조회 월 (MONTHLY용, 예: "2024-12") + * @return 랭킹 상품 목록 + */ + @Transactional(readOnly = true) + public Page getRankingProductsByPeriod( + RankingPeriod period, + Pageable pageable, + LocalDate date, + String yearWeek, + String yearMonth) { + + return switch (period) { + case DAILY -> getRankingProducts(pageable, date); + case WEEKLY -> getWeeklyRankingProducts(pageable, yearWeek); + case MONTHLY -> getMonthlyRankingProducts(pageable, yearMonth); + }; + } + + /** + * 주간 랭킹 상품 목록 조회 + * + * @param pageable 페이징 정보 + * @param yearWeek 조회 주차 (예: "2024-W52") + * @return 주간 랭킹 상품 목록 + */ + @Transactional(readOnly = true) + public Page getWeeklyRankingProducts(Pageable pageable, String yearWeek) { + if (yearWeek == null || yearWeek.trim().isEmpty()) { + log.warn("주간 랭킹 조회 시 yearWeek 파라미터가 필요합니다"); + return Page.empty(pageable); + } + + // 1. 주간 랭킹 조회 + Page weeklyRankings = weeklyRankingService.getWeeklyRanking(yearWeek, pageable); + + if (weeklyRankings.isEmpty()) { + log.debug("주간 랭킹 데이터 없음: yearWeek={}", yearWeek); + return Page.empty(pageable); + } + + // 2. 상품 ID 목록 추출 + List productIds = weeklyRankings.getContent().stream() + .map(WeeklyRankEntity::getProductId) + .collect(Collectors.toList()); + + // 3. 상품 정보 조회 (MV 사용) + List products = mvService.getByIds(productIds); + + // 4. 랭킹 순서대로 정렬 + List sortedProducts = productIds.stream() + .map(productId -> products.stream() + .filter(p -> p.getProductId().equals(productId)) + .findFirst() + .map(ProductInfo::from) + .orElse(null)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // 5. Page 객체 생성 + return new PageImpl<>(sortedProducts, pageable, weeklyRankings.getTotalElements()); + } + + /** + * 월간 랭킹 상품 목록 조회 + * + * @param pageable 페이징 정보 + * @param yearMonth 조회 월 (예: "2024-12") + * @return 월간 랭킹 상품 목록 + */ + @Transactional(readOnly = true) + public Page getMonthlyRankingProducts(Pageable pageable, String yearMonth) { + if (yearMonth == null || yearMonth.trim().isEmpty()) { + log.warn("월간 랭킹 조회 시 yearMonth 파라미터가 필요합니다"); + return Page.empty(pageable); + } + + // 1. 월간 랭킹 조회 + Page monthlyRankings = monthlyRankingService.getMonthlyRanking(yearMonth, pageable); + + if (monthlyRankings.isEmpty()) { + log.debug("월간 랭킹 데이터 없음: yearMonth={}", yearMonth); + return Page.empty(pageable); + } + + // 2. 상품 ID 목록 추출 + List productIds = monthlyRankings.getContent().stream() + .map(MonthlyRankEntity::getProductId) + .collect(Collectors.toList()); + + // 3. 상품 정보 조회 (MV 사용) + List products = mvService.getByIds(productIds); + + // 4. 랭킹 순서대로 정렬 + List sortedProducts = productIds.stream() + .map(productId -> products.stream() + .filter(p -> p.getProductId().equals(productId)) + .findFirst() + .map(ProductInfo::from) + .orElse(null)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // 5. Page 객체 생성 + return new PageImpl<>(sortedProducts, pageable, monthlyRankings.getTotalElements()); + } + /** * 상품을 삭제합니다. *

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java new file mode 100644 index 000000000..02f968fc3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java @@ -0,0 +1,48 @@ +package com.loopers.application.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.ranking.MonthlyRankEntity; +import com.loopers.domain.ranking.MonthlyRankRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 월간 랭킹 조회 서비스 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class MonthlyRankingService { + + private final MonthlyRankRepository monthlyRankRepository; + + /** + * 특정 월의 랭킹을 페이지네이션하여 조회합니다. + * + * @param yearMonth 조회할 월 (예: "2024-12") + * @param pageable 페이징 정보 + * @return 월간 랭킹 페이지 + */ + public Page getMonthlyRanking(String yearMonth, Pageable pageable) { + log.debug("월간 랭킹 조회: yearMonth={}, page={}, size={}", + yearMonth, pageable.getPageNumber(), pageable.getPageSize()); + + // 1. 전체 랭킹 조회 (순위 순으로 정렬됨) + Page pagedRankings = monthlyRankRepository.findByYearMonth(yearMonth, pageable); + + + log.debug("월간 랭킹 조회 완료: yearMonth={}, 전체={}, 페이지={}", + yearMonth, pagedRankings.getTotalPages(), pagedRankings.getNumber()); + + return pagedRankings; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java new file mode 100644 index 000000000..27c2c24d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java @@ -0,0 +1,60 @@ +package com.loopers.application.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.ranking.WeeklyRankEntity; +import com.loopers.domain.ranking.WeeklyRankRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 주간 랭킹 조회 서비스 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class WeeklyRankingService { + + private final WeeklyRankRepository weeklyRankRepository; + + /** + * 특정 주차의 랭킹을 페이지네이션하여 조회합니다. + * + * @param yearWeek 조회할 주차 (예: "2024-W52") + * @param pageable 페이징 정보 + * @return 주간 랭킹 페이지 + */ + public Page getWeeklyRanking(String yearWeek, Pageable pageable) { + log.debug("주간 랭킹 조회: yearWeek={}, page={}, size={}", + yearWeek, pageable.getPageNumber(), pageable.getPageSize()); + + // 1. 전체 랭킹 조회 (순위 순으로 정렬됨) + Page pagedRankings = weeklyRankRepository.findByYearWeek(yearWeek , pageable); + + if (pagedRankings.isEmpty()) { + log.debug("주간 랭킹 데이터 없음: yearWeek={}", yearWeek); + return Page.empty(pageable); + } + + return pagedRankings; + } + + /** + * 특정 주차의 전체 랭킹 개수를 조회합니다. + * + * @param yearWeek 조회할 주차 + * @return 랭킹 개수 + */ + public long getWeeklyRankingCount(String yearWeek) { + List rankings = weeklyRankRepository.findByYearWeek(yearWeek); + return rankings.size(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java index 570da9cea..63187cc67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java @@ -4,7 +4,6 @@ import java.util.Objects; import com.loopers.domain.BaseEntity; -import com.loopers.domain.product.ProductEntity; import com.loopers.domain.user.UserEntity; import lombok.AccessLevel; 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..cddb3e7cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -0,0 +1,21 @@ +package com.loopers.domain.ranking; + +/** + * 랭킹 조회 기간 타입 + */ +public enum RankingPeriod { + /** + * 일간 랭킹 (Redis ZSET 기반) + */ + DAILY, + + /** + * 주간 랭킹 (mv_product_rank_weekly 기반) + */ + WEEKLY, + + /** + * 월간 랭킹 (mv_product_rank_monthly 기반) + */ + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java index 9312e8d0e..330ca1e2e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java @@ -6,7 +6,6 @@ import java.util.Objects; import com.loopers.domain.BaseEntity; -import com.loopers.support.Uris; import lombok.AccessLevel; import lombok.Getter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java new file mode 100644 index 000000000..d45fcd5eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.ranking.MonthlyRankEntity; +import com.loopers.domain.ranking.MonthlyRankId; + +/** + * 월간 랭킹 JPA Repository (commerce-api용) + */ +public interface MonthlyRankJpaRepository extends JpaRepository { + + /** + * 특정 월의 랭킹을 순위 순으로 조회합니다. + */ + @Query("SELECT m FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth ORDER BY m.rankPosition ASC") + List findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth); + + /** + * 특정 월의 랭킹을 순위 순으로 페이지네이션하여 조회합니다. + */ + @Query("SELECT m FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth ORDER BY m.rankPosition ASC") + Page findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable); + + /** + * 특정 월의 모든 랭킹을 삭제합니다. + * + * @param yearMonth 삭제할 월 + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth") + long deleteByIdYearMonth(@Param("yearMonth") String yearMonth); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java new file mode 100644 index 000000000..cbee3ed47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.ranking.MonthlyRankEntity; +import com.loopers.domain.ranking.MonthlyRankRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 랭킹 Repository 구현체 (commerce-api용) + */ +@Repository +@RequiredArgsConstructor +public class MonthlyRankRepositoryImpl implements MonthlyRankRepository { + + private final MonthlyRankJpaRepository jpaRepository; + + @Override + public MonthlyRankEntity save(MonthlyRankEntity entity) { + return jpaRepository.save(entity); + } + + @Override + public List saveAll(List entities) { + return jpaRepository.saveAll(entities); + } + + @Override + public Page findByYearMonth(String yearMonth, Pageable pageable) { + return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth, pageable); + } + + @Override + public long deleteByYearMonth(String yearMonth) { + return jpaRepository.deleteByIdYearMonth(yearMonth); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java new file mode 100644 index 000000000..be16adc99 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.ranking.WeeklyRankEntity; +import com.loopers.domain.ranking.WeeklyRankId; + +/** + * 주간 랭킹 JPA Repository (commerce-api용) + */ +public interface WeeklyRankJpaRepository extends JpaRepository { + + /** + * 특정 주차의 랭킹을 순위 순으로 조회합니다. + */ + @Query("SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC") + List findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek); + + /** + * 특정 주차의 랭킹을 순위 순으로 페이지네이션하여 조회합니다. + */ + @Query("SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC") + Page findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable); + + /** + * 특정 주차의 모든 랭킹을 삭제합니다. + * + * @param yearWeek 삭제할 주차 + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek") + long deleteByIdYearWeek(@Param("yearWeek") String yearWeek); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java new file mode 100644 index 000000000..9b3d2d221 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.ranking.WeeklyRankEntity; +import com.loopers.domain.ranking.WeeklyRankRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 랭킹 Repository 구현체 (commerce-api용) + */ +@Repository +@RequiredArgsConstructor +public class WeeklyRankRepositoryImpl implements WeeklyRankRepository { + + private final WeeklyRankJpaRepository jpaRepository; + + @Override + public WeeklyRankEntity save(WeeklyRankEntity entity) { + return jpaRepository.save(entity); + } + + @Override + public List saveAll(List entities) { + return jpaRepository.saveAll(entities); + } + + @Override + public List findByYearWeek(String yearWeek) { + return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek); + } + @Override + public long deleteByYearWeek(String yearWeek) { + return jpaRepository.deleteByIdYearWeek(yearWeek); + } + + @Override + public Page findByYearWeek(String yearWeek, Pageable pageable) { + return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index daed7d359..1684fcd0a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -5,8 +5,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import java.time.LocalDate; - import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.PathVariable; @@ -31,21 +29,6 @@ ApiResponse> getProducts( @PageableDefault(size = 20) Pageable pageable, @RequestParam(required = false) Long brandId, @RequestParam(required = false) String productName - ); - - - @Operation( - summary = "랭킹 상품 목록 조회", - description = "일자 기준 랭킹 상품 목록을 페이징하여 조회합니다. date 파라미터가 없으면 오늘 날짜 기준으로 조회합니다." - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청") - }) - ApiResponse> getRankingProducts( - @PageableDefault(size = 20) Pageable pageable, - @Parameter(description = "조회 날짜 (yyyy-MM-dd 형식, 선택)", example = "2025-12-23") - @RequestParam(required = false) LocalDate date ); @Operation( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 85bac4c0c..4f6dc50e7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,7 +1,5 @@ package com.loopers.interfaces.api.product; -import java.time.LocalDate; - import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -10,7 +8,6 @@ import com.loopers.application.product.ProductDetailInfo; import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; -import com.loopers.cache.dto.CachePayloads.RankingItem; import com.loopers.domain.product.dto.ProductSearchFilter; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.common.PageResponse; @@ -37,17 +34,6 @@ public ApiResponse> getProducts( return ApiResponse.success(PageResponse.from(responsePage)); } - @GetMapping(Uris.Ranking.GET_RANKING) - @Override - public ApiResponse> getRankingProducts( - @PageableDefault(size = 20) Pageable pageable, - @RequestParam(required = false) LocalDate date - ) { - Page products = productFacade.getRankingProducts(pageable, date); - Page responsePage = products.map(ProductV1Dtos.ProductListResponse::from); - return ApiResponse.success(PageResponse.from(responsePage)); - } - @GetMapping(Uris.Product.GET_DETAIL) @Override public ApiResponse getProductDetail( @@ -55,7 +41,7 @@ public ApiResponse getProductDetail( @RequestHeader(value = "X-USER-ID", required = false) String username ) { ProductDetailInfo productDetail = productFacade.getProductDetail(productId, username); - + // 3. 응답 생성 return ApiResponse.success(ProductV1Dtos.ProductDetailResponse.from(productDetail)); } 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 new file mode 100644 index 000000000..8f3b8a25a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.ranking; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; + +import org.springframework.data.domain.Pageable; + +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.ProductV1Dtos; + +/** + * 랭킹 API 명세 + * - 일간/주간/월간 랭킹 조회 API + */ +@Tag(name = "Ranking", description = "상품 랭킹 API") +public interface RankingV1ApiSpec { + + @Operation( + summary = "일간 랭킹 조회", + description = "특정 날짜의 상품 랭킹을 조회합니다. 날짜 미지정 시 오늘 기준이며, 데이터가 없으면 어제 랭킹으로 fallback됩니다." + ) + ApiResponse> getRankingProducts( + Pageable pageable, + @Parameter(description = "조회할 날짜 (YYYY-MM-DD)", example = "2024-12-26") + LocalDate date + ); + + @Operation( + summary = "기간별 랭킹 조회", + description = "주간/월간 랭킹을 조회합니다. Java 8 Date API를 활용하여 파라미터를 자동 처리합니다." + ) + ApiResponse> getRankingProductsByPeriod( + @Parameter(description = "랭킹 기간", example = "WEEKLY") + RankingPeriod period, + Pageable pageable, + @Parameter(description = "기준 날짜 (YYYY-MM-DD)", example = "2024-12-26") + LocalDate date, + @Parameter(description = "연도-주차 (YYYY-WNN)", example = "2024-W52") + String yearWeek, + @Parameter(description = "연도-월 (YYYY-MM)", example = "2024-12") + String yearMonth + ); +} 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 new file mode 100644 index 000000000..37c0ba8dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,103 @@ +package com.loopers.interfaces.api.ranking; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.Locale; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.ProductV1Dtos; +import com.loopers.support.Uris; + +import lombok.RequiredArgsConstructor; + +/** + * 랭킹 전용 Controller + * - 일간/주간/월간 랭킹 API 제공 + * - Java 8 Date API 활용 + */ +@RestController +@RequiredArgsConstructor +public class RankingV1Controller implements RankingV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping(Uris.Ranking.GET_RANKING) + @Override + public ApiResponse> getRankingProducts( + @PageableDefault(size = 20) Pageable pageable, + @RequestParam(required = false) LocalDate date + ) { + Page products = productFacade.getRankingProducts(pageable, date); + Page responsePage = products.map(ProductV1Dtos.ProductListResponse::from); + return ApiResponse.success(PageResponse.from(responsePage)); + } + + @GetMapping(Uris.Ranking.GET_RANKING_BY_PERIOD) + @Override + public ApiResponse> getRankingProductsByPeriod( + @RequestParam RankingPeriod period, + @PageableDefault(size = 20) Pageable pageable, + @RequestParam(required = false) LocalDate date, + @RequestParam(required = false) String yearWeek, + @RequestParam(required = false) String yearMonth + ) { + // Java 8 Date API를 활용한 파라미터 검증 및 변환 + String processedYearWeek = processYearWeekParameter(yearWeek, date); + String processedYearMonth = processYearMonthParameter(yearMonth, date); + + Page products = productFacade.getRankingProductsByPeriod( + period, pageable, date, processedYearWeek, processedYearMonth); + Page responsePage = products.map(ProductV1Dtos.ProductListResponse::from); + return ApiResponse.success(PageResponse.from(responsePage)); + } + + /** + * yearWeek 파라미터 처리 + * - 파라미터가 없으면 현재 주차로 설정 + * - Java 8 WeekFields 활용 + */ + private String processYearWeekParameter(String yearWeek, LocalDate date) { + if (yearWeek != null && !yearWeek.trim().isEmpty()) { + return yearWeek; + } + + // date가 있으면 해당 날짜의 주차, 없으면 현재 주차 + LocalDate targetDate = date != null ? date : LocalDate.now(); + + WeekFields weekFields = WeekFields.ISO; + int year = targetDate.getYear(); + int week = targetDate.get(weekFields.weekOfWeekBasedYear()); + + return String.format("%d-W%02d", year, week); + } + + /** + * yearMonth 파라미터 처리 + * - 파라미터가 없으면 현재 월로 설정 + * - Java 8 YearMonth 활용 + */ + private String processYearMonthParameter(String yearMonth, LocalDate date) { + if (yearMonth != null && !yearMonth.trim().isEmpty()) { + return yearMonth; + } + + // date가 있으면 해당 날짜의 월, 없으면 현재 월 + LocalDate targetDate = date != null ? date : LocalDate.now(); + YearMonth ym = YearMonth.from(targetDate); + + return ym.format(DateTimeFormatter.ofPattern("yyyy-MM")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/Uris.java b/apps/commerce-api/src/main/java/com/loopers/support/Uris.java index 598e71128..9409a5158 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/Uris.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/Uris.java @@ -88,6 +88,7 @@ private Ranking() { public static final String BASE = API_V1 + "/rankings"; public static final String GET_RANKING = BASE; + public static final String GET_RANKING_BY_PERIOD = BASE + "/period"; } /** diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java index b64e4c8e0..f5e2b66b0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java @@ -1,11 +1,10 @@ package com.loopers.application.product; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Answers.RETURNS_DEEP_STUBS; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; -import static org.mockito.Answers.RETURNS_DEEP_STUBS; - import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; @@ -28,7 +27,10 @@ import com.loopers.cache.dto.CachePayloads.RankingItem; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeService; -import com.loopers.domain.product.*; +import com.loopers.domain.product.ProductCacheService; +import com.loopers.domain.product.ProductMVService; +import com.loopers.domain.product.ProductMaterializedViewEntity; +import com.loopers.domain.product.ProductService; import com.loopers.domain.tracking.UserBehaviorTracker; import com.loopers.domain.user.UserService; @@ -79,10 +81,10 @@ private ProductMaterializedViewEntity createMockMVEntity(Long productId, String when(mv.getBrandId()).thenReturn(1L); when(mv.getBrandName()).thenReturn("Test Brand"); when(mv.getCreatedAt()).thenReturn(java.time.ZonedDateTime.now()); - + when(mv.getPrice().getOriginPrice()).thenReturn(BigDecimal.valueOf(10000)); when(mv.getPrice().getDiscountPrice()).thenReturn(BigDecimal.valueOf(9000)); - + return mv; } @@ -243,7 +245,7 @@ void shouldIncludeRankingInProductDetail() { // Given Long productId = 301L; LocalDate today = LocalDate.now(); - + ProductMaterializedViewEntity mvEntity = createMockMVEntity(productId, "Ranked Product"); RankingItem ranking = new RankingItem(5, productId, 75.0); @@ -268,7 +270,7 @@ void shouldHaveNullRankingForUnrankedProduct() { // Given Long productId = 302L; LocalDate today = LocalDate.now(); - + ProductMaterializedViewEntity mvEntity = createMockMVEntity(productId, "Unranked Product"); when(productCacheService.getProductDetailFromCache(productId)).thenReturn(Optional.empty()); diff --git a/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java index 49ee37912..30b41ac8a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java +++ b/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java @@ -1,5 +1,6 @@ package com.loopers.fixtures; +import java.math.BigDecimal; import java.time.LocalDate; import org.assertj.core.api.Assertions; @@ -165,20 +166,20 @@ public static class InvalidGender { * 사용자의 포인트가 0인지 검증하는 헬퍼 메서드 */ public static void assertUserPointIsZero(UserEntity user) { - Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(java.math.BigDecimal.ZERO.setScale(2)); + Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(BigDecimal.ZERO.setScale(2)); } /** * 사용자의 포인트 금액 검증 헬퍼 메서드 */ - public static void assertUserPointAmount(UserEntity user, java.math.BigDecimal expectedAmount) { + public static void assertUserPointAmount(UserEntity user, BigDecimal expectedAmount) { Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(expectedAmount); } /** * 포인트 충전 실패 검증 헬퍼 메서드 */ - public static void assertChargePointFails(UserEntity user, java.math.BigDecimal amount, String expectedMessage) { + public static void assertChargePointFails(UserEntity user, BigDecimal amount, String expectedMessage) { Assertions.assertThatThrownBy(() -> user.chargePoint(amount)) .isInstanceOf(IllegalArgumentException.class) .hasMessage(expectedMessage); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java similarity index 86% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java index bb3a128e6..718cbb74f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java @@ -1,21 +1,25 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.ranking; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; - import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.*; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import com.loopers.cache.CacheKeyGenerator; import com.loopers.cache.RankingRedisService; @@ -23,9 +27,13 @@ import com.loopers.cache.dto.CachePayloads.RankingScore.EventType; import com.loopers.domain.brand.BrandEntity; import com.loopers.domain.brand.BrandService; -import com.loopers.domain.product.*; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductMVService; +import com.loopers.domain.product.ProductService; import com.loopers.fixtures.BrandTestFixture; import com.loopers.fixtures.ProductTestFixture; +import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.common.PageResponse; import com.loopers.interfaces.api.product.ProductV1Dtos; import com.loopers.support.Uris; @@ -42,7 +50,7 @@ */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @DisplayName("Ranking API E2E 테스트") -class RankingV1ApiE2ETest { +class RankingApiE2ETest { @Autowired private TestRestTemplate testRestTemplate; @@ -79,7 +87,7 @@ void setUp() { databaseCleanUp.truncateAllTables(); redisCleanUp.truncateAll(); testProductIds.clear(); - Long testBrandId = null; + Long testBrandId; today = LocalDate.now(); @@ -114,7 +122,7 @@ class GetRankingProductsTest { @Test @DisplayName("랭킹 데이터가 있으면 점수 순으로 상품 목록을 반환한다") void should_return_products_in_ranking_order() { - // Given - Redis에 랭킹 데이터 직접 적재 + // given - Redis에 랭킹 데이터 직접 적재 Long product1 = testProductIds.get(0); // 1위 (높은 점수) Long product2 = testProductIds.get(1); // 3위 Long product3 = testProductIds.get(2); // 2위 @@ -127,40 +135,42 @@ void should_return_products_in_ranking_order() { ); rankingRedisService.updateRankingScoresBatch(scores, today); - // When + // when ParameterizedTypeReference>> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity>> response = testRestTemplate.exchange( Uris.Ranking.GET_RANKING + "?page=0&size=10", HttpMethod.GET, null, responseType ); - // Then + // then assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(3), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1), - () -> assertThat(response.getBody().data().content().get(2).productId()).isEqualTo(product2), - () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(1).productId()).isEqualTo(product3) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(1).productId()).isEqualTo(product3), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(2).productId()).isEqualTo(product2) ); } @Test @DisplayName("랭킹 데이터가 없으면 빈 목록을 반환한다") void should_return_empty_when_no_ranking_data() { - // Given - 랭킹 데이터 없음 + // given - 랭킹 데이터 없음 - // When + // when ParameterizedTypeReference>> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity>> response = testRestTemplate.exchange( Uris.Ranking.GET_RANKING, HttpMethod.GET, null, responseType ); - // Then + // then assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty(), @@ -171,7 +181,7 @@ void should_return_empty_when_no_ranking_data() { @Test @DisplayName("페이징이 정상적으로 동작한다") void should_paginate_ranking_results() { - // Given - 5개 상품 모두 랭킹에 등록 + // given - 5개 상품 모두 랭킹에 등록 List scores = new ArrayList<>(); for (int i = 0; i < 5; i++) { scores.add(new RankingScore( @@ -183,16 +193,17 @@ void should_paginate_ranking_results() { } rankingRedisService.updateRankingScoresBatch(scores, today); - // When - 페이지 크기 2로 조회 + // when - 페이지 크기 2로 조회 ParameterizedTypeReference>> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity>> response = testRestTemplate.exchange( Uris.Ranking.GET_RANKING + "?page=0&size=2", HttpMethod.GET, null, responseType ); - // Then + // then assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(2), @@ -206,7 +217,7 @@ void should_paginate_ranking_results() { @Test @DisplayName("특정 날짜의 랭킹을 조회할 수 있다") void should_return_ranking_for_specific_date() { - // Given - 어제 날짜에 랭킹 데이터 적재 + // given - 어제 날짜에 랭킹 데이터 적재 LocalDate yesterday = today.minusDays(1); Long product1 = testProductIds.get(0); @@ -215,16 +226,17 @@ void should_return_ranking_for_specific_date() { ); rankingRedisService.updateRankingScoresBatch(scores, yesterday); - // When - 어제 날짜로 조회 + // when - 어제 날짜로 조회 ParameterizedTypeReference>> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity>> response = testRestTemplate.exchange( Uris.Ranking.GET_RANKING + "?date=" + yesterday, HttpMethod.GET, null, responseType ); - // Then + // then assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(1), @@ -240,7 +252,7 @@ class ColdStartFallbackTest { @Test @DisplayName("오늘 랭킹이 없으면 어제 랭킹을 반환한다") void should_fallback_to_yesterday_when_today_is_empty() { - // Given - 어제 랭킹만 있음 + // given - 어제 랭킹만 있음 LocalDate yesterday = today.minusDays(1); Long product1 = testProductIds.get(0); @@ -249,16 +261,17 @@ void should_fallback_to_yesterday_when_today_is_empty() { ); rankingRedisService.updateRankingScoresBatch(scores, yesterday); - // When - 날짜 미지정 (오늘 기준) + // when - 날짜 미지정 (오늘 기준) ParameterizedTypeReference>> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity>> response = testRestTemplate.exchange( Uris.Ranking.GET_RANKING, HttpMethod.GET, null, responseType ); - // Then - 어제 랭킹이 반환됨 + // then - 어제 랭킹이 반환됨 assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(1), @@ -269,7 +282,7 @@ void should_fallback_to_yesterday_when_today_is_empty() { @Test @DisplayName("명시적 날짜 지정 시 Fallback하지 않는다") void should_not_fallback_when_date_is_explicitly_specified() { - // Given - 어제 랭킹만 있음 + // given - 어제 랭킹만 있음 LocalDate yesterday = today.minusDays(1); Long product1 = testProductIds.get(0); @@ -278,16 +291,17 @@ void should_not_fallback_when_date_is_explicitly_specified() { ); rankingRedisService.updateRankingScoresBatch(scores, yesterday); - // When - 오늘 날짜 명시적 지정 + // when - 오늘 날짜 명시적 지정 ParameterizedTypeReference>> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity>> response = testRestTemplate.exchange( Uris.Ranking.GET_RANKING + "?date=" + today, HttpMethod.GET, null, responseType ); - // Then - 빈 결과 (Fallback 안 함) + // then - 빈 결과 (Fallback 안 함) assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty() @@ -302,7 +316,7 @@ class ProductDetailWithRankingTest { @Test @DisplayName("랭킹에 있는 상품은 랭킹 정보가 포함된다") void should_include_ranking_info_for_ranked_product() { - // Given - 상품을 랭킹에 등록 + // given - 상품을 랭킹에 등록 Long productId = testProductIds.get(0); double score = 123.45; @@ -311,16 +325,17 @@ void should_include_ranking_info_for_ranked_product() { ); rankingRedisService.updateRankingScoresBatch(scores, today); - // When + // when ParameterizedTypeReference> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> response = testRestTemplate.exchange( Uris.Product.GET_DETAIL, HttpMethod.GET, null, responseType, productId ); - // Then + // then assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(productId), @@ -333,19 +348,20 @@ void should_include_ranking_info_for_ranked_product() { @Test @DisplayName("랭킹에 없는 상품은 랭킹 정보가 null이다") void should_have_null_ranking_for_unranked_product() { - // Given - 랭킹 데이터 없음 + // given - 랭킹 데이터 없음 Long productId = testProductIds.get(0); - // When + // when ParameterizedTypeReference> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> response = testRestTemplate.exchange( Uris.Product.GET_DETAIL, HttpMethod.GET, null, responseType, productId ); - // Then + // then assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(productId), @@ -356,7 +372,7 @@ void should_have_null_ranking_for_unranked_product() { @Test @DisplayName("여러 상품 중 특정 상품의 순위가 정확히 반환된다") void should_return_correct_rank_among_multiple_products() { - // Given - 3개 상품 랭킹 등록 (product2가 2위) + // given - 3개 상품 랭킹 등록 (product2가 2위) Long product1 = testProductIds.get(0); Long product2 = testProductIds.get(1); Long product3 = testProductIds.get(2); @@ -368,16 +384,17 @@ void should_return_correct_rank_among_multiple_products() { ); rankingRedisService.updateRankingScoresBatch(scores, today); - // When - product2 상세 조회 + // when - product2 상세 조회 ParameterizedTypeReference> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> response = testRestTemplate.exchange( Uris.Product.GET_DETAIL, HttpMethod.GET, null, responseType, product2 ); - // Then - 2위로 반환 + // then - 2위로 반환 assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(product2), @@ -394,7 +411,7 @@ class ScoreAccumulationTest { @Test @DisplayName("동일 상품에 여러 이벤트 점수가 누적된다") void should_accumulate_scores_for_same_product() { - // Given - 동일 상품에 여러 점수 적재 + // given - 동일 상품에 여러 점수 적재 Long productId = testProductIds.get(0); // 첫 번째 점수 적재 (PRODUCT_VIEW: weight 0.1, score 10.0 → 1.0) @@ -410,16 +427,17 @@ void should_accumulate_scores_for_same_product() { today ); - // When + // when ParameterizedTypeReference> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> response = testRestTemplate.exchange( Uris.Product.GET_DETAIL, HttpMethod.GET, null, responseType, productId ); - // Then - 점수가 누적됨 (weight 적용: 10*0.1 + 20*0.2 = 5.0) + // then - 점수가 누적됨 (weight 적용: 10*0.1 + 20*0.2 = 5.0) assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNotNull(), @@ -435,7 +453,7 @@ class CarryOverTest { @Test @DisplayName("Carry-Over 후 다음 날 랭킹에 점수가 이월된다") void should_carry_over_scores_to_next_day() { - // Given - 오늘 랭킹 데이터 직접 Redis에 적재 (weight 적용된 점수) + // given - 오늘 랭킹 데이터 직접 Redis에 적재 (weight 적용된 점수) Long productId = testProductIds.get(0); double weightedScore = 60.0; // PAYMENT_SUCCESS weight 0.6 * score 100 = 60 @@ -446,10 +464,10 @@ void should_carry_over_scores_to_next_day() { String tomorrowKey = cacheKeyGenerator.generateDailyRankingKey(tomorrow); redisTemplate.delete(tomorrowKey); // 내일 키 정리 - // When - Carry-Over 실행 (10%) + // when - Carry-Over 실행 (10%) rankingRedisService.carryOverScores(today, tomorrow, 0.1); - // Then - 내일 키에 10% 점수가 이월됨 + // then - 내일 키에 10% 점수가 이월됨 Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); assertThat(tomorrowScore).isNotNull(); diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..b22b6477c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // 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..fe1c301d9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,25 @@ +package com.loopers; + +import java.util.TimeZone; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import jakarta.annotation.PostConstruct; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..0a40b9d0b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,50 @@ +package com.loopers.batch.job.demo; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; + +import lombok.RequiredArgsConstructor; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..6fbcc88b9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,34 @@ +package com.loopers.batch.job.demo.step; + +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.demo.DemoJobConfig; + +import lombok.RequiredArgsConstructor; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..c7075ce03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,79 @@ +package com.loopers.batch.job.ranking; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; +import com.loopers.batch.job.ranking.processor.RankingProcessor; +import com.loopers.batch.job.ranking.reader.MonthlyMetricsReader; +import com.loopers.batch.job.ranking.writer.MonthlyRankWriter; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 랭킹 배치 Job 설정 + * 실행 방법: + * java -jar commerce-batch.jar --spring.batch.job.name=monthlyRankingJob --yearMonth=2024-12 + */ +@Configuration +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_NAME = "monthlyAggregationStep"; + private static final int CHUNK_SIZE = 100; // TOP 100이므로 한 번에 처리 + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final MonthlyMetricsReader monthlyMetricsReader; + private final RankingProcessor rankingProcessor; + private final MonthlyRankWriter monthlyRankWriter; + + /** + * 월간 랭킹 집계 Job + * + * @return 월간 랭킹 Job + */ + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyAggregationStep()) + .listener(jobListener) + .build(); + } + + /** + * 월간 랭킹 집계 Step + * - Reader: 월간 메트릭 데이터 집계 및 TOP 100 선별 + * - Processor: 추가 가공 (현재는 pass-through) + * - Writer: mv_product_rank_monthly 테이블에 저장 + * + * @return 월간 집계 Step + */ + @Bean(STEP_NAME) + @JobScope + public Step monthlyAggregationStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyMetricsReader) + .processor(rankingProcessor) + .writer(monthlyRankWriter) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..cc758b973 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,80 @@ +package com.loopers.batch.job.ranking; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; +import com.loopers.batch.job.ranking.processor.RankingProcessor; +import com.loopers.batch.job.ranking.reader.WeeklyMetricsReader; +import com.loopers.batch.job.ranking.writer.WeeklyRankWriter; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 랭킹 배치 Job 설정 + *

+ * 실행 방법: + * java -jar commerce-batch.jar --spring.batch.job.name=weeklyRankingJob --yearWeek=2024-W52 + */ +@Configuration +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_NAME = "weeklyAggregationStep"; + private static final int CHUNK_SIZE = 100; // TOP 100이므로 한 번에 처리 + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final WeeklyMetricsReader weeklyMetricsReader; + private final RankingProcessor rankingProcessor; + private final WeeklyRankWriter weeklyRankWriter; + + /** + * 주간 랭킹 집계 Job + * + * @return 주간 랭킹 Job + */ + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyAggregationStep()) + .listener(jobListener) + .build(); + } + + /** + * 주간 랭킹 집계 Step + * - Reader: 7일치 메트릭 데이터 집계 및 TOP 100 선별 + * - Processor: 추가 가공 (현재는 pass-through) + * - Writer: mv_product_rank_weekly 테이블에 저장 + * + * @return 주간 집계 Step + */ + @Bean(STEP_NAME) + @JobScope + public Step weeklyAggregationStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyMetricsReader) + .processor(rankingProcessor) + .writer(weeklyRankWriter) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java new file mode 100644 index 000000000..057179365 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java @@ -0,0 +1,91 @@ +package com.loopers.batch.job.ranking.dto; + +import java.math.BigDecimal; + +import com.loopers.batch.job.ranking.support.ScoreCalculator; +import com.loopers.domain.metrics.ProductMetricsAggregation; + +import lombok.Getter; + +/** + * 랭킹 집계 결과 DTO + * - DB 집계 쿼리 결과를 담는 불변 객체 + * - 점수 계산 및 순위 부여 기능 포함 + */ +@Getter +public class RankingAggregation { + + private final Long productId; + private final long viewCount; + private final long likeCount; + private final long salesCount; + private final long orderCount; + private final BigDecimal totalSalesAmount; + private final long totalScore; + private int rankPosition; // 가변 필드 (순위 부여용) + + private RankingAggregation(Long productId, long viewCount, long likeCount, + long salesCount, long orderCount, BigDecimal totalSalesAmount, long totalScore) { + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.orderCount = orderCount; + this.totalSalesAmount = totalSalesAmount; + this.totalScore = totalScore; + this.rankPosition = 0; // 초기값 + } + + /** + * DB 집계 결과로부터 RankingAggregation을 생성합니다. + * + * @param metrics 상품 메트릭 집계 결과 DTO + * @param calculator 점수 계산기 + * @return 생성된 RankingAggregation 객체 + * @throws IllegalArgumentException metrics가 null인 경우 + */ + public static RankingAggregation from(ProductMetricsAggregation metrics, ScoreCalculator calculator) { + if (metrics == null) { + throw new IllegalArgumentException("집계 결과(metrics)가 null입니다."); + } + + long totalScore = calculator.calculate( + metrics.viewCount(), + metrics.likeCount(), + metrics.totalSalesAmount() + ); + + return new RankingAggregation( + metrics.productId(), + metrics.viewCount(), + metrics.likeCount(), + metrics.salesCount(), + metrics.orderCount(), + metrics.totalSalesAmount(), + totalScore + ); + } + + /** + * 순위를 부여합니다. + * + * @param rank 부여할 순위 (1~100) + * @throws IllegalArgumentException 순위가 유효하지 않은 경우 + */ + public void assignRank(int rank) { + if (rank < 1 || rank > 100) { + throw new IllegalArgumentException( + String.format("순위는 1~100 범위여야 합니다. (입력값: %d)", rank)); + } + this.rankPosition = rank; + } + + /** + * 디버깅용 문자열 표현 + */ + @Override + public String toString() { + return String.format("RankingAggregation{productId=%d, score=%d, rank=%d}", + productId, totalScore, rankPosition); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.java new file mode 100644 index 000000000..0dc5223c7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.java @@ -0,0 +1,25 @@ +package com.loopers.batch.job.ranking.processor; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; + +/** + * 랭킹 데이터 처리기 + * - Reader에서 이미 점수 계산 및 순위 부여가 완료됨 + * - 추가 비즈니스 로직이 필요할 때 확장 포인트로 활용 + */ +@Component +public class RankingProcessor implements ItemProcessor { + + @Override + public RankingAggregation process(RankingAggregation item) throws Exception { + // Reader에서 이미 순위 부여됨 + // 추가 가공이 필요하면 여기서 처리 + // 예: 특정 조건 필터링, 데이터 보정 등 + + // 현재는 단순 통과 (pass-through) + return item; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java new file mode 100644 index 000000000..f500a1004 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java @@ -0,0 +1,87 @@ +package com.loopers.batch.job.ranking.reader; + +import java.time.LocalDate; +import java.util.Iterator; +import java.util.List; + +import org.springframework.batch.item.ItemReader; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; +import com.loopers.batch.job.ranking.support.RankingAggregator; +import com.loopers.domain.metrics.ProductMetricsAggregation; +import com.loopers.domain.metrics.ProductMetricsRepository; + +import lombok.extern.slf4j.Slf4j; + +/** + * 메트릭 Reader 공통 추상 클래스 + * - 특정 기간의 데이터를 집계하고 랭킹을 생성하는 공통 로직을 포함 + */ +@Slf4j +public abstract class AbstractMetricsReader implements ItemReader { + + protected final ProductMetricsRepository productMetricsRepository; + protected final RankingAggregator rankingAggregator; + + private Iterator iterator; + + protected AbstractMetricsReader(ProductMetricsRepository productMetricsRepository, RankingAggregator rankingAggregator) { + this.productMetricsRepository = productMetricsRepository; + this.rankingAggregator = rankingAggregator; + } + + @Override + public RankingAggregation read() throws Exception { + if (iterator == null) { + initializeIterator(); + } + + return iterator.hasNext() ? iterator.next() : null; + } + + private void initializeIterator() { + String logIdentifier = getLogIdentifier(); + log.info("{} 랭킹 집계 시작: parameter={}", logIdentifier, getParameterValue()); + + try { + // 1. 기간 파싱 (추상 메서드 호출) + LocalDate[] dateRange = parseDateRange(); + if (dateRange == null || dateRange.length != 2) { + throw new IllegalStateException("parseDateRange()는 정확히 2개의 날짜를 반환해야 합니다."); + } + LocalDate startDate = dateRange[0]; + LocalDate endDate = dateRange[1]; + + log.info("집계 기간: {} ~ {}", startDate, endDate); + + // 2. DB에서 집계 쿼리 실행 + List aggregationResults = productMetricsRepository.aggregateByDateRange(startDate, endDate); + log.info("집계 대상 상품 수: {}", aggregationResults.size()); + + // 3. 랭킹 처리 (정렬 + TOP 100 + 순위 부여) + List rankings = rankingAggregator.processRankings(aggregationResults); + log.info("생성된 랭킹 수: {}", rankings.size()); + + iterator = rankings.iterator(); + + } catch (Exception e) { + log.error("{} 랭킹 집계 중 오류 발생: parameter={}", logIdentifier, getParameterValue(), e); + throw new RuntimeException(logIdentifier + " 랭킹 집계 실패", e); + } + } + + /** + * 기간에 해당하는 LocalDate 범위를 반환합니다. + */ + protected abstract LocalDate[] parseDateRange(); + + /** + * 로그 식별자를 반환합니다. (예: "월간", "주간") + */ + protected abstract String getLogIdentifier(); + + /** + * 현재 사용 중인 파라미터 값을 반환합니다. + */ + protected abstract String getParameterValue(); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java new file mode 100644 index 000000000..4486ffc1b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java @@ -0,0 +1,52 @@ +package com.loopers.batch.job.ranking.reader; + +import java.time.LocalDate; + +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.ranking.support.DateRangeParser; +import com.loopers.batch.job.ranking.support.RankingAggregator; +import com.loopers.domain.metrics.ProductMetricsRepository; + +import lombok.extern.slf4j.Slf4j; + +/** + * 월간 메트릭 Reader + * - 지정된 월의 전체 일간 데이터를 집계 + * - TOP 100 랭킹 생성 및 순위 부여 + */ +@Slf4j +@StepScope +@Component +public class MonthlyMetricsReader extends AbstractMetricsReader { + + private final DateRangeParser dateRangeParser; + + @Value("#{jobParameters['yearMonth']}") + private String yearMonth; // e.g., "2024-12" + + public MonthlyMetricsReader( + ProductMetricsRepository productMetricsRepository, + RankingAggregator rankingAggregator, + DateRangeParser dateRangeParser) { + super(productMetricsRepository, rankingAggregator); + this.dateRangeParser = dateRangeParser; + } + + @Override + protected LocalDate[] parseDateRange() { + return dateRangeParser.parseYearMonth(yearMonth); + } + + @Override + protected String getLogIdentifier() { + return "월간"; + } + + @Override + protected String getParameterValue() { + return yearMonth; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java new file mode 100644 index 000000000..f712214ee --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java @@ -0,0 +1,52 @@ +package com.loopers.batch.job.ranking.reader; + +import java.time.LocalDate; + +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.ranking.support.DateRangeParser; +import com.loopers.batch.job.ranking.support.RankingAggregator; +import com.loopers.domain.metrics.ProductMetricsRepository; + +import lombok.extern.slf4j.Slf4j; + +/** + * 주간 메트릭 Reader + * - 지정된 주차의 7일간 데이터를 집계 + * - TOP 100 랭킹 생성 및 순위 부여 + */ +@Slf4j +@StepScope +@Component +public class WeeklyMetricsReader extends AbstractMetricsReader { + + private final DateRangeParser dateRangeParser; + + @Value("#{jobParameters['yearWeek']}") + private String yearWeek; // e.g., "2024-W52" + + public WeeklyMetricsReader( + ProductMetricsRepository productMetricsRepository, + RankingAggregator rankingAggregator, + DateRangeParser dateRangeParser) { + super(productMetricsRepository, rankingAggregator); + this.dateRangeParser = dateRangeParser; + } + + @Override + protected LocalDate[] parseDateRange() { + return dateRangeParser.parseYearWeek(yearWeek); + } + + @Override + protected String getLogIdentifier() { + return "주간"; + } + + @Override + protected String getParameterValue() { + return yearWeek; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java new file mode 100644 index 000000000..fe5409c54 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java @@ -0,0 +1,73 @@ +package com.loopers.batch.job.ranking.support; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.WeekFields; + +import org.springframework.stereotype.Component; + +/** + * 날짜 범위 파싱 유틸리티 + * - yearWeek (e.g., "2024-W52") → 주간 날짜 범위 + * - yearMonth (e.g., "2024-12") → 월간 날짜 범위 + */ +@Component +public class DateRangeParser { + + /** + * yearWeek 문자열을 주간 날짜 범위로 변환합니다. + * + * @param yearWeek "2024-W52" 형식의 문자열 + * @return [startDate, endDate] 배열 + * @throws IllegalArgumentException 잘못된 형식인 경우 + */ + public LocalDate[] parseYearWeek(String yearWeek) { + if (yearWeek == null || !yearWeek.matches("\\d{4}-W\\d{1,2}")) { + throw new IllegalArgumentException( + String.format("잘못된 yearWeek 형식입니다. 예상: '2024-W52', 실제: '%s'", yearWeek)); + } + + try { + String[] parts = yearWeek.split("-W"); + int year = Integer.parseInt(parts[0]); + int week = Integer.parseInt(parts[1]); + + // ISO 주차 시스템 사용 (월요일 시작) + WeekFields weekFields = WeekFields.ISO; + LocalDate startOfWeek = LocalDate.of(year, 1, 1) + .with(weekFields.weekOfYear(), week) + .with(weekFields.dayOfWeek(), 1); + LocalDate endOfWeek = startOfWeek.plusDays(6); + + return new LocalDate[]{startOfWeek, endOfWeek}; + } catch (Exception e) { + throw new IllegalArgumentException( + String.format("yearWeek 파싱 중 오류가 발생했습니다: %s", yearWeek), e); + } + } + + /** + * yearMonth 문자열을 월간 날짜 범위로 변환합니다. + * + * @param yearMonth "2024-12" 형식의 문자열 + * @return [startDate, endDate] 배열 + * @throws IllegalArgumentException 잘못된 형식인 경우 + */ + public LocalDate[] parseYearMonth(String yearMonth) { + if (yearMonth == null || !yearMonth.matches("\\d{4}-\\d{2}")) { + throw new IllegalArgumentException( + String.format("잘못된 yearMonth 형식입니다. 예상: '2024-12', 실제: '%s'", yearMonth)); + } + + try { + YearMonth ym = YearMonth.parse(yearMonth); + LocalDate startOfMonth = ym.atDay(1); + LocalDate endOfMonth = ym.atEndOfMonth(); + + return new LocalDate[]{startOfMonth, endOfMonth}; + } catch (Exception e) { + throw new IllegalArgumentException( + String.format("yearMonth 파싱 중 오류가 발생했습니다: %s", yearMonth), e); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java new file mode 100644 index 000000000..8fffd5df0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java @@ -0,0 +1,63 @@ +package com.loopers.batch.job.ranking.support; + +import java.util.Comparator; +import java.util.List; + +import com.loopers.domain.metrics.ProductMetricsAggregation; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; + +import lombok.RequiredArgsConstructor; + +/** + * 랭킹 집계 처리기 + * - 집계 결과를 점수 기준으로 정렬 + * - TOP 100 필터링 + * - 순위 부여 + */ +@Component +@RequiredArgsConstructor +public class RankingAggregator { + + private static final int TOP_RANK_LIMIT = 100; + + private final ScoreCalculator scoreCalculator; + + /** + * DB 집계 결과를 랭킹으로 변환합니다. + * + * @param aggregationResults DB 집계 쿼리 결과 목록 + * @return TOP 100 랭킹 목록 (순위 부여 완료) + */ + public List processRankings(List aggregationResults) { + if (aggregationResults == null || aggregationResults.isEmpty()) { + return List.of(); + } + + // 1. DTO 변환 + 점수 계산 + List aggregations = aggregationResults.stream() + .map(metrics -> RankingAggregation.from(metrics, scoreCalculator)) + .toList(); + + // 2. 점수 기준 내림차순 정렬 + TOP 100 필터링 + List topRankings = aggregations.stream() + .sorted(Comparator.comparingLong(RankingAggregation::getTotalScore).reversed()) + .limit(TOP_RANK_LIMIT) + .toList(); + + // 3. 순위 부여 (1위부터 시작) + for (int i = 0; i < topRankings.size(); i++) { + topRankings.get(i).assignRank(i + 1); + } + + return topRankings; + } + + /** + * TOP 랭킹 제한 수를 반환합니다. + */ + public int getTopRankLimit() { + return TOP_RANK_LIMIT; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java new file mode 100644 index 000000000..39950e248 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java @@ -0,0 +1,50 @@ +package com.loopers.batch.job.ranking.support; + +import java.math.BigDecimal; + +import org.springframework.stereotype.Component; + +/** + * 랭킹 점수 계산기 + * - Redis ZSET과 동일한 가중치 적용 + * - 점수 = viewCount*1 + likeCount*3 + salesCount*5 + orderCount*2 + */ +@Component +public class ScoreCalculator { + + // CachePayloads의 EventType 가중치와 일치하도록 조정 (비율 유지) + private static final double VIEW_WEIGHT = 0.1; + private static final double LIKE_WEIGHT = 0.2; + private static final double SALES_WEIGHT = 0.6; // 주문(결제성공) 가중치 + + /** + * 메트릭 데이터를 기반으로 랭킹 점수를 계산합니다. + * + * @param viewCount 조회수 + * @param likeCount 좋아요수 + * @param totalSalesAmount 총 판매 금액 + * @return 계산된 총 점수 + */ + public long calculate(long viewCount, long likeCount, BigDecimal totalSalesAmount) { + // 1. 조회와 좋아요는 단순 수량 기반 가중치 적용 + double viewScore = viewCount * VIEW_WEIGHT; + double likeScore = likeCount * LIKE_WEIGHT; + + // 2. 판매량(Sales)은 CachePayloads.forPaymentSuccess와 동일하게 로그 정규화 적용 + // RankingScore.forPaymentSuccess: normalizedScore = Math.log(totalPrice.doubleValue() + 1); + double amount = totalSalesAmount != null ? totalSalesAmount.doubleValue() : 0.0; + double normalizedSalesScore = Math.log(amount + 1) * SALES_WEIGHT; + + // 3. 최종 점수 계산 (소수점 처리를 위해 적절한 스케일 곱산 후 long 변환) + // Redis ZSET의 score가 double임을 감안하여 정밀도를 유지합니다. + return (long) ((viewScore + likeScore + normalizedSalesScore) * 10); + } + + /** + * 가중치 정보를 반환합니다. (테스트 및 디버깅용) + */ + public String getWeightInfo() { + return String.format("VIEW=%f, LIKE=%f, SALES=%f,", + VIEW_WEIGHT, LIKE_WEIGHT, SALES_WEIGHT); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.java new file mode 100644 index 000000000..c619594da --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.java @@ -0,0 +1,79 @@ +package com.loopers.batch.job.ranking.writer; + +import java.util.List; + +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; +import com.loopers.domain.ranking.MonthlyRankEntity; +import com.loopers.domain.ranking.MonthlyRankRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 월간 랭킹 Writer + * - RankingAggregation을 MonthlyRankEntity로 변환하여 저장 + * - 멱등성 보장을 위해 기존 데이터 삭제 후 저장 + */ +@Slf4j +@StepScope +@Component +@RequiredArgsConstructor +public class MonthlyRankWriter implements ItemWriter { + + private final MonthlyRankRepository monthlyRankRepository; + + @Value("#{jobParameters['yearMonth']}") + private String yearMonth; + + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems(); + + if (items.isEmpty()) { + log.info("[Batch-Ranking] 저장할 월간 랭킹 데이터가 없습니다. (yearMonth: {})", yearMonth); + return; + } + + int targetCount = items.size(); + log.info("[Batch-Ranking] 월간 랭킹 저장 프로세스 시작 (yearMonth: {}, 대상 건수: {})", yearMonth, targetCount); + + try { + // 1. 기존 데이터 삭제 (멱등성 보장) + long deletedCount = monthlyRankRepository.deleteByYearMonth(yearMonth); + log.info("[Batch-Ranking] 기존 데이터 클렌징 완료 (yearMonth: {}, 삭제 건수: {})", yearMonth, deletedCount); + + // 2. 새로운 데이터 저장 + List entities = items.stream() + .map(this::convertToEntity) + .toList(); + + monthlyRankRepository.saveAll(entities); + log.info("[Batch-Ranking] 월간 랭킹 저장 성공 (yearMonth: {}, 저장 건수: {})", yearMonth, entities.size()); + + } catch (Exception e) { + // 상세한 컨텍스트를 포함한 에러 로그 + log.error("[Batch-Ranking] 월간 랭킹 저장 중 예외 발생! (yearMonth: {}, 처리 중이던 건수: {}) - 원인: {}", + yearMonth, targetCount, e.getMessage(), e); + throw e; + } + } + + private MonthlyRankEntity convertToEntity(RankingAggregation aggregation) { + return MonthlyRankEntity.create( + aggregation.getProductId(), + yearMonth, + aggregation.getViewCount(), + aggregation.getLikeCount(), + aggregation.getSalesCount(), + aggregation.getOrderCount(), + aggregation.getTotalScore(), + aggregation.getRankPosition() + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankWriter.java new file mode 100644 index 000000000..9e269a45f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankWriter.java @@ -0,0 +1,79 @@ +package com.loopers.batch.job.ranking.writer; + +import java.util.List; + +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; +import com.loopers.domain.ranking.WeeklyRankEntity; +import com.loopers.domain.ranking.WeeklyRankRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 주간 랭킹 Writer + * - RankingAggregation을 WeeklyRankEntity로 변환하여 저장 + * - 멱등성 보장을 위해 기존 데이터 삭제 후 저장 + */ +@Slf4j +@StepScope +@Component +@RequiredArgsConstructor +public class WeeklyRankWriter implements ItemWriter { + + private final WeeklyRankRepository weeklyRankRepository; + + @Value("#{jobParameters['yearWeek']}") + private String yearWeek; + + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems(); + + if (items.isEmpty()) { + log.info("[Batch-Ranking] 저장할 주간 랭킹 데이터가 없습니다. (yearWeek: {})", yearWeek); + return; + } + + int targetCount = items.size(); + log.info("[Batch-Ranking] 주간 랭킹 저장 프로세스 시작 (yearWeek: {}, 대상 건수: {})", yearWeek, targetCount); + + try { + // 1. 기존 데이터 삭제 (멱등성 보장) + long deletedCount = weeklyRankRepository.deleteByYearWeek(yearWeek); + log.info("[Batch-Ranking] 기존 데이터 클렌징 완료 (yearWeek: {}, 삭제 건수: {})", yearWeek, deletedCount); + + // 2. 새로운 데이터 저장 + List entities = items.stream() + .map(this::convertToEntity) + .toList(); + + weeklyRankRepository.saveAll(entities); + log.info("[Batch-Ranking] 주간 랭킹 저장 성공 (yearWeek: {}, 저장 건수: {})", yearWeek, entities.size()); + + } catch (Exception e) { + // 상세한 컨텍스트를 포함한 에러 로그 + log.error("[Batch-Ranking] 주간 랭킹 저장 중 예외 발생! (yearWeek: {}, 처리 중이던 건수: {}) - 원인: {}", + yearWeek, targetCount, e.getMessage(), e); + throw e; // 예외를 그대로 던져서 Batch Step이 실패 상태가 되도록 위임 + } + } + + private WeeklyRankEntity convertToEntity(RankingAggregation aggregation) { + return WeeklyRankEntity.create( + aggregation.getProductId(), + yearWeek, + aggregation.getViewCount(), + aggregation.getLikeCount(), + aggregation.getSalesCount(), + aggregation.getOrderCount(), + aggregation.getTotalScore(), + aggregation.getRankPosition() + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..396fbe30f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,22 @@ +package com.loopers.batch.listener; + +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cd923b827 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,54 @@ +package com.loopers.batch.listener; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..e69e74557 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,47 @@ +package com.loopers.batch.listener; + +import java.util.Objects; +import java.util.stream.Collectors; + +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import jakarta.annotation.Nonnull; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} 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..a173e83b9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.metrics; + +import java.time.LocalDate; +import java.util.List; + +import com.loopers.domain.metrics.ProductMetricsAggregation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.metrics.ProductMetricsEntity; +import com.loopers.domain.metrics.ProductMetricsId; + +/** + * ProductMetrics JPA Repository + * - 배치 Job에서 사용하는 집계 쿼리 포함 + */ +public interface ProductMetricsJpaRepository extends JpaRepository { + + /** + * 기간별 상품 집계 (GROUP BY product_id) + * - 배치 Job에서 사용하는 핵심 쿼리 + * + * @param startDate 시작 날짜 (포함) + * @param endDate 종료 날짜 (포함) + * @return 집계 결과 목록 + */ + @Query(""" + SELECT new com.loopers.domain.metrics.ProductMetricsAggregation( + m.id.productId, + SUM(m.viewCount), + SUM(m.likeCount), + SUM(m.salesCount), + SUM(m.orderCount), + SUM(m.totalSalesAmount)) + FROM ProductMetricsEntity m + WHERE m.id.metricDate BETWEEN :startDate AND :endDate + GROUP BY m.id.productId + """) + List aggregateByDateRange( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query("SELECT m FROM ProductMetricsEntity m WHERE m.id.metricDate BETWEEN :startDate AND :endDate") + List findByMetricDateBetween(LocalDate startDate, LocalDate endDate); +} 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..78ed15b61 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.metrics; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import com.loopers.domain.metrics.ProductMetricsAggregation; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.metrics.ProductMetricsEntity; +import com.loopers.domain.metrics.ProductMetricsId; +import com.loopers.domain.metrics.ProductMetricsRepository; + +import lombok.RequiredArgsConstructor; + +/** + * ProductMetrics Repository 구현체 + * - JPA Repository를 래핑하여 도메인 인터페이스 구현 + */ +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository jpaRepository; + + @Override + public ProductMetricsEntity save(ProductMetricsEntity entity) { + return jpaRepository.save(entity); + } + + @Override + public Optional findById(ProductMetricsId id) { + return jpaRepository.findById(id); + } + + @Override + public Optional findByProductIdAndMetricDate(Long productId, LocalDate metricDate) { + ProductMetricsId id = ProductMetricsId.of(productId, metricDate); + return jpaRepository.findById(id); + } + + @Override + public List findByMetricDateBetween(LocalDate startDate, LocalDate endDate) { + return jpaRepository.findByMetricDateBetween(startDate, endDate); + } + + @Override + public List aggregateByDateRange(LocalDate startDate, LocalDate endDate) { + return jpaRepository.aggregateByDateRange(startDate, endDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java new file mode 100644 index 000000000..0cec29040 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.ranking.MonthlyRankEntity; +import com.loopers.domain.ranking.MonthlyRankId; + +/** + * 월간 랭킹 JPA Repository + */ +public interface MonthlyRankJpaRepository extends JpaRepository { + + /** + * 특정 월의 랭킹을 순위 순으로 페이지네이션하여 조회합니다. + */ + @Query("SELECT m FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth ORDER BY m.rankPosition ASC") + Page findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable); + + /** + * 특정 월의 모든 랭킹을 삭제합니다. + * + * @param yearMonth 삭제할 월 + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth") + long deleteByIdYearMonth(@Param("yearMonth") String yearMonth); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java new file mode 100644 index 000000000..478e195fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.ranking.MonthlyRankEntity; +import com.loopers.domain.ranking.MonthlyRankRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 랭킹 Repository 구현체 + */ +@Repository +@RequiredArgsConstructor +public class MonthlyRankRepositoryImpl implements MonthlyRankRepository { + + private final MonthlyRankJpaRepository jpaRepository; + + @Override + public MonthlyRankEntity save(MonthlyRankEntity entity) { + return jpaRepository.save(entity); + } + + @Override + public List saveAll(List entities) { + return jpaRepository.saveAll(entities); + } + + @Override + public Page findByYearMonth(String yearMonth, Pageable pageable) { + return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth, pageable); + } + + @Override + public long deleteByYearMonth(String yearMonth) { + return jpaRepository.deleteByIdYearMonth(yearMonth); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java new file mode 100644 index 000000000..3dceb4bf7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.ranking.WeeklyRankEntity; +import com.loopers.domain.ranking.WeeklyRankId; + +/** + * 주간 랭킹 JPA Repository + */ +public interface WeeklyRankJpaRepository extends JpaRepository { + + /** + * 특정 주차의 랭킹을 순위 순으로 조회합니다. + */ + @Query("SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC") + List findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek); + + /** + * 특정 주차의 랭킹을 순위 순으로 페이지네이션하여 조회합니다. + */ + @Query("SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC") + Page findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable); + + /** + * 특정 주차의 모든 랭킹을 삭제합니다. + * + * @param yearWeek 삭제할 주차 + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek") + long deleteByIdYearWeek(@Param("yearWeek") String yearWeek); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java new file mode 100644 index 000000000..5626d881e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java @@ -0,0 +1,48 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.ranking.WeeklyRankEntity; +import com.loopers.domain.ranking.WeeklyRankRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 랭킹 Repository 구현체 + */ +@Repository +@RequiredArgsConstructor +public class WeeklyRankRepositoryImpl implements WeeklyRankRepository { + + private final WeeklyRankJpaRepository jpaRepository; + + @Override + public WeeklyRankEntity save(WeeklyRankEntity entity) { + return jpaRepository.save(entity); + } + + @Override + public List saveAll(List entities) { + return jpaRepository.saveAll(entities); + } + + @Override + public List findByYearWeek(String yearWeek) { + return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek); + } + + @Override + public long deleteByYearWeek(String yearWeek) { + return jpaRepository.deleteByIdYearWeek(yearWeek); + } + + @Override + public Page findByYearWeek(String yearWeek, Pageable pageable) { + return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek, pageable); + } +} 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..9aa0d760a --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java new file mode 100644 index 000000000..16f463c14 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java @@ -0,0 +1,147 @@ +package com.loopers.batch.job.ranking.dto; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.loopers.batch.job.ranking.support.ScoreCalculator; +import com.loopers.domain.metrics.ProductMetricsAggregation; + +@DisplayName("RankingAggregation 단위 테스트") +class RankingAggregationUnitTest { + + private final ScoreCalculator calculator = new ScoreCalculator(); + + @Nested + @DisplayName("집계 결과로부터 생성") + class 집계_결과로부터_생성 { + + @Test + @DisplayName("유효한 집계 결과로부터 객체를 생성한다") + void should_create_from_valid_aggregation_result() { + // given + ProductMetricsAggregation metrics = new ProductMetricsAggregation( + 1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000) + ); + + // when + RankingAggregation aggregation = RankingAggregation.from(metrics, calculator); + + // then + Assertions.assertThat(aggregation.getProductId()).isEqualTo(1L); + Assertions.assertThat(aggregation.getViewCount()).isEqualTo(100L); + Assertions.assertThat(aggregation.getLikeCount()).isEqualTo(50L); + Assertions.assertThat(aggregation.getSalesCount()).isEqualTo(10L); + Assertions.assertThat(aggregation.getOrderCount()).isEqualTo(5L); + Assertions.assertThat(aggregation.getTotalSalesAmount()).isEqualByComparingTo(java.math.BigDecimal.valueOf(1000)); + // score = (100*0.1 + 50*0.2 + log(1001)*0.6) * 10 = (10+10+4.145) * 10 = 241 + Assertions.assertThat(aggregation.getTotalScore()).isEqualTo(241L); + Assertions.assertThat(aggregation.getRankPosition()).isEqualTo(0); // 초기값 + } + + @Test + @DisplayName("null 메트릭에 대해 예외가 발생한다") + void should_throw_exception_when_metrics_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> RankingAggregation.from(null, calculator)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("집계 결과(metrics)가 null입니다."); + } + } + + @Nested + @DisplayName("순위 부여") + class 순위_부여 { + + @Test + @DisplayName("유효한 순위를 부여한다") + void should_assign_valid_rank() { + // given + ProductMetricsAggregation metrics = new ProductMetricsAggregation( + 1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000) + ); + RankingAggregation aggregation = RankingAggregation.from(metrics, calculator); + + // when + aggregation.assignRank(1); + + // then + Assertions.assertThat(aggregation.getRankPosition()).isEqualTo(1); + } + + @Test + @DisplayName("100위까지 순위를 부여할 수 있다") + void should_assign_rank_up_to_100() { + // given + ProductMetricsAggregation metrics = new ProductMetricsAggregation( + 1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000) + ); + RankingAggregation aggregation = RankingAggregation.from(metrics, calculator); + + // when + aggregation.assignRank(100); + + // then + Assertions.assertThat(aggregation.getRankPosition()).isEqualTo(100); + } + + @Test + @DisplayName("0 이하의 순위에 대해 예외가 발생한다") + void should_throw_exception_when_rank_is_zero_or_negative() { + // given + ProductMetricsAggregation metrics = new ProductMetricsAggregation( + 1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000) + ); + RankingAggregation aggregation = RankingAggregation.from(metrics, calculator); + + // when & then + Assertions.assertThatThrownBy(() -> aggregation.assignRank(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("순위는 1~100 범위여야 합니다"); + + Assertions.assertThatThrownBy(() -> aggregation.assignRank(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("순위는 1~100 범위여야 합니다"); + } + + @Test + @DisplayName("100을 초과하는 순위에 대해 예외가 발생한다") + void should_throw_exception_when_rank_exceeds_100() { + // given + ProductMetricsAggregation metrics = new ProductMetricsAggregation( + 1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000) + ); + RankingAggregation aggregation = RankingAggregation.from(metrics, calculator); + + // when & then + Assertions.assertThatThrownBy(() -> aggregation.assignRank(101)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("순위는 1~100 범위여야 합니다"); + } + } + + @Nested + @DisplayName("문자열 표현") + class 문자열_표현 { + + @Test + @DisplayName("toString이 올바른 형식을 반환한다") + void should_return_correct_string_format() { + // given + ProductMetricsAggregation metrics = new ProductMetricsAggregation( + 1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000) + ); + RankingAggregation aggregation = RankingAggregation.from(metrics, calculator); + aggregation.assignRank(1); + + // when + String result = aggregation.toString(); + + // then + Assertions.assertThat(result).contains("productId=1"); + Assertions.assertThat(result).contains("score=241"); + Assertions.assertThat(result).contains("rank=1"); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.java new file mode 100644 index 000000000..14e85e12f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.java @@ -0,0 +1,161 @@ +package com.loopers.batch.job.ranking.support; + +import java.time.LocalDate; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("DateRangeParser 단위 테스트") +class DateRangeParserUnitTest { + + private final DateRangeParser parser = new DateRangeParser(); + + @Nested + @DisplayName("주간 날짜 범위 파싱") + class 주간_날짜_범위_파싱 { + + @Test + @DisplayName("유효한 yearWeek 형식을 올바르게 파싱한다") + void should_parse_valid_year_week_correctly() { + // given + String yearWeek = "2024-W52"; + + // when + LocalDate[] dateRange = parser.parseYearWeek(yearWeek); + + // then + Assertions.assertThat(dateRange).hasSize(2); + Assertions.assertThat(dateRange[0]).isBefore(dateRange[1]); + Assertions.assertThat(dateRange[1]).isEqualTo(dateRange[0].plusDays(6)); + } + + @Test + @DisplayName("2024년 1주차를 올바르게 파싱한다") + void should_parse_first_week_of_2024_correctly() { + // given + String yearWeek = "2024-W1"; + + // when + LocalDate[] dateRange = parser.parseYearWeek(yearWeek); + + // then + Assertions.assertThat(dateRange).hasSize(2); + // 2024년 1주차는 1월 1일(월요일)부터 시작 + Assertions.assertThat(dateRange[0]).isEqualTo(LocalDate.of(2024, 1, 1)); + Assertions.assertThat(dateRange[1]).isEqualTo(LocalDate.of(2024, 1, 7)); + } + + @Test + @DisplayName("null yearWeek에 대해 예외가 발생한다") + void should_throw_exception_when_year_week_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> parser.parseYearWeek(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("잘못된 yearWeek 형식입니다"); + } + + @Test + @DisplayName("잘못된 yearWeek 형식에 대해 예외가 발생한다") + void should_throw_exception_when_year_week_format_is_invalid() { + // given + String invalidYearWeek = "2024-52"; + + // when & then + Assertions.assertThatThrownBy(() -> parser.parseYearWeek(invalidYearWeek)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("잘못된 yearWeek 형식입니다"); + } + + @Test + @DisplayName("빈 문자열에 대해 예외가 발생한다") + void should_throw_exception_when_year_week_is_empty() { + // given & when & then + Assertions.assertThatThrownBy(() -> parser.parseYearWeek("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("잘못된 yearWeek 형식입니다"); + } + } + + @Nested + @DisplayName("월간 날짜 범위 파싱") + class 월간_날짜_범위_파싱 { + + @Test + @DisplayName("유효한 yearMonth 형식을 올바르게 파싱한다") + void should_parse_valid_year_month_correctly() { + // given + String yearMonth = "2024-12"; + + // when + LocalDate[] dateRange = parser.parseYearMonth(yearMonth); + + // then + Assertions.assertThat(dateRange).hasSize(2); + Assertions.assertThat(dateRange[0]).isEqualTo(LocalDate.of(2024, 12, 1)); + Assertions.assertThat(dateRange[1]).isEqualTo(LocalDate.of(2024, 12, 31)); + } + + @Test + @DisplayName("2월(윤년)을 올바르게 파싱한다") + void should_parse_february_in_leap_year_correctly() { + // given + String yearMonth = "2024-02"; // 2024년은 윤년 + + // when + LocalDate[] dateRange = parser.parseYearMonth(yearMonth); + + // then + Assertions.assertThat(dateRange[0]).isEqualTo(LocalDate.of(2024, 2, 1)); + Assertions.assertThat(dateRange[1]).isEqualTo(LocalDate.of(2024, 2, 29)); // 윤년 + } + + @Test + @DisplayName("2월(평년)을 올바르게 파싱한다") + void should_parse_february_in_non_leap_year_correctly() { + // given + String yearMonth = "2023-02"; // 2023년은 평년 + + // when + LocalDate[] dateRange = parser.parseYearMonth(yearMonth); + + // then + Assertions.assertThat(dateRange[0]).isEqualTo(LocalDate.of(2023, 2, 1)); + Assertions.assertThat(dateRange[1]).isEqualTo(LocalDate.of(2023, 2, 28)); // 평년 + } + + @Test + @DisplayName("null yearMonth에 대해 예외가 발생한다") + void should_throw_exception_when_year_month_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> parser.parseYearMonth(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("잘못된 yearMonth 형식입니다"); + } + + @Test + @DisplayName("잘못된 yearMonth 형식에 대해 예외가 발생한다") + void should_throw_exception_when_year_month_format_is_invalid() { + // given + String invalidYearMonth = "2024/12"; + + // when & then + Assertions.assertThatThrownBy(() -> parser.parseYearMonth(invalidYearMonth)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("잘못된 yearMonth 형식입니다"); + } + + @Test + @DisplayName("존재하지 않는 월에 대해 예외가 발생한다") + void should_throw_exception_when_month_does_not_exist() { + // given + String invalidYearMonth = "2024-13"; + + // when & then + Assertions.assertThatThrownBy(() -> parser.parseYearMonth(invalidYearMonth)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("yearMonth 파싱 중 오류가 발생했습니다"); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java new file mode 100644 index 000000000..b817b2726 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java @@ -0,0 +1,137 @@ +package com.loopers.batch.job.ranking.support; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import com.loopers.domain.metrics.ProductMetricsAggregation; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; + +@DisplayName("RankingAggregator 단위 테스트") +class RankingAggregatorUnitTest { + + private final ScoreCalculator calculator = new ScoreCalculator(); + private final RankingAggregator aggregator = new RankingAggregator(calculator); + + @Nested + @DisplayName("랭킹 처리") + class 랭킹_처리 { + + @Test + @DisplayName("집계 결과를 점수 기준으로 정렬하고 순위를 부여한다") + void should_sort_by_score_and_assign_ranks() { + // given + List results = List.of( + new ProductMetricsAggregation(1L, 100L, 10L, 5L, 2L , new BigDecimal(0)), + new ProductMetricsAggregation(2L, 200L, 20L, 10L, 4L, new BigDecimal(0)), + new ProductMetricsAggregation(3L, 50L, 5L, 2L, 1L, new BigDecimal(0)) + ); + + // when + List rankings = aggregator.processRankings(results); + + // then + Assertions.assertThat(rankings).hasSize(3); + + // 점수 기준 내림차순 정렬 확인 + Assertions.assertThat(rankings.get(0).getProductId()).isEqualTo(2L); // 1위 + Assertions.assertThat(rankings.get(0).getRankPosition()).isEqualTo(1); + Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(240L); // (200*0.1 + 20*0.2 + log(1)*0.6) * 10 = (20 + 4 + 0) * 10 = 240 + + Assertions.assertThat(rankings.get(1).getProductId()).isEqualTo(1L); // 2위 + Assertions.assertThat(rankings.get(1).getRankPosition()).isEqualTo(2); + Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(120L); // (100*0.1 + 10*0.2) * 10 = 120 + + Assertions.assertThat(rankings.get(2).getProductId()).isEqualTo(3L); // 3위 + Assertions.assertThat(rankings.get(2).getRankPosition()).isEqualTo(3); + Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(60L); // (50*0.1 + 5*0.2) * 10 = 60 + } + + @Test + @DisplayName("TOP 100을 초과하는 결과는 필터링된다") + void should_filter_results_beyond_top_100() { + // given - 150개의 결과 생성 + List results = new ArrayList<>(); + for (int i = 1; i <= 150; i++) { + // 점수가 높은 순서대로 생성 (i가 클수록 점수 높음) + results.add(new ProductMetricsAggregation((long) i, (long) i * 10, (long) i, (long) i, (long) i, new BigDecimal(i))); + } + + // when + List rankings = aggregator.processRankings(results); + + // then + Assertions.assertThat(rankings).hasSize(100); // TOP 100만 반환 + Assertions.assertThat(rankings.get(0).getRankPosition()).isEqualTo(1); + Assertions.assertThat(rankings.get(99).getRankPosition()).isEqualTo(100); + } + + @Test + @DisplayName("빈 결과에 대해 빈 목록을 반환한다") + void should_return_empty_list_for_empty_results() { + // given + List emptyResults = List.of(); + + // when + List rankings = aggregator.processRankings(emptyResults); + + // then + Assertions.assertThat(rankings).isEmpty(); + } + + @Test + @DisplayName("null 결과에 대해 빈 목록을 반환한다") + void should_return_empty_list_for_null_results() { + // when + List rankings = aggregator.processRankings(null); + + // then + Assertions.assertThat(rankings).isEmpty(); + } + + @Test + @DisplayName("동일한 점수의 상품들은 순서가 유지된다") + void should_maintain_order_for_same_scores() { + // given - 동일한 점수를 가진 상품들 + List results = List.of( + new ProductMetricsAggregation(1L, 100L, 0L, 0L, 0L, new BigDecimal(0)), // score = 100 + new ProductMetricsAggregation(2L, 100L, 0L, 0L, 0L, new BigDecimal(0)), // score = 100 + new ProductMetricsAggregation(3L, 100L, 0L, 0L, 0L, new BigDecimal(0)) // score = 100 + ); + + // when + List rankings = aggregator.processRankings(results); + + // then + Assertions.assertThat(rankings).hasSize(3); + Assertions.assertThat(rankings.get(0).getRankPosition()).isEqualTo(1); + Assertions.assertThat(rankings.get(1).getRankPosition()).isEqualTo(2); + Assertions.assertThat(rankings.get(2).getRankPosition()).isEqualTo(3); + + // 모든 점수가 동일함을 확인 + Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(100L); + Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(100L); + Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(100L); + } + } + + @Nested + @DisplayName("설정 정보") + class 설정_정보 { + + @Test + @DisplayName("TOP 랭킹 제한 수를 반환한다") + void should_return_top_rank_limit() { + // when + int limit = aggregator.getTopRankLimit(); + + // then + Assertions.assertThat(limit).isEqualTo(100); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java new file mode 100644 index 000000000..7d8b4e36b --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java @@ -0,0 +1,94 @@ +package com.loopers.batch.job.ranking.support; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("ScoreCalculator 단위 테스트") +class ScoreCalculatorUnitTest { + + private final ScoreCalculator calculator = new ScoreCalculator(); + + @Nested + @DisplayName("점수 계산") + class 점수_계산 { + + @Test + @DisplayName("가중치가 올바르게 적용되어 점수가 계산된다") + void should_calculate_score_with_correct_weights() { + // given + long viewCount = 100, likeCount = 50, salesCount = 10, orderCount = 5; + java.math.BigDecimal totalSalesAmount = java.math.BigDecimal.valueOf(1000); + + // when + long score = calculator.calculate(viewCount, likeCount, totalSalesAmount); + + // then + // viewScore = 100 * 0.1 = 10.0 + // likeScore = 50 * 0.2 = 10.0 + // salesScore = log(1000 + 1) * 0.6 = 6.908 * 0.6 = 4.145 + // total = (10.0 + 10.0 + 4.145) * 10 = 24.145 * 10 = 241 + Assertions.assertThat(score).isEqualTo(241L); + } + + @Test + @DisplayName("모든 메트릭이 0인 경우 점수는 0이다") + void should_return_zero_when_all_metrics_are_zero() { + // given & when + long score = calculator.calculate(0, 0, java.math.BigDecimal.ZERO); + + // then + Assertions.assertThat(score).isEqualTo(0L); + } + + @Test + @DisplayName("판매금액이 가장 높은 가중치를 가진다") + void should_have_highest_weight_for_sales_amount() { + // given + long viewScore = calculator.calculate(100, 0, java.math.BigDecimal.ZERO); // 100 * 0.1 * 10 = 100 + long amountScore = calculator.calculate(0, 0, java.math.BigDecimal.valueOf(1000000)); // log(1000001) * 0.6 * 10 = 13.8 * 6 = 82 + + // 로그 정규화로 인해 금액이 매우 커야 다른 지표를 압도함 + long largeAmountScore = calculator.calculate(0, 0, java.math.BigDecimal.valueOf(1000000000)); + + Assertions.assertThat(viewScore).isEqualTo(100L); + Assertions.assertThat(largeAmountScore).isGreaterThan(viewScore); + } + + @Test + @DisplayName("큰 숫자에서도 정확히 계산된다") + void should_calculate_correctly_with_large_numbers() { + // given + long viewCount = 1_000_000L; + long likeCount = 500_000L; + long salesCount = 100_000L; + long orderCount = 50_000L; + java.math.BigDecimal totalSalesAmount = java.math.BigDecimal.valueOf(100_000_000L); + + // when + long score = calculator.calculate(viewCount, likeCount, totalSalesAmount); + + // then + long expected = (long) (((1_000_000L * 0.1) + (500_000L * 0.2) + Math.log(100_000_000L + 1) * 0.6) * 10); + Assertions.assertThat(score).isEqualTo(expected); + } + } + + @Nested + @DisplayName("가중치 정보") + class 가중치_정보 { + + @Test + @DisplayName("가중치 정보를 올바른 형식으로 반환한다") + void should_return_weight_info_in_correct_format() { + // when + String weightInfo = calculator.getWeightInfo(); + + // then + Assertions.assertThat(weightInfo).contains("VIEW=0.1"); + Assertions.assertThat(weightInfo).contains("LIKE=0.2"); + Assertions.assertThat(weightInfo).contains("SALES=0.6"); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..088dddbba --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,75 @@ +package com.loopers.job.demo; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import java.time.LocalDate; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import com.loopers.batch.job.demo.DemoJobConfig; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java index 501d5a8c9..5450a2870 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java @@ -48,10 +48,10 @@ public class EventProcessingFacade { // Application Layer 의존성 private final MetricsService metricsService; - + // Domain Layer 의존성 private final RankingService rankingService; - + // Infrastructure Layer 의존성 private final EventDeserializer eventDeserializer; @@ -66,7 +66,7 @@ public class EventProcessingFacade { */ public CatalogEventResult processCatalogEvent(Object eventValue) { final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue); - + if (!isValidEnvelope(envelope)) { log.warn("Invalid event envelope: {}", eventValue); return CatalogEventResult.notProcessed(); @@ -106,7 +106,7 @@ public CatalogEventResult processCatalogEvent(Object eventValue) { */ public OrderEventResult processOrderEvent(Object eventValue) { final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue); - + if (!isValidEnvelope(envelope)) { log.warn("Invalid event envelope: {}", eventValue); return OrderEventResult.notProcessed(); @@ -218,7 +218,7 @@ private OrderEventResult processPaymentSuccess(DomainEventEnvelope envelope) { return OrderEventResult.notProcessed(); } - metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); + metricsService.addSales(payload.productId(), payload.quantity(), payload.totalPrice(), envelope.occurredAtEpochMillis()); log.debug("Processed PAYMENT_SUCCESS - orderId: {}, productId: {}, quantity: {}, totalPrice: {}", payload.orderId(), payload.productId(), payload.quantity(), payload.totalPrice()); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java index ccae167bd..8b3933131 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java @@ -45,7 +45,7 @@ public class MetricsService { // Domain Layer 의존성 private final ProductMetricsService productMetricsService; private final EventHandledService eventHandledService; - + // Infrastructure Layer 의존성 private final ProductCacheService productCacheService; @@ -64,7 +64,7 @@ public class MetricsService { /** * 이벤트 처리 여부 확인 및 마킹 - * + * * @param eventId 이벤트 ID * @return true: 처음 처리, false: 이미 처리됨 */ @@ -121,15 +121,15 @@ public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis /** * 판매량 증가 */ - public void addSales(Long productId, int quantity, long occurredAtEpochMillis) { + public void addSales(Long productId, int quantity, java.math.BigDecimal totalAmount, long occurredAtEpochMillis) { executeWithLock(productId, () -> { ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); - boolean updated = productMetricsService.addSales(productId, quantity, eventTime); - + boolean updated = productMetricsService.addSales(productId, quantity, totalAmount, eventTime); + if (updated) { // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향) productCacheService.onSalesCountChanged(productId); - log.debug("판매량 업데이트 성공: productId={}, quantity={}", productId, quantity); + log.debug("판매량 업데이트 성공: productId={}, quantity={}, totalAmount={}", productId, quantity, totalAmount); } }); } @@ -140,7 +140,7 @@ public void addSales(Long productId, int quantity, long occurredAtEpochMillis) { public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) { int stockToUpdate = (remainingStock != null) ? remainingStock : 0; productCacheService.updateProductStock(productId, stockToUpdate); - log.info("재고 소진 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}", + log.info("재고 소진 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}", productId, brandId, stockToUpdate); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java index 3f53ab6d2..cde3d41eb 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java @@ -32,7 +32,7 @@ public boolean isAlreadyHandled(String eventId) { /** * 이벤트 처리 완료 마킹 - * + * * @return true: 저장 성공 (처음 처리), false: 저장 실패 (이미 처리됨 또는 동시성 충돌) */ @Transactional diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java deleted file mode 100644 index a07933520..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.loopers.domain.metrics; - -import java.time.ZonedDateTime; -import java.util.Objects; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -/** - * - * @author hyunjikoh - * @since 2025. 12. 16. - */ - -@Entity -@Getter -@Table(name = "product_metrics") -@AllArgsConstructor -@NoArgsConstructor -public class ProductMetricsEntity { - @Id - @Column(name = "product_id", nullable = false) - private Long id; - - @Column(name = "view_count", nullable = false) - private long viewCount = 0L; - - @Column(name = "like_count", nullable = false) - private long likeCount = 0L; - - @Column(name = "sales_count", nullable = false) - private long salesCount = 0L; - - @Column(name = "order_count", nullable = false) - private long orderCount = 0L; - - @Column(name = "last_event_at") - private ZonedDateTime lastEventAt; - - - private ProductMetricsEntity(final Long productId) { - Objects.requireNonNull(productId); - this.id = productId; - } - - public static ProductMetricsEntity create(final Long productId) { - return new ProductMetricsEntity(productId); - } - - - public void incrementView(ZonedDateTime eventTime) { - this.viewCount += 1; - this.lastEventAt = eventTime; - } - - public void applyLikeDelta(final int delta, ZonedDateTime eventTime) { - final long next = this.likeCount + delta; - - // 좋아요 수는 0 미만으로 내려가지 않도록 보장 - this.likeCount = Math.max(0, next); - - this.lastEventAt = eventTime; - } - - - public void addSales(final int quantity, ZonedDateTime eventTime) { - if (quantity <= 0) { - return; - } - this.salesCount += quantity; - this.orderCount += 1; // 주문 건수도 함께 증가 - this.lastEventAt = eventTime; - } - -} 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 deleted file mode 100644 index c9ec7e09d..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.domain.metrics; - -import java.util.Optional; - -/** - * 상품 메트릭 Repository 인터페이스 - *

- * Domain 계층의 순수한 Repository 인터페이스입니다. - * Infrastructure 계층에서 JPA로 구현됩니다. - * - * @author hyunjikoh - * @since 2025. 12. 16. - */ -public interface ProductMetricsRepository { - - /** - * 메트릭 저장 - */ - ProductMetricsEntity save(ProductMetricsEntity metrics); - - /** - * 상품 ID로 메트릭 조회 - */ - Optional findById(Long productId); -} 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 befdcf1d1..94bf2478c 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 @@ -1,5 +1,6 @@ package com.loopers.domain.metrics; +import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.Optional; @@ -14,6 +15,8 @@ *

* 상품 메트릭 관련 비즈니스 로직을 담당하는 Domain 계층 서비스입니다. * 트랜잭션 경계를 관리하고 Repository를 통해 데이터를 조작합니다. + *

+ * 일간 집계를 위해 이벤트 시간에서 날짜를 추출하여 메트릭을 관리합니다. * * @author hyunjikoh * @since 2025. 12. 26. @@ -21,42 +24,52 @@ @Service @RequiredArgsConstructor @Slf4j +@Transactional(readOnly = true) public class ProductMetricsService { private final ProductMetricsRepository productMetricsRepository; /** * 조회수 증가 + * + * @param productId 상품 ID + * @param eventTime 이벤트 발생 시간 */ @Transactional public void incrementView(Long productId, ZonedDateTime eventTime) { - ProductMetricsEntity metrics = getOrCreateMetrics(productId); + LocalDate metricDate = eventTime.toLocalDate(); + ProductMetricsEntity metrics = getOrCreateMetrics(productId, metricDate); metrics.incrementView(eventTime); productMetricsRepository.save(metrics); - log.debug("조회수 증가 완료: productId={}", productId); + log.debug("조회수 증가 완료: productId={}, date={}", productId, metricDate); } /** * 좋아요 수 변경 - * + * + * @param productId 상품 ID + * @param delta 변경량 (양수: 증가, 음수: 감소) + * @param eventTime 이벤트 발생 시간 * @return true: 변경됨, false: 변경 안 됨 (새 상품에 대한 좋아요 감소) */ @Transactional public boolean applyLikeDelta(Long productId, int delta, ZonedDateTime eventTime) { - Optional existing = productMetricsRepository.findById(productId); + LocalDate metricDate = eventTime.toLocalDate(); + Optional existing = productMetricsRepository + .findByProductIdAndMetricDate(productId, metricDate); if (existing.isPresent()) { ProductMetricsEntity metrics = existing.get(); metrics.applyLikeDelta(delta, eventTime); productMetricsRepository.save(metrics); - log.debug("좋아요 수 변경 완료: productId={}, delta={}", productId, delta); + log.debug("좋아요 수 변경 완료: productId={}, delta={}, date={}", productId, delta, metricDate); return true; } else if (delta > 0) { // 새로운 상품에 대한 좋아요 추가만 허용 - ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); + ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId, metricDate); newMetrics.applyLikeDelta(delta, eventTime); productMetricsRepository.save(newMetrics); - log.debug("새 상품 좋아요 추가 완료: productId={}, delta={}", productId, delta); + log.debug("새 상품 좋아요 추가 완료: productId={}, delta={}, date={}", productId, delta, metricDate); return true; } else { log.debug("새로운 상품에 대한 좋아요 감소 무시: productId={}, delta={}", productId, delta); @@ -66,28 +79,38 @@ public boolean applyLikeDelta(Long productId, int delta, ZonedDateTime eventTime /** * 판매량 증가 - * + * + * @param productId 상품 ID + * @param quantity 판매 수량 + * @param totalAmount 총 판매 금액 + * @param eventTime 이벤트 발생 시간 * @return true: 증가됨, false: 증가 안 됨 (잘못된 수량) */ @Transactional - public boolean addSales(Long productId, int quantity, ZonedDateTime eventTime) { + public boolean addSales(Long productId, int quantity, java.math.BigDecimal totalAmount, ZonedDateTime eventTime) { if (quantity <= 0) { log.debug("잘못된 판매량 무시: productId={}, quantity={}", productId, quantity); return false; } - ProductMetricsEntity metrics = getOrCreateMetrics(productId); - metrics.addSales(quantity, eventTime); + LocalDate metricDate = eventTime.toLocalDate(); + ProductMetricsEntity metrics = getOrCreateMetrics(productId, metricDate); + metrics.addSales(quantity, totalAmount, eventTime); productMetricsRepository.save(metrics); - log.debug("판매량 증가 완료: productId={}, quantity={}", productId, quantity); + log.debug("판매량 증가 완료: productId={}, quantity={}, totalAmount={}, date={}", productId, quantity, totalAmount, metricDate); return true; } /** * 상품 메트릭 조회 또는 생성 + * + * @param productId 상품 ID + * @param metricDate 메트릭 날짜 + * @return 메트릭 엔티티 */ - private ProductMetricsEntity getOrCreateMetrics(Long productId) { - return productMetricsRepository.findById(productId) - .orElseGet(() -> ProductMetricsEntity.create(productId)); + private ProductMetricsEntity getOrCreateMetrics(Long productId, LocalDate metricDate) { + return productMetricsRepository + .findByProductIdAndMetricDate(productId, metricDate) + .orElseGet(() -> ProductMetricsEntity.create(productId, metricDate)); } } 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 6615061fa..929653aab 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,13 +1,63 @@ package com.loopers.infrastructure.metrics; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import com.loopers.domain.metrics.ProductMetricsAggregation; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.loopers.domain.metrics.ProductMetricsEntity; +import com.loopers.domain.metrics.ProductMetricsId; /** + * 상품 메트릭 JPA Repository * * @author hyunjikoh * @since 2025. 12. 16. */ -public interface ProductMetricsJpaRepository extends JpaRepository { +public interface ProductMetricsJpaRepository extends JpaRepository { + + /** + * 상품 ID와 날짜로 메트릭 조회 + */ + @Query("SELECT m FROM ProductMetricsEntity m WHERE m.id.productId = :productId AND m.id.metricDate = :metricDate") + Optional findByProductIdAndMetricDate( + @Param("productId") Long productId, + @Param("metricDate") LocalDate metricDate); + + /** + * 기간별 메트릭 조회 + */ + @Query("SELECT m FROM ProductMetricsEntity m WHERE m.id.metricDate BETWEEN :startDate AND :endDate") + List findByMetricDateBetween( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + /** + * 특정 날짜의 전체 메트릭 조회 + */ + @Query("SELECT m FROM ProductMetricsEntity m WHERE m.id.metricDate = :metricDate") + List findByMetricDate(@Param("metricDate") LocalDate metricDate); + + /** + * 기간별 상품 집계 (GROUP BY) + */ + @Query(""" + SELECT new com.loopers.domain.metrics.ProductMetricsAggregation( + m.id.productId, + SUM(m.viewCount), + SUM(m.likeCount), + SUM(m.salesCount), + SUM(m.orderCount), + SUM(m.totalSalesAmount)) + FROM ProductMetricsEntity m + WHERE m.id.metricDate BETWEEN :startDate AND :endDate + GROUP BY m.id.productId + """) + List aggregateByDateRange( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); } 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 633220ac4..461aebcb9 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 @@ -1,15 +1,20 @@ package com.loopers.infrastructure.metrics; +import java.time.LocalDate; +import java.util.List; import java.util.Optional; import org.springframework.stereotype.Component; +import com.loopers.domain.metrics.ProductMetricsAggregation; import com.loopers.domain.metrics.ProductMetricsEntity; +import com.loopers.domain.metrics.ProductMetricsId; import com.loopers.domain.metrics.ProductMetricsRepository; import lombok.RequiredArgsConstructor; /** + * 상품 메트릭 Repository 구현체 * * @author hyunjikoh * @since 2025. 12. 16. @@ -17,6 +22,7 @@ @Component @RequiredArgsConstructor public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + private final ProductMetricsJpaRepository productMetricsJpaRepository; @Override @@ -25,7 +31,22 @@ public ProductMetricsEntity save(ProductMetricsEntity metrics) { } @Override - public Optional findById(Long productId) { - return productMetricsJpaRepository.findById(productId); + public Optional findById(ProductMetricsId id) { + return productMetricsJpaRepository.findById(id); + } + + @Override + public Optional findByProductIdAndMetricDate(Long productId, LocalDate metricDate) { + return productMetricsJpaRepository.findByProductIdAndMetricDate(productId, metricDate); + } + + @Override + public List findByMetricDateBetween(LocalDate startDate, LocalDate endDate) { + return productMetricsJpaRepository.findByMetricDateBetween(startDate, endDate); + } + + @Override + public List aggregateByDateRange(LocalDate startDate, LocalDate endDate) { + return productMetricsJpaRepository.aggregateByDateRange(startDate, endDate); } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java index 1ba974843..0df5a144b 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java @@ -2,19 +2,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; - import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -59,14 +55,14 @@ void shouldGenerateScoreForProductView() throws Exception { // Given Long productId = 1L; long occurredAt = System.currentTimeMillis(); - + ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L); String payloadJson = objectMapper.writeValueAsString(payload); - + DomainEventEnvelope envelope = new DomainEventEnvelope( "event-1", "PRODUCT_VIEW", "v1", occurredAt, payloadJson ); - + when(eventDeserializer.deserializeProductView(payloadJson)).thenReturn(payload); // When @@ -86,14 +82,14 @@ void shouldGenerateScoreForLikeAction() throws Exception { // Given Long productId = 2L; long occurredAt = System.currentTimeMillis(); - + LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, 100L, "LIKE"); String payloadJson = objectMapper.writeValueAsString(payload); - + DomainEventEnvelope envelope = new DomainEventEnvelope( "event-2", "LIKE_ACTION", "v1", occurredAt, payloadJson ); - + when(eventDeserializer.deserializeLikeAction(payloadJson)).thenReturn(payload); // When @@ -112,14 +108,14 @@ void shouldNotGenerateScoreForUnlike() throws Exception { // Given Long productId = 2L; long occurredAt = System.currentTimeMillis(); - + LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, 100L, "UNLIKE"); String payloadJson = objectMapper.writeValueAsString(payload); - + DomainEventEnvelope envelope = new DomainEventEnvelope( "event-3", "LIKE_ACTION", "v1", occurredAt, payloadJson ); - + when(eventDeserializer.deserializeLikeAction(payloadJson)).thenReturn(payload); // When @@ -136,16 +132,16 @@ void shouldGenerateLogNormalizedScoreForPaymentSuccess() throws Exception { Long productId = 3L; long occurredAt = System.currentTimeMillis(); BigDecimal totalPrice = BigDecimal.valueOf(10000); - + PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1( 1L, 1L, 100L, productId, 2, BigDecimal.valueOf(5000), totalPrice ); String payloadJson = objectMapper.writeValueAsString(payload); - + DomainEventEnvelope envelope = new DomainEventEnvelope( "event-4", "PAYMENT_SUCCESS", "v1", occurredAt, payloadJson ); - + when(eventDeserializer.deserializePaymentSuccess(payloadJson)).thenReturn(payload); // When @@ -155,11 +151,11 @@ void shouldGenerateLogNormalizedScoreForPaymentSuccess() throws Exception { assertThat(score).isNotNull(); assertThat(score.productId()).isEqualTo(productId); assertThat(score.eventType()).isEqualTo(RankingScore.EventType.PAYMENT_SUCCESS); - + // 로그 정규화 확인: log(10000 + 1) ≈ 9.21 double expectedScore = Math.log(10001); assertThat(score.score()).isCloseTo(expectedScore, org.assertj.core.data.Offset.offset(0.01)); - + // 가중치 적용: 0.6 * log(10001) ≈ 5.53 assertThat(score.getWeightedScore()).isCloseTo(0.6 * expectedScore, org.assertj.core.data.Offset.offset(0.01)); } @@ -245,7 +241,7 @@ void shouldGetPaginatedRanking() { new RankingItem(2, 102L, 90.0), new RankingItem(3, 103L, 80.0) ); - + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(expectedRankings); // When @@ -264,7 +260,7 @@ void shouldGetProductRanking() { LocalDate today = LocalDate.now(); Long productId = 101L; RankingItem expectedRanking = new RankingItem(5, productId, 75.0); - + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(expectedRanking); // When @@ -283,7 +279,7 @@ void shouldReturnNullForUnrankedProduct() { // Given LocalDate today = LocalDate.now(); Long productId = 999L; - + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(null); // When diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java index 679ce3c79..6524077e8 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import java.time.Duration; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -61,6 +62,7 @@ void setUp() { void shouldIncrementViewCountOnProductViewEvent() throws Exception { // Given Long productId = 1L; + LocalDate today = LocalDate.now(); String eventId = "product-view-test-" + System.currentTimeMillis(); ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L); @@ -80,7 +82,8 @@ void shouldIncrementViewCountOnProductViewEvent() throws Exception { // Then await().atMost(Duration.ofSeconds(10)) .untilAsserted(() -> { - Optional metrics = productMetricsRepository.findById(productId); + Optional metrics = productMetricsRepository + .findByProductIdAndMetricDate(productId, today); assertThat(metrics).isPresent(); assertThat(metrics.get().getViewCount()).isEqualTo(1L); assertThat(metrics.get().getLastEventAt()).isNotNull(); @@ -95,6 +98,7 @@ void shouldIncrementViewCountOnProductViewEvent() throws Exception { void shouldProcessDuplicateEventOnlyOnce() throws Exception { // Given Long productId = 2L; + LocalDate today = LocalDate.now(); String eventId = "duplicate-test-" + System.currentTimeMillis(); ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 200L); @@ -115,7 +119,8 @@ void shouldProcessDuplicateEventOnlyOnce() throws Exception { // Then - 조회수는 1만 증가해야 함 await().atMost(Duration.ofSeconds(10)) .untilAsserted(() -> { - Optional metrics = productMetricsRepository.findById(productId); + Optional metrics = productMetricsRepository + .findByProductIdAndMetricDate(productId, today); assertThat(metrics).isPresent(); assertThat(metrics.get().getViewCount()).isEqualTo(1L); }); @@ -126,6 +131,7 @@ void shouldProcessDuplicateEventOnlyOnce() throws Exception { void shouldIncrementSalesCountOnPaymentSuccessEvent() throws Exception { // Given Long productId = 3L; + LocalDate today = LocalDate.now(); String eventId = "payment-success-test-" + System.currentTimeMillis(); // 새로운 PaymentSuccessPayloadV1 구조 (상품별 개별 이벤트) @@ -154,7 +160,8 @@ void shouldIncrementSalesCountOnPaymentSuccessEvent() throws Exception { // Then await().atMost(Duration.ofSeconds(10)) .untilAsserted(() -> { - Optional metrics = productMetricsRepository.findById(productId); + Optional metrics = productMetricsRepository + .findByProductIdAndMetricDate(productId, today); assertThat(metrics).isPresent(); assertThat(metrics.get().getSalesCount()).isEqualTo(2L); @@ -167,6 +174,7 @@ void shouldIncrementSalesCountOnPaymentSuccessEvent() throws Exception { void shouldIgnoreOldEvents() throws Exception { // Given Long productId = 5L; + LocalDate today = LocalDate.now(); long currentTime = System.currentTimeMillis(); // 먼저 최신 이벤트를 처리 @@ -187,7 +195,8 @@ void shouldIgnoreOldEvents() throws Exception { // 최신 이벤트가 처리될 때까지 대기 await().atMost(Duration.ofSeconds(10)) .untilAsserted(() -> { - Optional metrics = productMetricsRepository.findById(productId); + Optional metrics = productMetricsRepository + .findByProductIdAndMetricDate(productId, today); assertThat(metrics).isPresent(); assertThat(metrics.get().getViewCount()).isEqualTo(1L); }); @@ -207,11 +216,12 @@ void shouldIgnoreOldEvents() throws Exception { kafkaTemplate.send("catalog-events", oldEnvelope); -// Then - 조회수는 여전히 1이어야 함 (과거 이벤트 무시) + // Then - 조회수는 여전히 1이어야 함 (과거 이벤트 무시) await().atMost(Duration.ofSeconds(2)) .until(() -> { - Optional finalMetrics = productMetricsRepository.findById(productId); - return finalMetrics.isPresent() && finalMetrics.get().getViewCount()==1L; + Optional finalMetrics = productMetricsRepository + .findByProductIdAndMetricDate(productId, today); + return finalMetrics.isPresent() && finalMetrics.get().getViewCount() == 1L; }); // 과거 이벤트도 멱등성 테이블에는 기록되어야 함 @@ -223,6 +233,7 @@ void shouldIgnoreOldEvents() throws Exception { void shouldInitializeNewMetricFields() throws Exception { // Given Long productId = 6L; + LocalDate today = LocalDate.now(); String eventId = "new-metrics-test-" + System.currentTimeMillis(); ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L); @@ -242,7 +253,8 @@ void shouldInitializeNewMetricFields() throws Exception { // Then - 새로운 메트릭 필드들이 0으로 초기화되어야 함 await().atMost(Duration.ofSeconds(10)) .untilAsserted(() -> { - Optional metrics = productMetricsRepository.findById(productId); + Optional metrics = productMetricsRepository + .findByProductIdAndMetricDate(productId, today); assertThat(metrics).isPresent(); ProductMetricsEntity entity = metrics.get(); diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java index a3bd9084c..ce4e1b87b 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.data.Offset.offset; import static org.awaitility.Awaitility.await; - import java.math.BigDecimal; import java.time.Duration; import java.time.LocalDate; @@ -22,7 +21,6 @@ import com.loopers.cache.CacheKeyGenerator; import com.loopers.cache.RankingRedisService; import com.loopers.cache.dto.CachePayloads.RankingItem; -import com.loopers.config.redis.RedisConfig; import com.loopers.infrastructure.event.DomainEventEnvelope; import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; @@ -149,7 +147,7 @@ void shouldStorePaymentSuccessAsLogNormalizedScore() throws Exception { // Then - 로그 정규화된 점수가 적재되어야 함 // Weight 0.6 * log(10001) ≈ 5.53 double expectedScore = 0.6 * Math.log(10001); - + await().atMost(Duration.ofSeconds(10)) .untilAsserted(() -> { RankingItem ranking = rankingRedisService.getProductRanking(today, productId); @@ -170,14 +168,14 @@ void shouldAccumulateScoresForSameProduct() throws Exception { ProductViewPayloadV1 viewPayload = new ProductViewPayloadV1(productId, 100L); DomainEventEnvelope viewEnvelope = new DomainEventEnvelope( "view-" + productId + "-" + i + "-" + baseTime, - "PRODUCT_VIEW", "v1", baseTime + i, + "PRODUCT_VIEW", "v1", baseTime + i, objectMapper.writeValueAsString(viewPayload) ); kafkaTemplate.send("catalog-events", viewEnvelope); } for (int i = 0; i < 2; i++) { - LikeActionPayloadV1 likePayload = new LikeActionPayloadV1(productId, (long)(100 + i), "LIKE"); + LikeActionPayloadV1 likePayload = new LikeActionPayloadV1(productId, (long) (100 + i), "LIKE"); DomainEventEnvelope likeEnvelope = new DomainEventEnvelope( "like-" + productId + "-" + i + "-" + baseTime, "LIKE_ACTION", "v1", baseTime + 10 + i, @@ -188,7 +186,7 @@ void shouldAccumulateScoresForSameProduct() throws Exception { // Then - 점수가 누적되어야 함 double expectedScore = 0.1 * 3 + 0.2 * 2; // 0.7 - + await().atMost(Duration.ofSeconds(15)) .untilAsserted(() -> { RankingItem ranking = rankingRedisService.getProductRanking(today, productId); @@ -241,7 +239,7 @@ void shouldReturnRankingsInOrder() throws Exception { .untilAsserted(() -> { List rankings = rankingRedisService.getRanking(today, 1, 10); assertThat(rankings).hasSizeGreaterThanOrEqualTo(3); - + // 첫 번째가 가장 높은 점수 (결제) assertThat(rankings.get(0).productId()).isEqualTo(product1); // 두 번째가 중간 점수 (좋아요) @@ -262,7 +260,7 @@ void shouldCarryOverScoresToNextDay() { // Given - 오늘 랭킹 데이터 직접 추가 Long productId = 3001L; double originalScore = 100.0; - + redisTemplate.opsForZSet().add(todayRankingKey, productId.toString(), originalScore); LocalDate tomorrow = today.plusDays(1); @@ -274,7 +272,7 @@ void shouldCarryOverScoresToNextDay() { // Then assertThat(carryOverCount).isEqualTo(1); - + Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); assertThat(tomorrowScore).isNotNull(); assertThat(tomorrowScore).isCloseTo(10.0, offset(0.01)); // 100 * 0.1 @@ -289,7 +287,7 @@ void shouldSkipCarryOverWhenNoSourceData() { // Given LocalDate emptyDate = today.minusDays(10); LocalDate targetDate = emptyDate.plusDays(1); - + String emptyKey = cacheKeyGenerator.generateDailyRankingKey(emptyDate); redisTemplate.delete(emptyKey); // 확실히 비어있게 diff --git a/modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankEntity.java b/modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankEntity.java new file mode 100644 index 000000000..c0cefa113 --- /dev/null +++ b/modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankEntity.java @@ -0,0 +1,63 @@ +package com.loopers.domain.ranking; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QWeeklyRankEntity is a Querydsl query type for WeeklyRankEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QWeeklyRankEntity extends EntityPathBase { + + private static final long serialVersionUID = 2039637561L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QWeeklyRankEntity weeklyRankEntity = new QWeeklyRankEntity("weeklyRankEntity"); + + public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); + + public final QWeeklyRankId id; + + public final NumberPath likeCount = createNumber("likeCount", Long.class); + + public final NumberPath orderCount = createNumber("orderCount", Long.class); + + public final NumberPath rankPosition = createNumber("rankPosition", Integer.class); + + public final NumberPath salesCount = createNumber("salesCount", Long.class); + + public final NumberPath totalScore = createNumber("totalScore", Long.class); + + public final NumberPath viewCount = createNumber("viewCount", Long.class); + + public QWeeklyRankEntity(String variable) { + this(WeeklyRankEntity.class, forVariable(variable), INITS); + } + + public QWeeklyRankEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QWeeklyRankEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QWeeklyRankEntity(PathMetadata metadata, PathInits inits) { + this(WeeklyRankEntity.class, metadata, inits); + } + + public QWeeklyRankEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.id = inits.isInitialized("id") ? new QWeeklyRankId(forProperty("id")) : null; + } + +} + diff --git a/modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankId.java b/modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankId.java new file mode 100644 index 000000000..fd785ddac --- /dev/null +++ b/modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankId.java @@ -0,0 +1,39 @@ +package com.loopers.domain.ranking; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QWeeklyRankId is a Querydsl query type for WeeklyRankId + */ +@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") +public class QWeeklyRankId extends BeanPath { + + private static final long serialVersionUID = 1225730673L; + + public static final QWeeklyRankId weeklyRankId = new QWeeklyRankId("weeklyRankId"); + + public final NumberPath productId = createNumber("productId", Long.class); + + public final StringPath yearWeek = createString("yearWeek"); + + public QWeeklyRankId(String variable) { + super(WeeklyRankId.class, forVariable(variable)); + } + + public QWeeklyRankId(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QWeeklyRankId(PathMetadata metadata) { + super(WeeklyRankId.class, metadata); + } + +} + diff --git a/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java new file mode 100644 index 000000000..c1131478f --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java @@ -0,0 +1,129 @@ +package com.loopers.domain.metrics; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.Objects; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +/** + * 상품 메트릭 엔티티 (일간 집계) + *

+ * 상품별 일간 메트릭을 저장합니다. + * 복합키(product_id + metric_date)를 사용하여 일간 집계를 구분합니다. + * + * @author hyunjikoh + * @since 2025. 12. 16. + */ +@Entity +@Getter +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsEntity { + + @EmbeddedId + private ProductMetricsId id; + + @Column(name = "view_count", nullable = false) + private long viewCount = 0L; + + @Column(name = "like_count", nullable = false) + private long likeCount = 0L; + + @Column(name = "sales_count", nullable = false) + private long salesCount = 0L; + + @Column(name = "order_count", nullable = false) + private long orderCount = 0L; + + @Column(name = "total_sales_amount", nullable = false) + private java.math.BigDecimal totalSalesAmount = java.math.BigDecimal.ZERO; + + @Column(name = "last_event_at") + private ZonedDateTime lastEventAt; + + private ProductMetricsEntity(Long productId, LocalDate metricDate) { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + Objects.requireNonNull(metricDate, "메트릭 날짜는 필수입니다."); + this.id = ProductMetricsId.of(productId, metricDate); + this.totalSalesAmount = java.math.BigDecimal.ZERO; + } + + /** + * 상품 메트릭 엔티티 생성 + * + * @param productId 상품 ID + * @param metricDate 메트릭 날짜 + * @return ProductMetricsEntity 인스턴스 + */ + public static ProductMetricsEntity create(Long productId, LocalDate metricDate) { + return new ProductMetricsEntity(productId, metricDate); + } + + // === 편의 메서드 === + + /** + * 상품 ID 조회 + */ + public Long getProductId() { + return id.getProductId(); + } + + /** + * 메트릭 날짜 조회 + */ + public LocalDate getMetricDate() { + return id.getMetricDate(); + } + + // === 비즈니스 메서드 === + + /** + * 조회수 증가 + * + * @param eventTime 이벤트 발생 시간 + */ + public void incrementView(ZonedDateTime eventTime) { + this.viewCount += 1; + this.lastEventAt = eventTime; + } + + /** + * 좋아요 수 변경 + *

+ * 좋아요 수는 0 미만으로 내려가지 않도록 보장합니다. + * + * @param delta 변경량 (양수: 증가, 음수: 감소) + * @param eventTime 이벤트 발생 시간 + */ + public void applyLikeDelta(int delta, ZonedDateTime eventTime) { + Objects.requireNonNull(eventTime, "이벤트 시간은 필수입니다."); + long next = this.likeCount + delta; + this.likeCount = Math.max(0, next); + this.lastEventAt = eventTime; + } + + /** + * 판매량 증가 + * + * @param quantity 판매 수량 + * @param totalAmount 총 판매 금액 + * @param eventTime 이벤트 발생 시간 + */ + public void addSales(int quantity, java.math.BigDecimal totalAmount, ZonedDateTime eventTime) { + if (quantity <= 0) { + return; + } + this.salesCount += quantity; + this.orderCount += 1; + this.totalSalesAmount = this.totalSalesAmount.add(totalAmount != null ? totalAmount : java.math.BigDecimal.ZERO); + this.lastEventAt = eventTime; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java b/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java new file mode 100644 index 000000000..0fbc00ff9 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java @@ -0,0 +1,57 @@ +package com.loopers.domain.metrics; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +/** + * 상품 메트릭 복합 PK + *

+ * product_id + metric_date 조합으로 일간 집계를 구분합니다. + * Hibernate 6.x 권장 방식인 @Embeddable + @EmbeddedId 패턴을 사용합니다. + * + * @author hyunjikoh + * @since 2025. 12. 31. + */ +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class ProductMetricsId implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + + private ProductMetricsId(Long productId, LocalDate metricDate) { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + Objects.requireNonNull(metricDate, "메트릭 날짜는 필수입니다."); + this.productId = productId; + this.metricDate = metricDate; + } + + /** + * 복합키 생성 팩토리 메서드 + * + * @param productId 상품 ID + * @param metricDate 메트릭 날짜 + * @return ProductMetricsId 인스턴스 + */ + public static ProductMetricsId of(Long productId, LocalDate metricDate) { + return new ProductMetricsId(productId, metricDate); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..b930ba9a5 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,60 @@ +package com.loopers.domain.metrics; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * 상품 메트릭 Repository 인터페이스 + *

+ * Domain 계층의 순수한 Repository 인터페이스입니다. + * Infrastructure 계층에서 JPA로 구현됩니다. + * + * @author hyunjikoh + * @since 2025. 12. 16. + */ +public interface ProductMetricsRepository { + + /** + * 메트릭 저장 + * + * @param metrics 저장할 메트릭 엔티티 + * @return 저장된 메트릭 엔티티 + */ + ProductMetricsEntity save(ProductMetricsEntity metrics); + + /** + * 복합키로 메트릭 조회 + * + * @param id 복합키 (productId + metricDate) + * @return 메트릭 엔티티 + */ + Optional findById(ProductMetricsId id); + + /** + * 상품 ID와 날짜로 메트릭 조회 + * + * @param productId 상품 ID + * @param metricDate 메트릭 날짜 + * @return 메트릭 엔티티 + */ + Optional findByProductIdAndMetricDate(Long productId, LocalDate metricDate); + + /** + * 기간별 메트릭 조회 (배치용) + * + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 메트릭 엔티티 목록 + */ + List findByMetricDateBetween(LocalDate startDate, LocalDate endDate); + + /** + * 기간별 상품 집계 (배치용 - GROUP BY) + * + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 집계 결과 목록 + */ + List aggregateByDateRange(LocalDate startDate, LocalDate endDate); +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java new file mode 100644 index 000000000..d6f25268c --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java @@ -0,0 +1,96 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 월간 랭킹 MV 엔티티 + * - 배치 Job에서 월간 TOP 100 랭킹을 저장 + */ +@Entity +@Table( + name = "mv_product_rank_monthly", + indexes = { + @Index(name = "idx_year_month_rank", columnList = "base_year_month, base_rank_position") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyRankEntity { + + @EmbeddedId + private MonthlyRankId id; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "total_score", nullable = false) + private long totalScore; + + @Column(name = "base_rank_position", nullable = false) + private int rankPosition; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + private MonthlyRankEntity(MonthlyRankId id, long viewCount, long likeCount, + long salesCount, long orderCount, long totalScore, int rankPosition) { + this.id = id; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.orderCount = orderCount; + this.totalScore = totalScore; + this.rankPosition = rankPosition; + this.createdAt = LocalDateTime.now(); + } + + /** + * 월간 랭킹 엔티티를 생성합니다. + */ + public static MonthlyRankEntity create(Long productId, String yearMonth, + long viewCount, long likeCount, + long salesCount, long orderCount, + long totalScore, int rankPosition) { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + Objects.requireNonNull(yearMonth, "월 정보는 필수입니다."); + validateRankPosition(rankPosition); + + MonthlyRankId id = MonthlyRankId.of(productId, yearMonth); + return new MonthlyRankEntity(id, viewCount, likeCount, salesCount, orderCount, totalScore, rankPosition); + } + + private static void validateRankPosition(int rankPosition) { + if (rankPosition < 1 || rankPosition > 100) { + throw new IllegalArgumentException( + String.format("순위는 1~100 범위여야 합니다. (입력값: %d)", rankPosition)); + } + } + + // 편의 메서드 + public Long getProductId() { + return id.getProductId(); + } + + public String getYearMonth() { + return id.getYearMonth(); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.java new file mode 100644 index 000000000..6207c756f --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.java @@ -0,0 +1,36 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 월간 랭킹 복합 PK + * - product_id + year_month 조합으로 유일성 보장 + */ +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class MonthlyRankId implements Serializable { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "base_year_month", nullable = false, length = 7) + private String yearMonth; // e.g., "2024-12" + + private MonthlyRankId(Long productId, String yearMonth) { + this.productId = productId; + this.yearMonth = yearMonth; + } + + public static MonthlyRankId of(Long productId, String yearMonth) { + return new MonthlyRankId(productId, yearMonth); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java new file mode 100644 index 000000000..127f823a6 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java @@ -0,0 +1,36 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** + * 월간 랭킹 Repository 인터페이스 + */ +public interface MonthlyRankRepository { + + /** + * 월간 랭킹 엔티티를 저장합니다. + */ + MonthlyRankEntity save(MonthlyRankEntity entity); + + /** + * 월간 랭킹 엔티티 목록을 저장합니다. + */ + List saveAll(List entities); + + /** + * 특정 월의 랭킹을 조회합니다. + */ + Page findByYearMonth(String yearMonth, Pageable pageable); + + + /** + * 특정 월의 모든 랭킹을 삭제합니다. (멱등성 보장용) + * + * @param yearMonth 삭제할 월 (예: "2024-12") + * @return 삭제된 레코드 수 + */ + long deleteByYearMonth(String yearMonth); +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java new file mode 100644 index 000000000..310bee042 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java @@ -0,0 +1,96 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 주간 랭킹 MV 엔티티 + * - 배치 Job에서 주간 TOP 100 랭킹을 저장 + */ +@Entity +@Table( + name = "mv_product_rank_weekly", + indexes = { + @Index(name = "idx_year_week_rank", columnList = "base_year_week, base_rank_position") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyRankEntity { + + @EmbeddedId + private WeeklyRankId id; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "total_score", nullable = false) + private long totalScore; + + @Column(name = "base_rank_position", nullable = false) + private int rankPosition; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + private WeeklyRankEntity(WeeklyRankId id, long viewCount, long likeCount, + long salesCount, long orderCount, long totalScore, int rankPosition) { + this.id = id; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.orderCount = orderCount; + this.totalScore = totalScore; + this.rankPosition = rankPosition; + this.createdAt = LocalDateTime.now(); + } + + /** + * 주간 랭킹 엔티티를 생성합니다. + */ + public static WeeklyRankEntity create(Long productId, String yearWeek, + long viewCount, long likeCount, + long salesCount, long orderCount, + long totalScore, int rankPosition) { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + Objects.requireNonNull(yearWeek, "주차 정보는 필수입니다."); + validateRankPosition(rankPosition); + + WeeklyRankId id = WeeklyRankId.of(productId, yearWeek); + return new WeeklyRankEntity(id, viewCount, likeCount, salesCount, orderCount, totalScore, rankPosition); + } + + private static void validateRankPosition(int rankPosition) { + if (rankPosition < 1 || rankPosition > 100) { + throw new IllegalArgumentException( + String.format("순위는 1~100 범위여야 합니다. (입력값: %d)", rankPosition)); + } + } + + // 편의 메서드 + public Long getProductId() { + return id.getProductId(); + } + + public String getYearWeek() { + return id.getYearWeek(); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.java new file mode 100644 index 000000000..7bfc86650 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.java @@ -0,0 +1,36 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 주간 랭킹 복합 PK + * - product_id + year_week 조합으로 유일성 보장 + */ +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class WeeklyRankId implements Serializable { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "base_year_week", nullable = false, length = 8) + private String yearWeek; // e.g., "2024-W52" + + private WeeklyRankId(Long productId, String yearWeek) { + this.productId = productId; + this.yearWeek = yearWeek; + } + + public static WeeklyRankId of(Long productId, String yearWeek) { + return new WeeklyRankId(productId, yearWeek); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java new file mode 100644 index 000000000..cc5ab6b07 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java @@ -0,0 +1,37 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** + * 주간 랭킹 Repository 인터페이스 + */ +public interface WeeklyRankRepository { + + /** + * 주간 랭킹 엔티티를 저장합니다. + */ + WeeklyRankEntity save(WeeklyRankEntity entity); + + /** + * 주간 랭킹 엔티티 목록을 저장합니다. + */ + List saveAll(List entities); + + /** + * 특정 주차의 랭킹을 조회합니다. + */ + List findByYearWeek(String yearWeek); + + /** + * 특정 주차의 모든 랭킹을 삭제합니다. (멱등성 보장용) + * + * @param yearWeek 삭제할 주차 (예: "2024-W52") + * @return 삭제된 레코드 수 + */ + long deleteByYearWeek(String yearWeek); + + Page findByYearWeek(String yearWeek, Pageable pageable); +} diff --git a/modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.java b/modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.java new file mode 100644 index 000000000..eaca5818a --- /dev/null +++ b/modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.java @@ -0,0 +1,207 @@ +package com.loopers.domain.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * ProductMetricsEntity 단위 테스트 + * + * @author hyunjikoh + * @since 2025. 12. 31. + */ +@DisplayName("ProductMetricsEntity 단위 테스트") +class ProductMetricsEntityUnitTest { + + @Nested + @DisplayName("엔티티 생성") + class 엔티티_생성 { + + @Test + @DisplayName("유효한 정보로 메트릭 엔티티를 생성하면 성공한다") + void should_create_metrics_entity_successfully_with_valid_information() { + // given + Long productId = 1L; + LocalDate metricDate = LocalDate.of(2024, 12, 31); + + // when + ProductMetricsEntity entity = ProductMetricsEntity.create(productId, metricDate); + + // then + assertThat(entity).isNotNull(); + assertThat(entity.getProductId()).isEqualTo(productId); + assertThat(entity.getMetricDate()).isEqualTo(metricDate); + assertThat(entity.getViewCount()).isZero(); + assertThat(entity.getLikeCount()).isZero(); + assertThat(entity.getSalesCount()).isZero(); + assertThat(entity.getOrderCount()).isZero(); + } + + @Test + @DisplayName("상품 ID가 null이면 예외가 발생한다") + void should_throw_exception_when_product_id_is_null() { + // given + LocalDate metricDate = LocalDate.of(2024, 12, 31); + + // when & then + assertThatThrownBy(() -> ProductMetricsEntity.create(null, metricDate)) + .isInstanceOf(NullPointerException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("메트릭 날짜가 null이면 예외가 발생한다") + void should_throw_exception_when_metric_date_is_null() { + // given + Long productId = 1L; + + // when & then + assertThatThrownBy(() -> ProductMetricsEntity.create(productId, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("메트릭 날짜는 필수입니다."); + } + } + + @Nested + @DisplayName("조회수 증가") + class 조회수_증가 { + + @Test + @DisplayName("조회수가 1 증가한다") + void should_increment_view_count_by_one() { + // given + ProductMetricsEntity entity = ProductMetricsEntity.create(1L, LocalDate.now()); + ZonedDateTime eventTime = ZonedDateTime.now(); + + // when + entity.incrementView(eventTime); + + // then + assertThat(entity.getViewCount()).isEqualTo(1L); + assertThat(entity.getLastEventAt()).isEqualTo(eventTime); + } + + @Test + @DisplayName("여러 번 호출하면 조회수가 누적된다") + void should_accumulate_view_count_on_multiple_calls() { + // given + ProductMetricsEntity entity = ProductMetricsEntity.create(1L, LocalDate.now()); + ZonedDateTime eventTime = ZonedDateTime.now(); + + // when + entity.incrementView(eventTime); + entity.incrementView(eventTime); + entity.incrementView(eventTime); + + // then + assertThat(entity.getViewCount()).isEqualTo(3L); + } + } + + @Nested + @DisplayName("좋아요 수 변경") + class 좋아요_수_변경 { + + @Test + @DisplayName("좋아요 수가 증가한다") + void should_increase_like_count() { + // given + ProductMetricsEntity entity = ProductMetricsEntity.create(1L, LocalDate.now()); + ZonedDateTime eventTime = ZonedDateTime.now(); + + // when + entity.applyLikeDelta(5, eventTime); + + // then + assertThat(entity.getLikeCount()).isEqualTo(5L); + } + + @Test + @DisplayName("좋아요 수가 감소한다") + void should_decrease_like_count() { + // given + ProductMetricsEntity entity = ProductMetricsEntity.create(1L, LocalDate.now()); + ZonedDateTime eventTime = ZonedDateTime.now(); + entity.applyLikeDelta(10, eventTime); + + // when + entity.applyLikeDelta(-3, eventTime); + + // then + assertThat(entity.getLikeCount()).isEqualTo(7L); + } + + @Test + @DisplayName("좋아요 수는 0 미만으로 내려가지 않는다") + void should_not_go_below_zero() { + // given + ProductMetricsEntity entity = ProductMetricsEntity.create(1L, LocalDate.now()); + ZonedDateTime eventTime = ZonedDateTime.now(); + entity.applyLikeDelta(5, eventTime); + + // when + entity.applyLikeDelta(-10, eventTime); + + // then + assertThat(entity.getLikeCount()).isZero(); + } + } + + @Nested + @DisplayName("판매량 증가") + class 판매량_증가 { + + @Test + @DisplayName("판매량과 주문 건수가 증가한다") + void should_increase_sales_and_order_count() { + // given + ProductMetricsEntity entity = ProductMetricsEntity.create(1L, LocalDate.now()); + ZonedDateTime eventTime = ZonedDateTime.now(); + + // when + entity.addSales(5, eventTime); + + // then + assertThat(entity.getSalesCount()).isEqualTo(5L); + assertThat(entity.getOrderCount()).isEqualTo(1L); + } + + @Test + @DisplayName("0 이하의 수량은 무시된다") + void should_ignore_zero_or_negative_quantity() { + // given + ProductMetricsEntity entity = ProductMetricsEntity.create(1L, LocalDate.now()); + ZonedDateTime eventTime = ZonedDateTime.now(); + + // when + entity.addSales(0, eventTime); + entity.addSales(-5, eventTime); + + // then + assertThat(entity.getSalesCount()).isZero(); + assertThat(entity.getOrderCount()).isZero(); + } + + @Test + @DisplayName("여러 번 호출하면 판매량과 주문 건수가 누적된다") + void should_accumulate_sales_and_order_count() { + // given + ProductMetricsEntity entity = ProductMetricsEntity.create(1L, LocalDate.now()); + ZonedDateTime eventTime = ZonedDateTime.now(); + + // when + entity.addSales(3, eventTime); + entity.addSales(2, eventTime); + + // then + assertThat(entity.getSalesCount()).isEqualTo(5L); + assertThat(entity.getOrderCount()).isEqualTo(2L); + } + } +} diff --git a/modules/jpa/src/test/java/com/loopers/domain/ranking/MonthlyRankEntityUnitTest.java b/modules/jpa/src/test/java/com/loopers/domain/ranking/MonthlyRankEntityUnitTest.java new file mode 100644 index 000000000..a5292c7f5 --- /dev/null +++ b/modules/jpa/src/test/java/com/loopers/domain/ranking/MonthlyRankEntityUnitTest.java @@ -0,0 +1,141 @@ +package com.loopers.domain.ranking; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("MonthlyRankEntity 단위 테스트") +class MonthlyRankEntityUnitTest { + + @Nested + @DisplayName("월간 랭킹 엔티티 생성") + class 월간_랭킹_엔티티_생성 { + + @Test + @DisplayName("유효한 정보로 월간 랭킹 엔티티를 생성하면 성공한다") + void should_create_monthly_rank_entity_successfully_with_valid_information() { + // given + Long productId = 1L; + String yearMonth = "2024-12"; + long viewCount = 1000L; + long likeCount = 500L; + long salesCount = 100L; + long orderCount = 50L; + long totalScore = 3100L; + int rankPosition = 1; + + // when + MonthlyRankEntity entity = MonthlyRankEntity.create( + productId, yearMonth, viewCount, likeCount, salesCount, orderCount, totalScore, rankPosition + ); + + // then + Assertions.assertThat(entity).isNotNull(); + Assertions.assertThat(entity.getProductId()).isEqualTo(productId); + Assertions.assertThat(entity.getYearMonth()).isEqualTo(yearMonth); + Assertions.assertThat(entity.getViewCount()).isEqualTo(viewCount); + Assertions.assertThat(entity.getLikeCount()).isEqualTo(likeCount); + Assertions.assertThat(entity.getSalesCount()).isEqualTo(salesCount); + Assertions.assertThat(entity.getOrderCount()).isEqualTo(orderCount); + Assertions.assertThat(entity.getTotalScore()).isEqualTo(totalScore); + Assertions.assertThat(entity.getRankPosition()).isEqualTo(rankPosition); + Assertions.assertThat(entity.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("상품 ID가 null이면 예외가 발생한다") + void should_throw_exception_when_product_id_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> + MonthlyRankEntity.create(null, "2024-12", 1000L, 500L, 100L, 50L, 3100L, 1) + ) + .isInstanceOf(NullPointerException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("월 정보가 null이면 예외가 발생한다") + void should_throw_exception_when_year_month_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> + MonthlyRankEntity.create(1L, null, 1000L, 500L, 100L, 50L, 3100L, 1) + ) + .isInstanceOf(NullPointerException.class) + .hasMessage("월 정보는 필수입니다."); + } + + @Test + @DisplayName("순위가 0이면 예외가 발생한다") + void should_throw_exception_when_rank_position_is_zero() { + // given & when & then + Assertions.assertThatThrownBy(() -> + MonthlyRankEntity.create(1L, "2024-12", 1000L, 500L, 100L, 50L, 3100L, 0) + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("순위는 1~100 범위여야 합니다"); + } + + @Test + @DisplayName("순위가 100을 초과하면 예외가 발생한다") + void should_throw_exception_when_rank_position_exceeds_100() { + // given & when & then + Assertions.assertThatThrownBy(() -> + MonthlyRankEntity.create(1L, "2024-12", 1000L, 500L, 100L, 50L, 3100L, 101) + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("순위는 1~100 범위여야 합니다"); + } + + @Test + @DisplayName("순위가 100이면 정상적으로 생성된다") + void should_create_entity_when_rank_position_is_100() { + // given & when + MonthlyRankEntity entity = MonthlyRankEntity.create( + 1L, "2024-12", 1000L, 500L, 100L, 50L, 3100L, 100 + ); + + // then + Assertions.assertThat(entity.getRankPosition()).isEqualTo(100); + } + } + + @Nested + @DisplayName("복합 PK 테스트") + class 복합_PK_테스트 { + + @Test + @DisplayName("동일한 productId와 yearMonth로 생성된 ID는 동등하다") + void should_be_equal_when_same_product_id_and_year_month() { + // given + MonthlyRankId id1 = MonthlyRankId.of(1L, "2024-12"); + MonthlyRankId id2 = MonthlyRankId.of(1L, "2024-12"); + + // when & then + Assertions.assertThat(id1).isEqualTo(id2); + Assertions.assertThat(id1.hashCode()).isEqualTo(id2.hashCode()); + } + + @Test + @DisplayName("다른 productId로 생성된 ID는 동등하지 않다") + void should_not_be_equal_when_different_product_id() { + // given + MonthlyRankId id1 = MonthlyRankId.of(1L, "2024-12"); + MonthlyRankId id2 = MonthlyRankId.of(2L, "2024-12"); + + // when & then + Assertions.assertThat(id1).isNotEqualTo(id2); + } + + @Test + @DisplayName("다른 yearMonth로 생성된 ID는 동등하지 않다") + void should_not_be_equal_when_different_year_month() { + // given + MonthlyRankId id1 = MonthlyRankId.of(1L, "2024-12"); + MonthlyRankId id2 = MonthlyRankId.of(1L, "2024-11"); + + // when & then + Assertions.assertThat(id1).isNotEqualTo(id2); + } + } +} diff --git a/modules/jpa/src/test/java/com/loopers/domain/ranking/WeeklyRankEntityUnitTest.java b/modules/jpa/src/test/java/com/loopers/domain/ranking/WeeklyRankEntityUnitTest.java new file mode 100644 index 000000000..7bdbfd7a5 --- /dev/null +++ b/modules/jpa/src/test/java/com/loopers/domain/ranking/WeeklyRankEntityUnitTest.java @@ -0,0 +1,141 @@ +package com.loopers.domain.ranking; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("WeeklyRankEntity 단위 테스트") +class WeeklyRankEntityUnitTest { + + @Nested + @DisplayName("주간 랭킹 엔티티 생성") + class 주간_랭킹_엔티티_생성 { + + @Test + @DisplayName("유효한 정보로 주간 랭킹 엔티티를 생성하면 성공한다") + void should_create_weekly_rank_entity_successfully_with_valid_information() { + // given + Long productId = 1L; + String yearWeek = "2024-W52"; + long viewCount = 100L; + long likeCount = 50L; + long salesCount = 10L; + long orderCount = 5L; + long totalScore = 310L; + int rankPosition = 1; + + // when + WeeklyRankEntity entity = WeeklyRankEntity.create( + productId, yearWeek, viewCount, likeCount, salesCount, orderCount, totalScore, rankPosition + ); + + // then + Assertions.assertThat(entity).isNotNull(); + Assertions.assertThat(entity.getProductId()).isEqualTo(productId); + Assertions.assertThat(entity.getYearWeek()).isEqualTo(yearWeek); + Assertions.assertThat(entity.getViewCount()).isEqualTo(viewCount); + Assertions.assertThat(entity.getLikeCount()).isEqualTo(likeCount); + Assertions.assertThat(entity.getSalesCount()).isEqualTo(salesCount); + Assertions.assertThat(entity.getOrderCount()).isEqualTo(orderCount); + Assertions.assertThat(entity.getTotalScore()).isEqualTo(totalScore); + Assertions.assertThat(entity.getRankPosition()).isEqualTo(rankPosition); + Assertions.assertThat(entity.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("상품 ID가 null이면 예외가 발생한다") + void should_throw_exception_when_product_id_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> + WeeklyRankEntity.create(null, "2024-W52", 100L, 50L, 10L, 5L, 310L, 1) + ) + .isInstanceOf(NullPointerException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("주차 정보가 null이면 예외가 발생한다") + void should_throw_exception_when_year_week_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> + WeeklyRankEntity.create(1L, null, 100L, 50L, 10L, 5L, 310L, 1) + ) + .isInstanceOf(NullPointerException.class) + .hasMessage("주차 정보는 필수입니다."); + } + + @Test + @DisplayName("순위가 0이면 예외가 발생한다") + void should_throw_exception_when_rank_position_is_zero() { + // given & when & then + Assertions.assertThatThrownBy(() -> + WeeklyRankEntity.create(1L, "2024-W52", 100L, 50L, 10L, 5L, 310L, 0) + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("순위는 1~100 범위여야 합니다"); + } + + @Test + @DisplayName("순위가 100을 초과하면 예외가 발생한다") + void should_throw_exception_when_rank_position_exceeds_100() { + // given & when & then + Assertions.assertThatThrownBy(() -> + WeeklyRankEntity.create(1L, "2024-W52", 100L, 50L, 10L, 5L, 310L, 101) + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("순위는 1~100 범위여야 합니다"); + } + + @Test + @DisplayName("순위가 100이면 정상적으로 생성된다") + void should_create_entity_when_rank_position_is_100() { + // given & when + WeeklyRankEntity entity = WeeklyRankEntity.create( + 1L, "2024-W52", 100L, 50L, 10L, 5L, 310L, 100 + ); + + // then + Assertions.assertThat(entity.getRankPosition()).isEqualTo(100); + } + } + + @Nested + @DisplayName("복합 PK 테스트") + class 복합_PK_테스트 { + + @Test + @DisplayName("동일한 productId와 yearWeek로 생성된 ID는 동등하다") + void should_be_equal_when_same_product_id_and_year_week() { + // given + WeeklyRankId id1 = WeeklyRankId.of(1L, "2024-W52"); + WeeklyRankId id2 = WeeklyRankId.of(1L, "2024-W52"); + + // when & then + Assertions.assertThat(id1).isEqualTo(id2); + Assertions.assertThat(id1.hashCode()).isEqualTo(id2.hashCode()); + } + + @Test + @DisplayName("다른 productId로 생성된 ID는 동등하지 않다") + void should_not_be_equal_when_different_product_id() { + // given + WeeklyRankId id1 = WeeklyRankId.of(1L, "2024-W52"); + WeeklyRankId id2 = WeeklyRankId.of(2L, "2024-W52"); + + // when & then + Assertions.assertThat(id1).isNotEqualTo(id2); + } + + @Test + @DisplayName("다른 yearWeek로 생성된 ID는 동등하지 않다") + void should_not_be_equal_when_different_year_week() { + // given + WeeklyRankId id1 = WeeklyRankId.of(1L, "2024-W52"); + WeeklyRankId id2 = WeeklyRankId.of(1L, "2024-W51"); + + // when & then + Assertions.assertThat(id1).isNotEqualTo(id2); + } + } +} 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",