diff --git a/.gitignore b/.gitignore index 5a979af6f..0dafe803d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### QueryDSL ### +**/generated/ diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 32f0b9229..dd397224f 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,8 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) + implementation(project(":modules:event-contract")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueCountManager.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueCountManager.java new file mode 100644 index 000000000..e9c491eb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueCountManager.java @@ -0,0 +1,6 @@ +package com.loopers.application.coupon; + +public interface CouponIssueCountManager { + long increment(Long couponId); + void decrement(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java new file mode 100644 index 000000000..76f520e39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java @@ -0,0 +1,62 @@ +package com.loopers.application.coupon; + +import com.loopers.application.outbox.OutboxEventPublisher; +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import com.loopers.domain.coupon.CouponPromotion; +import com.loopers.domain.coupon.CouponPromotionRepository; +import com.loopers.event.EventType; +import com.loopers.event.payload.CouponIssueRequestedEventPayload; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class CouponIssueRequestFacade { + + private final CouponPromotionRepository couponPromotionRepository; + private final CouponIssueRequestRepository couponIssueRequestRepository; + private final CouponIssueCountManager couponIssueCountManager; + private final OutboxEventPublisher outboxEventPublisher; + + @Transactional + public CouponIssueRequestInfo issueRequest(Long userId, Long couponId) { + CouponPromotion promotion = couponPromotionRepository.findByCouponId(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "선착순 프로모션 대상 쿠폰이 아닙니다.")); + + promotion.validateIssuable(); + + long issuedCount = couponIssueCountManager.increment(couponId); + if (issuedCount > promotion.getMaxQuantity()) { + couponIssueCountManager.decrement(couponId); + throw new CoreException(ErrorType.BAD_REQUEST, "선착순 쿠폰이 모두 소진되었습니다."); + } + + CouponIssueRequest request; + try { + request = couponIssueRequestRepository.save(CouponIssueRequest.create(couponId, userId)); + } catch (DataIntegrityViolationException e) { + couponIssueCountManager.decrement(couponId); + throw new CoreException(ErrorType.CONFLICT, "이미 발급 요청한 쿠폰입니다."); + } + + outboxEventPublisher.publish( + EventType.COUPON_ISSUE_REQUESTED, + CouponIssueRequestedEventPayload.of(request.getId(), couponId, userId), + couponId + ); + + return CouponIssueRequestInfo.from(request); + } + + @Transactional(readOnly = true) + public CouponIssueRequestInfo getIssueRequest(Long userId, Long requestId) { + CouponIssueRequest request = couponIssueRequestRepository.findByIdAndUserId(requestId, userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급 요청을 찾을 수 없습니다.")); + return CouponIssueRequestInfo.from(request); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java new file mode 100644 index 000000000..b972c9a1d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; + +public record CouponIssueRequestInfo( + Long requestId, + Long couponId, + Long userId, + String status, + String reason +) { + public static CouponIssueRequestInfo from(CouponIssueRequest request) { + return new CouponIssueRequestInfo( + request.getId(), + request.getCouponId(), + request.getUserId(), + request.getStatus().name(), + request.getReason() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java new file mode 100644 index 000000000..275cd1880 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java @@ -0,0 +1,22 @@ +package com.loopers.application.event; + +import com.loopers.domain.outbox.Outbox; +import com.loopers.domain.outbox.OutboxEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +public class UserBehaviorLoggingListener { + + @Async("outboxPublishExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOutboxEvent(OutboxEvent event) { + Outbox outbox = event.getOutbox(); + log.info("[UserBehavior] type={}, partitionKey={}, payload={}", + outbox.getEventType(), outbox.getPartitionKey(), outbox.getPayload()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 09570baba..43be518e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,7 +1,11 @@ package com.loopers.application.like; +import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductService; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductLikedEventPayload; +import com.loopers.event.payload.ProductUnlikedEventPayload; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,18 +19,29 @@ public class LikeFacade { private final LikeService likeService; private final ProductService productService; + private final OutboxEventPublisher outboxEventPublisher; @Transactional public LikeInfo register(Long userId, Long productId) { + productService.getActiveProduct(productId); LikeInfo like = likeService.register(userId, productId); - productService.increaseLikeCount(productId); + outboxEventPublisher.publish( + EventType.PRODUCT_LIKED, + ProductLikedEventPayload.of(productId, userId), + productId + ); + return like; } @Transactional public void cancel(Long userId, Long productId) { if (likeService.cancel(userId, productId)) { - productService.decreaseLikeCount(productId); + outboxEventPublisher.publish( + EventType.PRODUCT_UNLIKED, + ProductUnlikedEventPayload.of(productId, userId), + productId + ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java new file mode 100644 index 000000000..144e1f354 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java @@ -0,0 +1,64 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.outbox.Outbox; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.infrastructure.outbox.OutboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MessageRelay { + + private final OutboxRepository outboxRepository; + private final KafkaTemplate kafkaTemplate; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void createOutbox(OutboxEvent event) { + outboxRepository.save(event.getOutbox()); + } + + @Async("outboxPublishExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void publishEvent(OutboxEvent event) { + publishToKafka(event.getOutbox()); + } + + private void publishToKafka(Outbox outbox) { + try { + kafkaTemplate.send( + outbox.getEventType().getTopic(), + String.valueOf(outbox.getPartitionKey()), + outbox.getPayload() + ).get(1, TimeUnit.SECONDS); + outboxRepository.delete(outbox); + } catch (Exception e) { + log.error("[MessageRelay] Kafka 발행 실패, outboxId={}", outbox.getOutboxId(), e); + } + } + + @Scheduled(fixedDelay = 10, timeUnit = TimeUnit.SECONDS) + public void publishPendingEvents() { + List pending = outboxRepository + .findAllByCreatedAtLessThanEqualOrderByCreatedAtAsc( + LocalDateTime.now().minusSeconds(10), + Pageable.ofSize(100) + ); + + for (Outbox outbox : pending) { + publishToKafka(outbox); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java new file mode 100644 index 000000000..6cd49e8a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java @@ -0,0 +1,27 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.outbox.Outbox; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.event.*; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OutboxEventPublisher { + + private final Snowflake outboxIdSnowflake = new Snowflake(); + private final Snowflake eventIdSnowflake = new Snowflake(); + private final ApplicationEventPublisher applicationEventPublisher; + + public void publish(EventType type, EventPayload payload, Long partitionKey) { + Outbox outbox = Outbox.create( + outboxIdSnowflake.nextId(), + type, + Event.of(eventIdSnowflake.nextId(), type, payload).toJson(), + partitionKey + ); + applicationEventPublisher.publishEvent(OutboxEvent.of(outbox)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index bd0b061be..96940ab45 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -2,11 +2,15 @@ import com.loopers.application.order.OrderCompensationService; import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; import com.loopers.application.order.OrderService; +import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.domain.order.Order; import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentRepository; import com.loopers.domain.payment.PaymentStatus; +import com.loopers.event.EventType; +import com.loopers.event.payload.PaymentCompletedEventPayload; import com.loopers.infrastructure.client.PgDeclinedException; import com.loopers.infrastructure.client.PgPaymentDto; import com.loopers.infrastructure.client.PgPaymentException; @@ -14,6 +18,8 @@ import com.loopers.infrastructure.client.PgTransactionStatus; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; + +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -27,6 +33,7 @@ public class PaymentFacade { private final PaymentRepository paymentRepository; private final OrderService orderService; private final OrderCompensationService orderCompensationService; + private final OutboxEventPublisher outboxEventPublisher; private final PgPaymentGateway pgPaymentGateway; private final String callbackUrl; @@ -34,12 +41,14 @@ public PaymentFacade( PaymentRepository paymentRepository, OrderService orderService, OrderCompensationService orderCompensationService, + OutboxEventPublisher outboxEventPublisher, PgPaymentGateway pgPaymentGateway, @Value("${payment.callback-url}") String callbackUrl ) { this.paymentRepository = paymentRepository; this.orderService = orderService; this.orderCompensationService = orderCompensationService; + this.outboxEventPublisher = outboxEventPublisher; this.pgPaymentGateway = pgPaymentGateway; this.callbackUrl = callbackUrl; } @@ -122,8 +131,18 @@ private void applyPgResult(Payment payment, PgTransactionStatus status, String r int affected = paymentRepository.completeIfPending(payment.getId()); if (affected > 0) { orderService.markOrderPaid(payment.getOrderId()); + List productIds = orderService.getOrderItems(payment.getOrderId()).stream() + .map(OrderItemInfo::productId) + .toList(); + + outboxEventPublisher.publish( + EventType.PAYMENT_COMPLETED, + PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds), + payment.getOrderId() + ); } } + case FAILED -> { int affected = paymentRepository.failIfPending(payment.getId(), reason); if (affected > 0) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 49c5d8496..6f201db82 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -27,7 +27,7 @@ public static ProductInfo from(Product product) { product.getDescription(), product.getPrice(), product.getStockQuantity(), - product.getLikeCount(), + 0, product.getVisibility(), product.getCreatedAt(), product.getUpdatedAt(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 451580852..26a84faa7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -98,26 +98,6 @@ public void deleteAllByBrandId(Long brandId) { products.forEach(Product::delete); } - @Transactional - public void increaseLikeCount(Long productId) { - Product product = findById(productId); - if (!product.isActive()) { - throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + productId + "] 를 찾을 수 없습니다."); - } - - productRepository.increaseLikeCount(productId); - } - - @Transactional - public void decreaseLikeCount(Long productId) { - Product product = findById(productId); - if (!product.isActive()) { - throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + productId + "] 를 찾을 수 없습니다."); - } - - productRepository.decreaseLikeCount(productId); - } - @Transactional public void decreaseStock(List items) { List sorted = items.stream() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java new file mode 100644 index 000000000..7a6095dc7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java @@ -0,0 +1,32 @@ +package com.loopers.application.product; + +import com.loopers.event.Event; +import com.loopers.event.EventType; +import com.loopers.event.Snowflake; +import com.loopers.event.Topic; +import com.loopers.event.payload.ProductViewedEventPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductViewEventPublisher { + private final KafkaTemplate kafkaTemplate; + private final Snowflake eventIdSnowflake = new Snowflake(); + + public void publish(Long productId) { + try { + String json = Event.of( + eventIdSnowflake.nextId(), + EventType.PRODUCT_VIEWED, + ProductViewedEventPayload.of(productId, null) + ).toJson(); + kafkaTemplate.send(Topic.CATALOG_EVENTS, String.valueOf(productId), json); + } catch (Exception e) { + log.warn("[ProductViewEventPublisher] 조회 이벤트 발행 실패, productId={}", productId, e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java new file mode 100644 index 000000000..668f1fae9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java @@ -0,0 +1,49 @@ +package com.loopers.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +@EnableScheduling +public class OutboxConfig { + + @Bean + public ProducerFactory outboxProducerFactory(KafkaProperties kafkaProperties) { + Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + return new DefaultKafkaProducerFactory<>(props); + } + + @Bean + public KafkaTemplate kafkaTemplate(ProducerFactory outboxProducerFactory) { + return new KafkaTemplate<>(outboxProducerFactory); + } + + @Bean("outboxPublishExecutor") + public Executor outboxPublishExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("outbox-publish-"); + executor.initialize(); + return executor; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java new file mode 100644 index 000000000..ab8659060 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "coupon_issue_requests", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "coupon_id"}) +}) +public class CouponIssueRequest extends BaseEntity { + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @Column + private String reason; + + protected CouponIssueRequest() {} + + private CouponIssueRequest(Long couponId, Long userId) { + this.couponId = couponId; + this.userId = userId; + this.status = Status.PENDING; + } + + public static CouponIssueRequest create(Long couponId, Long userId) { + return new CouponIssueRequest(couponId, userId); + } + + public void succeed() { + this.status = Status.SUCCESS; + } + + public void fail(String reason) { + this.status = Status.FAILED; + this.reason = reason; + } + + public enum Status { + PENDING, SUCCESS, FAILED + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java new file mode 100644 index 000000000..c91359dfc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponIssueRequestRepository { + CouponIssueRequest save(CouponIssueRequest request); + Optional findById(Long id); + Optional findByIdAndUserId(Long id, Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java new file mode 100644 index 000000000..d8e14cad5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java @@ -0,0 +1,52 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "coupon_promotions") +public class CouponPromotion extends BaseEntity { + + @Column(name = "coupon_id", nullable = false, unique = true) + private Long couponId; + + @Column(name = "max_quantity", nullable = false) + private int maxQuantity; + + @Column(name = "started_at", nullable = false) + private ZonedDateTime startedAt; + + @Column(name = "ended_at", nullable = false) + private ZonedDateTime endedAt; + + protected CouponPromotion() {} + + private CouponPromotion(Long couponId, int maxQuantity, ZonedDateTime startedAt, ZonedDateTime endedAt) { + this.couponId = couponId; + this.maxQuantity = maxQuantity; + this.startedAt = startedAt; + this.endedAt = endedAt; + } + + public static CouponPromotion create(Long couponId, int maxQuantity, ZonedDateTime startedAt, ZonedDateTime endedAt) { + return new CouponPromotion(couponId, maxQuantity, startedAt, endedAt); + } + + public void validateIssuable() { + ZonedDateTime now = ZonedDateTime.now(); + if (now.isBefore(startedAt)) { + throw new CoreException(ErrorType.BAD_REQUEST, "아직 시작되지 않은 프로모션입니다."); + } + if (now.isAfter(endedAt)) { + throw new CoreException(ErrorType.BAD_REQUEST, "종료된 프로모션입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotionRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotionRepository.java new file mode 100644 index 000000000..0fc5195ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotionRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponPromotionRepository { + Optional findByCouponId(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java new file mode 100644 index 000000000..303095961 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java @@ -0,0 +1,28 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import org.hibernate.annotations.Immutable; + +@Getter +@Entity +@Immutable +@Table(name = "product_metrics") +public class ProductMetricsReadModel { + @Id + private Long productId; + + @Column(nullable = false) + private Long likeCount; + + @Column(nullable = false) + private Long viewCount; + + @Column(nullable = false) + private Long orderCount; + + protected ProductMetricsReadModel() {} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java new file mode 100644 index 000000000..b63383769 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java @@ -0,0 +1,42 @@ +package com.loopers.domain.outbox; + +import com.loopers.event.EventType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "outbox") +public class Outbox { + + @Id + private Long outboxId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EventType eventType; + + @Column(nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(nullable = false) + private Long partitionKey; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public static Outbox create(Long outboxId, EventType eventType, String payload, Long partitionKey) { + Outbox outbox = new Outbox(); + outbox.outboxId = outboxId; + outbox.eventType = eventType; + outbox.payload = payload; + outbox.partitionKey = partitionKey; + outbox.createdAt = LocalDateTime.now(); + return outbox; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..c4a7c08ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,10 @@ +package com.loopers.domain.outbox; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(staticName = "of") +public class OutboxEvent { + private final Outbox outbox; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 71e015e21..e356f3589 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -29,9 +29,6 @@ public class Product extends BaseEntity { @Column(nullable = false) private Integer stockQuantity; - @Column(nullable = false) - private Integer likeCount = 0; - @Enumerated(EnumType.STRING) @Column(nullable = false) private Visibility visibility; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 8d1db9002..6b9f35914 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -15,6 +15,4 @@ public interface ProductRepository { List findAllByIdInAndDeletedAtIsNull(List ids); boolean decreaseStockIfEnough(Long productId, Integer quantity); int increaseStock(Long productId, Integer quantity); - void increaseLikeCount(Long productId); - void decreaseLikeCount(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..ef4e7500f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponIssueRequestJpaRepository extends JpaRepository { + java.util.Optional findByIdAndUserId(Long id, Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java new file mode 100644 index 000000000..ea677823d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CouponIssueRequestRepositoryImpl implements CouponIssueRequestRepository { + + private final CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + return couponIssueRequestJpaRepository.save(request); + } + + @Override + public Optional findById(Long id) { + return couponIssueRequestJpaRepository.findById(id); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return couponIssueRequestJpaRepository.findByIdAndUserId(id, userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionJpaRepository.java new file mode 100644 index 000000000..0cfd391a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponPromotion; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CouponPromotionJpaRepository extends JpaRepository { + Optional findByCouponId(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionRepositoryImpl.java new file mode 100644 index 000000000..a11836f65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponPromotion; +import com.loopers.domain.coupon.CouponPromotionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CouponPromotionRepositoryImpl implements CouponPromotionRepository { + + private final CouponPromotionJpaRepository couponPromotionJpaRepository; + + @Override + public Optional findByCouponId(Long couponId) { + return couponPromotionJpaRepository.findByCouponId(couponId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java new file mode 100644 index 000000000..bca285994 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.application.coupon.CouponIssueCountManager; +import com.loopers.config.redis.RedisConfig; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +public class RedisCouponIssueCountManager implements CouponIssueCountManager { + + private static final String KEY_PREFIX = "coupon-promotion:issued-count:"; + + private final RedisTemplate redisTemplate; + + public RedisCouponIssueCountManager( + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate redisTemplate + ) { + this.redisTemplate = redisTemplate; + } + + /** + * 발급 카운트를 1 증가시키고 증가 후 값을 반환한다. + * Redis INCR은 atomic하므로 동시 요청에도 정확한 순서가 보장된다. + */ + @Override + public long increment(Long couponId) { + Long count = redisTemplate.opsForValue().increment(key(couponId)); + if (count == null) { + throw new IllegalStateException("Redis INCR 실패: couponId=" + couponId); + } + return count; + } + + /** + * 컷오프 실패(거절) 시 또는 발급 실패 시 카운트를 되돌린다. + */ + @Override + public void decrement(Long couponId) { + redisTemplate.opsForValue().decrement(key(couponId)); + } + + private String key(Long couponId) { + return KEY_PREFIX + couponId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepository.java new file mode 100644 index 000000000..f9ce838f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.Outbox; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface OutboxRepository extends JpaRepository { + + List findAllByCreatedAtLessThanEqualOrderByCreatedAtAsc( + LocalDateTime createdAt, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 41c6a4113..56dc72df4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -29,12 +29,4 @@ int decreaseStockIfEnough( @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("UPDATE Product p SET p.stockQuantity = p.stockQuantity + :quantity WHERE p.id = :productId") int increaseStock(@Param("productId") Long productId, @Param("quantity") Integer quantity); - - @Modifying(clearAutomatically = true) - @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId") - int increaseLikeCount(@Param("productId") Long productId); - - @Modifying(clearAutomatically = true) - @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :productId AND p.likeCount > 0") - int decreaseLikeCount(@Param("productId") Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 0ec497814..56fd4fed6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,5 +1,6 @@ package com.loopers.infrastructure.product; +import com.loopers.domain.metrics.QProductMetricsReadModel; import com.loopers.domain.product.ProductOrder; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -35,6 +36,7 @@ public Optional findById(Long id) { @Override public Page findActiveProducts(Long brandId, ProductOrder order, Pageable pageable) { QProduct product = QProduct.product; + QProductMetricsReadModel metrics = QProductMetricsReadModel.productMetricsReadModel; BooleanBuilder builder = new BooleanBuilder(); builder.and(product.deletedAt.isNull()); @@ -46,12 +48,13 @@ public Page findActiveProducts(Long brandId, ProductOrder order, Pageab OrderSpecifier orderSpecifier = switch (order) { case PRICE_ASC -> product.price.asc(); - case LIKES_DESC -> product.likeCount.desc(); case LATEST -> product.id.desc(); + case LIKES_DESC -> metrics.likeCount.coalesce(0L).desc(); }; List content = queryFactory .selectFrom(product) + .leftJoin(metrics).on(metrics.productId.eq(product.id)) .where(builder) .orderBy(orderSpecifier) .offset(pageable.getOffset()) @@ -115,13 +118,4 @@ public int increaseStock(Long productId, Integer quantity) { return productJpaRepository.increaseStock(productId, quantity); } - @Override - public void increaseLikeCount(Long productId) { - productJpaRepository.increaseLikeCount(productId); - } - - @Override - public void decreaseLikeCount(Long productId) { - productJpaRepository.decreaseLikeCount(productId); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index b4f106cb8..7c7c913e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -1,11 +1,14 @@ package com.loopers.interfaces.api.coupon; import com.loopers.application.coupon.CouponIssueFacade; +import com.loopers.application.coupon.CouponIssueRequestFacade; +import com.loopers.application.coupon.CouponIssueRequestInfo; import com.loopers.application.coupon.IssuedCouponInfo; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.LoginUser; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,6 +21,7 @@ public class CouponV1Controller { private final CouponIssueFacade couponIssueFacade; + private final CouponIssueRequestFacade couponIssueRequestFacade; @PostMapping("/{couponId}/issue") @ResponseStatus(HttpStatus.CREATED) @@ -28,4 +32,23 @@ public ApiResponse issueCoupon( IssuedCouponInfo info = couponIssueFacade.issue(userId, couponId); return ApiResponse.success(CouponV1Dto.IssuedCouponResponse.from(info)); } + + @PostMapping("/{couponId}/issue-request") + @ResponseStatus(HttpStatus.ACCEPTED) + public ApiResponse issueRequest( + @LoginUser Long userId, + @PathVariable Long couponId + ) { + CouponIssueRequestInfo info = couponIssueRequestFacade.issueRequest(userId, couponId); + return ApiResponse.success(CouponV1Dto.IssueRequestResponse.from(info)); + } + + @GetMapping("/issue-requests/{requestId}") + public ApiResponse getIssueRequest( + @LoginUser Long userId, + @PathVariable Long requestId + ) { + CouponIssueRequestInfo info = couponIssueRequestFacade.getIssueRequest(userId, requestId); + return ApiResponse.success(CouponV1Dto.IssueRequestResponse.from(info)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java index 435ac7edf..7f6cf8af8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.coupon; +import com.loopers.application.coupon.CouponIssueRequestInfo; import com.loopers.application.coupon.IssuedCouponInfo; public class CouponV1Dto { @@ -9,4 +10,10 @@ public static IssuedCouponResponse from(IssuedCouponInfo info) { return new IssuedCouponResponse(info.couponId(), info.status()); } } + + public record IssueRequestResponse(Long requestId, Long couponId, String status) { + public static IssueRequestResponse from(CouponIssueRequestInfo info) { + return new IssueRequestResponse(info.requestId(), info.couponId(), info.status()); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index ee0f50dab..3b8e196c2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -4,6 +4,7 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductSort; +import com.loopers.application.product.ProductViewEventPublisher; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.product.dto.ProductV1Dto; @@ -22,10 +23,12 @@ public class ProductV1Controller { private final ProductFacade productFacade; + private final ProductViewEventPublisher productViewEventPublisher; @GetMapping("/{productId}") public ApiResponse getProduct(@PathVariable Long productId) { ProductInfo product = productFacade.getActiveProduct(productId); + productViewEventPublisher.publish(productId); return ApiResponse.success(ProductV1Dto.ProductResponse.from(product)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java b/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java index 6ad90adea..8aff908c3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java +++ b/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java @@ -45,13 +45,10 @@ void initialize() { for (int i = 0; i < brandIds.size(); i++) { Long brandId = brandIds.get(i); - boolean isTopBrand = i < 10; // 앞 10개 브랜드만 top + boolean isTopBrand = i < 10; int productCount = isTopBrand ? 10_000 : 2_500; - int likeMin = isTopBrand ? 300 : 0; - int likeMax = isTopBrand ? 3_000 : 2_000; insertProducts(brandId, productCount); - updateLikeCount(brandId, likeMin, likeMax); } } @@ -84,13 +81,4 @@ void insertProducts(Long brandId, int count) { }); } - void updateLikeCount(Long brandId, int min, int max) { - transactionTemplate.executeWithoutResult(status -> - entityManager.createNativeQuery("UPDATE products SET like_count = FLOOR(:min + RAND() * (:max - :min + 1)) WHERE brand_id = :brandId") - .setParameter("min", min) - .setParameter("max", max) - .setParameter("brandId", brandId) - .executeUpdate() - ); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueRequestFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueRequestFacadeTest.java new file mode 100644 index 000000000..148b657ca --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueRequestFacadeTest.java @@ -0,0 +1,214 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponPromotion; +import com.loopers.domain.coupon.InMemoryCouponIssueRequestRepository; +import com.loopers.domain.coupon.InMemoryCouponPromotionRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CouponIssueRequestFacadeTest { + + private InMemoryCouponPromotionRepository promotionRepository; + private InMemoryCouponIssueRequestRepository issueRequestRepository; + private FakeCouponIssueCountManager issueCountManager; + private FakeOutboxEventPublisher outboxEventPublisher; + private CouponIssueRequestFacade facade; + + @BeforeEach + void setUp() { + promotionRepository = new InMemoryCouponPromotionRepository(); + issueRequestRepository = new InMemoryCouponIssueRequestRepository(); + issueCountManager = new FakeCouponIssueCountManager(); + outboxEventPublisher = new FakeOutboxEventPublisher(); + facade = new CouponIssueRequestFacade( + promotionRepository, issueRequestRepository, issueCountManager, outboxEventPublisher + ); + } + + @DisplayName("선착순 쿠폰 발급 요청 시,") + @Nested + class IssueRequest { + + @DisplayName("유효한 프로모션이면 PENDING 상태의 요청을 생성하고 이벤트를 발행한다.") + @Test + void createsPendingRequest_whenPromotionIsActive() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = promotionRepository.save( + CouponPromotion.create(1L, 100, now.minusHours(1), now.plusDays(1)) + ); + + // act + CouponIssueRequestInfo result = facade.issueRequest(100L, promotion.getCouponId()); + + // assert + assertAll( + () -> assertThat(result.couponId()).isEqualTo(1L), + () -> assertThat(result.userId()).isEqualTo(100L), + () -> assertThat(result.status()).isEqualTo("PENDING"), + () -> assertThat(outboxEventPublisher.getPublishedCount()).isEqualTo(1) + ); + } + + @DisplayName("선착순 프로모션이 아닌 쿠폰이면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenPromotionNotExists() { + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.issueRequest(100L, 999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("아직 시작되지 않은 프로모션이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPromotionNotStarted() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = promotionRepository.save( + CouponPromotion.create(1L, 100, now.plusHours(1), now.plusDays(1)) + ); + + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.issueRequest(100L, promotion.getCouponId())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 초과되면 BAD_REQUEST 예외가 발생하고 카운터가 되돌려진다.") + @Test + void throwsBadRequest_whenQuantityExceeded() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = promotionRepository.save( + CouponPromotion.create(1L, 2, now.minusHours(1), now.plusDays(1)) + ); + // 이미 2개 발급됨 + issueCountManager.setCount(1L, 2); + + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.issueRequest(100L, promotion.getCouponId())); + + // assert + assertAll( + () -> assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST), + () -> assertThat(issueCountManager.getCount(1L)).isEqualTo(2) // 되돌려짐 + ); + } + + @DisplayName("동일 사용자가 중복 요청하면 CONFLICT 예외가 발생하고 카운터가 되돌려진다.") + @Test + void throwsConflict_whenDuplicateRequest() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = promotionRepository.save( + CouponPromotion.create(1L, 100, now.minusHours(1), now.plusDays(1)) + ); + facade.issueRequest(100L, promotion.getCouponId()); + + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.issueRequest(100L, promotion.getCouponId())); + + // assert + assertAll( + () -> assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT), + () -> assertThat(issueCountManager.getCount(1L)).isEqualTo(1) // 되돌려짐 + ); + } + } + + @DisplayName("발급 요청 조회 시,") + @Nested + class GetIssueRequest { + + @DisplayName("존재하는 요청이면 정보를 반환한다.") + @Test + void returnsInfo_whenRequestExists() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + promotionRepository.save( + CouponPromotion.create(1L, 100, now.minusHours(1), now.plusDays(1)) + ); + CouponIssueRequestInfo created = facade.issueRequest(100L, 1L); + + // act + CouponIssueRequestInfo result = facade.getIssueRequest(100L, created.requestId()); + + // assert + assertAll( + () -> assertThat(result.requestId()).isEqualTo(created.requestId()), + () -> assertThat(result.status()).isEqualTo("PENDING") + ); + } + + @DisplayName("존재하지 않는 요청이면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenRequestNotExists() { + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.getIssueRequest(100L, 999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + // === Test Doubles === + + static class FakeCouponIssueCountManager implements CouponIssueCountManager { + private final Map counts = new HashMap<>(); + + @Override + public long increment(Long couponId) { + return counts.merge(couponId, 1L, Long::sum); + } + + @Override + public void decrement(Long couponId) { + counts.computeIfPresent(couponId, (k, v) -> v - 1); + } + + void setCount(Long couponId, long count) { + counts.put(couponId, count); + } + + long getCount(Long couponId) { + return counts.getOrDefault(couponId, 0L); + } + } + + static class FakeOutboxEventPublisher extends com.loopers.application.outbox.OutboxEventPublisher { + private int publishedCount = 0; + + FakeOutboxEventPublisher() { + super(null); + } + + @Override + public void publish(com.loopers.event.EventType type, com.loopers.event.EventPayload payload, Long partitionKey) { + publishedCount++; + } + + int getPublishedCount() { + return publishedCount; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 733089fb3..847f76013 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -1,5 +1,6 @@ package com.loopers.application.like; +import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.application.product.ProductCreateCommand; import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductService; @@ -7,6 +8,9 @@ import com.loopers.domain.like.InMemoryLikeRepository; import com.loopers.domain.product.InMemoryProductRepository; import com.loopers.domain.product.Product; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductLikedEventPayload; +import com.loopers.event.payload.ProductUnlikedEventPayload; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -18,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; public class LikeFacadeTest { @@ -25,6 +30,7 @@ public class LikeFacadeTest { private InMemoryProductRepository productRepository; private LikeService likeService; private ProductService productService; + private OutboxEventPublisher outboxEventPublisher; private LikeFacade likeFacade; @BeforeEach @@ -33,7 +39,8 @@ void setUp() { productRepository = new InMemoryProductRepository(); likeService = new LikeService(likeRepository); productService = new ProductService(productRepository); - likeFacade = new LikeFacade(likeService, productService); + outboxEventPublisher = mock(OutboxEventPublisher.class); + likeFacade = new LikeFacade(likeService, productService, outboxEventPublisher); } @DisplayName("좋아요 등록 시, ") @@ -56,13 +63,31 @@ void throws_when_already_liked() { // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.ALREADY_LIKED); } + + @DisplayName("좋아요 등록 시 PRODUCT_LIKED 이벤트가 발행된다.") + @Test + void publishes_product_liked_event() { + // arrange + long userId = 1L; + ProductInfo product = productService.register(new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10)); + + // act + likeFacade.register(userId, product.id()); + + // assert + verify(outboxEventPublisher).publish( + eq(EventType.PRODUCT_LIKED), + any(ProductLikedEventPayload.class), + eq(product.id()) + ); + } } @DisplayName("좋아요 취소 시, ") @Nested class Cancel { - @DisplayName("좋아요가 없을 때 취소하면 예외 없이 처리되고 likeCount는 감소하지 않는다.") + @DisplayName("좋아요가 없을 때 취소하면 예외 없이 처리되고 이벤트가 발행되지 않는다.") @Test void noop_when_like_does_not_exist() { // arrange @@ -71,15 +96,32 @@ void noop_when_like_does_not_exist() { new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10) ); - long productId = product.id(); - int before = productService.getProduct(productId).likeCount(); + // act + likeFacade.cancel(userId, product.id()); + + // assert + verify(outboxEventPublisher, never()).publish(any(), any(), any()); + } + + @DisplayName("좋아요 취소 시 PRODUCT_UNLIKED 이벤트가 발행된다.") + @Test + void publishes_product_unliked_event() { + // arrange + long userId = 1L; + ProductInfo product = productService.register( + new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10) + ); + likeService.register(userId, product.id()); // act - likeFacade.cancel(userId, productId); + likeFacade.cancel(userId, product.id()); // assert - int after = productService.getProduct(productId).likeCount(); - assertThat(after).isEqualTo(before); + verify(outboxEventPublisher).publish( + eq(EventType.PRODUCT_UNLIKED), + any(ProductUnlikedEventPayload.class), + eq(product.id()) + ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java index 58e45a411..14395199e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java @@ -1,5 +1,6 @@ package com.loopers.application.payment; +import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.domain.order.InMemoryOrderItemRepository; import com.loopers.domain.order.InMemoryOrderRepository; import com.loopers.application.order.OrderCompensationService; @@ -38,6 +39,7 @@ class PaymentFacadeTest { private InMemoryOrderRepository orderRepository; private OrderService orderService; private OrderCompensationService orderCompensationService; + private OutboxEventPublisher outboxEventPublisher; private PgPaymentGateway pgPaymentGateway; private PaymentFacade paymentFacade; @@ -47,8 +49,9 @@ void setUp() { orderRepository = new InMemoryOrderRepository(); orderService = new OrderService(orderRepository, new InMemoryOrderItemRepository()); orderCompensationService = mock(OrderCompensationService.class); + outboxEventPublisher = mock(OutboxEventPublisher.class); pgPaymentGateway = mock(PgPaymentGateway.class); - paymentFacade = new PaymentFacade(paymentRepository, orderService, orderCompensationService, pgPaymentGateway, "http://localhost:8080/api/v1/payments/callback"); + paymentFacade = new PaymentFacade(paymentRepository, orderService, orderCompensationService, outboxEventPublisher, pgPaymentGateway, "http://localhost:8080/api/v1/payments/callback"); } @DisplayName("결제 요청 시, ") diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java new file mode 100644 index 000000000..964b18764 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java @@ -0,0 +1,71 @@ +package com.loopers.domain.coupon; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +public class CouponIssueRequestTest { + + @DisplayName("발급 요청 생성 시,") + @Nested + class Create { + + @DisplayName("PENDING 상태로 생성된다.") + @Test + void createsWithPendingStatus() { + // act + CouponIssueRequest request = CouponIssueRequest.create(1L, 100L); + + // assert + assertAll( + () -> assertThat(request.getCouponId()).isEqualTo(1L), + () -> assertThat(request.getUserId()).isEqualTo(100L), + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.PENDING), + () -> assertThat(request.getReason()).isNull() + ); + } + } + + @DisplayName("발급 성공 시,") + @Nested + class Succeed { + + @DisplayName("SUCCESS 상태로 변경된다.") + @Test + void changesStatusToSuccess() { + // arrange + CouponIssueRequest request = CouponIssueRequest.create(1L, 100L); + + // act + request.succeed(); + + // assert + assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.SUCCESS); + } + } + + @DisplayName("발급 실패 시,") + @Nested + class Fail { + + @DisplayName("FAILED 상태와 실패 사유가 설정된다.") + @Test + void changesStatusToFailedWithReason() { + // arrange + CouponIssueRequest request = CouponIssueRequest.create(1L, 100L); + String reason = "선착순 쿠폰이 모두 소진되었습니다."; + + // act + request.fail(reason); + + // assert + assertAll( + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.FAILED), + () -> assertThat(request.getReason()).isEqualTo(reason) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java new file mode 100644 index 000000000..e0a3f3a07 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CouponPromotionTest { + + @DisplayName("프로모션 생성 시,") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면 정상적으로 생성된다.") + @Test + void createsPromotion_whenFieldsAreValid() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + + // act + CouponPromotion promotion = CouponPromotion.create( + 1L, 100, now.minusHours(1), now.plusDays(1) + ); + + // assert + assertAll( + () -> assertThat(promotion.getCouponId()).isEqualTo(1L), + () -> assertThat(promotion.getMaxQuantity()).isEqualTo(100), + () -> assertThat(promotion.getStartedAt()).isEqualTo(now.minusHours(1)), + () -> assertThat(promotion.getEndedAt()).isEqualTo(now.plusDays(1)) + ); + } + } + + @DisplayName("발급 가능 여부 검증 시,") + @Nested + class ValidateIssuable { + + @DisplayName("진행 중인 프로모션이면 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenPromotionIsActive() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = CouponPromotion.create( + 1L, 100, now.minusHours(1), now.plusDays(1) + ); + + // act & assert + promotion.validateIssuable(); + } + + @DisplayName("아직 시작되지 않은 프로모션이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPromotionNotStarted() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = CouponPromotion.create( + 1L, 100, now.plusHours(1), now.plusDays(1) + ); + + // act + CoreException result = assertThrows(CoreException.class, promotion::validateIssuable); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("종료된 프로모션이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPromotionEnded() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = CouponPromotion.create( + 1L, 100, now.minusDays(2), now.minusDays(1) + ); + + // act + CoreException result = assertThrows(CoreException.class, promotion::validateIssuable); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponIssueRequestRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponIssueRequestRepository.java new file mode 100644 index 000000000..a826e74e2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponIssueRequestRepository.java @@ -0,0 +1,45 @@ +package com.loopers.domain.coupon; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class InMemoryCouponIssueRequestRepository implements CouponIssueRequestRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + if (request.getId() == 0L) { + boolean duplicate = store.values().stream() + .anyMatch(r -> r.getUserId().equals(request.getUserId()) + && r.getCouponId().equals(request.getCouponId())); + if (duplicate) { + throw new org.springframework.dao.DataIntegrityViolationException( + "UNIQUE constraint violated: (user_id, coupon_id)"); + } + + try { + var idField = request.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(request, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + store.put(request.getId(), request); + return request; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return findById(id).filter(request -> request.getUserId().equals(userId)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponPromotionRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponPromotionRepository.java new file mode 100644 index 000000000..daaef6561 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponPromotionRepository.java @@ -0,0 +1,33 @@ +package com.loopers.domain.coupon; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class InMemoryCouponPromotionRepository implements CouponPromotionRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + public CouponPromotion save(CouponPromotion promotion) { + if (promotion.getId() == 0L) { + try { + var idField = promotion.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(promotion, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + store.put(promotion.getId(), promotion); + return promotion; + } + + @Override + public Optional findByCouponId(Long couponId) { + return store.values().stream() + .filter(p -> p.getCouponId().equals(couponId)) + .findFirst(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java index 3f5818303..4af37ae01 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java @@ -95,13 +95,4 @@ public int increaseStock(Long productId, Integer quantity) { throw new UnsupportedOperationException("Atomic UPDATE는 DB에 의존하므로 통합테스트에서 커버합니다."); } - @Override - public void increaseLikeCount(Long productId) { - throw new UnsupportedOperationException("Atomic UPDATE는 DB에 의존하므로 통합테스트에서 커버합니다."); - } - - @Override - public void decreaseLikeCount(Long productId) { - throw new UnsupportedOperationException("Atomic UPDATE는 DB에 의존하므로 통합테스트에서 커버합니다."); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 33afb2881..92035af91 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -304,20 +304,4 @@ void changesVisibility_fromHiddenToVisible() { } } - @DisplayName("좋아요 수 변경 시, ") - @Nested - class LikeCount { - - @DisplayName("기본값은 0이다.") - @Test - void likeCount_기본값은_0이다() { - // act - Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); - - // assert - assertThat(product.getLikeCount()).isEqualTo(0); - } - - } - } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java index a40251096..7b01f2849 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java @@ -1,26 +1,32 @@ package com.loopers.interfaces.api; +import com.loopers.config.redis.RedisConfig; import com.loopers.domain.brand.Brand; import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponPromotion; import com.loopers.domain.coupon.IssuedCoupon; import com.loopers.domain.product.Product; import com.loopers.domain.user.User; import com.loopers.domain.user.UserFixture; import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.coupon.CouponIssueRequestJpaRepository; import com.loopers.infrastructure.coupon.CouponJpaRepository; +import com.loopers.infrastructure.coupon.CouponPromotionJpaRepository; import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; -import com.loopers.infrastructure.like.LikeJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.coupon.CouponV1Dto; import com.loopers.interfaces.api.order.OrderV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -29,6 +35,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -44,7 +51,6 @@ class ConcurrencyE2ETest { private static final String ORDERS_ENDPOINT = "/api/v1/orders"; - private static final String PRODUCTS_ENDPOINT = "/api/v1/products"; private static final String RAW_PASSWORD = "TestPass1!"; private final TestRestTemplate testRestTemplate; @@ -52,8 +58,10 @@ class ConcurrencyE2ETest { private final BrandJpaRepository brandJpaRepository; private final ProductJpaRepository productJpaRepository; private final CouponJpaRepository couponJpaRepository; + private final CouponPromotionJpaRepository couponPromotionJpaRepository; + private final CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; private final IssuedCouponJpaRepository issuedCouponJpaRepository; - private final LikeJpaRepository likeJpaRepository; + private final RedisTemplate redisTemplate; private final DatabaseCleanUp databaseCleanUp; private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); @@ -64,8 +72,10 @@ public ConcurrencyE2ETest( BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, CouponJpaRepository couponJpaRepository, + CouponPromotionJpaRepository couponPromotionJpaRepository, + CouponIssueRequestJpaRepository couponIssueRequestJpaRepository, IssuedCouponJpaRepository issuedCouponJpaRepository, - LikeJpaRepository likeJpaRepository, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate redisTemplate, DatabaseCleanUp databaseCleanUp ) { this.testRestTemplate = testRestTemplate; @@ -73,14 +83,17 @@ public ConcurrencyE2ETest( this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; this.couponJpaRepository = couponJpaRepository; + this.couponPromotionJpaRepository = couponPromotionJpaRepository; + this.couponIssueRequestJpaRepository = couponIssueRequestJpaRepository; this.issuedCouponJpaRepository = issuedCouponJpaRepository; - this.likeJpaRepository = likeJpaRepository; + this.redisTemplate = redisTemplate; this.databaseCleanUp = databaseCleanUp; } @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisTemplate.delete(redisTemplate.keys("coupon-promotion:issued-count:*")); } private HttpHeaders headersFor(String loginId) { @@ -151,42 +164,44 @@ private HttpHeaders headersFor(String loginId) { assertThat(finalProduct.getStockQuantity()).isEqualTo(0); } - @DisplayName("동일 쿠폰으로 N번 동시 주문하면, 정확히 1건만 성공하고 쿠폰은 사용 처리된다.") + @DisplayName("선착순 쿠폰 N장에 M명(M>N)이 동시 요청하면, 정확히 N건만 ACCEPTED되고 나머지는 거절된다.") @Test - void 쿠폰_동시_사용_테스트() throws InterruptedException { + void 선착순_쿠폰_동시_발급_요청_테스트() throws InterruptedException { // arrange - int threadCount = 10; + int maxQuantity = 5; + int threadCount = 20; String encodedPassword = bCryptPasswordEncoder.encode(RAW_PASSWORD); - User user = userJpaRepository.save(UserFixture.builder().loginId("couponConcUser").password(encodedPassword).build()); - Brand brand = brandJpaRepository.save(Brand.create("나이키", "스포츠")); - Product product = productJpaRepository.save(Product.create(brand.getId(), "에어맥스", null, 10000, 100)); - Coupon coupon = couponJpaRepository.save( - Coupon.create("동시성 테스트 쿠폰", Coupon.DiscountType.FIXED, 1000L, 1000L, LocalDateTime.now().plusDays(30))); - IssuedCoupon issuedCoupon = issuedCouponJpaRepository.save( - IssuedCoupon.create(user.getId(), coupon.getId(), LocalDateTime.now().plusDays(30))); + Coupon.create("선착순 쿠폰", Coupon.DiscountType.FIXED, 1000L, 1000L, LocalDateTime.now().plusDays(30))); + couponPromotionJpaRepository.save( + CouponPromotion.create(coupon.getId(), maxQuantity, ZonedDateTime.now().minusHours(1), ZonedDateTime.now().plusDays(1))); - OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( - List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 1)), issuedCoupon.getId()); - HttpEntity entity = new HttpEntity<>(request, headersFor(user.getLoginId())); + List users = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + users.add(userJpaRepository.save( + UserFixture.builder().loginId("flashUser" + i).password(encodedPassword).build())); + } + String endpoint = "/api/v1/coupons/" + coupon.getId() + "/issue-request"; ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch doneLatch = new CountDownLatch(threadCount); - AtomicInteger successCount = new AtomicInteger(0); - ConcurrentLinkedQueue failErrorCodes = new ConcurrentLinkedQueue<>(); + AtomicInteger acceptedCount = new AtomicInteger(0); + AtomicInteger rejectedCount = new AtomicInteger(0); for (int i = 0; i < threadCount; i++) { + final User user = users.get(i); executor.submit(() -> { try { startLatch.await(); - ResponseEntity> response = - testRestTemplate.exchange(ORDERS_ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); - if (response.getStatusCode() == HttpStatus.CREATED) { - successCount.incrementAndGet(); + HttpEntity entity = new HttpEntity<>(headersFor(user.getLoginId())); + ResponseEntity> response = + testRestTemplate.exchange(endpoint, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + if (response.getStatusCode() == HttpStatus.ACCEPTED) { + acceptedCount.incrementAndGet(); } else { - failErrorCodes.add(response.getBody().meta().errorCode()); + rejectedCount.incrementAndGet(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -202,50 +217,49 @@ private HttpHeaders headersFor(String loginId) { executor.shutdown(); // assert - IssuedCoupon usedCoupon = issuedCouponJpaRepository.findById(issuedCoupon.getId()).orElseThrow(); + long issueRequestCount = couponIssueRequestJpaRepository.count(); assertThat(completed).isTrue(); - assertThat(successCount.get()).isEqualTo(1); - assertThat(failErrorCodes).hasSize(threadCount - 1); - assertThat(failErrorCodes).containsOnly("COUPON_ALREADY_USED"); - assertThat(usedCoupon.getUsedAt()).isNotNull(); + assertThat(acceptedCount.get()).isEqualTo(maxQuantity); + assertThat(rejectedCount.get()).isEqualTo(threadCount - maxQuantity); + assertThat(issueRequestCount).isEqualTo(maxQuantity); } - @DisplayName("N명이 동시에 좋아요를 등록하면, likeCount는 정확히 N이 된다.") + @DisplayName("동일 쿠폰으로 N번 동시 주문하면, 정확히 1건만 성공하고 쿠폰은 사용 처리된다.") @Test - void likeCount_동시성_테스트() throws InterruptedException { + void 쿠폰_동시_사용_테스트() throws InterruptedException { // arrange int threadCount = 10; String encodedPassword = bCryptPasswordEncoder.encode(RAW_PASSWORD); + User user = userJpaRepository.save(UserFixture.builder().loginId("couponConcUser").password(encodedPassword).build()); Brand brand = brandJpaRepository.save(Brand.create("나이키", "스포츠")); Product product = productJpaRepository.save(Product.create(brand.getId(), "에어맥스", null, 10000, 100)); - String likeUrl = PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes"; - List users = new ArrayList<>(); - for (int i = 0; i < threadCount; i++) { - users.add(userJpaRepository.save( - UserFixture.builder() - .loginId("likeUser" + i) - .password(encodedPassword) - .build())); - } + Coupon coupon = couponJpaRepository.save( + Coupon.create("동시성 테스트 쿠폰", Coupon.DiscountType.FIXED, 1000L, 1000L, LocalDateTime.now().plusDays(30))); + IssuedCoupon issuedCoupon = issuedCouponJpaRepository.save( + IssuedCoupon.create(user.getId(), coupon.getId(), LocalDateTime.now().plusDays(30))); + + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 1)), issuedCoupon.getId()); + HttpEntity entity = new HttpEntity<>(request, headersFor(user.getLoginId())); ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch doneLatch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); + ConcurrentLinkedQueue failErrorCodes = new ConcurrentLinkedQueue<>(); for (int i = 0; i < threadCount; i++) { - final User user = users.get(i); executor.submit(() -> { try { startLatch.await(); - ResponseEntity> response = testRestTemplate.exchange( - likeUrl, HttpMethod.POST, - new HttpEntity<>(headersFor(user.getLoginId())), - new ParameterizedTypeReference<>() {}); + ResponseEntity> response = + testRestTemplate.exchange(ORDERS_ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); if (response.getStatusCode() == HttpStatus.CREATED) { successCount.incrementAndGet(); + } else { + failErrorCodes.add(response.getBody().meta().errorCode()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -261,10 +275,12 @@ private HttpHeaders headersFor(String loginId) { executor.shutdown(); // assert - Product finalProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + IssuedCoupon usedCoupon = issuedCouponJpaRepository.findById(issuedCoupon.getId()).orElseThrow(); assertThat(completed).isTrue(); - assertThat(successCount.get()).isEqualTo(threadCount); - assertThat(finalProduct.getLikeCount()).isEqualTo(threadCount); - assertThat(likeJpaRepository.findAll()).hasSize(threadCount); + assertThat(successCount.get()).isEqualTo(1); + assertThat(failErrorCodes).hasSize(threadCount - 1); + assertThat(failErrorCodes).containsOnly("COUPON_ALREADY_USED"); + assertThat(usedCoupon.getUsedAt()).isNotNull(); } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index d026d7398..469b239b7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api; -import com.loopers.application.product.ProductService; import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; import com.loopers.infrastructure.brand.BrandJpaRepository; @@ -33,7 +32,6 @@ class ProductV1ApiE2ETest { private final TestRestTemplate testRestTemplate; private final BrandJpaRepository brandJpaRepository; private final ProductJpaRepository productJpaRepository; - private final ProductService productService; private final DatabaseCleanUp databaseCleanUp; private final RedisCleanUp redisCleanUp; @@ -42,14 +40,12 @@ public ProductV1ApiE2ETest( TestRestTemplate testRestTemplate, BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, - ProductService productService, DatabaseCleanUp databaseCleanUp, RedisCleanUp redisCleanUp ) { this.testRestTemplate = testRestTemplate; this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; - this.productService = productService; this.databaseCleanUp = databaseCleanUp; this.redisCleanUp = redisCleanUp; } @@ -304,41 +300,6 @@ void returnsSortedByPriceAsc() { ); } - @DisplayName("sort=LIKES_DESC로 조회하면, 좋아요 많은순으로 반환한다.") - @Test - void returnsSortedByLikesDesc() { - // arrange - Brand brand = saveBrand("TEST_BRAND"); - Product lowLikes = saveProduct(brand.getId(), "좋아요적은상품", 100000, 10); - Product middleLikes = saveProduct(brand.getId(), "좋아요중간상품", 120000, 10); - Product highLikes = saveProduct(brand.getId(), "좋아요많은상품", 140000, 10); - - productService.increaseLikeCount(lowLikes.getId()); - productService.increaseLikeCount(middleLikes.getId()); - productService.increaseLikeCount(middleLikes.getId()); - productService.increaseLikeCount(highLikes.getId()); - productService.increaseLikeCount(highLikes.getId()); - productService.increaseLikeCount(highLikes.getId()); - - // act - ResponseEntity>> response = - testRestTemplate.exchange( - ENDPOINT + "?sort=LIKES_DESC", - HttpMethod.GET, null, new ParameterizedTypeReference<>() {} - ); - - List ids = response.getBody().data().content().stream() - .map(ProductV1Dto.ProductResponse::id) - .toList(); - // assert - assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(ids.indexOf(highLikes.getId())) - .isLessThan(ids.indexOf(middleLikes.getId())) - .isLessThan(ids.indexOf(lowLikes.getId())) - ); - } - @DisplayName("삭제된 상품은 목록에서 제외된다.") @Test void excludesDeletedProducts() { diff --git a/apps/commerce-streamer/build.gradle.kts b/apps/commerce-streamer/build.gradle.kts index ba710e6eb..30b26e409 100644 --- a/apps/commerce-streamer/build.gradle.kts +++ b/apps/commerce-streamer/build.gradle.kts @@ -3,6 +3,7 @@ dependencies { implementation(project(":modules:jpa")) implementation(project(":modules:redis")) implementation(project(":modules:kafka")) + implementation(project(":modules:event-contract")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -20,4 +21,7 @@ dependencies { testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) testImplementation(testFixtures(project(":modules:kafka"))) + + // kafka test + testImplementation("org.springframework.kafka:spring-kafka-test") } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java b/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java new file mode 100644 index 000000000..f55a603ae --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java @@ -0,0 +1,58 @@ +package com.loopers.application; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class CouponIssueProcessor { + + private final CouponRepository couponRepository; + private final IssuedCouponRepository issuedCouponRepository; + private final CouponIssueRequestRepository couponIssueRequestRepository; + + @Transactional + public void process(Long requestId, Long couponId, Long userId) { + CouponIssueRequest request = couponIssueRequestRepository.findById(requestId).orElse(null); + if (request == null) { + log.warn("[CouponIssueProcessor] 발급 요청을 찾을 수 없음, requestId={}", requestId); + return; + } + + if (!request.isPending()) { + log.info("[CouponIssueProcessor] 이미 처리된 요청, requestId={}", requestId); + return; + } + + if (issuedCouponRepository.existsByUserIdAndCouponId(userId, couponId)) { + request.fail("이미 발급된 쿠폰입니다."); + return; + } + + Coupon coupon = couponRepository.findIssuableCoupon(couponId).orElse(null); + if (coupon == null) { + request.fail("발급 가능한 쿠폰이 아닙니다."); + return; + } + + try { + issuedCouponRepository.save(IssuedCoupon.create(userId, couponId, coupon.getExpiresAt())); + } catch (DataIntegrityViolationException e) { + log.warn("[CouponIssueProcessor] 중복 발급 감지, requestId={}, couponId={}, userId={}", requestId, couponId, userId); + request.fail("이미 발급된 쿠폰입니다."); + return; + } + + request.succeed(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java new file mode 100644 index 000000000..7cd3ce58a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java @@ -0,0 +1,9 @@ +package com.loopers.application; + +import com.loopers.event.Event; +import com.loopers.event.EventPayload; + +public interface EventHandler { + boolean supports(Event event); + void handle(Event event); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java new file mode 100644 index 000000000..ac085d913 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java @@ -0,0 +1,39 @@ +package com.loopers.application; + +import com.loopers.domain.EventHandled; +import com.loopers.infrastructure.EventHandledRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Service +public class EventProcessingService { + private final List eventHandlers; + private final EventHandledRepository eventHandledRepository; + + @Transactional + @SuppressWarnings("unchecked") + public void process(Event event) { + try { + eventHandledRepository.save(new EventHandled(event.getEventId(), LocalDateTime.now())); + } catch (DataIntegrityViolationException e) { + log.info("[EventProcessingService] 이미 처리된 이벤트, eventId={}", event.getEventId()); + return; + } + + for (EventHandler handler : eventHandlers) { + if (handler.supports(event)) { + handler.handle(event); + } + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/CouponIssueRequestedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/CouponIssueRequestedEventHandler.java new file mode 100644 index 000000000..507f07608 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/CouponIssueRequestedEventHandler.java @@ -0,0 +1,32 @@ +package com.loopers.application.handler; + +import com.loopers.application.CouponIssueProcessor; +import com.loopers.application.EventHandler; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.CouponIssueRequestedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CouponIssueRequestedEventHandler implements EventHandler { + + private final CouponIssueProcessor couponIssueProcessor; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.COUPON_ISSUE_REQUESTED; + } + + @Override + public void handle(Event event) { + CouponIssueRequestedEventPayload payload = event.getPayload(); + couponIssueProcessor.process( + payload.getCouponIssueRequestId(), + payload.getCouponId(), + payload.getUserId() + ); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java new file mode 100644 index 000000000..eb0bbf6b2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java @@ -0,0 +1,28 @@ +package com.loopers.application.handler; + +import com.loopers.application.EventHandler; +import com.loopers.infrastructure.ProductMetricsRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.PaymentCompletedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PaymentCompletedEventHandler implements EventHandler { + private final ProductMetricsRepository productMetricsRepository; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PAYMENT_COMPLETED; + } + + @Override + public void handle(Event event) { + for (Long productId : event.getPayload().getProductIds()) { + productMetricsRepository.incrementOrderCount(productId); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java new file mode 100644 index 000000000..e61854bfb --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java @@ -0,0 +1,26 @@ +package com.loopers.application.handler; + +import com.loopers.application.EventHandler; +import com.loopers.infrastructure.ProductMetricsRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductLikedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductLikedEventHandler implements EventHandler { + private final ProductMetricsRepository productMetricsRepository; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PRODUCT_LIKED; + } + + @Override + public void handle(Event event) { + productMetricsRepository.incrementLikeCount(event.getPayload().getProductId()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java new file mode 100644 index 000000000..bd46d3a40 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java @@ -0,0 +1,26 @@ +package com.loopers.application.handler; + +import com.loopers.application.EventHandler; +import com.loopers.infrastructure.ProductMetricsRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductUnlikedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductUnlikedEventHandler implements EventHandler { + private final ProductMetricsRepository productMetricsRepository; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PRODUCT_UNLIKED; + } + + @Override + public void handle(Event event) { + productMetricsRepository.decrementLikeCount(event.getPayload().getProductId()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java new file mode 100644 index 000000000..b3e3f2beb --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java @@ -0,0 +1,26 @@ +package com.loopers.application.handler; + +import com.loopers.application.EventHandler; +import com.loopers.infrastructure.ProductMetricsRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductViewedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductViewedEventHandler implements EventHandler { + private final ProductMetricsRepository productMetricsRepository; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PRODUCT_VIEWED; + } + + @Override + public void handle(Event event) { + productMetricsRepository.incrementViewCount(event.getPayload().getProductId()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandled.java new file mode 100644 index 000000000..7aa739165 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandled.java @@ -0,0 +1,27 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "event_handled") +public class EventHandled { + @Id + private Long eventId; + + @Column(nullable = false) + private LocalDateTime handledAt; + + protected EventHandled() {} + + public EventHandled(Long eventId, LocalDateTime handledAt) { + this.eventId = eventId; + this.handledAt = handledAt; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java new file mode 100644 index 000000000..8accb3b54 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java @@ -0,0 +1,26 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "product_metrics") +public class ProductMetrics { + @Id + private Long productId; + + @Column(nullable = false) + private Long likeCount = 0L; + + @Column(nullable = false) + private Long viewCount = 0L; + + @Column(nullable = false) + private Long orderCount = 0L; + + protected ProductMetrics() {} +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 000000000..5f34439cd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,52 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "coupons") +public class Coupon { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String discountType; + + @Column(nullable = false) + private Long discountValue; + + @Column(nullable = false) + private Long minOrderAmount; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + private ZonedDateTime createdAt; + + @Column(nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + protected Coupon() {} + + public boolean isIssuable() { + return deletedAt == null && expiresAt.isAfter(LocalDateTime.now()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java new file mode 100644 index 000000000..57d8a7af1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java @@ -0,0 +1,68 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "coupon_issue_requests", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "coupon_id"}) +}) +public class CouponIssueRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @Column + private String reason; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + protected CouponIssueRequest() {} + + public boolean isPending() { + return status == Status.PENDING; + } + + public void succeed() { + this.status = Status.SUCCESS; + this.reason = null; + } + + public void fail(String reason) { + this.status = Status.FAILED; + this.reason = reason; + } + + public enum Status { + PENDING, SUCCESS, FAILED + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java new file mode 100644 index 000000000..3c9082cf5 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponIssueRequestRepository { + Optional findById(Long id); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..e8e7ed037 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponRepository { + Optional findIssuableCoupon(Long couponId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java new file mode 100644 index 000000000..f54810f02 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java @@ -0,0 +1,53 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "issued_coupons", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "coupon_id"}) +}) +public class IssuedCoupon { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + private ZonedDateTime createdAt; + + @Column(nullable = false) + private ZonedDateTime updatedAt; + + protected IssuedCoupon() {} + + public static IssuedCoupon create(Long userId, Long couponId, LocalDateTime expiresAt) { + IssuedCoupon issuedCoupon = new IssuedCoupon(); + issuedCoupon.userId = userId; + issuedCoupon.couponId = couponId; + issuedCoupon.expiresAt = expiresAt; + ZonedDateTime now = ZonedDateTime.now(); + issuedCoupon.createdAt = now; + issuedCoupon.updatedAt = now; + return issuedCoupon; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java new file mode 100644 index 000000000..6915ee8e3 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.coupon; + +public interface IssuedCouponRepository { + boolean existsByUserIdAndCouponId(Long userId, Long couponId); + IssuedCoupon save(IssuedCoupon issuedCoupon); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java new file mode 100644 index 000000000..7c98bc42b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.EventHandled; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventHandledRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java new file mode 100644 index 000000000..e98b54be6 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.ProductMetrics; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductMetricsRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, order_count) + VALUES (:productId, 1, 0, 0) + ON DUPLICATE KEY UPDATE like_count = like_count + 1 + """, nativeQuery = true) + void incrementLikeCount(@Param("productId") Long productId); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, order_count) + VALUES (:productId, 0, 0, 0) + ON DUPLICATE KEY UPDATE like_count = GREATEST(like_count - 1, 0) + """, nativeQuery = true) + void decrementLikeCount(@Param("productId") Long productId); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, order_count) + VALUES (:productId, 0, 1, 0) + ON DUPLICATE KEY UPDATE view_count = view_count + 1 + """, nativeQuery = true) + void incrementViewCount(@Param("productId") Long productId); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, order_count) + VALUES (:productId, 0, 0, 1) + ON DUPLICATE KEY UPDATE order_count = order_count + 1 + """, nativeQuery = true) + void incrementOrderCount(@Param("productId") Long productId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..0b1c9918f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponIssueRequestJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java new file mode 100644 index 000000000..2ea79787c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CouponIssueRequestRepositoryImpl implements CouponIssueRequestRepository { + + private final CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; + + @Override + public Optional findById(Long id) { + return couponIssueRequestJpaRepository.findById(id); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..77f6bdec2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface CouponJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNullAndExpiresAtAfter(Long id, LocalDateTime now); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..05a31d550 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public Optional findIssuableCoupon(Long couponId) { + return couponJpaRepository.findByIdAndDeletedAtIsNullAndExpiresAtAfter(couponId, LocalDateTime.now()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java new file mode 100644 index 000000000..81c49032d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.IssuedCoupon; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IssuedCouponJpaRepository extends JpaRepository { + boolean existsByUserIdAndCouponId(Long userId, Long couponId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java new file mode 100644 index 000000000..4698fad4f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class IssuedCouponRepositoryImpl implements IssuedCouponRepository { + + private final IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Override + public boolean existsByUserIdAndCouponId(Long userId, Long couponId) { + return issuedCouponJpaRepository.existsByUserIdAndCouponId(userId, couponId); + } + + @Override + public IssuedCoupon save(IssuedCoupon issuedCoupon) { + return issuedCouponJpaRepository.save(issuedCoupon); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java new file mode 100644 index 000000000..513e0b45a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.application.EventProcessingService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.Topic; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponIssueConsumer { + private final EventProcessingService eventProcessingService; + + @KafkaListener( + topics = Topic.COUPON_ISSUE_REQUESTS, + groupId = "commerce-streamer-coupon-issue", + containerFactory = KafkaConfig.SINGLE_LISTENER + ) + public void consume(String message, Acknowledgment ack) { + Event event = Event.fromJson(message); + if (event == null) { + log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, message={}", message); + ack.acknowledge(); + return; + } + + eventProcessingService.process(event); + ack.acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java new file mode 100644 index 000000000..b19af8ce7 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.application.EventProcessingService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.Topic; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MetricsEventConsumer { + private final EventProcessingService eventProcessingService; + + @KafkaListener( + topics = {Topic.CATALOG_EVENTS, Topic.ORDER_EVENTS}, + groupId = "commerce-streamer-metrics", + containerFactory = KafkaConfig.SINGLE_LISTENER + ) + public void consume(String message, Acknowledgment ack) { + Event event = Event.fromJson(message); + if (event == null) { + log.warn("[MetricsEventConsumer] 이벤트 파싱 실패, message={}", message); + ack.acknowledge(); + return; + } + + eventProcessingService.process(event); + ack.acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueProcessorTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueProcessorTest.java new file mode 100644 index 000000000..aa90bde03 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueProcessorTest.java @@ -0,0 +1,118 @@ +package com.loopers.application; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.infrastructure.coupon.CouponIssueRequestJpaRepository; +import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@EmbeddedKafka( + partitions = 1, + brokerProperties = {"listeners=PLAINTEXT://localhost:0"}, + topics = {"catalog-events", "order-events", "coupon-issue-requests"} +) +class CouponIssueProcessorTest { + + @Autowired + private CouponIssueProcessor couponIssueProcessor; + + @Autowired + private EntityManager entityManager; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Autowired + private CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("유효한 발급 요청이면 issued_coupon을 생성하고 요청 상태를 SUCCESS로 변경한다.") + @Test + void processesIssueRequest() { + // arrange + Long couponId = insertCoupon(); + Long requestId = insertPendingRequest(couponId, 1L); + + // act + couponIssueProcessor.process(requestId, couponId, 1L); + + // assert + CouponIssueRequest request = couponIssueRequestJpaRepository.findById(requestId).orElseThrow(); + assertAll( + () -> assertThat(issuedCouponJpaRepository.existsByUserIdAndCouponId(1L, couponId)).isTrue(), + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.SUCCESS), + () -> assertThat(request.getReason()).isNull() + ); + } + + @DisplayName("이미 발급된 쿠폰이면 요청 상태를 FAILED로 변경한다.") + @Test + void failsWhenCouponAlreadyIssued() { + // arrange + Long couponId = insertCoupon(); + insertIssuedCoupon(couponId, 1L); + Long requestId = insertPendingRequest(couponId, 1L); + + // act + couponIssueProcessor.process(requestId, couponId, 1L); + + // assert + CouponIssueRequest request = couponIssueRequestJpaRepository.findById(requestId).orElseThrow(); + assertAll( + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.FAILED), + () -> assertThat(request.getReason()).isEqualTo("이미 발급된 쿠폰입니다.") + ); + } + + private Long insertCoupon() { + return transactionTemplate.execute(status -> { + entityManager.createNativeQuery(""" + INSERT INTO coupons (name, discount_type, discount_value, min_order_amount, expires_at, created_at, updated_at) + VALUES ('이벤트 쿠폰', 'FIXED', 1000, 1000, DATE_ADD(NOW(), INTERVAL 30 DAY), NOW(6), NOW(6)) + """) + .executeUpdate(); + return ((Number) entityManager.createNativeQuery("SELECT LAST_INSERT_ID()").getSingleResult()).longValue(); + }); + } + + private Long insertPendingRequest(Long couponId, Long userId) { + return transactionTemplate.execute(status -> { + entityManager.createNativeQuery(""" + INSERT INTO coupon_issue_requests (coupon_id, user_id, status, created_at, updated_at) + VALUES (:couponId, :userId, 'PENDING', NOW(6), NOW(6)) + """) + .setParameter("couponId", couponId) + .setParameter("userId", userId) + .executeUpdate(); + return ((Number) entityManager.createNativeQuery("SELECT LAST_INSERT_ID()").getSingleResult()).longValue(); + }); + } + + private void insertIssuedCoupon(Long couponId, Long userId) { + issuedCouponJpaRepository.save(IssuedCoupon.create(userId, couponId, LocalDateTime.now().plusDays(30))); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java new file mode 100644 index 000000000..1ea8b13bb --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java @@ -0,0 +1,73 @@ +package com.loopers.application; + +import com.loopers.infrastructure.EventHandledRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductLikedEventPayload; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class EventProcessingServiceTest { + + private EventHandledRepository eventHandledRepository; + private EventProcessingService eventProcessingService; + private AtomicInteger handleCount; + + @BeforeEach + void setUp() { + eventHandledRepository = mock(EventHandledRepository.class); + handleCount = new AtomicInteger(0); + + EventHandler handler = new EventHandler<>() { + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PRODUCT_LIKED; + } + + @Override + @SuppressWarnings("unchecked") + public void handle(Event event) { + handleCount.incrementAndGet(); + } + }; + + eventProcessingService = new EventProcessingService(List.of(handler), eventHandledRepository); + } + + @DisplayName("이벤트를 처리하면 핸들러가 실행된다") + @Test + void executes_handler_for_matching_event() { + // arrange + Event event = Event.of(1L, EventType.PRODUCT_LIKED, ProductLikedEventPayload.of(100L, 1L)); + + // act + eventProcessingService.process(event); + + // assert + assertThat(handleCount.get()).isEqualTo(1); + verify(eventHandledRepository).save(any()); + } + + @DisplayName("이미 처리된 이벤트는 핸들러를 실행하지 않는다") + @Test + void skips_already_handled_event() { + // arrange + Event event = Event.of(1L, EventType.PRODUCT_LIKED, ProductLikedEventPayload.of(100L, 1L)); + when(eventHandledRepository.save(any())).thenThrow(new DataIntegrityViolationException("duplicate")); + + // act + eventProcessingService.process(event); + + // assert + assertThat(handleCount.get()).isEqualTo(0); + } +} diff --git a/modules/event-contract/build.gradle.kts b/modules/event-contract/build.gradle.kts new file mode 100644 index 000000000..cf93f274b --- /dev/null +++ b/modules/event-contract/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `java-library` +} + +dependencies { + api("com.fasterxml.jackson.core:jackson-databind") + api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java b/modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java new file mode 100644 index 000000000..1fd9c34a6 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java @@ -0,0 +1,43 @@ +package com.loopers.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.io.IOException; + +public final class DataSerializer { + + private static final ObjectMapper objectMapper = initialize(); + + private DataSerializer() { + } + + private static ObjectMapper initialize() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return mapper; + } + + public static String serialize(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new RuntimeException("[DataSerializer] serialize failed", e); + } + } + + public static T deserialize(String json, Class clazz) { + try { + return objectMapper.readValue(json, clazz); + } catch (IOException e) { + throw new RuntimeException("[DataSerializer] deserialize failed", e); + } + } + + public static T deserialize(Object object, Class clazz) { + return objectMapper.convertValue(object, clazz); + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/Event.java b/modules/event-contract/src/main/java/com/loopers/event/Event.java new file mode 100644 index 000000000..1bd4dca20 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/Event.java @@ -0,0 +1,46 @@ +package com.loopers.event; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class Event { + + private Long eventId; + private EventType type; + private T payload; + + public static Event of(Long eventId, EventType type, T payload) { + Event event = new Event<>(); + event.eventId = eventId; + event.type = type; + event.payload = payload; + return event; + } + + public String toJson() { + return DataSerializer.serialize(this); + } + + @SuppressWarnings("unchecked") + public static Event fromJson(String json) { + EventRaw raw = DataSerializer.deserialize(json, EventRaw.class); + if (raw == null) { + return null; + } + Event event = new Event<>(); + event.eventId = raw.getEventId(); + event.type = EventType.from(raw.getType()); + event.payload = DataSerializer.deserialize(raw.getPayload(), event.type.getPayloadClass()); + return event; + } + + @Getter + @NoArgsConstructor + private static class EventRaw { + private Long eventId; + private String type; + private Object payload; + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/EventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/EventPayload.java new file mode 100644 index 000000000..69e56d551 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/EventPayload.java @@ -0,0 +1,4 @@ +package com.loopers.event; + +public interface EventPayload { +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/EventType.java b/modules/event-contract/src/main/java/com/loopers/event/EventType.java new file mode 100644 index 000000000..82c17a134 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/EventType.java @@ -0,0 +1,30 @@ +package com.loopers.event; + +import com.loopers.event.payload.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum EventType { + + PRODUCT_LIKED(ProductLikedEventPayload.class, Topic.CATALOG_EVENTS), + PRODUCT_UNLIKED(ProductUnlikedEventPayload.class, Topic.CATALOG_EVENTS), + PRODUCT_VIEWED(ProductViewedEventPayload.class, Topic.CATALOG_EVENTS), + ORDER_COMPLETED(OrderCompletedEventPayload.class, Topic.ORDER_EVENTS), + PAYMENT_COMPLETED(PaymentCompletedEventPayload.class, Topic.ORDER_EVENTS), + COUPON_ISSUE_REQUESTED(CouponIssueRequestedEventPayload.class, Topic.COUPON_ISSUE_REQUESTS), + ; + + private final Class payloadClass; + private final String topic; + + public static EventType from(String type) { + return Arrays.stream(values()) + .filter(e -> e.name().equals(type)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown EventType: " + type)); + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/Snowflake.java b/modules/event-contract/src/main/java/com/loopers/event/Snowflake.java new file mode 100644 index 000000000..4b583047a --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/Snowflake.java @@ -0,0 +1,45 @@ +package com.loopers.event; + +import java.util.random.RandomGenerator; + +public class Snowflake { + + private static final long EPOCH = 1704067200000L; // 2024-01-01T00:00:00Z + private static final int MACHINE_ID_BITS = 10; + private static final int SEQUENCE_BITS = 12; + + private static final long MAX_MACHINE_ID = (1L << MACHINE_ID_BITS) - 1; + private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; + + private final long machineId; + private long lastTimestamp = -1L; + private long sequence = 0L; + + public Snowflake() { + this.machineId = RandomGenerator.getDefault().nextLong(MAX_MACHINE_ID + 1); + } + + public synchronized long nextId() { + long timestamp = System.currentTimeMillis() - EPOCH; + if (timestamp == lastTimestamp) { + sequence = (sequence + 1) & MAX_SEQUENCE; + if (sequence == 0) { + timestamp = waitNextMillis(lastTimestamp); + } + } else { + sequence = 0; + } + lastTimestamp = timestamp; + return (timestamp << (MACHINE_ID_BITS + SEQUENCE_BITS)) + | (machineId << SEQUENCE_BITS) + | sequence; + } + + private long waitNextMillis(long lastTimestamp) { + long timestamp = System.currentTimeMillis() - EPOCH; + while (timestamp <= lastTimestamp) { + timestamp = System.currentTimeMillis() - EPOCH; + } + return timestamp; + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/Topic.java b/modules/event-contract/src/main/java/com/loopers/event/Topic.java new file mode 100644 index 000000000..fde90a8f7 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/Topic.java @@ -0,0 +1,10 @@ +package com.loopers.event; + +public final class Topic { + + public static final String CATALOG_EVENTS = "catalog-events"; + public static final String ORDER_EVENTS = "order-events"; + public static final String COUPON_ISSUE_REQUESTS = "coupon-issue-requests"; + + private Topic() {} +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/CouponIssueRequestedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/CouponIssueRequestedEventPayload.java new file mode 100644 index 000000000..ed7dd2cc0 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/CouponIssueRequestedEventPayload.java @@ -0,0 +1,15 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class CouponIssueRequestedEventPayload implements EventPayload { + private Long couponIssueRequestId; + private Long couponId; + private Long userId; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/OrderCompletedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/OrderCompletedEventPayload.java new file mode 100644 index 000000000..702b0cd0c --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/OrderCompletedEventPayload.java @@ -0,0 +1,17 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class OrderCompletedEventPayload implements EventPayload { + private Long orderId; + private Long userId; + private List productIds; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java new file mode 100644 index 000000000..005838374 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java @@ -0,0 +1,18 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class PaymentCompletedEventPayload implements EventPayload { + private Long paymentId; + private Long orderId; + private Long userId; + private List productIds; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/ProductLikedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductLikedEventPayload.java new file mode 100644 index 000000000..e08ef3546 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductLikedEventPayload.java @@ -0,0 +1,14 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class ProductLikedEventPayload implements EventPayload { + private Long productId; + private Long userId; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/ProductUnlikedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductUnlikedEventPayload.java new file mode 100644 index 000000000..08f901ce5 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductUnlikedEventPayload.java @@ -0,0 +1,14 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class ProductUnlikedEventPayload implements EventPayload { + private Long productId; + private Long userId; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/ProductViewedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductViewedEventPayload.java new file mode 100644 index 000000000..c5f3040fa --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductViewedEventPayload.java @@ -0,0 +1,14 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class ProductViewedEventPayload implements EventPayload { + private Long productId; + private Long userId; +} diff --git a/modules/event-contract/src/test/java/com/loopers/event/EventTest.java b/modules/event-contract/src/test/java/com/loopers/event/EventTest.java new file mode 100644 index 000000000..41c526cef --- /dev/null +++ b/modules/event-contract/src/test/java/com/loopers/event/EventTest.java @@ -0,0 +1,31 @@ +package com.loopers.event; + +import com.loopers.event.payload.ProductLikedEventPayload; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventTest { + + @Test + @DisplayName("Event를 JSON으로 직렬화 후 역직렬화하면 eventId, type, payload가 보존된다") + void serializeAndDeserializePreservesAllFields() { + // Arrange + ProductLikedEventPayload payload = ProductLikedEventPayload.of(100L, 1L); + Event event = Event.of(1234L, EventType.PRODUCT_LIKED, payload); + + // Act + String json = event.toJson(); + Event result = Event.fromJson(json); + + // Assert + assertThat(result.getEventId()).isEqualTo(event.getEventId()); + assertThat(result.getType()).isEqualTo(event.getType()); + assertThat(result.getPayload()).isInstanceOf(ProductLikedEventPayload.class); + + ProductLikedEventPayload resultPayload = (ProductLikedEventPayload) result.getPayload(); + assertThat(resultPayload.getProductId()).isEqualTo(payload.getProductId()); + assertThat(resultPayload.getUserId()).isEqualTo(payload.getUserId()); + } +} diff --git a/modules/event-contract/src/test/java/com/loopers/event/SnowflakeTest.java b/modules/event-contract/src/test/java/com/loopers/event/SnowflakeTest.java new file mode 100644 index 000000000..b5a6d2d60 --- /dev/null +++ b/modules/event-contract/src/test/java/com/loopers/event/SnowflakeTest.java @@ -0,0 +1,57 @@ +package com.loopers.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.assertj.core.api.Assertions.assertThat; + +class SnowflakeTest { + + Snowflake snowflake = new Snowflake(); + + @Test + @DisplayName("멀티스레드 환경에서 생성된 ID는 유니크하고 각 작업 내에서는 증가한다") + void nextIdIsUniqueAndIncreasingWithinEachTaskUnderConcurrency() throws ExecutionException, InterruptedException { + // Arrange + ExecutorService executorService = Executors.newFixedThreadPool(10); + try { + List>> futures = new ArrayList<>(); + int repeatCount = 1000; + int idCount = 1000; + + // Act + for (int i = 0; i < repeatCount; i++) { + futures.add(executorService.submit(() -> generateIdList(snowflake, idCount))); + } + + // Assert + List result = new ArrayList<>(); + for (Future> future : futures) { + List idList = future.get(); + for (int i = 1; i < idList.size(); i++) { + assertThat(idList.get(i)).isGreaterThan(idList.get(i - 1)); + } + result.addAll(idList); + } + + assertThat(result.stream().distinct().count()).isEqualTo((long) repeatCount * idCount); + } finally { + executorService.shutdown(); + } + } + + private List generateIdList(Snowflake snowflake, int count) { + List ids = new ArrayList<>(); + for (int i = 0; i < count; i++) { + ids.add(snowflake.nextId()); + } + return ids; + } +} diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index a73842775..1878b9e7a 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -2,16 +2,20 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; -import org.springframework.kafka.core.*; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.DefaultErrorHandler; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter; +import org.springframework.util.backoff.FixedBackOff; import java.util.HashMap; import java.util.Map; @@ -20,6 +24,7 @@ @Configuration @EnableConfigurationProperties(KafkaProperties.class) public class KafkaConfig { + public static final String SINGLE_LISTENER = "SINGLE_LISTENER_DEFAULT"; public static final String BATCH_LISTENER = "BATCH_LISTENER_DEFAULT"; public static final int MAX_POLLING_SIZE = 3000; // read 3000 msg @@ -29,11 +34,8 @@ public class KafkaConfig { public static final int HEARTBEAT_INTERVAL_MS = 20 * 1000; // heartbeat interval = 20s ( 1/3 of session_timeout ) public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m - @Bean - public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { - Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); - return new DefaultKafkaProducerFactory<>(props); - } + private static final long RETRY_INTERVAL_MS = 1000L; + private static final long MAX_RETRY_ATTEMPTS = 3L; @Bean public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { @@ -41,16 +43,27 @@ public ConsumerFactory consumerFactory(KafkaProperties kafkaProp return new DefaultKafkaConsumerFactory<>(props); } - @Bean - public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { - return new KafkaTemplate<>(producerFactory); - } - @Bean public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMapper) { return new ByteArrayJsonMessageConverter(objectMapper); } + @Bean(name = SINGLE_LISTENER) + public ConcurrentKafkaListenerContainerFactory defaultSingleListenerContainerFactory( + KafkaProperties kafkaProperties + ) { + Map consumerConfig = new HashMap<>(kafkaProperties.buildConsumerProperties()); + consumerConfig.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + consumerConfig.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.setBatchListener(false); + factory.setCommonErrorHandler(new DefaultErrorHandler(new FixedBackOff(RETRY_INTERVAL_MS, MAX_RETRY_ATTEMPTS))); + return factory; + } + @Bean(name = BATCH_LISTENER) public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..59b3c91e8 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -12,9 +12,12 @@ spring: offset.reset: latest use.latest.version: true producer: + acks: all key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + properties: + enable.idempotence: true consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer diff --git a/settings.gradle.kts b/settings.gradle.kts index 240be71d2..411852a99 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,6 +8,7 @@ include( ":modules:jpa", ":modules:redis", ":modules:kafka", + ":modules:event-contract", ":supports:jackson", ":supports:logging", ":supports:monitoring",