From 3cecbfa8112e2cc995cccd126a237a2352e24752 Mon Sep 17 00:00:00 2001 From: dfdf0202 Date: Fri, 27 Mar 2026 15:59:32 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=207=EC=A3=BC=20=EA=B3=BC=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 1 + .../com/loopers/CommerceApiApplication.java | 2 + .../application/coupon/CouponFacade.java | 46 +++ .../coupon/dto/CouponIssueRequestResDto.java | 11 + .../event/FavoriteEventListener.java | 61 ++++ .../application/event/OrderEventListener.java | 73 ++++ .../event/OrderExpireScheduler.java | 38 ++ .../application/event/OutboxRelay.java | 49 +++ .../event/PaymentEventListener.java | 82 +++++ .../application/favorite/FavoriteFacade.java | 8 +- .../application/order/OrderFacade.java | 9 +- .../application/payment/PaymentFacade.java | 66 ++-- .../coupon/model/CouponIssueRequest.java | 43 +++ .../coupon/model/CouponIssueStatus.java | 7 + .../domain/coupon/model/FirstComeCoupon.java | 38 ++ .../domain/coupon/model/UserCoupon.java | 8 + .../CouponIssueRequestRepository.java | 19 + .../repository/FirstComeCouponRepository.java | 10 + .../domain/coupon/service/CouponService.java | 7 + .../service/FirstComeCouponService.java | 96 ++++++ .../domain/event/FavoriteAddedEvent.java | 6 + .../domain/event/FavoriteRemovedEvent.java | 4 + .../domain/event/OrderCreatedEvent.java | 4 + .../domain/event/OrderExpiredEvent.java | 4 + .../domain/event/PaymentCompletedEvent.java | 4 + .../domain/event/PaymentFailedEvent.java | 4 + .../domain/event/PaymentRequestedEvent.java | 13 + .../com/loopers/domain/order/OrderStatus.java | 3 +- .../order/repository/OrderRepository.java | 2 + .../domain/order/service/OrderService.java | 6 + .../domain/outbox/model/OutboxEvent.java | 45 +++ .../domain/outbox/model/OutboxEventType.java | 8 + .../domain/outbox/model/OutboxStatus.java | 7 + .../repository/OutboxEventRepository.java | 12 + .../loopers/domain/payment/PaymentInfo.java | 2 +- .../domain/payment/PgPaymentStatus.java | 7 + .../entity/CouponIssueRequestEntity.java | 58 ++++ .../coupon/entity/FirstComeCouponEntity.java | 41 +++ .../CouponIssueRequestJpaRepository.java | 11 + .../FirstComeCouponJpaRepository.java | 11 + .../CouponIssueRequestRepositoryImpl.java | 48 +++ .../impl/FirstComeCouponRepositoryImpl.java | 22 ++ .../order/repository/OrderJpaRepository.java | 3 + .../repository/impl/OrderRepositoryImpl.java | 8 + .../outbox/entity/OutboxEventEntity.java | 65 ++++ .../repository/OutboxEventJpaRepository.java | 12 + .../impl/OutboxEventRepositoryImpl.java | 31 ++ .../infrastructure/pg/PgPaymentAdapter.java | 11 +- .../api/coupon/CouponV1Controller.java | 20 ++ .../api/payment/PaymentV1Controller.java | 3 +- .../src/main/resources/application.yml | 3 + .../favorite/FavoriteFacadeTest.java | 18 +- .../application/order/OrderFacadeTest.java | 4 + .../payment/PaymentFacadeIntegrationTest.java | 324 ++++++++++++++++++ .../payment/PaymentFacadeTest.java | 237 +++++++++++++ .../FirstComeCouponConcurrencyTest.java | 105 ++++++ .../coupon/FirstComeCouponScenarioTest.java | 168 +++++++++ .../infrastructure/pg/Ch5_FallbackTest.java | 2 +- .../domain/event/model/EventHandleStatus.java | 7 + .../loopers/domain/event/model/EventType.java | 8 + .../CouponIssueRequestStreamerEntity.java | 42 +++ .../entity/UserCouponStreamerEntity.java | 41 +++ ...uponIssueRequestStreamerJpaRepository.java | 7 + .../event/entity/EventHandledEntity.java | 40 +++ .../repository/EventHandledJpaRepository.java | 7 + .../metrics/entity/ProductMetricsEntity.java | 70 ++++ .../ProductMetricsJpaRepository.java | 7 + .../consumer/CatalogEventConsumer.java | 71 ++++ .../consumer/CouponIssueConsumer.java | 69 ++++ .../consumer/OrderEventConsumer.java | 57 +++ .../consumer/dto/CatalogEventMessage.java | 15 + .../consumer/dto/CouponIssueMessage.java | 7 + .../consumer/dto/OrderEventMessage.java | 16 + modules/jpa/src/main/resources/jpa.yml | 3 + .../com/loopers/confg/kafka/KafkaConfig.java | 17 + modules/kafka/src/main/resources/kafka.yml | 3 + 76 files changed, 2442 insertions(+), 55 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/dto/CouponIssueRequestResDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/OrderExpireScheduler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/FirstComeCouponRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/FavoriteAddedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/FavoriteRemovedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/OrderExpiredEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentCompletedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentRequestedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEventType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/repository/OutboxEventRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PgPaymentStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/FirstComeCouponEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/FirstComeCouponJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/impl/CouponIssueRequestRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/impl/FirstComeCouponRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/entity/OutboxEventEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/OutboxEventJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/impl/OutboxEventRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponConcurrencyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponScenarioTest.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventHandleStatus.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventType.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestStreamerEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/UserCouponStreamerEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestStreamerJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/entity/EventHandledEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/repository/EventHandledJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/entity/ProductMetricsEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/CatalogEventMessage.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/CouponIssueMessage.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/OrderEventMessage.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 265664ab8..d02debadd 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51bf..5042b2ba2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,8 +4,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; +@EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication public class CommerceApiApplication { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java index 2bc3fedbd..423a2c149 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java @@ -1,18 +1,28 @@ package com.loopers.application.coupon; +import com.loopers.application.coupon.dto.CouponIssueRequestResDto; import com.loopers.application.coupon.dto.FindMyCouponResDto; import com.loopers.application.coupon.dto.IssueCouponResDto; +import com.loopers.domain.coupon.model.CouponIssueRequest; +import com.loopers.domain.coupon.model.FirstComeCoupon; import com.loopers.domain.coupon.model.UserCoupon; import com.loopers.domain.coupon.model.UserCouponItem; +import com.loopers.domain.coupon.repository.CouponIssueRequestRepository; import com.loopers.domain.coupon.service.CouponService; +import com.loopers.domain.coupon.service.FirstComeCouponService; import com.loopers.domain.member.model.Member; import com.loopers.domain.member.service.MemberService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.Map; + @RequiredArgsConstructor @Component @Transactional(readOnly = true) @@ -20,6 +30,9 @@ public class CouponFacade { private final CouponService couponService; private final MemberService memberService; + private final FirstComeCouponService firstComeCouponService; + private final CouponIssueRequestRepository couponIssueRequestRepository; + private final KafkaTemplate kafkaTemplate; @Transactional(rollbackFor = Exception.class) public IssueCouponResDto issueCoupon(String loginId, String password, Long couponTemplateId) { @@ -33,4 +46,37 @@ public Page getMyCoupons(String loginId, String password, Pa Page items = couponService.getUserCouponsWithTemplate(member.getId(), pageable); return items.map(FindMyCouponResDto::from); } + + @Transactional(rollbackFor = Exception.class) + public CouponIssueRequestResDto requestFirstComeIssue(String loginId, String password, Long couponTemplateId) { + Member member = memberService.findMember(loginId, password); + FirstComeCoupon fcCoupon = firstComeCouponService.getByTemplateId(couponTemplateId); + + firstComeCouponService.addToQueue(fcCoupon, member.getId()); + try { + CouponIssueRequest request = couponIssueRequestRepository.save( + CouponIssueRequest.create(couponTemplateId, member.getId())); + + kafkaTemplate.send("coupon-issue-requests", + String.valueOf(couponTemplateId), + Map.of("requestId", request.getId(), + "couponTemplateId", couponTemplateId, + "memberId", member.getId())); + + return CouponIssueRequestResDto.from(request); + } catch (Exception e) { + firstComeCouponService.removeFromQueue(couponTemplateId, member.getId()); + throw e; + } + } + + public CouponIssueRequestResDto getIssueRequestStatus(String loginId, String password, Long requestId) { + Member member = memberService.findMember(loginId, password); + CouponIssueRequest request = couponIssueRequestRepository.findById(requestId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 발급 요청입니다.")); + if (!request.getMemberId().equals(member.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 발급 요청입니다."); + } + return CouponIssueRequestResDto.from(request); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/dto/CouponIssueRequestResDto.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/dto/CouponIssueRequestResDto.java new file mode 100644 index 000000000..6c8750b41 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/dto/CouponIssueRequestResDto.java @@ -0,0 +1,11 @@ +package com.loopers.application.coupon.dto; + +import com.loopers.domain.coupon.model.CouponIssueRequest; +import com.loopers.domain.coupon.model.CouponIssueStatus; + +public record CouponIssueRequestResDto(Long requestId, Long couponTemplateId, CouponIssueStatus status) { + + public static CouponIssueRequestResDto from(CouponIssueRequest request) { + return new CouponIssueRequestResDto(request.getId(), request.getCouponTemplateId(), request.getStatus()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java new file mode 100644 index 000000000..2e4317147 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/FavoriteEventListener.java @@ -0,0 +1,61 @@ +package com.loopers.application.event; + +import com.loopers.domain.event.FavoriteAddedEvent; +import com.loopers.domain.event.FavoriteRemovedEvent; +import com.loopers.domain.outbox.model.OutboxEvent; +import com.loopers.domain.outbox.model.OutboxEventType; +import com.loopers.domain.outbox.repository.OutboxEventRepository; +import com.loopers.domain.product.service.ProductService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@Component +public class FavoriteEventListener { + + private final ProductService productService; + private final OutboxEventRepository outboxEventRepository; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void onFavoriteAdded(FavoriteAddedEvent event) { + log.info("좋아요 집계 처리 - productId: {}, memberId: {}", event.productId(), event.memberId()); + productService.increaseLikeCount(event.productId()); + outboxEventRepository.save(OutboxEvent.create( + OutboxEventType.FAVORITE_ADDED, + String.valueOf(event.productId()), + toJson(event) + )); + log.info("유저 행동 로깅 - memberId: {}, action: FAVORITE_ADD, targetId: {}", event.memberId(), event.productId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void onFavoriteRemoved(FavoriteRemovedEvent event) { + log.info("좋아요 집계 해제 처리 - productId: {}, memberId: {}", event.productId(), event.memberId()); + productService.decreaseLikeCount(event.productId()); + outboxEventRepository.save(OutboxEvent.create( + OutboxEventType.FAVORITE_REMOVED, + String.valueOf(event.productId()), + toJson(event) + )); + log.info("유저 행동 로깅 - memberId: {}, action: FAVORITE_REMOVE, targetId: {}", event.memberId(), event.productId()); + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new RuntimeException("이벤트 직렬화 실패", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java new file mode 100644 index 000000000..bcd2d39ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java @@ -0,0 +1,73 @@ +package com.loopers.application.event; + +import com.loopers.domain.coupon.service.CouponService; +import com.loopers.domain.event.OrderCreatedEvent; +import com.loopers.domain.event.OrderExpiredEvent; +import com.loopers.domain.order.model.OrderProduct; +import com.loopers.domain.order.service.OrderProductService; +import com.loopers.domain.outbox.model.OutboxEvent; +import com.loopers.domain.outbox.model.OutboxEventType; +import com.loopers.domain.outbox.repository.OutboxEventRepository; +import com.loopers.domain.product.service.ProductService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class OrderEventListener { + + private final OutboxEventRepository outboxEventRepository; + private final OrderProductService orderProductService; + private final ProductService productService; + private final CouponService couponService; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void onOrderCreated(OrderCreatedEvent event) { + log.info("주문 생성 이벤트 - orderId: {}, memberId: {}, totalPrice: {}", event.orderId(), event.memberId(), event.totalPrice()); + outboxEventRepository.save(OutboxEvent.create( + OutboxEventType.ORDER_CREATED, + String.valueOf(event.orderId()), + toJson(event) + )); + log.info("유저 행동 로깅 - memberId: {}, action: ORDER_CREATE, targetId: {}", event.memberId(), event.orderId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void onOrderExpired(OrderExpiredEvent event) { + log.info("주문 만료 처리 - orderId: {}", event.orderId()); + try { + List products = orderProductService.findByOrderId(event.orderId()); + for (OrderProduct op : products) { + productService.increaseStockAtomic(op.getProductId(), op.getQuantity().value()); + } + if (event.userCouponId() != null) { + couponService.restoreUserCoupon(event.userCouponId()); + } + log.info("주문 만료 복원 완료 - orderId: {}, 재고 복원 {}건, 쿠폰 복원: {}", + event.orderId(), products.size(), event.userCouponId() != null); + } catch (Exception e) { + log.error("주문 만료 복원 실패 - orderId: {}, 수동 확인 필요", event.orderId(), e); + } + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new RuntimeException("이벤트 직렬화 실패", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderExpireScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderExpireScheduler.java new file mode 100644 index 000000000..23e9322f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderExpireScheduler.java @@ -0,0 +1,38 @@ +package com.loopers.application.event; + +import com.loopers.domain.event.OrderExpiredEvent; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.model.Orders; +import com.loopers.domain.order.service.OrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class OrderExpireScheduler { + + private final OrderService orderService; + private final ApplicationEventPublisher eventPublisher; + + @Scheduled(fixedDelay = 60000) + @Transactional + public void expireUnpaidOrders() { + List expiredOrders = orderService.findExpiredOrders(); + if (expiredOrders.isEmpty()) { + return; + } + + for (Orders order : expiredOrders) { + orderService.updateOrderStatus(order.getId(), OrderStatus.CANCELLED); + eventPublisher.publishEvent(new OrderExpiredEvent(order.getId(), order.getUserCouponId())); + log.info("주문 만료 처리 - orderId: {}", order.getId()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java new file mode 100644 index 000000000..3942b858f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java @@ -0,0 +1,49 @@ +package com.loopers.application.event; + +import com.loopers.domain.outbox.model.OutboxEvent; +import com.loopers.domain.outbox.model.OutboxEventType; +import com.loopers.domain.outbox.repository.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class OutboxRelay { + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + + @Scheduled(fixedDelay = 1000) + public void relay() { + List events = outboxEventRepository.findByStatusInit(); + if (events.isEmpty()) { + return; + } + + for (OutboxEvent event : events) { + String topic = resolveTopic(event.getEventType()); + try { + kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()).get(); + event.markPublished(); + log.info("Outbox 이벤트 발행 - topic: {}, key: {}, eventType: {}", topic, event.getAggregateId(), event.getEventType()); + } catch (Exception e) { + event.markFailed(); + log.error("Outbox 이벤트 발행 실패 - eventId: {}", event.getId(), e); + } + outboxEventRepository.save(event); + } + } + + private String resolveTopic(OutboxEventType eventType) { + return switch (eventType) { + case FAVORITE_ADDED, FAVORITE_REMOVED -> "catalog-events"; + case ORDER_CREATED, PAYMENT_COMPLETED -> "order-events"; + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java new file mode 100644 index 000000000..e81b13e37 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java @@ -0,0 +1,82 @@ +package com.loopers.application.event; + +import com.loopers.domain.event.PaymentCompletedEvent; +import com.loopers.domain.event.PaymentFailedEvent; +import com.loopers.domain.event.PaymentRequestedEvent; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.service.OrderService; +import com.loopers.domain.outbox.model.OutboxEvent; +import com.loopers.domain.outbox.model.OutboxEventType; +import com.loopers.domain.outbox.repository.OutboxEventRepository; +import com.loopers.domain.payment.PaymentCommand; +import com.loopers.domain.payment.PaymentGateway; +import com.loopers.domain.payment.PaymentInfo; +import com.loopers.domain.payment.service.PaymentService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@Component +public class PaymentEventListener { + + private final OutboxEventRepository outboxEventRepository; + private final PaymentService paymentService; + private final PaymentGateway paymentGateway; + private final OrderService orderService; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void onPaymentRequested(PaymentRequestedEvent event) { + log.info("PG 결제 요청 처리 - paymentId: {}, orderId: {}", event.paymentId(), event.orderId()); + PaymentCommand.PgRequest pgCommand = new PaymentCommand.PgRequest( + event.orderNumber(), event.cardType(), event.cardNo(), event.amount(), event.callbackUrl() + ); + PaymentInfo info = paymentGateway.requestPayment(event.memberId(), pgCommand); + + if (!info.hasTransactionKey()) { + paymentService.handlePgFailure(event.paymentId()); + orderService.updateOrderStatus(event.orderId(), OrderStatus.PAYMENT_FAILED); + log.warn("PG 결제 요청 실패 - paymentId: {}", event.paymentId()); + return; + } + + paymentService.markRequested(event.paymentId(), info.transactionKey()); + orderService.updateOrderStatus(event.orderId(), OrderStatus.PAYMENT_REQUESTED); + log.info("PG 결제 요청 성공 - paymentId: {}, transactionKey: {}", event.paymentId(), info.transactionKey()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void onPaymentCompleted(PaymentCompletedEvent event) { + log.info("결제 성공 이벤트 - orderId: {}, paymentId: {}", event.orderId(), event.paymentId()); + outboxEventRepository.save(OutboxEvent.create( + OutboxEventType.PAYMENT_COMPLETED, + event.orderId(), + toJson(event) + )); + log.info("유저 행동 로깅 - action: PAYMENT_SUCCESS, orderId: {}", event.orderId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPaymentFailed(PaymentFailedEvent event) { + log.info("결제 실패 이벤트 - orderId: {}, paymentId: {}, reason: {}", event.orderId(), event.paymentId(), event.reason()); + log.info("유저 행동 로깅 - action: PAYMENT_FAILED, orderId: {}", event.orderId()); + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new RuntimeException("이벤트 직렬화 실패", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java index fec6bb36f..2ffdf7dec 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java @@ -1,5 +1,7 @@ package com.loopers.application.favorite; +import com.loopers.domain.event.FavoriteAddedEvent; +import com.loopers.domain.event.FavoriteRemovedEvent; import com.loopers.domain.favorite.model.FavoriteCommand; import com.loopers.domain.favorite.service.FavoriteService; import com.loopers.domain.member.model.Member; @@ -7,6 +9,7 @@ import com.loopers.domain.product.model.Product; import com.loopers.domain.product.service.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +21,7 @@ public class FavoriteFacade { private final FavoriteService favoriteService; private final MemberService memberService; private final ProductService productService; + private final ApplicationEventPublisher eventPublisher; @Transactional(rollbackFor = {Exception.class}) public void addFavorite(String loginId, String password, Long productId) { @@ -26,7 +30,7 @@ public void addFavorite(String loginId, String password, Long productId) { FavoriteCommand.Add command = new FavoriteCommand.Add(member.getId(), product.getId()); boolean added = favoriteService.addFavorite(command); if (added) { - productService.increaseLikeCount(product.getId()); + eventPublisher.publishEvent(new FavoriteAddedEvent(product.getId(), member.getId())); } } @@ -36,6 +40,6 @@ public void deleteFavorite(String loginId, String password, Long productId) { Product product = productService.findProduct(productId); FavoriteCommand.Delete command = new FavoriteCommand.Delete(member.getId(), product.getId()); favoriteService.delete(command); - productService.decreaseLikeCount(product.getId()); + eventPublisher.publishEvent(new FavoriteRemovedEvent(product.getId(), member.getId())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 6c2d4a338..9ae39f288 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -4,6 +4,7 @@ import com.loopers.application.order.dto.FindOrderResDto; import com.loopers.domain.coupon.model.CouponTemplate; import com.loopers.domain.coupon.service.CouponService; +import com.loopers.domain.event.OrderCreatedEvent; import com.loopers.domain.member.model.Member; import com.loopers.domain.member.service.MemberService; import com.loopers.domain.order.model.OrderProduct; @@ -13,6 +14,7 @@ import com.loopers.domain.order.service.OrderService; import com.loopers.domain.product.service.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +31,7 @@ public class OrderFacade { private final MemberService memberService; private final ProductService productService; private final CouponService couponService; + private final ApplicationEventPublisher eventPublisher; @Transactional(rollbackFor = {Exception.class}) public FindOrderResDto createOrder(String loginId, String password, CreateOrderReqDto dto) { @@ -40,7 +43,11 @@ public FindOrderResDto createOrder(String loginId, String password, CreateOrderR int discountAmount = calculateDiscount(dto.userCouponId(), member.getId(), subtotal); OrderCommand.Create command = new OrderCommand.Create(member.getId(), orderProducts, discountAmount, dto.userCouponId()); - return FindOrderResDto.from(orderService.createOrder(command)); + Orders savedOrder = orderService.createOrder(command); + + eventPublisher.publishEvent(new OrderCreatedEvent(savedOrder.getId(), member.getId(), savedOrder.getTotalPrice().value())); + + return FindOrderResDto.from(savedOrder); } public List getOrders(String loginId, String password, LocalDateTime startAt, LocalDateTime endAt) { 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 5adb6873b..0862d441e 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 @@ -4,6 +4,9 @@ import com.loopers.application.payment.dto.FindPaymentResDto; import com.loopers.domain.member.model.Member; import com.loopers.domain.member.service.MemberService; +import com.loopers.domain.event.PaymentCompletedEvent; +import com.loopers.domain.event.PaymentFailedEvent; +import com.loopers.domain.event.PaymentRequestedEvent; import com.loopers.domain.order.OrderStatus; import com.loopers.domain.order.model.OrderProduct; import com.loopers.domain.order.model.Orders; @@ -13,15 +16,16 @@ import com.loopers.domain.payment.PaymentGateway; import com.loopers.domain.payment.PaymentInfo; import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.payment.PgPaymentStatus; import com.loopers.domain.payment.model.Payment; import com.loopers.domain.payment.service.PaymentService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionTemplate; import java.util.List; @@ -34,83 +38,63 @@ public class PaymentFacade { private final MemberService memberService; private final OrderService orderService; private final OrderProductService orderProductService; - private final TransactionTemplate transactionTemplate; + private final ApplicationEventPublisher eventPublisher; @Value("${payment.callback-url}") private String callbackUrl; + @Transactional(rollbackFor = Exception.class) public FindPaymentResDto createPayment(String loginId, String password, CreatePaymentReqDto dto) { Member member = memberService.findMember(loginId, password); Orders order = orderService.getOrderByOrderNumber(dto.orderId()); + if (!order.getMemberId().equals(member.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다."); + } String amount = String.valueOf(order.getTotalPrice().value()); List orderProducts = orderProductService.findByOrderId(order.getId()); - // Phase 1: Payment PENDING 생성 + 스냅샷 저장 PaymentCommand.Create createCommand = new PaymentCommand.Create( order.getOrderNumber(), member.getId(), dto.cardType(), dto.cardNo(), amount ); - Payment payment = transactionTemplate.execute(status -> - paymentService.createPaymentWithSnapshots(createCommand, orderProducts)); - - // Phase 2: PG 호출 (트랜잭션 없음 — DB 커넥션 미점유) - PaymentCommand.PgRequest pgCommand = new PaymentCommand.PgRequest( - dto.orderId(), dto.cardType(), dto.cardNo(), amount, callbackUrl - ); - PaymentInfo info = paymentGateway.requestPayment(member.getId(), pgCommand); - - if (!info.hasTransactionKey()) { - transactionTemplate.executeWithoutResult(status -> { - paymentService.handlePgFailure(payment.getId()); - orderService.updateOrderStatus(order.getId(), OrderStatus.PAYMENT_FAILED); - }); - throw new CoreException(ErrorType.INTERNAL_ERROR, "결제 서비스에 일시적인 문제가 발생했습니다."); - } + Payment payment = paymentService.createPaymentWithSnapshots(createCommand, orderProducts); - // Phase 3: REQUESTED + 주문 상태 변경 - transactionTemplate.executeWithoutResult(status -> { - paymentService.markRequested(payment.getId(), info.transactionKey()); - orderService.updateOrderStatus(order.getId(), OrderStatus.PAYMENT_REQUESTED); - }); + eventPublisher.publishEvent(new PaymentRequestedEvent( + payment.getId(), member.getId(), order.getId(), + order.getOrderNumber(), dto.cardType(), dto.cardNo(), amount, callbackUrl)); - payment.markRequested(info.transactionKey()); return FindPaymentResDto.from(payment); } @Transactional(rollbackFor = Exception.class) - public void handleCallback(String transactionKey, String status) { + public void handleCallback(String transactionKey, PgPaymentStatus status) { Payment payment = paymentService.getPaymentByTransactionKey(transactionKey); - Orders order = orderService.getOrderByOrderNumber(payment.getOrderId()); - if ("SUCCESS".equals(status)) { + if (status == PgPaymentStatus.SUCCESS) { paymentService.markSuccess(transactionKey); orderService.updateOrderStatus(order.getId(), OrderStatus.PAID); + eventPublisher.publishEvent(new PaymentCompletedEvent(payment.getOrderId(), payment.getId())); } else { - paymentService.markFailed(transactionKey, status); + paymentService.markFailed(transactionKey, status.name()); orderService.updateOrderStatus(order.getId(), OrderStatus.PAYMENT_FAILED); paymentService.restoreStock(payment.getId()); + eventPublisher.publishEvent(new PaymentFailedEvent(payment.getOrderId(), payment.getId(), status.name())); } } - @Transactional(rollbackFor = Exception.class) public FindPaymentResDto checkPaymentStatus(String loginId, String password, String orderId) { - memberService.findMember(loginId, password); + Member member = memberService.findMember(loginId, password); Payment payment = paymentService.getPaymentByOrderId(orderId); + if (!payment.getMemberId().equals(member.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."); + } if (payment.getStatus() == PaymentStatus.REQUESTED && payment.getTransactionKey() != null) { PaymentInfo info = paymentGateway.getPaymentByOrderId(payment.getMemberId(), orderId); if (info != null && info.transactionKey() != null && info.status() != null) { - Orders order = orderService.getOrderByOrderNumber(orderId); - if ("SUCCESS".equals(info.status())) { - paymentService.markSuccess(payment.getTransactionKey()); - orderService.updateOrderStatus(order.getId(), OrderStatus.PAID); - payment.markSuccess(); - } else if (!"PENDING".equals(info.status())) { - paymentService.markFailed(payment.getTransactionKey(), info.status()); - orderService.updateOrderStatus(order.getId(), OrderStatus.PAYMENT_FAILED); - paymentService.restoreStock(payment.getId()); - payment.markFailed(info.status()); + if (info.status() != PgPaymentStatus.PENDING) { + handleCallback(payment.getTransactionKey(), info.status()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueRequest.java new file mode 100644 index 000000000..4e44fafe0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueRequest.java @@ -0,0 +1,43 @@ +package com.loopers.domain.coupon.model; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CouponIssueRequest { + + private Long id; + private Long couponTemplateId; + private Long memberId; + private CouponIssueStatus status; + private LocalDateTime createdAt; + + private CouponIssueRequest(Long couponTemplateId, Long memberId) { + this.couponTemplateId = couponTemplateId; + this.memberId = memberId; + this.status = CouponIssueStatus.PENDING; + this.createdAt = LocalDateTime.now(); + } + + public static CouponIssueRequest create(Long couponTemplateId, Long memberId) { + return new CouponIssueRequest(couponTemplateId, memberId); + } + + public static CouponIssueRequest reconstruct(Long id, Long couponTemplateId, Long memberId, + CouponIssueStatus status, LocalDateTime createdAt) { + CouponIssueRequest request = new CouponIssueRequest(couponTemplateId, memberId); + request.id = id; + request.status = status; + request.createdAt = createdAt; + return request; + } + + public void markIssued() { + this.status = CouponIssueStatus.ISSUED; + } + + public void markRejected() { + this.status = CouponIssueStatus.REJECTED; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueStatus.java new file mode 100644 index 000000000..8e0998db2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/CouponIssueStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon.model; + +public enum CouponIssueStatus { + PENDING, + ISSUED, + REJECTED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java new file mode 100644 index 000000000..e05943b8e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/FirstComeCoupon.java @@ -0,0 +1,38 @@ +package com.loopers.domain.coupon.model; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class FirstComeCoupon { + + private Long id; + private Long couponTemplateId; + private int maxQuantity; + private LocalDateTime startAt; + private LocalDateTime endAt; + + private FirstComeCoupon(Long couponTemplateId, int maxQuantity, LocalDateTime startAt, LocalDateTime endAt) { + this.couponTemplateId = couponTemplateId; + this.maxQuantity = maxQuantity; + this.startAt = startAt; + this.endAt = endAt; + } + + public static FirstComeCoupon create(Long couponTemplateId, int maxQuantity, LocalDateTime startAt, LocalDateTime endAt) { + return new FirstComeCoupon(couponTemplateId, maxQuantity, startAt, endAt); + } + + public static FirstComeCoupon reconstruct(Long id, Long couponTemplateId, int maxQuantity, + LocalDateTime startAt, LocalDateTime endAt) { + FirstComeCoupon fc = new FirstComeCoupon(couponTemplateId, maxQuantity, startAt, endAt); + fc.id = id; + return fc; + } + + public boolean isActive() { + LocalDateTime now = LocalDateTime.now(); + return now.isAfter(startAt) && now.isBefore(endAt); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/UserCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/UserCoupon.java index 5344c442a..ade034f3e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/UserCoupon.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/model/UserCoupon.java @@ -44,6 +44,14 @@ public void use() { this.usedAt = LocalDateTime.now(); } + public void restore() { + if (this.status != CouponEnums.Status.USED) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용된 쿠폰만 복원할 수 있습니다."); + } + this.status = CouponEnums.Status.AVAILABLE; + this.usedAt = null; + } + public void validateOwnership(Long requestMemberId) { if (!this.memberId.equals(requestMemberId)) { throw new CoreException(ErrorType.BAD_REQUEST, "본인 소유의 쿠폰만 사용할 수 있습니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java new file mode 100644 index 000000000..74509e95f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.coupon.repository; + +import com.loopers.domain.coupon.model.CouponIssueRequest; +import com.loopers.domain.coupon.model.CouponIssueStatus; + +import java.util.Optional; + +public interface CouponIssueRequestRepository { + + CouponIssueRequest save(CouponIssueRequest request); + + Optional findById(Long id); + + void updateStatus(Long id, CouponIssueStatus status); + + long countByTemplateId(Long couponTemplateId); + + boolean existsByTemplateIdAndMemberId(Long couponTemplateId, Long memberId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/FirstComeCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/FirstComeCouponRepository.java new file mode 100644 index 000000000..136e84706 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/FirstComeCouponRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.coupon.repository; + +import com.loopers.domain.coupon.model.FirstComeCoupon; + +import java.util.Optional; + +public interface FirstComeCouponRepository { + + Optional findByTemplateId(Long couponTemplateId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java index 3a97d52c9..af6aaeecb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java @@ -83,6 +83,13 @@ public Page getIssuedCoupons(Long couponTemplateId, Pageable pageabl return userCouponRepository.findByCouponTemplateId(couponTemplateId, pageable); } + public void restoreUserCoupon(Long userCouponId) { + UserCoupon userCoupon = userCouponRepository.findById(userCouponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 쿠폰입니다.")); + userCoupon.restore(); + userCouponRepository.update(userCoupon); + } + public CouponTemplate useUserCoupon(Long userCouponId, Long memberId, int orderAmount) { UserCoupon userCoupon = userCouponRepository.findById(userCouponId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 쿠폰입니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java new file mode 100644 index 000000000..2ab03b853 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java @@ -0,0 +1,96 @@ +package com.loopers.domain.coupon.service; + +import com.loopers.domain.coupon.model.FirstComeCoupon; +import com.loopers.domain.coupon.repository.CouponIssueRequestRepository; +import com.loopers.domain.coupon.repository.FirstComeCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@RequiredArgsConstructor +@Component +public class FirstComeCouponService { + + private final FirstComeCouponRepository firstComeCouponRepository; + private final CouponIssueRequestRepository couponIssueRequestRepository; + private final RedisTemplate redisTemplate; + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + private static final String REDIS_KEY_PREFIX = "coupon:fcfs:"; + + private static final RedisScript FCFS_SCRIPT = new DefaultRedisScript<>(""" + local key = KEYS[1] + local memberId = ARGV[1] + local maxQty = tonumber(ARGV[2]) + local score = tonumber(ARGV[3]) + if redis.call('zscore', key, memberId) then + return -1 + end + if redis.call('zcard', key) >= maxQty then + return -2 + end + redis.call('zadd', key, score, memberId) + return 1 + """, Long.class); + + public FirstComeCoupon getByTemplateId(Long templateId) { + return cache.computeIfAbsent(templateId, + id -> firstComeCouponRepository.findByTemplateId(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "선착순 쿠폰 이벤트가 없습니다."))); + } + + @CircuitBreaker(name = "redis-fcfs", fallbackMethod = "fallbackAddToQueue") + public void addToQueue(FirstComeCoupon fcCoupon, Long memberId) { + String key = REDIS_KEY_PREFIX + fcCoupon.getCouponTemplateId(); + + if (!fcCoupon.isActive()) { + throw new CoreException(ErrorType.BAD_REQUEST, "선착순 쿠폰 이벤트 기간이 아닙니다."); + } + + Long result = redisTemplate.execute(FCFS_SCRIPT, + List.of(key), + memberId.toString(), + String.valueOf(fcCoupon.getMaxQuantity()), + String.valueOf(System.currentTimeMillis())); + + if (result != null && result == -1) { + throw new CoreException(ErrorType.CONFLICT, "이미 발급 요청한 쿠폰입니다."); + } + if (result != null && result == -2) { + throw new CoreException(ErrorType.BAD_REQUEST, "선착순 쿠폰이 모두 소진되었습니다."); + } + } + + public void removeFromQueue(Long templateId, Long memberId) { + String key = REDIS_KEY_PREFIX + templateId; + redisTemplate.opsForZSet().remove(key, memberId.toString()); + } + + public void fallbackAddToQueue(FirstComeCoupon fcCoupon, Long memberId, Exception e) { + log.warn("Redis 장애 발생, DB Fallback 처리 - templateId: {}", fcCoupon.getCouponTemplateId(), e); + + if (!fcCoupon.isActive()) { + throw new CoreException(ErrorType.BAD_REQUEST, "선착순 쿠폰 이벤트 기간이 아닙니다."); + } + + if (couponIssueRequestRepository.existsByTemplateIdAndMemberId(fcCoupon.getCouponTemplateId(), memberId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 발급 요청한 쿠폰입니다."); + } + + long count = couponIssueRequestRepository.countByTemplateId(fcCoupon.getCouponTemplateId()); + if (count >= fcCoupon.getMaxQuantity()) { + throw new CoreException(ErrorType.BAD_REQUEST, "선착순 쿠폰이 모두 소진되었습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/FavoriteAddedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/FavoriteAddedEvent.java new file mode 100644 index 000000000..da7fb49e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/FavoriteAddedEvent.java @@ -0,0 +1,6 @@ +package com.loopers.domain.event; + +public record FavoriteAddedEvent( + Long productId, + Long memberId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/FavoriteRemovedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/FavoriteRemovedEvent.java new file mode 100644 index 000000000..f993ec55c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/FavoriteRemovedEvent.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record FavoriteRemovedEvent(Long productId, Long memberId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java new file mode 100644 index 000000000..ab6f650c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record OrderCreatedEvent(Long orderId, Long memberId, int totalPrice) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderExpiredEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderExpiredEvent.java new file mode 100644 index 000000000..8ef922d82 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderExpiredEvent.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record OrderExpiredEvent(Long orderId, Long userCouponId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentCompletedEvent.java new file mode 100644 index 000000000..3b10ce24c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentCompletedEvent.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record PaymentCompletedEvent(String orderId, Long paymentId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java new file mode 100644 index 000000000..475440aac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentFailedEvent.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record PaymentFailedEvent(String orderId, Long paymentId, String reason) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentRequestedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentRequestedEvent.java new file mode 100644 index 000000000..bc591f00b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentRequestedEvent.java @@ -0,0 +1,13 @@ +package com.loopers.domain.event; + +public record PaymentRequestedEvent( + Long paymentId, + Long memberId, + Long orderId, + String orderNumber, + String cardType, + String cardNo, + String amount, + String callbackUrl +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java index 918a340cb..0b9d088b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -4,5 +4,6 @@ public enum OrderStatus { CREATED, PAYMENT_REQUESTED, PAID, - PAYMENT_FAILED + PAYMENT_FAILED, + CANCELLED } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/repository/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/repository/OrderRepository.java index 5107827a4..990d116fd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/repository/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/repository/OrderRepository.java @@ -18,4 +18,6 @@ public interface OrderRepository { Optional findByOrderNumber(String orderNumber); void updateStatus(Long orderId, OrderStatus status); + + List findExpiredOrders(OrderStatus status, LocalDateTime expireBefore); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderService.java index 9aba9326a..c2bdcc6ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderService.java @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; import java.util.List; @RequiredArgsConstructor @@ -49,6 +50,11 @@ public Orders getOrderByOrderNumber(String orderNumber) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다.")); } + public List findExpiredOrders() { + LocalDateTime expireBefore = LocalDateTime.now().minusMinutes(30); + return orderRepository.findExpiredOrders(OrderStatus.CREATED, expireBefore); + } + public Orders getOrder(OrderCommand.GetByMember command) { Orders orders = orderRepository.findById(command.orderId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEvent.java new file mode 100644 index 000000000..1d9fb1936 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEvent.java @@ -0,0 +1,45 @@ +package com.loopers.domain.outbox.model; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class OutboxEvent { + + private Long id; + private OutboxEventType eventType; + private String aggregateId; + private String payload; + private OutboxStatus status; + private LocalDateTime createdAt; + + private OutboxEvent(OutboxEventType eventType, String aggregateId, String payload) { + this.eventType = eventType; + this.aggregateId = aggregateId; + this.payload = payload; + this.status = OutboxStatus.INIT; + this.createdAt = LocalDateTime.now(); + } + + public static OutboxEvent create(OutboxEventType eventType, String aggregateId, String payload) { + return new OutboxEvent(eventType, aggregateId, payload); + } + + public static OutboxEvent reconstruct(Long id, OutboxEventType eventType, String aggregateId, + String payload, OutboxStatus status, LocalDateTime createdAt) { + OutboxEvent event = new OutboxEvent(eventType, aggregateId, payload); + event.id = id; + event.status = status; + event.createdAt = createdAt; + return event; + } + + public void markPublished() { + this.status = OutboxStatus.PUBLISHED; + } + + public void markFailed() { + this.status = OutboxStatus.FAILED; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEventType.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEventType.java new file mode 100644 index 000000000..c59d9eb52 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEventType.java @@ -0,0 +1,8 @@ +package com.loopers.domain.outbox.model; + +public enum OutboxEventType { + FAVORITE_ADDED, + FAVORITE_REMOVED, + ORDER_CREATED, + PAYMENT_COMPLETED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxStatus.java new file mode 100644 index 000000000..41b71152a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.outbox.model; + +public enum OutboxStatus { + INIT, + PUBLISHED, + FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/repository/OutboxEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/repository/OutboxEventRepository.java new file mode 100644 index 000000000..9b006f91a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/repository/OutboxEventRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.outbox.repository; + +import com.loopers.domain.outbox.model.OutboxEvent; + +import java.util.List; + +public interface OutboxEventRepository { + + OutboxEvent save(OutboxEvent outboxEvent); + + List findByStatusInit(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentInfo.java index 512683b6c..e58dba927 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentInfo.java @@ -6,7 +6,7 @@ public record PaymentInfo( String cardType, String cardNo, String amount, - String status + PgPaymentStatus status ) { public static PaymentInfo empty() { return new PaymentInfo(null, null, null, null, null, null); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PgPaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PgPaymentStatus.java new file mode 100644 index 000000000..0f45a9904 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PgPaymentStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.payment; + +public enum PgPaymentStatus { + SUCCESS, + PENDING, + FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestEntity.java new file mode 100644 index 000000000..2621b0c76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestEntity.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.coupon.entity; + +import com.loopers.domain.coupon.model.CouponIssueRequest; +import com.loopers.domain.coupon.model.CouponIssueStatus; +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 lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "coupon_issue_request") +public class CouponIssueRequestEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long couponTemplateId; + + @Column(nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private CouponIssueStatus status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public static CouponIssueRequestEntity toEntity(CouponIssueRequest model) { + CouponIssueRequestEntity entity = new CouponIssueRequestEntity(); + entity.couponTemplateId = model.getCouponTemplateId(); + entity.memberId = model.getMemberId(); + entity.status = model.getStatus(); + entity.createdAt = model.getCreatedAt(); + return entity; + } + + public CouponIssueRequest toModel() { + return CouponIssueRequest.reconstruct(id, couponTemplateId, memberId, status, createdAt); + } + + public void updateStatus(CouponIssueStatus status) { + this.status = status; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/FirstComeCouponEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/FirstComeCouponEntity.java new file mode 100644 index 000000000..7a0ec4069 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/entity/FirstComeCouponEntity.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.coupon.entity; + +import com.loopers.domain.coupon.model.FirstComeCoupon; +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.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "first_come_coupon") +public class FirstComeCouponEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long couponTemplateId; + + @Column(nullable = false) + private int maxQuantity; + + @Column(nullable = false) + private LocalDateTime startAt; + + @Column(nullable = false) + private LocalDateTime endAt; + + public FirstComeCoupon toModel() { + return FirstComeCoupon.reconstruct(id, couponTemplateId, maxQuantity, startAt, endAt); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..70660ccff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.coupon.repository; + +import com.loopers.infrastructure.coupon.entity.CouponIssueRequestEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponIssueRequestJpaRepository extends JpaRepository { + + long countByCouponTemplateId(Long couponTemplateId); + + boolean existsByCouponTemplateIdAndMemberId(Long couponTemplateId, Long memberId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/FirstComeCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/FirstComeCouponJpaRepository.java new file mode 100644 index 000000000..5124aa66f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/FirstComeCouponJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.coupon.repository; + +import com.loopers.infrastructure.coupon.entity.FirstComeCouponEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface FirstComeCouponJpaRepository extends JpaRepository { + + Optional findByCouponTemplateId(Long couponTemplateId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/impl/CouponIssueRequestRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/impl/CouponIssueRequestRepositoryImpl.java new file mode 100644 index 000000000..a816cac3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/impl/CouponIssueRequestRepositoryImpl.java @@ -0,0 +1,48 @@ +package com.loopers.infrastructure.coupon.repository.impl; + +import com.loopers.domain.coupon.model.CouponIssueRequest; +import com.loopers.domain.coupon.model.CouponIssueStatus; +import com.loopers.domain.coupon.repository.CouponIssueRequestRepository; +import com.loopers.infrastructure.coupon.entity.CouponIssueRequestEntity; +import com.loopers.infrastructure.coupon.repository.CouponIssueRequestJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CouponIssueRequestRepositoryImpl implements CouponIssueRequestRepository { + + private final CouponIssueRequestJpaRepository jpaRepository; + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + CouponIssueRequestEntity entity = jpaRepository.save(CouponIssueRequestEntity.toEntity(request)); + return entity.toModel(); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id).map(CouponIssueRequestEntity::toModel); + } + + @Override + public void updateStatus(Long id, CouponIssueStatus status) { + CouponIssueRequestEntity entity = jpaRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 발급 요청입니다.")); + entity.updateStatus(status); + } + + @Override + public long countByTemplateId(Long couponTemplateId) { + return jpaRepository.countByCouponTemplateId(couponTemplateId); + } + + @Override + public boolean existsByTemplateIdAndMemberId(Long couponTemplateId, Long memberId) { + return jpaRepository.existsByCouponTemplateIdAndMemberId(couponTemplateId, memberId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/impl/FirstComeCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/impl/FirstComeCouponRepositoryImpl.java new file mode 100644 index 000000000..44748e646 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/repository/impl/FirstComeCouponRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.coupon.repository.impl; + +import com.loopers.domain.coupon.model.FirstComeCoupon; +import com.loopers.domain.coupon.repository.FirstComeCouponRepository; +import com.loopers.infrastructure.coupon.entity.FirstComeCouponEntity; +import com.loopers.infrastructure.coupon.repository.FirstComeCouponJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class FirstComeCouponRepositoryImpl implements FirstComeCouponRepository { + + private final FirstComeCouponJpaRepository jpaRepository; + + @Override + public Optional findByTemplateId(Long couponTemplateId) { + return jpaRepository.findByCouponTemplateId(couponTemplateId).map(FirstComeCouponEntity::toModel); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/OrderJpaRepository.java index 88a6be7f8..5636e9f4d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/OrderJpaRepository.java @@ -1,5 +1,6 @@ package com.loopers.infrastructure.order.repository; +import com.loopers.domain.order.OrderStatus; import com.loopers.infrastructure.order.entity.OrderEntity; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,4 +13,6 @@ public interface OrderJpaRepository extends JpaRepository { List findAllByMemberIdAndCreatedAtBetween(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt); Optional findByOrderNumber(String orderNumber); + + List findByStatusAndCreatedAtBefore(OrderStatus status, ZonedDateTime createdAt); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/impl/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/impl/OrderRepositoryImpl.java index 350f87307..077311647 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/impl/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/repository/impl/OrderRepositoryImpl.java @@ -45,6 +45,14 @@ public void updateStatus(Long orderId, OrderStatus status) { entity.updateStatus(status); } + @Override + public List findExpiredOrders(OrderStatus status, LocalDateTime expireBefore) { + ZonedDateTime zonedExpire = expireBefore.atZone(ZoneId.systemDefault()); + return orderJpaRepository.findByStatusAndCreatedAtBefore(status, zonedExpire).stream() + .map(OrderEntity::toModel) + .toList(); + } + @Override public List findByMemberIdAndCreatedAtBetween(Long memberId, LocalDateTime startAt, LocalDateTime endAt) { ZonedDateTime zonedStart = startAt.atZone(ZoneId.systemDefault()); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/entity/OutboxEventEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/entity/OutboxEventEntity.java new file mode 100644 index 000000000..d2eae461e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/entity/OutboxEventEntity.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure.outbox.entity; + +import com.loopers.domain.outbox.model.OutboxEvent; +import com.loopers.domain.outbox.model.OutboxEventType; +import com.loopers.domain.outbox.model.OutboxStatus; +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 lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "outbox_events") +public class OutboxEventEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 100) + private OutboxEventType eventType; + + @Column(nullable = false, length = 100) + private String aggregateId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private OutboxStatus status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public static OutboxEventEntity toEntity(OutboxEvent model) { + OutboxEventEntity entity = new OutboxEventEntity(); + entity.id = model.getId(); + entity.eventType = model.getEventType(); + entity.aggregateId = model.getAggregateId(); + entity.payload = model.getPayload(); + entity.status = model.getStatus(); + entity.createdAt = model.getCreatedAt(); + return entity; + } + + public OutboxEvent toModel() { + return OutboxEvent.reconstruct(id, eventType, aggregateId, payload, status, createdAt); + } + + public void updateStatus(OutboxStatus status) { + this.status = status; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/OutboxEventJpaRepository.java new file mode 100644 index 000000000..597d0857c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/OutboxEventJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.outbox.repository; + +import com.loopers.domain.outbox.model.OutboxStatus; +import com.loopers.infrastructure.outbox.entity.OutboxEventEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OutboxEventJpaRepository extends JpaRepository { + + List findByStatusOrderByIdAsc(OutboxStatus status); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/impl/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/impl/OutboxEventRepositoryImpl.java new file mode 100644 index 000000000..440a27148 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/impl/OutboxEventRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.outbox.repository.impl; + +import com.loopers.domain.outbox.model.OutboxEvent; +import com.loopers.domain.outbox.model.OutboxStatus; +import com.loopers.domain.outbox.repository.OutboxEventRepository; +import com.loopers.infrastructure.outbox.entity.OutboxEventEntity; +import com.loopers.infrastructure.outbox.repository.OutboxEventJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository outboxEventJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent outboxEvent) { + OutboxEventEntity entity = outboxEventJpaRepository.save(OutboxEventEntity.toEntity(outboxEvent)); + return entity.toModel(); + } + + @Override + public List findByStatusInit() { + return outboxEventJpaRepository.findByStatusOrderByIdAsc(OutboxStatus.INIT).stream() + .map(OutboxEventEntity::toModel) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentAdapter.java index f421b85f0..7026f92ec 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentAdapter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentAdapter.java @@ -3,6 +3,7 @@ import com.loopers.domain.payment.PaymentCommand; import com.loopers.domain.payment.PaymentGateway; import com.loopers.domain.payment.PaymentInfo; +import com.loopers.domain.payment.PgPaymentStatus; import com.loopers.infrastructure.pg.dto.PgApiResponse; import com.loopers.infrastructure.pg.dto.PgPaymentRequest; import com.loopers.infrastructure.pg.dto.PgPaymentResponse; @@ -67,13 +68,21 @@ private PgPaymentRequest toRequest(PaymentCommand.PgRequest command) { } private PaymentInfo toInfo(PgPaymentResponse response) { + PgPaymentStatus pgStatus = null; + if (response.status() != null) { + try { + pgStatus = PgPaymentStatus.valueOf(response.status()); + } catch (IllegalArgumentException e) { + pgStatus = PgPaymentStatus.FAILED; + } + } return new PaymentInfo( response.transactionKey(), response.orderId(), response.cardType(), response.cardNo(), response.amount(), - response.status() + pgStatus ); } } 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 cb62aaad2..a7c3e19ce 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,6 +1,7 @@ package com.loopers.interfaces.api.coupon; import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.coupon.dto.CouponIssueRequestResDto; import com.loopers.application.coupon.dto.FindMyCouponResDto; import com.loopers.application.coupon.dto.IssueCouponResDto; import com.loopers.interfaces.api.ApiResponse; @@ -9,11 +10,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +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.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @@ -43,4 +46,21 @@ public ApiResponse> getMyCoupons(@RequestHeader(HEAD Page result = couponFacade.getMyCoupons(loginId, password, pageable); return ApiResponse.success(result.map(FindMyCouponApiResDto::from)); } + + @PostMapping("/coupons/{couponId}/first-come-issue") + @ResponseStatus(HttpStatus.ACCEPTED) + public ApiResponse firstComeIssueCoupon( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @PathVariable Long couponId) { + return ApiResponse.success(couponFacade.requestFirstComeIssue(loginId, password, couponId)); + } + + @GetMapping("/coupon-issues/{requestId}/status") + public ApiResponse getIssueStatus( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @PathVariable Long requestId) { + return ApiResponse.success(couponFacade.getIssueRequestStatus(loginId, password, requestId)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java index f4133c5e2..b872d6736 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.payment; import com.loopers.application.payment.PaymentFacade; +import com.loopers.domain.payment.PgPaymentStatus; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.payment.dto.CreatePaymentApiReqDto; import com.loopers.interfaces.api.payment.dto.FindPaymentApiResDto; @@ -36,7 +37,7 @@ public ApiResponse createPayment(@RequestHeader(HEADER_LOG @Override @PostMapping("/callback") public ApiResponse handleCallback(@RequestBody PaymentCallbackApiReqDto request) { - paymentFacade.handleCallback(request.transactionKey(), request.status()); + paymentFacade.handleCallback(request.transactionKey(), PgPaymentStatus.valueOf(request.status())); return ApiResponse.successNoContent(); } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 3f3c7137d..04293f00b 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -21,6 +21,7 @@ spring: import: - jpa.yml - redis.yml + - kafka.yml - logging.yml - monitoring.yml @@ -77,6 +78,8 @@ spring: config: activate: on-profile: local, test + main: + allow-bean-definition-overriding: true --- spring: diff --git a/apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java index 23bb9fee3..5d504cf80 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java @@ -6,6 +6,8 @@ import com.loopers.domain.member.service.MemberService; import com.loopers.domain.product.model.Product; import com.loopers.domain.product.vo.DisplayStatus; +import com.loopers.domain.event.FavoriteAddedEvent; +import com.loopers.domain.event.FavoriteRemovedEvent; import com.loopers.domain.product.service.ProductService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -16,6 +18,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.time.LocalDate; @@ -41,6 +44,9 @@ class FavoriteFacadeTest { @Mock private ProductService productService; + @Mock + private ApplicationEventPublisher eventPublisher; + private static Member createTestMember() { return Member.reconstruct(1L, "testuser", "encodedPw", "홍길동", LocalDate.of(1990, 1, 1), "test@test.com"); } @@ -98,9 +104,9 @@ void throwsException_whenAlreadyFavorited() { .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)); } - @DisplayName("정상 등록 시 favoriteService.addFavorite과 productService.increaseLikeCount가 호출된다") + @DisplayName("정상 등록 시 favoriteService.addFavorite 호출 후 FavoriteAddedEvent가 발행된다") @Test - void callsAddFavoriteAndIncreaseLikeCount_onSuccess() { + void callsAddFavoriteAndPublishesEvent_onSuccess() { // arrange Member member = createTestMember(); Product product = createTestProduct(); @@ -113,7 +119,7 @@ void callsAddFavoriteAndIncreaseLikeCount_onSuccess() { // assert verify(favoriteService).addFavorite(any(FavoriteCommand.Add.class)); - verify(productService).increaseLikeCount(1L); + verify(eventPublisher).publishEvent(any(FavoriteAddedEvent.class)); } } @@ -151,9 +157,9 @@ void throwsException_whenFavoriteNotFound() { .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); } - @DisplayName("정상 취소 시 favoriteService.delete와 productService.decreaseLikeCount가 호출된다") + @DisplayName("정상 취소 시 favoriteService.delete 호출 후 FavoriteRemovedEvent가 발행된다") @Test - void callsDeleteAndDecreaseLikeCount_onSuccess() { + void callsDeleteAndPublishesEvent_onSuccess() { // arrange Member member = createTestMember(); Product product = createTestProduct(); @@ -165,7 +171,7 @@ void callsDeleteAndDecreaseLikeCount_onSuccess() { // assert verify(favoriteService).delete(any(FavoriteCommand.Delete.class)); - verify(productService).decreaseLikeCount(1L); + verify(eventPublisher).publishEvent(any(FavoriteRemovedEvent.class)); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index e7d15d1d6..2fdd2c38e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -22,6 +22,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.time.LocalDate; import java.util.List; @@ -54,6 +55,9 @@ class OrderFacadeTest { @Mock private CouponService couponService; + @Mock + private ApplicationEventPublisher eventPublisher; + private static Member createTestMember() { return Member.reconstruct(1L, "testuser", "encodedPw", "홍길동", LocalDate.of(1990, 1, 1), "test@test.com"); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java new file mode 100644 index 000000000..eaf3efcb5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java @@ -0,0 +1,324 @@ +package com.loopers.application.payment; + +import com.loopers.application.payment.dto.CreatePaymentReqDto; +import com.loopers.application.payment.dto.FindPaymentResDto; +import com.loopers.domain.brand.model.Brand; +import com.loopers.domain.brand.model.BrandCommand; +import com.loopers.domain.member.model.MemberCommand; +import com.loopers.domain.member.service.MemberService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.model.OrderCommand; +import com.loopers.domain.order.model.OrderProduct; +import com.loopers.domain.order.model.Orders; +import com.loopers.domain.order.service.OrderService; +import com.loopers.domain.payment.PaymentGateway; +import com.loopers.domain.payment.PaymentInfo; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.payment.PgPaymentStatus; +import com.loopers.domain.product.model.Product; +import com.loopers.domain.product.model.ProductCommand; +import com.loopers.domain.product.service.ProductService; +import com.loopers.infrastructure.brand.entity.BrandEntity; +import com.loopers.infrastructure.brand.repository.BrandJpaRepository; +import com.loopers.infrastructure.member.entity.MemberEntity; +import com.loopers.infrastructure.member.repository.MemberJpaRepository; +import com.loopers.infrastructure.order.repository.OrderJpaRepository; +import com.loopers.infrastructure.order.repository.OrderProductJpaRepository; +import com.loopers.infrastructure.payment.repository.PaymentJpaRepository; +import com.loopers.infrastructure.payment.repository.PaymentProductJpaRepository; +import com.loopers.infrastructure.product.entity.ProductEntity; +import com.loopers.infrastructure.product.repository.ProductJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@SpringBootTest(properties = "spring.profiles.active=test") +@Import(MySqlTestContainersConfig.class) +class PaymentFacadeIntegrationTest { + + @Autowired + private PaymentFacade paymentFacade; + + @Autowired + private OrderService orderService; + + @Autowired + private ProductService productService; + + @Autowired + private MemberService memberService; + + @MockBean + private PaymentGateway paymentGateway; + + @Autowired + private PaymentJpaRepository paymentJpaRepository; + + @Autowired + private PaymentProductJpaRepository paymentProductJpaRepository; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @Autowired + private OrderProductJpaRepository orderProductJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private MemberJpaRepository memberJpaRepository; + + @BeforeEach + void setUp() { + paymentProductJpaRepository.deleteAll(); + paymentJpaRepository.deleteAll(); + orderProductJpaRepository.deleteAll(); + orderJpaRepository.deleteAll(); + productJpaRepository.deleteAll(); + brandJpaRepository.deleteAll(); + memberJpaRepository.deleteAll(); + } + + private MemberEntity saveMember(String loginId) { + memberService.addMember(new MemberCommand.SignUp( + loginId, "Password123!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com" + )); + return memberJpaRepository.findByLoginId(loginId).orElseThrow(); + } + + private BrandEntity saveBrand() { + Brand brand = Brand.create(new BrandCommand.Create("테스트브랜드", "테스트 설명")); + return brandJpaRepository.save(BrandEntity.toEntity(brand)); + } + + private ProductEntity saveProduct(Long brandId, String name, int price, int stock) { + Product product = Product.create(brandId, new ProductCommand.Create(brandId, name, price, stock)); + return productJpaRepository.save(ProductEntity.toEntity(product)); + } + + private Orders createOrder(MemberEntity member, ProductEntity product, int quantity) { + List orderProducts = productService.decreaseStockAndCreateOrderProducts( + List.of(new OrderCommand.OrderItem(product.getId(), quantity))); + int subtotal = orderProducts.stream().mapToInt(OrderProduct::subtotal).sum(); + OrderCommand.Create command = new OrderCommand.Create(member.getId(), orderProducts, 0, null); + return orderService.createOrder(command); + } + + @DisplayName("결제 생성") + @Nested + class CreatePayment { + + @DisplayName("정상 결제 생성 시 Payment가 PENDING 상태로 저장된다") + @Test + void createPayment_success() { + // arrange + MemberEntity member = saveMember("testuser"); + BrandEntity brand = saveBrand(); + ProductEntity product = saveProduct(brand.getId(), "상품A", 10000, 100); + Orders order = createOrder(member, product, 2); + + CreatePaymentReqDto dto = new CreatePaymentReqDto( + order.getOrderNumber(), "VISA", "1234-5678-9012-3456"); + + // act + FindPaymentResDto result = paymentFacade.createPayment("testuser", "Password123!", dto); + + // assert + assertThat(result).isNotNull(); + assertThat(result.status()).isEqualTo(PaymentStatus.PENDING.name()); + } + + @DisplayName("다른 사람의 주문으로 결제 시도 시 NOT_FOUND 예외가 발생한다") + @Test + void createPayment_ownershipFail() { + // arrange + MemberEntity owner = saveMember("owner"); + MemberEntity other = saveMember("other"); + BrandEntity brand = saveBrand(); + ProductEntity product = saveProduct(brand.getId(), "상품A", 10000, 100); + Orders order = createOrder(owner, product, 1); + + CreatePaymentReqDto dto = new CreatePaymentReqDto( + order.getOrderNumber(), "VISA", "1234-5678-9012-3456"); + + // act & assert + assertThatThrownBy(() -> paymentFacade.createPayment("other", "Password123!", dto)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @DisplayName("결제 콜백") + @Nested + class HandleCallback { + + @DisplayName("SUCCESS 콜백 시 결제 성공 + 주문 PAID 처리된다") + @Test + void handleCallback_success() { + // arrange + MemberEntity member = saveMember("testuser"); + BrandEntity brand = saveBrand(); + ProductEntity product = saveProduct(brand.getId(), "상품A", 10000, 100); + Orders order = createOrder(member, product, 2); + + CreatePaymentReqDto dto = new CreatePaymentReqDto( + order.getOrderNumber(), "VISA", "1234-5678-9012-3456"); + paymentFacade.createPayment("testuser", "Password123!", dto); + + // Payment를 REQUESTED 상태로 변경 (PG 호출 성공 시뮬레이션) + var paymentEntity = paymentJpaRepository.findByOrderId(order.getOrderNumber()).orElseThrow(); + paymentJpaRepository.updateTransactionKeyAndStatus( + paymentEntity.getId(), "txn-123", PaymentStatus.REQUESTED); + + // act + paymentFacade.handleCallback("txn-123", PgPaymentStatus.SUCCESS); + + // assert + var updatedPayment = paymentJpaRepository.findByTransactionKey("txn-123").orElseThrow(); + assertThat(updatedPayment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + + var updatedOrder = orderJpaRepository.findByOrderNumber(order.getOrderNumber()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID); + } + + @DisplayName("FAILED 콜백 시 결제 실패 + 주문 PAYMENT_FAILED + 재고 복원된다") + @Test + void handleCallback_failed() { + // arrange + MemberEntity member = saveMember("testuser"); + BrandEntity brand = saveBrand(); + ProductEntity product = saveProduct(brand.getId(), "상품A", 10000, 100); + int orderQuantity = 2; + Orders order = createOrder(member, product, orderQuantity); + + CreatePaymentReqDto dto = new CreatePaymentReqDto( + order.getOrderNumber(), "VISA", "1234-5678-9012-3456"); + paymentFacade.createPayment("testuser", "Password123!", dto); + + var paymentEntity = paymentJpaRepository.findByOrderId(order.getOrderNumber()).orElseThrow(); + paymentJpaRepository.updateTransactionKeyAndStatus( + paymentEntity.getId(), "txn-456", PaymentStatus.REQUESTED); + + // act + paymentFacade.handleCallback("txn-456", PgPaymentStatus.FAILED); + + // assert + var updatedPayment = paymentJpaRepository.findByTransactionKey("txn-456").orElseThrow(); + assertThat(updatedPayment.getStatus()).isEqualTo(PaymentStatus.FAILED); + + var updatedOrder = orderJpaRepository.findByOrderNumber(order.getOrderNumber()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAYMENT_FAILED); + + // 재고 복원 확인 + var updatedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getStock()).isEqualTo(100); // 원래 재고로 복원 + } + } + + @DisplayName("결제 상태 확인") + @Nested + class CheckPaymentStatus { + + @DisplayName("소유자 검증 실패 시 NOT_FOUND 예외가 발생한다") + @Test + void checkPaymentStatus_ownershipFail() { + // arrange + MemberEntity owner = saveMember("owner"); + MemberEntity other = saveMember("other"); + BrandEntity brand = saveBrand(); + ProductEntity product = saveProduct(brand.getId(), "상품A", 10000, 100); + Orders order = createOrder(owner, product, 1); + + CreatePaymentReqDto dto = new CreatePaymentReqDto( + order.getOrderNumber(), "VISA", "1234-5678-9012-3456"); + paymentFacade.createPayment("owner", "Password123!", dto); + + // act & assert + assertThatThrownBy(() -> paymentFacade.checkPaymentStatus("other", "Password123!", order.getOrderNumber())) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @DisplayName("PG 조회 결과가 SUCCESS이면 결제 완료 처리된다") + @Test + void checkPaymentStatus_pgSuccess() { + // arrange + MemberEntity member = saveMember("testuser"); + BrandEntity brand = saveBrand(); + ProductEntity product = saveProduct(brand.getId(), "상품A", 10000, 100); + Orders order = createOrder(member, product, 1); + + CreatePaymentReqDto dto = new CreatePaymentReqDto( + order.getOrderNumber(), "VISA", "1234-5678-9012-3456"); + paymentFacade.createPayment("testuser", "Password123!", dto); + + var paymentEntity = paymentJpaRepository.findByOrderId(order.getOrderNumber()).orElseThrow(); + paymentJpaRepository.updateTransactionKeyAndStatus( + paymentEntity.getId(), "txn-789", PaymentStatus.REQUESTED); + + when(paymentGateway.getPaymentByOrderId(eq(member.getId()), eq(order.getOrderNumber()))) + .thenReturn(new PaymentInfo("txn-789", order.getOrderNumber(), "VISA", "1234", "10000", PgPaymentStatus.SUCCESS)); + + // act + FindPaymentResDto result = paymentFacade.checkPaymentStatus("testuser", "Password123!", order.getOrderNumber()); + + // assert + assertThat(result).isNotNull(); + + var updatedOrder = orderJpaRepository.findByOrderNumber(order.getOrderNumber()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID); + } + + @DisplayName("PG 조회 결과가 PENDING이면 상태 변경 없이 반환한다") + @Test + void checkPaymentStatus_pgPending() { + // arrange + MemberEntity member = saveMember("testuser"); + BrandEntity brand = saveBrand(); + ProductEntity product = saveProduct(brand.getId(), "상품A", 10000, 100); + Orders order = createOrder(member, product, 1); + + CreatePaymentReqDto dto = new CreatePaymentReqDto( + order.getOrderNumber(), "VISA", "1234-5678-9012-3456"); + paymentFacade.createPayment("testuser", "Password123!", dto); + + var paymentEntity = paymentJpaRepository.findByOrderId(order.getOrderNumber()).orElseThrow(); + paymentJpaRepository.updateTransactionKeyAndStatus( + paymentEntity.getId(), "txn-000", PaymentStatus.REQUESTED); + + when(paymentGateway.getPaymentByOrderId(eq(member.getId()), eq(order.getOrderNumber()))) + .thenReturn(new PaymentInfo("txn-000", order.getOrderNumber(), "VISA", "1234", "10000", PgPaymentStatus.PENDING)); + + // act + FindPaymentResDto result = paymentFacade.checkPaymentStatus("testuser", "Password123!", order.getOrderNumber()); + + // assert + assertThat(result).isNotNull(); + + var updatedOrder = orderJpaRepository.findByOrderNumber(order.getOrderNumber()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.CREATED); // 변경 없음 + } + } +} 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 new file mode 100644 index 000000000..3403240bb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java @@ -0,0 +1,237 @@ +package com.loopers.application.payment; + +import com.loopers.application.payment.dto.CreatePaymentReqDto; +import com.loopers.application.payment.dto.FindPaymentResDto; +import com.loopers.domain.member.model.Member; +import com.loopers.domain.member.service.MemberService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.model.OrderProduct; +import com.loopers.domain.order.model.Orders; +import com.loopers.domain.order.service.OrderProductService; +import com.loopers.domain.order.service.OrderService; +import com.loopers.domain.payment.PaymentCommand; +import com.loopers.domain.payment.PaymentGateway; +import com.loopers.domain.payment.PaymentInfo; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.payment.PgPaymentStatus; +import com.loopers.domain.payment.model.Payment; +import com.loopers.domain.payment.service.PaymentService; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class PaymentFacadeTest { + + @InjectMocks + private PaymentFacade paymentFacade; + + @Mock + private PaymentService paymentService; + + @Mock + private PaymentGateway paymentGateway; + + @Mock + private MemberService memberService; + + @Mock + private OrderService orderService; + + @Mock + private OrderProductService orderProductService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private static Member createTestMember(Long id) { + return Member.reconstruct(id, "testuser", "encodedPw", "홍길동", LocalDate.of(1990, 1, 1), "test@test.com"); + } + + private static Orders createTestOrder(Long memberId) { + OrderProduct op = OrderProduct.create(1L, "상품A", 10000, 2); + return Orders.reconstruct(1L, "ORD-001", memberId, 20000, 0, null, OrderStatus.CREATED, List.of(op)); + } + + private static Payment createTestPayment(Long memberId) { + return Payment.reconstruct(1L, "ORD-001", memberId, null, "VISA", "1234", "20000", PaymentStatus.PENDING, null); + } + + private static Payment createRequestedPayment(Long memberId) { + return Payment.reconstruct(1L, "ORD-001", memberId, "txn-123", "VISA", "1234", "20000", PaymentStatus.REQUESTED, null); + } + + @DisplayName("결제 생성") + @Nested + class CreatePayment { + + @DisplayName("정상 결제 생성 시 Payment 생성 + 이벤트 발행") + @Test + void createPayment_success() { + // arrange + Member member = createTestMember(1L); + Orders order = createTestOrder(1L); + Payment payment = createTestPayment(1L); + + when(memberService.findMember("testuser", "password")).thenReturn(member); + when(orderService.getOrderByOrderNumber("ORD-001")).thenReturn(order); + when(orderProductService.findByOrderId(1L)).thenReturn(order.getOrderProducts()); + when(paymentService.createPaymentWithSnapshots(any(PaymentCommand.Create.class), any())).thenReturn(payment); + + CreatePaymentReqDto dto = new CreatePaymentReqDto("ORD-001", "VISA", "1234"); + + // act + FindPaymentResDto result = paymentFacade.createPayment("testuser", "password", dto); + + // assert + assertThat(result).isNotNull(); + verify(paymentService).createPaymentWithSnapshots(any(), any()); + verify(eventPublisher, atLeastOnce()).publishEvent(any(Object.class)); + } + + @DisplayName("다른 사람의 주문으로 결제 시도 시 NOT_FOUND") + @Test + void createPayment_ownershipFail() { + // arrange + Member member = createTestMember(2L); // memberId = 2 + Orders order = createTestOrder(1L); // order.memberId = 1 + + when(memberService.findMember("testuser", "password")).thenReturn(member); + when(orderService.getOrderByOrderNumber("ORD-001")).thenReturn(order); + + CreatePaymentReqDto dto = new CreatePaymentReqDto("ORD-001", "VISA", "1234"); + + // act & assert + assertThatThrownBy(() -> paymentFacade.createPayment("testuser", "password", dto)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @DisplayName("결제 콜백") + @Nested + class HandleCallback { + + @DisplayName("SUCCESS 콜백 시 결제 성공 + 주문 PAID + 이벤트 발행") + @Test + void handleCallback_success() { + // arrange + Payment payment = createRequestedPayment(1L); + Orders order = createTestOrder(1L); + + when(paymentService.getPaymentByTransactionKey("txn-123")).thenReturn(payment); + when(orderService.getOrderByOrderNumber("ORD-001")).thenReturn(order); + + // act + paymentFacade.handleCallback("txn-123", PgPaymentStatus.SUCCESS); + + // assert + verify(paymentService).markSuccess("txn-123"); + verify(orderService).updateOrderStatus(1L, OrderStatus.PAID); + verify(eventPublisher, atLeastOnce()).publishEvent(any(Object.class)); + } + + @DisplayName("FAILED 콜백 시 결제 실패 + 주문 PAYMENT_FAILED + 재고 복원") + @Test + void handleCallback_failed() { + // arrange + Payment payment = createRequestedPayment(1L); + Orders order = createTestOrder(1L); + + when(paymentService.getPaymentByTransactionKey("txn-123")).thenReturn(payment); + when(orderService.getOrderByOrderNumber("ORD-001")).thenReturn(order); + + // act + paymentFacade.handleCallback("txn-123", PgPaymentStatus.FAILED); + + // assert + verify(paymentService).markFailed("txn-123", "FAILED"); + verify(orderService).updateOrderStatus(1L, OrderStatus.PAYMENT_FAILED); + verify(paymentService).restoreStock(1L); + verify(eventPublisher, atLeastOnce()).publishEvent(any(Object.class)); + } + } + + @DisplayName("결제 상태 확인") + @Nested + class CheckPaymentStatus { + + @DisplayName("소유자 검증 실패 시 NOT_FOUND") + @Test + void checkPaymentStatus_ownershipFail() { + // arrange + Member member = createTestMember(2L); + Payment payment = createRequestedPayment(1L); // memberId = 1 + + when(memberService.findMember("testuser", "password")).thenReturn(member); + when(paymentService.getPaymentByOrderId("ORD-001")).thenReturn(payment); + + // act & assert + assertThatThrownBy(() -> paymentFacade.checkPaymentStatus("testuser", "password", "ORD-001")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @DisplayName("PG SUCCESS 시 handleCallback 재사용으로 PAID 처리") + @Test + void checkPaymentStatus_pgSuccess() { + // arrange + Member member = createTestMember(1L); + Payment payment = createRequestedPayment(1L); + Orders order = createTestOrder(1L); + + when(memberService.findMember("testuser", "password")).thenReturn(member); + when(paymentService.getPaymentByOrderId("ORD-001")).thenReturn(payment); + when(paymentGateway.getPaymentByOrderId(1L, "ORD-001")) + .thenReturn(new PaymentInfo("txn-123", "ORD-001", "VISA", "1234", "20000", PgPaymentStatus.SUCCESS)); + when(paymentService.getPaymentByTransactionKey("txn-123")).thenReturn(payment); + when(orderService.getOrderByOrderNumber("ORD-001")).thenReturn(order); + + // act + paymentFacade.checkPaymentStatus("testuser", "password", "ORD-001"); + + // assert + verify(paymentService).markSuccess("txn-123"); + verify(orderService).updateOrderStatus(1L, OrderStatus.PAID); + } + + @DisplayName("PG PENDING 시 상태 변경 없음") + @Test + void checkPaymentStatus_pgPending() { + // arrange + Member member = createTestMember(1L); + Payment payment = createRequestedPayment(1L); + + when(memberService.findMember("testuser", "password")).thenReturn(member); + when(paymentService.getPaymentByOrderId("ORD-001")).thenReturn(payment); + when(paymentGateway.getPaymentByOrderId(1L, "ORD-001")) + .thenReturn(new PaymentInfo("txn-123", "ORD-001", "VISA", "1234", "20000", PgPaymentStatus.PENDING)); + + // act + paymentFacade.checkPaymentStatus("testuser", "password", "ORD-001"); + + // assert — handleCallback 호출되지 않음 + verify(paymentService, never()).markSuccess(any()); + verify(paymentService, never()).markFailed(any(), any()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponConcurrencyTest.java new file mode 100644 index 000000000..e2adb8465 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponConcurrencyTest.java @@ -0,0 +1,105 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.coupon.model.FirstComeCoupon; +import com.loopers.domain.coupon.service.FirstComeCouponService; +import com.loopers.support.error.CoreException; +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.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class FirstComeCouponConcurrencyTest { + + @Autowired + private FirstComeCouponService firstComeCouponService; + + @Autowired + private RedisTemplate redisTemplate; + + @DisplayName("선착순 100장 쿠폰에 200명이 동시 요청하면 100명만 성공한다") + @Test + void onlyMaxQuantitySucceeds_whenConcurrentRequests() throws InterruptedException { + // arrange + int maxQuantity = 100; + int threadCount = 200; + Long templateId = 999L; + String redisKey = "coupon:fcfs:" + templateId; + + // Redis 초기화 + redisTemplate.delete(redisKey); + + FirstComeCoupon fcCoupon = FirstComeCoupon.create( + templateId, maxQuantity, + LocalDateTime.now().minusMinutes(1), + LocalDateTime.now().plusHours(1) + ); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // act + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1; + executor.submit(() -> { + try { + firstComeCouponService.addToQueue(fcCoupon, memberId); + successCount.incrementAndGet(); + } catch (CoreException e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert + Long zcard = redisTemplate.opsForZSet().zCard(redisKey); + assertThat(successCount.get()).isEqualTo(maxQuantity); + assertThat(failCount.get()).isEqualTo(threadCount - maxQuantity); + assertThat(zcard).isEqualTo(maxQuantity); + + // cleanup + redisTemplate.delete(redisKey); + } + + @DisplayName("같은 회원이 중복 요청하면 두 번째는 실패한다") + @Test + void rejectsDuplicate_whenSameMemberRequests() { + // arrange + Long templateId = 998L; + String redisKey = "coupon:fcfs:" + templateId; + redisTemplate.delete(redisKey); + + FirstComeCoupon fcCoupon = FirstComeCoupon.create( + templateId, 100, + LocalDateTime.now().minusMinutes(1), + LocalDateTime.now().plusHours(1) + ); + + // act + firstComeCouponService.addToQueue(fcCoupon, 1L); + + // assert + org.junit.jupiter.api.Assertions.assertThrows(CoreException.class, () -> + firstComeCouponService.addToQueue(fcCoupon, 1L)); + + // cleanup + redisTemplate.delete(redisKey); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponScenarioTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponScenarioTest.java new file mode 100644 index 000000000..586d2fd98 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/FirstComeCouponScenarioTest.java @@ -0,0 +1,168 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.coupon.model.CouponIssueRequest; +import com.loopers.domain.coupon.model.FirstComeCoupon; +import com.loopers.domain.coupon.repository.CouponIssueRequestRepository; +import com.loopers.domain.coupon.service.FirstComeCouponService; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class FirstComeCouponScenarioTest { + + @Autowired + private CouponIssueRequestRepository couponIssueRequestRepository; + + @Autowired + private FirstComeCouponService firstComeCouponService; + + @Autowired + private RedisTemplate redisTemplate; + + private static final int MAX_QUANTITY = 100; + private static final int THREAD_COUNT = 200; + + @DisplayName("시나리오 1: DB COUNT 기반 수량 제어") + @Nested + class Scenario1_DbCount { + + @DisplayName("200명 동시 요청 시 DB COUNT 기반은 초과 발급이 발생한다") + @Test + void overIssuance_whenDbCountOnly() throws InterruptedException { + // arrange + Long templateId = 9001L; + ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(THREAD_COUNT); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // act — DB COUNT 기반 수량 제어 (Race Condition 발생) + for (int i = 0; i < THREAD_COUNT; i++) { + long memberId = i + 1; + executor.submit(() -> { + try { + // DB COUNT로 수량 확인 (원자적이지 않음) + long count = couponIssueRequestRepository.countByTemplateId(templateId); + if (count >= MAX_QUANTITY) { + failCount.incrementAndGet(); + return; + } + if (couponIssueRequestRepository.existsByTemplateIdAndMemberId(templateId, memberId)) { + failCount.incrementAndGet(); + return; + } + // 수량 남았으면 INSERT + couponIssueRequestRepository.save(CouponIssueRequest.create(templateId, memberId)); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert — 100장 초과 발급이 발생함을 증명 + long totalIssued = couponIssueRequestRepository.countByTemplateId(templateId); + System.out.println("[시나리오 1] 발급된 수량: " + totalIssued + " (기대: 100, 실제: 초과)"); + assertThat(totalIssued).isGreaterThan(MAX_QUANTITY); + } + } + + @DisplayName("시나리오 2: Redis Sorted Set 기반 수량 제어") + @Nested + class Scenario2_RedisSortedSet { + + @DisplayName("200명 동시 요청 시 Redis Sorted Set은 정확히 100명만 성공한다") + @Test + void exactQuantity_whenRedisSortedSet() throws InterruptedException { + // arrange + Long templateId = 9002L; + String redisKey = "coupon:fcfs:" + templateId; + redisTemplate.delete(redisKey); + + FirstComeCoupon fcCoupon = FirstComeCoupon.create( + templateId, MAX_QUANTITY, + LocalDateTime.now().minusMinutes(1), + LocalDateTime.now().plusHours(1)); + + ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch latch = new CountDownLatch(THREAD_COUNT); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // act + for (int i = 0; i < THREAD_COUNT; i++) { + long memberId = i + 1; + executor.submit(() -> { + try { + firstComeCouponService.addToQueue(fcCoupon, memberId); + successCount.incrementAndGet(); + } catch (CoreException e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert + Long zcard = redisTemplate.opsForZSet().zCard(redisKey); + System.out.println("[시나리오 2] 성공: " + successCount.get() + ", 실패: " + failCount.get() + ", ZCARD: " + zcard); + assertThat(successCount.get()).isEqualTo(MAX_QUANTITY); + assertThat(failCount.get()).isEqualTo(THREAD_COUNT - MAX_QUANTITY); + assertThat(zcard).isEqualTo(MAX_QUANTITY); + + // cleanup + redisTemplate.delete(redisKey); + } + } + + @DisplayName("시나리오 3: Redis 장애 시 DB Fallback") + @Nested + class Scenario3_Fallback { + + @DisplayName("같은 회원이 중복 요청하면 거부된다") + @Test + void rejectsDuplicate() { + // arrange + Long templateId = 9003L; + String redisKey = "coupon:fcfs:" + templateId; + redisTemplate.delete(redisKey); + + FirstComeCoupon fcCoupon = FirstComeCoupon.create( + templateId, MAX_QUANTITY, + LocalDateTime.now().minusMinutes(1), + LocalDateTime.now().plusHours(1)); + + // act — 첫 번째 요청 성공 + firstComeCouponService.addToQueue(fcCoupon, 1L); + + // assert — 두 번째 요청 거부 + org.junit.jupiter.api.Assertions.assertThrows(CoreException.class, () -> + firstComeCouponService.addToQueue(fcCoupon, 1L)); + + // cleanup + redisTemplate.delete(redisKey); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/Ch5_FallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/Ch5_FallbackTest.java index ab6524cb1..0376b293a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/Ch5_FallbackTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/Ch5_FallbackTest.java @@ -183,7 +183,7 @@ class FallbackSafety { String cardType = result.cardType(); String cardNo = result.cardNo(); String amount = result.amount(); - String status = result.status(); + Object status = result.status(); // null-safe 연산도 문제없이 동작 boolean hasTxn = txnKey != null; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventHandleStatus.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventHandleStatus.java new file mode 100644 index 000000000..210cecf21 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventHandleStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.event.model; + +public enum EventHandleStatus { + SUCCESS, + SKIPPED, + FAILED +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventType.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventType.java new file mode 100644 index 000000000..3244354a2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/model/EventType.java @@ -0,0 +1,8 @@ +package com.loopers.domain.event.model; + +public enum EventType { + FAVORITE_ADDED, + FAVORITE_REMOVED, + ORDER_CREATED, + PAYMENT_COMPLETED +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestStreamerEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestStreamerEntity.java new file mode 100644 index 000000000..20e7e7df6 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/CouponIssueRequestStreamerEntity.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.coupon.entity; + +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.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "coupon_issue_request") +public class CouponIssueRequestStreamerEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long couponTemplateId; + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false, length = 20) + private String status; + + private static final String STATUS_ISSUED = "ISSUED"; + private static final String STATUS_REJECTED = "REJECTED"; + + public void markIssued() { + this.status = STATUS_ISSUED; + } + + public void markRejected() { + this.status = STATUS_REJECTED; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/UserCouponStreamerEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/UserCouponStreamerEntity.java new file mode 100644 index 000000000..02e1b7969 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/entity/UserCouponStreamerEntity.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.coupon.entity; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "user_coupon", uniqueConstraints = + @UniqueConstraint(columnNames = {"memberId", "couponTemplateId"})) +public class UserCouponStreamerEntity extends BaseEntity { + + private static final String STATUS_AVAILABLE = "AVAILABLE"; + + @Column(nullable = false) + private Long couponTemplateId; + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private String status; + + @Version + private Long version; + + public static UserCouponStreamerEntity create(Long couponTemplateId, Long memberId) { + UserCouponStreamerEntity entity = new UserCouponStreamerEntity(); + entity.couponTemplateId = couponTemplateId; + entity.memberId = memberId; + entity.status = STATUS_AVAILABLE; + return entity; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestStreamerJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestStreamerJpaRepository.java new file mode 100644 index 000000000..13988ed85 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/repository/CouponIssueRequestStreamerJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.coupon.repository; + +import com.loopers.infrastructure.coupon.entity.CouponIssueRequestStreamerEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponIssueRequestStreamerJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/entity/EventHandledEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/entity/EventHandledEntity.java new file mode 100644 index 000000000..5099040d2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/entity/EventHandledEntity.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.event.entity; + +import com.loopers.domain.event.model.EventHandleStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "event_handled") +public class EventHandledEntity { + + @Id + @Column(length = 100) + private String eventId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private EventHandleStatus status; + + @Column(nullable = false) + private LocalDateTime handledAt; + + public static EventHandledEntity of(String eventId, EventHandleStatus status) { + EventHandledEntity entity = new EventHandledEntity(); + entity.eventId = eventId; + entity.status = status; + entity.handledAt = LocalDateTime.now(); + return entity; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/repository/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/repository/EventHandledJpaRepository.java new file mode 100644 index 000000000..a7d94131a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/repository/EventHandledJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.event.repository; + +import com.loopers.infrastructure.event.entity.EventHandledEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventHandledJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/entity/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/entity/ProductMetricsEntity.java new file mode 100644 index 000000000..93ac945bf --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/entity/ProductMetricsEntity.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.metrics.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "product_metrics") +public class ProductMetricsEntity { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long version; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public static ProductMetricsEntity createNew(Long productId) { + ProductMetricsEntity entity = new ProductMetricsEntity(); + entity.productId = productId; + entity.likeCount = 0; + entity.orderCount = 0; + entity.viewCount = 0; + entity.version = 0; + entity.updatedAt = LocalDateTime.now(); + return entity; + } + + public void incrementLikeCount() { + this.likeCount++; + this.updatedAt = LocalDateTime.now(); + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + this.updatedAt = LocalDateTime.now(); + } + + public void incrementOrderCount() { + this.orderCount++; + this.updatedAt = LocalDateTime.now(); + } + + public void updateVersion(long version) { + this.version = version; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..f12e8efa2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/repository/ProductMetricsJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.metrics.repository; + +import com.loopers.infrastructure.metrics.entity.ProductMetricsEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java new file mode 100644 index 000000000..3e2cb21b1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.model.EventHandleStatus; +import com.loopers.domain.event.model.EventType; +import com.loopers.infrastructure.event.entity.EventHandledEntity; +import com.loopers.infrastructure.event.repository.EventHandledJpaRepository; +import com.loopers.infrastructure.metrics.entity.ProductMetricsEntity; +import com.loopers.infrastructure.metrics.repository.ProductMetricsJpaRepository; +import com.loopers.interfaces.consumer.dto.CatalogEventMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CatalogEventConsumer { + + private final ProductMetricsJpaRepository productMetricsRepository; + private final EventHandledJpaRepository eventHandledRepository; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + @KafkaListener(topics = "catalog-events", containerFactory = KafkaConfig.BATCH_LISTENER) + public void consume(List> records, Acknowledgment ack) { + for (ConsumerRecord record : records) { + try { + transactionTemplate.executeWithoutResult(status -> processRecord(record)); + } catch (Exception e) { + log.error("catalog-events 처리 실패 - record: {}", record, e); + } + } + ack.acknowledge(); + } + + private void processRecord(ConsumerRecord record) { + CatalogEventMessage event = objectMapper.convertValue(record.value(), CatalogEventMessage.class); + + if (eventHandledRepository.existsById(event.eventId())) { + return; + } + + ProductMetricsEntity metrics = productMetricsRepository.findById(event.productId()) + .orElseGet(() -> ProductMetricsEntity.createNew(event.productId())); + + if (event.version() <= metrics.getVersion()) { + eventHandledRepository.save(EventHandledEntity.of(event.eventId(), EventHandleStatus.SKIPPED)); + return; + } + + switch (event.eventType()) { + case FAVORITE_ADDED -> metrics.incrementLikeCount(); + case FAVORITE_REMOVED -> metrics.decrementLikeCount(); + default -> {} + } + metrics.updateVersion(event.version()); + + productMetricsRepository.save(metrics); + eventHandledRepository.save(EventHandledEntity.of(event.eventId(), EventHandleStatus.SUCCESS)); + + log.info("catalog-events 처리 완료 - eventType: {}, productId: {}", event.eventType(), event.productId()); + } +} 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..53c64eab5 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.model.EventHandleStatus; +import com.loopers.infrastructure.coupon.entity.CouponIssueRequestStreamerEntity; +import com.loopers.infrastructure.coupon.entity.UserCouponStreamerEntity; +import com.loopers.infrastructure.coupon.repository.CouponIssueRequestStreamerJpaRepository; +import com.loopers.infrastructure.coupon.repository.UserCouponStreamerJpaRepository; +import com.loopers.infrastructure.event.entity.EventHandledEntity; +import com.loopers.infrastructure.event.repository.EventHandledJpaRepository; +import com.loopers.interfaces.consumer.dto.CouponIssueMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CouponIssueConsumer { + + private final EventHandledJpaRepository eventHandledRepository; + private final UserCouponStreamerJpaRepository userCouponRepository; + private final CouponIssueRequestStreamerJpaRepository couponIssueRequestRepository; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + @KafkaListener(topics = "coupon-issue-requests", containerFactory = KafkaConfig.BATCH_LISTENER) + public void consume(List> records, Acknowledgment ack) { + for (ConsumerRecord record : records) { + try { + transactionTemplate.executeWithoutResult(status -> processRecord(record)); + } catch (Exception e) { + log.error("coupon-issue-requests 처리 실패 - record: {}", record, e); + } + } + ack.acknowledge(); + } + + private void processRecord(ConsumerRecord record) { + CouponIssueMessage msg = objectMapper.convertValue(record.value(), CouponIssueMessage.class); + String eventId = "coupon-issue-" + msg.requestId(); + + if (eventHandledRepository.existsById(eventId)) { + return; + } + + UserCouponStreamerEntity userCoupon = UserCouponStreamerEntity.create( + msg.couponTemplateId(), msg.memberId()); + userCouponRepository.save(userCoupon); + + couponIssueRequestRepository.findById(msg.requestId()) + .ifPresentOrElse( + CouponIssueRequestStreamerEntity::markIssued, + () -> log.warn("쿠폰 발급 요청을 찾을 수 없음 - requestId: {}", msg.requestId()) + ); + + eventHandledRepository.save(EventHandledEntity.of(eventId, EventHandleStatus.SUCCESS)); + + log.info("쿠폰 발급 완료 - requestId: {}, templateId: {}, memberId: {}", + msg.requestId(), msg.couponTemplateId(), msg.memberId()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java new file mode 100644 index 000000000..8f2cdec2b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java @@ -0,0 +1,57 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.model.EventHandleStatus; +import com.loopers.infrastructure.event.entity.EventHandledEntity; +import com.loopers.infrastructure.event.repository.EventHandledJpaRepository; +import com.loopers.interfaces.consumer.dto.OrderEventMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class OrderEventConsumer { + + private final EventHandledJpaRepository eventHandledRepository; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + @KafkaListener(topics = "order-events", containerFactory = KafkaConfig.BATCH_LISTENER) + public void consume(List> records, Acknowledgment ack) { + for (ConsumerRecord record : records) { + try { + transactionTemplate.executeWithoutResult(status -> processRecord(record)); + } catch (Exception e) { + log.error("order-events 처리 실패 - record: {}", record, e); + } + } + ack.acknowledge(); + } + + private void processRecord(ConsumerRecord record) { + OrderEventMessage event = objectMapper.convertValue(record.value(), OrderEventMessage.class); + + if (eventHandledRepository.existsById(event.eventId())) { + return; + } + + switch (event.eventType()) { + case ORDER_CREATED -> + log.info("주문 생성 이벤트 수신 - orderId: {}, memberId: {}", event.orderId(), event.memberId()); + case PAYMENT_COMPLETED -> + log.info("결제 완료 이벤트 수신 - orderId: {}", event.orderId()); + default -> {} + } + + eventHandledRepository.save(EventHandledEntity.of(event.eventId(), EventHandleStatus.SUCCESS)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/CatalogEventMessage.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/CatalogEventMessage.java new file mode 100644 index 000000000..b42afcc8a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/CatalogEventMessage.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.consumer.dto; + +import com.loopers.domain.event.model.EventType; + +import java.time.LocalDateTime; + +public record CatalogEventMessage( + String eventId, + EventType eventType, + Long productId, + Long memberId, + long version, + LocalDateTime createdAt +) { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/CouponIssueMessage.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/CouponIssueMessage.java new file mode 100644 index 000000000..62b8647df --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/CouponIssueMessage.java @@ -0,0 +1,7 @@ +package com.loopers.interfaces.consumer.dto; + +public record CouponIssueMessage( + Long requestId, + Long couponTemplateId, + Long memberId) { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/OrderEventMessage.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/OrderEventMessage.java new file mode 100644 index 000000000..d958a756c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/dto/OrderEventMessage.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.consumer.dto; + +import com.loopers.domain.event.model.EventType; + +import java.time.LocalDateTime; + +public record OrderEventMessage( + String eventId, + EventType eventType, + Long orderId, + Long memberId, + int totalPrice, + long version, + LocalDateTime createdAt +) { +} diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b0..c64da4c5e 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -58,6 +58,9 @@ spring: datasource: mysql-jpa: main: + jdbc-url: ${datasource.mysql-jpa.main.jdbc-url:jdbc:mysql://localhost:3306/loopers} + username: ${datasource.mysql-jpa.main.username:test} + password: ${datasource.mysql-jpa.main.password:test} maximum-pool-size: 10 minimum-idle: 5 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..6c1579f75 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 @@ -1,6 +1,7 @@ package com.loopers.confg.kafka; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -8,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.config.TopicBuilder; import org.springframework.kafka.core.*; import org.springframework.kafka.listener.ContainerProperties; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; @@ -51,6 +53,21 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap return new ByteArrayJsonMessageConverter(objectMapper); } + @Bean + public NewTopic catalogEventsTopic() { + return TopicBuilder.name("catalog-events").partitions(3).replicas(1).build(); + } + + @Bean + public NewTopic orderEventsTopic() { + return TopicBuilder.name("order-events").partitions(3).replicas(1).build(); + } + + @Bean + public NewTopic couponIssueRequestsTopic() { + return TopicBuilder.name("coupon-issue-requests").partitions(3).replicas(1).build(); + } + @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