diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..e70d04887 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")) @@ -11,6 +12,12 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // resilience4j + implementation("io.github.resilience4j:resilience4j-spring-boot3") + + // feign + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") 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..e32af66a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,9 +4,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + import java.util.TimeZone; @ConfigurationPropertiesScan +@EnableFeignClients +@EnableScheduling +@EnableAsync @SpringBootApplication public class CommerceApiApplication { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 5d885672e..82b643505 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -23,12 +23,11 @@ public class LikeFacade { private final LikeService likeService; - public void createLike(String userId, Long productId) { + public void like(String userId, Long productId) { likeService.like(userId, productId); } - public void deleteLike(String userId, Long productId) { + public void unlike(String userId, Long productId) { likeService.unlike(userId, productId); } } - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java index 683e39cdd..dfc7339b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java @@ -15,5 +15,6 @@ */ public record CreateOrderCommand( String userId, - List items + List items, + OrderPaymentCommand payment ) {} 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 d22a3780b..5292b532d 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 @@ -3,7 +3,10 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; -import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.event.OrderEvent; +import com.loopers.domain.order.event.OrderEventPublisher; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentService; import com.loopers.domain.point.Point; import com.loopers.domain.point.PointService; import com.loopers.domain.product.Product; @@ -11,23 +14,10 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -/** - * packageName : com.loopers.application.order - * fileName : OrderFacade - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun 최초 생성 - */ - -@Slf4j @Component @RequiredArgsConstructor public class OrderFacade { @@ -35,6 +25,11 @@ public class OrderFacade { private final OrderService orderService; private final ProductService productService; private final PointService pointService; + private final PaymentService paymentService; + private final OrderEventPublisher orderEventPublisher; + @Value("${app.callback.base-url}") + private String callbackBaseUrl; + @Transactional public OrderInfo createOrder(CreateOrderCommand command) { @@ -47,13 +42,10 @@ public OrderInfo createOrder(CreateOrderCommand command) { for (OrderItemCommand itemCommand : command.items()) { - //상품가져오고 Product product = productService.getProduct(itemCommand.productId()); - // 재고감소 product.decreaseStock(itemCommand.quantity()); - // OrderItem생성 OrderItem orderItem = OrderItem.create( product.getId(), product.getName(), @@ -64,7 +56,6 @@ public OrderInfo createOrder(CreateOrderCommand command) { orderItem.setOrder(order); } - //총 가격구하고 long totalAmount = order.getOrderItems().stream() .mapToLong(OrderItem::getAmount) .sum(); @@ -74,10 +65,39 @@ public OrderInfo createOrder(CreateOrderCommand command) { Point point = pointService.findPointByUserId(command.userId()); point.use(totalAmount); - //저장 Order saved = orderService.createOrder(order); - saved.updateStatus(OrderStatus.COMPLETE); + + OrderPaymentCommand paymentCommand = command.payment(); + String cardType = paymentCommand.cardType(); + String orderReference = createOrderReference(saved.getId()); + String callbackUrl = callbackBaseUrl + "/api/v1/orders/" + orderReference + "/callback"; + + Payment payment = Payment.pending( + saved.getId(), + command.userId(), + orderReference, + cardType, + paymentCommand.cardNo(), + saved.getTotalAmount() + ); + + paymentService.save(payment); + orderEventPublisher.publish( + OrderEvent.PaymentRequested.of( + saved.getId(), + command.userId(), + orderReference, + cardType, + paymentCommand.cardNo(), + saved.getTotalAmount(), + callbackUrl + ) + ); return OrderInfo.from(saved); } + + private String createOrderReference(Long orderId) { + return "order-" + orderId; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 70028c27c..7177304b4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -3,26 +3,13 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderStatus; -import java.time.LocalDateTime; import java.util.List; -/** - * packageName : com.loopers.application.order - * fileName : OrderInfo - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun 최초 생성 - */ public record OrderInfo( Long orderId, String userId, Long totalAmount, OrderStatus status, - LocalDateTime createdAt, List items ) { public static OrderInfo from(Order order) { @@ -35,7 +22,6 @@ public static OrderInfo from(Order order) { order.getUserId(), order.getTotalAmount(), order.getStatus(), - order.getCreatedAt(), itemInfos ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentCommand.java new file mode 100644 index 000000000..11c04e29e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.order; + +public record OrderPaymentCommand( + String cardType, + String cardNo +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java new file mode 100644 index 000000000..207aececb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentProcessor.java @@ -0,0 +1,109 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentService; +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.infrastructure.dataplatform.OrderDataPlatformClient; +import com.loopers.infrastructure.payment.PgPaymentClient; +import com.loopers.infrastructure.payment.dto.PgPaymentV1Dto; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class OrderPaymentProcessor { + + private static final Logger log = LoggerFactory.getLogger(OrderPaymentProcessor.class); + + private final PaymentService paymentService; + private final OrderService orderService; + private final ProductService productService; + private final PointService pointService; + private final PgPaymentClient pgPaymentClient; + private final OrderDataPlatformClient orderDataPlatformClient; + + @Transactional + public void handlePaymentCallback(String orderReference, PgPaymentV1Dto.TransactionStatus status, String transactionKey, String reason) { + Payment payment = paymentService.findByOrderReference(orderReference) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다.")); + + if (payment.getTransactionKey() != null && payment.getTransactionKey().equals(transactionKey)) { + return; + } + + Order order = orderService.findById(payment.getOrderId()); + applyPaymentResult(order, payment, status, transactionKey, reason); + } + + @Transactional + public void syncPayment(Long orderId) { + Payment payment = paymentService.findByOrderId(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다.")); + Order order = orderService.findById(orderId); + ApiResponse response = pgPaymentClient.getPayments(payment.getUserId(), payment.getOrderReference()); + PgPaymentV1Dto.OrderResponse data = response != null ? response.data() : null; + + if (data == null || data.transactions() == null || data.transactions().isEmpty()) { + log.warn("결제 내역을 찾을 수 없습니다. orderId={}, orderReference={}", orderId, payment.getOrderReference()); + return; + } + + PgPaymentV1Dto.TransactionRecord record = data.transactions().get(data.transactions().size() - 1); + applyPaymentResult(order, payment, record.status(), record.transactionKey(), record.reason()); + } + + @Transactional + public void handlePaymentResult(Long orderId, PgPaymentV1Dto.TransactionStatus status, String transactionKey, String reason) { + Payment payment = paymentService.findByOrderId(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다.")); + Order order = orderService.findById(orderId); + applyPaymentResult(order, payment, status, transactionKey, reason); + } + + private void applyPaymentResult( + Order order, + Payment payment, + PgPaymentV1Dto.TransactionStatus status, + String transactionKey, + String reason + ) { + OrderStatus newStatus = OrderPaymentSupport.mapOrderStatus(status); + payment.updateStatus(OrderPaymentSupport.mapPaymentStatus(status), transactionKey, reason); + paymentService.save(payment); + + if (newStatus == OrderStatus.COMPLETE) { + order.updateStatus(OrderStatus.COMPLETE); + } else if (newStatus == OrderStatus.FAIL) { + revertOrder(order, payment.getUserId()); + } else { + order.updateStatus(OrderStatus.PENDING); + } + + orderDataPlatformClient.send(order, payment); + } + + private void revertOrder(Order order, String userId) { + for (OrderItem item : order.getOrderItems()) { + Product product = productService.getProduct(item.getProductId()); + product.increaseStock(item.getQuantity()); + } + Point point = pointService.findPointByUserId(userId); + if (point != null) { + point.refund(order.getTotalAmount()); + } + order.updateStatus(OrderStatus.FAIL); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentSupport.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentSupport.java new file mode 100644 index 000000000..2e7963226 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderPaymentSupport.java @@ -0,0 +1,45 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.infrastructure.payment.dto.PgPaymentV1Dto.Response; +import com.loopers.infrastructure.payment.dto.PgPaymentV1Dto.TransactionStatus; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import static com.loopers.infrastructure.payment.dto.PgPaymentV1Dto.TransactionStatus.SUCCESS; + +public final class OrderPaymentSupport { + + private OrderPaymentSupport() { + } + + public static OrderStatus mapOrderStatus(TransactionStatus status) { + if (status == SUCCESS) { + return OrderStatus.COMPLETE; + } + if (status == TransactionStatus.FAILED) { + return OrderStatus.FAIL; + } + return OrderStatus.PENDING; + } + + public static PaymentStatus mapPaymentStatus(TransactionStatus status) { + if (status == SUCCESS) { + return PaymentStatus.SUCCESS; + } + if (status == TransactionStatus.FAILED) { + return PaymentStatus.FAIL; + } + return PaymentStatus.PENDING; + } + + public static Response requirePgResponse(ApiResponse response) { + Response data = response != null ? response.data() : null; + if (data == null) { + throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 응답을 확인할 수 없습니다."); + } + return data; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index f42bd5206..d8352d2e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.user; +import com.loopers.domain.point.PointService; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; @@ -11,9 +12,11 @@ @Component public class UserFacade { private final UserService userService; + private final PointService pointService; public UserInfo register(String userId, String email, String birth, String gender) { User user = userService.register(userId, email, birth, gender); + pointService.initPoint(user.getUserId()); return UserInfo.from(user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 41ae90b6a..9466507be 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -1,6 +1,7 @@ package com.loopers.domain.like; -import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.like.event.LikeEvent; +import com.loopers.domain.like.event.LikeEventPublisher; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -21,7 +22,7 @@ public class LikeService { private final LikeRepository likeRepository; - private final ProductRepository productRepository; + private final LikeEventPublisher likeEventPublisher; @Transactional public void like(String userId, Long productId) { @@ -29,7 +30,7 @@ public void like(String userId, Long productId) { Like like = Like.create(userId, productId); likeRepository.save(like); - productRepository.incrementLikeCount(productId); + likeEventPublisher.publish(LikeEvent.ProductLiked.of(userId, productId)); } @Transactional @@ -37,7 +38,7 @@ public void unlike(String userId, Long productId) { likeRepository.findByUserIdAndProductId(userId, productId) .ifPresent(like -> { likeRepository.delete(like); - productRepository.decrementLikeCount(productId); + likeEventPublisher.publish(LikeEvent.ProductUnliked.of(userId, productId)); }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeEvent.java new file mode 100644 index 000000000..4fcb9aea6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeEvent.java @@ -0,0 +1,33 @@ +package com.loopers.domain.like.event; + +import java.util.Objects; + +public final class LikeEvent { + + private LikeEvent() { + } + + public record ProductLiked(String userId, Long productId) { + + public ProductLiked { + Objects.requireNonNull(userId, "userId 는 null 일 수 없습니다."); + Objects.requireNonNull(productId, "productId 는 null 일 수 없습니다."); + } + + public static ProductLiked of(String userId, Long productId) { + return new ProductLiked(userId, productId); + } + } + + public record ProductUnliked(String userId, Long productId) { + + public ProductUnliked { + Objects.requireNonNull(userId, "userId 는 null 일 수 없습니다."); + Objects.requireNonNull(productId, "productId 는 null 일 수 없습니다."); + } + + public static ProductUnliked of(String userId, Long productId) { + return new ProductUnliked(userId, productId); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeEventPublisher.java new file mode 100644 index 000000000..463215b41 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeEventPublisher.java @@ -0,0 +1,9 @@ +package com.loopers.domain.like.event; + +public interface LikeEventPublisher { + + void publish(LikeEvent.ProductLiked event); + + void publish(LikeEvent.ProductUnliked event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index f2bcc9b81..ec5d485cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -6,7 +6,6 @@ import jakarta.persistence.*; import lombok.Getter; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -39,9 +38,6 @@ public class Order extends BaseEntity { @Enumerated(EnumType.STRING) private OrderStatus status; - @Column(nullable = false) - private LocalDateTime createdAt; - @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List orderItems = new ArrayList<>(); @@ -52,7 +48,6 @@ private Order(String userId, OrderStatus status) { this.userId = requiredValidUserId(userId); this.totalAmount = 0L; this.status = requiredValidStatus(status); - this.createdAt = LocalDateTime.now(); } public static Order create(String userId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index a66be03d3..5ad5afabb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,5 +1,7 @@ package com.loopers.domain.order; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -25,4 +27,10 @@ public class OrderService { public Order createOrder(Order order) { return orderRepository.save(order); } + + @Transactional(readOnly = true) + public Order findById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문 정보를 찾을 수 없습니다.")); + } } 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 14ea592ef..51dc8f4e0 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 @@ -13,10 +13,9 @@ */ public enum OrderStatus { - COMPLETE("결제성공"), - CANCEL("결제취소"), - FAIL("결제실패"), - PENDING("결제중"); + PENDING("주문중"), + FAIL("주문실패"), + COMPLETE("주문성공"); private final String description; @@ -32,10 +31,6 @@ public boolean isPending() { return this == PENDING; } - public boolean isCanceled() { - return this == CANCEL; - } - public String description() { return description; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEvent.java new file mode 100644 index 000000000..b8c4b6f81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEvent.java @@ -0,0 +1,42 @@ +package com.loopers.domain.order.event; + +import java.util.Objects; + +public final class OrderEvent { + + private OrderEvent() { + } + + public record PaymentRequested( + Long orderId, + String userId, + String orderReference, + String cardType, + String cardNo, + Long totalAmount, + String callbackUrl + ) { + + public PaymentRequested { + Objects.requireNonNull(orderId, "orderId 는 null 일 수 없습니다."); + Objects.requireNonNull(userId, "userId 는 null 일 수 없습니다."); + Objects.requireNonNull(orderReference, "orderReference 는 null 일 수 없습니다."); + Objects.requireNonNull(cardType, "cardType 은 null 일 수 없습니다."); + Objects.requireNonNull(cardNo, "cardNo 는 null 일 수 없습니다."); + Objects.requireNonNull(totalAmount, "totalAmount 는 null 일 수 없습니다."); + Objects.requireNonNull(callbackUrl, "callbackUrl 은 null 일 수 없습니다."); + } + + public static PaymentRequested of( + Long orderId, + String userId, + String orderReference, + String cardType, + String cardNo, + Long totalAmount, + String callbackUrl + ) { + return new PaymentRequested(orderId, userId, orderReference, cardType, cardNo, totalAmount, callbackUrl); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventPublisher.java new file mode 100644 index 000000000..0b7cb9a99 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventPublisher.java @@ -0,0 +1,7 @@ +package com.loopers.domain.order.event; + +public interface OrderEventPublisher { + + void publish(OrderEvent.PaymentRequested event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..104386a7c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,114 @@ +package com.loopers.domain.outbox; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "event_outbox") +@Getter +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 64) + private String eventId; + + @Column(nullable = false, length = 100) + private String topic; + + @Column(name = "partition_key", nullable = false, length = 100) + private String partitionKey; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(nullable = false, columnDefinition = "TEXT") + private String payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private OutboxStatus status; + + @Column(name = "attempt_count", nullable = false) + private int attemptCount; + + @Column(name = "last_error", length = 500) + private String lastError; + + @Column(name = "occurred_at", nullable = false) + private ZonedDateTime occurredAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected OutboxEvent() { + } + + private OutboxEvent( + String eventId, + String topic, + String partitionKey, + String eventType, + String payload, + ZonedDateTime occurredAt + ) { + this.eventId = eventId; + this.topic = topic; + this.partitionKey = partitionKey; + this.eventType = eventType; + this.payload = payload; + this.status = OutboxStatus.PENDING; + this.attemptCount = 0; + this.occurredAt = occurredAt; + } + + public static OutboxEvent pending( + String eventId, + String topic, + String partitionKey, + String eventType, + String payload, + ZonedDateTime occurredAt + ) { + return new OutboxEvent(eventId, topic, partitionKey, eventType, payload, occurredAt); + } + + public void markSent() { + this.status = OutboxStatus.SENT; + this.lastError = null; + } + + public void markFailed(String message) { + this.status = OutboxStatus.FAILED; + this.lastError = message; + } + + public void markPendingForRetry(String message) { + this.status = OutboxStatus.PENDING; + this.lastError = message; + } + + public void increaseAttempt() { + this.attemptCount += 1; + } + + @PrePersist + void onCreate() { + ZonedDateTime now = ZonedDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = ZonedDateTime.now(); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java new file mode 100644 index 000000000..858a335d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.outbox; + +import java.util.List; +import java.util.Optional; + +public interface OutboxEventRepository { + + OutboxEvent save(OutboxEvent event); + + Optional findByEventId(String eventId); + + List findTopPending(int size); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java new file mode 100644 index 000000000..36e66164b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java @@ -0,0 +1,61 @@ +package com.loopers.domain.outbox; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class OutboxService { + + private final OutboxEventRepository outboxEventRepository; + private final ObjectMapper objectMapper; + + public String nextEventId() { + return UUID.randomUUID().toString(); + } + + @Transactional + public void append(String eventId, String topic, String partitionKey, String eventType, Object payload, ZonedDateTime occurredAt) { + try { + String serializedPayload = objectMapper.writeValueAsString(payload); + OutboxEvent event = OutboxEvent.pending( + eventId, + topic, + partitionKey, + eventType, + serializedPayload, + occurredAt + ); + outboxEventRepository.save(event); + } catch (JsonProcessingException exception) { + throw new CoreException(ErrorType.INTERNAL_ERROR, "이벤트 직렬화에 실패했습니다."); + } + } + + @Transactional(readOnly = true) + public List fetchPending(int size) { + return outboxEventRepository.findTopPending(size); + } + + @Transactional + public void markSent(OutboxEvent event) { + event.markSent(); + outboxEventRepository.save(event); + } + + @Transactional + public void markFailed(OutboxEvent event, String message) { + event.increaseAttempt(); + event.markPendingForRetry(message); + outboxEventRepository.save(event); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java new file mode 100644 index 000000000..c738cee7b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java @@ -0,0 +1,8 @@ +package com.loopers.domain.outbox; + +public enum OutboxStatus { + PENDING, + SENT, + FAILED +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java new file mode 100644 index 000000000..5bc62e348 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -0,0 +1,87 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Table(name = "payment") +@Getter +public class Payment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "ref_order_id", nullable = false) + private Long orderId; + + @Column(nullable = false) + private String userId; + + @Column(nullable = false) + private String orderReference; + + @Column(nullable = false) + private String cardType; + + @Column(nullable = false) + private String cardNo; + + @Column(nullable = false) + private Long amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + + @Column + private String transactionKey; + + @Column + private String reason; + + protected Payment() { + } + + private Payment(Long orderId, String userId, String orderReference, String cardType, String cardNo, Long amount, PaymentStatus status) { + this.orderId = orderId; + this.userId = userId; + this.orderReference = orderReference; + this.cardType = cardType; + this.cardNo = cardNo; + this.amount = amount; + this.status = status; + } + + public static Payment pending(Long orderId, String userId, String orderReference, String cardType, String cardNo, Long amount) { + if (orderId == null || orderId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 양수여야 합니다."); + } + if (userId == null || userId.trim().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + if (orderReference == null || orderReference.trim().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 참조는 필수입니다."); + } + if (cardType == null || cardType.trim().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다."); + } + if (cardNo == null || cardNo.trim().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); + } + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0보다 커야 합니다."); + } + + return new Payment(orderId, userId, orderReference, cardType, cardNo, amount, PaymentStatus.PENDING); + } + + public void updateStatus(PaymentStatus status, String transactionKey, String reason) { + this.status = status; + this.transactionKey = transactionKey; + this.reason = reason; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java new file mode 100644 index 000000000..983f12710 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +public interface PaymentRepository { + + Payment save(Payment payment); + + Optional findByOrderId(Long orderId); + + Optional findByOrderReference(String orderReference); + + List findByStatus(PaymentStatus status); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java new file mode 100644 index 000000000..38fce0640 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java @@ -0,0 +1,34 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + + @Transactional + public Payment save(Payment payment) { + return paymentRepository.save(payment); + } + + @Transactional(readOnly = true) + public Optional findByOrderId(Long orderId) { + return paymentRepository.findByOrderId(orderId); + } + + @Transactional(readOnly = true) + public Optional findByOrderReference(String orderReference) { + return paymentRepository.findByOrderReference(orderReference); + } + + @Transactional(readOnly = true) + public List findPendingPayments() { + return paymentRepository.findByStatus(PaymentStatus.PENDING); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java new file mode 100644 index 000000000..66e7f4527 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.payment; + +public enum PaymentStatus { + PENDING, + SUCCESS, + FAIL +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index ea0c18c9d..0dca0071d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -54,4 +54,11 @@ public void use(Long useAmount) { } this.balance -= useAmount; } + + public void refund(Long amount) { + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "0원 이하로 환불할 수 없습니다."); + } + this.balance += amount; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index 1a6293f91..e3e3f598f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -17,6 +17,12 @@ public Point findPointByUserId(String userId) { return pointRepository.findByUserId(userId).orElse(null); } + @Transactional + public Point initPoint(String userId) { + return pointRepository.findByUserId(userId) + .orElseGet(() -> pointRepository.save(Point.create(userId, 0L))); + } + @Transactional public Point chargePoint(String userId, Long chargeAmount) { Point point = pointRepository.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트를 충전할수 없는 사용자입니다.")); @@ -40,4 +46,12 @@ public Point usePoint(String userId, Long useAmount) { point.use(useAmount); return pointRepository.save(point); } + + @Transactional + public void refundPoint(String userId, Long amount) { + Point point = pointRepository.findByUserId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보를 찾을 수 없습니다.")); + point.refund(amount); + pointRepository.save(point); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 3ea8c6957..5c4b27ce3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -116,4 +116,11 @@ public void decreaseStock(Long quantity) { } this.stock -= quantity; } + + public void increaseStock(Long quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "증가 수량은 0보다 커야 합니다."); + } + this.stock += quantity; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCache.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCache.java new file mode 100644 index 000000000..74ccbc614 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCache.java @@ -0,0 +1,17 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface ProductCache { + + Optional> getProductList(Long brandId, Pageable pageable); + + void putProductList(Long brandId, Pageable pageable, Page products); + + Optional getProductDetail(Long productId); + + void putProductDetail(Long productId, Product product); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index b12dd45bd..87700911f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -5,12 +5,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.time.Duration; - /** * packageName : com.loopers.domain.product * fileName : ProductService @@ -27,64 +24,33 @@ @RequiredArgsConstructor public class ProductService { - private static final Duration TTL_LIST = Duration.ofMinutes(10); - private static final Duration TTL_DETAIL = Duration.ofMinutes(5); - - private final RedisTemplate redisTemplate; private final ProductRepository productRepository; + private final ProductCache productCache; @Transactional(readOnly = true) public Page getProducts(Long brandId, Pageable sortedPageable) { - String key = "product:list:" - + (brandId == null ? "all" : brandId) + ":" - + sortedPageable.getPageNumber() + ":" - + sortedPageable.getPageSize(); - - try { - Page cached = (Page) redisTemplate.opsForValue().get(key); - if (cached != null) { - return cached; - } - } catch (Exception e) { - return (brandId == null) - ? productRepository.findAll(sortedPageable) - : productRepository.findByBrandId(brandId, sortedPageable); - } - - Page products = (brandId == null) - ? productRepository.findAll(sortedPageable) - : productRepository.findByBrandId(brandId, sortedPageable); - - try { - redisTemplate.opsForValue().set(key, products, TTL_LIST); - } catch (Exception ignored) { - } - - return products; + return productCache.getProductList(brandId, sortedPageable) + .orElseGet(() -> { + Page products = fetchProducts(brandId, sortedPageable); + productCache.putProductList(brandId, sortedPageable, products); + return products; + }); } public Product getProduct(Long productId) { - String key = "product:detail:" + productId; - - try { - Product cached = (Product) redisTemplate.opsForValue().get(key); - if (cached != null) { - return cached; - } - } catch (Exception e) { - return productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 없습니다")); - } - - Product product = productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 없습니다")); - - try { - redisTemplate.opsForValue().set(key, product, TTL_DETAIL); - } catch (Exception ignored) { - } + return productCache.getProductDetail(productId) + .orElseGet(() -> { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 없습니다")); + productCache.putProductDetail(productId, product); + return product; + }); + } - return product; + private Page fetchProducts(Long brandId, Pageable sortedPageable) { + return (brandId == null) + ? productRepository.findAll(sortedPageable) + : productRepository.findByBrandId(brandId, sortedPageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClient.java new file mode 100644 index 000000000..95f37b567 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClient.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.dataplatform; + +import com.loopers.domain.order.Order; +import com.loopers.domain.payment.Payment; + +public interface OrderDataPlatformClient { + + void send(Order order, Payment payment); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java new file mode 100644 index 000000000..25d53e538 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/OrderDataPlatformClientImpl.java @@ -0,0 +1,77 @@ +package com.loopers.infrastructure.dataplatform; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.outbox.OutboxService; +import com.loopers.domain.payment.Payment; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class OrderDataPlatformClientImpl implements OrderDataPlatformClient { + + private static final String ORDER_EVENTS_TOPIC = "order-events"; + private static final String ORDER_STATUS_CHANGED = "ORDER_STATUS_CHANGED"; + + private final OutboxService outboxService; + + @Override + public void send(Order order, Payment payment) { + if (order == null || payment == null) { + return; + } + + ZonedDateTime occurredAt = ZonedDateTime.now(); + String eventId = outboxService.nextEventId(); + Map payload = buildPayload(eventId, order, payment, occurredAt); + outboxService.append( + eventId, + ORDER_EVENTS_TOPIC, + order.getId().toString(), + ORDER_STATUS_CHANGED, + payload, + occurredAt + ); + } + + private Map buildPayload( + String eventId, + Order order, + Payment payment, + ZonedDateTime occurredAt + ) { + Map payload = new HashMap<>(); + payload.put("eventId", eventId); + payload.put("eventType", ORDER_STATUS_CHANGED); + payload.put("orderId", order.getId()); + payload.put("userId", order.getUserId()); + payload.put("totalAmount", order.getTotalAmount()); + payload.put("orderStatus", order.getStatus()); + payload.put("paymentStatus", payment.getStatus()); + payload.put("transactionKey", payment.getTransactionKey()); + payload.put("reason", payment.getReason()); + payload.put("occurredAt", occurredAt); + payload.put("items", toItemPayload(order.getOrderItems())); + return payload; + } + + private List> toItemPayload(List items) { + return items.stream() + .map(item -> { + Map row = new HashMap<>(); + row.put("productId", item.getProductId()); + row.put("productName", item.getProductName()); + row.put("quantity", item.getQuantity()); + row.put("price", item.getPrice()); + return row; + }) + .collect(Collectors.toList()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java new file mode 100644 index 000000000..d603e7d49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeAggregationEventListener.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.event.LikeEvent; +import com.loopers.domain.outbox.OutboxService; +import com.loopers.domain.product.ProductRepository; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LikeAggregationEventListener { + + private static final String CATALOG_TOPIC = "catalog-events"; + private static final String EVENT_PRODUCT_LIKED = "PRODUCT_LIKED"; + private static final String EVENT_PRODUCT_UNLIKED = "PRODUCT_UNLIKED"; + + private final ProductRepository productRepository; + private final OutboxService outboxService; + + @Async + @Transactional + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeEvent.ProductLiked event) { + try { + productRepository.incrementLikeCount(event.productId()); + publishCatalogEvent(EVENT_PRODUCT_LIKED, event.userId(), event.productId(), 1); + } catch (Exception exception) { + log.error("집계 처리 실패 - productId={}, type=LIKE", event.productId(), exception); + } + } + + @Async + @Transactional + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeEvent.ProductUnliked event) { + try { + productRepository.decrementLikeCount(event.productId()); + publishCatalogEvent(EVENT_PRODUCT_UNLIKED, event.userId(), event.productId(), -1); + } catch (Exception exception) { + log.error("집계 처리 실패 - productId={}, type=UNLIKE", event.productId(), exception); + } + } + + private void publishCatalogEvent(String eventType, String userId, Long productId, long delta) { + ZonedDateTime occurredAt = ZonedDateTime.now(); + String eventId = outboxService.nextEventId(); + Map payload = new HashMap<>(); + payload.put("eventId", eventId); + payload.put("eventType", eventType); + payload.put("productId", productId); + payload.put("userId", userId); + payload.put("delta", delta); + payload.put("occurredAt", occurredAt); + outboxService.append( + eventId, + CATALOG_TOPIC, + productId.toString(), + eventType, + payload, + occurredAt + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeCoreEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeCoreEventPublisher.java new file mode 100644 index 000000000..715e8fdab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeCoreEventPublisher.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.event.LikeEvent; +import com.loopers.domain.like.event.LikeEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LikeCoreEventPublisher implements LikeEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(LikeEvent.ProductLiked event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(LikeEvent.ProductUnliked event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCoreEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCoreEventPublisher.java new file mode 100644 index 000000000..c0e0fd460 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderCoreEventPublisher.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.event.OrderEvent; +import com.loopers.domain.order.event.OrderEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderCoreEventPublisher implements OrderEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(OrderEvent.PaymentRequested event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderPaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderPaymentEventListener.java new file mode 100644 index 000000000..320c9d16a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderPaymentEventListener.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.order; + +import com.loopers.application.order.OrderPaymentProcessor; +import com.loopers.application.order.OrderPaymentSupport; +import com.loopers.domain.order.event.OrderEvent; +import com.loopers.infrastructure.payment.PgPaymentClient; +import com.loopers.infrastructure.payment.dto.PgPaymentV1Dto; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderPaymentEventListener { + + private final PgPaymentClient pgPaymentClient; + private final OrderPaymentProcessor orderPaymentProcessor; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(OrderEvent.PaymentRequested event) { + PgPaymentV1Dto.Request request = new PgPaymentV1Dto.Request( + event.orderReference(), + event.cardType(), + event.cardNo(), + event.totalAmount(), + event.callbackUrl() + ); + + try { + ApiResponse response = pgPaymentClient.requestPayment(event.userId(), request); + PgPaymentV1Dto.Response pgData = OrderPaymentSupport.requirePgResponse(response); + orderPaymentProcessor.handlePaymentResult(event.orderId(), pgData.status(), pgData.transactionKey(), pgData.reason()); + } catch (Exception exception) { + log.error("결제 요청 처리 실패 orderId={}", event.orderId(), exception); + orderPaymentProcessor.handlePaymentResult(event.orderId(), PgPaymentV1Dto.TransactionStatus.FAILED, null, exception.getMessage()); + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java new file mode 100644 index 000000000..a2da60956 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OutboxEventJpaRepository extends JpaRepository { + + Optional findByEventId(String eventId); + + List findByStatusOrderByIdAsc(OutboxStatus status, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java new file mode 100644 index 000000000..9fb44e9d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxEventPublisher { + + private final OutboxService outboxService; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + @Value("${outbox.publisher.batch-size:50}") + private int batchSize; + @Value("${outbox.publisher.enabled:true}") + private boolean publisherEnabled; + + @PostConstruct + void logConfiguration() { + log.info("OutboxEventPublisher enabled={}, batchSize={}", publisherEnabled, batchSize); + } + + @Scheduled(fixedDelayString = "${outbox.publisher.fixed-delay-ms:1000}") + @Transactional + public void publishPendingEvents() { + if (!publisherEnabled) { + return; + } + + List events = outboxService.fetchPending(batchSize); + if (events.isEmpty()) { + return; + } + + for (OutboxEvent event : events) { + try { + JsonNode payload = objectMapper.readTree(event.getPayload()); + kafkaTemplate.send(event.getTopic(), event.getPartitionKey(), payload).get(); + outboxService.markSent(event); + } catch (Exception exception) { + log.error("Outbox publish failed eventId={} topic={}", event.getEventId(), event.getTopic(), exception); + outboxService.markFailed(event, truncateMessage(exception.getMessage())); + } + } + } + + private String truncateMessage(String message) { + if (message == null) { + return null; + } + return message.length() > 500 ? message.substring(0, 500) : message; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java new file mode 100644 index 000000000..d178c1c15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import com.loopers.domain.outbox.OutboxStatus; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository outboxEventJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent event) { + return outboxEventJpaRepository.save(event); + } + + @Override + public Optional findByEventId(String eventId) { + return outboxEventJpaRepository.findByEventId(eventId); + } + + @Override + public List findTopPending(int size) { + int pageSize = Math.max(1, Math.min(size, 100)); + return outboxEventJpaRepository.findByStatusOrderByIdAsc( + OutboxStatus.PENDING, + PageRequest.of(0, pageSize) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/OrderPaymentSyncScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/OrderPaymentSyncScheduler.java new file mode 100644 index 000000000..59aeed092 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/OrderPaymentSyncScheduler.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.application.order.OrderPaymentProcessor; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderPaymentSyncScheduler { + + private final PaymentService paymentService; + private final OrderPaymentProcessor orderPaymentProcessor; + + @Scheduled(fixedDelayString = "${pg.sync.fixed-delay-ms:6000000}") + public void syncPendingPayments() { + List payments = paymentService.findPendingPayments(); + for (Payment payment : payments) { + try { + orderPaymentProcessor.syncPayment(payment.getOrderId()); + } catch (Exception e) { + log.warn("결제 동기화 실패 orderId={}, reason={}", payment.getOrderId(), e.getMessage()); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java new file mode 100644 index 000000000..08f592b38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PaymentJpaRepository extends JpaRepository { + + Optional findByOrderId(Long orderId); + + Optional findByOrderReference(String orderReference); + + List findByStatus(PaymentStatus status); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java new file mode 100644 index 000000000..cd2d302f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import com.loopers.domain.payment.PaymentStatus; +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public Payment save(Payment payment) { + return paymentJpaRepository.save(payment); + } + + @Override + public Optional findByOrderId(Long orderId) { + return paymentJpaRepository.findByOrderId(orderId); + } + + @Override + public Optional findByOrderReference(String orderReference) { + return paymentJpaRepository.findByOrderReference(orderReference); + } + + @Override + public List findByStatus(PaymentStatus status) { + return paymentJpaRepository.findByStatus(status); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java new file mode 100644 index 000000000..7c29be813 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.infrastructure.payment.dto.PgPaymentV1Dto; +import com.loopers.interfaces.api.ApiResponse; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import java.util.Collections; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient( + name = "pgPaymentClient", + url = "${pg.client.base-url:http://localhost:8081}" +) +public interface PgPaymentClient { + + @PostMapping("/api/v1/payments") + @CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallback") + @Retry(name = "pgRetry") + ApiResponse requestPayment( + @RequestHeader("X-USER-ID") String userId, + @RequestBody PgPaymentV1Dto.Request request + ); + + @GetMapping("/api/v1/payments") + @CircuitBreaker(name = "pgCircuit", fallbackMethod = "getPaymentsFallback") + @Retry(name = "pgRetry") + ApiResponse getPayments( + @RequestHeader("X-USER-ID") String userId, + @RequestParam("orderId") String orderId + ); + + default ApiResponse fallback(String userId, PgPaymentV1Dto.Request request, Throwable throwable) { + PgPaymentV1Dto.Response response = new PgPaymentV1Dto.Response( + null, + PgPaymentV1Dto.TransactionStatus.FAILED, + throwable.getMessage() + ); + return new ApiResponse<>( + ApiResponse.Metadata.fail("PG_ERROR", throwable.getMessage()), + response + ); + } + + default ApiResponse getPaymentsFallback(String userId, String orderId, Throwable throwable) { + PgPaymentV1Dto.OrderResponse response = new PgPaymentV1Dto.OrderResponse(orderId, Collections.emptyList()); + return new ApiResponse<>( + ApiResponse.Metadata.fail("PG_ERROR", throwable.getMessage()), + response + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentV1Dto.java new file mode 100644 index 000000000..86790686e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentV1Dto.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.payment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.util.Collections; +import java.util.List; + +public class PgPaymentV1Dto { + + public enum TransactionStatus { + SUCCESS, + FAILED, + PENDING + } + + public record Request( + @NotBlank(message = "주문 ID는 필수입니다") + String orderId, + @NotBlank(message = "카드 타입은 필수입니다") + String cardType, + @NotBlank(message = "카드 번호는 필수입니다") + String cardNo, + @NotNull(message = "결제 금액은 필수입니다") + @Positive(message = "결제 금액은 0보다 커야 합니다") + Long amount, + @NotBlank(message = "콜백 URL은 필수입니다") + String callbackUrl + ) { + } + + public record Response( + String transactionKey, + PgPaymentV1Dto.TransactionStatus status, + String reason + ) { + } + + public record OrderResponse( + String orderId, + List transactions + ) { + public OrderResponse(String orderId) { + this(orderId, Collections.emptyList()); + } + } + + public record TransactionRecord( + String transactionKey, + PgPaymentV1Dto.TransactionStatus status, + String reason + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java new file mode 100644 index 000000000..58c361d20 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java @@ -0,0 +1,100 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCache; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class RedisProductCache implements ProductCache { + + private static final Duration TTL_LIST = Duration.ofMinutes(10); + private static final Duration TTL_DETAIL = Duration.ofMinutes(5); + private static final Logger log = LoggerFactory.getLogger(RedisProductCache.class); + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public Optional> getProductList(Long brandId, Pageable pageable) { + + String key = listKey(brandId, pageable); + String json = redisTemplate.opsForValue().get(key); + + if (json == null) return Optional.empty(); + + try { + Page page = + objectMapper.readValue(json, new TypeReference>() { + }); + return Optional.of(page); + } catch (Exception e) { + log.warn("Failed to deserialize product list from cache for key: {}", key, e); + return Optional.empty(); + } + } + + @Override + public void putProductList(Long brandId, Pageable pageable, Page products) { + String key = listKey(brandId, pageable); + + try { + String json = objectMapper.writeValueAsString(products); + redisTemplate.opsForValue().set(key, json, TTL_LIST); + } catch (Exception e) { + log.warn("Failed to cache product list for key: {}", key, e); + } + } + + @Override + public Optional getProductDetail(Long productId) { + String key = detailKey(productId); + String json = redisTemplate.opsForValue().get(key); + + if (json == null) return Optional.empty(); + + try { + Product product = objectMapper.readValue(json, Product.class); + return Optional.of(product); + } catch (Exception e) { + log.warn("Failed to deserialize product detail from cache for key: {}", key, e); + return Optional.empty(); + } + } + + @Override + public void putProductDetail(Long productId, Product product) { + String key = detailKey(productId); + try { + String json = objectMapper.writeValueAsString(product); + redisTemplate.opsForValue().set(key, json, TTL_DETAIL); + } catch (Exception e) { + log.warn("Failed to cache product detail for key: {}", key, e); + } + } + + private String listKey(Long brandId, Pageable pageable) { + String sortKey = pageable.getSort().toString(); + return "product:list:" + + (brandId == null ? "all" : brandId) + ":" + + pageable.getPageNumber() + ":" + + pageable.getPageSize() + ":" + + sortKey; + } + + private String detailKey(Long productId) { + return "product:detail:" + productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..62b4885dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Order V1 API", description = "Order API 입니다.") +@RequestMapping("/api/v1/orders") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 생성", description = "상품 주문을 생성합니다.") + @PostMapping + ApiResponse createOrder( + @RequestHeader("X-USER-ID") String userId, + @Valid @RequestBody OrderV1Dto.OrderCreateRequest request + ); + + @Operation(summary = "결제 콜백", description = "PG가 결제 결과를 전달합니다.") + @PostMapping("/{orderReference}/callback") + ApiResponse callback( + @PathVariable("orderReference") String orderReference, + @Valid @RequestBody OrderV1Dto.PaymentCallbackRequest request + ); + + @Operation(summary = "결제 상태 동기화", description = "콜백 누락 시 결제 상태를 수동으로 동기화합니다.") + @PostMapping("/{orderId}/sync") + ApiResponse syncPayment( + @PathVariable("orderId") Long orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..af0310d2d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderPaymentProcessor; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + private final OrderPaymentProcessor orderPaymentProcessor; + + @Override + @PostMapping + public ApiResponse createOrder( + @RequestHeader("X-USER-ID") String userId, + @Valid @RequestBody OrderV1Dto.OrderCreateRequest request + ) { + OrderInfo orderInfo = orderFacade.createOrder(request.toCommand(userId)); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(orderInfo)); + } + + @Override + @PostMapping("/{orderReference}/callback") + public ApiResponse callback( + @PathVariable("orderReference") String orderReference, + @Valid @RequestBody OrderV1Dto.PaymentCallbackRequest request + ) { + orderPaymentProcessor.handlePaymentCallback(orderReference, request.status(), request.transactionKey(), request.reason()); + return ApiResponse.success(); + } + + @Override + @PostMapping("/{orderId}/sync") + public ApiResponse syncPayment(@PathVariable("orderId") Long orderId) { + orderPaymentProcessor.syncPayment(orderId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..b6bc7bc54 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,108 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.CreateOrderCommand; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderItemInfo; +import com.loopers.application.order.OrderPaymentCommand; +import com.loopers.domain.order.OrderStatus; +import com.loopers.infrastructure.payment.dto.PgPaymentV1Dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +public class OrderV1Dto { + + public record OrderCreateRequest( + @Valid + @NotEmpty(message = "상품 정보는 필수입니다.") + List items, + @Valid + @NotNull(message = "결제 정보는 필수입니다.") + PaymentRequest payment + ) { + public CreateOrderCommand toCommand(String userId) { + List itemCommands = items.stream() + .map(OrderItemRequest::toCommand) + .toList(); + return new CreateOrderCommand(userId, itemCommands, payment.toCommand()); + } + } + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + @NotNull(message = "수량은 필수입니다.") + @Positive(message = "수량은 1개 이상이어야 합니다.") + Long quantity + ) { + private OrderItemCommand toCommand() { + return new OrderItemCommand(productId, quantity); + } + } + + public record PaymentRequest( + @NotBlank(message = "카드사는 필수입니다.") + String cardType, + @NotBlank(message = "카드 번호는 필수입니다.") + @Pattern(regexp = "^\\d{4}-\\d{4}-\\d{4}-\\d{4}$", message = "카드 번호 형식이 올바르지 않습니다.") + String cardNo + ) { + private OrderPaymentCommand toCommand() { + return new OrderPaymentCommand(cardType, cardNo); + } + } + + public record PaymentCallbackRequest( + @NotNull(message = "상태는 필수입니다.") + PgPaymentV1Dto.TransactionStatus status, + String transactionKey, + String reason + ) { + } + + public record OrderResponse( + Long orderId, + String userId, + Long totalAmount, + OrderStatus status, + List items + ) { + public static OrderResponse from(OrderInfo info) { + List responses = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.orderId(), + info.userId(), + info.totalAmount(), + info.status(), + responses + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + Long quantity, + Long price, + Long amount + ) { + public static OrderItemResponse from(OrderItemInfo info) { + return new OrderItemResponse( + info.productId(), + info.productName(), + info.quantity(), + info.price(), + info.amount() + ); + } + } +} diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..f33c9ab29 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -10,6 +10,45 @@ server: keep-alive-timeout: 60s # 60s max-http-request-header-size: 8KB +app: + callback: + base-url: http://localhost:8082 + +pg: + client: + base-url: http://localhost:8081 + connect-timeout-ms: 1000 + read-timeout-ms: 500 + sync: + fixed-delay-ms: 6000000 + +feign: + client: + config: + pgPaymentClient: + connectTimeout: ${pg.client.connect-timeout-ms} + readTimeout: ${pg.client.read-timeout-ms} + +resilience4j: + retry: + instances: + pgRetry: + max-attempts: 3 + wait-duration: 200ms + retry-exceptions: + - feign.RetryableException + fail-after-max-attempts: true + + circuitbreaker: + instances: + pgCircuit: + sliding-window-size: 10 + failure-rate-threshold: 50 # 실패율이 50% 넘으면 Open + wait-duration-in-open-state: 5s # Open 상태 유지 시간 + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 50 + spring: main: web-application-type: servlet @@ -21,6 +60,7 @@ spring: import: - jpa.yml - redis.yml + - kafka.yml - logging.yml - monitoring.yml @@ -55,4 +95,4 @@ spring: springdoc: api-docs: - enabled: false \ No newline at end of file + enabled: false diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index 0be07a6fb..8141eaa39 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.*; @@ -26,6 +25,9 @@ @SpringBootTest class LikeServiceIntegrationTest { + private static final long LIKE_COUNT_AWAIT_TIMEOUT_MILLIS = 2_000L; + private static final long LIKE_COUNT_AWAIT_INTERVAL_MILLIS = 50L; + @Autowired private LikeService likeService; @@ -52,7 +54,6 @@ class LikeTests { @Test @DisplayName("좋아요 생성 성공 → 좋아요 저장 + 상품의 likeCount 증가") - @Transactional void likeSuccess() { // given User user = userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); @@ -65,19 +66,18 @@ void likeSuccess() { Like saved = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); assertThat(saved).isNotNull(); - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(1L); + awaitProductLikeCount(1L, 1L); } @Test @DisplayName("중복 좋아요 시 likeCount 증가 안 하고 저장도 안 됨") - @Transactional void duplicateLike() { // given userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); productRepository.save(Product.create(1L, "상품A", 1000L, 10L)); likeService.like("user1", 1L); + awaitProductLikeCount(1L, 1L); // when likeService.like("user1", 1L); // 중복 호출 @@ -86,19 +86,18 @@ void duplicateLike() { long likeCount = likeRepository.countByProductId(1L); assertThat(likeCount).isEqualTo(1L); - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(1L); // 증가 X + awaitProductLikeCount(1L, 1L); // 증가 X } @Test @DisplayName("좋아요 취소 성공 → like 삭제 + 상품의 likeCount 감소") - @Transactional void unlikeSuccess() { // given userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); productRepository.save(Product.create(1L, "상품A", 1000L, 10L)); likeService.like("user1", 1L); + awaitProductLikeCount(1L, 1L); // when likeService.unlike("user1", 1L); @@ -107,13 +106,11 @@ void unlikeSuccess() { Like like = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); assertThat(like).isNull(); - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(0L); + awaitProductLikeCount(1L, 0L); } @Test @DisplayName("없는 좋아요 취소 시 likeCount 감소 안 함") - @Transactional void unlikeNonExisting() { // given userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); @@ -135,7 +132,6 @@ void unlikeNonExisting() { @Test @DisplayName("countByProductId 정상 조회") - @Transactional void countTest() { // given userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); @@ -152,4 +148,28 @@ void countTest() { assertThat(count).isEqualTo(2L); } } + + private void awaitProductLikeCount(Long productId, long expectedCount) { + long waited = 0L; + while (waited <= LIKE_COUNT_AWAIT_TIMEOUT_MILLIS) { + long current = productRepository.findById(productId) + .map(Product::getLikeCount) + .orElseThrow(); + if (current == expectedCount) { + return; + } + sleep(LIKE_COUNT_AWAIT_INTERVAL_MILLIS); + waited += LIKE_COUNT_AWAIT_INTERVAL_MILLIS; + } + fail(String.format("productId=%d likeCount가 %d 에 도달하지 않았습니다.", productId, expectedCount)); + } + + private void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interruptedException); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java index d5b8bd851..612041a19 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -5,8 +5,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.time.LocalDateTime; - import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -41,7 +39,6 @@ void createLike_success() { // then assertThat(like.getUserId()).isEqualTo(userId); assertThat(like.getProductId()).isEqualTo(productId); - assertThat(like.getCreatedAt()).isBeforeOrEqualTo(LocalDateTime.now()); } @Test diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java index 149e71540..1623a9dcf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -4,10 +4,14 @@ import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderPaymentCommand; import com.loopers.domain.point.Point; import com.loopers.domain.point.PointRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.payment.PgPaymentClient; +import com.loopers.infrastructure.payment.dto.PgPaymentV1Dto; +import com.loopers.interfaces.api.ApiResponse; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -15,12 +19,16 @@ 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.transaction.annotation.Transactional; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; /** * packageName : com.loopers.domain.order @@ -36,6 +44,9 @@ @SpringBootTest public class OrderServiceIntegrationTest { + private static final long ORDER_AWAIT_TIMEOUT_MILLIS = 2_000L; + private static final long ORDER_AWAIT_INTERVAL_MILLIS = 50L; + @Autowired private OrderFacade orderFacade; @@ -51,6 +62,9 @@ public class OrderServiceIntegrationTest { @Autowired private DatabaseCleanUp databaseCleanUp; + @MockBean + private PgPaymentClient pgPaymentClient; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -70,12 +84,16 @@ void createOrder_success() { pointRepository.save(Point.create("user1", 20000L)); + when(pgPaymentClient.requestPayment(any(), any())) + .thenReturn(ApiResponse.success(new PgPaymentV1Dto.Response("tx-1", PgPaymentV1Dto.TransactionStatus.SUCCESS, null))); + CreateOrderCommand command = new CreateOrderCommand( "user1", List.of( new OrderItemCommand(p1.getId(), 2L), // 6000원 new OrderItemCommand(p2.getId(), 1L) // 4000원 - ) + ), + new OrderPaymentCommand("LOOP_CARD", "1111-2222-3333-4444") ); // when @@ -84,7 +102,7 @@ void createOrder_success() { // then Order saved = orderRepository.findById(info.orderId()).orElseThrow(); - assertThat(saved.getStatus()).isEqualTo(OrderStatus.COMPLETE); + awaitOrderStatus(saved.getId(), OrderStatus.COMPLETE); assertThat(saved.getTotalAmount()).isEqualTo(10000L); assertThat(saved.getOrderItems()).hasSize(2); @@ -114,7 +132,8 @@ void insufficientStock_fail() { CreateOrderCommand command = new CreateOrderCommand( "user1", - List.of(new OrderItemCommand(item.getId(), 5L)) + List.of(new OrderItemCommand(item.getId(), 5L)), + new OrderPaymentCommand("LOOP_CARD", "1111-2222-3333-4444") ); assertThatThrownBy(() -> orderFacade.createOrder(command)) @@ -130,7 +149,8 @@ void insufficientPoint_fail() { CreateOrderCommand command = new CreateOrderCommand( "user1", - List.of(new OrderItemCommand(item.getId(), 5L)) // 총 5000원 + List.of(new OrderItemCommand(item.getId(), 5L)), // 총 5000원 + new OrderPaymentCommand("LOOP_CARD", "1111-2222-3333-4444") ); assertThatThrownBy(() -> orderFacade.createOrder(command)) @@ -145,7 +165,8 @@ void noProduct_fail() { CreateOrderCommand command = new CreateOrderCommand( "user1", - List.of(new OrderItemCommand(999L, 1L)) + List.of(new OrderItemCommand(999L, 1L)), + new OrderPaymentCommand("LOOP_CARD", "1111-2222-3333-4444") ); assertThatThrownBy(() -> orderFacade.createOrder(command)) @@ -160,11 +181,34 @@ void noUserPoint_fail() { CreateOrderCommand command = new CreateOrderCommand( "user1", - List.of(new OrderItemCommand(item.getId(), 1L)) + List.of(new OrderItemCommand(item.getId(), 1L)), + new OrderPaymentCommand("LOOP_CARD", "1111-2222-3333-4444") ); assertThatThrownBy(() -> orderFacade.createOrder(command)) .isInstanceOf(RuntimeException.class); } } + + private void awaitOrderStatus(Long orderId, OrderStatus expectedStatus) { + long waited = 0L; + while (waited <= ORDER_AWAIT_TIMEOUT_MILLIS) { + Order order = orderRepository.findById(orderId).orElseThrow(); + if (order.getStatus() == expectedStatus) { + return; + } + sleep(ORDER_AWAIT_INTERVAL_MILLIS); + waited += ORDER_AWAIT_INTERVAL_MILLIS; + } + fail(String.format("orderId=%d 상태가 %s 로 변경되지 않았습니다.", orderId, expectedStatus)); + } + + private void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interruptedException); + } + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java new file mode 100644 index 000000000..1de215852 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java @@ -0,0 +1,62 @@ +package com.loopers.domain.event; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "event_handled") +@Getter +public class EventHandled { + + @Id + @Column(name = "event_id", nullable = false, length = 64) + private String eventId; + + @Column(nullable = false, length = 100) + private String handler; + + @Column(nullable = false, length = 100) + private String topic; + + @Column(name = "event_type", length = 100) + private String eventType; + + @Column(name = "occurred_at") + private ZonedDateTime occurredAt; + + @Column(name = "handled_at", nullable = false) + private ZonedDateTime handledAt; + + protected EventHandled() { + } + + private EventHandled( + String eventId, + String handler, + String topic, + String eventType, + ZonedDateTime occurredAt + ) { + this.eventId = eventId; + this.handler = handler; + this.topic = topic; + this.eventType = eventType; + this.occurredAt = occurredAt; + this.handledAt = ZonedDateTime.now(); + } + + public static EventHandled handled( + String eventId, + String handler, + String topic, + String eventType, + ZonedDateTime occurredAt + ) { + return new EventHandled(eventId, handler, topic, eventType, occurredAt); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java new file mode 100644 index 000000000..2918d3a02 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.event; + +import java.util.Optional; + +public interface EventHandledRepository { + + boolean existsByEventId(String eventId); + + EventHandled save(EventHandled eventHandled); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java new file mode 100644 index 000000000..99a155084 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java @@ -0,0 +1,31 @@ +package com.loopers.domain.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; + +@Component +@RequiredArgsConstructor +public class EventHandledService { + + private final EventHandledRepository eventHandledRepository; + + @Transactional(readOnly = true) + public boolean isHandled(String eventId) { + return eventId != null && eventHandledRepository.existsByEventId(eventId); + } + + @Transactional + public void markHandled(String eventId, String handler, String topic, String eventType, ZonedDateTime occurredAt) { + if (eventId == null) { + return; + } + try { + eventHandledRepository.save(EventHandled.handled(eventId, handler, topic, eventType, occurredAt)); + } catch (DataIntegrityViolationException ignore) { + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..5035dbffa --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,65 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "product_metrics") +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "last_catalog_version") + private Long lastCatalogVersion; + + @Column(name = "last_order_version") + private Long lastOrderVersion; + + protected ProductMetrics() { + } + + private ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.salesCount = 0L; + } + + public static ProductMetrics initialize(Long productId) { + return new ProductMetrics(productId); + } + + public boolean applyLikeDelta(long delta, long version) { + if (shouldSkip(version, lastCatalogVersion)) { + return false; + } + long next = this.likeCount + delta; + this.likeCount = Math.max(0L, next); + this.lastCatalogVersion = version; + return true; + } + + public boolean increaseSales(long quantity, long version) { + if (quantity <= 0 || shouldSkip(version, lastOrderVersion)) { + return false; + } + this.salesCount = Math.max(0L, this.salesCount + quantity); + this.lastOrderVersion = version; + return true; + } + + private boolean shouldSkip(long version, Long lastVersion) { + return lastVersion != null && lastVersion >= version; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..f321b6c39 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.metrics; + +import java.util.Optional; + +public interface ProductMetricsRepository { + + Optional findByProductId(Long productId); + + ProductMetrics save(ProductMetrics metrics); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java new file mode 100644 index 000000000..28c45eb73 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -0,0 +1,36 @@ +package com.loopers.domain.metrics; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class ProductMetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + @Transactional + public void applyLikeDelta(Long productId, long delta, long version) { + if (productId == null) { + return; + } + ProductMetrics metrics = productMetricsRepository.findByProductId(productId) + .orElseGet(() -> ProductMetrics.initialize(productId)); + if (metrics.applyLikeDelta(delta, version)) { + productMetricsRepository.save(metrics); + } + } + + @Transactional + public void increaseSales(Long productId, long quantity, long version) { + if (productId == null || quantity <= 0) { + return; + } + ProductMetrics metrics = productMetricsRepository.findByProductId(productId) + .orElseGet(() -> ProductMetrics.initialize(productId)); + if (metrics.increaseSales(quantity, version)) { + productMetricsRepository.save(metrics); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRefreshService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRefreshService.java new file mode 100644 index 000000000..246297753 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRefreshService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.product; + +import com.loopers.infrastructure.product.ProductCacheRefresher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class ProductCacheRefreshService { + + private final ProductInventoryService productInventoryService; + private final ProductCacheRefresher productCacheRefresher; + + @Transactional(readOnly = true) + public void refreshIfSoldOut(Long productId) { + if (productId == null) { + return; + } + Optional inventory = productInventoryService.findById(productId); + inventory.ifPresent(product -> { + Long stock = product.getStock(); + if (stock != null && stock <= 0) { + productCacheRefresher.evict(product.getId(), product.getBrandId()); + } + }); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventory.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventory.java new file mode 100644 index 000000000..f8a27dda3 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventory.java @@ -0,0 +1,26 @@ +package com.loopers.domain.product; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "product") +@Getter +public class ProductInventory { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "ref_brand_id") + private Long brandId; + + @Column(nullable = false) + private Long stock; + + protected ProductInventory() { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryRepository.java new file mode 100644 index 000000000..495c86c2a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.product; + +import java.util.Optional; + +public interface ProductInventoryRepository { + + Optional findById(Long productId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java new file mode 100644 index 000000000..e3a708528 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductInventoryService.java @@ -0,0 +1,19 @@ +package com.loopers.domain.product; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class ProductInventoryService { + + private final ProductInventoryRepository productInventoryRepository; + + @Transactional(readOnly = true) + public Optional findById(Long productId) { + return productInventoryRepository.findById(productId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledJpaRepository.java new file mode 100644 index 000000000..0a864cb48 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.event; + +import com.loopers.domain.event.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventHandledJpaRepository extends JpaRepository { + + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledRepositoryImpl.java new file mode 100644 index 000000000..7515e93e2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventHandledRepositoryImpl.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.event; + +import com.loopers.domain.event.EventHandled; +import com.loopers.domain.event.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository eventHandledJpaRepository; + + @Override + public boolean existsByEventId(String eventId) { + return eventHandledJpaRepository.existsByEventId(eventId); + } + + @Override + public EventHandled save(EventHandled eventHandled) { + return eventHandledJpaRepository.save(eventHandled); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..f691f0e6e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..4d52011f1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findById(productId); + } + + @Override + public ProductMetrics save(ProductMetrics metrics) { + return productMetricsJpaRepository.save(metrics); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRefresher.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRefresher.java new file mode 100644 index 000000000..d38e6a0cb --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRefresher.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.product; + +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductCacheRefresher { + + private static final String DETAIL_PREFIX = "product:detail:"; + private static final String LIST_PREFIX = "product:list:"; + + private final RedisTemplate redisTemplate; + + public void evict(Long productId, Long brandId) { + if (productId == null) { + return; + } + String detailKey = DETAIL_PREFIX + productId; + redisTemplate.delete(detailKey); + log.debug("Evicted product detail cache key={}", detailKey); + + evictPattern(LIST_PREFIX + "all:*"); + + if (brandId != null) { + evictPattern(LIST_PREFIX + brandId + ":*"); + } + } + + private void evictPattern(String pattern) { + Set keys = redisTemplate.keys(pattern); + if (CollectionUtils.isEmpty(keys)) { + return; + } + redisTemplate.delete(keys); + log.debug("Evicted {} keys for pattern={}", keys.size(), pattern); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryJpaRepository.java new file mode 100644 index 000000000..f38d20167 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductInventory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductInventoryJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java new file mode 100644 index 000000000..4355b1248 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductInventoryRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductInventory; +import com.loopers.domain.product.ProductInventoryRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductInventoryRepositoryImpl implements ProductInventoryRepository { + + private final ProductInventoryJpaRepository productInventoryJpaRepository; + + @Override + public Optional findById(Long productId) { + return productInventoryJpaRepository.findById(productId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java new file mode 100644 index 000000000..f27081233 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogMetricsConsumer.java @@ -0,0 +1,65 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.EventHandledService; +import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.interfaces.consumer.message.CatalogEventMessage; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CatalogMetricsConsumer { + + private static final String TOPIC = "catalog-events"; + private static final String HANDLER = "catalog-metrics-consumer"; + + private final ProductMetricsService productMetricsService; + private final EventHandledService eventHandledService; + + @KafkaListener( + topics = TOPIC, + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consume( + List messages, + Acknowledgment acknowledgment + ) { + if (messages == null || messages.isEmpty()) { + acknowledgment.acknowledge(); + return; + } + + try { + for (CatalogEventMessage message : messages) { + if (message == null || message.eventId() == null) { + continue; + } + if (eventHandledService.isHandled(message.eventId())) { + log.debug("Skip already handled catalog event eventId={}", message.eventId()); + continue; + } + long version = message.occurredAt() == null + ? System.currentTimeMillis() + : message.occurredAt().toInstant().toEpochMilli(); + productMetricsService.applyLikeDelta(message.productId(), message.delta(), version); + eventHandledService.markHandled( + message.eventId(), + HANDLER, + TOPIC, + message.eventType(), + message.occurredAt() + ); + } + acknowledgment.acknowledge(); + } catch (Exception exception) { + log.error("Failed to process catalog events", exception); + throw exception; + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java new file mode 100644 index 000000000..46baa5978 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java @@ -0,0 +1,94 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.EventHandledService; +import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.domain.product.ProductCacheRefreshService; +import com.loopers.interfaces.consumer.message.OrderEventMessage; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderMetricsConsumer { + + private static final String TOPIC = "order-events"; + private static final String HANDLER = "order-metrics-consumer"; + + private final ProductMetricsService productMetricsService; + private final ProductCacheRefreshService productCacheRefreshService; + private final EventHandledService eventHandledService; + + @KafkaListener( + topics = TOPIC, + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consume( + List messages, + Acknowledgment acknowledgment + ) { + if (messages == null || messages.isEmpty()) { + acknowledgment.acknowledge(); + return; + } + + try { + for (OrderEventMessage message : messages) { + if (!isValid(message)) { + continue; + } + if (eventHandledService.isHandled(message.eventId())) { + log.debug("Skip already handled order event eventId={}", message.eventId()); + continue; + } + long version = extractVersion(message); + if (shouldCountSale(message)) { + handleSalesMetrics(message, version); + } + eventHandledService.markHandled( + message.eventId(), + HANDLER, + TOPIC, + message.eventType(), + message.occurredAt() + ); + } + acknowledgment.acknowledge(); + } catch (Exception exception) { + log.error("Failed to process order events", exception); + throw exception; + } + } + + private boolean isValid(OrderEventMessage message) { + return message != null + && message.eventId() != null + && message.items() != null; + } + + private boolean shouldCountSale(OrderEventMessage message) { + return Objects.equals(message.orderStatus(), "COMPLETE"); + } + + private void handleSalesMetrics(OrderEventMessage message, long version) { + for (OrderEventMessage.OrderItemPayload item : message.items()) { + if (item == null || item.productId() == null || item.quantity() == null) { + continue; + } + productMetricsService.increaseSales(item.productId(), item.quantity(), version); + productCacheRefreshService.refreshIfSoldOut(item.productId()); + } + } + + private long extractVersion(OrderEventMessage message) { + return message.occurredAt() == null + ? System.currentTimeMillis() + : message.occurredAt().toInstant().toEpochMilli(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/CatalogEventMessage.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/CatalogEventMessage.java new file mode 100644 index 000000000..7b84ed0a0 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/CatalogEventMessage.java @@ -0,0 +1,13 @@ +package com.loopers.interfaces.consumer.message; + +import java.time.ZonedDateTime; + +public record CatalogEventMessage( + String eventId, + String eventType, + Long productId, + String userId, + long delta, + ZonedDateTime occurredAt +) { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/OrderEventMessage.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/OrderEventMessage.java new file mode 100644 index 000000000..d36581858 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/message/OrderEventMessage.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.consumer.message; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderEventMessage( + String eventId, + String eventType, + Long orderId, + String userId, + Long totalAmount, + String orderStatus, + String paymentStatus, + String transactionKey, + String reason, + ZonedDateTime occurredAt, + List items +) { + public record OrderItemPayload(Long productId, String productName, Long quantity, Long price) { + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..dfcd8334b --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ProductMetrics 도메인 테스트") +class ProductMetricsTest { + + @Nested + @DisplayName("좋아요 증감 로직") + class LikeDeltaTests { + + @Test + @DisplayName("같은 이벤트가 다시 들어오면 좋아요가 한 번만 반영된다") + void applyLikeDelta_updatesOncePerVersion() { + ProductMetrics metrics = ProductMetrics.initialize(1L); + + boolean applied = metrics.applyLikeDelta(2, 100L); + boolean skipped = metrics.applyLikeDelta(1, 99L); + boolean duplicateSkip = metrics.applyLikeDelta(1, 100L); + + assertThat(applied).isTrue(); + assertThat(skipped).isFalse(); + assertThat(duplicateSkip).isFalse(); + assertThat(metrics.getLikeCount()).isEqualTo(2); + } + } + + @Nested + @DisplayName("판매량 집계 로직") + class SalesTests { + + @Test + @DisplayName("이전 버전이나 동일 버전이면 판매량이 증가하지 않는다") + void increaseSales_appliesOnlyForNewerVersion() { + ProductMetrics metrics = ProductMetrics.initialize(2L); + + boolean applied = metrics.increaseSales(3, 200L); + boolean skipped = metrics.increaseSales(5, 150L); + boolean duplicateSkip = metrics.increaseSales(1, 200L); + + assertThat(applied).isTrue(); + assertThat(skipped).isFalse(); + assertThat(duplicateSkip).isFalse(); + assertThat(metrics.getSalesCount()).isEqualTo(3); + } + } +} diff --git a/apps/pg-simulator/README.md b/apps/pg-simulator/README.md new file mode 100644 index 000000000..118642638 --- /dev/null +++ b/apps/pg-simulator/README.md @@ -0,0 +1,42 @@ +## PG-Simulator (PaymentGateway) + +### Description +Loopback BE 과정을 위해 PaymentGateway 를 시뮬레이션하는 App Module 입니다. +`local` 프로필로 실행 권장하며, 커머스 서비스와의 동시 실행을 위해 서버 포트가 조정되어 있습니다. +- server port : 8082 +- actuator port : 8083 + +### Getting Started +부트 서버를 아래 명령어 혹은 `intelliJ` 통해 실행해주세요. +```shell +./gradlew :apps:pg-simulator:bootRun +``` + +API 는 아래와 같이 주어지니, 커머스 서비스와 동시에 실행시킨 후 진행해주시면 됩니다. +- 결제 요청 API +- 결제 정보 확인 `by transactionKey` +- 결제 정보 목록 조회 `by orderId` + +```http request +### 결제 요청 +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### 결제 정보 확인 +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### 주문에 엮인 결제 정보 조회 +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135 + +``` \ No newline at end of file diff --git a/apps/pg-simulator/build.gradle.kts b/apps/pg-simulator/build.gradle.kts new file mode 100644 index 000000000..653d549da --- /dev/null +++ b/apps/pg-simulator/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + val kotlinVersion = "2.0.20" + + id("org.jetbrains.kotlin.jvm") version(kotlinVersion) + id("org.jetbrains.kotlin.kapt") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion) +} + +kotlin { + compilerOptions { + jvmToolchain(21) + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // kotlin + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // querydsl + kapt("com.querydsl:querydsl-apt::jakarta") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt new file mode 100644 index 000000000..05595d135 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt @@ -0,0 +1,24 @@ +package com.loopers + +import jakarta.annotation.PostConstruct +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableAsync +import java.util.TimeZone + +@ConfigurationPropertiesScan +@EnableAsync +@SpringBootApplication +class PaymentGatewayApplication { + + @PostConstruct + fun started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + } +} + +fun main(args: Array) { + runApplication(*args) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt new file mode 100644 index 000000000..7e04d1ce0 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt @@ -0,0 +1,14 @@ +package com.loopers.application.payment + +/** + * 결제 주문 정보 + * + * 결제는 주문에 대한 다수 트랜잭션으로 구성됩니다. + * + * @property orderId 주문 정보 + * @property transactions 주문에 엮인 트랜잭션 목록 + */ +data class OrderInfo( + val orderId: String, + val transactions: List, +) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt new file mode 100644 index 000000000..9a5ebdc5d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt @@ -0,0 +1,88 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import com.loopers.domain.payment.PaymentRelay +import com.loopers.domain.payment.PaymentRepository +import com.loopers.domain.payment.TransactionKeyGenerator +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class PaymentApplicationService( + private val paymentRepository: PaymentRepository, + private val paymentEventPublisher: PaymentEventPublisher, + private val paymentRelay: PaymentRelay, + private val transactionKeyGenerator: TransactionKeyGenerator, +) { + companion object { + private val RATE_LIMIT_EXCEEDED = (1..20) + private val RATE_INVALID_CARD = (21..30) + } + + @Transactional + fun createTransaction(command: PaymentCommand.CreateTransaction): TransactionInfo { + command.validate() + + val transactionKey = transactionKeyGenerator.generate() + val payment = paymentRepository.save( + Payment( + transactionKey = transactionKey, + userId = command.userId, + orderId = command.orderId, + cardType = command.cardType, + cardNo = command.cardNo, + amount = command.amount, + callbackUrl = command.callbackUrl, + ), + ) + + paymentEventPublisher.publish(PaymentEvent.PaymentCreated.from(payment = payment)) + + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun getTransactionDetailInfo(userInfo: UserInfo, transactionKey: String): TransactionInfo { + val payment = paymentRepository.findByTransactionKey(userId = userInfo.userId, transactionKey = transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun findTransactionsByOrderId(userInfo: UserInfo, orderId: String): OrderInfo { + val payments = paymentRepository.findByOrderId(userId = userInfo.userId, orderId = orderId) + if (payments.isEmpty()) { + throw CoreException(ErrorType.NOT_FOUND, "(orderId: $orderId) 에 해당하는 결제건이 존재하지 않습니다.") + } + + return OrderInfo( + orderId = orderId, + transactions = payments.map { TransactionInfo.from(it) }, + ) + } + + @Transactional + fun handle(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + + val rate = (1..100).random() + when (rate) { + in RATE_LIMIT_EXCEEDED -> payment.limitExceeded() + in RATE_INVALID_CARD -> payment.invalidCard() + else -> payment.approve() + } + paymentEventPublisher.publish(event = PaymentEvent.PaymentHandled.from(payment)) + } + + fun notifyTransactionResult(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment)) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt new file mode 100644 index 000000000..01d8ae440 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt @@ -0,0 +1,22 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentCommand { + data class CreateTransaction( + val userId: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + fun validate() { + if (amount <= 0L) { + throw CoreException(ErrorType.BAD_REQUEST, "요청 금액은 0 보다 큰 정수여야 합니다.") + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt new file mode 100644 index 000000000..5c21e51af --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt @@ -0,0 +1,39 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.TransactionStatus + +/** + * 트랜잭션 정보 + * + * @property transactionKey 트랜잭션 KEY + * @property orderId 주문 ID + * @property cardType 카드 종류 + * @property cardNo 카드 번호 + * @property amount 금액 + * @property status 처리 상태 + * @property reason 처리 사유 + */ +data class TransactionInfo( + val transactionKey: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val status: TransactionStatus, + val reason: String?, +) { + companion object { + fun from(payment: Payment): TransactionInfo = + TransactionInfo( + transactionKey = payment.transactionKey, + orderId = payment.orderId, + cardType = payment.cardType, + cardNo = payment.cardNo, + amount = payment.amount, + status = payment.status, + reason = payment.reason, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt new file mode 100644 index 000000000..8aec9dc82 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt @@ -0,0 +1,13 @@ +package com.loopers.config.web + +import com.loopers.interfaces.api.argumentresolver.UserInfoArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(UserInfoArgumentResolver()) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt new file mode 100644 index 000000000..55008a95d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class CardType { + SAMSUNG, + KB, + HYUNDAI, +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt new file mode 100644 index 000000000..cfc2386c1 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt @@ -0,0 +1,87 @@ +package com.loopers.domain.payment + +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table( + name = "payments", + indexes = [ + Index(name = "idx_user_transaction", columnList = "user_id, transaction_key"), + Index(name = "idx_user_order", columnList = "user_id, order_id"), + Index(name = "idx_unique_user_order_transaction", columnList = "user_id, order_id, transaction_key", unique = true), + ] +) +class Payment( + @Id + @Column(name = "transaction_key", nullable = false, unique = true) + val transactionKey: String, + + @Column(name = "user_id", nullable = false) + val userId: String, + + @Column(name = "order_id", nullable = false) + val orderId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "card_type", nullable = false) + val cardType: CardType, + + @Column(name = "card_no", nullable = false) + val cardNo: String, + + @Column(name = "amount", nullable = false) + val amount: Long, + + @Column(name = "callback_url", nullable = false) + val callbackUrl: String, +) { + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: TransactionStatus = TransactionStatus.PENDING + private set + + @Column(name = "reason", nullable = true) + var reason: String? = null + private set + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now() + private set + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() + private set + + fun approve() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "결제승인은 대기상태에서만 가능합니다.") + } + status = TransactionStatus.SUCCESS + reason = "정상 승인되었습니다." + } + + fun invalidCard() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "결제처리는 대기상태에서만 가능합니다.") + } + status = TransactionStatus.FAILED + reason = "잘못된 카드입니다. 다른 카드를 선택해주세요." + } + + fun limitExceeded() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "한도초과 처리는 대기상태에서만 가능합니다.") + } + status = TransactionStatus.FAILED + reason = "한도초과입니다. 다른 카드를 선택해주세요." + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt new file mode 100644 index 000000000..8e495b2e3 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt @@ -0,0 +1,28 @@ +package com.loopers.domain.payment + +object PaymentEvent { + data class PaymentCreated( + val transactionKey: String, + ) { + companion object { + fun from(payment: Payment): PaymentCreated = PaymentCreated(transactionKey = payment.transactionKey) + } + } + + data class PaymentHandled( + val transactionKey: String, + val status: TransactionStatus, + val reason: String?, + val callbackUrl: String, + ) { + companion object { + fun from(payment: Payment): PaymentHandled = + PaymentHandled( + transactionKey = payment.transactionKey, + status = payment.status, + reason = payment.reason, + callbackUrl = payment.callbackUrl, + ) + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt new file mode 100644 index 000000000..251c68319 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt @@ -0,0 +1,6 @@ +package com.loopers.domain.payment + +interface PaymentEventPublisher { + fun publish(event: PaymentEvent.PaymentCreated) + fun publish(event: PaymentEvent.PaymentHandled) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt new file mode 100644 index 000000000..e622899b2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +import com.loopers.application.payment.TransactionInfo + +interface PaymentRelay { + fun notify(callbackUrl: String, transactionInfo: TransactionInfo) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt new file mode 100644 index 000000000..c1173c0aa --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.payment + +interface PaymentRepository { + fun save(payment: Payment): Payment + fun findByTransactionKey(transactionKey: String): Payment? + fun findByTransactionKey(userId: String, transactionKey: String): Payment? + fun findByOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt new file mode 100644 index 000000000..c8703a763 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt @@ -0,0 +1,20 @@ +package com.loopers.domain.payment + +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +@Component +class TransactionKeyGenerator { + companion object { + private const val KEY_TRANSACTION = "TR" + private val DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") + } + + fun generate(): String { + val now = LocalDateTime.now() + val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 6) + return "${DATETIME_FORMATTER.format(now)}:$KEY_TRANSACTION:$uuid" + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt new file mode 100644 index 000000000..0c94bcfb9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class TransactionStatus { + PENDING, + SUCCESS, + FAILED +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt new file mode 100644 index 000000000..c51e660a9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.user + +/** + * user 정보 + * + * @param userId 유저 식별자 + */ +data class UserInfo(val userId: String) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt new file mode 100644 index 000000000..715516360 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class PaymentCoreEventPublisher( + private val applicationEventPublisher: ApplicationEventPublisher, +) : PaymentEventPublisher { + override fun publish(event: PaymentEvent.PaymentCreated) { + applicationEventPublisher.publishEvent(event) + } + + override fun publish(event: PaymentEvent.PaymentHandled) { + applicationEventPublisher.publishEvent(event) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt new file mode 100644 index 000000000..ffd643c0f --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.payment + +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.PaymentRelay +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate + +@Component +class PaymentCoreRelay : PaymentRelay { + companion object { + private val logger = LoggerFactory.getLogger(PaymentCoreRelay::class.java) + private val restTemplate = RestTemplate() + } + + override fun notify(callbackUrl: String, transactionInfo: TransactionInfo) { + runCatching { + restTemplate.postForEntity(callbackUrl, transactionInfo, Any::class.java) + }.onFailure { e -> logger.error("콜백 호출을 실패했습니다. {}", e.message, e) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt new file mode 100644 index 000000000..cf521c47d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentRepository +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import kotlin.jvm.optionals.getOrNull + +@Component +class PaymentCoreRepository( + private val paymentJpaRepository: PaymentJpaRepository, +) : PaymentRepository { + @Transactional + override fun save(payment: Payment): Payment { + return paymentJpaRepository.save(payment) + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(transactionKey: String): Payment? { + return paymentJpaRepository.findById(transactionKey).getOrNull() + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(userId: String, transactionKey: String): Payment? { + return paymentJpaRepository.findByUserIdAndTransactionKey(userId, transactionKey) + } + + override fun findByOrderId(userId: String, orderId: String): List { + return paymentJpaRepository.findByUserIdAndOrderId(userId, orderId) + .sortedByDescending { it.updatedAt } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt new file mode 100644 index 000000000..a5ea32822 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import org.springframework.data.jpa.repository.JpaRepository + +interface PaymentJpaRepository : JpaRepository { + fun findByUserIdAndTransactionKey(userId: String, transactionKey: String): Payment? + fun findByUserIdAndOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt new file mode 100644 index 000000000..434a229e2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api + +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.server.ServerWebInputException +import org.springframework.web.servlet.resource.NoResourceFoundException +import kotlin.collections.joinToString +import kotlin.jvm.java +import kotlin.text.isNotEmpty +import kotlin.text.toRegex + +@RestControllerAdvice +class ApiControllerAdvice { + private val log = LoggerFactory.getLogger(ApiControllerAdvice::class.java) + + @ExceptionHandler + fun handle(e: CoreException): ResponseEntity> { + log.warn("CoreException : {}", e.customMessage ?: e.message, e) + return failureResponse(errorType = e.errorType, errorMessage = e.customMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: MethodArgumentTypeMismatchException): ResponseEntity> { + val name = e.name + val type = e.requiredType?.simpleName ?: "unknown" + val value = e.value ?: "null" + val message = "요청 파라미터 '$name' (타입: $type)의 값 '$value'이(가) 잘못되었습니다." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: MissingServletRequestParameterException): ResponseEntity> { + val name = e.parameterName + val type = e.parameterType + val message = "필수 요청 파라미터 '$name' (타입: $type)가 누락되었습니다." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: HttpMessageNotReadableException): ResponseEntity> { + val errorMessage = when (val rootCause = e.rootCause) { + is InvalidFormatException -> { + val fieldName = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + + val valueIndicationMessage = when { + rootCause.targetType.isEnum -> { + val enumClass = rootCause.targetType + val enumValues = enumClass.enumConstants.joinToString(", ") { it.toString() } + "사용 가능한 값 : [$enumValues]" + } + + else -> "" + } + + val expectedType = rootCause.targetType.simpleName + val value = rootCause.value + + "필드 '$fieldName'의 값 '$value'이(가) 예상 타입($expectedType)과 일치하지 않습니다. $valueIndicationMessage" + } + + is MismatchedInputException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "필수 필드 '$fieldPath'이(가) 누락되었습니다." + } + + is JsonMappingException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "필드 '$fieldPath'에서 JSON 매핑 오류가 발생했습니다: ${rootCause.originalMessage}" + } + + else -> "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요." + } + + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = errorMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: ServerWebInputException): ResponseEntity> { + fun extractMissingParameter(message: String): String { + val regex = "'(.+?)'".toRegex() + return regex.find(message)?.groupValues?.get(1) ?: "" + } + + val missingParams = extractMissingParameter(e.reason ?: "") + return if (missingParams.isNotEmpty()) { + failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = "필수 요청 값 \'$missingParams\'가 누락되었습니다.") + } else { + failureResponse(errorType = ErrorType.BAD_REQUEST) + } + } + + @ExceptionHandler + fun handleNotFound(e: NoResourceFoundException): ResponseEntity> { + return failureResponse(errorType = ErrorType.NOT_FOUND) + } + + @ExceptionHandler + fun handle(e: Throwable): ResponseEntity> { + log.error("Exception : {}", e.message, e) + val errorType = ErrorType.INTERNAL_ERROR + return failureResponse(errorType = errorType) + } + + private fun failureResponse(errorType: ErrorType, errorMessage: String? = null): ResponseEntity> = + ResponseEntity( + ApiResponse.fail(errorCode = errorType.code, errorMessage = errorMessage ?: errorType.message), + errorType.status, + ) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt new file mode 100644 index 000000000..f5c38ab5e --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api + +data class ApiResponse( + val meta: Metadata, + val data: T?, +) { + data class Metadata( + val result: Result, + val errorCode: String?, + val message: String?, + ) { + enum class Result { SUCCESS, FAIL } + + companion object { + fun success() = Metadata(Result.SUCCESS, null, null) + + fun fail(errorCode: String, errorMessage: String) = Metadata(Result.FAIL, errorCode, errorMessage) + } + } + + companion object { + fun success(): ApiResponse = ApiResponse(Metadata.success(), null) + + fun success(data: T? = null) = ApiResponse(Metadata.success(), data) + + fun fail(errorCode: String, errorMessage: String): ApiResponse = + ApiResponse( + meta = Metadata.fail(errorCode = errorCode, errorMessage = errorMessage), + data = null, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt new file mode 100644 index 000000000..9ef6c25da --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.argumentresolver + +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class UserInfoArgumentResolver: HandlerMethodArgumentResolver { + companion object { + private const val KEY_USER_ID = "X-USER-ID" + } + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return UserInfo::class.java.isAssignableFrom(parameter.parameterType) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): UserInfo { + val userId = webRequest.getHeader(KEY_USER_ID) + ?: throw CoreException(ErrorType.BAD_REQUEST, "유저 ID 헤더는 필수입니다.") + + return UserInfo(userId) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt new file mode 100644 index 000000000..22d5cbe38 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.interfaces.api.ApiResponse +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/payments") +class PaymentApi( + private val paymentApplicationService: PaymentApplicationService, +) { + @PostMapping + fun request( + userInfo: UserInfo, + @RequestBody request: PaymentDto.PaymentRequest, + ): ApiResponse { + request.validate() + + // 100ms ~ 500ms 지연 + Thread.sleep((100..500L).random()) + + // 40% 확률로 요청 실패 + if ((1..100).random() <= 40) { + throw CoreException(ErrorType.INTERNAL_ERROR, "현재 서버가 불안정합니다. 잠시 후 다시 시도해주세요.") + } + + return paymentApplicationService.createTransaction(request.toCommand(userInfo.userId)) + .let { PaymentDto.TransactionResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping("/{transactionKey}") + fun getTransaction( + userInfo: UserInfo, + @PathVariable("transactionKey") transactionKey: String, + ): ApiResponse { + return paymentApplicationService.getTransactionDetailInfo(userInfo, transactionKey) + .let { PaymentDto.TransactionDetailResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping + fun getTransactionsByOrder( + userInfo: UserInfo, + @RequestParam("orderId", required = false) orderId: String, + ): ApiResponse { + return paymentApplicationService.findTransactionsByOrderId(userInfo, orderId) + .let { PaymentDto.OrderResponse.from(it) } + .let { ApiResponse.success(it) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt new file mode 100644 index 000000000..52a00b156 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt @@ -0,0 +1,136 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.OrderInfo +import com.loopers.application.payment.PaymentCommand +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.TransactionStatus +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentDto { + data class PaymentRequest( + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + companion object { + private val REGEX_CARD_NO = Regex("^\\d{4}-\\d{4}-\\d{4}-\\d{4}$") + private const val PREFIX_CALLBACK_URL = "http://localhost:8080" + } + + fun validate() { + if (orderId.isBlank() || orderId.length < 6) { + throw CoreException(ErrorType.BAD_REQUEST, "주문 ID는 6자리 이상 문자열이어야 합니다.") + } + if (!REGEX_CARD_NO.matches(cardNo)) { + throw CoreException(ErrorType.BAD_REQUEST, "카드 번호는 xxxx-xxxx-xxxx-xxxx 형식이어야 합니다.") + } + if (amount <= 0) { + throw CoreException(ErrorType.BAD_REQUEST, "결제금액은 양의 정수여야 합니다.") + } + if (!callbackUrl.startsWith(PREFIX_CALLBACK_URL)) { + throw CoreException(ErrorType.BAD_REQUEST, "콜백 URL 은 $PREFIX_CALLBACK_URL 로 시작해야 합니다.") + } + } + + fun toCommand(userId: String): PaymentCommand.CreateTransaction = + PaymentCommand.CreateTransaction( + userId = userId, + orderId = orderId, + cardType = cardType.toCardType(), + cardNo = cardNo, + amount = amount, + callbackUrl = callbackUrl, + ) + } + + data class TransactionDetailResponse( + val transactionKey: String, + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionDetailResponse = + TransactionDetailResponse( + transactionKey = transactionInfo.transactionKey, + orderId = transactionInfo.orderId, + cardType = CardTypeDto.from(transactionInfo.cardType), + cardNo = transactionInfo.cardNo, + amount = transactionInfo.amount, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class TransactionResponse( + val transactionKey: String, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionResponse = + TransactionResponse( + transactionKey = transactionInfo.transactionKey, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class OrderResponse( + val orderId: String, + val transactions: List, + ) { + companion object { + fun from(orderInfo: OrderInfo): OrderResponse = + OrderResponse( + orderId = orderInfo.orderId, + transactions = orderInfo.transactions.map { TransactionResponse.from(it) }, + ) + } + } + + enum class CardTypeDto { + SAMSUNG, + KB, + HYUNDAI, + ; + + fun toCardType(): CardType = when (this) { + SAMSUNG -> CardType.SAMSUNG + KB -> CardType.KB + HYUNDAI -> CardType.HYUNDAI + } + + companion object { + fun from(cardType: CardType) = when (cardType) { + CardType.SAMSUNG -> SAMSUNG + CardType.KB -> KB + CardType.HYUNDAI -> HYUNDAI + } + } + } + + enum class TransactionStatusResponse { + PENDING, + SUCCESS, + FAILED, + ; + + companion object { + fun from(transactionStatus: TransactionStatus) = when (transactionStatus) { + TransactionStatus.PENDING -> PENDING + TransactionStatus.SUCCESS -> SUCCESS + TransactionStatus.FAILED -> FAILED + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt new file mode 100644 index 000000000..241322890 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt @@ -0,0 +1,28 @@ +package com.loopers.interfaces.event.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.domain.payment.PaymentEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class PaymentEventListener( + private val paymentApplicationService: PaymentApplicationService, +) { + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentCreated) { + val thresholdMillis = (1000L..5000L).random() + Thread.sleep(thresholdMillis) + + paymentApplicationService.handle(event.transactionKey) + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentHandled) { + paymentApplicationService.notifyTransactionResult(transactionKey = event.transactionKey) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt new file mode 100644 index 000000000..120f7fc5f --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt @@ -0,0 +1,6 @@ +package com.loopers.support.error + +class CoreException( + val errorType: ErrorType, + val customMessage: String? = null, +) : RuntimeException(customMessage ?: errorType.message) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt new file mode 100644 index 000000000..e0799a5ea --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt @@ -0,0 +1,11 @@ +package com.loopers.support.error + +import org.springframework.http.HttpStatus + +enum class ErrorType(val status: HttpStatus, val code: String, val message: String) { + /** 범용 에러 */ + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase, "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.reasonPhrase, "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.reasonPhrase, "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.reasonPhrase, "이미 존재하는 리소스입니다."), +} diff --git a/apps/pg-simulator/src/main/resources/application.yml b/apps/pg-simulator/src/main/resources/application.yml new file mode 100644 index 000000000..addf0e29c --- /dev/null +++ b/apps/pg-simulator/src/main/resources/application.yml @@ -0,0 +1,77 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + +datasource: + mysql-jpa: + main: + jdbc-url: jdbc:mysql://localhost:3306/paymentgateway + +springdoc: + use-fqn: true + swagger-ui: + path: /swagger-ui.html + +--- +spring: + config: + activate: + on-profile: local, test + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: dev + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/docs/6round/6round.md b/docs/6round/6round.md new file mode 100644 index 000000000..fbdad45d9 --- /dev/null +++ b/docs/6round/6round.md @@ -0,0 +1,109 @@ +# 📝 Round 6 Quests + +--- + +## 💻 Implementation Quest + +> 외부 시스템(PG) 장애 및 지연에 대응하는 Resilience 설계를 학습하고 적용해봅니다. +`pg-simulator` 모듈을 활용하여 다양한 비동기 시스템과의 연동 및 실패 시나리오를 구현, 점검합니다. + + + +### **📦 추가 요구사항** + +```java +###결제 요청 + +POST { + { + pg - simulator + } +}/api/v1/payments +X-USER-ID:135135 +Content-Type:application/ + +json { + "orderId":"1351039135", + "cardType":"SAMSUNG", + "cardNo":"1234-5678-9814-1451", + "amount" :"5000", + "callbackUrl":"http://localhost:8080/api/v1/examples/callback" +} + +### +결제 정보 +확인 + +GET { + { + pg - simulator + } +}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID:135135 + + ### +주문에 엮인 +결제 정보 +조회 + +GET { + { + pg - simulator + } +}/api/v1/payments?orderId=1351039135 +X-USER-ID:135135 +``` + +- 결제 수단으로 PG 기반 카드 결제 기능을 추가합니다. +- PG 시스템은 로컬에서 실행가능한 `pg-simulator` 모듈이 제공됩니다. ( 별도 SpringBootApp ) +- PG 시스템은 **비동기 결제** 기능을 제공합니다. + +> *비동기 결제란, 요청과 실제 처리가 분리되어 있음을 의미합니다.* +**요청 성공 확률 : 60% +요청 지연 :** 100ms ~ 500ms +**처리 지연** : 1s ~ 5s +**처리 결과** + +* 성공 : 70% +* 한도 초과 : 20% +* 잘못된 카드 : 10% + +> + +### 📋 과제 정보 + +- 외부 시스템에 대해 적절한 타임아웃 기준에 대해 고려해보고, 적용합니다. +- 외부 시스템의 응답 지연 및 실패에 대해서 대처할 방법에 대해 고민해 봅니다. +- PG 결제 결과를 적절하게 시스템과 연동하고 이를 기반으로 주문 상태를 안전하게 처리할 방법에 대해 고민해 봅니다. +- 서킷브레이커를 통해 외부 시스템의 지연, 실패에 대해 대응하여 서비스 전체가 무너지지 않도록 보호합니다. + +--- + +## ✅ Checklist + +### **⚡ PG 연동 대응** + +- [x] PG 연동 API는 RestTemplate 혹은 FeignClient 로 외부 시스템을 호출한다. +- [x] 응답 지연에 대해 타임아웃을 설정하고, 실패 시 적절한 예외 처리 로직을 구현한다. +- [x] 결제 요청에 대한 실패 응답에 대해 적절한 시스템 연동을 진행한다. +- [x] 콜백 방식 + **결제 상태 확인 API**를 활용해 적절하게 시스템과 결제정보를 연동한다. + +### **🛡 Resilience 설계** + +- [x] 서킷 브레이커 혹은 재시도 정책을 적용하여 장애 확산을 방지한다. +- [x] 외부 시스템 장애 시에도 내부 시스템은 **정상적으로 응답**하도록 보호한다. +- [x] 콜백이 오지 않더라도, 일정 주기 혹은 수동 API 호출로 상태를 복구할 수 있다. +- [x] PG 에 대한 요청이 타임아웃에 의해 실패되더라도 해당 결제건에 대한 정보를 확인하여 정상적으로 시스템에 반영한다. \ No newline at end of file diff --git a/docs/7round/7round.md b/docs/7round/7round.md new file mode 100644 index 000000000..ba88a4d57 --- /dev/null +++ b/docs/7round/7round.md @@ -0,0 +1,53 @@ +# 📝 Round 7 Quests + +--- + +## 💻 Implementation Quest + +> `ApplicationEvent` 를 활용해 트랜잭션이나 기능을 무조건 분리하는 것이 아니라, **느슨해도 되는 경계** 를 잘 구분 지어 분리하는 실습을 진행합니다. +> 적절한 판단 기준을 근거로 **주요 로직**과 **부가 로직**을 잘 구분지어 보세요. +> + + + +### 📋 과제 정보 + +- **무조건 이벤트로 분리**가 아니라, *경계에 따라 동기/비동기를 나누는 감각*을 익힌다. +- 주문–결제 플로우에서 외부 I/O를 이벤트로 분리한다. +- 좋아요–집계 플로우에서 **eventual consistency** 를 적용한다. +- 트랜잭션 결과와의 상관관계에 따라 적절한 리스너를 활용해 메인 트랜잭션과 느슨하게 연결한다. + +--- + +## ✅ Checklist + +### 🧾 주문 ↔ 결제 + +- [x] **이벤트 기반**으로 주문 트랜잭션과 쿠폰 사용 처리를 분리한다. +- [x] **이벤트 기반**으로 결제 결과에 따른 주문 처리를 분리한다. +- [x] **이벤트 기반**으로 주문, 결제의 결과에 대한 데이터 플랫폼에 전송하는 후속처리를 진행한다. + +### ❤️ 좋아요 ↔ 집계 + +- [x] **이벤트 기반**으로 좋아요 처리와 집계를 분리한다. +- [x] 집계 로직의 성공/실패와 상관 없이, 좋아요 처리는 정상적으로 완료되어야 한다. + +### 📽️ 공통 + +- [x] 이벤트 기반으로 `유저의 행동` 에 대해 서버 레벨에서 로깅하고, 추적할 방법을 고민해 봅니다. + +> *상품 조회, 클릭, 좋아요, 주문 등* +> + +- [x] 동작의 주체를 적절하게 분리하고, 트랜잭션 간의 연관관계를 고민해 봅니다. diff --git a/docs/8round/8round.md b/docs/8round/8round.md new file mode 100644 index 000000000..943730259 --- /dev/null +++ b/docs/8round/8round.md @@ -0,0 +1,74 @@ +## 💻 Implementation Quest + +> 이번에는 카프카 기반의 **이벤트 파이프라인**을 구현합니다. +> 각 이벤트를 외부 시스템과 적절하게 주고 받을 수 있는 구조를 직접 체험해봅니다. +> + + + +### 📋 과제 정보 + +**Kafka 기반 이벤트 파이프라인을 구현합니다.** (최소 기준) + +- `commerce-api` → Kafka 의 방향으로 소통합니다. +- **Producer** 는 **At Least Once** 보장을 위해 이벤트를 반드시 발행합니다. + - **Transactional Outbox Pattern** 을 구현해 보고, 동작을 확인해 봅니다. +- **Consumer** 는 이벤트를 수취해 아래 기능을 수행합니다. + - **집계(Metrics)** : 좋아요 수 / 판매량 / 상세 페이지 조회 수 등을 `product_metrics` 테이블에 upsert + +**토픽 설계** (예시) + +- `catalog-events` (상품/재고/좋아요 이벤트, key=productId) +- `order-events` (주문/결제 이벤트, key=orderId) +- *각 세부 이벤트 별로 분리하고 싶다면, 분리해도 좋습니다.* + +**Producer, Consumer 필수 처리** + +- **Producer** + - acks=all, idempotence=true 설정 +- **Consumer** + - **manual Ack** 처리 + - `event_handled(event_id PK)` (DB or Redis) 기반의 멱등 처리 + - `version` 또는 `updated_at` 기준으로 최신 이벤트만 반영 + +> *왜 이벤트 핸들링 테이블과 로그 테이블을 분리하는 걸까? 에 대해 고민하고 리뷰 포인트에 작성해주세요* +> + +--- + +## ✅ Checklist + +### 🎾 Producer + +- [x] 도메인(애플리케이션) 이벤트 설계 +- [x] Producer 앱에서 도메인 이벤트 발행 (catalog-events, order-events, 등) +- [x] **PartitionKey** 기반의 이벤트 순서 보장 +- [x] 메세지 발행이 실패했을 경우에 대해 고민해보기 + +> Outbox → Kafka 퍼블리셔에서 `kafkaTemplate.send(...).get()` 호출이 예외를 던지면 해당 event_outbox 레코드를 `markFailed`로 PENDING 상태로 되돌리고 `last_error`, `attempt_count`를 업데이트합니다. 이후 스케줄러가 다시 조회해 재시도하고, 장애가 누적되면 last_error를 기반으로 원인 분석/알람을 할 수 있도록 설계했습니다. + +### ⚾ Consumer + +- [x] Consumer 가 Metrics 집계 처리 +- [x] `event_handled` 테이블을 통한 멱등 처리 구현 +- [x] 재고 소진 시 상품 캐시 갱신 +- [x] 중복 메세지 재전송 테스트 → 최종 결과가 한 번만 반영되는지 확인 + +> `catalog-events`, `order-events` 모두 event_handled 테이블을 조회 후 처리하기 때문에 동일 event_id를 재전송해도 두 번째부터는 skip 로그만 남고 metrics/caches는 변하지 않습니다. (로컬에서 동일 페이로드를 두 번 publish해 manual 확인) diff --git a/http/commerce-api/orders.http b/http/commerce-api/orders.http new file mode 100644 index 000000000..dcfac5069 --- /dev/null +++ b/http/commerce-api/orders.http @@ -0,0 +1,27 @@ +### create order +POST http://localhost:8080/api/v1/orders +Content-Type: application/json +X-USER-ID: user-1 + +{ + "items": [ + { "productId": 1, "quantity": 1 } + ], + "payment": { + "cardType": "SAMSUNG", + "cardNo": "1234-5678-1234-5678" + } +} + +### payment callback +POST http://localhost:8080/api/v1/orders/order-1/callback +Content-Type: application/json + +{ + "transactionKey": "20250816:TR:9577c5", + "status": "SUCCESS", + "reason": null +} + +### payment sync +POST http://localhost:8080/api/v1/orders/1/sync diff --git a/http/commerce-api/pints.http b/http/commerce-api/pints.http new file mode 100644 index 000000000..1a1754155 --- /dev/null +++ b/http/commerce-api/pints.http @@ -0,0 +1,5 @@ +### +GET http://localhost:8080/api/v1/points +X-USER-ID: yh45g + +<> 2025-12-06T113149.200.json \ No newline at end of file diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..20317d792 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -15,6 +15,10 @@ spring: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + acks: all + properties: + enable.idempotence: true + max.in.flight.requests.per.connection: 5 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer diff --git a/settings.gradle.kts b/settings.gradle.kts index c99fb6360..906b49231 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", ":apps:commerce-streamer", + ":apps:pg-simulator", ":modules:jpa", ":modules:redis", ":modules:kafka",