From 08765881a043dba59aaf995d9719e8447fc987f2 Mon Sep 17 00:00:00 2001 From: kilian Date: Sun, 28 Dec 2025 21:59:22 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feature=20:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20daily=20metric=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ncreaseProductLikeMetricKafkaConsumer.java | 37 +++++++++++++++++++ .../dto/IncreaseProductLikeMetricEvent.java | 12 ++++++ .../core/domain/event/type/EventType.java | 3 +- ...uctMetric.java => DailyProductMetric.java} | 36 +++++++++++------- .../repository/ProductMetricRepository.java | 7 ++-- .../product/ProductMetricJpaRepository.java | 9 +++-- ...ity.java => DailyProductMetricEntity.java} | 14 +++---- .../impl/ProductMetricRepositoryImpl.java | 15 ++++---- .../IncreaseProductLikeMetricService.java | 34 +++++++++++++++++ ...IncreaseProductMetricViewCountService.java | 13 ++++--- .../IncreaseProductTotalSalesService.java | 9 +++-- .../IncreaseProductLikeMetricCommand.java | 7 ++++ 12 files changed, 152 insertions(+), 44 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeMetricKafkaConsumer.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeMetricEvent.java rename core/domain/src/main/java/com/loopers/core/domain/product/{ProductMetric.java => DailyProductMetric.java} (66%) rename core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/{ProductMetricEntity.java => DailyProductMetricEntity.java} (85%) create mode 100644 core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java create mode 100644 core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductLikeMetricCommand.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeMetricKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeMetricKafkaConsumer.java new file mode 100644 index 000000000..fc24df856 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeMetricKafkaConsumer.java @@ -0,0 +1,37 @@ +package com.loopers.applications.streamer.consumer.product; + +import com.loopers.JacksonUtil; +import com.loopers.applications.streamer.consumer.product.dto.IncreaseProductLikeMetricEvent; +import com.loopers.core.infra.event.kafka.config.KafkaConfig; +import com.loopers.core.service.product.IncreaseProductLikeMetricService; +import lombok.RequiredArgsConstructor; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class IncreaseProductLikeMetricKafkaConsumer { + + private final IncreaseProductLikeMetricService service; + + @KafkaListener( + topics = {"${spring.kafka.topic.product-like}"}, + containerFactory = KafkaConfig.BATCH_LISTENER, + groupId = "increase-product-like-count" + ) + public void listen( + List> records, + Acknowledgment acknowledgment + ) { + records.stream() + .map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductLikeMetricEvent.class)) + .map(IncreaseProductLikeMetricEvent::toCommand) + .forEach(service::increase); + + acknowledgment.acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeMetricEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeMetricEvent.java new file mode 100644 index 000000000..25232b655 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeMetricEvent.java @@ -0,0 +1,12 @@ +package com.loopers.applications.streamer.consumer.product.dto; + +import com.loopers.core.service.product.command.IncreaseProductLikeMetricCommand; + +public record IncreaseProductLikeMetricEvent( + String eventId, + String productId +) { + public IncreaseProductLikeMetricCommand toCommand() { + return new IncreaseProductLikeMetricCommand(this.eventId, this.productId); + } +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java b/core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java index 30ebfae88..1e4b01661 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java +++ b/core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java @@ -15,5 +15,6 @@ public enum EventType { LIKE_PRODUCT, INCREASE_PRODUCT_LIKE_RANKING_SCORE, INCREASE_PRODUCT_VIEW_RANKING_SCORE, - INCREASE_SALES_RANKING_COUNT + INCREASE_SALES_RANKING_COUNT, + INCREASE_PRODUCT_METRIC_LIKE_COUNT } diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/ProductMetric.java b/core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java similarity index 66% rename from core/domain/src/main/java/com/loopers/core/domain/product/ProductMetric.java rename to core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java index 4ec43c18e..db23f13a6 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/ProductMetric.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java @@ -3,21 +3,20 @@ import com.loopers.core.domain.common.vo.CreatedAt; import com.loopers.core.domain.common.vo.UpdatedAt; import com.loopers.core.domain.order.vo.Quantity; -import com.loopers.core.domain.product.vo.ProductDetailViewCount; -import com.loopers.core.domain.product.vo.ProductId; -import com.loopers.core.domain.product.vo.ProductMetricId; -import com.loopers.core.domain.product.vo.ProductTotalSalesCount; +import com.loopers.core.domain.product.vo.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @Getter -public class ProductMetric { +public class DailyProductMetric { private final ProductMetricId id; private final ProductId productId; + private final ProductLikeCount likeCount; + private final ProductTotalSalesCount totalSalesCount; private final ProductDetailViewCount viewCount; @@ -27,9 +26,10 @@ public class ProductMetric { private final UpdatedAt updatedAt; @Builder(access = AccessLevel.PRIVATE, toBuilder = true) - private ProductMetric( + private DailyProductMetric( ProductMetricId id, ProductId productId, + ProductLikeCount likeCount, ProductTotalSalesCount totalSalesCount, ProductDetailViewCount viewCount, CreatedAt createdAt, @@ -37,45 +37,53 @@ private ProductMetric( ) { this.id = id; this.productId = productId; + this.likeCount = likeCount; this.totalSalesCount = totalSalesCount; this.viewCount = viewCount; this.createdAt = createdAt; this.updatedAt = updatedAt; } - public static ProductMetric init(ProductId productId) { - return new ProductMetric( + public static DailyProductMetric init(ProductId productId) { + return new DailyProductMetric( ProductMetricId.empty(), productId, + ProductLikeCount.init(), ProductTotalSalesCount.init(), ProductDetailViewCount.init(), CreatedAt.now(), - UpdatedAt.now() - ); + UpdatedAt.now()); } - public static ProductMetric mappedBy( + public static DailyProductMetric mappedBy( ProductMetricId id, ProductId productId, + ProductLikeCount productLikeCount, ProductTotalSalesCount totalSalesCount, ProductDetailViewCount viewCount, CreatedAt createdAt, UpdatedAt updatedAt ) { - return new ProductMetric(id, productId, totalSalesCount, viewCount, createdAt, updatedAt); + return new DailyProductMetric(id, productId, productLikeCount, totalSalesCount, viewCount, createdAt, updatedAt); } - public ProductMetric increaseViewCount() { + public DailyProductMetric increaseViewCount() { return this.toBuilder() .viewCount(this.viewCount.increase()) .updatedAt(UpdatedAt.now()) .build(); } - public ProductMetric increaseSalesCount(Quantity quantity) { + public DailyProductMetric increaseSalesCount(Quantity quantity) { return this.toBuilder() .totalSalesCount(this.totalSalesCount.increase(quantity)) .updatedAt(UpdatedAt.now()) .build(); } + + public DailyProductMetric increaseLikeCount() { + return this.toBuilder() + .likeCount(this.likeCount.increase()) + .build(); + } } diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java b/core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java index 0a738b788..cae96e645 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java @@ -1,13 +1,14 @@ package com.loopers.core.domain.product.repository; -import com.loopers.core.domain.product.ProductMetric; +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.product.DailyProductMetric; import com.loopers.core.domain.product.vo.ProductId; import java.util.Optional; public interface ProductMetricRepository { - Optional findByWithLock(ProductId productId); + Optional findByWithLock(ProductId productId, CreatedAt createdAt); - ProductMetric save(ProductMetric productMetric); + DailyProductMetric save(DailyProductMetric dailyProductMetric); } diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductMetricJpaRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductMetricJpaRepository.java index 129114888..f56766690 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductMetricJpaRepository.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductMetricJpaRepository.java @@ -1,16 +1,17 @@ package com.loopers.core.infra.database.mysql.product; -import com.loopers.core.infra.database.mysql.product.entity.ProductMetricEntity; +import com.loopers.core.infra.database.mysql.product.entity.DailyProductMetricEntity; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +import java.time.LocalDateTime; import java.util.Optional; -public interface ProductMetricJpaRepository extends JpaRepository { +public interface ProductMetricJpaRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select pme from ProductMetricEntity pme where pme.productId = :productId") - Optional findByProductIdWithLock(Long productId); + @Query("select pme from DailyProductMetricEntity pme where pme.productId = :productId and CAST(pme.createdAt AS date) = CAST(:createdAt AS date)") + Optional findByProductIdWithLock(Long productId, LocalDateTime createdAt); } diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/ProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java similarity index 85% rename from core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/ProductMetricEntity.java rename to core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java index a73b1ce90..a72a40f53 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/ProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java @@ -2,7 +2,7 @@ import com.loopers.core.domain.common.vo.CreatedAt; import com.loopers.core.domain.common.vo.UpdatedAt; -import com.loopers.core.domain.product.ProductMetric; +import com.loopers.core.domain.product.DailyProductMetric; import com.loopers.core.domain.product.vo.ProductDetailViewCount; import com.loopers.core.domain.product.vo.ProductId; import com.loopers.core.domain.product.vo.ProductMetricId; @@ -18,14 +18,14 @@ @Entity @Table( - name = "product_metrics", + name = "daily_product_metrics", indexes = { @Index(name = "idx_product_metric_product_id", columnList = "product_id") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class ProductMetricEntity { +public class DailyProductMetricEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -44,8 +44,8 @@ public class ProductMetricEntity { private LocalDateTime updatedAt; - public static ProductMetricEntity from(ProductMetric metric) { - return new ProductMetricEntity( + public static DailyProductMetricEntity from(DailyProductMetric metric) { + return new DailyProductMetricEntity( Optional.ofNullable(metric.getId().value()) .map(Long::parseLong) .orElse(null), @@ -57,8 +57,8 @@ public static ProductMetricEntity from(ProductMetric metric) { ); } - public ProductMetric to() { - return ProductMetric.mappedBy( + public DailyProductMetric to() { + return DailyProductMetric.mappedBy( new ProductMetricId(this.id.toString()), new ProductId(this.productId.toString()), new ProductTotalSalesCount(this.totalSalesCount), diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductMetricRepositoryImpl.java index 103e67adb..f0b39ce43 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductMetricRepositoryImpl.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductMetricRepositoryImpl.java @@ -1,10 +1,11 @@ package com.loopers.core.infra.database.mysql.product.impl; -import com.loopers.core.domain.product.ProductMetric; +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.product.DailyProductMetric; import com.loopers.core.domain.product.repository.ProductMetricRepository; import com.loopers.core.domain.product.vo.ProductId; import com.loopers.core.infra.database.mysql.product.ProductMetricJpaRepository; -import com.loopers.core.infra.database.mysql.product.entity.ProductMetricEntity; +import com.loopers.core.infra.database.mysql.product.entity.DailyProductMetricEntity; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -18,13 +19,13 @@ public class ProductMetricRepositoryImpl implements ProductMetricRepository { private final ProductMetricJpaRepository repository; @Override - public Optional findByWithLock(ProductId productId) { - return repository.findByProductIdWithLock(Long.parseLong(Objects.requireNonNull(productId.value()))) - .map(ProductMetricEntity::to); + public Optional findByWithLock(ProductId productId, CreatedAt createdAt) { + return repository.findByProductIdWithLock(Long.parseLong(Objects.requireNonNull(productId.value())), createdAt.value()) + .map(DailyProductMetricEntity::to); } @Override - public ProductMetric save(ProductMetric productMetric) { - return repository.save(ProductMetricEntity.from(productMetric)).to(); + public DailyProductMetric save(DailyProductMetric dailyProductMetric) { + return repository.save(DailyProductMetricEntity.from(dailyProductMetric)).to(); } } diff --git a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java new file mode 100644 index 000000000..7f084efde --- /dev/null +++ b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java @@ -0,0 +1,34 @@ +package com.loopers.core.service.product; + +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.product.DailyProductMetric; +import com.loopers.core.domain.product.repository.ProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductId; +import com.loopers.core.service.config.InboxEvent; +import com.loopers.core.service.product.command.IncreaseProductLikeMetricCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class IncreaseProductLikeMetricService { + + private final ProductMetricRepository productMetricRepository; + + @InboxEvent( + aggregateType = "PRODUCT", + eventType = "INCREASE_PRODUCT_METRIC_LIKE_COUNT", + eventIdField = "eventId", + aggregateIdField = "productId" + ) + @Transactional + public void increase(IncreaseProductLikeMetricCommand command) { + DailyProductMetric metric = productMetricRepository.findByWithLock(new ProductId(command.productId()), new CreatedAt(LocalDateTime.now())) + .orElse(DailyProductMetric.init(new ProductId(command.productId()))); + + productMetricRepository.save(metric.increaseLikeCount()); + } +} diff --git a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java index d4a4a0a65..d11779a31 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java +++ b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java @@ -1,6 +1,7 @@ package com.loopers.core.service.product; -import com.loopers.core.domain.product.ProductMetric; +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.product.DailyProductMetric; import com.loopers.core.domain.product.repository.ProductMetricRepository; import com.loopers.core.domain.product.vo.ProductId; import com.loopers.core.service.config.InboxEvent; @@ -9,6 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor public class IncreaseProductMetricViewCountService { @@ -19,12 +22,12 @@ public class IncreaseProductMetricViewCountService { aggregateType = "PRODUCT", eventType = "INCREASE_PRODUCT_VIEW_COUNT", eventIdField = "eventId", - aggregateIdField = "id" + aggregateIdField = "productId" ) @Transactional - public ProductMetric increase(IncreaseProductMetricViewCountCommand command) { - ProductMetric metric = productMetricRepository.findByWithLock(new ProductId(command.productId())) - .orElse(ProductMetric.init(new ProductId(command.productId()))); + public DailyProductMetric increase(IncreaseProductMetricViewCountCommand command) { + DailyProductMetric metric = productMetricRepository.findByWithLock(new ProductId(command.productId()), new CreatedAt(LocalDateTime.now())) + .orElse(DailyProductMetric.init(new ProductId(command.productId()))); return productMetricRepository.save(metric.increaseViewCount()); } diff --git a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java index 461507be4..c854a334c 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java +++ b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java @@ -1,12 +1,13 @@ package com.loopers.core.service.product; +import com.loopers.core.domain.common.vo.CreatedAt; import com.loopers.core.domain.order.Order; import com.loopers.core.domain.order.repository.OrderItemRepository; import com.loopers.core.domain.order.repository.OrderRepository; import com.loopers.core.domain.payment.Payment; import com.loopers.core.domain.payment.repository.PaymentRepository; import com.loopers.core.domain.payment.vo.PaymentId; -import com.loopers.core.domain.product.ProductMetric; +import com.loopers.core.domain.product.DailyProductMetric; import com.loopers.core.domain.product.repository.ProductMetricRepository; import com.loopers.core.service.config.InboxEvent; import com.loopers.core.service.product.command.IncreaseProductTotalSalesCommand; @@ -14,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor public class IncreaseProductTotalSalesService { @@ -34,8 +37,8 @@ public void increase(IncreaseProductTotalSalesCommand command) { Payment payment = paymentRepository.getById(new PaymentId(command.paymentId())); Order order = orderRepository.getBy(payment.getOrderKey()); orderItemRepository.findAllByOrderId(order.getId()).forEach(orderItem -> { - ProductMetric metric = productMetricRepository.findByWithLock(orderItem.getProductId()) - .orElse(ProductMetric.init(orderItem.getProductId())); + DailyProductMetric metric = productMetricRepository.findByWithLock(orderItem.getProductId(), new CreatedAt(LocalDateTime.now())) + .orElse(DailyProductMetric.init(orderItem.getProductId())); productMetricRepository.save(metric.increaseSalesCount(orderItem.getQuantity())); }); diff --git a/core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductLikeMetricCommand.java b/core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductLikeMetricCommand.java new file mode 100644 index 000000000..6153129dc --- /dev/null +++ b/core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductLikeMetricCommand.java @@ -0,0 +1,7 @@ +package com.loopers.core.service.product.command; + +public record IncreaseProductLikeMetricCommand( + String eventId, + String productId +) { +} From 72603de74b1871c8e296e0d1b76e0411b38d7643 Mon Sep 17 00:00:00 2001 From: kilian Date: Sun, 28 Dec 2025 22:00:15 +0900 Subject: [PATCH 02/13] =?UTF-8?q?fix=20:=20entity=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mysql/product/entity/DailyProductMetricEntity.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java index a72a40f53..29f95fe70 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java @@ -3,10 +3,7 @@ import com.loopers.core.domain.common.vo.CreatedAt; import com.loopers.core.domain.common.vo.UpdatedAt; import com.loopers.core.domain.product.DailyProductMetric; -import com.loopers.core.domain.product.vo.ProductDetailViewCount; -import com.loopers.core.domain.product.vo.ProductId; -import com.loopers.core.domain.product.vo.ProductMetricId; -import com.loopers.core.domain.product.vo.ProductTotalSalesCount; +import com.loopers.core.domain.product.vo.*; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -34,6 +31,9 @@ public class DailyProductMetricEntity { @Column(nullable = false) private Long productId; + @Column(nullable = false) + private Long likeCount; + @Column(nullable = false) private Long totalSalesCount; @@ -50,6 +50,7 @@ public static DailyProductMetricEntity from(DailyProductMetric metric) { .map(Long::parseLong) .orElse(null), Long.parseLong(Objects.requireNonNull(metric.getProductId().value())), + metric.getLikeCount().value(), metric.getTotalSalesCount().value(), metric.getViewCount().value(), metric.getCreatedAt().value(), @@ -61,6 +62,7 @@ public DailyProductMetric to() { return DailyProductMetric.mappedBy( new ProductMetricId(this.id.toString()), new ProductId(this.productId.toString()), + new ProductLikeCount(this.likeCount), new ProductTotalSalesCount(this.totalSalesCount), new ProductDetailViewCount(this.viewCount), new CreatedAt(this.createdAt), From ab12a68b12f022ce722b8de1d0da3f6f307ee036 Mon Sep 17 00:00:00 2001 From: kilian Date: Mon, 29 Dec 2025 01:52:50 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feature=20:=20weekly,=20monthly=20metric?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/domain/common/vo/YearMonthWeek.java | 26 ++++++ .../domain/product/MonthlyProductMetric.java | 75 ++++++++++++++++ .../domain/product/WeeklyProductMetric.java | 76 ++++++++++++++++ .../MonthlyProductMetricRepository.java | 10 +++ .../WeeklyProductMetricRepository.java | 10 +++ .../product/vo/MonthlyProductMetricId.java | 8 ++ .../product/vo/WeeklyProductMetricId.java | 8 ++ .../core/domain/product/vo/YearMonth.java | 4 + .../MonthlyProductMetricJpaRepository.java | 7 ++ .../WeeklyProductMetricJpaRepository.java | 7 ++ .../entity/DailyProductMetricEntity.java | 1 + .../entity/MonthlyProductMetricEntity.java | 82 +++++++++++++++++ .../entity/WeeklyProductMetricEntity.java | 87 +++++++++++++++++++ .../MonthlyProductMetricRepositoryImpl.java | 18 ++++ .../WeeklyProductMetricRepositoryImpl.java | 16 ++++ 15 files changed, 435 insertions(+) create mode 100644 core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/vo/MonthlyProductMetricId.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/vo/WeeklyProductMetricId.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricJpaRepository.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricJpaRepository.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java diff --git a/core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java b/core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java new file mode 100644 index 000000000..3edf71dcc --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java @@ -0,0 +1,26 @@ +package com.loopers.core.domain.common.vo; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.WeekFields; +import java.util.Locale; + +public record YearMonthWeek( + Integer year, Integer month, Integer weekOfYear +) { + + public static YearMonthWeek from(LocalDate date) { + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + int weekOfYear = date.get(weekFields.weekOfYear()); + + return new YearMonthWeek( + date.getYear(), + date.getMonthValue(), + weekOfYear + ); + } + + public static YearMonthWeek from(LocalDateTime dateTime) { + return from(dateTime.toLocalDate()); + } +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java b/core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java new file mode 100644 index 000000000..3f2b25332 --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java @@ -0,0 +1,75 @@ +package com.loopers.core.domain.product; + +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.common.vo.UpdatedAt; +import com.loopers.core.domain.product.vo.*; +import lombok.Getter; + +@Getter +public class MonthlyProductMetric { + + private final MonthlyProductMetricId id; + + private final ProductId productId; + + private final ProductLikeCount likeCount; + + private final ProductDetailViewCount viewCount; + + private final ProductTotalSalesCount totalSalesCount; + + private final YearMonth yearMonth; + + private final CreatedAt createdAt; + + private final UpdatedAt updatedAt; + + private MonthlyProductMetric( + MonthlyProductMetricId id, + ProductId productId, + ProductLikeCount likeCount, + ProductDetailViewCount viewCount, + ProductTotalSalesCount totalSalesCount, + YearMonth yearMonth, + CreatedAt createdAt, + UpdatedAt updatedAt + ) { + this.id = id; + this.productId = productId; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.totalSalesCount = totalSalesCount; + this.yearMonth = yearMonth; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static MonthlyProductMetric create( + ProductId productId, + YearMonth yearMonth + ) { + return new MonthlyProductMetric( + MonthlyProductMetricId.empty(), + productId, + ProductLikeCount.init(), + ProductDetailViewCount.init(), + ProductTotalSalesCount.init(), + yearMonth, + CreatedAt.now(), + UpdatedAt.now() + ); + } + + public static MonthlyProductMetric mappedBy( + MonthlyProductMetricId id, + ProductId productId, + ProductLikeCount likeCount, + ProductDetailViewCount viewCount, + ProductTotalSalesCount totalSalesCount, + YearMonth yearMonth, + CreatedAt createdAt, + UpdatedAt updatedAt + ) { + return new MonthlyProductMetric(id, productId, likeCount, viewCount, totalSalesCount, yearMonth, createdAt, updatedAt); + } +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java b/core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java new file mode 100644 index 000000000..3f0f59194 --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java @@ -0,0 +1,76 @@ +package com.loopers.core.domain.product; + +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.common.vo.UpdatedAt; +import com.loopers.core.domain.common.vo.YearMonthWeek; +import com.loopers.core.domain.product.vo.*; +import lombok.Getter; + +@Getter +public class WeeklyProductMetric { + + private final WeeklyProductMetricId id; + + private final ProductId productId; + + private final ProductLikeCount likeCount; + + private final ProductDetailViewCount viewCount; + + private final ProductTotalSalesCount totalSalesCount; + + private final YearMonthWeek yearMonthWeek; + + private final CreatedAt createdAt; + + private final UpdatedAt updatedAt; + + private WeeklyProductMetric( + WeeklyProductMetricId id, + ProductId productId, + ProductLikeCount likeCount, + ProductDetailViewCount viewCount, + ProductTotalSalesCount totalSalesCount, + YearMonthWeek yearMonthWeek, + CreatedAt createdAt, + UpdatedAt updatedAt + ) { + this.id = id; + this.productId = productId; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.totalSalesCount = totalSalesCount; + this.yearMonthWeek = yearMonthWeek; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static WeeklyProductMetric create( + ProductId productId, + YearMonthWeek yearMonthWeek + ) { + return new WeeklyProductMetric( + WeeklyProductMetricId.empty(), + productId, + ProductLikeCount.init(), + ProductDetailViewCount.init(), + ProductTotalSalesCount.init(), + yearMonthWeek, + CreatedAt.now(), + UpdatedAt.now() + ); + } + + public static WeeklyProductMetric mappedBy( + WeeklyProductMetricId id, + ProductId productId, + ProductLikeCount likeCount, + ProductDetailViewCount viewCount, + ProductTotalSalesCount totalSalesCount, + YearMonthWeek yearMonthWeek, + CreatedAt createdAt, + UpdatedAt updatedAt + ) { + return new WeeklyProductMetric(id, productId, likeCount, viewCount, totalSalesCount, yearMonthWeek, createdAt, updatedAt); + } +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java b/core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java new file mode 100644 index 000000000..a128cb9b6 --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java @@ -0,0 +1,10 @@ +package com.loopers.core.domain.product.repository; + +import com.loopers.core.domain.product.MonthlyProductMetric; + +import java.util.List; + +public interface MonthlyProductMetricRepository { + + void bulkUpsert(List metrics); +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java b/core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java new file mode 100644 index 000000000..442a7baec --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java @@ -0,0 +1,10 @@ +package com.loopers.core.domain.product.repository; + +import com.loopers.core.domain.product.WeeklyProductMetric; + +import java.util.List; + +public interface WeeklyProductMetricRepository { + + void bulkUpsert(List weeklyProductMetrics); +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/vo/MonthlyProductMetricId.java b/core/domain/src/main/java/com/loopers/core/domain/product/vo/MonthlyProductMetricId.java new file mode 100644 index 000000000..e47e4e8c4 --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/vo/MonthlyProductMetricId.java @@ -0,0 +1,8 @@ +package com.loopers.core.domain.product.vo; + +public record MonthlyProductMetricId(String value) { + + public static MonthlyProductMetricId empty() { + return new MonthlyProductMetricId(null); + } +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/vo/WeeklyProductMetricId.java b/core/domain/src/main/java/com/loopers/core/domain/product/vo/WeeklyProductMetricId.java new file mode 100644 index 000000000..83ef25664 --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/vo/WeeklyProductMetricId.java @@ -0,0 +1,8 @@ +package com.loopers.core.domain.product.vo; + +public record WeeklyProductMetricId(String value) { + + public static WeeklyProductMetricId empty() { + return new WeeklyProductMetricId(null); + } +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java b/core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java new file mode 100644 index 000000000..f4a73a2f2 --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java @@ -0,0 +1,4 @@ +package com.loopers.core.domain.product.vo; + +public record YearMonth(Integer year, Integer month) { +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricJpaRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricJpaRepository.java new file mode 100644 index 000000000..adb117624 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.entity.MonthlyProductMetricEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MonthlyProductMetricJpaRepository extends JpaRepository { +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricJpaRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricJpaRepository.java new file mode 100644 index 000000000..d23980850 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.entity.WeeklyProductMetricEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WeeklyProductMetricJpaRepository extends JpaRepository { +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java index 29f95fe70..6d7952210 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java @@ -40,6 +40,7 @@ public class DailyProductMetricEntity { @Column(nullable = false) private Long viewCount; + @Column(nullable = false) private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java new file mode 100644 index 000000000..bfbe86eb8 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java @@ -0,0 +1,82 @@ +package com.loopers.core.infra.database.mysql.product.entity; + +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.common.vo.UpdatedAt; +import com.loopers.core.domain.product.MonthlyProductMetric; +import com.loopers.core.domain.product.vo.*; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Optional; + +@Entity +@Table( + name = "monthly_product_metrics", + indexes = { + @Index(name = "idx_product_metric_product_id", columnList = "product_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MonthlyProductMetricEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer year; + + @Column(nullable = false) + private Integer month; + + @Column(nullable = false) + private Long likeCount; + + @Column(nullable = false) + private Long totalSalesCount; + + @Column(nullable = false) + private Long viewCount; + + @Column(nullable = false) + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + public static MonthlyProductMetricEntity from(MonthlyProductMetric metric) { + return new MonthlyProductMetricEntity( + Optional.ofNullable(metric.getProductId().value()) + .map(Long::parseLong) + .orElse(null), + Long.parseLong(Objects.requireNonNull(metric.getProductId().value())), + metric.getYearMonth().year(), + metric.getYearMonth().month(), + metric.getLikeCount().value(), + metric.getTotalSalesCount().value(), + metric.getViewCount().value(), + metric.getCreatedAt().value(), + metric.getUpdatedAt().value() + ); + } + + public MonthlyProductMetric to() { + return MonthlyProductMetric.mappedBy( + new MonthlyProductMetricId(this.id.toString()), + new ProductId(this.productId.toString()), + new ProductLikeCount(this.likeCount), + new ProductDetailViewCount(this.viewCount), + new ProductTotalSalesCount(this.totalSalesCount), + new YearMonth(this.year, this.month), + new CreatedAt(this.createdAt), + new UpdatedAt(this.updatedAt) + ); + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java new file mode 100644 index 000000000..b1cc07ca2 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java @@ -0,0 +1,87 @@ +package com.loopers.core.infra.database.mysql.product.entity; + +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.common.vo.UpdatedAt; +import com.loopers.core.domain.common.vo.YearMonthWeek; +import com.loopers.core.domain.product.WeeklyProductMetric; +import com.loopers.core.domain.product.vo.*; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Optional; + +@Entity +@Table( + name = "weekly_product_metrics", + indexes = { + @Index(name = "idx_product_metric_product_id", columnList = "product_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class WeeklyProductMetricEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer year; + + @Column(nullable = false) + private Integer month; + + @Column(nullable = false) + private Integer weekOfYear; + + @Column(nullable = false) + private Long likeCount; + + @Column(nullable = false) + private Long totalSalesCount; + + @Column(nullable = false) + private Long viewCount; + + @Column(nullable = false) + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + public static WeeklyProductMetricEntity from(WeeklyProductMetric metric) { + return new WeeklyProductMetricEntity( + Optional.ofNullable(metric.getProductId().value()) + .map(Long::parseLong) + .orElse(null), + Long.parseLong(Objects.requireNonNull(metric.getProductId().value())), + metric.getYearMonthWeek().year(), + metric.getYearMonthWeek().month(), + metric.getYearMonthWeek().weekOfYear(), + metric.getLikeCount().value(), + metric.getTotalSalesCount().value(), + metric.getViewCount().value(), + metric.getCreatedAt().value(), + metric.getUpdatedAt().value() + ); + } + + public WeeklyProductMetric to() { + return WeeklyProductMetric.mappedBy( + new WeeklyProductMetricId(this.id.toString()), + new ProductId(this.productId.toString()), + new ProductLikeCount(this.likeCount), + new ProductDetailViewCount(this.viewCount), + new ProductTotalSalesCount(this.totalSalesCount), + new YearMonthWeek(this.year, this.month, this.weekOfYear), + new CreatedAt(this.createdAt), + new UpdatedAt(this.updatedAt) + ); + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java new file mode 100644 index 000000000..36c0e540f --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.core.infra.database.mysql.product.impl; + +import com.loopers.core.domain.product.MonthlyProductMetric; +import com.loopers.core.domain.product.repository.MonthlyProductMetricRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class MonthlyProductMetricRepositoryImpl implements MonthlyProductMetricRepository { + + @Override + public void bulkUpsert(List metrics) { + + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java new file mode 100644 index 000000000..35a41e7a4 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java @@ -0,0 +1,16 @@ +package com.loopers.core.infra.database.mysql.product.impl; + +import com.loopers.core.domain.product.WeeklyProductMetric; +import com.loopers.core.domain.product.repository.WeeklyProductMetricRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class WeeklyProductMetricRepositoryImpl implements WeeklyProductMetricRepository { + + @Override + public void bulkUpsert(List weeklyProductMetrics) { + + } +} From f7b2134e42017e0221d4d3ed7b7ffa5a4c1b1711 Mon Sep 17 00:00:00 2001 From: kilian Date: Wed, 31 Dec 2025 00:10:21 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feature=20:=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=A7=91=EA=B3=84=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-batch/build.gradle.kts | 26 +++ .../com/loopers/CommerceBatchApplication.java | 14 ++ .../WeeklyProductMetricBatchPartitioner.java | 55 +++++ .../WeeklyProductMetricBatchReader.java | 41 ++++ .../WeeklyProductMetricBatchWriter.java | 38 +++ .../product/WeeklyProductMetricScheduler.java | 39 ++++ .../WeeklyProductMetricBatchConfig.java | 89 +++++++ .../src/main/resources/application.yml | 86 +++++++ .../application/batch/IntegrationTest.java | 21 ++ .../WeeklyProductMetricSchedulerTest.java | 220 ++++++++++++++++++ .../src/test/resources/application-test.yml | 57 +++++ .../core/domain/common/vo/YearMonthWeek.java | 25 ++ .../domain/product/DailyProductMetric.java | 34 +-- .../domain/product/WeeklyProductMetric.java | 35 +-- .../DailyProductMetricRepository.java | 23 ++ .../repository/ProductMetricRepository.java | 14 -- .../product/vo/DailyProductMetricId.java | 8 + .../product/vo/ProductMetricAggregation.java | 21 ++ .../domain/product/vo/ProductMetricId.java | 8 - .../product/DailyProductMetricFixture.java | 67 ++++++ .../mysql-config/src/main/resources/jpa.yml | 2 - .../DailyProductMetricJpaRepository.java | 47 ++++ .../product/ProductMetricJpaRepository.java | 17 -- .../WeeklyProductMetricBulkRepository.java | 10 + ...WeeklyProductMetricBulkRepositoryImpl.java | 54 +++++ .../entity/DailyProductMetricEntity.java | 2 +- .../entity/WeeklyProductMetricEntity.java | 4 +- .../DailyProductMetricRepositoryImpl.java | 54 +++++ .../impl/ProductMetricRepositoryImpl.java | 31 --- .../WeeklyProductMetricRepositoryImpl.java | 11 +- .../IncreaseProductLikeMetricService.java | 8 +- ...IncreaseProductMetricViewCountService.java | 8 +- .../IncreaseProductTotalSalesService.java | 8 +- settings.gradle.kts | 2 + 34 files changed, 1036 insertions(+), 143 deletions(-) create mode 100644 apps/commerce-batch/build.gradle.kts create mode 100644 apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchPartitioner.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricScheduler.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml create mode 100644 apps/commerce-batch/src/test/java/com/loopers/application/batch/IntegrationTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/application/batch/product/WeeklyProductMetricSchedulerTest.java create mode 100644 apps/commerce-batch/src/test/resources/application-test.yml create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/repository/DailyProductMetricRepository.java delete mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/vo/DailyProductMetricId.java create mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricAggregation.java delete mode 100644 core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricId.java create mode 100644 core/domain/src/testFixtures/java/com/loopers/core/domain/product/DailyProductMetricFixture.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/DailyProductMetricJpaRepository.java delete mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductMetricJpaRepository.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepository.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepositoryImpl.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/DailyProductMetricRepositoryImpl.java delete mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductMetricRepositoryImpl.java diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..0957b29f2 --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,26 @@ +dependencies { + implementation(project(":core:infra:database:mysql:mysql-config")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + //service + implementation(project(":core:service")) + + //domain + implementation(project(":core:domain")) + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + + //batch + implementation("org.springframework.boot:spring-boot-starter-batch") + + implementation("org.springframework:spring-tx") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // test-fixtures + testImplementation(project(":core:infra:database:mysql:mysql-config")) + testImplementation(testFixtures(project(":core:domain"))) + testImplementation(testFixtures(project(":core:infra:database:mysql"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..1265bd37a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,14 @@ +package com.loopers; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@SpringBootApplication +public class CommerceBatchApplication { + + public static void main(String[] args) { + SpringApplication.run(CommerceBatchApplication.class, args); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchPartitioner.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchPartitioner.java new file mode 100644 index 000000000..318ec8e5b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchPartitioner.java @@ -0,0 +1,55 @@ +package com.loopers.application.batch.product; + +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.partition.support.Partitioner; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Component +@StepScope +@RequiredArgsConstructor +public class WeeklyProductMetricBatchPartitioner implements Partitioner { + + private final DailyProductMetricRepository dailyProductMetricRepository; + + @Value("#{jobParameters['startDate']}") + private String startDateParam; + + @Value("#{jobParameters['endDate']}") + private String endDateParam; + + @Override + public Map partition(int gridSize) { + LocalDate startDate = LocalDate.parse(startDateParam); + LocalDate endDate = LocalDate.parse(endDateParam); + Long totalCount = dailyProductMetricRepository.countDistinctProductIdsBy(startDate, endDate); + + if (totalCount == 0) { + return Collections.emptyMap(); + } + + long targetSize = (totalCount / gridSize) + 1; + Map partitions = new HashMap<>(); + + for (int i = 0; i < gridSize; i++) { + ExecutionContext context = new ExecutionContext(); + + context.putLong("partitionOffset", i * targetSize); + context.putLong("partitionLimit", targetSize); + context.putString("startDate", startDateParam); + context.putString("endDate", endDateParam); + + partitions.put("partition" + i, context); + } + + return partitions; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchReader.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchReader.java new file mode 100644 index 000000000..0dc0ee31b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchReader.java @@ -0,0 +1,41 @@ +package com.loopers.application.batch.product; + +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductMetricAggregation; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamReader; +import org.springframework.lang.Nullable; + +import java.time.LocalDate; +import java.util.Iterator; +import java.util.Objects; + +@RequiredArgsConstructor +public class WeeklyProductMetricBatchReader implements ItemStreamReader { + + private final DailyProductMetricRepository dailyProductMetricRepository; + private Iterator iterator; + + @Override + public void open(@NonNull ExecutionContext executionContext) { + LocalDate startDate = LocalDate.parse(executionContext.getString("startDate")); + LocalDate endDate = LocalDate.parse(executionContext.getString("endDate")); + long partitionOffset = executionContext.getLong("partitionOffset"); + long partitionLimit = executionContext.getLong("partitionLimit"); + + this.iterator = dailyProductMetricRepository.findAggregatedBy(startDate, endDate, partitionOffset, partitionLimit) + .iterator(); + } + + @Nullable + @Override + public ProductMetricAggregation read() { + if (Objects.isNull(iterator) || !iterator.hasNext()) { + return null; + } + + return iterator.next(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java new file mode 100644 index 000000000..79f8a954c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java @@ -0,0 +1,38 @@ +package com.loopers.application.batch.product; + +import com.loopers.core.domain.common.vo.YearMonthWeek; +import com.loopers.core.domain.product.WeeklyProductMetric; +import com.loopers.core.domain.product.repository.WeeklyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductMetricAggregation; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamWriter; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class WeeklyProductMetricBatchWriter implements ItemStreamWriter { + + private final WeeklyProductMetricRepository repository; + private YearMonthWeek yearMonthWeek; + + @Override + public void open(@NonNull ExecutionContext executionContext) { + LocalDate startDate = LocalDate.parse(executionContext.getString("startDate")); + this.yearMonthWeek = YearMonthWeek.from(startDate); + } + + @Override + public void write(@NonNull Chunk chunk) { + List weeklyMetrics = chunk.getItems().stream() + .map(aggregation -> aggregation.to(yearMonthWeek)) + .toList(); + + repository.bulkUpsert(weeklyMetrics); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricScheduler.java new file mode 100644 index 000000000..808133357 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricScheduler.java @@ -0,0 +1,39 @@ +package com.loopers.application.batch.product; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; +import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; +import org.springframework.batch.core.repository.JobRestartException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.temporal.ChronoField; + +@Component +@RequiredArgsConstructor +public class WeeklyProductMetricScheduler { + + private final JobLauncher jobLauncher; + private final Job weeklyProductMetricJob; + + @Scheduled(cron = "0 0 2 ? * MON") + public void run() throws JobInstanceAlreadyCompleteException, JobExecutionAlreadyRunningException, JobParametersInvalidException, JobRestartException { + LocalDate now = LocalDate.now(); + LocalDate lastMonday = now.minusDays(1).with(ChronoField.DAY_OF_WEEK, 1); + LocalDate lastSunday = now.minusDays(1); + + JobParameters jobParameters = new JobParametersBuilder() + .addString("startDate", lastMonday.toString()) + .addString("endDate", lastSunday.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(weeklyProductMetricJob, jobParameters); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java new file mode 100644 index 000000000..0af62feba --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java @@ -0,0 +1,89 @@ +package com.loopers.application.batch.product.config; + +import com.loopers.application.batch.product.WeeklyProductMetricBatchPartitioner; +import com.loopers.application.batch.product.WeeklyProductMetricBatchReader; +import com.loopers.application.batch.product.WeeklyProductMetricBatchWriter; +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductMetricAggregation; +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.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemStreamReader; +import org.springframework.batch.item.support.builder.SynchronizedItemStreamReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.dao.DataAccessException; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class WeeklyProductMetricBatchConfig { + + @Bean + public Job weeklyProductMetricJob( + JobRepository jobRepository, + Step partitionDailyMetricStep + ) { + return new JobBuilder("weeklyProductMetricJob", jobRepository) + .start(partitionDailyMetricStep) + .build(); + } + + @Bean + public Step partitionDailyMetricStep( + JobRepository jobRepository, + Step collectDailyMetricStep, + WeeklyProductMetricBatchPartitioner partitioner, + TaskExecutor asyncTaskExecutor, + @Value("${batch.weekly-product-metric.partition.grid-size:4}") int gridSize + ) { + return new StepBuilder("partitionDailyMetricStep", jobRepository) + .partitioner("collectDailyMetricStep", partitioner) + .step(collectDailyMetricStep) + .gridSize(gridSize) + .taskExecutor(asyncTaskExecutor) + .build(); + } + + @Bean + @StepScope + public ItemStreamReader synchronizedWeeklyProductMetricReader( + DailyProductMetricRepository dailyProductMetricRepository + ) { + WeeklyProductMetricBatchReader reader = new WeeklyProductMetricBatchReader(dailyProductMetricRepository); + return new SynchronizedItemStreamReaderBuilder() + .delegate(reader) + .build(); + } + + @Bean + public Step collectDailyMetricStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ItemStreamReader synchronizedWeeklyProductMetricReader, + WeeklyProductMetricBatchWriter weeklyProductMetricBatchWriter, + @Value("${batch.weekly-product-metric.chunk:50}") int chunk + ) { + return new StepBuilder("collectDailyMetricStep", jobRepository) + .chunk(chunk, transactionManager) + .reader(synchronizedWeeklyProductMetricReader) + .writer(weeklyProductMetricBatchWriter) + .taskExecutor(asyncTaskExecutor()) + .faultTolerant() + .retry(DataAccessException.class) + .build(); + } + + @Bean + public TaskExecutor asyncTaskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("weekly-product-metric-batch-"); + executor.setVirtualThreads(true); + executor.setConcurrencyLimit(20); + return executor; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..186439561 --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,86 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-batch + profiles: + active: local + kafka: + topic: + product-detail-viewed: product-detail-viewed.v1 + product-out-of-stock: product-out-of-stock.v1 + payment-completed: payment-completed.v1 + product-like: product-like.v1 + config: + import: + - jpa.yml + - logging.yml + - monitoring.yml + batch: + jdbc: + initialize-schema: always + job: + enabled: false +http-client: + pg-simulator: + url: http://localhost:8082 + path: /api/v1/payments + +pg: + callback: + url: http://localhost:8080/api/v1/payments/callback + +product: + ranking: + score: + weight: + like: 0.2 + view: 0.1 + pay: 0.7 + carryOver: 0.1 + +batch: + weekly-product-metric: + chunk: 100 + partition: + grid-size: 4 + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false diff --git a/apps/commerce-batch/src/test/java/com/loopers/application/batch/IntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/application/batch/IntegrationTest.java new file mode 100644 index 000000000..f865508e1 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/application/batch/IntegrationTest.java @@ -0,0 +1,21 @@ +package com.loopers.application.batch; + +import com.loopers.core.infra.mysql.testcontainers.MySqlTestContainersExtension; +import com.loopers.core.infra.mysql.util.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@ExtendWith(MySqlTestContainersExtension.class) +public class IntegrationTest { + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void cleanup() { + databaseCleanUp.truncateAllTables(); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/application/batch/product/WeeklyProductMetricSchedulerTest.java b/apps/commerce-batch/src/test/java/com/loopers/application/batch/product/WeeklyProductMetricSchedulerTest.java new file mode 100644 index 000000000..cf012a620 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/application/batch/product/WeeklyProductMetricSchedulerTest.java @@ -0,0 +1,220 @@ +package com.loopers.application.batch.product; + +import com.loopers.application.batch.IntegrationTest; +import com.loopers.core.domain.product.DailyProductMetric; +import com.loopers.core.domain.product.DailyProductMetricFixture; +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("주간 상품 메트릭 배치") +class WeeklyProductMetricSchedulerTest extends IntegrationTest { + + @Autowired + private DailyProductMetricRepository dailyProductMetricRepository; + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + private Job weeklyProductMetricJob; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Nested + @DisplayName("배치 작업 실행") + class BatchJobExecution { + + @Test + @DisplayName("저번주 월요일부터 일요일까지의 일일 메트릭을 주간 메트릭으로 집계한다") + void shouldAggregateWeeklyMetricFromDailyMetric() throws Exception { + // given + LocalDate startDate = LocalDate.of(2025, 1, 6); // 월요일 + LocalDate endDate = LocalDate.of(2025, 1, 12); // 일요일 + + // 저번주 데이터 준비 (5개 상품 × 7일 = 35개 메트릭) + List dailyMetrics = new ArrayList<>(); + for (int productNum = 1; productNum <= 5; productNum++) { + for (int dayOffset = 0; dayOffset < 7; dayOffset++) { + LocalDateTime createdAt = startDate.plusDays(dayOffset).atStartOfDay(); + DailyProductMetric metric = DailyProductMetricFixture.createWith( + String.valueOf(productNum), + 10L + dayOffset, // likeCount + 20L + dayOffset, // viewCount + 30L + dayOffset, // totalSalesCount + createdAt + ); + dailyMetrics.add(metric); + } + } + + // 데이터 저장 + dailyProductMetricRepository.saveAll(dailyMetrics); + + // when + JobParameters jobParameters = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(weeklyProductMetricJob, jobParameters); + + // then + Integer savedWeeklyMetrics = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM weekly_product_metrics", + Integer.class + ); + assertThat(savedWeeklyMetrics).isEqualTo(5); + } + + @Test + @DisplayName("파티션 배치가 올바르게 분할되어 동시 처리된다") + void shouldPartitionDataCorrectly() throws Exception { + // given + LocalDate startDate = LocalDate.of(2025, 1, 6); + LocalDate endDate = LocalDate.of(2025, 1, 12); + + // 100개 상품 × 7일 = 700개 메트릭 + List dailyMetrics = new ArrayList<>(); + for (int productNum = 1; productNum <= 100; productNum++) { + for (int dayOffset = 0; dayOffset < 7; dayOffset++) { + LocalDateTime createdAt = startDate.plusDays(dayOffset).atStartOfDay(); + DailyProductMetric metric = DailyProductMetricFixture.createWith( + String.valueOf(productNum), + productNum, + (long) productNum * 2, + (long) productNum * 3, + createdAt + ); + dailyMetrics.add(metric); + } + } + + // 데이터 저장 + dailyProductMetricRepository.saveAll(dailyMetrics); + + // when + JobParameters jobParameters = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(weeklyProductMetricJob, jobParameters); + + // then - 100개 상품 모두 저장되었는지 확인 + Integer savedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM weekly_product_metrics", + Integer.class + ); + assertThat(savedCount).isEqualTo(100); + + // 중복 없이 각 상품별 1개씩만 저장되었는지 확인 + Integer distinctProductCount = jdbcTemplate.queryForObject( + "SELECT COUNT(DISTINCT product_id) FROM weekly_product_metrics", + Integer.class + ); + assertThat(distinctProductCount).isEqualTo(100); + } + + @Test + @DisplayName("메트릭 값이 올바르게 합산되어 저장된다") + void shouldSumMetricsCorrectly() throws Exception { + LocalDate startDate = LocalDate.of(2025, 1, 6); + LocalDate endDate = LocalDate.of(2025, 1, 12); + +// // 상품 1: 7일 동안 좋아요 10씩 (총 70) + List dailyMetrics = new ArrayList<>(); + for (int day = 0; day < 7; day++) { + LocalDateTime createdAt = startDate.plusDays(day).atStartOfDay(); + DailyProductMetric metric = DailyProductMetricFixture.createWith("1", 10, 20, 30, createdAt); + dailyMetrics.add(metric); + } + dailyProductMetricRepository.saveAll(dailyMetrics); + + // when + JobParameters jobParameters = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(weeklyProductMetricJob, jobParameters); + + // then + Long likeCount = jdbcTemplate.queryForObject( + "SELECT like_count FROM weekly_product_metrics WHERE product_id = 1", + Long.class + ); + Long viewCount = jdbcTemplate.queryForObject( + "SELECT view_count FROM weekly_product_metrics WHERE product_id = 1", + Long.class + ); + Long salesCount = jdbcTemplate.queryForObject( + "SELECT total_sales_count FROM weekly_product_metrics WHERE product_id = 1", + Long.class + ); + + assertThat(likeCount).isEqualTo(70L); // 10 * 7 + assertThat(viewCount).isEqualTo(140L); // 20 * 7 + assertThat(salesCount).isEqualTo(210L); // 30 * 7 + } + + @Test + @DisplayName("UPSERT가 정상 동작하여 중복 저장을 방지한다") + void shouldHandleUpsertCorrectly() throws Exception { + // given + LocalDate startDate = LocalDate.of(2025, 1, 6); + LocalDate endDate = LocalDate.of(2025, 1, 12); + + // 초기 데이터: 상품 1에 대한 7일 메트릭 저장 + List dailyMetrics = new ArrayList<>(); + for (int day = 0; day < 7; day++) { + LocalDateTime createdAt = startDate.plusDays(day).atStartOfDay(); + DailyProductMetric metric = DailyProductMetricFixture.createWith("1", 10, 20, 30, createdAt); + dailyMetrics.add(metric); + } + dailyProductMetricRepository.saveAll(dailyMetrics); + + // when - 첫 번째 배치 실행 + JobParameters jobParameters1 = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + jobLauncher.run(weeklyProductMetricJob, jobParameters1); + + // 같은 데이터로 두 번째 배치 실행 + JobParameters jobParameters2 = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis() + 1000) + .toJobParameters(); + jobLauncher.run(weeklyProductMetricJob, jobParameters2); + + // then - 2개가 아닌 1개만 저장되어야 함 + Integer savedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM weekly_product_metrics WHERE product_id = 1", + Integer.class + ); + assertThat(savedCount).isEqualTo(1); + } + } +} + diff --git a/apps/commerce-batch/src/test/resources/application-test.yml b/apps/commerce-batch/src/test/resources/application-test.yml new file mode 100644 index 000000000..8fd2e6e72 --- /dev/null +++ b/apps/commerce-batch/src/test/resources/application-test.yml @@ -0,0 +1,57 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-batch + batch: + jdbc: + initialize-schema: always + job: + enabled: false + kafka: + topic: + product-detail-viewed: product-detail-viewed.v1 + product-out-of-stock: product-out-of-stock.v1 + payment-completed: payment-completed.v1 + product-like: product-like.v1 + config: + import: + - jpa.yml + - logging.yml + - monitoring.yml + +http-client: + pg-simulator: + url: http://localhost:8082 + path: /api/v1/payments + +pg: + callback: + url: http://localhost:8080/api/v1/payments/callback + +product: + ranking: + score: + weight: + like: 0.2 + view: 0.1 + pay: 0.7 + carryOver: 0.1 + +batch: + weekly-product-metric: + chunk: 100 + partition: + grid-size: 4 diff --git a/core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java b/core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java index 3edf71dcc..fab58318a 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java +++ b/core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.temporal.ChronoField; import java.time.temporal.WeekFields; import java.util.Locale; @@ -23,4 +24,28 @@ public static YearMonthWeek from(LocalDate date) { public static YearMonthWeek from(LocalDateTime dateTime) { return from(dateTime.toLocalDate()); } + + public LocalDate getWeekStartDate() { + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + LocalDate date = LocalDate.of(this.year, this.month, 1); + + return date.with(weekFields.weekOfYear(), this.weekOfYear) + .with(ChronoField.DAY_OF_WEEK, 1); + } + + public LocalDate getWeekEndDate() { + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + LocalDate date = LocalDate.of(this.year, this.month, 1); + + return date.with(weekFields.weekOfYear(), this.weekOfYear) + .with(ChronoField.DAY_OF_WEEK, 7); + } + + public LocalDateTime getWeekStartDateTime() { + return this.getWeekStartDate().atStartOfDay(); + } + + public LocalDateTime getWeekEndDateTime() { + return this.getWeekEndDate().atTime(23, 59, 59); + } } diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java b/core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java index db23f13a6..9e0499ff1 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java @@ -5,48 +5,26 @@ import com.loopers.core.domain.order.vo.Quantity; import com.loopers.core.domain.product.vo.*; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @Getter +@Builder(access = AccessLevel.PRIVATE, toBuilder = true) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class DailyProductMetric { - private final ProductMetricId id; - + private final DailyProductMetricId id; private final ProductId productId; - private final ProductLikeCount likeCount; - private final ProductTotalSalesCount totalSalesCount; - private final ProductDetailViewCount viewCount; - private final CreatedAt createdAt; - private final UpdatedAt updatedAt; - @Builder(access = AccessLevel.PRIVATE, toBuilder = true) - private DailyProductMetric( - ProductMetricId id, - ProductId productId, - ProductLikeCount likeCount, - ProductTotalSalesCount totalSalesCount, - ProductDetailViewCount viewCount, - CreatedAt createdAt, - UpdatedAt updatedAt - ) { - this.id = id; - this.productId = productId; - this.likeCount = likeCount; - this.totalSalesCount = totalSalesCount; - this.viewCount = viewCount; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - } - public static DailyProductMetric init(ProductId productId) { return new DailyProductMetric( - ProductMetricId.empty(), + DailyProductMetricId.empty(), productId, ProductLikeCount.init(), ProductTotalSalesCount.init(), @@ -56,7 +34,7 @@ public static DailyProductMetric init(ProductId productId) { } public static DailyProductMetric mappedBy( - ProductMetricId id, + DailyProductMetricId id, ProductId productId, ProductLikeCount productLikeCount, ProductTotalSalesCount totalSalesCount, diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java b/core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java index 3f0f59194..92f307ac4 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java @@ -4,57 +4,36 @@ import com.loopers.core.domain.common.vo.UpdatedAt; import com.loopers.core.domain.common.vo.YearMonthWeek; import com.loopers.core.domain.product.vo.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; @Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class WeeklyProductMetric { private final WeeklyProductMetricId id; - private final ProductId productId; - private final ProductLikeCount likeCount; - private final ProductDetailViewCount viewCount; - private final ProductTotalSalesCount totalSalesCount; - private final YearMonthWeek yearMonthWeek; - private final CreatedAt createdAt; - private final UpdatedAt updatedAt; - private WeeklyProductMetric( - WeeklyProductMetricId id, + public static WeeklyProductMetric create( ProductId productId, ProductLikeCount likeCount, ProductDetailViewCount viewCount, ProductTotalSalesCount totalSalesCount, - YearMonthWeek yearMonthWeek, - CreatedAt createdAt, - UpdatedAt updatedAt - ) { - this.id = id; - this.productId = productId; - this.likeCount = likeCount; - this.viewCount = viewCount; - this.totalSalesCount = totalSalesCount; - this.yearMonthWeek = yearMonthWeek; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - } - - public static WeeklyProductMetric create( - ProductId productId, YearMonthWeek yearMonthWeek ) { return new WeeklyProductMetric( WeeklyProductMetricId.empty(), productId, - ProductLikeCount.init(), - ProductDetailViewCount.init(), - ProductTotalSalesCount.init(), + likeCount, + viewCount, + totalSalesCount, yearMonthWeek, CreatedAt.now(), UpdatedAt.now() diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/repository/DailyProductMetricRepository.java b/core/domain/src/main/java/com/loopers/core/domain/product/repository/DailyProductMetricRepository.java new file mode 100644 index 000000000..adde1e176 --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/repository/DailyProductMetricRepository.java @@ -0,0 +1,23 @@ +package com.loopers.core.domain.product.repository; + +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.product.DailyProductMetric; +import com.loopers.core.domain.product.vo.ProductId; +import com.loopers.core.domain.product.vo.ProductMetricAggregation; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface DailyProductMetricRepository { + + Optional findByWithLock(ProductId productId, CreatedAt createdAt); + + DailyProductMetric save(DailyProductMetric dailyProductMetric); + + Long countDistinctProductIdsBy(LocalDate startDate, LocalDate endDate); + + List findAggregatedBy(LocalDate startDate, LocalDate endDate, long partitionOffset, long partitionLimit); + + List saveAll(List dailyProductMetrics); +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java b/core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java deleted file mode 100644 index cae96e645..000000000 --- a/core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.loopers.core.domain.product.repository; - -import com.loopers.core.domain.common.vo.CreatedAt; -import com.loopers.core.domain.product.DailyProductMetric; -import com.loopers.core.domain.product.vo.ProductId; - -import java.util.Optional; - -public interface ProductMetricRepository { - - Optional findByWithLock(ProductId productId, CreatedAt createdAt); - - DailyProductMetric save(DailyProductMetric dailyProductMetric); -} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/vo/DailyProductMetricId.java b/core/domain/src/main/java/com/loopers/core/domain/product/vo/DailyProductMetricId.java new file mode 100644 index 000000000..b07791e6d --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/vo/DailyProductMetricId.java @@ -0,0 +1,8 @@ +package com.loopers.core.domain.product.vo; + +public record DailyProductMetricId(String value) { + + public static DailyProductMetricId empty() { + return new DailyProductMetricId(null); + } +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricAggregation.java b/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricAggregation.java new file mode 100644 index 000000000..115673682 --- /dev/null +++ b/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricAggregation.java @@ -0,0 +1,21 @@ +package com.loopers.core.domain.product.vo; + +import com.loopers.core.domain.common.vo.YearMonthWeek; +import com.loopers.core.domain.product.WeeklyProductMetric; + +public record ProductMetricAggregation( + String productId, + Long totalLikeCount, + Long totalViewCount, + Long totalSalesCount +) { + public WeeklyProductMetric to(YearMonthWeek yearMonthWeek) { + return WeeklyProductMetric.create( + new ProductId(this.productId()), + new ProductLikeCount(this.totalLikeCount()), + new ProductDetailViewCount(this.totalViewCount()), + new ProductTotalSalesCount(this.totalSalesCount()), + yearMonthWeek + ); + } +} diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricId.java b/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricId.java deleted file mode 100644 index 4536143a0..000000000 --- a/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricId.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.core.domain.product.vo; - -public record ProductMetricId(String value) { - - public static ProductMetricId empty() { - return new ProductMetricId(null); - } -} diff --git a/core/domain/src/testFixtures/java/com/loopers/core/domain/product/DailyProductMetricFixture.java b/core/domain/src/testFixtures/java/com/loopers/core/domain/product/DailyProductMetricFixture.java new file mode 100644 index 000000000..c02d305b4 --- /dev/null +++ b/core/domain/src/testFixtures/java/com/loopers/core/domain/product/DailyProductMetricFixture.java @@ -0,0 +1,67 @@ +package com.loopers.core.domain.product; + +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.common.vo.UpdatedAt; +import com.loopers.core.domain.product.vo.*; +import org.instancio.Instancio; + +import java.time.LocalDateTime; + +import static org.instancio.Select.field; + +public class DailyProductMetricFixture { + + public static DailyProductMetric create() { + return Instancio.of(DailyProductMetric.class) + .set(field(DailyProductMetric::getId), DailyProductMetricId.empty()) + .set(field(DailyProductMetric::getProductId), new ProductId("1")) + .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(0L)) + .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(0L)) + .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(0L)) + .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(LocalDateTime.now())) + .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(LocalDateTime.now())) + .create(); + } + + public static DailyProductMetric createWith(String productId) { + return Instancio.of(DailyProductMetric.class) + .set(field(DailyProductMetric::getId), DailyProductMetricId.empty()) + .set(field(DailyProductMetric::getProductId), new ProductId(productId)) + .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(0L)) + .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(0L)) + .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(0L)) + .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(LocalDateTime.now())) + .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(LocalDateTime.now())) + .create(); + } + + public static DailyProductMetric createWith(String productId, LocalDateTime createdAt) { + return Instancio.of(DailyProductMetric.class) + .set(field(DailyProductMetric::getId), DailyProductMetricId.empty()) + .set(field(DailyProductMetric::getProductId), new ProductId(productId)) + .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(0L)) + .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(0L)) + .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(0L)) + .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(createdAt)) + .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(createdAt)) + .create(); + } + + public static DailyProductMetric createWith( + String productId, + long likeCount, + long viewCount, + long totalSalesCount, + LocalDateTime createdAt + ) { + return Instancio.of(DailyProductMetric.class) + .set(field(DailyProductMetric::getId), DailyProductMetricId.empty()) + .set(field(DailyProductMetric::getProductId), new ProductId(productId)) + .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(likeCount)) + .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(totalSalesCount)) + .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(viewCount)) + .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(createdAt)) + .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(createdAt)) + .create(); + } +} diff --git a/core/infra/database/mysql/mysql-config/src/main/resources/jpa.yml b/core/infra/database/mysql/mysql-config/src/main/resources/jpa.yml index daf158409..f37deb88b 100644 --- a/core/infra/database/mysql/mysql-config/src/main/resources/jpa.yml +++ b/core/infra/database/mysql/mysql-config/src/main/resources/jpa.yml @@ -8,8 +8,6 @@ spring: properties: hibernate: default_batch_fetch_size: 100 - timezone.default_storage: NORMALIZE_UTC - jdbc.time_zone: UTC datasource: mysql-jpa: diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/DailyProductMetricJpaRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/DailyProductMetricJpaRepository.java new file mode 100644 index 000000000..8a1921a00 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/DailyProductMetricJpaRepository.java @@ -0,0 +1,47 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.domain.product.vo.ProductMetricAggregation; +import com.loopers.core.infra.database.mysql.product.entity.DailyProductMetricEntity; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface DailyProductMetricJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select pme from DailyProductMetricEntity pme where pme.productId = :productId and CAST(pme.createdAt AS date) = CAST(:createdAt AS date)") + Optional findByProductIdWithLock(Long productId, LocalDateTime createdAt); + + @Query("select count(distinct pme.productId) from DailyProductMetricEntity pme " + + "where cast(pme.createdAt as date) >= :startDate " + + "and cast(pme.createdAt as date) <= :endDate") + Long countDistinctProductIds( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + @Query("select new com.loopers.core.domain.product.vo.ProductMetricAggregation(" + + "cast(pme.productId as string), " + + "sum(pme.likeCount), " + + "sum(pme.viewCount), " + + "sum(pme.totalSalesCount)) " + + "from DailyProductMetricEntity pme " + + "where cast(pme.createdAt as date) >= :startDate " + + "and cast(pme.createdAt as date) <= :endDate " + + "group by pme.productId " + + "order by pme.productId " + + "limit :partitionLimit offset :partitionOffset") + List findAggregatedBy( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("partitionOffset") long partitionOffset, + @Param("partitionLimit") long partitionLimit + ); +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductMetricJpaRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductMetricJpaRepository.java deleted file mode 100644 index f56766690..000000000 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductMetricJpaRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.core.infra.database.mysql.product; - -import com.loopers.core.infra.database.mysql.product.entity.DailyProductMetricEntity; -import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; - -import java.time.LocalDateTime; -import java.util.Optional; - -public interface ProductMetricJpaRepository extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select pme from DailyProductMetricEntity pme where pme.productId = :productId and CAST(pme.createdAt AS date) = CAST(:createdAt AS date)") - Optional findByProductIdWithLock(Long productId, LocalDateTime createdAt); -} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepository.java new file mode 100644 index 000000000..5e75b0e72 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepository.java @@ -0,0 +1,10 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.entity.WeeklyProductMetricEntity; + +import java.util.List; + +public interface WeeklyProductMetricBulkRepository { + + void bulkUpsert(List weeklyProductMetrics); +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepositoryImpl.java new file mode 100644 index 000000000..0b57af3e0 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.entity.WeeklyProductMetricEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +@Component +@RequiredArgsConstructor +public class WeeklyProductMetricBulkRepositoryImpl implements WeeklyProductMetricBulkRepository { + + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Override + public void bulkUpsert(List weeklyProductMetrics) { + if (Objects.isNull(weeklyProductMetrics) || weeklyProductMetrics.isEmpty()) { + return; + } + + String sql = """ + INSERT INTO weekly_product_metrics + (product_id, year, month, week_of_year, like_count, total_sales_count, view_count, created_at, updated_at) + VALUES + (:productId, :year, :month, :weekOfYear, :likeCount, :totalSalesCount, :viewCount, :createdAt, :updatedAt) + ON DUPLICATE KEY UPDATE + like_count = VALUES(like_count), + total_sales_count = VALUES(total_sales_count), + view_count = VALUES(view_count), + updated_at = VALUES(updated_at) + """; + + SqlParameterSource[] batch = weeklyProductMetrics.stream() + .map(metric -> new MapSqlParameterSource() + .addValue("productId", metric.getProductId()) + .addValue("year", metric.getYear()) + .addValue("month", metric.getMonth()) + .addValue("weekOfYear", metric.getWeekOfYear()) + .addValue("likeCount", metric.getLikeCount()) + .addValue("totalSalesCount", metric.getTotalSalesCount()) + .addValue("viewCount", metric.getViewCount()) + .addValue("createdAt", metric.getCreatedAt()) + .addValue("updatedAt", LocalDateTime.now()) + ) + .toArray(SqlParameterSource[]::new); + + jdbcTemplate.batchUpdate(sql, batch); + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java index 6d7952210..cf8568890 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java @@ -61,7 +61,7 @@ public static DailyProductMetricEntity from(DailyProductMetric metric) { public DailyProductMetric to() { return DailyProductMetric.mappedBy( - new ProductMetricId(this.id.toString()), + new DailyProductMetricId(this.id.toString()), new ProductId(this.productId.toString()), new ProductLikeCount(this.likeCount), new ProductTotalSalesCount(this.totalSalesCount), diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java index b1cc07ca2..baa2c46f2 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java @@ -8,12 +8,14 @@ import jakarta.persistence.*; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.Objects; import java.util.Optional; +@Getter @Entity @Table( name = "weekly_product_metrics", @@ -29,7 +31,7 @@ public class WeeklyProductMetricEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) + @Column(nullable = false, unique = true) private Long productId; @Column(nullable = false) diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/DailyProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/DailyProductMetricRepositoryImpl.java new file mode 100644 index 000000000..8dea977be --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/DailyProductMetricRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.loopers.core.infra.database.mysql.product.impl; + +import com.loopers.core.domain.common.vo.CreatedAt; +import com.loopers.core.domain.product.DailyProductMetric; +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductId; +import com.loopers.core.domain.product.vo.ProductMetricAggregation; +import com.loopers.core.infra.database.mysql.product.DailyProductMetricJpaRepository; +import com.loopers.core.infra.database.mysql.product.entity.DailyProductMetricEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class DailyProductMetricRepositoryImpl implements DailyProductMetricRepository { + + private final DailyProductMetricJpaRepository repository; + + @Override + public Optional findByWithLock(ProductId productId, CreatedAt createdAt) { + return repository.findByProductIdWithLock(Long.parseLong(Objects.requireNonNull(productId.value())), createdAt.value()) + .map(DailyProductMetricEntity::to); + } + + @Override + public DailyProductMetric save(DailyProductMetric dailyProductMetric) { + return repository.save(DailyProductMetricEntity.from(dailyProductMetric)).to(); + } + + @Override + public Long countDistinctProductIdsBy(LocalDate startDate, LocalDate endDate) { + return repository.countDistinctProductIds(startDate, endDate); + } + + @Override + public List findAggregatedBy(LocalDate startDate, LocalDate endDate, long partitionOffset, long partitionLimit) { + return repository.findAggregatedBy(startDate, endDate, partitionOffset, partitionLimit); + } + + @Override + public List saveAll(List dailyProductMetrics) { + return repository.saveAll(dailyProductMetrics.stream() + .map(DailyProductMetricEntity::from) + .toList() + ).stream() + .map(DailyProductMetricEntity::to) + .toList(); + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductMetricRepositoryImpl.java deleted file mode 100644 index f0b39ce43..000000000 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductMetricRepositoryImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.core.infra.database.mysql.product.impl; - -import com.loopers.core.domain.common.vo.CreatedAt; -import com.loopers.core.domain.product.DailyProductMetric; -import com.loopers.core.domain.product.repository.ProductMetricRepository; -import com.loopers.core.domain.product.vo.ProductId; -import com.loopers.core.infra.database.mysql.product.ProductMetricJpaRepository; -import com.loopers.core.infra.database.mysql.product.entity.DailyProductMetricEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -import java.util.Objects; -import java.util.Optional; - -@Repository -@RequiredArgsConstructor -public class ProductMetricRepositoryImpl implements ProductMetricRepository { - - private final ProductMetricJpaRepository repository; - - @Override - public Optional findByWithLock(ProductId productId, CreatedAt createdAt) { - return repository.findByProductIdWithLock(Long.parseLong(Objects.requireNonNull(productId.value())), createdAt.value()) - .map(DailyProductMetricEntity::to); - } - - @Override - public DailyProductMetric save(DailyProductMetric dailyProductMetric) { - return repository.save(DailyProductMetricEntity.from(dailyProductMetric)).to(); - } -} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java index 35a41e7a4..14f634fa0 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java @@ -2,15 +2,24 @@ import com.loopers.core.domain.product.WeeklyProductMetric; import com.loopers.core.domain.product.repository.WeeklyProductMetricRepository; +import com.loopers.core.infra.database.mysql.product.WeeklyProductMetricBulkRepository; +import com.loopers.core.infra.database.mysql.product.entity.WeeklyProductMetricEntity; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; @Repository +@RequiredArgsConstructor public class WeeklyProductMetricRepositoryImpl implements WeeklyProductMetricRepository { + private final WeeklyProductMetricBulkRepository bulkRepository; + @Override public void bulkUpsert(List weeklyProductMetrics) { - + List entities = weeklyProductMetrics.stream() + .map(WeeklyProductMetricEntity::from) + .toList(); + bulkRepository.bulkUpsert(entities); } } diff --git a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java index 7f084efde..0d4a82234 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java +++ b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java @@ -2,7 +2,7 @@ import com.loopers.core.domain.common.vo.CreatedAt; import com.loopers.core.domain.product.DailyProductMetric; -import com.loopers.core.domain.product.repository.ProductMetricRepository; +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; import com.loopers.core.domain.product.vo.ProductId; import com.loopers.core.service.config.InboxEvent; import com.loopers.core.service.product.command.IncreaseProductLikeMetricCommand; @@ -16,7 +16,7 @@ @RequiredArgsConstructor public class IncreaseProductLikeMetricService { - private final ProductMetricRepository productMetricRepository; + private final DailyProductMetricRepository dailyProductMetricRepository; @InboxEvent( aggregateType = "PRODUCT", @@ -26,9 +26,9 @@ public class IncreaseProductLikeMetricService { ) @Transactional public void increase(IncreaseProductLikeMetricCommand command) { - DailyProductMetric metric = productMetricRepository.findByWithLock(new ProductId(command.productId()), new CreatedAt(LocalDateTime.now())) + DailyProductMetric metric = dailyProductMetricRepository.findByWithLock(new ProductId(command.productId()), new CreatedAt(LocalDateTime.now())) .orElse(DailyProductMetric.init(new ProductId(command.productId()))); - productMetricRepository.save(metric.increaseLikeCount()); + dailyProductMetricRepository.save(metric.increaseLikeCount()); } } diff --git a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java index d11779a31..3f073be4e 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java +++ b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java @@ -2,7 +2,7 @@ import com.loopers.core.domain.common.vo.CreatedAt; import com.loopers.core.domain.product.DailyProductMetric; -import com.loopers.core.domain.product.repository.ProductMetricRepository; +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; import com.loopers.core.domain.product.vo.ProductId; import com.loopers.core.service.config.InboxEvent; import com.loopers.core.service.product.command.IncreaseProductMetricViewCountCommand; @@ -16,7 +16,7 @@ @RequiredArgsConstructor public class IncreaseProductMetricViewCountService { - private final ProductMetricRepository productMetricRepository; + private final DailyProductMetricRepository dailyProductMetricRepository; @InboxEvent( aggregateType = "PRODUCT", @@ -26,9 +26,9 @@ public class IncreaseProductMetricViewCountService { ) @Transactional public DailyProductMetric increase(IncreaseProductMetricViewCountCommand command) { - DailyProductMetric metric = productMetricRepository.findByWithLock(new ProductId(command.productId()), new CreatedAt(LocalDateTime.now())) + DailyProductMetric metric = dailyProductMetricRepository.findByWithLock(new ProductId(command.productId()), new CreatedAt(LocalDateTime.now())) .orElse(DailyProductMetric.init(new ProductId(command.productId()))); - return productMetricRepository.save(metric.increaseViewCount()); + return dailyProductMetricRepository.save(metric.increaseViewCount()); } } diff --git a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java index c854a334c..0d3b0d000 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java +++ b/core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java @@ -8,7 +8,7 @@ import com.loopers.core.domain.payment.repository.PaymentRepository; import com.loopers.core.domain.payment.vo.PaymentId; import com.loopers.core.domain.product.DailyProductMetric; -import com.loopers.core.domain.product.repository.ProductMetricRepository; +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; import com.loopers.core.service.config.InboxEvent; import com.loopers.core.service.product.command.IncreaseProductTotalSalesCommand; import lombok.RequiredArgsConstructor; @@ -24,7 +24,7 @@ public class IncreaseProductTotalSalesService { private final PaymentRepository paymentRepository; private final OrderRepository orderRepository; private final OrderItemRepository orderItemRepository; - private final ProductMetricRepository productMetricRepository; + private final DailyProductMetricRepository dailyProductMetricRepository; @InboxEvent( aggregateType = "PAYMENT", @@ -37,10 +37,10 @@ public void increase(IncreaseProductTotalSalesCommand command) { Payment payment = paymentRepository.getById(new PaymentId(command.paymentId())); Order order = orderRepository.getBy(payment.getOrderKey()); orderItemRepository.findAllByOrderId(order.getId()).forEach(orderItem -> { - DailyProductMetric metric = productMetricRepository.findByWithLock(orderItem.getProductId(), new CreatedAt(LocalDateTime.now())) + DailyProductMetric metric = dailyProductMetricRepository.findByWithLock(orderItem.getProductId(), new CreatedAt(LocalDateTime.now())) .orElse(DailyProductMetric.init(orderItem.getProductId())); - productMetricRepository.save(metric.increaseSalesCount(orderItem.getQuantity())); + dailyProductMetricRepository.save(metric.increaseSalesCount(orderItem.getQuantity())); }); } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 93b0ebab2..db52629e5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,3 +39,5 @@ pluginManagement { } } } + +include("apps:commerce-batch") \ No newline at end of file From 1e868e9b874dad6cb6fb9cce32f1666566a366d3 Mon Sep 17 00:00:00 2001 From: kilian Date: Wed, 31 Dec 2025 00:25:28 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feature=20:=20=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=A7=91=EA=B3=84=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonthlyProductMetricBatchPartitioner.java | 55 +++++ .../MonthlyProductMetricBatchReader.java | 39 ++++ .../MonthlyProductMetricBatchWriter.java | 38 +++ .../MonthlyProductMetricScheduler.java | 39 ++++ .../MonthlyProductMetricBatchConfig.java | 89 +++++++ .../src/main/resources/application.yml | 4 + .../MonthlyProductMetricSchedulerTest.java | 219 ++++++++++++++++++ .../src/test/resources/application-test.yml | 4 + .../domain/product/MonthlyProductMetric.java | 9 +- .../product/vo/ProductMetricAggregation.java | 11 + .../core/domain/product/vo/YearMonth.java | 11 + .../MonthlyProductMetricBulkRepository.java | 10 + ...onthlyProductMetricBulkRepositoryImpl.java | 53 +++++ .../entity/MonthlyProductMetricEntity.java | 4 +- .../MonthlyProductMetricRepositoryImpl.java | 10 +- 15 files changed, 589 insertions(+), 6 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchPartitioner.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricScheduler.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/application/batch/product/MonthlyProductMetricSchedulerTest.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepository.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepositoryImpl.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchPartitioner.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchPartitioner.java new file mode 100644 index 000000000..c143960ee --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchPartitioner.java @@ -0,0 +1,55 @@ +package com.loopers.application.batch.product; + +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.partition.support.Partitioner; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Component +@StepScope +@RequiredArgsConstructor +public class MonthlyProductMetricBatchPartitioner implements Partitioner { + + private final DailyProductMetricRepository dailyProductMetricRepository; + + @Value("#{jobParameters['startDate']}") + private String startDateParam; + + @Value("#{jobParameters['endDate']}") + private String endDateParam; + + @Override + public Map partition(int gridSize) { + LocalDate startDate = LocalDate.parse(startDateParam); + LocalDate endDate = LocalDate.parse(endDateParam); + Long totalCount = dailyProductMetricRepository.countDistinctProductIdsBy(startDate, endDate); + + if (totalCount == 0) { + return Collections.emptyMap(); + } + + long targetSize = (totalCount / gridSize) + 1; + Map partitions = new HashMap<>(); + + for (int i = 0; i < gridSize; i++) { + ExecutionContext context = new ExecutionContext(); + + context.putLong("partitionOffset", i * targetSize); + context.putLong("partitionLimit", targetSize); + context.putString("startDate", startDateParam); + context.putString("endDate", endDateParam); + + partitions.put("partition" + i, context); + } + + return partitions; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchReader.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchReader.java new file mode 100644 index 000000000..9764a32c5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchReader.java @@ -0,0 +1,39 @@ +package com.loopers.application.batch.product; + +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductMetricAggregation; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamReader; + +import java.time.LocalDate; +import java.util.Iterator; +import java.util.Objects; + +@RequiredArgsConstructor +public class MonthlyProductMetricBatchReader implements ItemStreamReader { + + private final DailyProductMetricRepository dailyProductMetricRepository; + private Iterator iterator; + + @Override + public void open(@NonNull ExecutionContext executionContext) { + LocalDate startDate = LocalDate.parse(executionContext.getString("startDate")); + LocalDate endDate = LocalDate.parse(executionContext.getString("endDate")); + long partitionOffset = executionContext.getLong("partitionOffset"); + long partitionLimit = executionContext.getLong("partitionLimit"); + + this.iterator = dailyProductMetricRepository.findAggregatedBy(startDate, endDate, partitionOffset, partitionLimit) + .iterator(); + } + + @Override + public ProductMetricAggregation read() { + if (Objects.isNull(iterator) || !iterator.hasNext()) { + return null; + } + + return iterator.next(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchWriter.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchWriter.java new file mode 100644 index 000000000..ccfb1b9ff --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchWriter.java @@ -0,0 +1,38 @@ +package com.loopers.application.batch.product; + +import com.loopers.core.domain.product.MonthlyProductMetric; +import com.loopers.core.domain.product.repository.MonthlyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductMetricAggregation; +import com.loopers.core.domain.product.vo.YearMonth; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamWriter; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MonthlyProductMetricBatchWriter implements ItemStreamWriter { + + private final MonthlyProductMetricRepository repository; + private YearMonth yearMonth; + + @Override + public void open(@NonNull ExecutionContext executionContext) { + LocalDate startDate = LocalDate.parse(executionContext.getString("startDate")); + this.yearMonth = YearMonth.from(startDate); + } + + @Override + public void write(@NonNull Chunk chunk) { + List monthlyMetrics = chunk.getItems().stream() + .map(aggregation -> aggregation.to(yearMonth)) + .toList(); + + repository.bulkUpsert(monthlyMetrics); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricScheduler.java new file mode 100644 index 000000000..1c7b8c568 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricScheduler.java @@ -0,0 +1,39 @@ +package com.loopers.application.batch.product; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; +import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; +import org.springframework.batch.core.repository.JobRestartException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.YearMonth; + +@Component +@RequiredArgsConstructor +public class MonthlyProductMetricScheduler { + + private final JobLauncher jobLauncher; + private final Job monthlyProductMetricJob; + + @Scheduled(cron = "0 0 2 1 * ?") + public void run() throws JobInstanceAlreadyCompleteException, JobExecutionAlreadyRunningException, JobParametersInvalidException, JobRestartException { + YearMonth lastMonth = YearMonth.now().minusMonths(1); + LocalDate startDate = lastMonth.atDay(1); + LocalDate endDate = lastMonth.atEndOfMonth(); + + JobParameters jobParameters = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyProductMetricJob, jobParameters); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java new file mode 100644 index 000000000..8d92cd107 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java @@ -0,0 +1,89 @@ +package com.loopers.application.batch.product.config; + +import com.loopers.application.batch.product.MonthlyProductMetricBatchPartitioner; +import com.loopers.application.batch.product.MonthlyProductMetricBatchReader; +import com.loopers.application.batch.product.MonthlyProductMetricBatchWriter; +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductMetricAggregation; +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.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemStreamReader; +import org.springframework.batch.item.support.builder.SynchronizedItemStreamReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.dao.DataAccessException; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class MonthlyProductMetricBatchConfig { + + @Bean + public Job monthlyProductMetricJob( + JobRepository jobRepository, + Step partitionMonthlyMetricStep + ) { + return new JobBuilder("monthlyProductMetricJob", jobRepository) + .start(partitionMonthlyMetricStep) + .build(); + } + + @Bean + public Step partitionMonthlyMetricStep( + JobRepository jobRepository, + Step collectMonthlyMetricStep, + MonthlyProductMetricBatchPartitioner partitioner, + TaskExecutor monthlyAsyncTaskExecutor, + @Value("${batch.monthly-product-metric.partition.grid-size:4}") int gridSize + ) { + return new StepBuilder("partitionMonthlyMetricStep", jobRepository) + .partitioner("collectMonthlyMetricStep", partitioner) + .step(collectMonthlyMetricStep) + .gridSize(gridSize) + .taskExecutor(monthlyAsyncTaskExecutor) + .build(); + } + + @Bean + @StepScope + public ItemStreamReader synchronizedMonthlyProductMetricReader( + DailyProductMetricRepository dailyProductMetricRepository + ) { + MonthlyProductMetricBatchReader reader = new MonthlyProductMetricBatchReader(dailyProductMetricRepository); + return new SynchronizedItemStreamReaderBuilder() + .delegate(reader) + .build(); + } + + @Bean + public Step collectMonthlyMetricStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ItemStreamReader synchronizedMonthlyProductMetricReader, + MonthlyProductMetricBatchWriter monthlyProductMetricBatchWriter, + @Value("${batch.monthly-product-metric.chunk:50}") int chunk + ) { + return new StepBuilder("collectMonthlyMetricStep", jobRepository) + .chunk(chunk, transactionManager) + .reader(synchronizedMonthlyProductMetricReader) + .writer(monthlyProductMetricBatchWriter) + .taskExecutor(monthlyAsyncTaskExecutor()) + .faultTolerant() + .retry(DataAccessException.class) + .build(); + } + + @Bean + public TaskExecutor monthlyAsyncTaskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("monthly-product-metric-batch-"); + executor.setVirtualThreads(true); + executor.setConcurrencyLimit(20); + return executor; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index 186439561..4ad4dea4f 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -56,6 +56,10 @@ batch: chunk: 100 partition: grid-size: 4 + monthly-product-metric: + chunk: 100 + partition: + grid-size: 4 --- spring: diff --git a/apps/commerce-batch/src/test/java/com/loopers/application/batch/product/MonthlyProductMetricSchedulerTest.java b/apps/commerce-batch/src/test/java/com/loopers/application/batch/product/MonthlyProductMetricSchedulerTest.java new file mode 100644 index 000000000..82e989e08 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/application/batch/product/MonthlyProductMetricSchedulerTest.java @@ -0,0 +1,219 @@ +package com.loopers.application.batch.product; + +import com.loopers.application.batch.IntegrationTest; +import com.loopers.core.domain.product.DailyProductMetric; +import com.loopers.core.domain.product.DailyProductMetricFixture; +import com.loopers.core.domain.product.repository.DailyProductMetricRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("월간 상품 메트릭 배치") +class MonthlyProductMetricSchedulerTest extends IntegrationTest { + + @Autowired + private DailyProductMetricRepository dailyProductMetricRepository; + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + private Job monthlyProductMetricJob; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Nested + @DisplayName("배치 작업 실행") + class BatchJobExecution { + + @Test + @DisplayName("지난달 1일부터 말일까지의 일일 메트릭을 월간 메트릭으로 집계한다") + void shouldAggregateMonthlyMetricFromDailyMetric() throws Exception { + // given + LocalDate startDate = LocalDate.of(2025, 1, 1); // 1월 1일 + LocalDate endDate = LocalDate.of(2025, 1, 31); // 1월 31일 + + // 1월 전체 데이터 준비 (5개 상품 × 31일 = 155개 메트릭) + List dailyMetrics = new ArrayList<>(); + for (int productNum = 1; productNum <= 5; productNum++) { + for (int dayOffset = 0; dayOffset < 31; dayOffset++) { + LocalDateTime createdAt = startDate.plusDays(dayOffset).atStartOfDay(); + DailyProductMetric metric = DailyProductMetricFixture.createWith( + String.valueOf(productNum), + 10L + dayOffset, // likeCount + 20L + dayOffset, // viewCount + 30L + dayOffset, // totalSalesCount + createdAt + ); + dailyMetrics.add(metric); + } + } + + // 데이터 저장 + dailyProductMetricRepository.saveAll(dailyMetrics); + + // when + JobParameters jobParameters = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyProductMetricJob, jobParameters); + + // then + Integer savedMonthlyMetrics = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM monthly_product_metrics", + Integer.class + ); + assertThat(savedMonthlyMetrics).isEqualTo(5); + } + + @Test + @DisplayName("파티션 배치가 올바르게 분할되어 동시 처리된다") + void shouldPartitionDataCorrectly() throws Exception { + // given + LocalDate startDate = LocalDate.of(2025, 1, 1); + LocalDate endDate = LocalDate.of(2025, 1, 31); + + // 100개 상품 × 31일 = 3100개 메트릭 + List dailyMetrics = new ArrayList<>(); + for (int productNum = 1; productNum <= 100; productNum++) { + for (int dayOffset = 0; dayOffset < 31; dayOffset++) { + LocalDateTime createdAt = startDate.plusDays(dayOffset).atStartOfDay(); + DailyProductMetric metric = DailyProductMetricFixture.createWith( + String.valueOf(productNum), + productNum, + (long) productNum * 2, + (long) productNum * 3, + createdAt + ); + dailyMetrics.add(metric); + } + } + + // 데이터 저장 + dailyProductMetricRepository.saveAll(dailyMetrics); + + // when + JobParameters jobParameters = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyProductMetricJob, jobParameters); + + // then - 100개 상품 모두 저장되었는지 확인 + Integer savedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM monthly_product_metrics", + Integer.class + ); + assertThat(savedCount).isEqualTo(100); + + // 중복 없이 각 상품별 1개씩만 저장되었는지 확인 + Integer distinctProductCount = jdbcTemplate.queryForObject( + "SELECT COUNT(DISTINCT product_id) FROM monthly_product_metrics", + Integer.class + ); + assertThat(distinctProductCount).isEqualTo(100); + } + + @Test + @DisplayName("메트릭 값이 올바르게 합산되어 저장된다") + void shouldSumMetricsCorrectly() throws Exception { + LocalDate startDate = LocalDate.of(2025, 1, 1); + LocalDate endDate = LocalDate.of(2025, 1, 31); + + // 상품 1: 31일 동안 좋아요 10씩 (총 310) + List dailyMetrics = new ArrayList<>(); + for (int day = 0; day < 31; day++) { + LocalDateTime createdAt = startDate.plusDays(day).atStartOfDay(); + DailyProductMetric metric = DailyProductMetricFixture.createWith("1", 10, 20, 30, createdAt); + dailyMetrics.add(metric); + } + dailyProductMetricRepository.saveAll(dailyMetrics); + + // when + JobParameters jobParameters = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyProductMetricJob, jobParameters); + + // then + Long likeCount = jdbcTemplate.queryForObject( + "SELECT like_count FROM monthly_product_metrics WHERE product_id = 1", + Long.class + ); + Long viewCount = jdbcTemplate.queryForObject( + "SELECT view_count FROM monthly_product_metrics WHERE product_id = 1", + Long.class + ); + Long salesCount = jdbcTemplate.queryForObject( + "SELECT total_sales_count FROM monthly_product_metrics WHERE product_id = 1", + Long.class + ); + + assertThat(likeCount).isEqualTo(310L); // 10 * 31 + assertThat(viewCount).isEqualTo(620L); // 20 * 31 + assertThat(salesCount).isEqualTo(930L); // 30 * 31 + } + + @Test + @DisplayName("UPSERT가 정상 동작하여 중복 저장을 방지한다") + void shouldHandleUpsertCorrectly() throws Exception { + // given + LocalDate startDate = LocalDate.of(2025, 1, 1); + LocalDate endDate = LocalDate.of(2025, 1, 31); + + // 초기 데이터: 상품 1에 대한 31일 메트릭 저장 + List dailyMetrics = new ArrayList<>(); + for (int day = 0; day < 31; day++) { + LocalDateTime createdAt = startDate.plusDays(day).atStartOfDay(); + DailyProductMetric metric = DailyProductMetricFixture.createWith("1", 10, 20, 30, createdAt); + dailyMetrics.add(metric); + } + dailyProductMetricRepository.saveAll(dailyMetrics); + + // when - 첫 번째 배치 실행 + JobParameters jobParameters1 = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + jobLauncher.run(monthlyProductMetricJob, jobParameters1); + + // 같은 데이터로 두 번째 배치 실행 + JobParameters jobParameters2 = new JobParametersBuilder() + .addString("startDate", startDate.toString()) + .addString("endDate", endDate.toString()) + .addLong("timestamp", System.currentTimeMillis() + 1000) + .toJobParameters(); + jobLauncher.run(monthlyProductMetricJob, jobParameters2); + + // then - 2개가 아닌 1개만 저장되어야 함 + Integer savedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM monthly_product_metrics WHERE product_id = 1", + Integer.class + ); + assertThat(savedCount).isEqualTo(1); + } + } +} diff --git a/apps/commerce-batch/src/test/resources/application-test.yml b/apps/commerce-batch/src/test/resources/application-test.yml index 8fd2e6e72..0035e0d17 100644 --- a/apps/commerce-batch/src/test/resources/application-test.yml +++ b/apps/commerce-batch/src/test/resources/application-test.yml @@ -55,3 +55,7 @@ batch: chunk: 100 partition: grid-size: 4 + monthly-product-metric: + chunk: 100 + partition: + grid-size: 4 diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java b/core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java index 3f2b25332..bd8b16a21 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java @@ -46,14 +46,17 @@ private MonthlyProductMetric( public static MonthlyProductMetric create( ProductId productId, + ProductLikeCount likeCount, + ProductDetailViewCount viewCount, + ProductTotalSalesCount totalSalesCount, YearMonth yearMonth ) { return new MonthlyProductMetric( MonthlyProductMetricId.empty(), productId, - ProductLikeCount.init(), - ProductDetailViewCount.init(), - ProductTotalSalesCount.init(), + likeCount, + viewCount, + totalSalesCount, yearMonth, CreatedAt.now(), UpdatedAt.now() diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricAggregation.java b/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricAggregation.java index 115673682..1e1897ee4 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricAggregation.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductMetricAggregation.java @@ -1,6 +1,7 @@ package com.loopers.core.domain.product.vo; import com.loopers.core.domain.common.vo.YearMonthWeek; +import com.loopers.core.domain.product.MonthlyProductMetric; import com.loopers.core.domain.product.WeeklyProductMetric; public record ProductMetricAggregation( @@ -18,4 +19,14 @@ public WeeklyProductMetric to(YearMonthWeek yearMonthWeek) { yearMonthWeek ); } + + public MonthlyProductMetric to(YearMonth yearMonth) { + return MonthlyProductMetric.create( + new ProductId(this.productId()), + new ProductLikeCount(this.totalLikeCount()), + new ProductDetailViewCount(this.totalViewCount()), + new ProductTotalSalesCount(this.totalSalesCount()), + yearMonth + ); + } } diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java b/core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java index f4a73a2f2..efdd541d7 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java @@ -1,4 +1,15 @@ package com.loopers.core.domain.product.vo; +import java.time.LocalDate; +import java.time.LocalDateTime; + public record YearMonth(Integer year, Integer month) { + + public static YearMonth from(LocalDate date) { + return new YearMonth(date.getYear(), date.getMonthValue()); + } + + public static YearMonth from(LocalDateTime dateTime) { + return from(dateTime.toLocalDate()); + } } diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepository.java new file mode 100644 index 000000000..16c52a725 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepository.java @@ -0,0 +1,10 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.entity.MonthlyProductMetricEntity; + +import java.util.List; + +public interface MonthlyProductMetricBulkRepository { + + void bulkUpsert(List monthlyProductMetrics); +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepositoryImpl.java new file mode 100644 index 000000000..2d93b5a47 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepositoryImpl.java @@ -0,0 +1,53 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.entity.MonthlyProductMetricEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +@Component +@RequiredArgsConstructor +public class MonthlyProductMetricBulkRepositoryImpl implements MonthlyProductMetricBulkRepository { + + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Override + public void bulkUpsert(List monthlyProductMetrics) { + if (Objects.isNull(monthlyProductMetrics) || monthlyProductMetrics.isEmpty()) { + return; + } + + String sql = """ + INSERT INTO monthly_product_metrics + (product_id, year, month, like_count, total_sales_count, view_count, created_at, updated_at) + VALUES + (:productId, :year, :month, :likeCount, :totalSalesCount, :viewCount, :createdAt, :updatedAt) + ON DUPLICATE KEY UPDATE + like_count = VALUES(like_count), + total_sales_count = VALUES(total_sales_count), + view_count = VALUES(view_count), + updated_at = VALUES(updated_at) + """; + + SqlParameterSource[] batch = monthlyProductMetrics.stream() + .map(metric -> new MapSqlParameterSource() + .addValue("productId", metric.getProductId()) + .addValue("year", metric.getYear()) + .addValue("month", metric.getMonth()) + .addValue("likeCount", metric.getLikeCount()) + .addValue("totalSalesCount", metric.getTotalSalesCount()) + .addValue("viewCount", metric.getViewCount()) + .addValue("createdAt", metric.getCreatedAt()) + .addValue("updatedAt", LocalDateTime.now()) + ) + .toArray(SqlParameterSource[]::new); + + jdbcTemplate.batchUpdate(sql, batch); + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java index bfbe86eb8..5d2f943df 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java @@ -7,12 +7,14 @@ import jakarta.persistence.*; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.Objects; import java.util.Optional; +@Getter @Entity @Table( name = "monthly_product_metrics", @@ -28,7 +30,7 @@ public class MonthlyProductMetricEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) + @Column(nullable = false, unique = true) private Long productId; @Column(nullable = false) diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java index 36c0e540f..3e9028ed6 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java @@ -2,6 +2,8 @@ import com.loopers.core.domain.product.MonthlyProductMetric; import com.loopers.core.domain.product.repository.MonthlyProductMetricRepository; +import com.loopers.core.infra.database.mysql.product.MonthlyProductMetricBulkRepository; +import com.loopers.core.infra.database.mysql.product.entity.MonthlyProductMetricEntity; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -11,8 +13,12 @@ @RequiredArgsConstructor public class MonthlyProductMetricRepositoryImpl implements MonthlyProductMetricRepository { - @Override - public void bulkUpsert(List metrics) { + private final MonthlyProductMetricBulkRepository bulkRepository; + @Override + public void bulkUpsert(List monthlyProductMetrics) { + bulkRepository.bulkUpsert(monthlyProductMetrics.stream() + .map(MonthlyProductMetricEntity::from) + .toList()); } } From 4c473983d56de11b99de5ac48ac6a976c4b879b5 Mon Sep 17 00:00:00 2001 From: kilian Date: Wed, 31 Dec 2025 22:37:53 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feature=20:=20=ED=8C=8C=ED=8B=B0=EC=85=94?= =?UTF-8?q?=EB=8B=9D=ED=95=9C=20Step=EB=93=A4=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/product/config/MonthlyProductMetricBatchConfig.java | 2 -- .../batch/product/config/WeeklyProductMetricBatchConfig.java | 2 -- 2 files changed, 4 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java index 8d92cd107..d3b3341da 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java @@ -39,14 +39,12 @@ public Step partitionMonthlyMetricStep( JobRepository jobRepository, Step collectMonthlyMetricStep, MonthlyProductMetricBatchPartitioner partitioner, - TaskExecutor monthlyAsyncTaskExecutor, @Value("${batch.monthly-product-metric.partition.grid-size:4}") int gridSize ) { return new StepBuilder("partitionMonthlyMetricStep", jobRepository) .partitioner("collectMonthlyMetricStep", partitioner) .step(collectMonthlyMetricStep) .gridSize(gridSize) - .taskExecutor(monthlyAsyncTaskExecutor) .build(); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java index 0af62feba..958ab486d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java @@ -39,14 +39,12 @@ public Step partitionDailyMetricStep( JobRepository jobRepository, Step collectDailyMetricStep, WeeklyProductMetricBatchPartitioner partitioner, - TaskExecutor asyncTaskExecutor, @Value("${batch.weekly-product-metric.partition.grid-size:4}") int gridSize ) { return new StepBuilder("partitionDailyMetricStep", jobRepository) .partitioner("collectDailyMetricStep", partitioner) .step(collectDailyMetricStep) .gridSize(gridSize) - .taskExecutor(asyncTaskExecutor) .build(); } From c8fe05b0393cc4bf129cb909adcb8b3cc04f994e Mon Sep 17 00:00:00 2001 From: kilian Date: Thu, 1 Jan 2026 00:16:09 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feature=20:=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/api/product/ProductV1Api.java | 3 +- .../api/product/ProductV1ApiSpec.java | 2 +- .../WeeklyProductMetricRepository.java | 11 +++ .../WeeklyProductMetricJpaRepository.java | 2 +- ...WeeklyProductMetricQuerydslRepository.java | 18 +++++ ...lyProductMetricQuerydslRepositoryImpl.java | 72 +++++++++++++++++++ .../dto/WeeklyProductRankingProjection.java | 29 ++++++++ .../WeeklyProductMetricRepositoryImpl.java | 36 ++++++++++ .../GetDailyProductRankingsStrategy.java | 20 ++++++ .../component/GetProductRankingsStrategy.java | 10 +++ .../GetWeeklyProductRankingsStrategy.java | 32 +++++++++ .../product/query/GetProductRankingQuery.java | 1 + 12 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepository.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/WeeklyProductRankingProjection.java create mode 100644 core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java create mode 100644 core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java create mode 100644 core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.java b/apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.java index cf545e5b8..96717abab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.java @@ -66,10 +66,11 @@ public ApiResponse getProductDetail( @GetMapping("/rankings") public ApiResponse getProductRankings( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @RequestParam(required = false, defaultValue = "DAILY") String type, @RequestParam(required = false, defaultValue = "0") int pageNo, @RequestParam(required = false, defaultValue = "10") int pageSize ) { - ProductRankingList ranking = getProductRankingService.getRanking(new GetProductRankingQuery(date, pageNo, pageSize)); + ProductRankingList ranking = getProductRankingService.getRanking(new GetProductRankingQuery(date, type, pageNo, pageSize)); return ApiResponse.success(GetProductRankingsResponse.from(ranking)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java index a59ce5990..532483190 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java @@ -41,5 +41,5 @@ ApiResponse getProductList( summary = "상품 랭킹 조회", description = "상품 랭킹을 조회합니다." ) - ApiResponse getProductRankings(LocalDate date, int pageNo, int pageSize); + ApiResponse getProductRankings(LocalDate date, String type, int pageNo, int pageSize); } diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java b/core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java index 442a7baec..b276fac5f 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java @@ -1,10 +1,21 @@ package com.loopers.core.domain.product.repository; import com.loopers.core.domain.product.WeeklyProductMetric; +import com.loopers.core.domain.product.vo.ProductRankings; +import java.time.LocalDate; import java.util.List; public interface WeeklyProductMetricRepository { void bulkUpsert(List weeklyProductMetrics); + + ProductRankings findRankingsBy( + LocalDate date, + Integer pageNo, + Integer pageSize, + Double payWeight, + Double viewWeight, + Double likeWeight + ); } diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricJpaRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricJpaRepository.java index d23980850..cd1a4e15b 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricJpaRepository.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricJpaRepository.java @@ -3,5 +3,5 @@ import com.loopers.core.infra.database.mysql.product.entity.WeeklyProductMetricEntity; import org.springframework.data.jpa.repository.JpaRepository; -public interface WeeklyProductMetricJpaRepository extends JpaRepository { +public interface WeeklyProductMetricJpaRepository extends JpaRepository, WeeklyProductMetricQuerydslRepository { } diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepository.java new file mode 100644 index 000000000..3ce883db3 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepository.java @@ -0,0 +1,18 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.dto.WeeklyProductRankingProjection; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; + +public interface WeeklyProductMetricQuerydslRepository { + + Page findWeeklyProductRanking( + LocalDate date, + Double payWeight, + Double viewWeight, + Double likeWeight, + Pageable pageable + ); +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java new file mode 100644 index 000000000..baf1d5b32 --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java @@ -0,0 +1,72 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.dto.QWeeklyProductRankingProjection; +import com.loopers.core.infra.database.mysql.product.dto.WeeklyProductRankingProjection; +import com.loopers.core.infra.database.mysql.product.entity.QWeeklyProductMetricEntity; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.temporal.WeekFields; +import java.util.List; +import java.util.Locale; + +@Component +@RequiredArgsConstructor +public class WeeklyProductMetricQuerydslRepositoryImpl implements WeeklyProductMetricQuerydslRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findWeeklyProductRanking( + LocalDate date, + Double payWeight, + Double viewWeight, + Double likeWeight, + Pageable pageable + ) { + QWeeklyProductMetricEntity metric = QWeeklyProductMetricEntity.weeklyProductMetricEntity; + + WeekFields weekFields = WeekFields.of(Locale.KOREA); + int year = date.getYear(); + int month = date.getMonthValue(); + int weekOfYear = date.get(weekFields.weekOfYear()); + + NumberExpression scoreCalculation = metric.totalSalesCount.doubleValue().multiply(payWeight) + .add(metric.viewCount.doubleValue().multiply(viewWeight)) + .add(metric.likeCount.doubleValue().multiply(likeWeight)); + + List content = queryFactory + .select(new QWeeklyProductRankingProjection( + metric.productId, + Expressions.numberTemplate(Long.class, + "ROW_NUMBER() OVER (ORDER BY {0} DESC)", + scoreCalculation), + scoreCalculation.as("score") + )) + .from(metric) + .where(metric.year.eq(year) + .and(metric.month.eq(month)) + .and(metric.weekOfYear.eq(weekOfYear))) + .orderBy(scoreCalculation.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(metric.count()) + .from(metric) + .where(metric.year.eq(year) + .and(metric.month.eq(month)) + .and(metric.weekOfYear.eq(weekOfYear))); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/WeeklyProductRankingProjection.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/WeeklyProductRankingProjection.java new file mode 100644 index 000000000..6ea8ecf1f --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/WeeklyProductRankingProjection.java @@ -0,0 +1,29 @@ +package com.loopers.core.infra.database.mysql.product.dto; + +import com.loopers.core.domain.product.vo.ProductId; +import com.loopers.core.domain.product.vo.ProductRanking; +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +@Getter +public class WeeklyProductRankingProjection { + + private final Long productId; + private final Long ranking; + private final Double score; + + @QueryProjection + public WeeklyProductRankingProjection(Long productId, Long ranking, Double score) { + this.productId = productId; + this.ranking = ranking; + this.score = score; + } + + public ProductRanking to() { + return new ProductRanking( + new ProductId(this.productId.toString()), + ranking, + score + ); + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java index 14f634fa0..4bb24a0a0 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/WeeklyProductMetricRepositoryImpl.java @@ -2,17 +2,24 @@ import com.loopers.core.domain.product.WeeklyProductMetric; import com.loopers.core.domain.product.repository.WeeklyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductRankings; import com.loopers.core.infra.database.mysql.product.WeeklyProductMetricBulkRepository; +import com.loopers.core.infra.database.mysql.product.WeeklyProductMetricJpaRepository; +import com.loopers.core.infra.database.mysql.product.dto.WeeklyProductRankingProjection; import com.loopers.core.infra.database.mysql.product.entity.WeeklyProductMetricEntity; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; @Repository @RequiredArgsConstructor public class WeeklyProductMetricRepositoryImpl implements WeeklyProductMetricRepository { + private final WeeklyProductMetricJpaRepository repository; private final WeeklyProductMetricBulkRepository bulkRepository; @Override @@ -22,4 +29,33 @@ public void bulkUpsert(List weeklyProductMetrics) { .toList(); bulkRepository.bulkUpsert(entities); } + + @Override + public ProductRankings findRankingsBy( + LocalDate date, + Integer pageNo, + Integer pageSize, + Double payWeight, + Double viewWeight, + Double likeWeight + ) { + + Page page = repository.findWeeklyProductRanking( + date, + payWeight, + viewWeight, + likeWeight, + PageRequest.of(pageNo, pageSize) + ); + + return new ProductRankings( + page.stream() + .map(WeeklyProductRankingProjection::to) + .toList(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext(), + page.hasPrevious() + ); + } } diff --git a/core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java b/core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java new file mode 100644 index 000000000..3ad9ff9c4 --- /dev/null +++ b/core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java @@ -0,0 +1,20 @@ +package com.loopers.core.service.product.component; + +import com.loopers.core.domain.product.repository.ProductRankingCacheRepository; +import com.loopers.core.domain.product.vo.ProductRankings; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class GetDailyProductRankingsStrategy implements GetProductRankingsStrategy { + + private final ProductRankingCacheRepository productRankingCacheRepository; + + @Override + public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) { + return productRankingCacheRepository.getRankings(date, pageNo, pageSize); + } +} diff --git a/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java b/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java new file mode 100644 index 000000000..b370eef82 --- /dev/null +++ b/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java @@ -0,0 +1,10 @@ +package com.loopers.core.service.product.component; + +import com.loopers.core.domain.product.vo.ProductRankings; + +import java.time.LocalDate; + +public interface GetProductRankingsStrategy { + + ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize); +} diff --git a/core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java b/core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java new file mode 100644 index 000000000..7c2dd8571 --- /dev/null +++ b/core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java @@ -0,0 +1,32 @@ +package com.loopers.core.service.product.component; + +import com.loopers.core.domain.product.repository.WeeklyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductRankings; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class GetWeeklyProductRankingsStrategy implements GetProductRankingsStrategy { + + private final WeeklyProductMetricRepository weeklyProductMetricRepository; + + @Value("${product.ranking.score.weight.pay}") + private Double payWeight; + + @Value("${product.ranking.score.weight.view}") + private Double viewWeight; + + @Value("${product.ranking.score.weight.like}") + private Double likeWeight; + + @Override + public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) { + + + return null; + } +} diff --git a/core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java b/core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java index 8c4d7ea51..4fce01bca 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java +++ b/core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java @@ -4,6 +4,7 @@ public record GetProductRankingQuery( LocalDate date, + String type, int pageNo, int pageSize ) { From 8ed3b46625ad235bc7a43c4bf1de4e668a8d8522 Mon Sep 17 00:00:00 2001 From: kilian Date: Thu, 1 Jan 2026 00:22:35 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feature=20:=20=EC=9B=94=EA=B0=84=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonthlyProductMetricRepository.java | 11 ++++ .../MonthlyProductMetricJpaRepository.java | 2 +- ...onthlyProductMetricQuerydslRepository.java | 18 +++++ ...lyProductMetricQuerydslRepositoryImpl.java | 66 +++++++++++++++++++ .../MonthlyProductMetricRepositoryImpl.java | 36 ++++++++++ .../product/GetProductRankingService.java | 8 ++- .../GetDailyProductRankingsStrategy.java | 5 ++ .../GetMonthlyProductRankingsStrategy.java | 35 ++++++++++ .../component/GetProductRankingsStrategy.java | 2 + .../GetProductRankingsStrategySelector.java | 20 ++++++ .../GetWeeklyProductRankingsStrategy.java | 9 ++- 11 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepository.java create mode 100644 core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepositoryImpl.java create mode 100644 core/service/src/main/java/com/loopers/core/service/product/component/GetMonthlyProductRankingsStrategy.java create mode 100644 core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategySelector.java diff --git a/core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java b/core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java index a128cb9b6..a7aeb2b60 100644 --- a/core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java +++ b/core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java @@ -1,10 +1,21 @@ package com.loopers.core.domain.product.repository; import com.loopers.core.domain.product.MonthlyProductMetric; +import com.loopers.core.domain.product.vo.ProductRankings; +import java.time.LocalDate; import java.util.List; public interface MonthlyProductMetricRepository { void bulkUpsert(List metrics); + + ProductRankings findRankingsBy( + LocalDate date, + Integer pageNo, + Integer pageSize, + Double payWeight, + Double viewWeight, + Double likeWeight + ); } diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricJpaRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricJpaRepository.java index adb117624..fbc5a23f4 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricJpaRepository.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricJpaRepository.java @@ -3,5 +3,5 @@ import com.loopers.core.infra.database.mysql.product.entity.MonthlyProductMetricEntity; import org.springframework.data.jpa.repository.JpaRepository; -public interface MonthlyProductMetricJpaRepository extends JpaRepository { +public interface MonthlyProductMetricJpaRepository extends JpaRepository, MonthlyProductMetricQuerydslRepository { } diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepository.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepository.java new file mode 100644 index 000000000..28398a65f --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepository.java @@ -0,0 +1,18 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.dto.MonthlyProductRankingProjection; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; + +public interface MonthlyProductMetricQuerydslRepository { + + Page findMonthlyProductRanking( + LocalDate date, + Double payWeight, + Double viewWeight, + Double likeWeight, + Pageable pageable + ); +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepositoryImpl.java new file mode 100644 index 000000000..18f43488a --- /dev/null +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepositoryImpl.java @@ -0,0 +1,66 @@ +package com.loopers.core.infra.database.mysql.product; + +import com.loopers.core.infra.database.mysql.product.dto.QMonthlyProductRankingProjection; +import com.loopers.core.infra.database.mysql.product.dto.MonthlyProductRankingProjection; +import com.loopers.core.infra.database.mysql.product.entity.QMonthlyProductMetricEntity; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MonthlyProductMetricQuerydslRepositoryImpl implements MonthlyProductMetricQuerydslRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findMonthlyProductRanking( + LocalDate date, + Double payWeight, + Double viewWeight, + Double likeWeight, + Pageable pageable + ) { + QMonthlyProductMetricEntity metric = QMonthlyProductMetricEntity.monthlyProductMetricEntity; + + int year = date.getYear(); + int month = date.getMonthValue(); + + NumberExpression scoreCalculation = metric.totalSalesCount.doubleValue().multiply(payWeight) + .add(metric.viewCount.doubleValue().multiply(viewWeight)) + .add(metric.likeCount.doubleValue().multiply(likeWeight)); + + List content = queryFactory + .select(new QMonthlyProductRankingProjection( + metric.productId, + Expressions.numberTemplate(Long.class, + "ROW_NUMBER() OVER (ORDER BY {0} DESC)", + scoreCalculation), + scoreCalculation.as("score") + )) + .from(metric) + .where(metric.year.eq(year) + .and(metric.month.eq(month))) + .orderBy(scoreCalculation.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(metric.count()) + .from(metric) + .where(metric.year.eq(year) + .and(metric.month.eq(month))); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } +} diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java index 3e9028ed6..ec8c95c58 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java @@ -2,11 +2,17 @@ import com.loopers.core.domain.product.MonthlyProductMetric; import com.loopers.core.domain.product.repository.MonthlyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductRankings; import com.loopers.core.infra.database.mysql.product.MonthlyProductMetricBulkRepository; +import com.loopers.core.infra.database.mysql.product.MonthlyProductMetricJpaRepository; +import com.loopers.core.infra.database.mysql.product.dto.MonthlyProductRankingProjection; import com.loopers.core.infra.database.mysql.product.entity.MonthlyProductMetricEntity; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; @Repository @@ -14,6 +20,7 @@ public class MonthlyProductMetricRepositoryImpl implements MonthlyProductMetricRepository { private final MonthlyProductMetricBulkRepository bulkRepository; + private final MonthlyProductMetricJpaRepository repository; @Override public void bulkUpsert(List monthlyProductMetrics) { @@ -21,4 +28,33 @@ public void bulkUpsert(List monthlyProductMetrics) { .map(MonthlyProductMetricEntity::from) .toList()); } + + @Override + public ProductRankings findRankingsBy( + LocalDate date, + Integer pageNo, + Integer pageSize, + Double payWeight, + Double viewWeight, + Double likeWeight + ) { + + Page page = repository.findMonthlyProductRanking( + date, + payWeight, + viewWeight, + likeWeight, + PageRequest.of(pageNo, pageSize) + ); + + return new ProductRankings( + page.stream() + .map(MonthlyProductRankingProjection::to) + .toList(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext(), + page.hasPrevious() + ); + } } diff --git a/core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java b/core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java index ccc7bbfeb..9d66020fb 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java +++ b/core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java @@ -1,9 +1,10 @@ package com.loopers.core.service.product; import com.loopers.core.domain.product.ProductRankingList; -import com.loopers.core.domain.product.repository.ProductRankingCacheRepository; import com.loopers.core.domain.product.repository.ProductRepository; import com.loopers.core.domain.product.vo.ProductRankings; +import com.loopers.core.service.product.component.GetProductRankingsStrategy; +import com.loopers.core.service.product.component.GetProductRankingsStrategySelector; import com.loopers.core.service.product.query.GetProductRankingQuery; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,11 +13,12 @@ @RequiredArgsConstructor public class GetProductRankingService { - private final ProductRankingCacheRepository productRankingCacheRepository; + private final GetProductRankingsStrategySelector strategySelector; private final ProductRepository productRepository; public ProductRankingList getRanking(GetProductRankingQuery query) { - ProductRankings rankings = productRankingCacheRepository.getRankings(query.date(), query.pageNo(), query.pageSize()); + GetProductRankingsStrategy strategy = strategySelector.select(query.type()); + ProductRankings rankings = strategy.getRankings(query.date(), query.pageNo(), query.pageSize()); ProductRankingList rankingList = productRepository.findRankingListBy(rankings.getProducts()); return rankingList.with(rankings); diff --git a/core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java b/core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java index 3ad9ff9c4..3819e50df 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java +++ b/core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java @@ -13,6 +13,11 @@ public class GetDailyProductRankingsStrategy implements GetProductRankingsStrate private final ProductRankingCacheRepository productRankingCacheRepository; + @Override + public boolean supports(String type) { + return type.equals("DAILY"); + } + @Override public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) { return productRankingCacheRepository.getRankings(date, pageNo, pageSize); diff --git a/core/service/src/main/java/com/loopers/core/service/product/component/GetMonthlyProductRankingsStrategy.java b/core/service/src/main/java/com/loopers/core/service/product/component/GetMonthlyProductRankingsStrategy.java new file mode 100644 index 000000000..ac04ed6cb --- /dev/null +++ b/core/service/src/main/java/com/loopers/core/service/product/component/GetMonthlyProductRankingsStrategy.java @@ -0,0 +1,35 @@ +package com.loopers.core.service.product.component; + +import com.loopers.core.domain.product.repository.MonthlyProductMetricRepository; +import com.loopers.core.domain.product.vo.ProductRankings; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class GetMonthlyProductRankingsStrategy implements GetProductRankingsStrategy { + + private final MonthlyProductMetricRepository monthlyProductMetricRepository; + + @Value("${product.ranking.score.weight.pay}") + private Double payWeight; + + @Value("${product.ranking.score.weight.view}") + private Double viewWeight; + + @Value("${product.ranking.score.weight.like}") + private Double likeWeight; + + @Override + public boolean supports(String type) { + return type.equals("MONTHLY"); + } + + @Override + public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) { + return monthlyProductMetricRepository.findRankingsBy(date, pageNo, pageSize, payWeight, viewWeight, likeWeight); + } +} diff --git a/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java b/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java index b370eef82..37e2e997d 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java +++ b/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java @@ -6,5 +6,7 @@ public interface GetProductRankingsStrategy { + boolean supports(String type); + ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize); } diff --git a/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategySelector.java b/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategySelector.java new file mode 100644 index 000000000..45f37970a --- /dev/null +++ b/core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategySelector.java @@ -0,0 +1,20 @@ +package com.loopers.core.service.product.component; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class GetProductRankingsStrategySelector { + + private final List strategies; + + public GetProductRankingsStrategy select(String type) { + return strategies.stream() + .filter(strategy -> strategy.supports(type)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 타입의 랭킹입니다.")); + } +} diff --git a/core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java b/core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java index 7c2dd8571..f72485308 100644 --- a/core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java +++ b/core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java @@ -24,9 +24,12 @@ public class GetWeeklyProductRankingsStrategy implements GetProductRankingsStrat private Double likeWeight; @Override - public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) { - + public boolean supports(String type) { + return type.equals("WEEKLY"); + } - return null; + @Override + public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) { + return weeklyProductMetricRepository.findRankingsBy(date, pageNo, pageSize, payWeight, viewWeight, likeWeight); } } From 05a212cfb371d45bff0f6a7096093a3c874ff047 Mon Sep 17 00:00:00 2001 From: kilian Date: Thu, 1 Jan 2026 16:47:40 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feature=20:=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=ED=8C=8C=ED=8B=B0=EC=85=94=EB=8B=9D=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/product/config/MonthlyProductMetricBatchConfig.java | 1 + .../batch/product/config/WeeklyProductMetricBatchConfig.java | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java index d3b3341da..362199a93 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java @@ -44,6 +44,7 @@ public Step partitionMonthlyMetricStep( return new StepBuilder("partitionMonthlyMetricStep", jobRepository) .partitioner("collectMonthlyMetricStep", partitioner) .step(collectMonthlyMetricStep) + .taskExecutor(monthlyAsyncTaskExecutor()) .gridSize(gridSize) .build(); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java index 958ab486d..a6c811b20 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java @@ -44,6 +44,7 @@ public Step partitionDailyMetricStep( return new StepBuilder("partitionDailyMetricStep", jobRepository) .partitioner("collectDailyMetricStep", partitioner) .step(collectDailyMetricStep) + .taskExecutor(asyncTaskExecutor()) .gridSize(gridSize) .build(); } From 3d54e49fb60de1a7d68798eb2136366a7ee0d865 Mon Sep 17 00:00:00 2001 From: kilian Date: Thu, 1 Jan 2026 17:30:44 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feature=20:=20Writer=EC=97=90=20stepScope?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/product/MonthlyProductMetricBatchWriter.java | 2 ++ .../batch/product/WeeklyProductMetricBatchWriter.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchWriter.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchWriter.java index ccfb1b9ff..de107cbbe 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchWriter.java @@ -6,6 +6,7 @@ import com.loopers.core.domain.product.vo.YearMonth; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ExecutionContext; import org.springframework.batch.item.ItemStreamWriter; @@ -14,6 +15,7 @@ import java.time.LocalDate; import java.util.List; +@StepScope @Component @RequiredArgsConstructor public class MonthlyProductMetricBatchWriter implements ItemStreamWriter { diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java index 79f8a954c..d99b1d500 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java @@ -6,6 +6,7 @@ import com.loopers.core.domain.product.vo.ProductMetricAggregation; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ExecutionContext; import org.springframework.batch.item.ItemStreamWriter; @@ -14,6 +15,7 @@ import java.time.LocalDate; import java.util.List; +@StepScope @Component @RequiredArgsConstructor public class WeeklyProductMetricBatchWriter implements ItemStreamWriter { From 18e0a6b8f1ec4b30c0026c838de0be83eaf8ae4a Mon Sep 17 00:00:00 2001 From: kilian Date: Thu, 1 Jan 2026 17:33:37 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feature=20:=20=EC=9C=A0=EB=8B=88=ED=81=AC?= =?UTF-8?q?=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mysql/product/entity/MonthlyProductMetricEntity.java | 3 +++ .../mysql/product/entity/WeeklyProductMetricEntity.java | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java index 5d2f943df..c547ed86f 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java @@ -20,6 +20,9 @@ name = "monthly_product_metrics", indexes = { @Index(name = "idx_product_metric_product_id", columnList = "product_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_monthly_product_metric", columnNames = {"product_id", "year", "month"}) } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java index baa2c46f2..3907aa1a2 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java @@ -21,6 +21,9 @@ name = "weekly_product_metrics", indexes = { @Index(name = "idx_product_metric_product_id", columnList = "product_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_monthly_product_metric", columnNames = {"product_id", "year", "month", "weekOfYear"}) } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -31,7 +34,7 @@ public class WeeklyProductMetricEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, unique = true) + @Column(nullable = false) private Long productId; @Column(nullable = false) From 9d527c722e974123ae898a71fda7df9b7de5966d Mon Sep 17 00:00:00 2001 From: kilian Date: Thu, 1 Jan 2026 17:34:17 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feature=20:=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mysql/product/entity/MonthlyProductMetricEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java index c547ed86f..2d1eb7051 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java @@ -58,7 +58,7 @@ public class MonthlyProductMetricEntity { public static MonthlyProductMetricEntity from(MonthlyProductMetric metric) { return new MonthlyProductMetricEntity( - Optional.ofNullable(metric.getProductId().value()) + Optional.ofNullable(metric.getId().value()) .map(Long::parseLong) .orElse(null), Long.parseLong(Objects.requireNonNull(metric.getProductId().value())), From b15faea69379006ee5c57ed3ec27e2389000ce1a Mon Sep 17 00:00:00 2001 From: kilian Date: Thu, 1 Jan 2026 17:34:44 +0900 Subject: [PATCH 13/13] =?UTF-8?q?feature=20:=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mysql/product/entity/WeeklyProductMetricEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java index 3907aa1a2..bb8de1e14 100644 --- a/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java +++ b/core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java @@ -62,7 +62,7 @@ public class WeeklyProductMetricEntity { public static WeeklyProductMetricEntity from(WeeklyProductMetric metric) { return new WeeklyProductMetricEntity( - Optional.ofNullable(metric.getProductId().value()) + Optional.ofNullable(metric.getId().value()) .map(Long::parseLong) .orElse(null), Long.parseLong(Objects.requireNonNull(metric.getProductId().value())),