diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/DailyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/DailyRankingService.java new file mode 100644 index 000000000..e70c61dbb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/DailyRankingService.java @@ -0,0 +1,62 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.Period; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class DailyRankingService { + private final StringRedisTemplate redisTemplate; + + public RankingV1Dto.ProductRankingPageResponse getDailyRanking(String date, Period period, int page, int size) { + if (page < 1) page = 1; + if (size < 1) size = 20; + + String key = "ranking:all:" + date; + ZSetOperations zset = redisTemplate.opsForZSet(); + + Long total = zset.size(key); + long totalElements = (total == null) ? 0 : total; + + if (totalElements == 0) { + return new RankingV1Dto.ProductRankingPageResponse(date, Period.DAILY, date, date, page, size, 0, 0, List.of()); + } + + long start = (long) (page - 1) * size; + long end = start + size - 1; + + if (start >= totalElements) { + int totalPages = (int) Math.ceil((double) totalElements / size); + return new RankingV1Dto.ProductRankingPageResponse(date, Period.DAILY, date, date, page, size, totalElements, totalPages, List.of()); + } + + Set> tuples = + zset.reverseRangeWithScores(key, start, end); + + List items = new ArrayList<>(); + if (tuples != null) { + long rank = start + 1; + for (var t : tuples) { + String member = t.getValue(); + Double score = t.getScore(); + if (member == null || score == null) continue; + + items.add(new RankingV1Dto.ProductRankingResponse( + rank++, + Long.parseLong(member), + score + )); + } + } + + int totalPages = (int) Math.ceil((double) totalElements / size); + return new RankingV1Dto.ProductRankingPageResponse(date, Period.DAILY, date, date, page, size, totalElements, totalPages, items); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java new file mode 100644 index 000000000..339e267f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java @@ -0,0 +1,90 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MonthlyRankingMv; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.Period; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MonthlyRankingService { + private static final DateTimeFormatter BASIC = DateTimeFormatter.BASIC_ISO_DATE; + + private final MonthlyRankingRepository monthlyRankingRepository; + + public RankingV1Dto.ProductRankingPageResponse getMonthlyTop100(String date, Period period, int page, int size) { + int safePage = Math.max(page, 1); + int safeSize = Math.max(size, 1); + + LocalDate end = LocalDate.parse(date, BASIC); + String endDate = end.format(BASIC); + + String startDate = end.minusDays(30).format(BASIC); + + System.out.println("startDate = " + startDate); + + long totalElements = monthlyRankingRepository.countMonthly(startDate, endDate); + + if (totalElements == 0) { + return new RankingV1Dto.ProductRankingPageResponse( + date, + period, + startDate, + endDate, + safePage, + safeSize, + 0, + 0, + List.of() + ); + } + + int totalPages = (int) Math.ceil((double) totalElements / safeSize); + + long offset = (long) (safePage - 1) * safeSize; + if (offset >= totalElements) { + return new RankingV1Dto.ProductRankingPageResponse( + date, + period, + startDate, + endDate, + safePage, + safeSize, + totalElements, + totalPages, + List.of() + ); + } + + List rows = monthlyRankingRepository.getMonthlyTop100(endDate, startDate, safePage, safeSize); + + List items = new ArrayList<>(rows.size()); + long rank = offset + 1; + + for (MonthlyRankingMv row : rows) { + items.add(new RankingV1Dto.ProductRankingResponse( + rank++, + row.getProductId(), + row.getScore().doubleValue() + )); + } + + return new RankingV1Dto.ProductRankingPageResponse( + date, + period, + startDate, + endDate, + safePage, + safeSize, + totalElements, + totalPages, + items + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 2781eceea..4155cc014 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -1,70 +1,31 @@ package com.loopers.application.ranking; -import com.loopers.interfaces.api.ranking.RankingV1Dto; +import com.loopers.domain.ranking.Period; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - @Component @RequiredArgsConstructor public class RankingFacade { - private final StringRedisTemplate redisTemplate; - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE; - - public RankingV1Dto.ProductRankingPageResponse getDailyProductRanking(int page, int size) { - if (page < 1) page = 1; - if (size < 1) size = 20; - - String date = LocalDate.now(KST).format(YYYYMMDD); - - String key = "ranking:all:" + date; - ZSetOperations zset = redisTemplate.opsForZSet(); - - Long total = zset.size(key); - long totalElements = (total == null) ? 0 : total; - - if (totalElements == 0) { - return new RankingV1Dto.ProductRankingPageResponse(date, page, size, 0, 0, List.of()); - } - - long start = (long) (page - 1) * size; - long end = start + size - 1; - - if (start >= totalElements) { - int totalPages = (int) Math.ceil((double) totalElements / size); - return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, List.of()); - } - - Set> tuples = - zset.reverseRangeWithScores(key, start, end); - - List items = new ArrayList<>(); - if (tuples != null) { - long rank = start + 1; - for (var t : tuples) { - String member = t.getValue(); - Double score = t.getScore(); - if (member == null || score == null) continue; - - items.add(new RankingV1Dto.ProductRankingResponse( - rank++, - Long.parseLong(member), - score - )); + private final DailyRankingService dailyRankingService; + private final WeeklyRankingService weeklyRankingService; + private final MonthlyRankingService monthlyRankingService; + + public RankingV1Dto.ProductRankingPageResponse getProductRanking(String date, Period period, int page, int size) { + switch (period) { + case DAILY -> { + return dailyRankingService.getDailyRanking(date, period, page, size); + } + case WEEKLY -> { + return weeklyRankingService.getWeeklyTop100(date, period, page, size); } + case MONTHLY -> { + return monthlyRankingService.getMonthlyTop100(date, period, page, size); + } + default -> throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 기간입니다."); } - - int totalPages = (int) Math.ceil((double) totalElements / size); - return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, items); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingV1Dto.java similarity index 71% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java rename to apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingV1Dto.java index 6d210553b..f73c42e8d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingV1Dto.java @@ -1,20 +1,25 @@ -package com.loopers.interfaces.api.ranking; - -import java.util.List; - -public class RankingV1Dto { - public record ProductRankingResponse( - Long rank, - Long productId, - double score - ) {} - - public record ProductRankingPageResponse( - String date, - int page, - int size, - long totalElements, - int totalPages, - List items - ) {} -} +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.Period; + +import java.util.List; + +public class RankingV1Dto { + public record ProductRankingResponse( + Long rank, + Long productId, + double score + ) {} + + public record ProductRankingPageResponse( + String date, + Period period, + String startDate, + String endDate, + int page, + int size, + long totalElements, + int totalPages, + List items + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java new file mode 100644 index 000000000..b429ca72b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java @@ -0,0 +1,91 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.Period; +import com.loopers.domain.ranking.WeeklyRankingMv; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class WeeklyRankingService { + private static final DateTimeFormatter BASIC = DateTimeFormatter.BASIC_ISO_DATE; + + private final WeeklyRankingRepository weeklyRankingRepository; + + public RankingV1Dto.ProductRankingPageResponse getWeeklyTop100(String date, Period period, int page, int size) { + int safePage = Math.max(page, 1); + int safeSize = Math.max(size, 1); + + LocalDate end = LocalDate.parse(date, BASIC); + String endDate = end.format(BASIC); + + String startDate = end.minusDays(6).format(BASIC); + + System.out.println("startDate = " + startDate); + + long totalElements = weeklyRankingRepository.countWeekly(startDate, endDate); + + if (totalElements == 0) { + return new RankingV1Dto.ProductRankingPageResponse( + date, + period, + startDate, + endDate, + safePage, + safeSize, + 0, + 0, + List.of() + ); + } + + int totalPages = (int) Math.ceil((double) totalElements / safeSize); + + long offset = (long) (safePage - 1) * safeSize; + if (offset >= totalElements) { + return new RankingV1Dto.ProductRankingPageResponse( + date, + period, + startDate, + endDate, + safePage, + safeSize, + totalElements, + totalPages, + List.of() + ); + } + + List rows = weeklyRankingRepository.getWeeklyTop100(endDate, startDate, safePage, safeSize); + + List items = new ArrayList<>(rows.size()); + long rank = offset + 1; + + for (WeeklyRankingMv row : rows) { + items.add(new RankingV1Dto.ProductRankingResponse( + rank++, + row.getProductId(), + row.getScore().doubleValue() + )); + } + + return new RankingV1Dto.ProductRankingPageResponse( + date, + period, + startDate, + endDate, + safePage, + safeSize, + totalElements, + totalPages, + items + ); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingMv.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingMv.java new file mode 100644 index 000000000..a64cdc4ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingMv.java @@ -0,0 +1,38 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class MonthlyRankingMv extends BaseEntity { + @Column(name = "start_date", nullable = false) + private String startDate; + + @Column(name = "end_date", nullable = false) + private String endDate; + + @Column(name = "ref_product_id", nullable = false) + private Long productId; + + @Column(name = "view_count_sum", nullable = false) + private Long viewCountSum; + + @Column(name = "like_count_sum", nullable = false) + private Long likeCountSum; + + @Column(name = "sales_volume_sum", nullable = false) + private Long salesVolumeSum; + + @Column(nullable = false) + private BigDecimal score; +} 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 000000000..568b5e91b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +public interface MonthlyRankingRepository { + List getMonthlyTop100(String endDate, String startDate, int safePage, int safeSize); + long countMonthly(String startDate, String endDate); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Period.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Period.java new file mode 100644 index 000000000..527f70b6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Period.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum Period { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingMv.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingMv.java new file mode 100644 index 000000000..2a28d8690 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingMv.java @@ -0,0 +1,40 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class WeeklyRankingMv extends BaseEntity { + + @Column(name = "start_date", nullable = false) + private String startDate; + + @Column(name = "end_date", nullable = false) + private String endDate; + + @Column(name = "ref_product_id", nullable = false) + private Long productId; + + @Column(name = "view_count_sum", nullable = false) + private Long viewCountSum; + + @Column(name = "like_count_sum", nullable = false) + private Long likeCountSum; + + @Column(name = "sales_volume_sum", nullable = false) + private Long salesVolumeSum; + + @Column(nullable = false) + private BigDecimal score; + +} 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 000000000..7744097ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +public interface WeeklyRankingRepository { + List getWeeklyTop100(String date, String lastWeek, int page, int size); + long countWeekly(String startDate, String endDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java new file mode 100644 index 000000000..c114848f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MonthlyRankingMv; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MonthlyRankingJpaRepository extends JpaRepository { +} 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 000000000..ce60aa978 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingRepositoryImpl.java @@ -0,0 +1,52 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MonthlyRankingMv; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static com.loopers.domain.ranking.QMonthlyRankingMv.monthlyRankingMv; + +@Component +@RequiredArgsConstructor +public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { + private final JPAQueryFactory queryFactory; + + @Override + public List getMonthlyTop100(String endDate, String startDate, int page, int size) { + int safePage = Math.max(page, 1); + int safeSize = Math.max(size, 1); + + long offset = (long) (safePage - 1) * safeSize; + + return queryFactory + .selectFrom(monthlyRankingMv) + .where( + monthlyRankingMv.startDate.eq(startDate) + .and(monthlyRankingMv.endDate.eq(endDate))) + .orderBy( + monthlyRankingMv.score.desc(), + monthlyRankingMv.productId.asc() + ) + .offset(offset) + .limit(safeSize) + .fetch(); + } + + @Override + public long countMonthly(String startDate, String endDate) { + Long cnt = queryFactory + .select(monthlyRankingMv.count()) + .from(monthlyRankingMv) + .where( + monthlyRankingMv.startDate.eq(startDate) + .and(monthlyRankingMv.endDate.eq(endDate)) + ) + .fetchOne(); + + return cnt == null ? 0L : cnt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java new file mode 100644 index 000000000..31d3cb04a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.WeeklyRankingMv; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + +} 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 000000000..bf924e193 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingRepositoryImpl.java @@ -0,0 +1,52 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.WeeklyRankingMv; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static com.loopers.domain.ranking.QWeeklyRankingMv.weeklyRankingMv; + +@Component +@RequiredArgsConstructor +public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { + private final JPAQueryFactory queryFactory; + + @Override + public List getWeeklyTop100(String endDate, String startDate, int page, int size) { + int safePage = Math.max(page, 1); + int safeSize = Math.max(size, 1); + + long offset = (long) (safePage - 1) * safeSize; + + return queryFactory + .selectFrom(weeklyRankingMv) + .where( + weeklyRankingMv.startDate.eq(startDate) + .and(weeklyRankingMv.endDate.eq(endDate))) + .orderBy( + weeklyRankingMv.score.desc(), + weeklyRankingMv.productId.asc() + ) + .offset(offset) + .limit(safeSize) + .fetch(); + } + + @Override + public long countWeekly(String startDate, String endDate) { + Long cnt = queryFactory + .select(weeklyRankingMv.count()) + .from(weeklyRankingMv) + .where( + weeklyRankingMv.startDate.eq(startDate) + .and(weeklyRankingMv.endDate.eq(endDate)) + ) + .fetchOne(); + + return cnt == null ? 0L : cnt; + } +} 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 7a4b6e4ba..4e66c0f83 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,9 +1,11 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.application.ranking.RankingV1Dto; +import com.loopers.domain.ranking.Period; import com.loopers.interfaces.api.ApiResponse; public interface RankingV1ApiSpec { - ApiResponse getDailyProductRanking(int size, int page); + ApiResponse getProductRanking(String date, Period period, int size, int page); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index 004f48972..336546260 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -1,6 +1,8 @@ package com.loopers.interfaces.api.ranking; import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingV1Dto; +import com.loopers.domain.ranking.Period; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -16,11 +18,13 @@ public class RankingV1Controller implements RankingV1ApiSpec { @GetMapping @Override - public ApiResponse getDailyProductRanking( + public ApiResponse getProductRanking( + @RequestParam String date, + @RequestParam(defaultValue = "DAILY") Period period, @RequestParam int size, @RequestParam int page ) { - RankingV1Dto.ProductRankingPageResponse response = rankingFacade.getDailyProductRanking(page, size); + RankingV1Dto.ProductRankingPageResponse response = rankingFacade.getProductRanking(date, period, page, size); return ApiResponse.success(response); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java new file mode 100644 index 000000000..82c2caabe --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java @@ -0,0 +1,150 @@ +package com.loopers.batch.config; + +import com.loopers.batch.dto.ProductMetricsAgg; +import com.loopers.batch.dto.ProductRankRow; +import com.loopers.batch.processor.ProductMetricProcessor; +import com.loopers.batch.reader.ProductMetricReader; +import com.loopers.batch.writer.ProductMetricMonthlyWriter; +import com.loopers.batch.writer.ProductMetricWeeklyWriter; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +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.core.step.tasklet.Tasklet; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class BatchConfig { + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final NamedParameterJdbcTemplate jdbcTemplate; + + private final ProductMetricReader productMetricReader; + private final ProductMetricProcessor productMetricProcessor; + private final ProductMetricWeeklyWriter productMetricWeeklyWriter; + private final ProductMetricMonthlyWriter productMetricMonthlyWriter; + + @Bean + @StepScope + public JdbcPagingItemReader weeklyProductMetricsAggReader( + @Value("#{jobParameters['weeklyStartDate']}") String startDate, + @Value("#{jobParameters['weeklyEndDate']}") String endDate + ) throws Exception { + return productMetricReader.reader(startDate, endDate, "weeklyProductMetricsAggReader"); + } + + @Bean + public Step deleteWeeklyMvStep(@Qualifier("deleteWeeklyMvTasklet") Tasklet deleteWeeklyMvTasklet) { + return new StepBuilder("deleteWeeklyMvStep", jobRepository) + .tasklet(deleteWeeklyMvTasklet, transactionManager) + .build(); + } + + @Bean + @StepScope + public Tasklet deleteWeeklyMvTasklet( + @Value("#{jobParameters['weeklyStartDate']}") String startDate, + @Value("#{jobParameters['weeklyEndDate']}") String endDate + ) { + return (contribution, chunkContext) -> { + String sql = """ + DELETE FROM mv_product_rank_weekly + WHERE start_date = :weeklyStartDate AND end_date = :weeklyEndDate + """; + jdbcTemplate.update(sql, Map.of( + "weeklyStartDate", startDate, + "weeklyEndDate", endDate + )); + return RepeatStatus.FINISHED; + }; + } + + @Bean + public Step aggregateWeeklyRankStep(@Qualifier("weeklyProductMetricsAggReader") JdbcPagingItemReader weeklyProductMetricsAggReader) { + return new StepBuilder("aggregateWeeklyRankStep", jobRepository) + .chunk(1000, transactionManager) + .reader(weeklyProductMetricsAggReader) + .processor(productMetricProcessor) + .writer(productMetricWeeklyWriter) + .build(); + } + + @Bean + public Job weeklyProductRankingJob(@Qualifier("deleteWeeklyMvStep") Step deleteWeeklyMvStep, @Qualifier("aggregateWeeklyRankStep") Step aggregateWeeklyRankStep) { + return new JobBuilder("weeklyProductRankingJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(deleteWeeklyMvStep) + .next(aggregateWeeklyRankStep) + .build(); + } + + + @Bean + @StepScope + public JdbcPagingItemReader monthlyProductMetricsAggReader( + @Value("#{jobParameters['monthlyStartDate']}") String startDate, + @Value("#{jobParameters['monthlyEndDate']}") String endDate + ) throws Exception { + return productMetricReader.reader(startDate, endDate, "monthlyProductMetricsAggReader"); + } + + @Bean + public Step deleteMonthlyMvStep(@Qualifier("deleteMonthlyMvTasklet") Tasklet deleteMonthlyMvTasklet) { + return new StepBuilder("deleteMonthlyMvStep", jobRepository) + .tasklet(deleteMonthlyMvTasklet, transactionManager) + .build(); + } + + @Bean + @StepScope + public Tasklet deleteMonthlyMvTasklet( + @Value("#{jobParameters['monthlyStartDate']}") String startDate, + @Value("#{jobParameters['monthlyEndDate']}") String endDate + ) { + return (contribution, chunkContext) -> { + String sql = """ + DELETE FROM mv_product_rank_monthly + WHERE start_date = :monthlyStartDate AND end_date = :monthlyEndDate + """; + jdbcTemplate.update(sql, Map.of( + "monthlyStartDate", startDate, + "monthlyEndDate", endDate + )); + return RepeatStatus.FINISHED; + }; + } + + @Bean + public Step aggregateMonthlyRankStep(@Qualifier("monthlyProductMetricsAggReader") JdbcPagingItemReader monthlyProductMetricsAggReader) { + return new StepBuilder("aggregateMonthlyRankStep", jobRepository) + .chunk(1000, transactionManager) + .reader(monthlyProductMetricsAggReader) + .processor(productMetricProcessor) + .writer(productMetricMonthlyWriter) + .build(); + } + + @Bean + public Job monthlyProductRankingJob(@Qualifier("deleteMonthlyMvStep") Step deleteMonthlyMvStep, @Qualifier("aggregateMonthlyRankStep") Step aggregateMonthlyRankStep) { + return new JobBuilder("monthlyProductRankingJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(deleteMonthlyMvStep) + .next(aggregateMonthlyRankStep) + .build(); + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/dto/ProductMetricsAgg.java b/apps/commerce-batch/src/main/java/com/loopers/batch/dto/ProductMetricsAgg.java new file mode 100644 index 000000000..0e5f67de3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/dto/ProductMetricsAgg.java @@ -0,0 +1,9 @@ +package com.loopers.batch.dto; + +public record ProductMetricsAgg( + Long productId, + long sumLike, + long sumView, + long sumSales +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/dto/ProductRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/dto/ProductRankRow.java new file mode 100644 index 000000000..a64190427 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/dto/ProductRankRow.java @@ -0,0 +1,12 @@ +package com.loopers.batch.dto; + +import java.math.BigDecimal; + +public record ProductRankRow( + Long productId, + long viewCountSum, + long likeCountSum, + long salesVolumeSum, + BigDecimal score +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java deleted file mode 100644 index 7c486483f..000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.loopers.batch.job.demo; - -import com.loopers.batch.job.demo.step.DemoTasklet; -import com.loopers.batch.listener.JobListener; -import com.loopers.batch.listener.StepMonitorListener; -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.configuration.annotation.JobScope; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.launch.support.RunIdIncrementer; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.support.transaction.ResourcelessTransactionManager; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) -@RequiredArgsConstructor -@Configuration -public class DemoJobConfig { - public static final String JOB_NAME = "demoJob"; - private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; - - private final JobRepository jobRepository; - private final JobListener jobListener; - private final StepMonitorListener stepMonitorListener; - private final DemoTasklet demoTasklet; - - @Bean(JOB_NAME) - public Job demoJob() { - return new JobBuilder(JOB_NAME, jobRepository) - .incrementer(new RunIdIncrementer()) - .start(categorySyncStep()) - .listener(jobListener) - .build(); - } - - @JobScope - @Bean(STEP_DEMO_SIMPLE_TASK_NAME) - public Step categorySyncStep() { - return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) - .tasklet(demoTasklet, new ResourcelessTransactionManager()) - .listener(stepMonitorListener) - .build(); - } -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java deleted file mode 100644 index 800fe5a03..000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.batch.job.demo.step; - -import com.loopers.batch.job.demo.DemoJobConfig; -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.StepContribution; -import org.springframework.batch.core.configuration.annotation.StepScope; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@StepScope -@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) -@RequiredArgsConstructor -@Component -public class DemoTasklet implements Tasklet { - @Value("#{jobParameters['requestDate']}") - private String requestDate; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { - if (requestDate == null) { - throw new RuntimeException("requestDate is null"); - } - System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); - Thread.sleep(1000); - System.out.println("Demo Tasklet 작업 완료"); - return RepeatStatus.FINISHED; - } -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java deleted file mode 100644 index 10b09b8fc..000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.loopers.batch.listener; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.annotation.AfterChunk; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.stereotype.Component; - -@Slf4j -@RequiredArgsConstructor -@Component -public class ChunkListener { - - @AfterChunk - void afterChunk(ChunkContext chunkContext) { - log.info( - "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + - "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" - ); - } -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java deleted file mode 100644 index cb5c8bebd..000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.loopers.batch.listener; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.JobExecution; -import org.springframework.batch.core.annotation.AfterJob; -import org.springframework.batch.core.annotation.BeforeJob; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; - -@Slf4j -@RequiredArgsConstructor -@Component -public class JobListener { - - @BeforeJob - void beforeJob(JobExecution jobExecution) { - log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); - jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); - } - - @AfterJob - void afterJob(JobExecution jobExecution) { - var startTime = jobExecution.getExecutionContext().getLong("startTime"); - var endTime = System.currentTimeMillis(); - - var startDateTime = Instant.ofEpochMilli(startTime) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(); - var endDateTime = Instant.ofEpochMilli(endTime) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(); - - var totalTime = endTime - startTime; - var duration = Duration.ofMillis(totalTime); - var hours = duration.toHours(); - var minutes = duration.toMinutes() % 60; - var seconds = duration.getSeconds() % 60; - - var message = String.format( - """ - *Start Time:* %s - *End Time:* %s - *Total Time:* %d시간 %d분 %d초 - """, startDateTime, endDateTime, hours, minutes, seconds - ).trim(); - - log.info(message); - } -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java deleted file mode 100644 index 4f22f40b0..000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.batch.listener; - -import jakarta.annotation.Nonnull; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.ExitStatus; -import org.springframework.batch.core.StepExecution; -import org.springframework.batch.core.StepExecutionListener; -import org.springframework.stereotype.Component; -import java.util.Objects; -import java.util.stream.Collectors; - -@Slf4j -@RequiredArgsConstructor -@Component -public class StepMonitorListener implements StepExecutionListener { - - @Override - public void beforeStep(@Nonnull StepExecution stepExecution) { - log.info("Step '{}' 시작", stepExecution.getStepName()); - } - - @Override - public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { - if (!stepExecution.getFailureExceptions().isEmpty()) { - var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); - var exceptions = stepExecution.getFailureExceptions().stream() - .map(Throwable::getMessage) - .filter(Objects::nonNull) - .collect(Collectors.joining("\n")); - log.info( - """ - [에러 발생] - jobName: {} - exceptions: - {} - """.trim(), jobName, exceptions - ); - // error 발생 시 slack 등 다른 채널로 모니터 전송 - return ExitStatus.FAILED; - } - return ExitStatus.COMPLETED; - } -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/processor/ProductMetricProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/processor/ProductMetricProcessor.java new file mode 100644 index 000000000..d96d604c8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/processor/ProductMetricProcessor.java @@ -0,0 +1,35 @@ +package com.loopers.batch.processor; + +import com.loopers.batch.dto.ProductMetricsAgg; +import com.loopers.batch.dto.ProductRankRow; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Component +@StepScope +public class ProductMetricProcessor implements ItemProcessor { + private static final BigDecimal W_VIEW = new BigDecimal("0.1"); + private static final BigDecimal W_LIKE = new BigDecimal("0.3"); + private static final BigDecimal W_SALES = new BigDecimal("0.6"); + + @Override + public ProductRankRow process(ProductMetricsAgg item) { + BigDecimal score = + BigDecimal.valueOf(item.sumView()).multiply(W_VIEW) + .add(BigDecimal.valueOf(item.sumLike()).multiply(W_LIKE)) + .add(BigDecimal.valueOf(item.sumSales()).multiply(W_SALES)) + .setScale(4, RoundingMode.HALF_UP); + + return new ProductRankRow( + item.productId(), + item.sumView(), + item.sumLike(), + item.sumSales(), + score + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricReader.java new file mode 100644 index 000000000..d01e4226b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricReader.java @@ -0,0 +1,60 @@ +package com.loopers.batch.reader; + +import com.loopers.batch.dto.ProductMetricsAgg; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.SqlPagingQueryProviderFactoryBean; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.ResultSet; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class ProductMetricReader { + private final DataSource dataSource; + + public JdbcPagingItemReader reader( + String startDate, + String endDate, + String readerName + ) throws Exception { + Map params = new LinkedHashMap<>(); + params.put("start", startDate); + params.put("end", endDate); + + SqlPagingQueryProviderFactoryBean provider = new SqlPagingQueryProviderFactoryBean(); + provider.setDataSource(dataSource); + provider.setSelectClause(""" + SELECT + ref_product_id, + SUM(COALESCE(like_count, 0)) AS sum_like, + SUM(COALESCE(view_count, 0)) AS sum_view, + SUM(COALESCE(sales_volume, 0)) AS sum_sales + """); + provider.setFromClause("FROM product_metrics"); + provider.setWhereClause("WHERE metric_date BETWEEN :start AND :end"); + provider.setGroupClause("GROUP BY ref_product_id"); + + Map sortKeys = new LinkedHashMap<>(); + sortKeys.put("ref_product_id", org.springframework.batch.item.database.Order.ASCENDING); + provider.setSortKeys(sortKeys); + + return new JdbcPagingItemReaderBuilder() + .name(readerName) + .dataSource(dataSource) + .queryProvider(provider.getObject()) + .parameterValues(params) + .pageSize(1000) + .rowMapper((ResultSet rs, int rowNum) -> new ProductMetricsAgg( + rs.getLong("ref_product_id"), + rs.getLong("sum_like"), + rs.getLong("sum_view"), + rs.getLong("sum_sales") + )) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/ProductMetricMonthlyWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/ProductMetricMonthlyWriter.java new file mode 100644 index 000000000..5c1f3118a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/ProductMetricMonthlyWriter.java @@ -0,0 +1,55 @@ +package com.loopers.batch.writer; + +import com.loopers.batch.dto.ProductRankRow; +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.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; + +@Component +@StepScope +@RequiredArgsConstructor +public class ProductMetricMonthlyWriter implements ItemWriter { + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['monthlyStartDate']}") + private String startDate; + + @Value("#{jobParameters['monthlyEndDate']}") + private String endDate; + + private static final String SQL = """ + INSERT INTO mv_product_rank_monthly + (start_date, end_date, ref_product_id, + view_count_sum, like_count_sum, sales_volume_sum, + score, created_at, updated_at, deleted_at) + VALUES + (:startDate, :endDate, :productId, + :viewCountSum, :likeCountSum, :salesVolumeSum, + :score, :createdAt, :updatedAt, NULL) + """; + + @Override + public void write(Chunk chunk) { + MapSqlParameterSource[] batch = chunk.getItems().stream() + .map(item -> new MapSqlParameterSource() + .addValue("startDate", startDate) + .addValue("endDate", endDate) + .addValue("productId", item.productId()) + .addValue("viewCountSum", item.viewCountSum()) + .addValue("likeCountSum", item.likeCountSum()) + .addValue("salesVolumeSum", item.salesVolumeSum()) + .addValue("score", item.score()) + .addValue("createdAt", ZonedDateTime.now()) + .addValue("updatedAt", ZonedDateTime.now())) + .toArray(MapSqlParameterSource[]::new); + + jdbcTemplate.batchUpdate(SQL, batch); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/ProductMetricWeeklyWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/ProductMetricWeeklyWriter.java new file mode 100644 index 000000000..ba9f12b3b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/ProductMetricWeeklyWriter.java @@ -0,0 +1,56 @@ +package com.loopers.batch.writer; + +import com.loopers.batch.dto.ProductRankRow; +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.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; + +@Component +@StepScope +@RequiredArgsConstructor +public class ProductMetricWeeklyWriter implements ItemWriter { + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['weeklyStartDate']}") + private String startDate; + + @Value("#{jobParameters['weeklyEndDate']}") + private String endDate; + + private static final String SQL = """ + INSERT INTO mv_product_rank_weekly + (start_date, end_date, ref_product_id, + view_count_sum, like_count_sum, sales_volume_sum, + score, created_at, updated_at, deleted_at) + VALUES + (:startDate, :endDate, :productId, + :viewCountSum, :likeCountSum, :salesVolumeSum, + :score, :createdAt, :updatedAt, NULL) + """; + + @Override + public void write(Chunk chunk) { + MapSqlParameterSource[] batch = chunk.getItems().stream() + .map(item -> new MapSqlParameterSource() + .addValue("startDate", startDate) + .addValue("endDate", endDate) + .addValue("productId", item.productId()) + .addValue("viewCountSum", item.viewCountSum()) + .addValue("likeCountSum", item.likeCountSum()) + .addValue("salesVolumeSum", item.salesVolumeSum()) + .addValue("score", item.score()) + .addValue("createdAt", ZonedDateTime.now()) + .addValue("updatedAt", ZonedDateTime.now())) + .toArray(MapSqlParameterSource[]::new); + + jdbcTemplate.batchUpdate(SQL, batch); + } + +}