diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java index 345cf2318..f8342e2dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java @@ -1,49 +1,40 @@ package com.loopers.application.ranking; +import com.loopers.domain.ranking.RankingPeriod; + import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; public record RankingCommand( LocalDate date, + RankingPeriod period, int page, int size ) { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - private static final int MAX_PAGE_SIZE = 100; - private static final int DEFAULT_PAGE_SIZE = 20; - public RankingCommand { - // 유효성 검증 - if (page < 0) { - page = 0; - } - if (size <= 0 || size > MAX_PAGE_SIZE) { - size = DEFAULT_PAGE_SIZE; - } - if (date == null) { - date = LocalDate.now(); - } - } + public static RankingCommand of(String date, String period, int page, int size) { + LocalDate parsedDate = (date == null || date.isBlank()) + ? LocalDate.now() + : LocalDate.parse(date, DATE_FORMATTER); + + RankingPeriod rankingPeriod = parsePeriod(period); - public static RankingCommand of(String dateString, int page, int size) { - LocalDate date = parseDate(dateString); - return new RankingCommand(date, page, size); + return new RankingCommand(parsedDate, rankingPeriod, page, size); } - public static RankingCommand today(int page, int size) { - return new RankingCommand(LocalDate.now(), page, size); + public static RankingCommand of(String date, int page, int size) { + return of(date, "daily", page, size); } - private static LocalDate parseDate(String dateString) { - if (dateString == null || dateString.isBlank()) { - return LocalDate.now(); + private static RankingPeriod parsePeriod(String period) { + if (period == null || period.isBlank()) { + return RankingPeriod.DAILY; } try { - return LocalDate.parse(dateString, DATE_FORMATTER); - } catch (DateTimeParseException e) { - return LocalDate.now(); + return RankingPeriod.valueOf(period.toUpperCase()); + } catch (IllegalArgumentException e) { + return RankingPeriod.DAILY; } } } - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index c780912f0..355959ac1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -4,6 +4,7 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingInfo; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.domain.ranking.RankingService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,22 +27,30 @@ public class RankingFacade { private final ProductRepository productRepository; private final Clock clock; - /** - * 랭킹 페이지 조회 - */ @Transactional(readOnly = true) public RankingPageInfo getRankingPage(RankingCommand command) { - List entries = rankingService.getRankingPage( - command.date(), - command.page(), - command.size() - ); + List entries; + Long totalCount; + + switch (command.period()) { + case WEEKLY -> { + entries = rankingService.getWeeklyRankingPage(command.date(), command.page(), command.size()); + totalCount = rankingService.getWeeklyRankingSize(command.date()); + } + case MONTHLY -> { + entries = rankingService.getMonthlyRankingPage(command.date(), command.page(), command.size()); + totalCount = rankingService.getMonthlyRankingSize(command.date()); + } + default -> { + entries = rankingService.getRankingPage(command.date(), command.page(), command.size()); + totalCount = rankingService.getRankingSize(command.date()); + } + } if (entries.isEmpty()) { - return RankingPageInfo.empty(command.date(), command.page(), command.size()); + return RankingPageInfo.empty(command.date(), command.period(), command.page(), command.size()); } - // 상품 정보 조회 List productIds = entries.stream() .map(RankingEntry::productId) .collect(Collectors.toList()); @@ -49,7 +58,6 @@ public RankingPageInfo getRankingPage(RankingCommand command) { Map productMap = productRepository.findAllByIds(productIds).stream() .collect(Collectors.toMap(Product::getId, p -> p)); - // 랭킹 정보 조합 List rankings = new ArrayList<>(); long startRank = (long) command.page() * command.size() + 1; @@ -69,23 +77,23 @@ public RankingPageInfo getRankingPage(RankingCommand command) { } } - Long totalCount = rankingService.getRankingSize(command.date()); - return RankingPageInfo.of( rankings, command.date(), + command.period(), command.page(), command.size(), totalCount ); } - /** - * Top-N 랭킹 조회 - */ @Transactional(readOnly = true) - public List getTopN(LocalDate date, int n) { - List entries = rankingService.getTopNWithScores(date, n); + public List getTopN(LocalDate date, RankingPeriod period, int n) { + List entries = switch (period) { + case WEEKLY -> rankingService.getWeeklyTopN(date, n); + case MONTHLY -> rankingService.getMonthlyTopN(date, n); + default -> rankingService.getTopNWithScores(date, n); + }; if (entries.isEmpty()) { return List.of(); @@ -118,16 +126,23 @@ public List getTopN(LocalDate date, int n) { return rankings; } - /** - * 특정 상품의 순위 조회 - */ + @Transactional(readOnly = true) + public List getTopN(LocalDate date, int n) { + return getTopN(date, RankingPeriod.DAILY, n); + } + + public Long getProductRank(Long productId, LocalDate date, RankingPeriod period) { + return switch (period) { + case WEEKLY -> rankingService.getWeeklyRank(productId, date); + case MONTHLY -> rankingService.getMonthlyRank(productId, date); + default -> rankingService.getRank(productId, date); + }; + } + public Long getProductRank(Long productId, LocalDate date) { return rankingService.getRank(productId, date); } - /** - * 특정 상품의 순위 조회 (오늘 기준) - */ public Long getProductRankToday(Long productId) { return rankingService.getRank(productId, LocalDate.now(clock)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java index fb1da6702..65c5c7f4e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java @@ -1,6 +1,7 @@ package com.loopers.application.ranking; import com.loopers.domain.ranking.RankingInfo; +import com.loopers.domain.ranking.RankingPeriod; import java.time.LocalDate; import java.util.List; @@ -8,23 +9,28 @@ public record RankingPageInfo( List rankings, LocalDate date, + RankingPeriod period, int page, int size, Long totalCount, int totalPages ) { - public static RankingPageInfo of( - List rankings, - LocalDate date, - int page, - int size, - Long totalCount - ) { + public static RankingPageInfo of(List rankings, LocalDate date, RankingPeriod period, + int page, int size, Long totalCount) { int totalPages = (int) Math.ceil((double) totalCount / size); - return new RankingPageInfo(rankings, date, page, size, totalCount, totalPages); + return new RankingPageInfo(rankings, date, period, page, size, totalCount, totalPages); + } + + public static RankingPageInfo empty(LocalDate date, RankingPeriod period, int page, int size) { + return new RankingPageInfo(List.of(), date, period, page, size, 0L, 0); + } + + public static RankingPageInfo of(List rankings, LocalDate date, + int page, int size, Long totalCount) { + return of(rankings, date, RankingPeriod.DAILY, page, size, totalCount); } public static RankingPageInfo empty(LocalDate date, int page, int size) { - return new RankingPageInfo(List.of(), date, page, size, 0L, 0); + return empty(date, RankingPeriod.DAILY, page, size); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRanking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRanking.java new file mode 100644 index 000000000..e0c9c843d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRanking.java @@ -0,0 +1,30 @@ +package com.loopers.domain.ranking; + +import com.loopers.infrastructure.converter.YearMonthAttributeConverter; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.YearMonth; + +@Entity +@Getter +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class MonthlyRanking { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "monthly_rank") + private int rank; + + private Long productId; + + private double score; + + @Convert(converter = YearMonthAttributeConverter.class) + @Column(name = "month_period") + private YearMonth monthPeriod; +} 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..f23babc17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum RankingPeriod { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java new file mode 100644 index 000000000..87b29bc54 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -0,0 +1,23 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; + +public interface RankingRepository { + + // Weekly + List findWeeklyByDateOrderByRank(LocalDate weekStart, LocalDate weekEnd, int limit, int offset); + + Optional findWeeklyByProductIdAndDate(Long productId, LocalDate weekStart, LocalDate weekEnd); + + long countWeeklyByDate(LocalDate weekStart, LocalDate weekEnd); + + // Monthly + List findMonthlyByPeriodOrderByRank(YearMonth period, int limit, int offset); + + Optional findMonthlyByProductIdAndPeriod(Long productId, YearMonth period); + + long countMonthlyByPeriod(YearMonth period); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java index 809097287..0da8fd972 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -6,24 +6,25 @@ import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Service; +import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; import java.util.*; -/** - * 랭킹 조회 서비스 (commerce-api용) - */ @Slf4j @Service @RequiredArgsConstructor public class RankingService { private final RedisTemplate redisTemplate; + private final RankingRepository rankingRepository; + + private static final String KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - /** - * Top-N 랭킹 조회 (점수 포함) - */ public List getTopNWithScores(LocalDate date, int n) { - String key = RankingKey.daily(date); + String key = KEY_PREFIX + date.format(DATE_FORMATTER); try { Set> tuples = redisTemplate.opsForZSet() @@ -40,11 +41,8 @@ public List getTopNWithScores(LocalDate date, int n) { } } - /** - * 페이지네이션 랭킹 조회 - */ public List getRankingPage(LocalDate date, int page, int size) { - String key = RankingKey.daily(date); + String key = KEY_PREFIX + date.format(DATE_FORMATTER); long start = (long) page * size; long end = start + size - 1; @@ -63,13 +61,8 @@ public List getRankingPage(LocalDate date, int page, int size) { } } - /** - * 특정 상품의 순위 조회 - * - * @return 순위 (1부터 시작), 랭킹에 없으면 null - */ public Long getRank(Long productId, LocalDate date) { - String key = RankingKey.daily(date); + String key = KEY_PREFIX + date.format(DATE_FORMATTER); String member = productId.toString(); try { @@ -81,36 +74,96 @@ public Long getRank(Long productId, LocalDate date) { } } - /** - * 특정 상품의 점수 조회 - */ - public Double getScore(Long productId, LocalDate date) { - String key = RankingKey.daily(date); - String member = productId.toString(); + public Long getRankingSize(LocalDate date) { + String key = KEY_PREFIX + date.format(DATE_FORMATTER); try { - return redisTemplate.opsForZSet().score(key, member); + Long size = redisTemplate.opsForZSet().zCard(key); + return size != null ? size : 0L; } catch (Exception e) { - log.error("점수 조회 실패: key={}, productId={}", key, productId, e); + log.error("랭킹 사이즈 조회 실패: key={}", key, e); + return 0L; + } + } + + public List getWeeklyRankingPage(LocalDate date, int page, int size) { + LocalDate weekStart = date.with(DayOfWeek.MONDAY); + LocalDate weekEnd = date.with(DayOfWeek.SUNDAY); + int offset = page * size; + + try { + List ranks = rankingRepository.findWeeklyByDateOrderByRank(weekStart, weekEnd, size, offset); + return ranks.stream() + .map(r -> new RankingEntry(r.getProductId(), r.getScore())) + .toList(); + } catch (Exception e) { + log.error("주간 랭킹 페이지 조회 실패: weekStart={}, weekEnd={}, page={}, size={}", + weekStart, weekEnd, page, size, e); + return Collections.emptyList(); + } + } + + public List getWeeklyTopN(LocalDate date, int n) { + return getWeeklyRankingPage(date, 0, n); + } + + public Long getWeeklyRank(Long productId, LocalDate date) { + LocalDate weekStart = date.with(DayOfWeek.MONDAY); + LocalDate weekEnd = date.with(DayOfWeek.SUNDAY); + + try { + return rankingRepository.findWeeklyByProductIdAndDate(productId, weekStart, weekEnd) + .map(r -> (long) r.getRank()) + .orElse(null); + } catch (Exception e) { + log.error("주간 순위 조회 실패: productId={}, weekStart={}, weekEnd={}", productId, weekStart, weekEnd, e); return null; } } - /** - * 랭킹에 진입한 상품 수 조회 - */ - public Long getRankingSize(LocalDate date) { - String key = RankingKey.daily(date); + public Long getWeeklyRankingSize(LocalDate date) { + LocalDate weekStart = date.with(DayOfWeek.MONDAY); + LocalDate weekEnd = date.with(DayOfWeek.SUNDAY); + return rankingRepository.countWeeklyByDate(weekStart, weekEnd); + } + + public List getMonthlyRankingPage(LocalDate date, int page, int size) { + YearMonth yearMonth = YearMonth.from(date); + int offset = page * size; try { - Long size = redisTemplate.opsForZSet().zCard(key); - return size != null ? size : 0L; + List ranks = rankingRepository.findMonthlyByPeriodOrderByRank(yearMonth, size, offset); + return ranks.stream() + .map(r -> new RankingEntry(r.getProductId(), r.getScore())) + .toList(); } catch (Exception e) { - log.error("랭킹 사이즈 조회 실패: key={}", key, e); - return 0L; + log.error("월간 랭킹 페이지 조회 실패: yearMonth={}, page={}, size={}", yearMonth, page, size, e); + return Collections.emptyList(); + } + } + + public List getMonthlyTopN(LocalDate date, int n) { + return getMonthlyRankingPage(date, 0, n); + } + + public Long getMonthlyRank(Long productId, LocalDate date) { + YearMonth yearMonth = YearMonth.from(date); + + try { + return rankingRepository.findMonthlyByProductIdAndPeriod(productId, yearMonth) + .map(r -> (long) r.getRank()) + .orElse(null); + } catch (Exception e) { + log.error("월간 순위 조회 실패: productId={}, yearMonth={}", productId, yearMonth, e); + return null; } } + public Long getMonthlyRankingSize(LocalDate date) { + YearMonth yearMonth = YearMonth.from(date); + return rankingRepository.countMonthlyByPeriod(yearMonth); + } + private List convertToRankingEntries(Set> tuples) { List entries = new ArrayList<>(); for (ZSetOperations.TypedTuple tuple : tuples) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRanking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRanking.java new file mode 100644 index 000000000..45bbb4c4e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRanking.java @@ -0,0 +1,29 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class WeeklyRanking { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "weekly_rank") + private int rank; + + private Long productId; + + private double score; + + private LocalDate weekStart; + + private LocalDate weekEnd; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java new file mode 100644 index 000000000..139cfd293 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.time.YearMonth; + +@Converter(autoApply = true) +public class YearMonthAttributeConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(YearMonth attribute) { + return attribute != null ? attribute.toString() : null; + } + + @Override + public YearMonth convertToEntityAttribute(String dbData) { + return dbData != null ? YearMonth.parse(dbData) : null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java new file mode 100644 index 000000000..d62c57370 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MonthlyRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + @Query(value = """ + SELECT * FROM mv_product_rank_monthly + WHERE month_period = :monthPeriod + ORDER BY monthly_rank ASC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findByPeriodWithPagination( + @Param("monthPeriod") String monthPeriod, + @Param("limit") int limit, + @Param("offset") int offset); + + Optional findByProductIdAndMonthPeriod(Long productId, YearMonth monthPeriod); + + long countByMonthPeriod(YearMonth monthPeriod); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java new file mode 100644 index 000000000..3f94ac6e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java @@ -0,0 +1,48 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class RankingRepositoryImpl implements RankingRepository { + + private final WeeklyRankingJpaRepository weeklyJpaRepository; + private final MonthlyRankingJpaRepository monthlyJpaRepository; + + @Override + public List findWeeklyByDateOrderByRank(LocalDate weekStart, LocalDate weekEnd, int limit, int offset) { + return weeklyJpaRepository.findByDateWithPagination(weekStart, weekEnd, limit, offset); + } + + @Override + public Optional findWeeklyByProductIdAndDate(Long productId, LocalDate weekStart, LocalDate weekEnd) { + return weeklyJpaRepository.findByProductIdAndWeekStartAndWeekEnd(productId, weekStart, weekEnd); + } + + @Override + public long countWeeklyByDate(LocalDate weekStart, LocalDate weekEnd) { + return weeklyJpaRepository.countByWeekStartAndWeekEnd(weekStart, weekEnd); + } + + @Override + public List findMonthlyByPeriodOrderByRank(YearMonth period, int limit, int offset) { + return monthlyJpaRepository.findByPeriodWithPagination(period.toString(), limit, offset); + } + + @Override + public Optional findMonthlyByProductIdAndPeriod(Long productId, YearMonth period) { + return monthlyJpaRepository.findByProductIdAndMonthPeriod(productId, period); + } + + @Override + public long countMonthlyByPeriod(YearMonth period) { + return monthlyJpaRepository.countByMonthPeriod(period); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java new file mode 100644 index 000000000..e3f2a39ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.WeeklyRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + @Query(value = """ + SELECT * FROM mv_product_rank_weekly + WHERE week_start = :weekStart AND week_end = :weekEnd + ORDER BY weekly_rank ASC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findByDateWithPagination( + @Param("weekStart") LocalDate weekStart, + @Param("weekEnd") LocalDate weekEnd, + @Param("limit") int limit, + @Param("offset") int offset); + + Optional findByProductIdAndWeekStartAndWeekEnd(Long productId, LocalDate weekStart, LocalDate weekEnd); + + long countByWeekStartAndWeekEnd(LocalDate weekStart, LocalDate weekEnd); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index 7af32d934..925313b6e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -13,7 +13,7 @@ @Tag(name = "Ranking API", description = "상품 랭킹 API") public interface RankingV1ApiSpec { - @Operation(summary = "랭킹 페이지 조회", description = "일간 상품 랭킹을 페이지로 조회합니다.") + @Operation(summary = "랭킹 페이지 조회", description = "일간/주간/월간 상품 랭킹을 페이지로 조회합니다.") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", @@ -26,6 +26,9 @@ ApiResponse getRankings( @Parameter(description = "날짜 (yyyyMMdd 형식, 기본값: 오늘)") @RequestParam(required = false) String date, + @Parameter(description = "기간 (daily, weekly, monthly)") + @RequestParam(defaultValue = "daily") String period, + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, @@ -33,7 +36,7 @@ ApiResponse getRankings( @RequestParam(defaultValue = "20") int size ); - @Operation(summary = "Top-N 랭킹 조회", description = "오늘의 Top-N 상품을 조회합니다.") + @Operation(summary = "Top-N 랭킹 조회", description = "일간/주간/월간 Top-N 상품을 조회합니다.") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", @@ -46,6 +49,9 @@ ApiResponse getTopN( @Parameter(description = "날짜 (yyyyMMdd 형식, 기본값: 오늘)") @RequestParam(required = false) String date, + @Parameter(description = "기간 (daily, weekly, monthly)") + @RequestParam(defaultValue = "daily") String period, + @Parameter(description = "조회할 상위 N개") @RequestParam(defaultValue = "10") int n ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index 14a748009..342052031 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -4,6 +4,7 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingPageInfo; import com.loopers.domain.ranking.RankingInfo; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.RequestMapping; @@ -23,17 +24,18 @@ public class RankingV1Controller implements RankingV1ApiSpec { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @Override - public ApiResponse getRankings(String date, int page, int size) { - RankingCommand command = RankingCommand.of(date, page, size); + public ApiResponse getRankings(String date, String period, int page, int size) { + RankingCommand command = RankingCommand.of(date, period, page, size); RankingPageInfo pageInfo = rankingFacade.getRankingPage(command); return ApiResponse.success(RankingV1Dto.RankingPageResponse.from(pageInfo)); } @Override - public ApiResponse getTopN(String date, int n) { + public ApiResponse getTopN(String date, String period, int n) { LocalDate targetDate = parseDate(date); - List rankings = rankingFacade.getTopN(targetDate, n); - return ApiResponse.success(RankingV1Dto.TopNResponse.of(rankings, targetDate)); + RankingPeriod rankingPeriod = parsePeriod(period); + List rankings = rankingFacade.getTopN(targetDate, rankingPeriod, n); + return ApiResponse.success(RankingV1Dto.TopNResponse.of(rankings, targetDate, rankingPeriod)); } private LocalDate parseDate(String date) { @@ -42,4 +44,15 @@ private LocalDate parseDate(String date) { } return LocalDate.parse(date, DATE_FORMATTER); } + + private RankingPeriod parsePeriod(String period) { + if (period == null || period.isBlank()) { + return RankingPeriod.DAILY; + } + try { + return RankingPeriod.valueOf(period.toUpperCase()); + } catch (IllegalArgumentException e) { + return RankingPeriod.DAILY; + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java index 5d3ab0c58..714613817 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -2,6 +2,7 @@ import com.loopers.application.ranking.RankingPageInfo; import com.loopers.domain.ranking.RankingInfo; +import com.loopers.domain.ranking.RankingPeriod; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -14,6 +15,7 @@ public class RankingV1Dto { public record RankingPageResponse( List rankings, String date, + String period, int page, int size, Long totalCount, @@ -27,6 +29,7 @@ public static RankingPageResponse from(RankingPageInfo info) { return new RankingPageResponse( items, info.date().format(DATE_FORMATTER), + info.period().name().toLowerCase(), info.page(), info.size(), info.totalCount(), @@ -58,14 +61,24 @@ public static RankingItemResponse from(RankingInfo info) { public record TopNResponse( List rankings, String date, + String period, int size ) { - public static TopNResponse of(List rankings, LocalDate date) { + public static TopNResponse of(List rankings, LocalDate date, RankingPeriod period) { List items = rankings.stream() .map(RankingItemResponse::from) .toList(); - return new TopNResponse(items, date.format(DATE_FORMATTER), items.size()); + return new TopNResponse( + items, + date.format(DATE_FORMATTER), + period.name().toLowerCase(), + items.size() + ); + } + + public static TopNResponse of(List rankings, LocalDate date) { + return of(rankings, date, RankingPeriod.DAILY); } } } diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..28425153c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,24 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + + // 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..52de54bac --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,25 @@ +package com.loopers; + + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@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/domain/RankingWeightReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/RankingWeightReader.java new file mode 100644 index 000000000..9c702d413 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/RankingWeightReader.java @@ -0,0 +1,57 @@ +package com.loopers.batch.domain; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Redis에서 랭킹 가중치를 읽어오는 컴포넌트 + * commerce-streamer의 RankingWeight와 동일한 Redis Key를 사용 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingWeightReader { + + private final RedisTemplate redisTemplate; + + // commerce-streamer의 RankingWeight와 동일한 Key + private static final String WEIGHT_KEY = "ranking:config:weights"; + + // 기본 가중치 (commerce-streamer와 동일) + private static final double DEFAULT_VIEW_WEIGHT = 0.1; + private static final double DEFAULT_LIKE_WEIGHT = 0.2; + private static final double DEFAULT_ORDER_WEIGHT = 0.7; + + public double getViewWeight() { + return getWeight("view", DEFAULT_VIEW_WEIGHT); + } + + public double getLikeWeight() { + return getWeight("like", DEFAULT_LIKE_WEIGHT); + } + + public double getOrderWeight() { + return getWeight("order", DEFAULT_ORDER_WEIGHT); + } + + /** + * 총점 계산 + */ + public double calculateTotalScore(int totalLikes, int totalSales, int totalViews) { + return (totalViews * getViewWeight()) + (totalLikes * getLikeWeight()) + (totalSales * getOrderWeight()); + } + + private double getWeight(String field, double defaultValue) { + try { + Object value = redisTemplate.opsForHash().get(WEIGHT_KEY, field); + if (value != null) { + return Double.parseDouble(value.toString()); + } + } catch (Exception e) { + log.warn("가중치 조회 실패, 기본값 사용: field={}, default={}", field, defaultValue, e); + } + return defaultValue; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..b11c86fb3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java @@ -0,0 +1,35 @@ +package com.loopers.batch.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetrics { + + @EmbeddedId + private ProductMetricsId id; + + @Column(name = "likes_delta") + private int likesDelta; + + @Column(name = "sales_delta") + private int salesDelta; + + @Column(name = "views_delta") + private int viewsDelta; + + public Long getProductId() { + return id.getProductId(); + } + + public LocalDate getMetricsDate() { + return id.getMetricsDate(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java new file mode 100644 index 000000000..294e6807f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java @@ -0,0 +1,24 @@ +package com.loopers.batch.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; + +@Embeddable +@Getter +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsId implements Serializable { + + @Column(name = "product_id") + private Long productId; + + @Column(name = "metrics_date") + private LocalDate metricsDate; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..77a64df1c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,10 @@ +package com.loopers.batch.domain.metrics; + +import com.loopers.dto.ProductMetricsSummary; + +import java.time.LocalDate; +import java.util.List; + +public interface ProductMetricsRepository { + List findAllByDateRange(LocalDate startDate, LocalDate endDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRanking.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRanking.java new file mode 100644 index 000000000..c1eb6cbbd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRanking.java @@ -0,0 +1,50 @@ +package com.loopers.batch.domain.ranking; + +import com.loopers.infrastructure.converter.YearMonthAttributeConverter; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.YearMonth; + +@Entity +@Getter +@Table( + name = "mv_product_rank_monthly", + indexes = { + @Index(name = "idx_monthPeriod", columnList = "month_period"), + @Index(name = "idx_score", columnList = "score") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uq_monthly_rank", columnNames = {"product_id", "month_period"}) + } +) +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class MonthlyRanking { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "monthly_rank") + private int rank; + + private Long productId; + + private double score; + + @Convert(converter = YearMonthAttributeConverter.class) + @Column(name = "month_period") + private YearMonth monthPeriod; + + public static MonthlyRanking create(int rank, Long productId, double score, YearMonth yearMonth) { + MonthlyRanking monthlyRanking = new MonthlyRanking(); + + monthlyRanking.rank = rank; + monthlyRanking.productId = productId; + monthlyRanking.score = score; + monthlyRanking.monthPeriod = yearMonth; + + return monthlyRanking; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRankingRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRankingRepository.java new file mode 100644 index 000000000..8d2832dbe --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRankingRepository.java @@ -0,0 +1,11 @@ +package com.loopers.batch.domain.ranking; + +import java.time.YearMonth; +import java.util.List; + +public interface MonthlyRankingRepository { + + void deleteByMonthPeriod(YearMonth monthPeriod); + + void saveAll(List rankings); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRanking.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRanking.java new file mode 100644 index 000000000..a68a66211 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRanking.java @@ -0,0 +1,50 @@ +package com.loopers.batch.domain.ranking; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@Table( + name = "mv_product_rank_weekly", + indexes = { + @Index(name = "idx_week", columnList = "week_start, week_end"), + @Index(name = "idx_score", columnList = "score") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uq_weekly_rank", columnNames = {"product_id", "week_start", "week_end"}) + } +) +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class WeeklyRanking { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "weekly_rank") + private int rank; + + private Long productId; + + private double score; + + private LocalDate weekStart; + + private LocalDate weekEnd; + + public static WeeklyRanking create(int rank, Long productId, double score, LocalDate weekStart, LocalDate weekEnd) { + WeeklyRanking weeklyRanking = new WeeklyRanking(); + + weeklyRanking.rank = rank; + weeklyRanking.productId = productId; + weeklyRanking.score = score; + weeklyRanking.weekStart = weekStart; + weeklyRanking.weekEnd = weekEnd; + + return weeklyRanking; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRankingRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRankingRepository.java new file mode 100644 index 000000000..f4578ac22 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRankingRepository.java @@ -0,0 +1,11 @@ +package com.loopers.batch.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyRankingRepository { + + void deleteByWeekStartAndWeekEnd(LocalDate weekStart, LocalDate weekEnd); + + void saveAll(List rankings); +} 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..7c486483f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@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..800fe5a03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@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..96f30e042 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.domain.ranking.MonthlyRanking; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.dto.RankedProduct; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_NAME = "monthlyRankingStep"; + private static final int CHUNK_SIZE = 100; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + + private final ItemReader monthlyRankingReader; + private final ItemProcessor monthlyRankingProcessor; + private final ItemWriter monthlyRankingWriter; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyRankingStep()) + .listener(jobListener) + .build(); + } + + @Bean(STEP_NAME) + public Step monthlyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyRankingReader) + .processor(monthlyRankingProcessor) + .writer(monthlyRankingWriter) + .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..817f29cb0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.domain.ranking.WeeklyRanking; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.dto.RankedProduct; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_NAME = "weeklyRankingStep"; + private static final int CHUNK_SIZE = 100; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + + private final ItemReader weeklyRankingReader; + private final ItemProcessor weeklyRankingProcessor; + private final ItemWriter weeklyRankingWriter; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankingStep()) + .listener(jobListener) + .build(); + } + + @Bean(STEP_NAME) + public Step weeklyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyRankingReader) + .processor(weeklyRankingProcessor) + .writer(weeklyRankingWriter) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/MonthlyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/MonthlyRankingProcessor.java new file mode 100644 index 000000000..d6fb768c0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/MonthlyRankingProcessor.java @@ -0,0 +1,47 @@ +package com.loopers.batch.job.ranking.processor; + +import com.loopers.batch.domain.ranking.MonthlyRanking; +import com.loopers.dto.RankedProduct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@StepScope +@Component +public class MonthlyRankingProcessor implements ItemProcessor { + + @Value("#{jobParameters['yearMonth']}") + private String yearMonthStr; + + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + private final AtomicInteger rankCounter = new AtomicInteger(0); + + @Override + public MonthlyRanking process(RankedProduct item) { + YearMonth yearMonth = parseYearMonth(); + + int rank = rankCounter.incrementAndGet(); + + return MonthlyRanking.create( + rank, + item.productId(), + item.score(), + yearMonth + ); + } + + private YearMonth parseYearMonth() { + if (yearMonthStr != null && !yearMonthStr.isBlank()) { + return YearMonth.parse(yearMonthStr, YEAR_MONTH_FORMATTER); + } + return YearMonth.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/WeeklyRankingProcessor.java new file mode 100644 index 000000000..144d48154 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/WeeklyRankingProcessor.java @@ -0,0 +1,63 @@ +package com.loopers.batch.job.ranking.processor; + +import com.loopers.batch.domain.ranking.WeeklyRanking; +import com.loopers.dto.RankedProduct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@StepScope +@Component +public class WeeklyRankingProcessor implements ItemProcessor { + + @Value("#{jobParameters['startDate']}") + private String startDateStr; + + @Value("#{jobParameters['endDate']}") + private String endDateStr; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final AtomicInteger rankCounter = new AtomicInteger(0); + + @Override + public WeeklyRanking process(RankedProduct item) { + LocalDate weekStart = parseStartDate(); + LocalDate weekEnd = parseEndDate(); + + int rank = rankCounter.incrementAndGet(); + + return WeeklyRanking.create( + rank, + item.productId(), + item.score(), + weekStart, + weekEnd + ); + } + + private LocalDate parseStartDate() { + if (startDateStr != null && !startDateStr.isBlank()) { + return LocalDate.parse(startDateStr, DATE_FORMATTER); + } + // 이번 주의 월요일을 반환 (오늘이 월요일이어도 오늘 반환) + return LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + } + + private LocalDate parseEndDate() { + if (endDateStr != null && !endDateStr.isBlank()) { + return LocalDate.parse(endDateStr, DATE_FORMATTER); + } + // 이번 주의 일요일을 반환 (오늘이 일요일이어도 오늘 반환) + return LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyRankingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyRankingReader.java new file mode 100644 index 000000000..ab9475f15 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyRankingReader.java @@ -0,0 +1,114 @@ +package com.loopers.batch.job.ranking.reader; + +import com.loopers.batch.domain.RankingWeightReader; +import com.loopers.dto.ProductMetricsSummary; +import com.loopers.dto.RankedProduct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemReader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@StepScope +@Component +public class MonthlyRankingReader implements ItemReader { + + private final ProductMetricsJpaRepository metricsRepository; + private final RankingWeightReader rankingWeightReader; + private Iterator iterator; + private boolean initialized = false; + + @Value("#{jobParameters['yearMonth']}") + private String yearMonthStr; + + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final int TOP_N = 100; + + public MonthlyRankingReader(ProductMetricsJpaRepository metricsRepository, + RankingWeightReader rankingWeightReader) { + this.metricsRepository = metricsRepository; + this.rankingWeightReader = rankingWeightReader; + } + + @Override + public RankedProduct read() { + if (!initialized) { + initialize(); + initialized = true; + } + + if (iterator != null && iterator.hasNext()) { + return iterator.next(); + } + return null; + } + + private void initialize() { + YearMonth yearMonth = parseYearMonth(); + LocalDate startDate = yearMonth.atDay(1); + LocalDate endDate = yearMonth.atEndOfMonth(); + + log.info("월간 랭킹 Reader 초기화: yearMonth={}, startDate={}, endDate={}", yearMonth, startDate, endDate); + + List summaries = metricsRepository.findAllByProductIdAndDateBetween(startDate, endDate); + + if (summaries.isEmpty()) { + log.warn("집계할 메트릭 데이터가 없습니다."); + iterator = List.of().iterator(); + return; + } + + // 상품별 집계 + Map aggregatedMap = summaries.stream() + .collect(Collectors.groupingBy( + ProductMetricsSummary::getProductId, + Collectors.collectingAndThen( + Collectors.toList(), + list -> { + double[] sums = new double[3]; + for (ProductMetricsSummary s : list) { + sums[0] += s.getLikeCount(); + sums[1] += s.getStockCount(); + sums[2] += s.getViewCount(); + } + return sums; + } + ) + )); + + // 점수 계산 및 정렬 + List rankedProducts = aggregatedMap.entrySet().stream() + .map(entry -> { + double[] sums = entry.getValue(); + double score = rankingWeightReader.calculateTotalScore( + (int) sums[0], (int) sums[1], (int) sums[2] + ); + return new RankedProduct(entry.getKey(), score); + }) + .sorted(Comparator.comparingDouble(RankedProduct::score).reversed()) + .limit(TOP_N) + .toList(); + + log.info("월간 랭킹 Reader 완료: 집계 상품 수={}, TOP-N={}", aggregatedMap.size(), rankedProducts.size()); + + iterator = rankedProducts.iterator(); + } + + private YearMonth parseYearMonth() { + if (yearMonthStr != null && !yearMonthStr.isBlank()) { + return YearMonth.parse(yearMonthStr, YEAR_MONTH_FORMATTER); + } + // 기본값: 이번 달 + return YearMonth.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyRankingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyRankingReader.java new file mode 100644 index 000000000..9c561b4b6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyRankingReader.java @@ -0,0 +1,125 @@ +package com.loopers.batch.job.ranking.reader; + +import com.loopers.batch.domain.RankingWeightReader; +import com.loopers.dto.ProductMetricsSummary; +import com.loopers.dto.RankedProduct; +import com.loopers.infrastructure.metrics.ProductMetricsJpaRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemReader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@StepScope +@Component +public class WeeklyRankingReader implements ItemReader { + + private final ProductMetricsJpaRepository metricsRepository; + private final RankingWeightReader rankingWeightReader; + private Iterator iterator; + private boolean initialized = false; + + @Value("#{jobParameters['startDate']}") + private String startDateStr; + + @Value("#{jobParameters['endDate']}") + private String endDateStr; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final int TOP_N = 100; + + public WeeklyRankingReader(ProductMetricsJpaRepository metricsRepository, + RankingWeightReader rankingWeightReader) { + this.metricsRepository = metricsRepository; + this.rankingWeightReader = rankingWeightReader; + } + + @Override + public RankedProduct read() { + if (!initialized) { + initialize(); + initialized = true; + } + + if (iterator != null && iterator.hasNext()) { + return iterator.next(); + } + return null; + } + + private void initialize() { + LocalDate startDate = parseStartDate(); + LocalDate endDate = parseEndDate(); + + log.info("주간 랭킹 Reader 초기화: startDate={}, endDate={}", startDate, endDate); + + List summaries = metricsRepository.findAllByDateRange(startDate, endDate); + + if (summaries.isEmpty()) { + log.warn("집계할 메트릭 데이터가 없습니다."); + iterator = List.of().iterator(); + return; + } + + // 상품별 집계 + Map aggregatedMap = summaries.stream() + .collect(Collectors.groupingBy( + ProductMetricsSummary::getProductId, + Collectors.collectingAndThen( + Collectors.toList(), + list -> { + double[] sums = new double[3]; + for (ProductMetricsSummary s : list) { + sums[0] += s.getLikeCount(); + sums[1] += s.getStockCount(); + sums[2] += s.getViewCount(); + } + return sums; + } + ) + )); + + // 점수 계산 및 정렬 + List rankedProducts = aggregatedMap.entrySet().stream() + .map(entry -> { + double[] sums = entry.getValue(); + double score = rankingWeightReader.calculateTotalScore( + (int) sums[0], (int) sums[1], (int) sums[2] + ); + return new RankedProduct(entry.getKey(), score); + }) + .sorted(Comparator.comparingDouble(RankedProduct::score).reversed()) + .limit(TOP_N) + .toList(); + + log.info("주간 랭킹 Reader 완료: 집계 상품 수={}, TOP-N={}", aggregatedMap.size(), rankedProducts.size()); + + iterator = rankedProducts.iterator(); + } + + private LocalDate parseStartDate() { + if (startDateStr != null && !startDateStr.isBlank()) { + return LocalDate.parse(startDateStr, DATE_FORMATTER); + } + // 기본값: 이번 주 월요일 + return LocalDate.now().with(DayOfWeek.MONDAY); + } + + private LocalDate parseEndDate() { + if (endDateStr != null && !endDateStr.isBlank()) { + return LocalDate.parse(endDateStr, DATE_FORMATTER); + } + // 기본값: 이번 주 일요일 + return LocalDate.now().with(DayOfWeek.SUNDAY); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankingWriter.java new file mode 100644 index 000000000..aa833aaa7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankingWriter.java @@ -0,0 +1,54 @@ +package com.loopers.batch.job.ranking.writer; + + +import com.loopers.batch.domain.ranking.MonthlyRanking; +import com.loopers.batch.domain.ranking.MonthlyRankingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 org.springframework.transaction.annotation.Transactional; + +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +@Slf4j +@StepScope +@Component +@RequiredArgsConstructor +public class MonthlyRankingWriter implements ItemWriter { + + private final MonthlyRankingRepository monthlyRankingRepository; + + @Value("#{jobParameters['yearMonth']}") + private String yearMonthStr; + + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + private boolean deletedExisting = false; + + @Override + @Transactional + public void write(Chunk chunk) { + if (!deletedExisting) { + YearMonth yearMonth = parseYearMonth(); + + log.info("기존 월간 랭킹 삭제: yearMonth={}", yearMonth); + monthlyRankingRepository.deleteByMonthPeriod(yearMonth); + deletedExisting = true; + } + + log.info("월간 랭킹 저장: count={}", chunk.size()); + monthlyRankingRepository.saveAll(chunk.getItems().stream().map(item -> (MonthlyRanking) item).toList()); + } + + private YearMonth parseYearMonth() { + if (yearMonthStr != null && !yearMonthStr.isBlank()) { + return YearMonth.parse(yearMonthStr, YEAR_MONTH_FORMATTER); + } + return YearMonth.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankingWriter.java new file mode 100644 index 000000000..197890fc1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankingWriter.java @@ -0,0 +1,65 @@ +package com.loopers.batch.job.ranking.writer; + +import com.loopers.batch.domain.ranking.WeeklyRanking; +import com.loopers.batch.domain.ranking.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@StepScope +@Component +@RequiredArgsConstructor +public class WeeklyRankingWriter implements ItemWriter { + + private final WeeklyRankingRepository weeklyRankingRepository; + + @Value("#{jobParameters['startDate']}") + private String startDateStr; + + @Value("#{jobParameters['endDate']}") + private String endDateStr; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private boolean deletedExisting = false; + + @Override + @Transactional + public void write(Chunk chunk) { + if (!deletedExisting) { + LocalDate weekStart = parseStartDate(); + LocalDate weekEnd = parseEndDate(); + + log.info("기존 주간 랭킹 삭제: weekStart={}, weekEnd={}", weekStart, weekEnd); + weeklyRankingRepository.deleteByWeekStartAndWeekEnd(weekStart, weekEnd); + deletedExisting = true; + } + + log.info("주간 랭킹 저장: count={}", chunk.size()); + weeklyRankingRepository.saveAll(chunk.getItems().stream().map(item -> (WeeklyRanking) item).toList()); + } + + private LocalDate parseStartDate() { + if (startDateStr != null && !startDateStr.isBlank()) { + return LocalDate.parse(startDateStr, DATE_FORMATTER); + } + return LocalDate.now().with(DayOfWeek.MONDAY); + } + + private LocalDate parseEndDate() { + if (endDateStr != null && !endDateStr.isBlank()) { + return LocalDate.parse(endDateStr, DATE_FORMATTER); + } + return LocalDate.now().with(DayOfWeek.SUNDAY); + } +} 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..ef0fa4e5b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@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..60460e3cb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@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..a826500a2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@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/dto/ProductMetricsSummary.java b/apps/commerce-batch/src/main/java/com/loopers/dto/ProductMetricsSummary.java new file mode 100644 index 000000000..07ff8c646 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/dto/ProductMetricsSummary.java @@ -0,0 +1,17 @@ +package com.loopers.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@AllArgsConstructor +public class ProductMetricsSummary { + + private Long productId; + private LocalDate date; + private Long likeCount; + private Long stockCount; + private Long viewCount; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/dto/RankedProduct.java b/apps/commerce-batch/src/main/java/com/loopers/dto/RankedProduct.java new file mode 100644 index 000000000..3662af62f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/dto/RankedProduct.java @@ -0,0 +1,7 @@ +package com.loopers.dto; + +public record RankedProduct( + Long productId, + double score +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java new file mode 100644 index 000000000..920d3a548 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.time.YearMonth; + +@Converter(autoApply = true) +public class YearMonthAttributeConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(YearMonth attribute) { + return attribute != null ? attribute.toString() : null; // "yyyy-MM" + } + + @Override + public YearMonth convertToEntityAttribute(String dbData) { + return dbData != null ? YearMonth.parse(dbData) : null; + } +} 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..700332eba --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.batch.domain.metrics.ProductMetrics; +import com.loopers.batch.domain.metrics.ProductMetricsId; +import com.loopers.dto.ProductMetricsSummary; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface ProductMetricsJpaRepository extends JpaRepository { + + @Query(""" + SELECT new com.loopers.dto.ProductMetricsSummary( + pm.id.productId, + pm.id.metricsDate, + CAST(pm.likesDelta AS long), + CAST(pm.salesDelta AS long), + CAST(pm.viewsDelta AS long) + ) + FROM ProductMetrics pm + WHERE pm.id.metricsDate BETWEEN :startDate AND :endDate + ORDER BY pm.id.productId, pm.id.metricsDate + """) + List findAllByDateRange( + @Param("startDate") LocalDate startDate, + @Param("endDate") 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..431407083 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.batch.domain.metrics.ProductMetricsRepository; +import com.loopers.dto.ProductMetricsSummary; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository jpaRepository; + + @Override + public List findAllByDateRange(LocalDate startDate, LocalDate endDate) { + return jpaRepository.findAllByDateRange(startDate, endDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java new file mode 100644 index 000000000..b00b7160e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.batch.domain.ranking.MonthlyRanking; +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 org.springframework.transaction.annotation.Transactional; + +import java.time.YearMonth; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + @Transactional + @Modifying + @Query("DELETE FROM MonthlyRanking m WHERE m.monthPeriod = :monthPeriod") + void deleteByMonthPeriod(@Param("monthPeriod") YearMonth monthPeriod); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java new file mode 100644 index 000000000..06cc8e053 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.batch.domain.ranking.MonthlyRanking; +import com.loopers.batch.domain.ranking.MonthlyRankingRepository; +import com.loopers.batch.domain.ranking.WeeklyRanking; +import com.loopers.batch.domain.ranking.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class RankingRepositoryImpl implements WeeklyRankingRepository, MonthlyRankingRepository { + + private final WeeklyRankingJpaRepository weeklyJpaRepository; + private final MonthlyRankingJpaRepository monthlyJpaRepository; + + @Override + public void deleteByWeekStartAndWeekEnd(LocalDate weekStart, LocalDate weekEnd) { + weeklyJpaRepository.deleteByWeekStartAndWeekEnd(weekStart, weekEnd); + } + + @Override + public void saveAll(List rankings) { + weeklyJpaRepository.saveAll(rankings); + } + + @Override + public void deleteByMonthPeriod(YearMonth monthPeriod) { + monthlyJpaRepository.deleteByMonthPeriod(monthPeriod); + } + + @Override + public void saveAll(List rankings) { + monthlyJpaRepository.saveAll(rankings); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java new file mode 100644 index 000000000..22f6a114a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.batch.domain.ranking.WeeklyRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM WeeklyRanking w WHERE w.weekStart = :weekStart AND w.weekEnd = :weekEnd") + void deleteByWeekStartAndWeekEnd(@Param("weekStart") LocalDate weekStart, @Param("weekEnd") LocalDate weekEnd); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/interfaces/batch/BatchController.java b/apps/commerce-batch/src/main/java/com/loopers/interfaces/batch/BatchController.java new file mode 100644 index 000000000..097d232c2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/interfaces/batch/BatchController.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.batch; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.*; +import org.springframework.batch.core.configuration.JobRegistry; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/batch") +public class BatchController { + + private final JobLauncher jobLauncher; + private final JobRegistry jobRegistry; + + @PostMapping("/weekly-ranking") + public ResponseEntity runWeeklyRankingJob( + @RequestParam String startDate, + @RequestParam String endDate + ) throws Exception { + + JobParameters params = new JobParametersBuilder() + .addString("startDate", startDate) + .addString("endDate", endDate) + .addLong("timestamp", System.currentTimeMillis()) // 중복 실행 방지 + .toJobParameters(); + + Job job = jobRegistry.getJob("weeklyRankingJob"); + jobLauncher.run(job, params); + + return ResponseEntity.ok("WeeklyRankingJob started!"); + } + + @PostMapping("/monthly-ranking") + public ResponseEntity runMonthlyRankingJob( + @RequestParam String yearMonth + ) throws Exception { + + JobParameters params = new JobParametersBuilder() + .addString("yearMonth", yearMonth) + .addLong("timestamp", System.currentTimeMillis()) // 중복 실행 방지 + .toJobParameters(); + + Job job = jobRegistry.getJob("monthlyRankingJob"); + JobExecution execution = jobLauncher.run(job, params); + + if (execution.getStatus() == BatchStatus.FAILED) { + return ResponseEntity.status(500).body("monthlyRankingJob failed!"); + } + + return ResponseEntity.ok("monthlyRankingJob started!"); + } +} 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/CommerceBatchApplicationTests.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTests.java new file mode 100644 index 000000000..b0fd9d64a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTests.java @@ -0,0 +1,13 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CommerceBatchApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/step/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/step/DemoJobE2ETest.java new file mode 100644 index 000000000..355bc792f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/step/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +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 java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@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/build.gradle.kts b/apps/commerce-streamer/build.gradle.kts index 94f46e265..6397565f4 100644 --- a/apps/commerce-streamer/build.gradle.kts +++ b/apps/commerce-streamer/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") diff --git a/settings.gradle.kts b/settings.gradle.kts index 2c1e4bfb6..58f770aca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", ":apps:commerce-streamer", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka",