diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java index abab0c40a2..2e50e554d8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java @@ -2,12 +2,18 @@ import com.loopers.domain.PageResult; import com.loopers.domain.ranking.ProductRanking; +import com.loopers.domain.ranking.RankingPeriod; +import java.time.LocalDate; import java.util.Optional; public interface RankingQueryService { - PageResult getDailyRanking(String date, int page, int size); + /** + * period 에 따라 일간(Redis) / 주간/월간(MV) 랭킹을 반환한다. + * Controller 는 이 단일 진입점만 사용한다. + */ + PageResult getRanking(RankingPeriod period, LocalDate baseDate, int page, int size); Optional getProductDailyRank(Long productId, String date); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java new file mode 100644 index 0000000000..badc0f613b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java @@ -0,0 +1,4 @@ +package com.loopers.domain.ranking; + +public interface MonthlyRankingRepository extends PeriodRankingRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingRepository.java new file mode 100644 index 0000000000..78d3c2b9be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingRepository.java @@ -0,0 +1,20 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +public interface PeriodRankingRepository { + + /** + * 특정 기간 키(예: "2026-W15", "2026-04") 의 랭킹을 offset/limit 기반으로 조회한다. + * + * @param periodKey 주간이면 "2026-W15", 월간이면 "2026-04" + * @param offset 건너뛸 row 수 (0부터 시작) + * @param limit 가져올 row 수 + */ + List findTopN(String periodKey, long offset, int limit); + + /** + * 해당 기간에 저장된 전체 row 수. MV 는 TOP 100 만 저장되므로 최대 100. + */ + long countByPeriod(String periodKey); +} 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 0000000000..f23babc174 --- /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/WeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java new file mode 100644 index 0000000000..ec663bbe77 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java @@ -0,0 +1,4 @@ +package com.loopers.domain.ranking; + +public interface WeeklyRankingRepository extends PeriodRankingRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingRepositoryImpl.java new file mode 100644 index 0000000000..5a043305ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.ProductRanking; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { + + private static final String SELECT_SQL = + "SELECT product_id, score, ranking_position " + + "FROM mv_product_rank_monthly " + + "WHERE year_month_key = ? " + + "ORDER BY ranking_position " + + "LIMIT ? OFFSET ?"; + + private static final String COUNT_SQL = + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE year_month_key = ?"; + + private final JdbcTemplate jdbcTemplate; + + @Override + public List findTopN(String periodKey, long offset, int limit) { + return jdbcTemplate.query( + SELECT_SQL, + (rs, rowNum) -> new ProductRanking( + rs.getLong("product_id"), + rs.getDouble("score"), + rs.getLong("ranking_position") + ), + periodKey, limit, offset + ); + } + + @Override + public long countByPeriod(String periodKey) { + Long count = jdbcTemplate.queryForObject(COUNT_SQL, Long.class, periodKey); + return count != null ? count : 0L; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingQueryServiceImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingQueryServiceImpl.java index f3dd6895f3..3d5838e739 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingQueryServiceImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingQueryServiceImpl.java @@ -2,13 +2,20 @@ import com.loopers.application.ranking.RankingQueryService; import com.loopers.domain.PageResult; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.PeriodRankingRepository; import com.loopers.domain.ranking.ProductRanking; -import com.loopers.support.redis.RankingKeyConstants; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.domain.ranking.RankingRepository; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import com.loopers.support.redis.RankingKeyConstants; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.IsoFields; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -18,10 +25,33 @@ @Component public class RankingQueryServiceImpl implements RankingQueryService { + private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.ofPattern("uuuuMMdd"); + private final RankingRepository rankingRepository; + private final WeeklyRankingRepository weeklyRankingRepository; + private final MonthlyRankingRepository monthlyRankingRepository; + + @Override + public PageResult getRanking(RankingPeriod period, LocalDate baseDate, int page, int size) { + return switch (period) { + case DAILY -> getDailyRankingFromRedis(baseDate.format(DAY_FORMAT), page, size); + case WEEKLY -> getPeriodRanking(weeklyRankingRepository, toYearWeek(baseDate), page, size); + case MONTHLY -> getPeriodRanking(monthlyRankingRepository, toYearMonth(baseDate), page, size); + }; + } @Override - public PageResult getDailyRanking(String date, int page, int size) { + public Optional getProductDailyRank(Long productId, String date) { + try { + String key = RankingKeyConstants.dayKey(date); + return rankingRepository.getRank(key, productId); + } catch (Exception e) { + log.warn("[Ranking] Redis 순위 조회 실패, empty 반환: {}", e.getMessage()); + return Optional.empty(); + } + } + + private PageResult getDailyRankingFromRedis(String date, int page, int size) { try { String key = RankingKeyConstants.dayKey(date); long start = (long) page * size; @@ -38,14 +68,25 @@ public PageResult getDailyRanking(String date, int page, int siz } } - @Override - public Optional getProductDailyRank(Long productId, String date) { - try { - String key = RankingKeyConstants.dayKey(date); - return rankingRepository.getRank(key, productId); - } catch (Exception e) { - log.warn("[Ranking] Redis 순위 조회 실패, empty 반환: {}", e.getMessage()); - return Optional.empty(); - } + private PageResult getPeriodRanking( + PeriodRankingRepository repository, String periodKey, int page, int size + ) { + // 주간/월간은 "정확성 > 실시간성" 원칙. Daily(Redis) 의 graceful degradation 과 달리 + // DB 조회 실패는 그대로 전파하여 사용자/운영자에게 장애를 숨기지 않는다. + long offset = (long) page * size; + List rankings = repository.findTopN(periodKey, offset, size); + long totalElements = repository.countByPeriod(periodKey); + int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size); + return new PageResult<>(rankings, page, size, totalElements, totalPages); + } + + private static String toYearWeek(LocalDate date) { + int year = date.get(IsoFields.WEEK_BASED_YEAR); + int week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + return String.format("%04d-W%02d", year, week); + } + + private static String toYearMonth(LocalDate date) { + return String.format("%04d-%02d", date.getYear(), date.getMonthValue()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingRepositoryImpl.java new file mode 100644 index 0000000000..8acd986e62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRanking; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { + + private static final String SELECT_SQL = + "SELECT product_id, score, ranking_position " + + "FROM mv_product_rank_weekly " + + "WHERE year_week = ? " + + "ORDER BY ranking_position " + + "LIMIT ? OFFSET ?"; + + private static final String COUNT_SQL = + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE year_week = ?"; + + private final JdbcTemplate jdbcTemplate; + + @Override + public List findTopN(String periodKey, long offset, int limit) { + return jdbcTemplate.query( + SELECT_SQL, + (rs, rowNum) -> new ProductRanking( + rs.getLong("product_id"), + rs.getDouble("score"), + rs.getLong("ranking_position") + ), + periodKey, limit, offset + ); + } + + @Override + public long countByPeriod(String periodKey) { + Long count = jdbcTemplate.queryForObject(COUNT_SQL, Long.class, periodKey); + return count != null ? count : 0L; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/schema/MonthlyRankingMvSchema.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/schema/MonthlyRankingMvSchema.java new file mode 100644 index 0000000000..94eab74a05 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/schema/MonthlyRankingMvSchema.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.ranking.schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * {@code mv_product_rank_monthly} schema-only 매핑. + * + *

runtime 쿼리는 {@code MonthlyRankingRepositoryImpl} 이 JdbcTemplate 으로 수행한다. + * 현재 프로젝트는 {@code ddl-auto} 로 DDL 을 관리한다. {@code sql/V12} 는 Flyway 이관 시 참조 스키마. + * + *

WARNING: commerce-batch 쪽 Entity, 이 Entity, 참조 SQL 세 곳에서 같은 테이블을 각자 정의한다. + * {@link WeeklyRankingMvSchema} 의 WARNING 참조. {@code year_month_key} 네이밍 근거는 + * commerce-batch 쪽 {@code MonthlyRankingMvSchema} 주석 참조. + */ +@Entity +@Table( + name = "mv_product_rank_monthly", + indexes = @Index( + name = "idx_mv_product_rank_monthly_position", + columnList = "year_month_key, ranking_position" + ) +) +@IdClass(MonthlyRankingMvSchema.PK.class) +public class MonthlyRankingMvSchema { + + @Id + @Column(name = "year_month_key", nullable = false, length = 7) + private String yearMonth; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "ranking_position", nullable = false) + private long rankingPosition; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected MonthlyRankingMvSchema() {} + + public static class PK implements Serializable { + private String yearMonth; + private Long productId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PK pk)) return false; + return Objects.equals(yearMonth, pk.yearMonth) && Objects.equals(productId, pk.productId); + } + + @Override + public int hashCode() { + return Objects.hash(yearMonth, productId); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/schema/WeeklyRankingMvSchema.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/schema/WeeklyRankingMvSchema.java new file mode 100644 index 0000000000..40912b6e21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/schema/WeeklyRankingMvSchema.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.ranking.schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * {@code mv_product_rank_weekly} schema-only 매핑. + * + *

runtime 쿼리는 {@code WeeklyRankingRepositoryImpl} 이 JdbcTemplate 으로 수행한다. + * 이 클래스는 Hibernate {@code ddl-auto} 가 동일 테이블을 생성할 수 있게 구조만 선언한다. + * + *

현재 프로젝트는 Flyway 를 쓰지 않고 {@code ddl-auto} 로 DDL 을 관리한다. + * {@code sql/V10__create_mv_product_rank_weekly.sql} 은 Flyway 이관 시 참조 스키마로 준비된 파일. + * + *

WARNING — 3중 정의 주의: 이 Entity 와 (1) commerce-batch 의 동명 Entity, (2) 참조 SQL 이 + * 같은 테이블을 각자 정의한다. 컬럼/인덱스 변경 시 세 곳을 함께 수정해야 drift 를 막을 수 있다. + */ +@Entity +@Table( + name = "mv_product_rank_weekly", + indexes = @Index( + name = "idx_mv_product_rank_weekly_position", + columnList = "year_week, ranking_position" + ) +) +@IdClass(WeeklyRankingMvSchema.PK.class) +public class WeeklyRankingMvSchema { + + @Id + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "ranking_position", nullable = false) + private long rankingPosition; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected WeeklyRankingMvSchema() {} + + public static class PK implements Serializable { + private String yearWeek; + private Long productId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PK pk)) return false; + return Objects.equals(yearWeek, pk.yearWeek) && Objects.equals(productId, pk.productId); + } + + @Override + public int hashCode() { + return Objects.hash(yearWeek, productId); + } + } +} 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 0607e4d64a..52cc5f9088 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 @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -7,9 +8,18 @@ @Tag(name = "Ranking V1 API", description = "랭킹 API 입니다.") public interface RankingV1ApiSpec { - @Operation(summary = "일별 랭킹 조회", description = "특정 날짜의 인기 상품 랭킹을 조회합니다. date 미지정 시 오늘 날짜를 사용합니다.") - ApiResponse getDailyRanking( + @Operation( + summary = "랭킹 조회", + description = "period(DAILY/WEEKLY/MONTHLY) 와 기준일을 받아 해당 기간의 인기 상품 랭킹을 조회합니다. " + + "date 미지정 시 오늘 날짜를 사용합니다. size 는 최대 100 까지 지원합니다. " + + "\n\n**page 는 0-based** 입니다. 첫 페이지는 `?page=0`, 두 번째는 `?page=1`. " + + "(Week 9 엔드포인트와의 호환성 유지를 위해 0-based 로 고정.) " + + "\n\n**period 는 대문자로 전달해야 합니다.** 소문자/혼용(daily, Daily 등)은 400 BAD_REQUEST 로 거부됩니다. " + + "허용 값: DAILY, WEEKLY, MONTHLY." + ) + ApiResponse getRanking( String date, + RankingPeriod period, int page, int size ); 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 631d643b6a..a1e5503d6e 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 @@ -5,11 +5,16 @@ import com.loopers.application.ranking.RankingQueryService; import com.loopers.domain.PageResult; import com.loopers.domain.ranking.ProductRanking; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -22,10 +27,10 @@ import java.time.format.ResolverStyle; import java.util.Map; import java.util.Set; -import java.util.regex.Pattern; import java.util.stream.Collectors; @Slf4j +@Validated @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/rankings") @@ -34,31 +39,32 @@ public class RankingV1Controller implements RankingV1ApiSpec { private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter STRICT_DAY_FORMAT = DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT); - private static final Pattern DATE_PATTERN = Pattern.compile("\\d{8}"); + private static final int MAX_PAGE_SIZE = 100; private final RankingQueryService rankingQueryService; private final ProductApplicationService productApplicationService; @GetMapping @Override - public ApiResponse getDailyRanking( - @RequestParam(required = false) String date, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + public ApiResponse getRanking( + @RequestParam(required = false) + @Pattern(regexp = "\\d{8}", message = "유효하지 않은 날짜 형식입니다.") + String date, + @RequestParam(defaultValue = "DAILY") RankingPeriod period, + @RequestParam(defaultValue = "0") + @Min(value = 0, message = "page는 0 이상이어야 합니다.") + int page, + @RequestParam(defaultValue = "20") + @Min(value = 1, message = "size는 1 이상이어야 합니다.") + @Max(value = MAX_PAGE_SIZE, message = "size는 " + MAX_PAGE_SIZE + " 이하여야 합니다.") + int size ) { - if (page < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "page는 0 이상이어야 합니다."); - } - if (size < 1) { - throw new CoreException(ErrorType.BAD_REQUEST, "size는 1 이상이어야 합니다."); - } - if (date != null && !DATE_PATTERN.matcher(date).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 날짜 형식입니다."); - } + LocalDate baseDate = (date != null) + ? parseDate(date) + : LocalDate.now(KST); - String resolvedDate = (date != null) ? date : LocalDate.now(KST).format(STRICT_DAY_FORMAT); - validateDate(resolvedDate); - PageResult rankings = rankingQueryService.getDailyRanking(resolvedDate, page, size); + PageResult rankings = + rankingQueryService.getRanking(period, baseDate, page, size); Set productIds = rankings.items().stream() .map(ProductRanking::productId) @@ -71,9 +77,9 @@ public ApiResponse getDailyRanking( return ApiResponse.success(RankingV1Dto.RankingPageResponse.from(rankings, productMap)); } - private void validateDate(String date) { + private LocalDate parseDate(String date) { try { - LocalDate.parse(date, STRICT_DAY_FORMAT); + return LocalDate.parse(date, STRICT_DAY_FORMAT); } catch (DateTimeParseException e) { log.warn("ranking date parse failed. raw={}", date, e); throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 날짜입니다.", e); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeMonthlyRankingRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeMonthlyRankingRepository.java new file mode 100644 index 0000000000..fc1cf13ad8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeMonthlyRankingRepository.java @@ -0,0 +1,4 @@ +package com.loopers.domain.ranking; + +public class FakeMonthlyRankingRepository extends FakePeriodRankingRepository implements MonthlyRankingRepository { +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakePeriodRankingRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakePeriodRankingRepository.java new file mode 100644 index 0000000000..004e53a630 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakePeriodRankingRepository.java @@ -0,0 +1,51 @@ +package com.loopers.domain.ranking; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FakePeriodRankingRepository implements PeriodRankingRepository { + + private final Map> store = new HashMap<>(); + private RuntimeException forcedFailure; + + public void addScore(String periodKey, Long productId, double score) { + store.computeIfAbsent(periodKey, k -> new HashMap<>()) + .merge(productId, score, Double::sum); + } + + public void failWith(RuntimeException exception) { + this.forcedFailure = exception; + } + + @Override + public List findTopN(String periodKey, long offset, int limit) { + if (forcedFailure != null) { + throw forcedFailure; + } + Map scores = store.getOrDefault(periodKey, Map.of()); + List> sorted = scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()) + .thenComparing(Map.Entry::getKey)) + .toList(); + + List result = new ArrayList<>(); + int startIndex = (int) offset; + int endExclusive = Math.min(startIndex + limit, sorted.size()); + for (int i = startIndex; i < endExclusive; i++) { + Map.Entry entry = sorted.get(i); + result.add(new ProductRanking(entry.getKey(), entry.getValue(), i + 1L)); + } + return result; + } + + @Override + public long countByPeriod(String periodKey) { + if (forcedFailure != null) { + throw forcedFailure; + } + return store.getOrDefault(periodKey, Map.of()).size(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeRankingRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeRankingRepository.java index 5f83eb9ed7..9941c8f079 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeRankingRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeRankingRepository.java @@ -10,14 +10,22 @@ public class FakeRankingRepository implements RankingRepository { private final Map> store = new HashMap<>(); + private RuntimeException forcedFailure; public void addScore(String key, Long productId, double score) { store.computeIfAbsent(key, k -> new HashMap<>()) .merge(productId, score, Double::sum); } + public void failWith(RuntimeException exception) { + this.forcedFailure = exception; + } + @Override public List getTopN(String key, long start, long stop) { + if (forcedFailure != null) { + throw forcedFailure; + } Map scores = store.getOrDefault(key, Map.of()); List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) @@ -33,6 +41,9 @@ public List getTopN(String key, long start, long stop) { @Override public Optional getRank(String key, Long productId) { + if (forcedFailure != null) { + throw forcedFailure; + } Map scores = store.getOrDefault(key, Map.of()); if (!scores.containsKey(productId)) { return Optional.empty(); @@ -52,6 +63,9 @@ public Optional getRank(String key, Long productId) { @Override public long getTotalCount(String key) { + if (forcedFailure != null) { + throw forcedFailure; + } return store.getOrDefault(key, Map.of()).size(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeWeeklyRankingRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeWeeklyRankingRepository.java new file mode 100644 index 0000000000..2d17eea13f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/FakeWeeklyRankingRepository.java @@ -0,0 +1,4 @@ +package com.loopers.domain.ranking; + +public class FakeWeeklyRankingRepository extends FakePeriodRankingRepository implements WeeklyRankingRepository { +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingQueryServiceImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingQueryServiceImplTest.java index 0a0116500a..0f19f76897 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingQueryServiceImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingQueryServiceImplTest.java @@ -1,29 +1,39 @@ package com.loopers.infrastructure.ranking; import com.loopers.domain.PageResult; +import com.loopers.domain.ranking.FakeMonthlyRankingRepository; import com.loopers.domain.ranking.FakeRankingRepository; +import com.loopers.domain.ranking.FakeWeeklyRankingRepository; import com.loopers.domain.ranking.ProductRanking; +import com.loopers.domain.ranking.RankingPeriod; 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 java.time.LocalDate; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class RankingQueryServiceImplTest { private FakeRankingRepository rankingRepository; + private FakeWeeklyRankingRepository weeklyRankingRepository; + private FakeMonthlyRankingRepository monthlyRankingRepository; private RankingQueryServiceImpl rankingQueryService; @BeforeEach void setUp() { rankingRepository = new FakeRankingRepository(); - rankingQueryService = new RankingQueryServiceImpl(rankingRepository); + weeklyRankingRepository = new FakeWeeklyRankingRepository(); + monthlyRankingRepository = new FakeMonthlyRankingRepository(); + rankingQueryService = new RankingQueryServiceImpl( + rankingRepository, weeklyRankingRepository, monthlyRankingRepository); } - @DisplayName("일별 랭킹 조회할 때, ") + @DisplayName("일별 랭킹을 getRanking(DAILY, ...) 로 조회할 때, ") @Nested class GetDailyRanking { @@ -35,7 +45,8 @@ void returnsPaginatedResult() { rankingRepository.addScore(key, i, i * 10.0); } - PageResult result = rankingQueryService.getDailyRanking("20260404", 0, 2); + PageResult result = rankingQueryService.getRanking( + RankingPeriod.DAILY, LocalDate.of(2026, 4, 4), 0, 2); assertThat(result.items()).hasSize(2); assertThat(result.items().get(0).productId()).isEqualTo(5L); @@ -47,7 +58,8 @@ void returnsPaginatedResult() { @DisplayName("데이터가 없으면 빈 결과를 반환한다.") @Test void returnsEmpty_whenNoData() { - PageResult result = rankingQueryService.getDailyRanking("20260404", 0, 20); + PageResult result = rankingQueryService.getRanking( + RankingPeriod.DAILY, LocalDate.of(2026, 4, 4), 0, 20); assertThat(result.items()).isEmpty(); assertThat(result.totalElements()).isZero(); @@ -77,4 +89,98 @@ void returnsEmpty_whenNotInRanking() { assertThat(rank).isEmpty(); } } + + @DisplayName("기간 랭킹 조회 (period) 할 때, ") + @Nested + class GetRanking { + + @DisplayName("period=DAILY 면 Redis 기반 일간 랭킹을 반환한다.") + @Test + void dailyDelegatesToRedisRepository() { + rankingRepository.addScore("ranking:day:20260407", 1L, 30.0); + rankingRepository.addScore("ranking:day:20260407", 2L, 20.0); + + PageResult result = rankingQueryService.getRanking( + RankingPeriod.DAILY, LocalDate.of(2026, 4, 7), 0, 10); + + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).productId()).isEqualTo(1L); + } + + @DisplayName("period=WEEKLY 면 해당 일자가 속한 ISO 주차로 주간 MV를 조회한다.") + @Test + void weeklyMapsBaseDateToIsoWeek() { + // 2026-04-07(화) 은 2026-W15 에 속함 + weeklyRankingRepository.addScore("2026-W15", 10L, 100.0); + weeklyRankingRepository.addScore("2026-W15", 20L, 50.0); + + PageResult result = rankingQueryService.getRanking( + RankingPeriod.WEEKLY, LocalDate.of(2026, 4, 7), 0, 10); + + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).productId()).isEqualTo(10L); + assertThat(result.items().get(0).rank()).isEqualTo(1L); + assertThat(result.totalElements()).isEqualTo(2); + } + + @DisplayName("period=MONTHLY 면 해당 일자의 연-월로 월간 MV를 조회한다.") + @Test + void monthlyMapsBaseDateToYearMonth() { + monthlyRankingRepository.addScore("2026-04", 100L, 500.0); + + PageResult result = rankingQueryService.getRanking( + RankingPeriod.MONTHLY, LocalDate.of(2026, 4, 15), 0, 10); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productId()).isEqualTo(100L); + } + + @DisplayName("범위 초과 page 는 빈 배열을 반환하고 totalElements 는 유지된다.") + @Test + void outOfRangePageReturnsEmptyWithTotal() { + for (long i = 1; i <= 100; i++) { + weeklyRankingRepository.addScore("2026-W15", i, (double) i); + } + + PageResult result = rankingQueryService.getRanking( + RankingPeriod.WEEKLY, LocalDate.of(2026, 4, 7), 10, 20); + + assertThat(result.items()).isEmpty(); + assertThat(result.totalElements()).isEqualTo(100); + } + + @DisplayName("period=WEEKLY 조회 중 DB 예외가 발생하면 그대로 전파된다 (fail-loud).") + @Test + void weeklyPropagatesRepositoryException() { + weeklyRankingRepository.failWith(new RuntimeException("DB connection lost")); + + assertThatThrownBy(() -> rankingQueryService.getRanking( + RankingPeriod.WEEKLY, LocalDate.of(2026, 4, 7), 0, 10)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("DB connection lost"); + } + + @DisplayName("period=MONTHLY 조회 중 DB 예외가 발생하면 그대로 전파된다 (fail-loud).") + @Test + void monthlyPropagatesRepositoryException() { + monthlyRankingRepository.failWith(new RuntimeException("DB connection lost")); + + assertThatThrownBy(() -> rankingQueryService.getRanking( + RankingPeriod.MONTHLY, LocalDate.of(2026, 4, 7), 0, 10)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("DB connection lost"); + } + + @DisplayName("period=DAILY 는 Week 9 의 Redis graceful degradation 을 유지하여 예외를 흡수한다.") + @Test + void dailyKeepsGracefulDegradation() { + rankingRepository.failWith(new RuntimeException("Redis down")); + + PageResult result = rankingQueryService.getRanking( + RankingPeriod.DAILY, LocalDate.of(2026, 4, 7), 0, 10); + + assertThat(result.items()).isEmpty(); + assertThat(result.totalElements()).isZero(); + } + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthRange.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthRange.java new file mode 100644 index 0000000000..ca57b92e07 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthRange.java @@ -0,0 +1,60 @@ +package com.loopers.batch.domain.ranking; + +import org.springframework.util.Assert; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public record MonthRange(String yearMonth, LocalDate start, LocalDate end) { + + private static final Pattern YEAR_MONTH_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})$"); + + /** + * {@code ranking_score_ledger.bucket_key} 와의 포맷 공유 계약: {@code yyyyMMdd}. + * 자세한 내용은 {@link WeekRange} 의 동일 상수 주석 참조. + */ + private static final DateTimeFormatter BASIC_ISO = DateTimeFormatter.BASIC_ISO_DATE; + + public MonthRange { + Assert.hasText(yearMonth, "yearMonth must not be blank"); + Assert.notNull(start, "start must not be null"); + Assert.notNull(end, "end must not be null"); + } + + public static MonthRange of(String yearMonth) { + Assert.hasText(yearMonth, "yearMonth must not be blank"); + Matcher matcher = YEAR_MONTH_PATTERN.matcher(yearMonth); + if (!matcher.matches()) { + throw new IllegalArgumentException( + "yearMonth must be in 'YYYY-MM' format (e.g. 2026-04): " + yearMonth); + } + int year = Integer.parseInt(matcher.group(1)); + int month = Integer.parseInt(matcher.group(2)); + if (month < 1 || month > 12) { + throw new IllegalArgumentException("month must be 1..12: " + month); + } + + YearMonth ym = YearMonth.of(year, month); + LocalDate start = ym.atDay(1); + LocalDate end = ym.atEndOfMonth(); + return new MonthRange(yearMonth, start, end); + } + + public static MonthRange ofPreviousMonth(LocalDate baseDate) { + Assert.notNull(baseDate, "baseDate must not be null"); + YearMonth previous = YearMonth.from(baseDate).minusMonths(1); + String yearMonth = String.format("%04d-%02d", previous.getYear(), previous.getMonthValue()); + return of(yearMonth); + } + + public String startKey() { + return start.format(BASIC_ISO); + } + + public String endKey() { + return end.format(BASIC_ISO); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeekRange.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeekRange.java new file mode 100644 index 0000000000..d8b521cd7a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeekRange.java @@ -0,0 +1,75 @@ +package com.loopers.batch.domain.ranking; + +import org.springframework.util.Assert; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.IsoFields; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public record WeekRange(String yearWeek, LocalDate start, LocalDate end) { + + private static final Pattern YEAR_WEEK_PATTERN = Pattern.compile("^(\\d{4})-W(\\d{2})$"); + + /** + * {@code ranking_score_ledger.bucket_key} 와의 포맷 공유 계약: {@code yyyyMMdd}. + * commerce-streamer 의 {@code com.loopers.support.redis.RankingKeyConstants#dayBucket(LocalDate)} + * 이 같은 포맷을 쓰며, 이 포맷이 일치하지 않으면 Reader 의 BETWEEN 범위가 조용히 빈 결과를 낸다. + * streamer 쪽 포맷이 변경되면 이 상수도 함께 바꿔야 하며, 그 반대도 마찬가지다. + */ + private static final DateTimeFormatter BASIC_ISO = DateTimeFormatter.BASIC_ISO_DATE; + + public WeekRange { + Assert.hasText(yearWeek, "yearWeek must not be blank"); + Assert.notNull(start, "start must not be null"); + Assert.notNull(end, "end must not be null"); + } + + public static WeekRange of(String yearWeek) { + Assert.hasText(yearWeek, "yearWeek must not be blank"); + Matcher matcher = YEAR_WEEK_PATTERN.matcher(yearWeek); + if (!matcher.matches()) { + throw new IllegalArgumentException( + "yearWeek must be in 'YYYY-Www' format (e.g. 2026-W15): " + yearWeek); + } + int year = Integer.parseInt(matcher.group(1)); + int week = Integer.parseInt(matcher.group(2)); + if (week < 1 || week > 53) { + throw new IllegalArgumentException("week must be 1..53: " + week); + } + + // ISO 8601 에서 해당 week-based year 의 1월 4일은 항상 Week 1 에 속한다. 이를 고정 앵커로 삼아 + // 현재 시각(LocalDate.now()) 같은 외부 상태를 pivot 에서 배제한다. + LocalDate monday = LocalDate.of(year, 1, 4) + .with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, week) + .with(java.time.DayOfWeek.MONDAY); + + // 주어진 year 에 해당 week 가 존재하지 않으면 (예: 2026-W53) 조정된 결과가 나옴 → 검증 + int resolvedYear = monday.get(IsoFields.WEEK_BASED_YEAR); + int resolvedWeek = monday.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + if (resolvedYear != year || resolvedWeek != week) { + throw new IllegalArgumentException( + "yearWeek does not exist in ISO calendar: " + yearWeek); + } + + return new WeekRange(yearWeek, monday, monday.plusDays(6)); + } + + public static WeekRange ofPreviousWeek(LocalDate baseDate) { + Assert.notNull(baseDate, "baseDate must not be null"); + LocalDate previousWeekDay = baseDate.minusWeeks(1); + int year = previousWeekDay.get(IsoFields.WEEK_BASED_YEAR); + int week = previousWeekDay.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + String yearWeek = String.format("%04d-W%02d", year, week); + return of(yearWeek); + } + + public String startKey() { + return start.format(BASIC_ISO); + } + + public String endKey() { + return end.format(BASIC_ISO); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/MonthlyRankingMvSchema.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/MonthlyRankingMvSchema.java new file mode 100644 index 0000000000..79f5094cad --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/MonthlyRankingMvSchema.java @@ -0,0 +1,73 @@ +package com.loopers.batch.infrastructure.schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * {@code mv_product_rank_monthly} schema-only 매핑. (DDL 생성 전용) + * + *

현재 앱의 모든 환경은 Hibernate {@code ddl-auto} 로 스키마를 관리한다. + * {@code sql/V12__create_mv_product_rank_monthly.sql} 은 Flyway 이관 시 사용할 참조 스키마. + * + *

year_month_key 네이밍: MySQL 8.0 parser 는 {@code year_month} 를 unquoted 컬럼명으로 거부한다 + * (ERROR 1064, {@code INTERVAL ... YEAR_MONTH} 문법과의 충돌로 추정). 백틱 wrap 은 가능하지만 + * JdbcTemplate SQL 문자열에 백틱을 박는 건 오타 유인이 커 {@code year_month_key} 접미를 선택했다. + * + *

WARNING: 컬럼/인덱스 변경 시 commerce-api 의 동명 Entity + 참조 SQL 함께 수정. + * {@link WeeklyRankingMvSchema} 의 WARNING 참조. + */ +@Entity +@Table( + name = "mv_product_rank_monthly", + indexes = @Index( + name = "idx_mv_product_rank_monthly_position", + columnList = "year_month_key, ranking_position" + ) +) +@IdClass(MonthlyRankingMvSchema.PK.class) +public class MonthlyRankingMvSchema { + + @Id + @Column(name = "year_month_key", nullable = false, length = 7) + private String yearMonth; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "ranking_position", nullable = false) + private long rankingPosition; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected MonthlyRankingMvSchema() {} + + public static class PK implements Serializable { + private String yearMonth; + private Long productId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PK pk)) return false; + return Objects.equals(yearMonth, pk.yearMonth) && Objects.equals(productId, pk.productId); + } + + @Override + public int hashCode() { + return Objects.hash(yearMonth, productId); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/MonthlyRankingStagingSchema.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/MonthlyRankingStagingSchema.java new file mode 100644 index 0000000000..08e8d4da69 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/MonthlyRankingStagingSchema.java @@ -0,0 +1,59 @@ +package com.loopers.batch.infrastructure.schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * {@code mv_product_rank_monthly_staging} schema-only 매핑. (DDL 생성 전용) + * + *

현재 앱의 모든 환경은 Hibernate {@code ddl-auto} 로 스키마를 관리한다. + * {@code sql/V13__create_mv_product_rank_monthly_staging.sql} 은 Flyway 이관 시 사용할 참조 스키마. + * + *

WARNING: 컬럼/인덱스 변경 시 참조 SQL 파일과 함께 수정해야 한다. + * {@link WeeklyRankingMvSchema} 의 WARNING 참조. + */ +@Entity +@Table(name = "mv_product_rank_monthly_staging") +@IdClass(MonthlyRankingStagingSchema.PK.class) +public class MonthlyRankingStagingSchema { + + @Id + @Column(name = "year_month_key", nullable = false, length = 7) + private String yearMonth; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected MonthlyRankingStagingSchema() {} + + public static class PK implements Serializable { + private String yearMonth; + private Long productId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PK pk)) return false; + return Objects.equals(yearMonth, pk.yearMonth) && Objects.equals(productId, pk.productId); + } + + @Override + public int hashCode() { + return Objects.hash(yearMonth, productId); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/RankingScoreLedgerSchema.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/RankingScoreLedgerSchema.java new file mode 100644 index 0000000000..cbb070fdcf --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/RankingScoreLedgerSchema.java @@ -0,0 +1,78 @@ +package com.loopers.batch.infrastructure.schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.Instant; + +/** + * {@code ranking_score_ledger} 테이블의 schema-only 매핑. + * + *

이 클래스는 runtime 에서 직접 사용되지 않는다. commerce-batch 가 commerce-streamer 를 + * 의존하지 않는 경계를 유지하면서도, 테스트 환경에서 Hibernate {@code ddl-auto: create} 가 + * 동일한 테이블을 생성할 수 있도록 구조만 선언한다. + * + *

현재 프로젝트 전체가 Flyway 를 사용하지 않고 {@code ddl-auto} 로 스키마를 관리한다. + * {@code sql/V8__create_ranking_score_ledger.sql} 은 Flyway 이관 시 참조 스키마로 미리 만들어둔 것. + * + *

WARNING — 2중 정의 주의: 이 Entity 는 commerce-streamer 의 + * {@code com.loopers.domain.ranking.RankingScoreLedger} 와 같은 테이블을 각자 정의하고 있다. + * Ledger 스키마 변경 시 (1) streamer 의 엔티티, (2) 이 파일, (3) Flyway 이관용 참조 SQL 을 + * 모두 함께 수정해야 한다. + */ +@Entity +@Table( + name = "ranking_score_ledger", + uniqueConstraints = @UniqueConstraint( + name = "uk_ranking_score_ledger_bucket_product", + columnNames = {"bucket_type", "bucket_key", "product_id"} + ), + indexes = @Index( + name = "idx_ranking_score_ledger_dirty", + columnList = "bucket_type, bucket_key, dirty" + ) +) +public class RankingScoreLedgerSchema { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "bucket_type", nullable = false, length = 8) + private String bucketType; + + @Column(name = "bucket_key", nullable = false, length = 16) + private String bucketKey; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "base_points", nullable = false) + private double basePoints; + + @Column(name = "last_scored_at", nullable = false) + private Instant lastScoredAt; + + @Column(name = "dirty", nullable = false) + private boolean dirty; + + @Column(name = "version") + private Long version; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "deleted_at") + private Instant deletedAt; + + protected RankingScoreLedgerSchema() {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/WeeklyRankingMvSchema.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/WeeklyRankingMvSchema.java new file mode 100644 index 0000000000..9cfd81c0ee --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/WeeklyRankingMvSchema.java @@ -0,0 +1,75 @@ +package com.loopers.batch.infrastructure.schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * {@code mv_product_rank_weekly} schema-only 매핑. (DDL 생성 전용) + * + *

runtime 코드는 JdbcTemplate 으로 이 테이블을 조회/변경한다. + * + *

현재 DDL 관리 방식: 프로젝트에 Flyway 가 아직 도입되지 않아 **앱의 모든 환경은 + * Hibernate {@code ddl-auto} 로 스키마를 관리**한다. {@code sql/V10__create_mv_product_rank_weekly.sql} + * 은 앞으로 Flyway 이관 시 사용할 참조 스키마로 미리 만들어둔 파일이며, 현재 런타임에서 실행되지 않는다. + * + *

WARNING — 2중 정의 주의: 이 Entity 와 commerce-api 의 동명 Entity, 그리고 Flyway 이관용 + * {@code sql/V10} 이 같은 테이블을 각자 정의한다. 컬럼/인덱스 변경 시 세 곳을 함께 수정해야 + * 하며, 그렇지 않으면 모듈 간 drift 및 추후 Flyway 이관 시 실제 DB 와 migration 불일치가 발생한다. + * 장기적으로는 Flyway 도입으로 진실 공급원을 하나로 통합하는 것이 이상적이며, 이는 차기 스프린트 + * 과제로 기록되어 있다. + */ +@Entity +@Table( + name = "mv_product_rank_weekly", + indexes = @Index( + name = "idx_mv_product_rank_weekly_position", + columnList = "year_week, ranking_position" + ) +) +@IdClass(WeeklyRankingMvSchema.PK.class) +public class WeeklyRankingMvSchema { + + @Id + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "ranking_position", nullable = false) + private long rankingPosition; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected WeeklyRankingMvSchema() {} + + public static class PK implements Serializable { + private String yearWeek; + private Long productId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PK pk)) return false; + return Objects.equals(yearWeek, pk.yearWeek) && Objects.equals(productId, pk.productId); + } + + @Override + public int hashCode() { + return Objects.hash(yearWeek, productId); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/WeeklyRankingStagingSchema.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/WeeklyRankingStagingSchema.java new file mode 100644 index 0000000000..8392c732a9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/schema/WeeklyRankingStagingSchema.java @@ -0,0 +1,59 @@ +package com.loopers.batch.infrastructure.schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * {@code mv_product_rank_weekly_staging} schema-only 매핑. (DDL 생성 전용) + * + *

현재 앱의 모든 환경은 Hibernate {@code ddl-auto} 로 스키마를 관리한다. + * {@code sql/V11__create_mv_product_rank_weekly_staging.sql} 은 Flyway 이관 시 사용할 참조 스키마. + * + *

WARNING: 컬럼/인덱스 변경 시 참조 SQL 파일과 함께 수정해야 한다. + * {@link WeeklyRankingMvSchema} 의 WARNING 참조. + */ +@Entity +@Table(name = "mv_product_rank_weekly_staging") +@IdClass(WeeklyRankingStagingSchema.PK.class) +public class WeeklyRankingStagingSchema { + + @Id + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected WeeklyRankingStagingSchema() {} + + public static class PK implements Serializable { + private String yearWeek; + private Long productId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PK pk)) return false; + return Objects.equals(yearWeek, pk.yearWeek) && Objects.equals(productId, pk.productId); + } + + @Override + public int hashCode() { + return Objects.hash(yearWeek, productId); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/cleanup/StagingCleanupJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/cleanup/StagingCleanupJobConfig.java new file mode 100644 index 0000000000..1fd7907c4d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/cleanup/StagingCleanupJobConfig.java @@ -0,0 +1,53 @@ +package com.loopers.batch.job.cleanup; + +import com.loopers.batch.job.cleanup.step.StagingCleanupTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty( + name = "spring.batch.job.name", + havingValue = StagingCleanupJobConfig.JOB_NAME +) +@Configuration +@RequiredArgsConstructor +public class StagingCleanupJobConfig { + + public static final String JOB_NAME = "rankingStagingCleanupJob"; + private static final String STEP_NAME = "rankingStagingCleanupStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final StagingCleanupTasklet stagingCleanupTasklet; + + @Bean(JOB_NAME) + public Job rankingStagingCleanupJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(rankingStagingCleanupStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step rankingStagingCleanupStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(stagingCleanupTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/cleanup/step/StagingCleanupTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/cleanup/step/StagingCleanupTasklet.java new file mode 100644 index 0000000000..b35e456d8d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/cleanup/step/StagingCleanupTasklet.java @@ -0,0 +1,66 @@ +package com.loopers.batch.job.cleanup.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.IsoFields; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class StagingCleanupTasklet implements Tasklet { + + static final int WEEKLY_RETENTION_WEEKS = 4; + static final int MONTHLY_RETENTION_MONTHS = 3; + // "오늘" 을 ISO 주차/월로 변환할 때 JVM 기본 TZ 에 의존하지 않도록 KST 를 명시. + // 다른 스케줄/Listener 가 모두 Asia/Seoul 기준으로 동작하는 것과 일관성을 맞춘다. + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static final String DELETE_WEEKLY_SQL = + "DELETE FROM mv_product_rank_weekly_staging WHERE year_week < ?"; + + private static final String DELETE_MONTHLY_SQL = + "DELETE FROM mv_product_rank_monthly_staging WHERE year_month_key < ?"; + + private final JdbcTemplate jdbcTemplate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + LocalDate today = LocalDate.now(KST); + + String weeklyThreshold = computeWeeklyThreshold(today); + int weeklyDeleted = jdbcTemplate.update(DELETE_WEEKLY_SQL, weeklyThreshold); + + String monthlyThreshold = computeMonthlyThreshold(today); + int monthlyDeleted = jdbcTemplate.update(DELETE_MONTHLY_SQL, monthlyThreshold); + + log.info( + "[StagingCleanupJob] cleaned staging. weeklyThreshold={}, weeklyDeleted={}, " + + "monthlyThreshold={}, monthlyDeleted={}", + weeklyThreshold, weeklyDeleted, monthlyThreshold, monthlyDeleted); + return RepeatStatus.FINISHED; + } + + static String computeWeeklyThreshold(LocalDate today) { + // 현재 주 포함 WEEKLY_RETENTION_WEEKS 주만 보존 (따라서 RETENTION-1 만큼 과거 지점을 cutoff 로). + LocalDate cutoff = today.minusWeeks(WEEKLY_RETENTION_WEEKS - 1L); + int year = cutoff.get(IsoFields.WEEK_BASED_YEAR); + int week = cutoff.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + return String.format("%04d-W%02d", year, week); + } + + static String computeMonthlyThreshold(LocalDate today) { + LocalDate cutoff = today.minusMonths(MONTHLY_RETENTION_MONTHS - 1L); + return String.format("%04d-%02d", cutoff.getYear(), cutoff.getMonthValue()); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..9b530621f9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,92 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.dto.DailyLedgerRow; +import com.loopers.batch.job.ranking.dto.StagingDelta; +import com.loopers.batch.job.ranking.step.DeleteMonthlyStagingTasklet; +import com.loopers.batch.job.ranking.step.LedgerToStagingProcessor; +import com.loopers.batch.job.ranking.step.MonthlyStagingToMvTasklet; +import com.loopers.batch.job.ranking.step.MonthlyStagingUpsertWriter; +import com.loopers.batch.job.ranking.validator.MonthlyJobParametersValidator; +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.item.ItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +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) +@Configuration +@RequiredArgsConstructor +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_DELETE_STAGING = "monthlyRankingStep0DeleteStaging"; + private static final String STEP_LEDGER_TO_STAGING = "monthlyRankingStep1LedgerToStaging"; + private static final String STEP_STAGING_TO_MV = "monthlyRankingStep2StagingToMv"; + + private static final int CHUNK_SIZE = 1000; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DeleteMonthlyStagingTasklet deleteMonthlyStagingTasklet; + private final LedgerToStagingProcessor ledgerToStagingProcessor; + private final MonthlyStagingUpsertWriter monthlyStagingUpsertWriter; + private final MonthlyStagingToMvTasklet monthlyStagingToMvTasklet; + private final MonthlyJobParametersValidator monthlyJobParametersValidator; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .validator(monthlyJobParametersValidator) + .start(monthlyRankingStep0DeleteStaging()) + .next(monthlyRankingStep1LedgerToStaging(null)) + .next(monthlyRankingStep2StagingToMv()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DELETE_STAGING) + public Step monthlyRankingStep0DeleteStaging() { + return new StepBuilder(STEP_DELETE_STAGING, jobRepository) + .tasklet(deleteMonthlyStagingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_LEDGER_TO_STAGING) + public Step monthlyRankingStep1LedgerToStaging( + @Qualifier("monthlyLedgerReader") ItemReader monthlyLedgerReader + ) { + return new StepBuilder(STEP_LEDGER_TO_STAGING, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyLedgerReader) + .processor(ledgerToStagingProcessor) + .writer(monthlyStagingUpsertWriter) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_STAGING_TO_MV) + public Step monthlyRankingStep2StagingToMv() { + return new StepBuilder(STEP_STAGING_TO_MV, jobRepository) + .tasklet(monthlyStagingToMvTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..18cca4717c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,92 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.dto.DailyLedgerRow; +import com.loopers.batch.job.ranking.dto.StagingDelta; +import com.loopers.batch.job.ranking.step.DeleteWeeklyStagingTasklet; +import com.loopers.batch.job.ranking.step.LedgerToStagingProcessor; +import com.loopers.batch.job.ranking.step.WeeklyStagingToMvTasklet; +import com.loopers.batch.job.ranking.step.WeeklyStagingUpsertWriter; +import com.loopers.batch.job.ranking.validator.WeeklyJobParametersValidator; +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.item.ItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +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) +@Configuration +@RequiredArgsConstructor +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_DELETE_STAGING = "weeklyRankingStep0DeleteStaging"; + private static final String STEP_LEDGER_TO_STAGING = "weeklyRankingStep1LedgerToStaging"; + private static final String STEP_STAGING_TO_MV = "weeklyRankingStep2StagingToMv"; + + private static final int CHUNK_SIZE = 1000; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DeleteWeeklyStagingTasklet deleteWeeklyStagingTasklet; + private final LedgerToStagingProcessor ledgerToStagingProcessor; + private final WeeklyStagingUpsertWriter weeklyStagingUpsertWriter; + private final WeeklyStagingToMvTasklet weeklyStagingToMvTasklet; + private final WeeklyJobParametersValidator weeklyJobParametersValidator; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .validator(weeklyJobParametersValidator) + .start(weeklyRankingStep0DeleteStaging()) + .next(weeklyRankingStep1LedgerToStaging(null)) + .next(weeklyRankingStep2StagingToMv()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DELETE_STAGING) + public Step weeklyRankingStep0DeleteStaging() { + return new StepBuilder(STEP_DELETE_STAGING, jobRepository) + .tasklet(deleteWeeklyStagingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_LEDGER_TO_STAGING) + public Step weeklyRankingStep1LedgerToStaging( + @Qualifier("weeklyLedgerReader") ItemReader weeklyLedgerReader + ) { + return new StepBuilder(STEP_LEDGER_TO_STAGING, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyLedgerReader) + .processor(ledgerToStagingProcessor) + .writer(weeklyStagingUpsertWriter) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_STAGING_TO_MV) + public Step weeklyRankingStep2StagingToMv() { + return new StepBuilder(STEP_STAGING_TO_MV, jobRepository) + .tasklet(weeklyStagingToMvTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/DailyLedgerRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/DailyLedgerRow.java new file mode 100644 index 0000000000..04121648b6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/DailyLedgerRow.java @@ -0,0 +1,4 @@ +package com.loopers.batch.job.ranking.dto; + +public record DailyLedgerRow(Long productId, String bucketKey, double basePoints) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/StagingDelta.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/StagingDelta.java new file mode 100644 index 0000000000..ce2078f1b0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/StagingDelta.java @@ -0,0 +1,4 @@ +package com.loopers.batch.job.ranking.dto; + +public record StagingDelta(Long productId, double delta) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/DeleteMonthlyStagingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/DeleteMonthlyStagingTasklet.java new file mode 100644 index 0000000000..b9c7b4610f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/DeleteMonthlyStagingTasklet.java @@ -0,0 +1,38 @@ +package com.loopers.batch.job.ranking.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class DeleteMonthlyStagingTasklet implements Tasklet { + + private static final String SQL = + "DELETE FROM mv_product_rank_monthly_staging WHERE year_month_key = ?"; + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['year_month']}") + private String yearMonth; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // MonthlyJobParametersValidator 가 Job 레벨에서 걸러주지만 Tasklet 단위 방어. + Assert.hasText(yearMonth, "year_month must not be blank"); + int deleted = jdbcTemplate.update(SQL, yearMonth); + log.info("[MonthlyRankingJob] cleared monthly staging rows. yearMonth={}, deleted={}", + yearMonth, deleted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/DeleteWeeklyStagingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/DeleteWeeklyStagingTasklet.java new file mode 100644 index 0000000000..aa35ec73e1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/DeleteWeeklyStagingTasklet.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.ranking.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class DeleteWeeklyStagingTasklet implements Tasklet { + + private static final String SQL = + "DELETE FROM mv_product_rank_weekly_staging WHERE year_week = ?"; + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['year_week']}") + private String yearWeek; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // WeeklyJobParametersValidator 가 Job 레벨에서 null/blank 를 거르지만, + // Tasklet 단위에서 한 번 더 방어하여 "validator 우회" 또는 "후속 리팩터링" 시의 parameter 바인딩 사고를 차단한다. + Assert.hasText(yearWeek, "year_week must not be blank"); + int deleted = jdbcTemplate.update(SQL, yearWeek); + log.info("[WeeklyRankingJob] cleared weekly staging rows. yearWeek={}, deleted={}", + yearWeek, deleted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/LedgerToStagingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/LedgerToStagingProcessor.java new file mode 100644 index 0000000000..345425412b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/LedgerToStagingProcessor.java @@ -0,0 +1,15 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.dto.DailyLedgerRow; +import com.loopers.batch.job.ranking.dto.StagingDelta; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +@Component +public class LedgerToStagingProcessor implements ItemProcessor { + + @Override + public StagingDelta process(DailyLedgerRow item) { + return new StagingDelta(item.productId(), item.basePoints()); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyLedgerReaderConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyLedgerReaderConfig.java new file mode 100644 index 0000000000..707bd72aeb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyLedgerReaderConfig.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.domain.ranking.MonthRange; +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.batch.job.ranking.dto.DailyLedgerRow; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; +import java.util.LinkedHashMap; +import java.util.Map; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@Configuration +public class MonthlyLedgerReaderConfig { + + static final int PAGE_SIZE = 1000; + static final String READER_BEAN_NAME = "monthlyLedgerReader"; + + @Bean(READER_BEAN_NAME) + @StepScope + public JdbcPagingItemReader monthlyLedgerReader( + DataSource dataSource, + @Value("#{jobParameters['year_month']}") String yearMonth + ) { + MonthRange range = MonthRange.of(yearMonth); + + Map sortKeys = new LinkedHashMap<>(); + sortKeys.put("bucket_key", Order.ASCENDING); + sortKeys.put("product_id", Order.ASCENDING); + + Map parameters = Map.of( + "start", range.startKey(), + "end", range.endKey() + ); + + return new JdbcPagingItemReaderBuilder() + .name(READER_BEAN_NAME) + .dataSource(dataSource) + .pageSize(PAGE_SIZE) + .selectClause("SELECT product_id, bucket_key, base_points") + .fromClause("FROM ranking_score_ledger") + .whereClause("WHERE bucket_type = 'DAY' AND bucket_key BETWEEN :start AND :end") + .sortKeys(sortKeys) + .parameterValues(parameters) + .rowMapper((rs, rowNum) -> new DailyLedgerRow( + rs.getLong("product_id"), + rs.getString("bucket_key"), + rs.getDouble("base_points") + )) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyStagingToMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyStagingToMvTasklet.java new file mode 100644 index 0000000000..913ec0372d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyStagingToMvTasklet.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class MonthlyStagingToMvTasklet implements Tasklet { + + static final int TOP_N = 100; + + private static final String DELETE_SQL = + "DELETE FROM mv_product_rank_monthly WHERE year_month_key = ?"; + + // 정렬을 서브쿼리 한 곳(window ORDER BY)에서만 수행하고, 바깥에서 ranking_position 으로 TOP-N 컷. + // "window ORDER BY 와 outer ORDER BY+LIMIT 이 일치해야만 순위 1..N 이 성립한다"는 숨은 불변식을 제거. + private static final String INSERT_SQL = + "INSERT INTO mv_product_rank_monthly " + + "(year_month_key, product_id, ranking_position, score, created_at) " + + "SELECT year_month_key, product_id, ranking_position, score, NOW(6) " + + "FROM (" + + " SELECT ? AS year_month_key, product_id, score," + + " ROW_NUMBER() OVER (ORDER BY score DESC, product_id ASC) AS ranking_position " + + " FROM mv_product_rank_monthly_staging " + + " WHERE year_month_key = ?" + + ") ranked " + + "WHERE ranking_position <= " + TOP_N; + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['year_month']}") + private String yearMonth; + + /** + * DELETE + INSERT 를 단일 트랜잭션에 묶는다. + * {@link WeeklyStagingToMvTasklet} 의 @Transactional 설명과 동일한 원자성 보장을 위해 명시한다. + */ + @Override + @Transactional(propagation = Propagation.REQUIRED) + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + int deleted = jdbcTemplate.update(DELETE_SQL, yearMonth); + int inserted = jdbcTemplate.update(INSERT_SQL, yearMonth, yearMonth); + log.info("[MonthlyRankingJob] promoted staging to MV. yearMonth={}, deleted={}, inserted={}", + yearMonth, deleted, inserted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyStagingUpsertWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyStagingUpsertWriter.java new file mode 100644 index 0000000000..8b48dbeeed --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyStagingUpsertWriter.java @@ -0,0 +1,52 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.dto.StagingDelta; +import lombok.RequiredArgsConstructor; +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.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +@StepScope +@RequiredArgsConstructor +public class MonthlyStagingUpsertWriter implements ItemWriter { + + // MySQL 8.0.20+ 에서 VALUES() 가 deprecated. row alias (AS new) 구문 사용. + private static final String UPSERT_SQL = + "INSERT INTO mv_product_rank_monthly_staging (year_month_key, product_id, score, updated_at) " + + "VALUES (?, ?, ?, ?) AS new " + + "ON DUPLICATE KEY UPDATE " + + "score = mv_product_rank_monthly_staging.score + new.score, " + + "updated_at = new.updated_at"; + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['year_month']}") + private String yearMonth; + + @Override + public void write(Chunk chunk) { + if (chunk.isEmpty()) { + return; + } + Map aggregated = WeeklyStagingUpsertWriter.aggregateByProductId(chunk.getItems()); + List> rows = new ArrayList<>(aggregated.entrySet()); + Timestamp now = Timestamp.from(Instant.now()); + + jdbcTemplate.batchUpdate(UPSERT_SQL, rows, rows.size(), (ps, entry) -> { + ps.setString(1, yearMonth); + ps.setLong(2, entry.getKey()); + ps.setDouble(3, entry.getValue()); + ps.setTimestamp(4, now); + }); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyLedgerReaderConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyLedgerReaderConfig.java new file mode 100644 index 0000000000..11afe1322d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyLedgerReaderConfig.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.domain.ranking.WeekRange; +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.batch.job.ranking.dto.DailyLedgerRow; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; +import java.util.LinkedHashMap; +import java.util.Map; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@Configuration +public class WeeklyLedgerReaderConfig { + + static final int PAGE_SIZE = 1000; + static final String READER_BEAN_NAME = "weeklyLedgerReader"; + + @Bean(READER_BEAN_NAME) + @StepScope + public JdbcPagingItemReader weeklyLedgerReader( + DataSource dataSource, + @Value("#{jobParameters['year_week']}") String yearWeek + ) { + WeekRange range = WeekRange.of(yearWeek); + + Map sortKeys = new LinkedHashMap<>(); + sortKeys.put("bucket_key", Order.ASCENDING); + sortKeys.put("product_id", Order.ASCENDING); + + Map parameters = Map.of( + "start", range.startKey(), + "end", range.endKey() + ); + + return new JdbcPagingItemReaderBuilder() + .name(READER_BEAN_NAME) + .dataSource(dataSource) + .pageSize(PAGE_SIZE) + .selectClause("SELECT product_id, bucket_key, base_points") + .fromClause("FROM ranking_score_ledger") + .whereClause("WHERE bucket_type = 'DAY' AND bucket_key BETWEEN :start AND :end") + .sortKeys(sortKeys) + .parameterValues(parameters) + .rowMapper((rs, rowNum) -> new DailyLedgerRow( + rs.getLong("product_id"), + rs.getString("bucket_key"), + rs.getDouble("base_points") + )) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyStagingToMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyStagingToMvTasklet.java new file mode 100644 index 0000000000..1ad1d3d46d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyStagingToMvTasklet.java @@ -0,0 +1,60 @@ +package com.loopers.batch.job.ranking.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class WeeklyStagingToMvTasklet implements Tasklet { + + static final int TOP_N = 100; + + private static final String DELETE_SQL = + "DELETE FROM mv_product_rank_weekly WHERE year_week = ?"; + + // 정렬을 서브쿼리 한 곳(window ORDER BY)에서만 수행하고, 바깥에서 ranking_position 으로 TOP-N 컷. + // "window ORDER BY 와 outer ORDER BY+LIMIT 이 일치해야만 순위 1..N 이 성립한다"는 숨은 불변식을 제거. + private static final String INSERT_SQL = + "INSERT INTO mv_product_rank_weekly " + + "(year_week, product_id, ranking_position, score, created_at) " + + "SELECT year_week, product_id, ranking_position, score, NOW(6) " + + "FROM (" + + " SELECT ? AS year_week, product_id, score," + + " ROW_NUMBER() OVER (ORDER BY score DESC, product_id ASC) AS ranking_position " + + " FROM mv_product_rank_weekly_staging " + + " WHERE year_week = ?" + + ") ranked " + + "WHERE ranking_position <= " + TOP_N; + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['year_week']}") + private String yearWeek; + + /** + * DELETE + INSERT 를 단일 트랜잭션에 묶는다. StepBuilder 가 tasklet 에 transactionManager 를 전달하여 + * 기본적으로 한 트랜잭션으로 실행되지만, 재사용 / 설정 변경에 안전하도록 @Transactional 을 명시한다. + * INSERT 가 실패하면 DELETE 도 롤백되어 "이전 주차 MV 만 사라지는" 상태가 되지 않는다. + */ + @Override + @Transactional(propagation = Propagation.REQUIRED) + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + int deleted = jdbcTemplate.update(DELETE_SQL, yearWeek); + int inserted = jdbcTemplate.update(INSERT_SQL, yearWeek, yearWeek); + log.info("[WeeklyRankingJob] promoted staging to MV. yearWeek={}, deleted={}, inserted={}", + yearWeek, deleted, inserted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyStagingUpsertWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyStagingUpsertWriter.java new file mode 100644 index 0000000000..0401cf2ed9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyStagingUpsertWriter.java @@ -0,0 +1,66 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.dto.StagingDelta; +import lombok.RequiredArgsConstructor; +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.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@StepScope +@RequiredArgsConstructor +public class WeeklyStagingUpsertWriter implements ItemWriter { + + // MySQL 8.0.20+ 에서 VALUES() 가 deprecated. row alias (AS new) 구문 사용. + private static final String UPSERT_SQL = + "INSERT INTO mv_product_rank_weekly_staging (year_week, product_id, score, updated_at) " + + "VALUES (?, ?, ?, ?) AS new " + + "ON DUPLICATE KEY UPDATE " + + "score = mv_product_rank_weekly_staging.score + new.score, " + + "updated_at = new.updated_at"; + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['year_week']}") + private String yearWeek; + + @Override + public void write(Chunk chunk) { + if (chunk.isEmpty()) { + return; + } + Map aggregated = aggregateByProductId(chunk.getItems()); + List> rows = new ArrayList<>(aggregated.entrySet()); + Timestamp now = Timestamp.from(Instant.now()); + + jdbcTemplate.batchUpdate(UPSERT_SQL, rows, rows.size(), (ps, entry) -> { + ps.setString(1, yearWeek); + ps.setLong(2, entry.getKey()); + ps.setDouble(3, entry.getValue()); + ps.setTimestamp(4, now); + }); + } + + /** + * chunk 내에 동일 product_id 가 여러 번 등장할 수 있다 (같은 상품의 7일치 row 등). + * JDBC 드라이버의 rewriteBatchedStatements / ON DUPLICATE KEY UPDATE 내 동일 PK 동작이 + * 버전에 의존적이므로, 여기서 product_id 별로 합산해 batch 에 고유 PK 만 넣는다. + */ + static Map aggregateByProductId(List items) { + Map aggregated = new LinkedHashMap<>(); + for (StagingDelta item : items) { + aggregated.merge(item.productId(), item.delta(), Double::sum); + } + return aggregated; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/validator/MonthlyJobParametersValidator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/validator/MonthlyJobParametersValidator.java new file mode 100644 index 0000000000..252192b1ad --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/validator/MonthlyJobParametersValidator.java @@ -0,0 +1,31 @@ +package com.loopers.batch.job.ranking.validator; + +import com.loopers.batch.domain.ranking.MonthRange; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.core.JobParametersValidator; +import org.springframework.stereotype.Component; + +@Component +public class MonthlyJobParametersValidator implements JobParametersValidator { + + static final String PARAM_YEAR_MONTH = "year_month"; + + @Override + public void validate(JobParameters parameters) throws JobParametersInvalidException { + if (parameters == null) { + throw new JobParametersInvalidException("JobParameters must not be null"); + } + String yearMonth = parameters.getString(PARAM_YEAR_MONTH); + if (yearMonth == null || yearMonth.isBlank()) { + throw new JobParametersInvalidException( + "required JobParameter missing: " + PARAM_YEAR_MONTH); + } + try { + MonthRange.of(yearMonth); + } catch (IllegalArgumentException e) { + throw new JobParametersInvalidException( + "invalid " + PARAM_YEAR_MONTH + "=" + yearMonth + " (" + e.getMessage() + ")"); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/validator/WeeklyJobParametersValidator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/validator/WeeklyJobParametersValidator.java new file mode 100644 index 0000000000..ed04eae16d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/validator/WeeklyJobParametersValidator.java @@ -0,0 +1,31 @@ +package com.loopers.batch.job.ranking.validator; + +import com.loopers.batch.domain.ranking.WeekRange; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.core.JobParametersValidator; +import org.springframework.stereotype.Component; + +@Component +public class WeeklyJobParametersValidator implements JobParametersValidator { + + static final String PARAM_YEAR_WEEK = "year_week"; + + @Override + public void validate(JobParameters parameters) throws JobParametersInvalidException { + if (parameters == null) { + throw new JobParametersInvalidException("JobParameters must not be null"); + } + String yearWeek = parameters.getString(PARAM_YEAR_WEEK); + if (yearWeek == null || yearWeek.isBlank()) { + throw new JobParametersInvalidException( + "required JobParameter missing: " + PARAM_YEAR_WEEK); + } + try { + WeekRange.of(yearWeek); + } catch (IllegalArgumentException e) { + throw new JobParametersInvalidException( + "invalid " + PARAM_YEAR_WEEK + "=" + yearWeek + " (" + e.getMessage() + ")"); + } + } +} 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 index cb5c8bebd7..143f627b88 100644 --- 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 @@ -16,38 +16,42 @@ @Component public class JobListener { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final String START_TIME_KEY = "startTime"; + @BeforeJob void beforeJob(JobExecution jobExecution) { - log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); - jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + String jobName = jobExecution.getJobInstance().getJobName(); + log.info("Job '{}' 시작", jobName); + jobExecution.getExecutionContext().putLong(START_TIME_KEY, System.currentTimeMillis()); } @AfterJob void afterJob(JobExecution jobExecution) { - var startTime = jobExecution.getExecutionContext().getLong("startTime"); + String jobName = jobExecution.getJobInstance().getJobName(); + var startTime = jobExecution.getExecutionContext().getLong(START_TIME_KEY); var endTime = System.currentTimeMillis(); var startDateTime = Instant.ofEpochMilli(startTime) - .atZone(ZoneId.systemDefault()) + .atZone(KST) .toLocalDateTime(); var endDateTime = Instant.ofEpochMilli(endTime) - .atZone(ZoneId.systemDefault()) + .atZone(KST) .toLocalDateTime(); - var totalTime = endTime - startTime; - var duration = Duration.ofMillis(totalTime); + var duration = Duration.ofMillis(endTime - startTime); 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); + log.info( + "Job '{}' 종료. batchStatus={}, exitStatus={}, startTime={}, endTime={}, duration={}시간 {}분 {}초", + jobName, + jobExecution.getStatus(), + jobExecution.getExitStatus().getExitCode(), + startDateTime, + endDateTime, + hours, minutes, seconds + ); } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java index c5e3bc7a35..38d6dd30a4 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -1,9 +1,14 @@ package com.loopers; +import com.loopers.testcontainers.MySqlTestContainersConfig; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = "spring.batch.job.enabled=false") public class CommerceBatchApplicationTest { @Test void contextLoads() {} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/domain/ranking/MonthRangeTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/domain/ranking/MonthRangeTest.java new file mode 100644 index 0000000000..a094382e19 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/domain/ranking/MonthRangeTest.java @@ -0,0 +1,118 @@ +package com.loopers.batch.domain.ranking; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MonthRangeTest { + + @Nested + @DisplayName("연월 문자열로 생성할 때, ") + class Of { + + @Test + void returnsFirstToLastDayOfMonth() { + MonthRange range = MonthRange.of("2026-04"); + + assertThat(range.yearMonth()).isEqualTo("2026-04"); + assertThat(range.start()).isEqualTo(LocalDate.of(2026, 4, 1)); + assertThat(range.end()).isEqualTo(LocalDate.of(2026, 4, 30)); + } + + @Test + void returnsBucketKeyFormatInBasicIsoDate() { + MonthRange range = MonthRange.of("2026-04"); + + assertThat(range.startKey()).isEqualTo("20260401"); + assertThat(range.endKey()).isEqualTo("20260430"); + } + + @Test + void handles31DayMonth() { + MonthRange range = MonthRange.of("2026-01"); + + assertThat(range.start()).isEqualTo(LocalDate.of(2026, 1, 1)); + assertThat(range.end()).isEqualTo(LocalDate.of(2026, 1, 31)); + } + + @Test + void handlesFebruaryLeapYear() { + MonthRange range = MonthRange.of("2024-02"); + + assertThat(range.end()).isEqualTo(LocalDate.of(2024, 2, 29)); + } + + @Test + void handlesFebruaryNonLeapYear() { + MonthRange range = MonthRange.of("2026-02"); + + assertThat(range.end()).isEqualTo(LocalDate.of(2026, 2, 28)); + } + + @Test + void throwsException_whenYearMonthIsNull() { + assertThatThrownBy(() -> MonthRange.of(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void throwsException_whenYearMonthIsBlank() { + assertThatThrownBy(() -> MonthRange.of(" ")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void throwsException_whenFormatIsInvalid() { + assertThatThrownBy(() -> MonthRange.of("2026/04")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> MonthRange.of("202604")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> MonthRange.of("2026-4")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void throwsException_whenMonthOutOfRange() { + assertThatThrownBy(() -> MonthRange.of("2026-00")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> MonthRange.of("2026-13")) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("기준 일자의 이전 달을 계산할 때, ") + class OfPreviousMonth { + + @Test + void returnsPreviousMonth_fromFirstDay() { + // 2026-04-01 기준 이전 달은 2026-03 + MonthRange range = MonthRange.ofPreviousMonth(LocalDate.of(2026, 4, 1)); + + assertThat(range.yearMonth()).isEqualTo("2026-03"); + assertThat(range.start()).isEqualTo(LocalDate.of(2026, 3, 1)); + assertThat(range.end()).isEqualTo(LocalDate.of(2026, 3, 31)); + } + + @Test + void returnsPreviousMonth_fromMidMonth() { + MonthRange range = MonthRange.ofPreviousMonth(LocalDate.of(2026, 4, 15)); + + assertThat(range.yearMonth()).isEqualTo("2026-03"); + } + + @Test + void handlesJanuary_returnsDecemberOfPreviousYear() { + MonthRange range = MonthRange.ofPreviousMonth(LocalDate.of(2026, 1, 1)); + + assertThat(range.yearMonth()).isEqualTo("2025-12"); + assertThat(range.start()).isEqualTo(LocalDate.of(2025, 12, 1)); + assertThat(range.end()).isEqualTo(LocalDate.of(2025, 12, 31)); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/domain/ranking/WeekRangeTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/domain/ranking/WeekRangeTest.java new file mode 100644 index 0000000000..64d9c37c0c --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/domain/ranking/WeekRangeTest.java @@ -0,0 +1,122 @@ +package com.loopers.batch.domain.ranking; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WeekRangeTest { + + @Nested + @DisplayName("ISO 주차 문자열로 생성할 때, ") + class Of { + + @Test + void returnsMondayToSunday_forIsoWeekString() { + WeekRange range = WeekRange.of("2026-W15"); + + assertThat(range.yearWeek()).isEqualTo("2026-W15"); + assertThat(range.start()).isEqualTo(LocalDate.of(2026, 4, 6)); + assertThat(range.end()).isEqualTo(LocalDate.of(2026, 4, 12)); + } + + @Test + void returnsBucketKeyFormatInBasicIsoDate() { + WeekRange range = WeekRange.of("2026-W15"); + + assertThat(range.startKey()).isEqualTo("20260406"); + assertThat(range.endKey()).isEqualTo("20260412"); + } + + @Test + void handlesWeekThatStraddlesMonthBoundary() { + WeekRange range = WeekRange.of("2026-W14"); + + assertThat(range.start()).isEqualTo(LocalDate.of(2026, 3, 30)); + assertThat(range.end()).isEqualTo(LocalDate.of(2026, 4, 5)); + } + + @Test + void handlesWeekThatStraddlesYearBoundary() { + // 2026-W01 은 ISO 기준으로 2025-12-29(월) ~ 2026-01-04(일) + WeekRange range = WeekRange.of("2026-W01"); + + assertThat(range.start()).isEqualTo(LocalDate.of(2025, 12, 29)); + assertThat(range.end()).isEqualTo(LocalDate.of(2026, 1, 4)); + } + + @Test + void handlesLastWeekOfYear() { + // 2025-W52 는 2025-12-22(월) ~ 2025-12-28(일) + WeekRange range = WeekRange.of("2025-W52"); + + assertThat(range.start()).isEqualTo(LocalDate.of(2025, 12, 22)); + assertThat(range.end()).isEqualTo(LocalDate.of(2025, 12, 28)); + } + + @Test + void throwsException_whenYearWeekIsNull() { + assertThatThrownBy(() -> WeekRange.of(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void throwsException_whenYearWeekIsBlank() { + assertThatThrownBy(() -> WeekRange.of(" ")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void throwsException_whenFormatIsInvalid() { + assertThatThrownBy(() -> WeekRange.of("2026-15")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> WeekRange.of("W15")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> WeekRange.of("2026W15")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void throwsException_whenWeekNumberOutOfRange() { + assertThatThrownBy(() -> WeekRange.of("2026-W00")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> WeekRange.of("2026-W54")) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("기준 일자의 이전 주를 계산할 때, ") + class OfPreviousWeek { + + @Test + void returnsPreviousIsoWeek_fromMonday() { + // 2026-04-13(월) 기준 이전 주는 2026-W15 + WeekRange range = WeekRange.ofPreviousWeek(LocalDate.of(2026, 4, 13)); + + assertThat(range.yearWeek()).isEqualTo("2026-W15"); + assertThat(range.start()).isEqualTo(LocalDate.of(2026, 4, 6)); + assertThat(range.end()).isEqualTo(LocalDate.of(2026, 4, 12)); + } + + @Test + void returnsPreviousIsoWeek_fromMidWeek() { + // 2026-04-08(수) 기준 이전 주는 2026-W14 + WeekRange range = WeekRange.ofPreviousWeek(LocalDate.of(2026, 4, 8)); + + assertThat(range.yearWeek()).isEqualTo("2026-W14"); + } + + @Test + void handlesYearBoundary_whenMondayOfFirstWeek() { + // 2026-01-05(월) 기준 이전 주는 2026-W01 (2025-12-29 ~ 2026-01-04) + WeekRange range = WeekRange.ofPreviousWeek(LocalDate.of(2026, 1, 5)); + + assertThat(range.yearWeek()).isEqualTo("2026-W01"); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/cleanup/StagingCleanupJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/cleanup/StagingCleanupJobE2ETest.java new file mode 100644 index 0000000000..e9cc94fb9f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/cleanup/StagingCleanupJobE2ETest.java @@ -0,0 +1,220 @@ +package com.loopers.batch.job.cleanup; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.junit.jupiter.api.AfterEach; +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.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.IsoFields; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = { + "spring.batch.job.name=" + StagingCleanupJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class StagingCleanupJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(StagingCleanupJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly_staging"); + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly_staging"); + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly"); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly_staging"); + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly_staging"); + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly"); + } + + private void insertWeeklyStaging(String yearWeek, long productId) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly_staging (year_week, product_id, score, updated_at) " + + "VALUES (?, ?, ?, ?)", + yearWeek, productId, 1.0, Timestamp.from(Instant.now()) + ); + } + + private void insertMonthlyStaging(String yearMonth, long productId) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_monthly_staging (year_month_key, product_id, score, updated_at) " + + "VALUES (?, ?, ?, ?)", + yearMonth, productId, 1.0, Timestamp.from(Instant.now()) + ); + } + + private String yearWeekOf(LocalDate date) { + return String.format("%04d-W%02d", + date.get(IsoFields.WEEK_BASED_YEAR), + date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)); + } + + private String yearMonthOf(LocalDate date) { + return String.format("%04d-%02d", date.getYear(), date.getMonthValue()); + } + + private JobExecution runCleanupJob() throws Exception { + return jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters() + ); + } + + @Nested + @DisplayName("Cleanup Job 실행 시, ") + class WhenExecuted { + + @Test + @DisplayName("4주 보존 경계 밖 주간 staging 과 3개월 보존 경계 밖 월간 staging 은 삭제된다.") + void deletesDataBeyondRetentionWindow_keepsRecent() throws Exception { + LocalDate today = LocalDate.now(); + String currentWeek = yearWeekOf(today); + String expiredWeek = yearWeekOf(today.minusWeeks(5)); + String currentMonth = yearMonthOf(today); + String expiredMonth = yearMonthOf(today.minusMonths(4)); + + insertWeeklyStaging(currentWeek, 1L); + insertWeeklyStaging(expiredWeek, 2L); + insertMonthlyStaging(currentMonth, 10L); + insertMonthlyStaging(expiredMonth, 20L); + + JobExecution exec = runCleanupJob(); + + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Long currentWeeklyCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly_staging WHERE year_week = ?", + Long.class, currentWeek); + Long expiredWeeklyCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly_staging WHERE year_week = ?", + Long.class, expiredWeek); + assertThat(currentWeeklyCount).isEqualTo(1L); + assertThat(expiredWeeklyCount).isEqualTo(0L); + + Long currentMonthlyCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly_staging WHERE year_month_key = ?", + Long.class, currentMonth); + Long expiredMonthlyCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly_staging WHERE year_month_key = ?", + Long.class, expiredMonth); + assertThat(currentMonthlyCount).isEqualTo(1L); + assertThat(expiredMonthlyCount).isEqualTo(0L); + } + + @Test + @DisplayName("4주 전 주차 (oldest retained) 는 보존되고, 4주 경계 밖은 삭제된다.") + void weeklyRetentionBoundary() throws Exception { + LocalDate today = LocalDate.now(); + String oldestRetainedWeek = yearWeekOf(today.minusWeeks(3)); // 현재 주 포함 4주 보존의 가장 오래된 주 + String firstExpiredWeek = yearWeekOf(today.minusWeeks(4)); // 경계 밖 + + insertWeeklyStaging(oldestRetainedWeek, 100L); + insertWeeklyStaging(firstExpiredWeek, 101L); + + JobExecution exec = runCleanupJob(); + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Long retained = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly_staging WHERE year_week = ?", + Long.class, oldestRetainedWeek); + Long deleted = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly_staging WHERE year_week = ?", + Long.class, firstExpiredWeek); + + assertThat(retained).as("3주 전은 보존되어야 함").isEqualTo(1L); + assertThat(deleted).as("4주 전은 경계 밖이므로 삭제되어야 함").isEqualTo(0L); + } + + @Test + @DisplayName("2개월 전은 보존되고, 3개월 전은 삭제된다.") + void monthlyRetentionBoundary() throws Exception { + LocalDate today = LocalDate.now(); + String oldestRetainedMonth = yearMonthOf(today.minusMonths(2)); + String firstExpiredMonth = yearMonthOf(today.minusMonths(3)); + + insertMonthlyStaging(oldestRetainedMonth, 200L); + insertMonthlyStaging(firstExpiredMonth, 201L); + + JobExecution exec = runCleanupJob(); + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Long retained = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly_staging WHERE year_month_key = ?", + Long.class, oldestRetainedMonth); + Long deleted = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly_staging WHERE year_month_key = ?", + Long.class, firstExpiredMonth); + + assertThat(retained).as("2개월 전은 보존되어야 함").isEqualTo(1L); + assertThat(deleted).as("3개월 전은 경계 밖이므로 삭제되어야 함").isEqualTo(0L); + } + + @Test + @DisplayName("MV 테이블은 건드려지지 않는다 (staging 전용 cleanup).") + void doesNotTouchMvTables() throws Exception { + LocalDate today = LocalDate.now(); + String oldWeek = yearWeekOf(today.minusWeeks(10)); + String oldMonth = yearMonthOf(today.minusMonths(10)); + + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly " + + "(year_week, product_id, ranking_position, score, created_at) " + + "VALUES (?, ?, ?, ?, ?)", + oldWeek, 999L, 1, 10.0, Timestamp.from(Instant.now()) + ); + jdbcTemplate.update( + "INSERT INTO mv_product_rank_monthly " + + "(year_month_key, product_id, ranking_position, score, created_at) " + + "VALUES (?, ?, ?, ?, ?)", + oldMonth, 888L, 1, 20.0, Timestamp.from(Instant.now()) + ); + + JobExecution exec = runCleanupJob(); + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Long weeklyMvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly", Long.class); + Long monthlyMvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly", Long.class); + + assertThat(weeklyMvCount).as("주간 MV 는 cleanup 대상이 아님").isEqualTo(1L); + assertThat(monthlyMvCount).as("월간 MV 는 cleanup 대상이 아님").isEqualTo(1L); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/cleanup/step/StagingCleanupTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/cleanup/step/StagingCleanupTaskletTest.java new file mode 100644 index 0000000000..761e61520e --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/cleanup/step/StagingCleanupTaskletTest.java @@ -0,0 +1,113 @@ +package com.loopers.batch.job.cleanup.step; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.temporal.IsoFields; + +import static org.assertj.core.api.Assertions.assertThat; + +class StagingCleanupTaskletTest { + + @Nested + @DisplayName("computeWeeklyThreshold 는 현재 주 포함 최근 4주 보존 경계를 반환한다.") + class WeeklyThreshold { + + @Test + void returnsThresholdYearWeek_forToday() { + // today 가 N주차면 threshold = N-3 주차 (현재 포함 4주 보존 → 가장 오래된 보존 주차) + LocalDate today = LocalDate.of(2026, 4, 15); // 2026-W16 (수요일) + String threshold = StagingCleanupTasklet.computeWeeklyThreshold(today); + + // 2026-W13 이어야 함 (16 - 3) + assertThat(threshold).isEqualTo("2026-W13"); + } + + @Test + void handlesIsoWeekYearBoundary() { + // 2026-01-05(월) 은 2026-W02 의 월요일. W02 - 3 = W51 (2025년, 2025-12-22 주) + LocalDate today = LocalDate.of(2026, 1, 5); + String threshold = StagingCleanupTasklet.computeWeeklyThreshold(today); + + assertThat(threshold).isEqualTo("2025-W51"); + } + + @Test + void zeroPaddingIsEnforced() { + // 월 초 몇 주차 시점에도 포맷이 "YYYY-W0N" (2자리) 으로 나와야 문자열 비교 안전성 확보 + LocalDate today = LocalDate.of(2026, 2, 1); // 2026-W05 + String threshold = StagingCleanupTasklet.computeWeeklyThreshold(today); + + assertThat(threshold).matches("\\d{4}-W\\d{2}"); + // 2026-W05 - 3 = 2026-W02 + assertThat(threshold).isEqualTo("2026-W02"); + } + + @Test + @DisplayName("year_week 문자열 비교가 연말/연초 경계에서도 안전하다 (2025-W52 < 2026-W01).") + void stringComparisonRespectsYearBoundary() { + // "2025-W52" < "2026-W01" 는 문자열 사전순으로도 true (연도 prefix 4자리) + // 즉 cleanup 의 year_week < :threshold SQL 이 연말에도 의도대로 동작한다. + String w52 = "2025-W52"; + String w01 = "2026-W01"; + + assertThat(w52.compareTo(w01)).isLessThan(0); + assertThat(w01.compareTo(w52)).isGreaterThan(0); + + // Threshold 가 2026-W02 면 2025-W52 는 삭제 대상 (< 2026-W02) + String threshold = "2026-W02"; + assertThat(w52.compareTo(threshold)).isLessThan(0); + assertThat(w01.compareTo(threshold)).isLessThan(0); + assertThat(threshold.compareTo(threshold)).isZero(); + } + + @Test + void boundaryMeaning_isOldestRetainedWeek() { + // "오늘 포함 최근 4주 보존" 계약: + // threshold = oldestRetainedYearWeek, SQL 은 year_week < threshold 이므로 + // threshold 자체는 보존되고 그보다 오래된 주차만 삭제된다. + LocalDate today = LocalDate.of(2026, 4, 8); // 2026-W15 + String threshold = StagingCleanupTasklet.computeWeeklyThreshold(today); + + int todayYear = today.get(IsoFields.WEEK_BASED_YEAR); + int todayWeek = today.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + String expected = String.format("%04d-W%02d", todayYear, todayWeek - 3); + + assertThat(threshold).isEqualTo(expected); + } + } + + @Nested + @DisplayName("computeMonthlyThreshold 는 현재 월 포함 최근 3개월 보존 경계를 반환한다.") + class MonthlyThreshold { + + @Test + void returnsThresholdYearMonth_forToday() { + LocalDate today = LocalDate.of(2026, 4, 10); + String threshold = StagingCleanupTasklet.computeMonthlyThreshold(today); + + // 4월 - 2 = 2월 (현재 포함 3개월 보존 → 가장 오래된 보존 월) + assertThat(threshold).isEqualTo("2026-02"); + } + + @Test + void handlesYearBoundary() { + LocalDate today = LocalDate.of(2026, 2, 15); + String threshold = StagingCleanupTasklet.computeMonthlyThreshold(today); + + // 2026-02 - 2 = 2025-12 + assertThat(threshold).isEqualTo("2025-12"); + } + + @Test + void zeroPaddingIsEnforced() { + LocalDate today = LocalDate.of(2026, 11, 5); + String threshold = StagingCleanupTasklet.computeMonthlyThreshold(today); + + assertThat(threshold).matches("\\d{4}-\\d{2}"); + assertThat(threshold).isEqualTo("2026-09"); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/MonthlyRankingJobE2ETest.java new file mode 100644 index 0000000000..637d32b8cd --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,190 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.junit.jupiter.api.AfterEach; +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.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = { + "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class MonthlyRankingJobE2ETest { + + private static final String YEAR_MONTH = "2026-03"; + // 2026-03 = 2026-03-01 ~ 2026-03-31 + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly_staging"); + jdbcTemplate.update("DELETE FROM ranking_score_ledger"); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly_staging"); + jdbcTemplate.update("DELETE FROM ranking_score_ledger"); + } + + private JobParameters params(String yearMonth) { + return new JobParametersBuilder() + .addString("year_month", yearMonth) + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters(); + } + + private void insertLedger(long productId, String bucketKey, double basePoints) { + jdbcTemplate.update( + "INSERT INTO ranking_score_ledger " + + "(bucket_type, bucket_key, product_id, base_points, last_scored_at, dirty, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "DAY", bucketKey, productId, basePoints, + Timestamp.from(Instant.now()), false, + Timestamp.from(Instant.now()), Timestamp.from(Instant.now()) + ); + } + + @Nested + @DisplayName("월간 랭킹 Job 을 정상 실행할 때, ") + class HappyPath { + + @Test + void sumsDailyBasePointsOverMonth_andInsertsIntoMv() throws Exception { + // given: 2026-03 전월 중 일부 날짜에 ledger 데이터 주입 + insertLedger(1L, "20260301", 2.0); + insertLedger(1L, "20260315", 3.0); + insertLedger(1L, "20260331", 5.0); // 월말 + insertLedger(2L, "20260310", 4.0); + insertLedger(2L, "20260320", 1.0); + + // when + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_MONTH)); + + // then + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, ranking_position, score FROM mv_product_rank_monthly " + + "WHERE year_month_key = ? ORDER BY ranking_position", YEAR_MONTH); + + assertThat(rows).hasSize(2); + assertThat(rows.get(0)).containsEntry("product_id", 1L); + assertThat(rows.get(0)).containsEntry("ranking_position", 1L); + assertThat(((Number) rows.get(0).get("score")).doubleValue()).isEqualTo(10.0); + + assertThat(rows.get(1)).containsEntry("product_id", 2L); + assertThat(rows.get(1)).containsEntry("ranking_position", 2L); + assertThat(((Number) rows.get(1).get("score")).doubleValue()).isEqualTo(5.0); + } + + @Test + void limitsMvRowsToTop100_whenManyProducts() throws Exception { + // given: 150 개 상품, 각자 고유 score + for (long pid = 1; pid <= 150; pid++) { + insertLedger(pid, "20260315", (double) pid); + } + + // when + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_MONTH)); + + // then + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Long mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE year_month_key = ?", + Long.class, YEAR_MONTH); + assertThat(mvCount).isEqualTo(100L); + + Map first = jdbcTemplate.queryForMap( + "SELECT product_id, ranking_position FROM mv_product_rank_monthly " + + "WHERE year_month_key = ? AND ranking_position = 1", YEAR_MONTH); + assertThat(first).containsEntry("product_id", 150L); + + Long minPos = jdbcTemplate.queryForObject( + "SELECT MIN(ranking_position) FROM mv_product_rank_monthly WHERE year_month_key = ?", + Long.class, YEAR_MONTH); + Long maxPos = jdbcTemplate.queryForObject( + "SELECT MAX(ranking_position) FROM mv_product_rank_monthly WHERE year_month_key = ?", + Long.class, YEAR_MONTH); + assertThat(minPos).isEqualTo(1L); + assertThat(maxPos).isEqualTo(100L); + } + + @Test + void filtersOutDataFromOtherMonths() throws Exception { + // given + insertLedger(1L, "20260301", 10.0); // 2026-03 포함 + insertLedger(1L, "20260228", 999.0); // 2026-02 (이전 달) + insertLedger(1L, "20260401", 999.0); // 2026-04 (다음 달) + + // when + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_MONTH)); + + // then + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Double score = jdbcTemplate.queryForObject( + "SELECT score FROM mv_product_rank_monthly WHERE year_month_key = ? AND product_id = ?", + Double.class, YEAR_MONTH, 1L); + assertThat(score).isEqualTo(10.0); + } + } + + @Nested + @DisplayName("같은 월로 Job 을 두 번 실행해도, ") + class Idempotency { + + @Test + void doesNotDoubleCountScores() throws Exception { + insertLedger(1L, "20260301", 7.0); + + // first run + jobLauncherTestUtils.launchJob(params(YEAR_MONTH)); + // second run + jobLauncherTestUtils.launchJob(params(YEAR_MONTH)); + + Double score = jdbcTemplate.queryForObject( + "SELECT score FROM mv_product_rank_monthly WHERE year_month_key = ? AND product_id = ?", + Double.class, YEAR_MONTH, 1L); + assertThat(score).isEqualTo(7.0); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/WeeklyRankingJobE2ETest.java new file mode 100644 index 0000000000..95e52c6fbe --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,280 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.junit.jupiter.api.AfterEach; +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.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = { + "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class WeeklyRankingJobE2ETest { + + private static final String YEAR_WEEK = "2026-W15"; + // 2026-W15 = 2026-04-06 (Mon) ~ 2026-04-12 (Sun) + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly_staging"); + jdbcTemplate.update("DELETE FROM ranking_score_ledger"); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly_staging"); + jdbcTemplate.update("DELETE FROM ranking_score_ledger"); + } + + private JobParameters params(String yearWeek) { + return new JobParametersBuilder() + .addString("year_week", yearWeek) + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters(); + } + + private void insertLedger(long productId, String bucketKey, double basePoints) { + jdbcTemplate.update( + "INSERT INTO ranking_score_ledger " + + "(bucket_type, bucket_key, product_id, base_points, last_scored_at, dirty, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "DAY", bucketKey, productId, basePoints, + Timestamp.from(Instant.now()), false, + Timestamp.from(Instant.now()), Timestamp.from(Instant.now()) + ); + } + + @Nested + @DisplayName("주간 랭킹 Job 을 정상 실행할 때, ") + class HappyPath { + + @Test + void sumsDailyBasePointsAndInsertsTopNIntoMv() throws Exception { + // given: 3개 상품, 7일치 ledger + // product 1: 매일 1.0 → 주간 7.0 + // product 2: 매일 2.0 → 주간 14.0 + // product 3: 매일 0.5 → 주간 3.5 + String[] days = {"20260406", "20260407", "20260408", "20260409", "20260410", "20260411", "20260412"}; + for (String day : days) { + insertLedger(1L, day, 1.0); + insertLedger(2L, day, 2.0); + insertLedger(3L, day, 0.5); + } + + // when + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_WEEK)); + + // then + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> mvRows = jdbcTemplate.queryForList( + "SELECT product_id, ranking_position, score FROM mv_product_rank_weekly " + + "WHERE year_week = ? ORDER BY ranking_position", YEAR_WEEK); + + assertThat(mvRows).hasSize(3); + assertThat(mvRows.get(0)).containsEntry("product_id", 2L); + assertThat(mvRows.get(0)).containsEntry("ranking_position", 1L); + assertThat(((Number) mvRows.get(0).get("score")).doubleValue()).isEqualTo(14.0); + + assertThat(mvRows.get(1)).containsEntry("product_id", 1L); + assertThat(mvRows.get(1)).containsEntry("ranking_position", 2L); + assertThat(((Number) mvRows.get(1).get("score")).doubleValue()).isEqualTo(7.0); + + assertThat(mvRows.get(2)).containsEntry("product_id", 3L); + assertThat(mvRows.get(2)).containsEntry("ranking_position", 3L); + assertThat(((Number) mvRows.get(2).get("score")).doubleValue()).isEqualTo(3.5); + } + + @Test + void filtersOutLedgerRowsOutsideWeekRange() throws Exception { + // given: 해당 주 데이터 + 주 범위 밖 데이터 섞어 주입 + insertLedger(1L, "20260406", 5.0); // 주간 포함 + insertLedger(1L, "20260405", 100.0); // 이전 주 (일) + insertLedger(1L, "20260413", 200.0); // 다음 주 (월) + + // when + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_WEEK)); + + // then + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Double score = jdbcTemplate.queryForObject( + "SELECT score FROM mv_product_rank_weekly WHERE year_week = ? AND product_id = ?", + Double.class, YEAR_WEEK, 1L); + assertThat(score).isEqualTo(5.0); + } + + @Test + void limitsMvRowsToTop100_whenManyProducts() throws Exception { + // given: 150 개 상품, 각자 고유 score + for (long pid = 1; pid <= 150; pid++) { + insertLedger(pid, "20260406", (double) pid); + } + + // when + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_WEEK)); + + // then + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Long mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE year_week = ?", + Long.class, YEAR_WEEK); + assertThat(mvCount).isEqualTo(100L); + + // rank 1 은 score 가 가장 큰 150번 상품 + Map first = jdbcTemplate.queryForMap( + "SELECT product_id, ranking_position FROM mv_product_rank_weekly " + + "WHERE year_week = ? AND ranking_position = 1", YEAR_WEEK); + assertThat(first).containsEntry("product_id", 150L); + + // ranking_position 은 1~100 연속이어야 함 + Long minPos = jdbcTemplate.queryForObject( + "SELECT MIN(ranking_position) FROM mv_product_rank_weekly WHERE year_week = ?", + Long.class, YEAR_WEEK); + Long maxPos = jdbcTemplate.queryForObject( + "SELECT MAX(ranking_position) FROM mv_product_rank_weekly WHERE year_week = ?", + Long.class, YEAR_WEEK); + assertThat(minPos).isEqualTo(1L); + assertThat(maxPos).isEqualTo(100L); + } + + @Test + @DisplayName("score 동점 시 product_id ASC 로 tie-break 되어 순위가 결정적이다.") + void tieBreakByProductIdAsc() throws Exception { + // given: 세 상품이 완전히 동일한 score (같은 bucket_key 에 같은 base_points) + insertLedger(30L, "20260406", 5.0); + insertLedger(10L, "20260406", 5.0); + insertLedger(20L, "20260406", 5.0); + + // when + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_WEEK)); + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + // then: product_id 작은 순으로 1, 2, 3 + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, ranking_position FROM mv_product_rank_weekly " + + "WHERE year_week = ? ORDER BY ranking_position", YEAR_WEEK); + assertThat(rows).hasSize(3); + assertThat(rows.get(0)).containsEntry("product_id", 10L); + assertThat(rows.get(1)).containsEntry("product_id", 20L); + assertThat(rows.get(2)).containsEntry("product_id", 30L); + } + } + + @Nested + @DisplayName("같은 주차로 Job 을 두 번 실행해도, ") + class Idempotency { + + @Test + void doesNotDoubleCountScores() throws Exception { + // given + insertLedger(1L, "20260406", 5.0); + insertLedger(1L, "20260407", 3.0); + + // when: 첫 실행 + JobExecution first = jobLauncherTestUtils.launchJob(params(YEAR_WEEK)); + assertThat(first.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + Double firstScore = jdbcTemplate.queryForObject( + "SELECT score FROM mv_product_rank_weekly WHERE year_week = ? AND product_id = ?", + Double.class, YEAR_WEEK, 1L); + assertThat(firstScore).isEqualTo(8.0); + + // when: 두 번째 실행 (새 run.id) + JobExecution second = jobLauncherTestUtils.launchJob(params(YEAR_WEEK)); + assertThat(second.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + // then: score 는 여전히 8.0 이어야 함 (2배 되지 않음) + Double secondScore = jdbcTemplate.queryForObject( + "SELECT score FROM mv_product_rank_weekly WHERE year_week = ? AND product_id = ?", + Double.class, YEAR_WEEK, 1L); + assertThat(secondScore).isEqualTo(8.0); + } + + @Test + void doesNotAffectOtherWeeksData() throws Exception { + // given: 2026-W14, 2026-W15 의 MV 데이터가 모두 있음 + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly " + + "(year_week, product_id, ranking_position, score, created_at) " + + "VALUES (?, ?, ?, ?, ?)", + "2026-W14", 999L, 1, 100.0, Timestamp.from(Instant.now()) + ); + insertLedger(1L, "20260406", 5.0); + + // when: 2026-W15 Job 실행 + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_WEEK)); + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + // then: 2026-W14 데이터는 건드려지지 않아야 함 + Long w14Count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE year_week = '2026-W14'", + Long.class); + assertThat(w14Count).isEqualTo(1L); + + Long w15Count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE year_week = '2026-W15'", + Long.class); + assertThat(w15Count).isEqualTo(1L); + } + } + + @Nested + @DisplayName("ledger 에 해당 주차 데이터가 하나도 없을 때, ") + class EmptySource { + + @Test + void completesWithoutError_andProducesNoMvRows() throws Exception { + // given: 아무 것도 없음 + + // when + JobExecution exec = jobLauncherTestUtils.launchJob(params(YEAR_WEEK)); + + // then + assertThat(exec.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE year_week = ?", + Long.class, YEAR_WEEK); + assertThat(count).isEqualTo(0L); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/WeeklyStagingUpsertWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/WeeklyStagingUpsertWriterTest.java new file mode 100644 index 0000000000..bd9f1c8fe4 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/WeeklyStagingUpsertWriterTest.java @@ -0,0 +1,68 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.dto.StagingDelta; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class WeeklyStagingUpsertWriterTest { + + @Test + @DisplayName("같은 product_id 의 여러 row 가 하나의 엔트리로 합산된다.") + void aggregatesDuplicatesByProductId() { + List items = List.of( + new StagingDelta(1L, 2.5), + new StagingDelta(2L, 1.0), + new StagingDelta(1L, 3.0), + new StagingDelta(1L, 0.5), + new StagingDelta(2L, 4.0) + ); + + Map aggregated = WeeklyStagingUpsertWriter.aggregateByProductId(items); + + assertThat(aggregated).hasSize(2); + assertThat(aggregated.get(1L)).isEqualTo(6.0); + assertThat(aggregated.get(2L)).isEqualTo(5.0); + } + + @Test + @DisplayName("빈 입력은 빈 Map 을 반환한다.") + void emptyInputReturnsEmptyMap() { + Map aggregated = WeeklyStagingUpsertWriter.aggregateByProductId(List.of()); + assertThat(aggregated).isEmpty(); + } + + @Test + @DisplayName("중복 없는 입력은 그대로 각 PK 당 1개 엔트리를 만든다.") + void uniqueKeysPassThrough() { + List items = List.of( + new StagingDelta(10L, 1.0), + new StagingDelta(20L, 2.0), + new StagingDelta(30L, 3.0) + ); + + Map aggregated = WeeklyStagingUpsertWriter.aggregateByProductId(items); + + assertThat(aggregated).containsExactlyInAnyOrderEntriesOf( + Map.of(10L, 1.0, 20L, 2.0, 30L, 3.0)); + } + + @Test + @DisplayName("첫 등장 순서가 LinkedHashMap 덕분에 보존된다.") + void preservesInsertionOrder() { + List items = List.of( + new StagingDelta(30L, 1.0), + new StagingDelta(10L, 1.0), + new StagingDelta(20L, 1.0), + new StagingDelta(30L, 1.0) // 중복 — 30 이 가장 먼저 등장 + ); + + Map aggregated = WeeklyStagingUpsertWriter.aggregateByProductId(items); + + assertThat(aggregated.keySet()).containsExactly(30L, 10L, 20L); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/validator/MonthlyJobParametersValidatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/validator/MonthlyJobParametersValidatorTest.java new file mode 100644 index 0000000000..cbb3c0d607 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/validator/MonthlyJobParametersValidatorTest.java @@ -0,0 +1,55 @@ +package com.loopers.batch.job.ranking.validator; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.JobParametersInvalidException; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MonthlyJobParametersValidatorTest { + + private final MonthlyJobParametersValidator validator = new MonthlyJobParametersValidator(); + + @Test + @DisplayName("유효한 year_month 포맷은 통과한다.") + void passesForValidYearMonth() { + JobParameters params = new JobParametersBuilder() + .addString("year_month", "2026-04") + .toJobParameters(); + assertThatCode(() -> validator.validate(params)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("year_month 파라미터 누락은 JobParametersInvalidException 을 던진다.") + void throwsWhenMissing() { + JobParameters params = new JobParametersBuilder() + .addLong("run.id", 1L) + .toJobParameters(); + assertThatThrownBy(() -> validator.validate(params)) + .isInstanceOf(JobParametersInvalidException.class) + .hasMessageContaining("year_month"); + } + + @Test + @DisplayName("잘못된 포맷(YYYYMM 등)은 JobParametersInvalidException 을 던진다.") + void throwsWhenFormatInvalid() { + JobParameters params = new JobParametersBuilder() + .addString("year_month", "202604") + .toJobParameters(); + assertThatThrownBy(() -> validator.validate(params)) + .isInstanceOf(JobParametersInvalidException.class); + } + + @Test + @DisplayName("존재하지 않는 월(13월)은 JobParametersInvalidException 을 던진다.") + void throwsWhenMonthOutOfRange() { + JobParameters params = new JobParametersBuilder() + .addString("year_month", "2026-13") + .toJobParameters(); + assertThatThrownBy(() -> validator.validate(params)) + .isInstanceOf(JobParametersInvalidException.class); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/validator/WeeklyJobParametersValidatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/validator/WeeklyJobParametersValidatorTest.java new file mode 100644 index 0000000000..837a1fe9d7 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/validator/WeeklyJobParametersValidatorTest.java @@ -0,0 +1,62 @@ +package com.loopers.batch.job.ranking.validator; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.JobParametersInvalidException; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WeeklyJobParametersValidatorTest { + + private final WeeklyJobParametersValidator validator = new WeeklyJobParametersValidator(); + + @Test + @DisplayName("유효한 year_week 포맷은 통과한다.") + void passesForValidYearWeek() { + JobParameters params = new JobParametersBuilder() + .addString("year_week", "2026-W15") + .toJobParameters(); + assertThatCode(() -> validator.validate(params)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("year_week 파라미터 누락은 JobParametersInvalidException 을 던진다.") + void throwsWhenMissing() { + JobParameters params = new JobParametersBuilder() + .addLong("run.id", 1L) + .toJobParameters(); + assertThatThrownBy(() -> validator.validate(params)) + .isInstanceOf(JobParametersInvalidException.class) + .hasMessageContaining("year_week"); + } + + @Test + @DisplayName("잘못된 포맷은 JobParametersInvalidException 을 던진다.") + void throwsWhenFormatInvalid() { + JobParameters params = new JobParametersBuilder() + .addString("year_week", "2026/15") + .toJobParameters(); + assertThatThrownBy(() -> validator.validate(params)) + .isInstanceOf(JobParametersInvalidException.class); + } + + @Test + @DisplayName("존재하지 않는 주차(W54)는 JobParametersInvalidException 을 던진다.") + void throwsWhenWeekOutOfRange() { + JobParameters params = new JobParametersBuilder() + .addString("year_week", "2026-W54") + .toJobParameters(); + assertThatThrownBy(() -> validator.validate(params)) + .isInstanceOf(JobParametersInvalidException.class); + } + + @Test + @DisplayName("JobParameters 가 null 이면 JobParametersInvalidException 을 던진다.") + void throwsWhenNull() { + assertThatThrownBy(() -> validator.validate(null)) + .isInstanceOf(JobParametersInvalidException.class); + } +} diff --git a/sql/V10__create_mv_product_rank_weekly.sql b/sql/V10__create_mv_product_rank_weekly.sql new file mode 100644 index 0000000000..184db35a45 --- /dev/null +++ b/sql/V10__create_mv_product_rank_weekly.sql @@ -0,0 +1,9 @@ +CREATE TABLE mv_product_rank_weekly ( + year_week VARCHAR(10) NOT NULL, + product_id BIGINT NOT NULL, + ranking_position INT NOT NULL, + score DOUBLE NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (year_week, product_id), + KEY idx_mv_product_rank_weekly_position (year_week, ranking_position) +); diff --git a/sql/V11__create_mv_product_rank_weekly_staging.sql b/sql/V11__create_mv_product_rank_weekly_staging.sql new file mode 100644 index 0000000000..6e338fd841 --- /dev/null +++ b/sql/V11__create_mv_product_rank_weekly_staging.sql @@ -0,0 +1,7 @@ +CREATE TABLE mv_product_rank_weekly_staging ( + year_week VARCHAR(10) NOT NULL, + product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (year_week, product_id) +); diff --git a/sql/V12__create_mv_product_rank_monthly.sql b/sql/V12__create_mv_product_rank_monthly.sql new file mode 100644 index 0000000000..af9e5bd83d --- /dev/null +++ b/sql/V12__create_mv_product_rank_monthly.sql @@ -0,0 +1,9 @@ +CREATE TABLE mv_product_rank_monthly ( + year_month_key VARCHAR(7) NOT NULL, + product_id BIGINT NOT NULL, + ranking_position INT NOT NULL, + score DOUBLE NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (year_month_key, product_id), + KEY idx_mv_product_rank_monthly_position (year_month_key, ranking_position) +); diff --git a/sql/V13__create_mv_product_rank_monthly_staging.sql b/sql/V13__create_mv_product_rank_monthly_staging.sql new file mode 100644 index 0000000000..83c79ad520 --- /dev/null +++ b/sql/V13__create_mv_product_rank_monthly_staging.sql @@ -0,0 +1,7 @@ +CREATE TABLE mv_product_rank_monthly_staging ( + year_month_key VARCHAR(7) NOT NULL, + product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (year_month_key, product_id) +);