From 35db47e064669bedd90eeef536350f62ae562e3c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:12 +0900 Subject: [PATCH 001/116] =?UTF-8?q?feat/116=20::=20Order=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20Aggregate,=20VO,=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8,=20=EC=98=88=EC=99=B8,=20=EA=B8=80=EB=A1=9C=EB=B2=8C?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EA=B3=84=EC=B8=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../order/domain/aggregate/Order.java | 261 ++++++++++++++++++ .../order/domain/aggregate/OrderLine.java | 127 +++++++++ .../domain/enums/OrderLineDeliveryStatus.java | 12 + .../order/domain/enums/OrderStatus.java | 18 ++ .../domain/event/OrderAutoConfirmedEvent.java | 48 ++++ .../order/domain/event/OrderCancelEvent.java | 38 +++ .../domain/event/OrderConfirmedEvent.java | 38 +++ .../domain/event/OrderDeliveredEvent.java | 36 +++ .../order/domain/event/OrderDomainEvent.java | 6 + .../domain/event/OrderDomainEventType.java | 17 ++ .../order/domain/event/OrderPrepareEvent.java | 78 ++++++ .../event/OrderPurchaseConfirmedEvent.java | 48 ++++ .../exception/InvalidFieldException.java | 11 + .../InvalidPurchaseTokenException.java | 11 + .../OrderLineComplainNotAllowedException.java | 11 + .../exception/OrderLineNotFoundException.java | 11 + .../exception/OrderNotFoundException.java | 11 + .../OrderStatusConflictException.java | 11 + .../exception/OrderUnauthorizedException.java | 11 + .../kr/magicbox/order/domain/vo/OrderId.java | 16 ++ .../magicbox/order/domain/vo/OrderLineId.java | 16 ++ .../order/domain/vo/ShippingAddress.java | 31 +++ .../kr/magicbox/order/domain/vo/UserId.java | 15 + .../order/global/exception/BaseException.java | 20 ++ .../global/exception/BusinessException.java | 17 ++ .../order/global/exception/SystemError.java | 14 + 26 files changed, 933 insertions(+) create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/enums/OrderLineDeliveryStatus.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/enums/OrderStatus.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderAutoConfirmedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderCancelEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderConfirmedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderDeliveredEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderPurchaseConfirmedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidFieldException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidPurchaseTokenException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineComplainNotAllowedException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineNotFoundException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderNotFoundException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderStatusConflictException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderUnauthorizedException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/vo/OrderId.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/vo/OrderLineId.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/vo/ShippingAddress.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/vo/UserId.java create mode 100644 services/order/src/main/java/kr/magicbox/order/global/exception/BaseException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/global/exception/BusinessException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/global/exception/SystemError.java diff --git a/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java new file mode 100644 index 00000000..17eb9548 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java @@ -0,0 +1,261 @@ +package kr.magicbox.order.domain.aggregate; + +import kr.magicbox.order.domain.enums.OrderLineDeliveryStatus; +import kr.magicbox.order.domain.enums.OrderStatus; +import kr.magicbox.order.domain.exception.InvalidFieldException; +import kr.magicbox.order.domain.exception.OrderLineComplainNotAllowedException; +import kr.magicbox.order.domain.exception.OrderStatusConflictException; +import kr.magicbox.order.domain.exception.OrderLineNotFoundException; +import kr.magicbox.order.domain.vo.OrderId; +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Getter +public class Order { + + private final OrderId id; + private final Long customerId; + private final Long sellerId; + private OrderStatus status; + private final Long totalAmount; + private final ShippingAddress shippingAddress; + private final List orderLines; + private final Instant createdAt; + private Instant updatedAt; + + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public Order(Long customerId, Long sellerId, Long totalAmount, + ShippingAddress shippingAddress, List orderLines) { + validateCreate(customerId, sellerId, totalAmount, shippingAddress); + this.id = null; + this.customerId = customerId; + this.sellerId = sellerId; + this.status = OrderStatus.PENDING; + this.totalAmount = totalAmount; + this.shippingAddress = shippingAddress; + this.orderLines = orderLines != null ? new ArrayList<>(orderLines) : new ArrayList<>(); + this.createdAt = Instant.now(); + this.updatedAt = Instant.now(); + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public Order(OrderId id, Long customerId, Long sellerId, OrderStatus status, + Long totalAmount, ShippingAddress shippingAddress, + List orderLines, Instant createdAt, Instant updatedAt) { + validateReconstruct(id, customerId, sellerId, status, totalAmount, shippingAddress, createdAt, updatedAt); + this.id = id; + this.customerId = customerId; + this.sellerId = sellerId; + this.status = status; + this.totalAmount = totalAmount; + this.shippingAddress = shippingAddress; + this.orderLines = orderLines != null ? new ArrayList<>(orderLines) : new ArrayList<>(); + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + private void validateCreate(Long customerId, Long sellerId, Long totalAmount, ShippingAddress shippingAddress) { + if (customerId == null || customerId <= 0) throw new InvalidFieldException("구매자 ID는 양수여야 합니다."); + if (sellerId == null || sellerId <= 0) throw new InvalidFieldException("판매자 ID는 양수여야 합니다."); + if (totalAmount == null || totalAmount < 0) throw new InvalidFieldException("총 금액은 0 이상이어야 합니다."); + if (shippingAddress == null) throw new InvalidFieldException("배송지 정보는 필수 값입니다."); + } + + private void validateReconstruct(OrderId id, Long customerId, Long sellerId, OrderStatus status, + Long totalAmount, ShippingAddress shippingAddress, + Instant createdAt, Instant updatedAt) { + if (id == null) throw new InvalidFieldException("주문 ID는 필수입니다."); + if (customerId == null || customerId <= 0) throw new InvalidFieldException("구매자 ID는 양수여야 합니다."); + if (sellerId == null || sellerId <= 0) throw new InvalidFieldException("판매자 ID는 양수여야 합니다."); + if (status == null) throw new InvalidFieldException("주문 상태는 필수입니다."); + if (totalAmount == null || totalAmount < 0) throw new InvalidFieldException("총 금액은 0 이상이어야 합니다."); + if (shippingAddress == null) throw new InvalidFieldException("배송지 정보는 필수입니다."); + if (createdAt == null) throw new InvalidFieldException("생성 시각은 필수입니다."); + if (updatedAt == null) throw new InvalidFieldException("수정 시각은 필수입니다."); + } + + public void reserveStock() { + validateStatus(OrderStatus.PENDING); + this.status = OrderStatus.STOCK_RESERVED; + this.updatedAt = Instant.now(); + } + + public void completePayment() { + validateStatus(OrderStatus.STOCK_RESERVED); + this.status = OrderStatus.PAYMENT_COMPLETED; + this.updatedAt = Instant.now(); + } + + /** + * 모든 OrderLine을 PREPARING 상태로 전환한다. + * Order도 PREPARING으로 전환한다. + */ + public void prepare() { + validateStatus(OrderStatus.PAYMENT_COMPLETED); + orderLines.forEach(OrderLine::prepare); + this.status = OrderStatus.PREPARING; + this.updatedAt = Instant.now(); + } + + /** + * 특정 OrderLine을 CONFIRMED 상태로 전환한다. + * 모든 라인이 CONFIRMED 이상이면 Order도 CONFIRMED로 전환한다. + */ + public void confirmOrderLine(Long orderLineId) { + if (this.status != OrderStatus.PREPARING && this.status != OrderStatus.CONFIRMED) { + throw new OrderStatusConflictException("현재 상태에서 확정 처리할 수 없습니다. 현재: " + this.status); + } + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.confirm(); + this.updatedAt = Instant.now(); + + if (isAllLinesAtLeast()) { + this.status = OrderStatus.CONFIRMED; + } + } + + /** + * 모든 OrderLine을 CONFIRMED 상태로 전환한다 (판매자 전체 확정). + * Order도 CONFIRMED로 전환한다. + */ + public void confirm() { + if (this.status != OrderStatus.PREPARING) { + throw new OrderStatusConflictException("현재 상태에서 확정 처리할 수 없습니다. 현재: " + this.status); + } + orderLines.forEach(OrderLine::confirm); + this.status = OrderStatus.CONFIRMED; + this.updatedAt = Instant.now(); + } + + /** + * 특정 OrderLine의 배송을 시작한다. + * 첫 번째 라인 배송 시작 시 Order를 DELIVERING으로 전환한다. + */ + public void startDelivery(Long orderLineId) { + if (this.status != OrderStatus.CONFIRMED && this.status != OrderStatus.DELIVERING) { + throw new OrderStatusConflictException("현재 상태에서 배송을 시작할 수 없습니다. 현재: " + this.status); + } + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.startDelivery(); + this.status = OrderStatus.DELIVERING; + this.updatedAt = Instant.now(); + } + + /** + * 특정 OrderLine의 배송을 완료한다. + * 모든 라인 SHIPPED → DELIVERED, 하나라도 COMPLAINING 포함 → COMPLAINING + */ + public void completeDelivery(Long orderLineId) { + validateStatus(OrderStatus.DELIVERING); + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.completeDelivery(); + this.updatedAt = Instant.now(); + updateDeliveryStatus(); + } + + /** + * 배달 중(SHIPPING) 상태의 OrderLine에 대해 미수령 신고를 처리한다. + * 모든 라인 완료 시 하나라도 COMPLAINING이면 Order → COMPLAINING, 전부 SHIPPED면 → DELIVERED + */ + public void complainOrderLine(Long orderLineId) { + if (this.status != OrderStatus.DELIVERING) { + throw new OrderLineComplainNotAllowedException(); + } + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.complain(); + this.updatedAt = Instant.now(); + updateDeliveryStatus(); + } + + public boolean isAllDelivered() { + return this.status == OrderStatus.DELIVERED || this.status == OrderStatus.COMPLAINING; + } + + private void updateDeliveryStatus() { + boolean allDone = orderLines.stream() + .allMatch(line -> line.getDeliveryStatus() == OrderLineDeliveryStatus.SHIPPED + || line.getDeliveryStatus() == OrderLineDeliveryStatus.COMPLAINING); + if (!allDone) return; + + boolean hasComplain = orderLines.stream() + .anyMatch(line -> line.getDeliveryStatus() == OrderLineDeliveryStatus.COMPLAINING); + this.status = hasComplain ? OrderStatus.COMPLAINING : OrderStatus.DELIVERED; + } + + public List shippedLines() { + return orderLines.stream() + .filter(line -> line.getDeliveryStatus() == OrderLineDeliveryStatus.SHIPPED) + .toList(); + } + + public void confirmPurchase() { + validateStatus(OrderStatus.DELIVERED); + this.status = OrderStatus.PURCHASE_CONFIRMED; + this.updatedAt = Instant.now(); + } + + /** + * 특정 OrderLine에 대해 취소를 요청한다. + * 모든 라인이 CANCEL_REQUESTED 상태가 되면 Order를 CANCELLING으로 전환한다. + */ + public void cancelOrderLine(Long orderLineId) { + if (this.status == OrderStatus.CANCELLED || this.status == OrderStatus.PURCHASE_CONFIRMED) { + throw new OrderStatusConflictException("취소할 수 없는 주문 상태입니다: " + this.status); + } + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.requestCancel(); + this.updatedAt = Instant.now(); + + if (orderLines.stream().allMatch(OrderLine::isCancelRequested)) { + this.status = OrderStatus.CANCELLING; + } + } + + public void completeCancellation() { + validateStatus(OrderStatus.CANCELLING); + orderLines.forEach(OrderLine::completeCancellation); + this.status = OrderStatus.CANCELLED; + this.updatedAt = Instant.now(); + } + + public void failCancellation() { + validateStatus(OrderStatus.CANCELLING); + this.status = OrderStatus.CANCELLATION_FAILED; + this.updatedAt = Instant.now(); + } + + public void failStock() { + validateStatus(OrderStatus.PENDING); + this.status = OrderStatus.STOCK_FAILED; + this.updatedAt = Instant.now(); + } + + public void failPayment() { + validateStatus(OrderStatus.STOCK_RESERVED); + this.status = OrderStatus.PAYMENT_FAILED; + this.updatedAt = Instant.now(); + } + + private boolean isAllLinesAtLeast() { + return orderLines.stream().allMatch(line -> line.isAtLeast(OrderLineDeliveryStatus.CONFIRMED)); + } + + private OrderLine findOrderLine(Long orderLineId) { + return orderLines.stream() + .filter(line -> line.getId() != null && line.getId().value().equals(orderLineId)) + .findFirst() + .orElseThrow(OrderLineNotFoundException::new); + } + + private void validateStatus(OrderStatus expected) { + if (this.status != expected) { + throw new OrderStatusConflictException( + "현재 상태에서 해당 작업을 수행할 수 없습니다. 현재: " + this.status + ", 기대: " + expected); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java new file mode 100644 index 00000000..38f3051c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java @@ -0,0 +1,127 @@ +package kr.magicbox.order.domain.aggregate; + +import kr.magicbox.order.domain.enums.OrderLineDeliveryStatus; +import kr.magicbox.order.domain.exception.InvalidFieldException; +import kr.magicbox.order.domain.exception.OrderLineComplainNotAllowedException; +import kr.magicbox.order.domain.exception.OrderStatusConflictException; +import kr.magicbox.order.domain.vo.OrderLineId; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class OrderLine { + + private final OrderLineId id; + private final Long productId; + private final Long sellerId; + private final String productName; + private final Integer quantity; + private final Long unitPrice; + private OrderLineDeliveryStatus deliveryStatus; + + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public OrderLine(Long productId, Long sellerId, String productName, Integer quantity, Long unitPrice) { + validateCreate(productId, quantity, unitPrice); + this.id = null; + this.productId = productId; + this.sellerId = sellerId; + this.productName = productName; + this.quantity = quantity; + this.unitPrice = unitPrice; + this.deliveryStatus = OrderLineDeliveryStatus.PENDING; + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public OrderLine(OrderLineId id, Long productId, Long sellerId, String productName, + Integer quantity, Long unitPrice, OrderLineDeliveryStatus deliveryStatus) { + validateReconstruct(id, productId, sellerId, productName, quantity, unitPrice, deliveryStatus); + this.id = id; + this.productId = productId; + this.sellerId = sellerId; + this.productName = productName; + this.quantity = quantity; + this.unitPrice = unitPrice; + this.deliveryStatus = deliveryStatus; + } + + private void validateCreate(Long productId, Integer quantity, Long unitPrice) { + if (productId == null || productId <= 0) throw new InvalidFieldException("상품 ID는 양수여야 합니다."); + if (quantity == null || quantity <= 0) throw new InvalidFieldException("주문 수량은 1 이상이어야 합니다."); + if (unitPrice == null || unitPrice < 0) throw new InvalidFieldException("단가는 0 이상이어야 합니다."); + } + + private void validateReconstruct(OrderLineId id, Long productId, Long sellerId, String productName, + Integer quantity, Long unitPrice, OrderLineDeliveryStatus deliveryStatus) { + if (id == null) throw new InvalidFieldException("주문 라인 ID는 필수입니다."); + if (productId == null || productId <= 0) throw new InvalidFieldException("상품 ID는 양수여야 합니다."); + if (sellerId == null || sellerId <= 0) throw new InvalidFieldException("판매자 ID는 양수여야 합니다."); + if (productName == null || productName.isBlank()) throw new InvalidFieldException("상품명은 필수입니다."); + if (quantity == null || quantity <= 0) throw new InvalidFieldException("주문 수량은 1 이상이어야 합니다."); + if (unitPrice == null || unitPrice < 0) throw new InvalidFieldException("단가는 0 이상이어야 합니다."); + if (deliveryStatus == null) throw new InvalidFieldException("배송 상태는 필수입니다."); + } + + public long lineTotal() { + return unitPrice * quantity; + } + + public void prepare() { + if (this.deliveryStatus != OrderLineDeliveryStatus.PENDING) { + throw new OrderStatusConflictException("준비 처리할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.PREPARING; + } + + public void confirm() { + if (this.deliveryStatus != OrderLineDeliveryStatus.PREPARING) { + throw new OrderStatusConflictException("확정 처리할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.CONFIRMED; + } + + public void startDelivery() { + if (this.deliveryStatus != OrderLineDeliveryStatus.CONFIRMED) { + throw new OrderStatusConflictException("배송 시작할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.SHIPPING; + } + + public void completeDelivery() { + if (this.deliveryStatus != OrderLineDeliveryStatus.SHIPPING) { + throw new OrderStatusConflictException("배송 완료 처리할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.SHIPPED; + } + + public void complain() { + if (this.deliveryStatus != OrderLineDeliveryStatus.SHIPPING) { + throw new OrderLineComplainNotAllowedException(); + } + this.deliveryStatus = OrderLineDeliveryStatus.COMPLAINING; + } + + public void requestCancel() { + if (this.deliveryStatus == OrderLineDeliveryStatus.SHIPPED + || this.deliveryStatus == OrderLineDeliveryStatus.COMPLAINING + || this.deliveryStatus == OrderLineDeliveryStatus.CANCEL_REQUESTED + || this.deliveryStatus == OrderLineDeliveryStatus.CANCELLED) { + throw new OrderStatusConflictException("취소 요청할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.CANCEL_REQUESTED; + } + + public void completeCancellation() { + if (this.deliveryStatus != OrderLineDeliveryStatus.CANCEL_REQUESTED) { + throw new OrderStatusConflictException("취소 완료 처리할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.CANCELLED; + } + + public boolean isCancelRequested() { + return this.deliveryStatus == OrderLineDeliveryStatus.CANCEL_REQUESTED; + } + + public boolean isAtLeast(OrderLineDeliveryStatus target) { + return this.deliveryStatus.ordinal() >= target.ordinal(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderLineDeliveryStatus.java b/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderLineDeliveryStatus.java new file mode 100644 index 00000000..6adf23d7 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderLineDeliveryStatus.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.domain.enums; + +public enum OrderLineDeliveryStatus { + PENDING, + PREPARING, + CONFIRMED, + SHIPPING, + SHIPPED, + COMPLAINING, + CANCEL_REQUESTED, + CANCELLED +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderStatus.java b/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderStatus.java new file mode 100644 index 00000000..fadb8915 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderStatus.java @@ -0,0 +1,18 @@ +package kr.magicbox.order.domain.enums; + +public enum OrderStatus { + PENDING, + STOCK_RESERVED, + PAYMENT_COMPLETED, + PREPARING, + CONFIRMED, + DELIVERING, + DELIVERED, + COMPLAINING, + PURCHASE_CONFIRMED, + CANCELLING, + CANCELLED, + CANCELLATION_FAILED, + STOCK_FAILED, + PAYMENT_FAILED +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderAutoConfirmedEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderAutoConfirmedEvent.java new file mode 100644 index 00000000..33db3434 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderAutoConfirmedEvent.java @@ -0,0 +1,48 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderAutoConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("gross_amount") long grossAmount, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderAutoConfirmedEvent from(Order order, OrderLine line, Instant now) { + return OrderAutoConfirmedEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .orderLineId(line.getId().value()) + .sellerId(line.getSellerId()) + .grossAmount(line.lineTotal()) + .occurredAt(now) + .build(); + } + + public static List fromShippedLines(Order order) { + Instant now = Instant.now(); + return order.shippedLines().stream() + .map(line -> from(order, line, now)) + .toList(); + } + + @Override + public String key() { + return orderLineId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_AUTO_CONFIRMED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderCancelEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderCancelEvent.java new file mode 100644 index 00000000..1e5f06ae --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderCancelEvent.java @@ -0,0 +1,38 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderCancelEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("requested_at") Instant requestedAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderCancelEvent from(Order order, String reason) { + Instant now = Instant.now(); + return OrderCancelEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .reason(reason) + .requestedAt(now) + .occurredAt(now) + .build(); + } + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_CANCEL; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderConfirmedEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderConfirmedEvent.java new file mode 100644 index 00000000..465c5ce4 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderConfirmedEvent.java @@ -0,0 +1,38 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("confirmed_at") Instant confirmedAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderConfirmedEvent from(Order order) { + Instant now = Instant.now(); + return OrderConfirmedEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .sellerId(order.getSellerId()) + .confirmedAt(now) + .occurredAt(now) + .build(); + } + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_CONFIRMED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDeliveredEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDeliveredEvent.java new file mode 100644 index 00000000..e1ce458d --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDeliveredEvent.java @@ -0,0 +1,36 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderDeliveredEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("delivered_at") Instant deliveredAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderDeliveredEvent from(Order order) { + Instant now = Instant.now(); + return OrderDeliveredEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .deliveredAt(now) + .occurredAt(now) + .build(); + } + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_DELIVERED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEvent.java new file mode 100644 index 00000000..9cc89dd2 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEvent.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.domain.event; + +public interface OrderDomainEvent { + String key(); + OrderDomainEventType eventType(); +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java new file mode 100644 index 00000000..ccddb36c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.domain.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OrderDomainEventType { + ORDER_PREPARE("order-prepare"), + ORDER_CONFIRMED("order-confirmed"), + ORDER_CANCEL("order-cancel"), + ORDER_PURCHASE_CONFIRMED("order-purchase-confirmed"), + ORDER_AUTO_CONFIRMED("order-auto-confirmed"), + ORDER_DELIVERED("order-delivered"); + + private final String value; +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java new file mode 100644 index 00000000..1b6810fb --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java @@ -0,0 +1,78 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderPrepareEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("items") List items, + @JsonProperty("total_amount") long totalAmount, + @JsonProperty("shipping_address") ShippingAddressPayload shippingAddress, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderPrepareEvent from(Long savedOrderId, Order order) { + List items = order.getOrderLines().stream() + .map(line -> OrderItemPayload.builder() + .productId(line.getProductId()) + .quantity(line.getQuantity()) + .unitPrice(line.getUnitPrice()) + .productName(line.getProductName()) + .build()) + .toList(); + + ShippingAddress addr = order.getShippingAddress(); + ShippingAddressPayload shippingAddress = ShippingAddressPayload.builder() + .recipient(addr.recipient()) + .phone(addr.phone()) + .zipCode(addr.zipCode()) + .address1(addr.address1()) + .address2(addr.address2()) + .build(); + + return OrderPrepareEvent.builder() + .orderId(savedOrderId) + .customerId(order.getCustomerId()) + .sellerId(order.getSellerId()) + .items(items) + .totalAmount(order.getTotalAmount()) + .shippingAddress(shippingAddress) + .occurredAt(Instant.now()) + .build(); + } + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_PREPARE; + } + + @Builder + public record OrderItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity, + @JsonProperty("unit_price") long unitPrice, + @JsonProperty("product_name") String productName + ) {} + + @Builder + public record ShippingAddressPayload( + @JsonProperty("recipient") String recipient, + @JsonProperty("phone") String phone, + @JsonProperty("zip_code") String zipCode, + @JsonProperty("address1") String address1, + @JsonProperty("address2") String address2 + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPurchaseConfirmedEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPurchaseConfirmedEvent.java new file mode 100644 index 00000000..f1d0ce4f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPurchaseConfirmedEvent.java @@ -0,0 +1,48 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderPurchaseConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("gross_amount") long grossAmount, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderPurchaseConfirmedEvent from(Order order, OrderLine line, Instant now) { + return OrderPurchaseConfirmedEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .orderLineId(line.getId().value()) + .sellerId(line.getSellerId()) + .grossAmount(line.lineTotal()) + .occurredAt(now) + .build(); + } + + public static List fromShippedLines(Order order) { + Instant now = Instant.now(); + return order.shippedLines().stream() + .map(line -> from(order, line, now)) + .toList(); + } + + @Override + public String key() { + return orderLineId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_PURCHASE_CONFIRMED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidFieldException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidFieldException.java new file mode 100644 index 00000000..90e33a17 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidFieldException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class InvalidFieldException extends BusinessException { + + public InvalidFieldException(String message) { + super(message, HttpStatus.BAD_REQUEST); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidPurchaseTokenException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidPurchaseTokenException.java new file mode 100644 index 00000000..2bf9c616 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidPurchaseTokenException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class InvalidPurchaseTokenException extends BusinessException { + public InvalidPurchaseTokenException() { + super("유효하지 않은 구매 토큰입니다.", HttpStatus.UNAUTHORIZED); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineComplainNotAllowedException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineComplainNotAllowedException.java new file mode 100644 index 00000000..6e1eaf69 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineComplainNotAllowedException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class OrderLineComplainNotAllowedException extends BusinessException { + public OrderLineComplainNotAllowedException() { + super("배달 중(SHIPPING) 상태의 주문 라인에서만 미수령 신고가 가능합니다.", HttpStatus.CONFLICT); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineNotFoundException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineNotFoundException.java new file mode 100644 index 00000000..9edaf4fe --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineNotFoundException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class OrderLineNotFoundException extends BusinessException { + + public OrderLineNotFoundException() { + super("주문 라인을 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderNotFoundException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderNotFoundException.java new file mode 100644 index 00000000..d9127d05 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderNotFoundException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class OrderNotFoundException extends BusinessException { + public OrderNotFoundException() { + super("주문을 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderStatusConflictException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderStatusConflictException.java new file mode 100644 index 00000000..8fd842b0 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderStatusConflictException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class OrderStatusConflictException extends BusinessException { + public OrderStatusConflictException(String message) { + super(message, HttpStatus.CONFLICT); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderUnauthorizedException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderUnauthorizedException.java new file mode 100644 index 00000000..9c3f8725 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderUnauthorizedException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class OrderUnauthorizedException extends BusinessException { + public OrderUnauthorizedException() { + super("해당 주문에 접근할 권한이 없습니다.", HttpStatus.FORBIDDEN); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderId.java b/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderId.java new file mode 100644 index 00000000..38f63c92 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderId.java @@ -0,0 +1,16 @@ +package kr.magicbox.order.domain.vo; + +import kr.magicbox.order.domain.exception.InvalidFieldException; + +public record OrderId(Long value) { + + public OrderId { + if (value == null || value <= 0) { + throw new InvalidFieldException("주문 ID는 양수여야 합니다."); + } + } + + public static OrderId of(Long value) { + return new OrderId(value); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderLineId.java b/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderLineId.java new file mode 100644 index 00000000..a0f36cf1 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderLineId.java @@ -0,0 +1,16 @@ +package kr.magicbox.order.domain.vo; + +import kr.magicbox.order.domain.exception.InvalidFieldException; + +public record OrderLineId(Long value) { + + public OrderLineId { + if (value == null || value <= 0) { + throw new InvalidFieldException("주문 라인 ID는 양수여야 합니다."); + } + } + + public static OrderLineId of(Long value) { + return new OrderLineId(value); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/vo/ShippingAddress.java b/services/order/src/main/java/kr/magicbox/order/domain/vo/ShippingAddress.java new file mode 100644 index 00000000..7fd26407 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/vo/ShippingAddress.java @@ -0,0 +1,31 @@ +package kr.magicbox.order.domain.vo; + +import kr.magicbox.order.domain.exception.InvalidFieldException; + +public record ShippingAddress( + String recipient, + String phone, + String zipCode, + String address1, + String address2 +) { + + public ShippingAddress { + if (recipient == null || recipient.isBlank()) { + throw new InvalidFieldException("수령인은 필수 값입니다."); + } + if (phone == null || phone.isBlank()) { + throw new InvalidFieldException("전화번호는 필수 값입니다."); + } + if (zipCode == null || zipCode.isBlank()) { + throw new InvalidFieldException("우편번호는 필수 값입니다."); + } + if (address1 == null || address1.isBlank()) { + throw new InvalidFieldException("도로명 주소는 필수 값입니다."); + } + } + + public static ShippingAddress of(String recipient, String phone, String zipCode, String address1, String address2) { + return new ShippingAddress(recipient, phone, zipCode, address1, address2); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/vo/UserId.java b/services/order/src/main/java/kr/magicbox/order/domain/vo/UserId.java new file mode 100644 index 00000000..c46dd36f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/vo/UserId.java @@ -0,0 +1,15 @@ +package kr.magicbox.order.domain.vo; + +import kr.magicbox.order.domain.exception.InvalidFieldException; + +public record UserId(Long value) { + public UserId { + if (value == null || value <= 0) { + throw new InvalidFieldException("사용자 ID는 양수여야 합니다."); + } + } + + public static UserId of(Long value) { + return new UserId(value); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/global/exception/BaseException.java b/services/order/src/main/java/kr/magicbox/order/global/exception/BaseException.java new file mode 100644 index 00000000..0b4cf2f0 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/global/exception/BaseException.java @@ -0,0 +1,20 @@ +package kr.magicbox.order.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class BaseException extends RuntimeException { + + private final HttpStatus status; + + public BaseException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public BaseException(String message, HttpStatus status, Throwable cause) { + super(message, cause); + this.status = status; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/global/exception/BusinessException.java b/services/order/src/main/java/kr/magicbox/order/global/exception/BusinessException.java new file mode 100644 index 00000000..a709771d --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/global/exception/BusinessException.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.global.exception; + +import org.springframework.http.HttpStatus; + +public class BusinessException extends BaseException { + + public BusinessException(String message, HttpStatus status) { + super(message, validateStatus(status)); + } + + private static HttpStatus validateStatus(HttpStatus status) { + if (!status.is4xxClientError()) { + throw new SystemError("클라이언트 에러가 아닙니다.", HttpStatus.INTERNAL_SERVER_ERROR); + } + return status; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/global/exception/SystemError.java b/services/order/src/main/java/kr/magicbox/order/global/exception/SystemError.java new file mode 100644 index 00000000..aec86b39 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/global/exception/SystemError.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.global.exception; + +import org.springframework.http.HttpStatus; + +public class SystemError extends BaseException { + + public SystemError(String message, HttpStatus status) { + super(message, status); + } + + public SystemError(String message, HttpStatus status, Throwable cause) { + super(message, status, cause); + } +} From f79c8da360fce226b22cfd0089f4e30d24741081 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:12 +0900 Subject: [PATCH 002/116] =?UTF-8?q?feat/116=20::=20Order=20UseCase,=20Port?= =?UTF-8?q?,=20Service,=20DTO=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dto/command/CancelOrderCommand.java | 6 ++ .../dto/command/ConfirmOrderCommand.java | 6 ++ .../dto/command/CreateOrderCommand.java | 24 ++++++++ .../command/CreateReleaseOrderCommand.java | 15 +++++ .../command/PurchaseConfirmOrderCommand.java | 6 ++ .../dto/query/GetOrderListQuery.java | 6 ++ .../application/dto/query/GetOrderQuery.java | 6 ++ .../dto/result/OrderLineResult.java | 12 ++++ .../application/dto/result/OrderResult.java | 19 ++++++ .../dto/result/ShippingAddressResult.java | 23 ++++++++ .../port/in/AutoConfirmOrderUseCase.java | 5 ++ .../port/in/CancelOrderUseCase.java | 7 +++ .../port/in/ComplainOrderLineUseCase.java | 5 ++ .../port/in/ConfirmOrderLineUseCase.java | 5 ++ .../port/in/ConfirmOrderUseCase.java | 7 +++ .../port/in/CreateOrderUseCase.java | 7 +++ .../port/in/CreateReleaseOrderUseCase.java | 7 +++ .../port/in/GetOrderListUseCase.java | 10 ++++ .../application/port/in/GetOrderUseCase.java | 8 +++ .../in/HandleDeliveryCompletedUseCase.java | 5 ++ .../port/in/HandleDeliveryStartedUseCase.java | 5 ++ .../port/in/HandleOrderPrepareUseCase.java | 5 ++ .../in/HandlePaymentCancelFailedUseCase.java | 5 ++ .../HandlePaymentCancelSucceededUseCase.java | 5 ++ .../port/in/HandlePaymentFailedUseCase.java | 5 ++ .../in/HandlePaymentSucceededUseCase.java | 5 ++ .../in/HandleStockReserveFailedUseCase.java | 5 ++ .../HandleStockReserveSucceededUseCase.java | 5 ++ .../port/in/PurchaseConfirmOrderUseCase.java | 7 +++ .../application/port/out/OrderOutboxPort.java | 7 +++ .../port/out/OrderRepositoryPort.java | 17 ++++++ .../port/out/PurchaseTokenValidationPort.java | 6 ++ .../out/ReleaseIncreaseSoldQuantityPort.java | 5 ++ .../service/AutoConfirmOrderService.java | 40 +++++++++++++ .../service/CancelOrderService.java | 40 +++++++++++++ .../service/ComplainOrderLineService.java | 37 ++++++++++++ .../service/ConfirmOrderLineService.java | 39 +++++++++++++ .../service/ConfirmOrderService.java | 35 +++++++++++ .../service/CreateOrderService.java | 47 +++++++++++++++ .../service/CreateReleaseOrderService.java | 58 +++++++++++++++++++ .../service/GetOrderListService.java | 32 ++++++++++ .../application/service/GetOrderService.java | 32 ++++++++++ .../HandleDeliveryCompletedService.java | 31 ++++++++++ .../service/HandleDeliveryStartedService.java | 24 ++++++++ .../service/HandleOrderPrepareService.java | 24 ++++++++ .../HandlePaymentCancelFailedService.java | 24 ++++++++ .../HandlePaymentCancelSucceededService.java | 24 ++++++++ .../service/HandlePaymentFailedService.java | 24 ++++++++ .../HandlePaymentSucceededService.java | 24 ++++++++ .../HandleStockReserveFailedService.java | 24 ++++++++ .../HandleStockReserveSucceededService.java | 24 ++++++++ .../service/OrderResultMapper.java | 36 ++++++++++++ .../service/PurchaseConfirmOrderService.java | 35 +++++++++++ 53 files changed, 925 insertions(+) create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/CancelOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/ConfirmOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/PurchaseConfirmOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderListQuery.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderQuery.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderResult.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/result/ShippingAddressResult.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/AutoConfirmOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/CancelOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/ComplainOrderLineUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderLineUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/CreateOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/CreateReleaseOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderListUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryCompletedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryStartedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleOrderPrepareUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelFailedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelSucceededUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentFailedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentSucceededUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveFailedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveSucceededUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/PurchaseConfirmOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/out/OrderOutboxPort.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/out/PurchaseTokenValidationPort.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/out/ReleaseIncreaseSoldQuantityPort.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/CancelOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/ComplainOrderLineService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/GetOrderListService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/GetOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryCompletedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryStartedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleOrderPrepareService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelFailedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelSucceededService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentFailedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentSucceededService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveFailedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveSucceededService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/PurchaseConfirmOrderService.java diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/CancelOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CancelOrderCommand.java new file mode 100644 index 00000000..2d515e97 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CancelOrderCommand.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.command; + +import lombok.Builder; + +@Builder +public record CancelOrderCommand(Long orderId, Long orderLineId, Long customerId, String reason) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/ConfirmOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/ConfirmOrderCommand.java new file mode 100644 index 00000000..5a3b9cab --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/ConfirmOrderCommand.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.command; + +import lombok.Builder; + +@Builder +public record ConfirmOrderCommand(Long orderId, Long sellerId) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java new file mode 100644 index 00000000..97e9c887 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.dto.command; + +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +import java.util.List; + +@Builder +public record CreateOrderCommand( + Long customerId, + Long sellerId, + Long totalAmount, + ShippingAddress shippingAddress, + List orderLines +) { + @Builder + public record OrderLineCommand( + Long productId, + Long sellerId, + String productName, + Integer quantity, + Long unitPrice + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java new file mode 100644 index 00000000..50181c10 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java @@ -0,0 +1,15 @@ +package kr.magicbox.order.application.dto.command; + +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +@Builder +public record CreateReleaseOrderCommand( + Long customerId, + Long sellerId, + Long releaseId, + String purchaseToken, + String productName, + Long unitPrice, + ShippingAddress shippingAddress +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/PurchaseConfirmOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/PurchaseConfirmOrderCommand.java new file mode 100644 index 00000000..0abae999 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/PurchaseConfirmOrderCommand.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.command; + +import lombok.Builder; + +@Builder +public record PurchaseConfirmOrderCommand(Long orderId, Long customerId) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderListQuery.java b/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderListQuery.java new file mode 100644 index 00000000..7bf7a40c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderListQuery.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.query; + +import lombok.Builder; + +@Builder +public record GetOrderListQuery(Long customerId, Long sellerId) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderQuery.java b/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderQuery.java new file mode 100644 index 00000000..8a23daa3 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderQuery.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.query; + +import lombok.Builder; + +@Builder +public record GetOrderQuery(Long orderId, Long requesterId) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java new file mode 100644 index 00000000..619f8acf --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.application.dto.result; + +import lombok.Builder; + +@Builder +public record OrderLineResult( + Long orderLineId, + Long productId, + String productName, + int quantity, + long unitPrice +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderResult.java b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderResult.java new file mode 100644 index 00000000..2baf2608 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderResult.java @@ -0,0 +1,19 @@ +package kr.magicbox.order.application.dto.result; + +import kr.magicbox.order.domain.enums.OrderStatus; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderResult( + Long orderId, + Long customerId, + Long sellerId, + OrderStatus status, + long totalAmount, + ShippingAddressResult shippingAddress, + List orderLines, + Instant createdAt +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/result/ShippingAddressResult.java b/services/order/src/main/java/kr/magicbox/order/application/dto/result/ShippingAddressResult.java new file mode 100644 index 00000000..9a21b092 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/result/ShippingAddressResult.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.application.dto.result; + +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +@Builder +public record ShippingAddressResult( + String recipient, + String phone, + String zipCode, + String address1, + String address2 +) { + public static ShippingAddressResult from(ShippingAddress domain) { + return ShippingAddressResult.builder() + .recipient(domain.recipient()) + .phone(domain.phone()) + .zipCode(domain.zipCode()) + .address1(domain.address1()) + .address2(domain.address2()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/AutoConfirmOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/AutoConfirmOrderUseCase.java new file mode 100644 index 00000000..8e114a98 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/AutoConfirmOrderUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface AutoConfirmOrderUseCase { + void autoConfirmDeliveredOrders(); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/CancelOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/CancelOrderUseCase.java new file mode 100644 index 00000000..73431f36 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/CancelOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.CancelOrderCommand; + +public interface CancelOrderUseCase { + void cancelOrder(CancelOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/ComplainOrderLineUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/ComplainOrderLineUseCase.java new file mode 100644 index 00000000..11242400 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/ComplainOrderLineUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface ComplainOrderLineUseCase { + void complainOrderLine(Long orderId, Long orderLineId, Long customerId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderLineUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderLineUseCase.java new file mode 100644 index 00000000..f0c04b25 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderLineUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface ConfirmOrderLineUseCase { + void confirmOrderLine(Long orderId, Long orderLineId, Long sellerId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderUseCase.java new file mode 100644 index 00000000..5f7a15d5 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.ConfirmOrderCommand; + +public interface ConfirmOrderUseCase { + void confirmOrder(ConfirmOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateOrderUseCase.java new file mode 100644 index 00000000..afc50982 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.CreateOrderCommand; + +public interface CreateOrderUseCase { + void createOrder(CreateOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateReleaseOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateReleaseOrderUseCase.java new file mode 100644 index 00000000..60f3f656 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateReleaseOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.CreateReleaseOrderCommand; + +public interface CreateReleaseOrderUseCase { + void createReleaseOrder(CreateReleaseOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderListUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderListUseCase.java new file mode 100644 index 00000000..271f2a0e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderListUseCase.java @@ -0,0 +1,10 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.query.GetOrderListQuery; +import kr.magicbox.order.application.dto.result.OrderResult; + +import java.util.List; + +public interface GetOrderListUseCase { + List getOrderList(GetOrderListQuery query); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderUseCase.java new file mode 100644 index 00000000..ade1ddef --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderUseCase.java @@ -0,0 +1,8 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.query.GetOrderQuery; +import kr.magicbox.order.application.dto.result.OrderResult; + +public interface GetOrderUseCase { + OrderResult getOrder(GetOrderQuery query); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryCompletedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryCompletedUseCase.java new file mode 100644 index 00000000..b7fb8ee1 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryCompletedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleDeliveryCompletedUseCase { + void handleDeliveryCompleted(Long orderId, Long orderLineId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryStartedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryStartedUseCase.java new file mode 100644 index 00000000..dea41246 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryStartedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleDeliveryStartedUseCase { + void handleDeliveryStarted(Long orderId, Long orderLineId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleOrderPrepareUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleOrderPrepareUseCase.java new file mode 100644 index 00000000..6ad65180 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleOrderPrepareUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleOrderPrepareUseCase { + void handleOrderPrepare(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelFailedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelFailedUseCase.java new file mode 100644 index 00000000..44cf7982 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelFailedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandlePaymentCancelFailedUseCase { + void handlePaymentCancelFailed(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelSucceededUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelSucceededUseCase.java new file mode 100644 index 00000000..e57a7e24 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandlePaymentCancelSucceededUseCase { + void handlePaymentCancelSucceeded(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentFailedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentFailedUseCase.java new file mode 100644 index 00000000..722ed873 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentFailedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandlePaymentFailedUseCase { + void handlePaymentFailed(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentSucceededUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentSucceededUseCase.java new file mode 100644 index 00000000..8c72f6be --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandlePaymentSucceededUseCase { + void handlePaymentSucceeded(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveFailedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveFailedUseCase.java new file mode 100644 index 00000000..f8ad3209 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveFailedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleStockReserveFailedUseCase { + void handleStockReserveFailed(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveSucceededUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveSucceededUseCase.java new file mode 100644 index 00000000..55148d54 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleStockReserveSucceededUseCase { + void handleStockReserveSucceeded(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/PurchaseConfirmOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/PurchaseConfirmOrderUseCase.java new file mode 100644 index 00000000..397ed075 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/PurchaseConfirmOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.PurchaseConfirmOrderCommand; + +public interface PurchaseConfirmOrderUseCase { + void purchaseConfirmOrder(PurchaseConfirmOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderOutboxPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderOutboxPort.java new file mode 100644 index 00000000..c166b966 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderOutboxPort.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.out; + +import kr.magicbox.order.domain.event.OrderDomainEvent; + +public interface OrderOutboxPort { + void save(OrderDomainEvent event); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java new file mode 100644 index 00000000..43ca951e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.application.port.out; + +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; + +import java.time.Instant; +import java.util.List; + +public interface OrderRepositoryPort { + Long save(Order order); + void update(Order order); + Order findById(OrderId id); + Order findByOrderLineId(Long orderLineId); + List findByCustomerId(Long customerId); + List findBySellerId(Long sellerId); + List findDeliveredBefore(Instant deliveredBefore); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/PurchaseTokenValidationPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/PurchaseTokenValidationPort.java new file mode 100644 index 00000000..f44e3d73 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/out/PurchaseTokenValidationPort.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.port.out; + +public interface PurchaseTokenValidationPort { + /** purchase_token 검증 및 소비 (1회용). 유효하지 않으면 false */ + boolean validate(Long releaseId, Long userId, String purchaseToken); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/ReleaseIncreaseSoldQuantityPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/ReleaseIncreaseSoldQuantityPort.java new file mode 100644 index 00000000..0ca2fcb6 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/out/ReleaseIncreaseSoldQuantityPort.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.out; + +public interface ReleaseIncreaseSoldQuantityPort { + void increaseSoldQuantity(Long releaseId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java new file mode 100644 index 00000000..2b9a02dd --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java @@ -0,0 +1,40 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.AutoConfirmOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderAutoConfirmedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoConfirmOrderService implements AutoConfirmOrderUseCase { + + private static final int AUTO_CONFIRM_DAYS = 7; + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void autoConfirmDeliveredOrders() { + Instant threshold = Instant.now().minus(AUTO_CONFIRM_DAYS, ChronoUnit.DAYS); + List orders = orderRepositoryPort.findDeliveredBefore(threshold); + + for (Order order : orders) { + order.confirmPurchase(); + orderRepositoryPort.update(order); + OrderAutoConfirmedEvent.fromShippedLines(order).forEach(orderOutboxPort::save); + log.info("[AutoConfirm] 자동 구매 확정 처리. orderId={}", order.getId().value()); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/CancelOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/CancelOrderService.java new file mode 100644 index 00000000..f3548d12 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CancelOrderService.java @@ -0,0 +1,40 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.CancelOrderCommand; +import kr.magicbox.order.application.port.in.CancelOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.enums.OrderStatus; +import kr.magicbox.order.domain.event.OrderCancelEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CancelOrderService implements CancelOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void cancelOrder(CancelOrderCommand command) { + Order order = orderRepositoryPort.findById(OrderId.of(command.orderId())); + + if (!order.getCustomerId().equals(command.customerId())) { + throw new OrderUnauthorizedException(); + } + + order.cancelOrderLine(command.orderLineId()); + orderRepositoryPort.update(order); + + // 모든 라인이 취소 요청 완료(Order CANCELLING)된 시점에 order-level 이벤트 1회 발행 + if (order.getStatus() == OrderStatus.CANCELLING) { + orderOutboxPort.save(OrderCancelEvent.from(order, command.reason())); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/ComplainOrderLineService.java b/services/order/src/main/java/kr/magicbox/order/application/service/ComplainOrderLineService.java new file mode 100644 index 00000000..83e6b453 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/ComplainOrderLineService.java @@ -0,0 +1,37 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.ComplainOrderLineUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderDeliveredEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ComplainOrderLineService implements ComplainOrderLineUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void complainOrderLine(Long orderId, Long orderLineId, Long customerId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + + if (!order.getCustomerId().equals(customerId)) { + throw new OrderUnauthorizedException(); + } + + order.complainOrderLine(orderLineId); + orderRepositoryPort.update(order); + + if (order.isAllDelivered()) { + orderOutboxPort.save(OrderDeliveredEvent.from(order)); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java new file mode 100644 index 00000000..698ea2ed --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java @@ -0,0 +1,39 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.ConfirmOrderLineUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.enums.OrderStatus; +import kr.magicbox.order.domain.event.OrderConfirmedEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ConfirmOrderLineService implements ConfirmOrderLineUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void confirmOrderLine(Long orderId, Long orderLineId, Long sellerId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + + if (!order.getSellerId().equals(sellerId)) { + throw new OrderUnauthorizedException(); + } + + order.confirmOrderLine(orderLineId); + orderRepositoryPort.update(order); + + // 모든 라인 CONFIRMED → Order CONFIRMED 전환 시 이벤트 발행 + if (order.getStatus() == OrderStatus.CONFIRMED) { + orderOutboxPort.save(OrderConfirmedEvent.from(order)); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderService.java new file mode 100644 index 00000000..63463325 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderService.java @@ -0,0 +1,35 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.ConfirmOrderCommand; +import kr.magicbox.order.application.port.in.ConfirmOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderConfirmedEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ConfirmOrderService implements ConfirmOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void confirmOrder(ConfirmOrderCommand command) { + Order order = orderRepositoryPort.findById(OrderId.of(command.orderId())); + + if (!order.getSellerId().equals(command.sellerId())) { + throw new OrderUnauthorizedException(); + } + + order.confirm(); + orderRepositoryPort.update(order); + orderOutboxPort.save(OrderConfirmedEvent.from(order)); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java new file mode 100644 index 00000000..b6157dda --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java @@ -0,0 +1,47 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.CreateOrderCommand; +import kr.magicbox.order.application.port.in.CreateOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import kr.magicbox.order.domain.event.OrderPrepareEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CreateOrderService implements CreateOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void createOrder(CreateOrderCommand command) { + List orderLines = command.orderLines().stream() + .map(line -> OrderLine.createBuilder() + .productId(line.productId()) + .sellerId(line.sellerId()) + .productName(line.productName()) + .quantity(line.quantity()) + .unitPrice(line.unitPrice()) + .build()) + .toList(); + + Order order = Order.createBuilder() + .customerId(command.customerId()) + .sellerId(command.sellerId()) + .totalAmount(command.totalAmount()) + .shippingAddress(command.shippingAddress()) + .orderLines(orderLines) + .build(); + + Long savedOrderId = orderRepositoryPort.save(order); + orderOutboxPort.save(OrderPrepareEvent.from(savedOrderId, order)); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java new file mode 100644 index 00000000..13ccfa43 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java @@ -0,0 +1,58 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.CreateReleaseOrderCommand; +import kr.magicbox.order.application.port.in.CreateReleaseOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.application.port.out.PurchaseTokenValidationPort; +import kr.magicbox.order.application.port.out.ReleaseIncreaseSoldQuantityPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import kr.magicbox.order.domain.event.OrderPrepareEvent; +import kr.magicbox.order.domain.exception.InvalidPurchaseTokenException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CreateReleaseOrderService implements CreateReleaseOrderUseCase { + + private final PurchaseTokenValidationPort purchaseTokenValidationPort; + private final ReleaseIncreaseSoldQuantityPort releaseIncreaseSoldQuantityPort; + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void createReleaseOrder(CreateReleaseOrderCommand command) { + boolean valid = purchaseTokenValidationPort.validate( + command.releaseId(), command.customerId(), command.purchaseToken()); + if (!valid) { + throw new InvalidPurchaseTokenException(); + } + + OrderLine orderLine = OrderLine.createBuilder() + .productId(command.releaseId()) + .sellerId(command.sellerId()) + .productName(command.productName()) + .quantity(1) + .unitPrice(command.unitPrice()) + .build(); + + Order order = Order.createBuilder() + .customerId(command.customerId()) + .sellerId(command.sellerId()) + .totalAmount(command.unitPrice()) + .shippingAddress(command.shippingAddress()) + .orderLines(List.of(orderLine)) + .build(); + + Long savedOrderId = orderRepositoryPort.save(order); + orderOutboxPort.save(OrderPrepareEvent.from(savedOrderId, order)); + + releaseIncreaseSoldQuantityPort.increaseSoldQuantity(command.releaseId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderListService.java b/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderListService.java new file mode 100644 index 00000000..50cb0b53 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderListService.java @@ -0,0 +1,32 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.query.GetOrderListQuery; +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.application.port.in.GetOrderListUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class GetOrderListService implements GetOrderListUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderResultMapper orderResultMapper; + + @Transactional(readOnly = true) + @Override + public List getOrderList(GetOrderListQuery query) { + List orders; + if (query.customerId() != null) { + orders = orderRepositoryPort.findByCustomerId(query.customerId()); + } else { + orders = orderRepositoryPort.findBySellerId(query.sellerId()); + } + return orders.stream().map(orderResultMapper::toResult).toList(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderService.java new file mode 100644 index 00000000..26ddf320 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderService.java @@ -0,0 +1,32 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.query.GetOrderQuery; +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.application.port.in.GetOrderUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GetOrderService implements GetOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderResultMapper orderResultMapper; + + @Transactional(readOnly = true) + @Override + public OrderResult getOrder(GetOrderQuery query) { + Order order = orderRepositoryPort.findById(OrderId.of(query.orderId())); + + if (!order.getCustomerId().equals(query.requesterId()) && !order.getSellerId().equals(query.requesterId())) { + throw new OrderUnauthorizedException(); + } + + return orderResultMapper.toResult(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryCompletedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryCompletedService.java new file mode 100644 index 00000000..8819d9af --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryCompletedService.java @@ -0,0 +1,31 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleDeliveryCompletedUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderDeliveredEvent; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleDeliveryCompletedService implements HandleDeliveryCompletedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void handleDeliveryCompleted(Long orderId, Long orderLineId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.completeDelivery(orderLineId); + orderRepositoryPort.update(order); + + if (order.isAllDelivered()) { + orderOutboxPort.save(OrderDeliveredEvent.from(order)); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryStartedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryStartedService.java new file mode 100644 index 00000000..b4ab6fca --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryStartedService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleDeliveryStartedUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleDeliveryStartedService implements HandleDeliveryStartedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handleDeliveryStarted(Long orderId, Long orderLineId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.startDelivery(orderLineId); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleOrderPrepareService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleOrderPrepareService.java new file mode 100644 index 00000000..067459ea --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleOrderPrepareService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleOrderPrepareUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleOrderPrepareService implements HandleOrderPrepareUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handleOrderPrepare(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.prepare(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelFailedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelFailedService.java new file mode 100644 index 00000000..18c1c321 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelFailedService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandlePaymentCancelFailedUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandlePaymentCancelFailedService implements HandlePaymentCancelFailedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handlePaymentCancelFailed(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.failCancellation(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelSucceededService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelSucceededService.java new file mode 100644 index 00000000..98463bb1 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelSucceededService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandlePaymentCancelSucceededUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandlePaymentCancelSucceededService implements HandlePaymentCancelSucceededUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handlePaymentCancelSucceeded(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.completeCancellation(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentFailedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentFailedService.java new file mode 100644 index 00000000..1ee44bb3 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentFailedService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandlePaymentFailedUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandlePaymentFailedService implements HandlePaymentFailedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handlePaymentFailed(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.failPayment(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentSucceededService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentSucceededService.java new file mode 100644 index 00000000..bde1f717 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentSucceededService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandlePaymentSucceededUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandlePaymentSucceededService implements HandlePaymentSucceededUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handlePaymentSucceeded(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.completePayment(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveFailedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveFailedService.java new file mode 100644 index 00000000..415af1e9 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveFailedService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleStockReserveFailedUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleStockReserveFailedService implements HandleStockReserveFailedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handleStockReserveFailed(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.failStock(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveSucceededService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveSucceededService.java new file mode 100644 index 00000000..d50c8678 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveSucceededService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleStockReserveSucceededUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleStockReserveSucceededService implements HandleStockReserveSucceededUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handleStockReserveSucceeded(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.reserveStock(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java b/services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java new file mode 100644 index 00000000..f1e518b2 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java @@ -0,0 +1,36 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.result.OrderLineResult; +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.application.dto.result.ShippingAddressResult; +import kr.magicbox.order.domain.aggregate.Order; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class OrderResultMapper { + + public OrderResult toResult(Order order) { + List lineResults = order.getOrderLines().stream() + .map(line -> OrderLineResult.builder() + .orderLineId(line.getId() != null ? line.getId().value() : null) + .productId(line.getProductId()) + .productName(line.getProductName()) + .quantity(line.getQuantity()) + .unitPrice(line.getUnitPrice()) + .build()) + .toList(); + + return OrderResult.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .sellerId(order.getSellerId()) + .status(order.getStatus()) + .totalAmount(order.getTotalAmount()) + .shippingAddress(ShippingAddressResult.from(order.getShippingAddress())) + .orderLines(lineResults) + .createdAt(order.getCreatedAt()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/PurchaseConfirmOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/PurchaseConfirmOrderService.java new file mode 100644 index 00000000..f4ae361c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/PurchaseConfirmOrderService.java @@ -0,0 +1,35 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.PurchaseConfirmOrderCommand; +import kr.magicbox.order.application.port.in.PurchaseConfirmOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderPurchaseConfirmedEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PurchaseConfirmOrderService implements PurchaseConfirmOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void purchaseConfirmOrder(PurchaseConfirmOrderCommand command) { + Order order = orderRepositoryPort.findById(OrderId.of(command.orderId())); + + if (!order.getCustomerId().equals(command.customerId())) { + throw new OrderUnauthorizedException(); + } + + order.confirmPurchase(); + orderRepositoryPort.update(order); + OrderPurchaseConfirmedEvent.fromShippedLines(order).forEach(orderOutboxPort::save); + } +} From fb32475e7d8d49cc2d5b301600cf0d4b9a480fc1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:13 +0900 Subject: [PATCH 003/116] =?UTF-8?q?feat/116=20::=20Order=20=EC=96=B4?= =?UTF-8?q?=EB=8C=91=ED=84=B0=20(Web/Kafka/gRPC/Persistence/Security/Sched?= =?UTF-8?q?uler)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../in/kafka/DeliveryEventKafkaListener.java | 40 +++++++ .../adapter/in/kafka/KafkaConfiguration.java | 22 ++++ .../in/kafka/OrderStateKafkaListener.java | 27 +++++ .../in/kafka/PaymentEventKafkaListener.java | 60 ++++++++++ .../in/kafka/StockEventKafkaListener.java | 38 ++++++ .../in/kafka/annotation/Idempotent.java | 11 ++ .../in/kafka/aop/IdempotentAspect.java | 97 +++++++++++++++ .../kafka/event/DeliveryCompletedEvent.java | 22 ++++ .../in/kafka/event/DeliveryStartedEvent.java | 17 +++ .../in/kafka/event/OrderPrepareEventDto.java | 22 ++++ .../kafka/event/PaymentCancelFailedEvent.java | 14 +++ .../event/PaymentCancelSucceededEvent.java | 22 ++++ .../in/kafka/event/PaymentFailedEvent.java | 14 +++ .../in/kafka/event/PaymentSucceededEvent.java | 11 ++ .../kafka/event/StockReserveFailedEvent.java | 20 ++++ .../event/StockReserveSucceededEvent.java | 11 ++ .../in/kafka/properties/InboxProperties.java | 12 ++ .../scheduler/AutoConfirmOrderScheduler.java | 22 ++++ .../in/scheduler/SchedulerConfiguration.java | 9 ++ .../configuration/SecurityConfiguration.java | 46 ++++++++ .../filter/UserInfoExtractFilter.java | 52 +++++++++ .../properties/TrustedIpProperties.java | 14 +++ .../in/web/OrderCommandController.java | 106 +++++++++++++++++ .../adapter/in/web/OrderQueryController.java | 48 ++++++++ .../web/dto/request/CancelOrderRequest.java | 17 +++ .../web/dto/request/CreateOrderRequest.java | 36 ++++++ .../request/CreateReleaseOrderRequest.java | 28 +++++ .../in/web/dto/request/OrderLineRequest.java | 14 +++ .../dto/request/ShippingAddressRequest.java | 16 +++ .../web/dto/response/OrderLineResponse.java | 23 ++++ .../in/web/dto/response/OrderResponse.java | 38 ++++++ .../dto/response/ShippingAddressResponse.java | 23 ++++ .../web/exception/handler/ErrorResponse.java | 14 +++ .../handler/GlobalExceptionHandler.java | 87 ++++++++++++++ .../out/communication/ServiceHost.java | 13 +++ .../communication/grpc/GrpcConfiguration.java | 21 ++++ .../grpc/ReleaseGrpcAdapter.java | 36 ++++++ .../grpc/WaitingGrpcAdapter.java | 41 +++++++ .../ReleaseServiceUnavailableException.java | 12 ++ .../WaitingServiceUnavailableException.java | 12 ++ .../out/persistence/OrderJpaAdapter.java | 110 ++++++++++++++++++ .../out/persistence/OrderOutboxAdapter.java | 26 +++++ .../configuration/JpaConfiguration.java | 9 ++ .../out/persistence/entity/BaseEntity.java | 30 +++++ .../out/persistence/entity/OrderEntity.java | 71 +++++++++++ .../persistence/entity/OrderInboxEntity.java | 53 +++++++++ .../persistence/entity/OrderInboxStatus.java | 7 ++ .../persistence/entity/OrderLineEntity.java | 56 +++++++++ .../persistence/entity/OrderOutboxEntity.java | 28 +++++ .../out/persistence/mapper/OrderMapper.java | 79 +++++++++++++ .../repository/OrderInboxJpaRepository.java | 12 ++ .../repository/OrderJpaRepository.java | 26 +++++ .../repository/OrderLineJpaRepository.java | 21 ++++ .../repository/OrderOutboxJpaRepository.java | 7 ++ services/order/src/main/proto/release.proto | 19 +++ services/order/src/main/proto/waiting.proto | 21 ++++ 56 files changed, 1763 insertions(+) create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/KafkaConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/annotation/Idempotent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/security/configuration/SecurityConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/security/filter/UserInfoExtractFilter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/security/properties/TrustedIpProperties.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderCommandController.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderQueryController.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CancelOrderRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/ShippingAddressRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderResponse.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/ShippingAddressResponse.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/ErrorResponse.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/GlobalExceptionHandler.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/ReleaseServiceUnavailableException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/WaitingServiceUnavailableException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderOutboxAdapter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/configuration/JpaConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/BaseEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxStatus.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderOutboxEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderInboxJpaRepository.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderLineJpaRepository.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderOutboxJpaRepository.java create mode 100644 services/order/src/main/proto/release.proto create mode 100644 services/order/src/main/proto/waiting.proto diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java new file mode 100644 index 00000000..43658baf --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java @@ -0,0 +1,40 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent; +import kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent; +import kr.magicbox.order.application.port.in.HandleDeliveryCompletedUseCase; +import kr.magicbox.order.application.port.in.HandleDeliveryStartedUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeliveryEventKafkaListener { + + private final HandleDeliveryStartedUseCase handleDeliveryStartedUseCase; + private final HandleDeliveryCompletedUseCase handleDeliveryCompletedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.delivery-started", groupId = "order-service") + public void handleDeliveryStarted(ConsumerRecord consumerRecord) { + log.info("[Inbox] delivery.started 이벤트 수신. eventId={}", consumerRecord.key()); + DeliveryStartedEvent event = consumerRecord.value(); + handleDeliveryStartedUseCase.handleDeliveryStarted(event.orderId(), event.orderLineId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.delivery-completed", groupId = "order-service") + public void handleDeliveryCompleted(ConsumerRecord consumerRecord) { + log.info("[Inbox] delivery.completed 이벤트 수신. eventId={}", consumerRecord.key()); + DeliveryCompletedEvent event = consumerRecord.value(); + handleDeliveryCompletedUseCase.handleDeliveryCompleted(event.orderId(), event.orderLineId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/KafkaConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/KafkaConfiguration.java new file mode 100644 index 00000000..85e3d609 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/KafkaConfiguration.java @@ -0,0 +1,22 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.properties.InboxProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@EnableKafkaRetryTopic +@Configuration +@EnableConfigurationProperties(InboxProperties.class) +public class KafkaConfiguration { + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java new file mode 100644 index 00000000..873c2d5c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java @@ -0,0 +1,27 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto; +import kr.magicbox.order.application.port.in.HandleOrderPrepareUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderStateKafkaListener { + + private final HandleOrderPrepareUseCase handleOrderPrepareUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.order-prepare", groupId = "order-service") + public void handleOrderPrepare(ConsumerRecord consumerRecord) { + log.info("[Inbox] order.prepare 이벤트 수신. eventId={}", consumerRecord.key()); + handleOrderPrepareUseCase.handleOrderPrepare(consumerRecord.value().orderId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java new file mode 100644 index 00000000..03600c45 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java @@ -0,0 +1,60 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent; +import kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent; +import kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent; +import kr.magicbox.order.adapter.in.kafka.event.PaymentSucceededEvent; +import kr.magicbox.order.application.port.in.HandlePaymentCancelFailedUseCase; +import kr.magicbox.order.application.port.in.HandlePaymentCancelSucceededUseCase; +import kr.magicbox.order.application.port.in.HandlePaymentFailedUseCase; +import kr.magicbox.order.application.port.in.HandlePaymentSucceededUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventKafkaListener { + + private final HandlePaymentSucceededUseCase handlePaymentSucceededUseCase; + private final HandlePaymentFailedUseCase handlePaymentFailedUseCase; + private final HandlePaymentCancelSucceededUseCase handlePaymentCancelSucceededUseCase; + private final HandlePaymentCancelFailedUseCase handlePaymentCancelFailedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-succeeded", groupId = "order-service") + public void handlePaymentSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentSucceededUseCase.handlePaymentSucceeded(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-failed", groupId = "order-service") + public void handlePaymentFailed(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.failed 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentFailedUseCase.handlePaymentFailed(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-cancel-succeeded", groupId = "order-service") + public void handlePaymentCancelSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.cancel.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentCancelSucceededUseCase.handlePaymentCancelSucceeded(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-cancel-failed", groupId = "order-service") + public void handlePaymentCancelFailed(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.cancel.failed 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentCancelFailedUseCase.handlePaymentCancelFailed(consumerRecord.value().orderId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java new file mode 100644 index 00000000..4e4dfe8a --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java @@ -0,0 +1,38 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent; +import kr.magicbox.order.adapter.in.kafka.event.StockReserveSucceededEvent; +import kr.magicbox.order.application.port.in.HandleStockReserveFailedUseCase; +import kr.magicbox.order.application.port.in.HandleStockReserveSucceededUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StockEventKafkaListener { + + private final HandleStockReserveSucceededUseCase handleStockReserveSucceededUseCase; + private final HandleStockReserveFailedUseCase handleStockReserveFailedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.stock-reserve-succeeded", groupId = "order-service") + public void handleStockReserveSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] stock.reserve.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + handleStockReserveSucceededUseCase.handleStockReserveSucceeded(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.stock-reserve-failed", groupId = "order-service") + public void handleStockReserveFailed(ConsumerRecord consumerRecord) { + log.info("[Inbox] stock.reserve.failed 이벤트 수신. eventId={}", consumerRecord.key()); + handleStockReserveFailedUseCase.handleStockReserveFailed(consumerRecord.value().orderId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/annotation/Idempotent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/annotation/Idempotent.java new file mode 100644 index 00000000..906de2fa --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/annotation/Idempotent.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.adapter.in.kafka.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Idempotent { +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..a768fc7c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,97 @@ +package kr.magicbox.order.adapter.in.kafka.aop; + +import kr.magicbox.order.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxEntity; +import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxStatus; +import kr.magicbox.order.adapter.out.persistence.repository.OrderInboxJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final OrderInboxJpaRepository orderInboxJpaRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.order.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + Long eventId = Long.parseLong(consumerRecord.key()); + Instant occurredAt = extractOccurredAt(consumerRecord.value()); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } + + return transactionTemplate.execute(status -> { + if (orderInboxJpaRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + OrderInboxEntity inbox = orderInboxJpaRepository.save(OrderInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(OrderInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + private Instant extractOccurredAt(Object payload) { + if (payload == null) { + return Instant.now(); + } + try { + for (RecordComponent component : payload.getClass().getRecordComponents()) { + if (component.getName().equals("occurredAt")) { + Object value = component.getAccessor().invoke(payload); + if (value instanceof Instant instant) { + return instant; + } + } + } + } catch (Exception e) { + log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); + } + return Instant.now(); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java new file mode 100644 index 00000000..d93caceb --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java @@ -0,0 +1,22 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record DeliveryCompletedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("delivery_id") Long deliveryId, + @JsonProperty("tracking_number") String trackingNumber, + @JsonProperty("delivered_at") Instant deliveredAt, + @JsonProperty("items") List items, + @JsonProperty("occurred_at") Instant occurredAt +) { + public record ItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java new file mode 100644 index 00000000..5cd946a0 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record DeliveryStartedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("delivery_id") Long deliveryId, + @JsonProperty("carrier") String carrier, + @JsonProperty("carrier_code") String carrierCode, + @JsonProperty("tracking_number") String trackingNumber, + @JsonProperty("dispatched_at") Instant dispatchedAt, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java new file mode 100644 index 00000000..ef7e9082 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java @@ -0,0 +1,22 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record OrderPrepareEventDto( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("items") List items, + @JsonProperty("total_amount") long totalAmount, + @JsonProperty("occurred_at") Instant occurredAt +) { + public record ItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity, + @JsonProperty("unit_price") long unitPrice, + @JsonProperty("product_name") String productName + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java new file mode 100644 index 00000000..74aa199c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record PaymentCancelFailedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("pg_code") String pgCode, + @JsonProperty("pg_message") String pgMessage, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java new file mode 100644 index 00000000..acdf76fc --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java @@ -0,0 +1,22 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record PaymentCancelSucceededEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("pg_transaction_id") String pgTransactionId, + @JsonProperty("refund_amount") long refundAmount, + @JsonProperty("currency") String currency, + @JsonProperty("refunded_at") Instant refundedAt, + @JsonProperty("items") List items, + @JsonProperty("occurred_at") Instant occurredAt +) { + public record ItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java new file mode 100644 index 00000000..97fe70b3 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record PaymentFailedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("pg_code") String pgCode, + @JsonProperty("pg_message") String pgMessage, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java new file mode 100644 index 00000000..236b2d95 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record PaymentSucceededEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java new file mode 100644 index 00000000..aeffa717 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java @@ -0,0 +1,20 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record StockReserveFailedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("failed_items") List failedItems, + @JsonProperty("occurred_at") Instant occurredAt +) { + public record FailedItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("requested") int requested, + @JsonProperty("available") int available + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java new file mode 100644 index 00000000..d8159fb2 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record StockReserveSucceededEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/properties/InboxProperties.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..ec5d6ade --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java new file mode 100644 index 00000000..4ba934ad --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java @@ -0,0 +1,22 @@ +package kr.magicbox.order.adapter.in.scheduler; + +import kr.magicbox.order.application.port.in.AutoConfirmOrderUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AutoConfirmOrderScheduler { + + private final AutoConfirmOrderUseCase autoConfirmOrderUseCase; + + @Scheduled(cron = "0 0 2 * * *") + public void autoConfirmDeliveredOrders() { + log.info("[Scheduler] 자동 구매 확정 스케줄러 시작"); + autoConfirmOrderUseCase.autoConfirmDeliveredOrders(); + log.info("[Scheduler] 자동 구매 확정 스케줄러 완료"); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java new file mode 100644 index 00000000..93cba13e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java @@ -0,0 +1,9 @@ +package kr.magicbox.order.adapter.in.scheduler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfiguration { +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/security/configuration/SecurityConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/configuration/SecurityConfiguration.java new file mode 100644 index 00000000..97a93333 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/configuration/SecurityConfiguration.java @@ -0,0 +1,46 @@ +package kr.magicbox.order.adapter.in.security.configuration; + +import kr.magicbox.order.adapter.in.security.filter.UserInfoExtractFilter; +import kr.magicbox.order.adapter.in.security.properties.TrustedIpProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.filter.ForwardedHeaderFilter; + +@Configuration +@EnableWebSecurity +@EnableConfigurationProperties(TrustedIpProperties.class) +@RequiredArgsConstructor +public class SecurityConfiguration { + + private final TrustedIpProperties trustedIpProperties; + + @Bean + public ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + @Bean + public UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(new UserInfoExtractFilter(trustedIpProperties), UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/security/filter/UserInfoExtractFilter.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/filter/UserInfoExtractFilter.java new file mode 100644 index 00000000..5f57742d --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/filter/UserInfoExtractFilter.java @@ -0,0 +1,52 @@ +package kr.magicbox.order.adapter.in.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.magicbox.order.adapter.in.security.properties.TrustedIpProperties; +import kr.magicbox.order.domain.vo.UserId; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class UserInfoExtractFilter extends OncePerRequestFilter { + + private final TrustedIpProperties trustedIpProperties; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + String clientIp = request.getRemoteAddr(); + + if (!trustedIpProperties.getIps().contains(clientIp)) { + filterChain.doFilter(request, response); + return; + } + + String userIdHeader = request.getHeader("X-User-Id"); + + if (!isValidUserId(userIdHeader)) { + filterChain.doFilter(request, response); + return; + } + + UserId userId = UserId.of(Long.valueOf(userIdHeader)); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userId, null); + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } + + private boolean isValidUserId(String userIdHeader) { + try { + return Long.parseLong(userIdHeader) > 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/security/properties/TrustedIpProperties.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/properties/TrustedIpProperties.java new file mode 100644 index 00000000..ed0c5c25 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/properties/TrustedIpProperties.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.adapter.in.security.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "security.trusted") +public class TrustedIpProperties { + private final List ips; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderCommandController.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderCommandController.java new file mode 100644 index 00000000..00f16d8c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderCommandController.java @@ -0,0 +1,106 @@ +package kr.magicbox.order.adapter.in.web; + +import jakarta.validation.Valid; +import kr.magicbox.order.adapter.in.web.dto.request.CancelOrderRequest; +import kr.magicbox.order.adapter.in.web.dto.request.CreateOrderRequest; +import kr.magicbox.order.adapter.in.web.dto.request.CreateReleaseOrderRequest; +import kr.magicbox.order.application.dto.command.ConfirmOrderCommand; +import kr.magicbox.order.application.dto.command.PurchaseConfirmOrderCommand; +import kr.magicbox.order.application.port.in.CancelOrderUseCase; +import kr.magicbox.order.application.port.in.ComplainOrderLineUseCase; +import kr.magicbox.order.application.port.in.ConfirmOrderUseCase; +import kr.magicbox.order.application.port.in.CreateOrderUseCase; +import kr.magicbox.order.application.port.in.CreateReleaseOrderUseCase; +import kr.magicbox.order.application.port.in.PurchaseConfirmOrderUseCase; +import kr.magicbox.order.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/order") +@RequiredArgsConstructor +@Validated +public class OrderCommandController { + + private final CreateOrderUseCase createOrderUseCase; + private final CreateReleaseOrderUseCase createReleaseOrderUseCase; + private final ConfirmOrderUseCase confirmOrderUseCase; + private final CancelOrderUseCase cancelOrderUseCase; + private final PurchaseConfirmOrderUseCase purchaseConfirmOrderUseCase; + private final ComplainOrderLineUseCase complainOrderLineUseCase; + + @PostMapping + public ResponseEntity createOrder( + @AuthenticationPrincipal UserId userId, + @Valid @RequestBody CreateOrderRequest request + ) { + createOrderUseCase.createOrder(request.toCommand(userId.value())); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/release") + public ResponseEntity createReleaseOrder( + @AuthenticationPrincipal UserId userId, + @Valid @RequestBody CreateReleaseOrderRequest request + ) { + createReleaseOrderUseCase.createReleaseOrder(request.toCommand(userId.value())); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/{orderId}/confirm") + public ResponseEntity confirmOrder( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId + ) { + confirmOrderUseCase.confirmOrder(toConfirmCommand(orderId, userId.value())); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{orderId}/lines/{orderLineId}/cancel") + public ResponseEntity cancelOrder( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId, + @PathVariable Long orderLineId, + @Valid @RequestBody CancelOrderRequest request + ) { + cancelOrderUseCase.cancelOrder(request.toCommand(orderId, orderLineId, userId.value())); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{orderId}/purchase-confirm") + public ResponseEntity purchaseConfirmOrder( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId + ) { + purchaseConfirmOrderUseCase.purchaseConfirmOrder(toPurchaseConfirmCommand(orderId, userId.value())); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{orderId}/lines/{orderLineId}/complain") + public ResponseEntity complainOrderLine( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId, + @PathVariable Long orderLineId + ) { + complainOrderLineUseCase.complainOrderLine(orderId, orderLineId, userId.value()); + return ResponseEntity.noContent().build(); + } + + private ConfirmOrderCommand toConfirmCommand(Long orderId, Long sellerId) { + return ConfirmOrderCommand.builder() + .orderId(orderId) + .sellerId(sellerId) + .build(); + } + + private PurchaseConfirmOrderCommand toPurchaseConfirmCommand(Long orderId, Long customerId) { + return PurchaseConfirmOrderCommand.builder() + .orderId(orderId) + .customerId(customerId) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderQueryController.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderQueryController.java new file mode 100644 index 00000000..9be4c93e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderQueryController.java @@ -0,0 +1,48 @@ +package kr.magicbox.order.adapter.in.web; + +import kr.magicbox.order.adapter.in.web.dto.response.OrderResponse; +import kr.magicbox.order.application.dto.query.GetOrderListQuery; +import kr.magicbox.order.application.dto.query.GetOrderQuery; +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.application.port.in.GetOrderListUseCase; +import kr.magicbox.order.application.port.in.GetOrderUseCase; +import kr.magicbox.order.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/order") +@RequiredArgsConstructor +public class OrderQueryController { + + private final GetOrderUseCase getOrderUseCase; + private final GetOrderListUseCase getOrderListUseCase; + + @GetMapping("/{orderId}") + public ResponseEntity getOrder( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId + ) { + OrderResult result = getOrderUseCase.getOrder(GetOrderQuery.builder() + .orderId(orderId) + .requesterId(userId.value()) + .build()); + return ResponseEntity.ok(OrderResponse.from(result)); + } + + @GetMapping + public ResponseEntity> getOrderList( + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) Long sellerId + ) { + List results = getOrderListUseCase.getOrderList(GetOrderListQuery.builder() + .customerId(customerId) + .sellerId(sellerId) + .build()); + return ResponseEntity.ok(results.stream().map(OrderResponse::from).toList()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CancelOrderRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CancelOrderRequest.java new file mode 100644 index 00000000..8a48b006 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CancelOrderRequest.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.constraints.NotBlank; +import kr.magicbox.order.application.dto.command.CancelOrderCommand; + +public record CancelOrderRequest( + @NotBlank(message = "취소 사유는 필수입니다.") String reason +) { + public CancelOrderCommand toCommand(Long orderId, Long orderLineId, Long customerId) { + return CancelOrderCommand.builder() + .orderId(orderId) + .orderLineId(orderLineId) + .customerId(customerId) + .reason(reason) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java new file mode 100644 index 00000000..132b5cbf --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java @@ -0,0 +1,36 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import kr.magicbox.order.application.dto.command.CreateOrderCommand; + +import java.util.List; + +public record CreateOrderRequest( + @NotNull(message = "판매자 ID는 필수입니다.") @Positive(message = "판매자 ID는 양수여야 합니다.") Long sellerId, + @NotNull(message = "총 금액은 필수입니다.") @Positive(message = "총 금액은 양수여야 합니다.") Long totalAmount, + @Valid @NotNull(message = "배송지 정보는 필수입니다.") ShippingAddressRequest shippingAddress, + @NotEmpty(message = "주문 항목은 하나 이상이어야 합니다.") List<@Valid OrderLineRequest> orderLines +) { + public CreateOrderCommand toCommand(Long customerId) { + List lineCommands = orderLines.stream() + .map(line -> CreateOrderCommand.OrderLineCommand.builder() + .productId(line.productId()) + .sellerId(line.sellerId()) + .productName(line.productName()) + .quantity(line.quantity()) + .unitPrice(line.unitPrice()) + .build()) + .toList(); + + return CreateOrderCommand.builder() + .customerId(customerId) + .sellerId(sellerId) + .totalAmount(totalAmount) + .shippingAddress(shippingAddress.toDomain()) + .orderLines(lineCommands) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java new file mode 100644 index 00000000..f5a3798a --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java @@ -0,0 +1,28 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import kr.magicbox.order.application.dto.command.CreateReleaseOrderCommand; + +public record CreateReleaseOrderRequest( + @NotNull(message = "판매자 ID는 필수입니다.") @Positive(message = "판매자 ID는 양수여야 합니다.") Long sellerId, + @NotNull(message = "릴리즈 ID는 필수입니다.") @Positive(message = "릴리즈 ID는 양수여야 합니다.") Long releaseId, + @NotBlank(message = "구매 토큰은 필수입니다.") String purchaseToken, + @NotBlank(message = "상품명은 필수입니다.") String productName, + @NotNull(message = "단가는 필수입니다.") @Positive(message = "단가는 양수여야 합니다.") Long unitPrice, + @Valid @NotNull(message = "배송지 정보는 필수입니다.") ShippingAddressRequest shippingAddress +) { + public CreateReleaseOrderCommand toCommand(Long customerId) { + return CreateReleaseOrderCommand.builder() + .customerId(customerId) + .sellerId(sellerId) + .releaseId(releaseId) + .purchaseToken(purchaseToken) + .productName(productName) + .unitPrice(unitPrice) + .shippingAddress(shippingAddress.toDomain()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java new file mode 100644 index 00000000..3f329b79 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record OrderLineRequest( + @NotNull(message = "상품 ID는 필수입니다.") @Positive(message = "상품 ID는 양수여야 합니다.") Long productId, + @NotNull(message = "판매자 ID는 필수입니다.") @Positive(message = "판매자 ID는 양수여야 합니다.") Long sellerId, + @NotBlank(message = "상품명은 필수입니다.") String productName, + @NotNull(message = "수량은 필수입니다.") @Min(value = 1, message = "수량은 1 이상이어야 합니다.") Integer quantity, + @NotNull(message = "단가는 필수입니다.") @Min(value = 0, message = "단가는 0 이상이어야 합니다.") Long unitPrice +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/ShippingAddressRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/ShippingAddressRequest.java new file mode 100644 index 00000000..7b144686 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/ShippingAddressRequest.java @@ -0,0 +1,16 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.constraints.NotBlank; +import kr.magicbox.order.domain.vo.ShippingAddress; + +public record ShippingAddressRequest( + @NotBlank(message = "수령인은 필수입니다.") String recipient, + @NotBlank(message = "전화번호는 필수입니다.") String phone, + @NotBlank(message = "우편번호는 필수입니다.") String zipCode, + @NotBlank(message = "도로명 주소는 필수입니다.") String address1, + String address2 +) { + public ShippingAddress toDomain() { + return ShippingAddress.of(recipient, phone, zipCode, address1, address2); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java new file mode 100644 index 00000000..f5552a45 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.web.dto.response; + +import kr.magicbox.order.application.dto.result.OrderLineResult; +import lombok.Builder; + +@Builder +public record OrderLineResponse( + Long orderLineId, + Long productId, + String productName, + int quantity, + long unitPrice +) { + public static OrderLineResponse from(OrderLineResult result) { + return OrderLineResponse.builder() + .orderLineId(result.orderLineId()) + .productId(result.productId()) + .productName(result.productName()) + .quantity(result.quantity()) + .unitPrice(result.unitPrice()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderResponse.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderResponse.java new file mode 100644 index 00000000..4e95270a --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderResponse.java @@ -0,0 +1,38 @@ +package kr.magicbox.order.adapter.in.web.dto.response; + +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.domain.enums.OrderStatus; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderResponse( + Long orderId, + Long customerId, + Long sellerId, + OrderStatus status, + long totalAmount, + ShippingAddressResponse shippingAddress, + List orderLines, + Instant createdAt +) { + public static OrderResponse from(OrderResult result) { + List lineResponses = result.orderLines().stream() + .map(OrderLineResponse::from) + .toList(); + + return OrderResponse.builder() + .orderId(result.orderId()) + .customerId(result.customerId()) + .sellerId(result.sellerId()) + .status(result.status()) + .totalAmount(result.totalAmount()) + .shippingAddress(ShippingAddressResponse.from(result.shippingAddress())) + .orderLines(lineResponses) + .createdAt(result.createdAt()) + .build(); + } +} + diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/ShippingAddressResponse.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/ShippingAddressResponse.java new file mode 100644 index 00000000..19f3e46b --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/ShippingAddressResponse.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.web.dto.response; + +import kr.magicbox.order.application.dto.result.ShippingAddressResult; +import lombok.Builder; + +@Builder +public record ShippingAddressResponse( + String recipient, + String phone, + String zipCode, + String address1, + String address2 +) { + public static ShippingAddressResponse from(ShippingAddressResult result) { + return ShippingAddressResponse.builder() + .recipient(result.recipient()) + .phone(result.phone()) + .zipCode(result.zipCode()) + .address1(result.address1()) + .address2(result.address2()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/ErrorResponse.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/ErrorResponse.java new file mode 100644 index 00000000..00171491 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/ErrorResponse.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.adapter.in.web.exception.handler; + +import lombok.Builder; +import org.springframework.http.HttpStatus; + +@Builder +public record ErrorResponse(int status, String message) { + public static ErrorResponse of(HttpStatus status, String message) { + return ErrorResponse.builder() + .status(status.value()) + .message(message) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/GlobalExceptionHandler.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..a51ba7e5 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,87 @@ +package kr.magicbox.order.adapter.in.web.exception.handler; + +import jakarta.validation.ConstraintViolationException; +import kr.magicbox.order.global.exception.BaseException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException() { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ErrorResponse.of(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다.")); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("요청 본문을 읽을 수 없습니다: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, "요청 본문을 읽을 수 없습니다.")); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String errorMessage = e.getBindingResult().getFieldError() != null ? + e.getBindingResult().getFieldError().getDefaultMessage() : "인자값이 유효하지 않습니다."; + log.error("요청 데이터 유효성 검증 실패: {}", errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, errorMessage)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { + String errorMessage = e.getConstraintViolations().isEmpty() ? + "유효성 검증에 실패했습니다." : + e.getConstraintViolations().iterator().next().getMessage(); + log.error("유효성 검증 실패: {}", errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, errorMessage)); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.error("지원되지 않는 HTTP 메서드: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED, "지원되지 않는 HTTP 메서드입니다.")); + } + + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + public ResponseEntity handleOptimisticLockingFailureException(ObjectOptimisticLockingFailureException e) { + log.warn("동시 수정 충돌 발생: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ErrorResponse.of(HttpStatus.CONFLICT, "다른 요청과 충돌이 발생했습니다. 다시 시도해주세요.")); + } + + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(BaseException e) { + HttpStatus status = e.getStatus(); + return ResponseEntity + .status(status) + .body(ErrorResponse.of(status, e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("예상하지 못한 오류 발생: {}", e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 오류가 발생했습니다.")); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java new file mode 100644 index 00000000..55b9a606 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java @@ -0,0 +1,13 @@ +package kr.magicbox.order.adapter.out.communication; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ServiceHost { + WAITING("waiting-service"), + RELEASE("release-service"); + + private final String hostName; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java new file mode 100644 index 00000000..73f70241 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java @@ -0,0 +1,21 @@ +package kr.magicbox.order.adapter.out.communication.grpc; + +import io.grpc.ManagedChannel; +import kr.magicbox.order.adapter.out.communication.ServiceHost; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.GrpcChannelFactory; + +@Configuration +public class GrpcConfiguration { + + @Bean + public ManagedChannel waitingManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.WAITING.getHostName()); + } + + @Bean + public ManagedChannel releaseManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java new file mode 100644 index 00000000..ff6fab63 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java @@ -0,0 +1,36 @@ +package kr.magicbox.order.adapter.out.communication.grpc; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.grpc.ManagedChannel; +import kr.magicbox.order.adapter.out.communication.grpc.exception.ReleaseServiceUnavailableException; +import kr.magicbox.order.application.port.out.ReleaseIncreaseSoldQuantityPort; +import kr.magicbox.order.grpc.release.IncreaseSoldQuantityRequest; +import kr.magicbox.order.grpc.release.ReleaseServiceGrpc; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReleaseGrpcAdapter implements ReleaseIncreaseSoldQuantityPort { + + private final ManagedChannel releaseManagedChannel; + + @Override + @CircuitBreaker(name = "releaseService", fallbackMethod = "increaseSoldQuantityFallback") + public void increaseSoldQuantity(Long releaseId) { + IncreaseSoldQuantityRequest request = IncreaseSoldQuantityRequest.newBuilder() + .setReleaseId(releaseId) + .build(); + + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(releaseManagedChannel); + stub.increaseSoldQuantity(request); + } + + @SuppressWarnings("unused") + private void increaseSoldQuantityFallback(Long releaseId, Throwable throwable) { + log.warn("릴리즈 서비스 연결 실패"); + throw new ReleaseServiceUnavailableException(throwable); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java new file mode 100644 index 00000000..a50e0325 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java @@ -0,0 +1,41 @@ +package kr.magicbox.order.adapter.out.communication.grpc; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.grpc.ManagedChannel; +import kr.magicbox.order.adapter.out.communication.grpc.exception.WaitingServiceUnavailableException; +import kr.magicbox.order.application.port.out.PurchaseTokenValidationPort; +import kr.magicbox.order.grpc.waiting.ValidatePurchaseTokenRequest; +import kr.magicbox.order.grpc.waiting.ValidatePurchaseTokenResponse; +import kr.magicbox.order.grpc.waiting.WaitingServiceGrpc; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WaitingGrpcAdapter implements PurchaseTokenValidationPort { + + private final ManagedChannel waitingManagedChannel; + + @Override + @CircuitBreaker(name = "waitingService", fallbackMethod = "validateFallback") + public boolean validate(Long releaseId, Long userId, String purchaseToken) { + ValidatePurchaseTokenRequest request = ValidatePurchaseTokenRequest.newBuilder() + .setReleaseId(releaseId) + .setUserId(userId) + .setPurchaseToken(purchaseToken) + .build(); + + WaitingServiceGrpc.WaitingServiceBlockingStub stub = WaitingServiceGrpc.newBlockingStub(waitingManagedChannel); + ValidatePurchaseTokenResponse response = stub.validatePurchaseToken(request); + + return response.getValid(); + } + + @SuppressWarnings("unused") + private boolean validateFallback(Long releaseId, Long userId, String purchaseToken, Throwable throwable) { + log.warn("대기열 서비스 연결 실패"); + throw new WaitingServiceUnavailableException(throwable); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/ReleaseServiceUnavailableException.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/ReleaseServiceUnavailableException.java new file mode 100644 index 00000000..1c5a41b9 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/ReleaseServiceUnavailableException.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.out.communication.grpc.exception; + +import kr.magicbox.order.global.exception.SystemError; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class ReleaseServiceUnavailableException extends SystemError { + + public ReleaseServiceUnavailableException(Throwable cause) { + super("릴리즈 서비스에 연결할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE, cause); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/WaitingServiceUnavailableException.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/WaitingServiceUnavailableException.java new file mode 100644 index 00000000..1e24c850 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/WaitingServiceUnavailableException.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.out.communication.grpc.exception; + +import kr.magicbox.order.global.exception.SystemError; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class WaitingServiceUnavailableException extends SystemError { + + public WaitingServiceUnavailableException(Throwable cause) { + super("대기열 서비스에 연결할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE, cause); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java new file mode 100644 index 00000000..762e7356 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java @@ -0,0 +1,110 @@ +package kr.magicbox.order.adapter.out.persistence; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderEntity; +import kr.magicbox.order.adapter.out.persistence.entity.OrderLineEntity; +import kr.magicbox.order.adapter.out.persistence.mapper.OrderMapper; +import kr.magicbox.order.adapter.out.persistence.repository.OrderJpaRepository; +import kr.magicbox.order.adapter.out.persistence.repository.OrderLineJpaRepository; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import kr.magicbox.order.domain.enums.OrderStatus; +import kr.magicbox.order.domain.exception.OrderNotFoundException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class OrderJpaAdapter implements OrderRepositoryPort { + + private final OrderJpaRepository orderJpaRepository; + private final OrderLineJpaRepository orderLineJpaRepository; + private final OrderMapper orderMapper; + + @Override + public Long save(Order order) { + OrderEntity orderEntity = orderJpaRepository.save(orderMapper.toEntity(order)); + List orderLines = order.getOrderLines(); + orderLines.forEach(line -> + orderLineJpaRepository.save(orderMapper.toLineEntity(orderEntity.getId(), line)) + ); + return orderEntity.getId(); + } + + @Override + public void update(Order order) { + OrderEntity entity = orderJpaRepository.findByIdAndIsDeletedFalse(order.getId().value()) + .orElseThrow(OrderNotFoundException::new); + entity.update(order.getStatus()); + orderJpaRepository.save(entity); + + List lineEntities = orderLineJpaRepository.findByOrderId(entity.getId()); + Map lineEntityById = lineEntities.stream() + .collect(Collectors.toMap(OrderLineEntity::getId, l -> l)); + order.getOrderLines().forEach(line -> { + if (line.getId() != null) { + OrderLineEntity lineEntity = lineEntityById.get(line.getId().value()); + if (lineEntity != null) { + lineEntity.updateDeliveryStatus(line.getDeliveryStatus()); + orderLineJpaRepository.save(lineEntity); + } + } + }); + } + + @Override + public Order findById(OrderId id) { + OrderEntity entity = orderJpaRepository.findByIdAndIsDeletedFalse(id.value()) + .orElseThrow(OrderNotFoundException::new); + List lineEntities = orderLineJpaRepository.findByOrderId(entity.getId()); + return orderMapper.toDomain(entity, lineEntities); + } + + @Override + public Order findByOrderLineId(Long orderLineId) { + OrderLineEntity lineEntity = orderLineJpaRepository.findByOrderLineId(orderLineId) + .orElseThrow(OrderNotFoundException::new); + OrderEntity orderEntity = orderJpaRepository.findByIdAndIsDeletedFalse(lineEntity.getOrderId()) + .orElseThrow(OrderNotFoundException::new); + List allLineEntities = orderLineJpaRepository.findByOrderId(orderEntity.getId()); + return orderMapper.toDomain(orderEntity, allLineEntities); + } + + @Override + public List findByCustomerId(Long customerId) { + List orders = orderJpaRepository.findByCustomerIdAndIsDeletedFalse(customerId); + return toDomainsWithLines(orders); + } + + @Override + public List findBySellerId(Long sellerId) { + List orders = orderJpaRepository.findBySellerIdAndIsDeletedFalse(sellerId); + return toDomainsWithLines(orders); + } + + @Override + public List findDeliveredBefore(Instant deliveredBefore) { + List orders = orderJpaRepository.findByStatusAndUpdatedAtBeforeAndIsDeletedFalse(OrderStatus.DELIVERED, deliveredBefore); + return toDomainsWithLines(orders); + } + + private List toDomainsWithLines(List orders) { + if (orders.isEmpty()) { + return List.of(); + } + List orderIds = orders.stream().map(OrderEntity::getId).toList(); + Map> linesByOrderId = orderLineJpaRepository.findByOrderIdIn(orderIds) + .stream() + .collect(Collectors.groupingBy(OrderLineEntity::getOrderId)); + + return orders.stream() + .map(entity -> orderMapper.toDomain(entity, linesByOrderId.getOrDefault(entity.getId(), List.of()))) + .toList(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderOutboxAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderOutboxAdapter.java new file mode 100644 index 00000000..481b27fa --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderOutboxAdapter.java @@ -0,0 +1,26 @@ +package kr.magicbox.order.adapter.out.persistence; + +import tools.jackson.databind.ObjectMapper; +import kr.magicbox.order.adapter.out.persistence.entity.OrderOutboxEntity; +import kr.magicbox.order.adapter.out.persistence.repository.OrderOutboxJpaRepository; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.domain.event.OrderDomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrderOutboxAdapter implements OrderOutboxPort { + + private final OrderOutboxJpaRepository orderOutboxJpaRepository; + private final ObjectMapper objectMapper; + + @Override + public void save(OrderDomainEvent event) { + String payload = objectMapper.writeValueAsString(event); + orderOutboxJpaRepository.save(OrderOutboxEntity.builder() + .eventType(event.eventType().getValue()) + .payload(payload) + .build()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/configuration/JpaConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/configuration/JpaConfiguration.java new file mode 100644 index 00000000..91a6cb99 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/configuration/JpaConfiguration.java @@ -0,0 +1,9 @@ +package kr.magicbox.order.adapter.out.persistence.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfiguration { +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/BaseEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/BaseEntity.java new file mode 100644 index 00000000..a39bc548 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/BaseEntity.java @@ -0,0 +1,30 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import com.github.lian2945.sonyflake.annotation.SonyflakeId; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + @Id + @SonyflakeId + private Long id; + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderEntity.java new file mode 100644 index 00000000..d1542f70 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderEntity.java @@ -0,0 +1,71 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import kr.magicbox.order.domain.enums.OrderStatus; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "orders") +public class OrderEntity extends BaseEntity { + + @Column(name = "customer_id", nullable = false) + private Long customerId; + + @Column(name = "seller_id", nullable = false) + private Long sellerId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "recipient", nullable = false) + private String recipient; + + @Column(name = "phone", nullable = false) + private String phone; + + @Column(name = "zip_code", nullable = false) + private String zipCode; + + @Column(name = "address1", nullable = false) + private String address1; + + @Column(name = "address2") + private String address2; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Version + private Integer version; + + @Builder + public OrderEntity(Long customerId, Long sellerId, OrderStatus status, Long totalAmount, + String recipient, String phone, String zipCode, String address1, String address2) { + this.customerId = customerId; + this.sellerId = sellerId; + this.status = status; + this.totalAmount = totalAmount; + this.recipient = recipient; + this.phone = phone; + this.zipCode = zipCode; + this.address1 = address1; + this.address2 = address2; + this.isDeleted = false; + } + + public void update(OrderStatus status) { + this.status = status; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxEntity.java new file mode 100644 index 00000000..b4051104 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "order_inbox") +public class OrderInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public OrderInboxEntity(Long eventId, String topic, Integer partition, Long offset, OrderInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = OrderInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = OrderInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxStatus.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxStatus.java new file mode 100644 index 00000000..517693a3 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +public enum OrderInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java new file mode 100644 index 00000000..9e5853b0 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java @@ -0,0 +1,56 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import kr.magicbox.order.domain.enums.OrderLineDeliveryStatus; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "order_line") +public class OrderLineEntity extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "seller_id", nullable = false) + private Long sellerId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Column(name = "unit_price", nullable = false) + private Long unitPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "delivery_status", nullable = false) + private OrderLineDeliveryStatus deliveryStatus; + + @Builder + public OrderLineEntity(Long orderId, Long productId, Long sellerId, String productName, Integer quantity, Long unitPrice, OrderLineDeliveryStatus deliveryStatus) { + this.orderId = orderId; + this.productId = productId; + this.sellerId = sellerId; + this.productName = productName; + this.quantity = quantity; + this.unitPrice = unitPrice; + this.deliveryStatus = deliveryStatus != null ? deliveryStatus : OrderLineDeliveryStatus.PENDING; + } + + public void updateDeliveryStatus(OrderLineDeliveryStatus deliveryStatus) { + this.deliveryStatus = deliveryStatus; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderOutboxEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderOutboxEntity.java new file mode 100644 index 00000000..75b34b42 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderOutboxEntity.java @@ -0,0 +1,28 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "order_outbox") +public class OrderOutboxEntity extends BaseEntity { + + @Column(nullable = false) + private String eventType; + + @Column(nullable = false, columnDefinition = "JSON") + private String payload; + + @Builder + public OrderOutboxEntity(String eventType, String payload) { + this.eventType = eventType; + this.payload = payload; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java new file mode 100644 index 00000000..683ce512 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java @@ -0,0 +1,79 @@ +package kr.magicbox.order.adapter.out.persistence.mapper; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderEntity; +import kr.magicbox.order.adapter.out.persistence.entity.OrderLineEntity; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import kr.magicbox.order.domain.vo.OrderId; +import kr.magicbox.order.domain.vo.OrderLineId; +import kr.magicbox.order.domain.vo.ShippingAddress; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class OrderMapper { + + public OrderEntity toEntity(Order domain) { + ShippingAddress addr = domain.getShippingAddress(); + return OrderEntity.builder() + .customerId(domain.getCustomerId()) + .sellerId(domain.getSellerId()) + .status(domain.getStatus()) + .totalAmount(domain.getTotalAmount()) + .recipient(addr.recipient()) + .phone(addr.phone()) + .zipCode(addr.zipCode()) + .address1(addr.address1()) + .address2(addr.address2()) + .build(); + } + + public OrderLineEntity toLineEntity(Long orderId, OrderLine domain) { + return OrderLineEntity.builder() + .orderId(orderId) + .productId(domain.getProductId()) + .sellerId(domain.getSellerId()) + .productName(domain.getProductName()) + .quantity(domain.getQuantity()) + .unitPrice(domain.getUnitPrice()) + .deliveryStatus(domain.getDeliveryStatus()) + .build(); + } + + public Order toDomain(OrderEntity entity, List lineEntities) { + List orderLines = lineEntities.stream() + .map(this::toLineDomain) + .toList(); + + return Order.reconstructBuilder() + .id(OrderId.of(entity.getId())) + .customerId(entity.getCustomerId()) + .sellerId(entity.getSellerId()) + .status(entity.getStatus()) + .totalAmount(entity.getTotalAmount()) + .shippingAddress(ShippingAddress.of( + entity.getRecipient(), + entity.getPhone(), + entity.getZipCode(), + entity.getAddress1(), + entity.getAddress2() + )) + .orderLines(orderLines) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + private OrderLine toLineDomain(OrderLineEntity entity) { + return OrderLine.reconstructBuilder() + .id(OrderLineId.of(entity.getId())) + .productId(entity.getProductId()) + .sellerId(entity.getSellerId()) + .productName(entity.getProductName()) + .quantity(entity.getQuantity()) + .unitPrice(entity.getUnitPrice()) + .deliveryStatus(entity.getDeliveryStatus()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderInboxJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderInboxJpaRepository.java new file mode 100644 index 00000000..f8921902 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderInboxJpaRepository.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.out.persistence.repository; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface OrderInboxJpaRepository extends JpaRepository { + + @Query("SELECT CASE WHEN EXISTS (SELECT i FROM OrderInboxEntity i WHERE i.eventId = :eventId) THEN true ELSE false END") + boolean existsByEventId(@Param("eventId") Long eventId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java new file mode 100644 index 00000000..f7c60b0c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java @@ -0,0 +1,26 @@ +package kr.magicbox.order.adapter.out.persistence.repository; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderEntity; +import kr.magicbox.order.domain.enums.OrderStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OrderEntity o WHERE o.id = :id AND o.isDeleted = false") + Optional findByIdAndIsDeletedFalse(@Param("id") Long id); + + @Query("SELECT o FROM OrderEntity o WHERE o.customerId = :customerId AND o.isDeleted = false") + List findByCustomerIdAndIsDeletedFalse(@Param("customerId") Long customerId); + + @Query("SELECT o FROM OrderEntity o WHERE o.sellerId = :sellerId AND o.isDeleted = false") + List findBySellerIdAndIsDeletedFalse(@Param("sellerId") Long sellerId); + + @Query("SELECT o FROM OrderEntity o WHERE o.status = :status AND o.updatedAt < :before AND o.isDeleted = false") + List findByStatusAndUpdatedAtBeforeAndIsDeletedFalse(@Param("status") OrderStatus status, @Param("before") Instant before); +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderLineJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderLineJpaRepository.java new file mode 100644 index 00000000..50683227 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderLineJpaRepository.java @@ -0,0 +1,21 @@ +package kr.magicbox.order.adapter.out.persistence.repository; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderLineEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface OrderLineJpaRepository extends JpaRepository { + + @Query("SELECT ol FROM OrderLineEntity ol WHERE ol.orderId = :orderId") + List findByOrderId(@Param("orderId") Long orderId); + + @Query("SELECT ol FROM OrderLineEntity ol WHERE ol.orderId IN :orderIds") + List findByOrderIdIn(@Param("orderIds") List orderIds); + + @Query("SELECT ol FROM OrderLineEntity ol WHERE ol.id = :orderLineId") + Optional findByOrderLineId(@Param("orderLineId") Long orderLineId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderOutboxJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderOutboxJpaRepository.java new file mode 100644 index 00000000..a6aec1fc --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderOutboxJpaRepository.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.adapter.out.persistence.repository; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderOutboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderOutboxJpaRepository extends JpaRepository { +} diff --git a/services/order/src/main/proto/release.proto b/services/order/src/main/proto/release.proto new file mode 100644 index 00000000..f2ddf354 --- /dev/null +++ b/services/order/src/main/proto/release.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package magicbox.release.v1; + +option java_package = "kr.magicbox.order.grpc.release"; +option java_outer_classname = "ReleaseServiceProto"; +option java_multiple_files = true; + +service ReleaseService { + rpc IncreaseSoldQuantity(IncreaseSoldQuantityRequest) returns (IncreaseSoldQuantityResponse); +} + +message IncreaseSoldQuantityRequest { + int64 release_id = 1; +} + +message IncreaseSoldQuantityResponse { + bool sold_out = 1; +} diff --git a/services/order/src/main/proto/waiting.proto b/services/order/src/main/proto/waiting.proto new file mode 100644 index 00000000..ebd5fbfb --- /dev/null +++ b/services/order/src/main/proto/waiting.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package magicbox.waiting.v1; + +option java_package = "kr.magicbox.order.grpc.waiting"; +option java_outer_classname = "WaitingServiceProto"; +option java_multiple_files = true; + +service WaitingService { + rpc ValidatePurchaseToken(ValidatePurchaseTokenRequest) returns (ValidatePurchaseTokenResponse); +} + +message ValidatePurchaseTokenRequest { + int64 release_id = 1; + int64 user_id = 2; + string purchase_token = 3; +} + +message ValidatePurchaseTokenResponse { + bool valid = 1; +} From c43e703c1472fb66c16c9c33dcf44138b2f28278 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:13 +0900 Subject: [PATCH 004/116] =?UTF-8?q?feat/116=20::=20Order=20application-*.y?= =?UTF-8?q?ml,=20Dockerfile,=20build.gradle=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- services/order/Dockerfile | 4 +- services/order/build.gradle | 44 +++++++++++++++- .../src/main/resources/application-dev.yml | 52 +++++++++++++++++++ .../src/main/resources/application-local.yml | 52 ++++++++++++++++++- .../src/main/resources/application-prod.yml | 44 ++++++++++++++++ 5 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 services/order/src/main/resources/application-dev.yml create mode 100644 services/order/src/main/resources/application-prod.yml diff --git a/services/order/Dockerfile b/services/order/Dockerfile index a8e8e580..d62c9f15 100644 --- a/services/order/Dockerfile +++ b/services/order/Dockerfile @@ -1,6 +1,6 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu -ARG JAR_FILE=build/libs/*.jar +ARG JAR_FILE=services/order/build/libs/*.jar WORKDIR /app COPY ${JAR_FILE} app.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/order/build.gradle b/services/order/build.gradle index e1213774..2f6923e9 100644 --- a/services/order/build.gradle +++ b/services/order/build.gradle @@ -1,5 +1,47 @@ +plugins { + id 'com.google.protobuf' version '0.9.6' +} + +ext { + springGrpcVersion = "1.0.2" + springCloudVersion = "2025.1.0" +} version = '0.0.1' description = 'order' dependencies { -} \ No newline at end of file + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework:spring-aspects' + implementation 'org.aspectj:aspectjweaver' + implementation 'org.springframework.boot:spring-boot-starter-kafka' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + runtimeOnly 'com.mysql:mysql-connector-j' + + testImplementation 'org.springframework.kafka:spring-kafka-test' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.grpc:spring-grpc-dependencies:$springGrpcVersion" + mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion" + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.34.0" + } + plugins { + grpc { + artifact = 'io.grpc:protoc-gen-grpc-java:1.79.0' + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} diff --git a/services/order/src/main/resources/application-dev.yml b/services/order/src/main/resources/application-dev.yml new file mode 100644 index 00000000..312dc09f --- /dev/null +++ b/services/order/src/main/resources/application-dev.yml @@ -0,0 +1,52 @@ +spring: + application: + name: order-dev + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + +security: + trusted: + ips: + - 127.0.0.1 + - 0:0:0:0:0:0:0:1 + +inbox: + max-event-age-minutes: 5 diff --git a/services/order/src/main/resources/application-local.yml b/services/order/src/main/resources/application-local.yml index 04ab09ba..5bd5137b 100644 --- a/services/order/src/main/resources/application-local.yml +++ b/services/order/src/main/resources/application-local.yml @@ -1,9 +1,59 @@ spring: application: name: order-local + jackson: + property-naming-strategy: SNAKE_CASE config: import: - file:services/order/env/local.env[.properties] + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext server: - port: ${SERVER_PORT} \ No newline at end of file + port: ${SERVER_PORT} + +security: + trusted: + ips: + - 127.0.0.1 + - 0:0:0:0:0:0:0:1 + +inbox: + max-event-age-minutes: 5 diff --git a/services/order/src/main/resources/application-prod.yml b/services/order/src/main/resources/application-prod.yml new file mode 100644 index 00000000..eca07210 --- /dev/null +++ b/services/order/src/main/resources/application-prod.yml @@ -0,0 +1,44 @@ +spring: + application: + name: order-prod + jackson: + property-naming-strategy: SNAKE_CASE + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: true + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 5 + backoff: + delay: 1s + max-delay: 10s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + +server: + port: ${SERVER_PORT} + +security: + trusted: + ips: ${TRUSTED_IPS} + +inbox: + max-event-age-minutes: 5 From 616e03cb2793d365dd3506902aff5c74b01a0d23 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:39 +0900 Subject: [PATCH 005/116] =?UTF-8?q?feat/117=20::=20Release=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20Aggregate,=20VO,=20=EC=98=88=EC=99=B8,=20?= =?UTF-8?q?=EA=B8=80=EB=A1=9C=EB=B2=8C=20=EC=98=88=EC=99=B8=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../release/domain/aggregate/Release.java | 138 ++++++++++++++++++ .../release/domain/enums/ReleaseLevel.java | 7 + .../release/domain/enums/ReleaseStatus.java | 8 + .../exception/InvalidFieldException.java | 11 ++ .../exception/ReleaseNotFoundException.java | 12 ++ .../ReleaseStatusConflictException.java | 12 ++ .../magicbox/release/domain/vo/CreatorId.java | 14 ++ .../magicbox/release/domain/vo/ReleaseId.java | 14 ++ .../kr/magicbox/release/domain/vo/UserId.java | 14 ++ .../global/exception/BaseException.java | 20 +++ .../global/exception/BusinessException.java | 17 +++ .../release/global/exception/SystemError.java | 21 +++ 12 files changed, 288 insertions(+) create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/aggregate/Release.java create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/enums/ReleaseLevel.java create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/enums/ReleaseStatus.java create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/exception/InvalidFieldException.java create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/exception/ReleaseNotFoundException.java create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/exception/ReleaseStatusConflictException.java create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/vo/CreatorId.java create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/vo/ReleaseId.java create mode 100644 services/release/src/main/java/kr/magicbox/release/domain/vo/UserId.java create mode 100644 services/release/src/main/java/kr/magicbox/release/global/exception/BaseException.java create mode 100644 services/release/src/main/java/kr/magicbox/release/global/exception/BusinessException.java create mode 100644 services/release/src/main/java/kr/magicbox/release/global/exception/SystemError.java diff --git a/services/release/src/main/java/kr/magicbox/release/domain/aggregate/Release.java b/services/release/src/main/java/kr/magicbox/release/domain/aggregate/Release.java new file mode 100644 index 00000000..8d355af6 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/aggregate/Release.java @@ -0,0 +1,138 @@ +package kr.magicbox.release.domain.aggregate; + +import kr.magicbox.release.domain.enums.ReleaseLevel; +import kr.magicbox.release.domain.enums.ReleaseStatus; +import kr.magicbox.release.domain.exception.InvalidFieldException; +import kr.magicbox.release.domain.exception.ReleaseStatusConflictException; +import kr.magicbox.release.domain.vo.CreatorId; +import kr.magicbox.release.domain.vo.ReleaseId; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; + +@Getter +public class Release { + + private final ReleaseId id; + private final CreatorId creatorId; + private final String title; + private final String description; + private final String thumbnailUrl; + private final ReleaseLevel level; + private ReleaseStatus status; + private final Long price; + private final Integer limitedQuantity; + private Integer soldQuantity; + private final Instant scheduledAt; + private final Instant createdAt; + private Instant updatedAt; + + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public Release(CreatorId creatorId, String title, String description, + String thumbnailUrl, ReleaseLevel level, Long price, + Integer limitedQuantity, Instant scheduledAt) { + validateCreate(creatorId, title, price, limitedQuantity, scheduledAt); + this.id = null; + this.creatorId = creatorId; + this.title = title; + this.description = description; + this.thumbnailUrl = thumbnailUrl; + this.level = level != null ? level : ReleaseLevel.BEGINNER; + this.status = ReleaseStatus.SCHEDULED; + this.price = price; + this.limitedQuantity = limitedQuantity; + this.soldQuantity = 0; + this.scheduledAt = scheduledAt; + this.createdAt = Instant.now(); + this.updatedAt = Instant.now(); + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public Release(ReleaseId id, CreatorId creatorId, String title, String description, + String thumbnailUrl, ReleaseLevel level, ReleaseStatus status, + Long price, Integer limitedQuantity, Integer soldQuantity, + Instant scheduledAt, Instant createdAt, Instant updatedAt) { + validateReconstruct(id, creatorId, title, level, status, price, limitedQuantity, soldQuantity, + scheduledAt, createdAt, updatedAt); + this.id = id; + this.creatorId = creatorId; + this.title = title; + this.description = description; + this.thumbnailUrl = thumbnailUrl; + this.level = level; + this.status = status; + this.price = price; + this.limitedQuantity = limitedQuantity; + this.soldQuantity = soldQuantity; + this.scheduledAt = scheduledAt; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + private void validateCreate(CreatorId creatorId, String title, Long price, + Integer limitedQuantity, Instant scheduledAt) { + if (creatorId == null) throw new InvalidFieldException("크리에이터 ID는 필수입니다."); + if (title == null || title.isBlank()) throw new InvalidFieldException("제목은 필수입니다."); + if (price == null || price <= 0) throw new InvalidFieldException("가격은 양수여야 합니다."); + if (limitedQuantity == null || limitedQuantity <= 0) throw new InvalidFieldException("한정 수량은 양수여야 합니다."); + if (scheduledAt == null) throw new InvalidFieldException("판매 예정 시각은 필수입니다."); + } + + private void validateReconstruct(ReleaseId id, CreatorId creatorId, String title, + ReleaseLevel level, ReleaseStatus status, + Long price, Integer limitedQuantity, Integer soldQuantity, + Instant scheduledAt, Instant createdAt, Instant updatedAt) { + if (id == null) throw new InvalidFieldException("릴리즈 ID는 필수입니다."); + if (creatorId == null) throw new InvalidFieldException("크리에이터 ID는 필수입니다."); + if (title == null || title.isBlank()) throw new InvalidFieldException("제목은 필수입니다."); + if (level == null) throw new InvalidFieldException("레벨은 필수입니다."); + if (status == null) throw new InvalidFieldException("상태는 필수입니다."); + if (price == null || price <= 0) throw new InvalidFieldException("가격은 양수여야 합니다."); + if (limitedQuantity == null || limitedQuantity <= 0) throw new InvalidFieldException("한정 수량은 양수여야 합니다."); + if (soldQuantity == null || soldQuantity < 0) throw new InvalidFieldException("판매 수량은 0 이상이어야 합니다."); + if (scheduledAt == null) throw new InvalidFieldException("판매 예정 시각은 필수입니다."); + if (createdAt == null) throw new InvalidFieldException("생성 시각은 필수입니다."); + if (updatedAt == null) throw new InvalidFieldException("수정 시각은 필수입니다."); + } + + public void startSale() { + if (this.status != ReleaseStatus.SCHEDULED) { + throw new ReleaseStatusConflictException("판매 예정 상태에서만 판매를 시작할 수 있습니다. 현재: " + this.status); + } + this.status = ReleaseStatus.ON_SALE; + this.updatedAt = Instant.now(); + } + + public boolean increaseSoldQuantity() { + if (this.status != ReleaseStatus.ON_SALE) { + throw new ReleaseStatusConflictException("판매 중 상태에서만 판매 수량을 증가할 수 있습니다. 현재: " + this.status); + } + this.soldQuantity++; + this.updatedAt = Instant.now(); + if (this.soldQuantity.equals(this.limitedQuantity)) { + this.status = ReleaseStatus.SOLD_OUT; + } + return this.status == ReleaseStatus.SOLD_OUT; + } + + public void soldOut() { + if (this.status != ReleaseStatus.ON_SALE) { + throw new ReleaseStatusConflictException("판매 중 상태에서만 매진 처리할 수 있습니다. 현재: " + this.status); + } + this.status = ReleaseStatus.SOLD_OUT; + this.updatedAt = Instant.now(); + } + + public void endSale() { + if (this.status != ReleaseStatus.ON_SALE && this.status != ReleaseStatus.SOLD_OUT) { + throw new ReleaseStatusConflictException("판매 중 또는 매진 상태에서만 판매를 종료할 수 있습니다. 현재: " + this.status); + } + this.status = ReleaseStatus.ENDED; + this.updatedAt = Instant.now(); + } + + public boolean isOnSale() { + return this.status == ReleaseStatus.ON_SALE; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/domain/enums/ReleaseLevel.java b/services/release/src/main/java/kr/magicbox/release/domain/enums/ReleaseLevel.java new file mode 100644 index 00000000..74a63217 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/enums/ReleaseLevel.java @@ -0,0 +1,7 @@ +package kr.magicbox.release.domain.enums; + +public enum ReleaseLevel { + BEGINNER, + INTERMEDIATE, + ADVANCED +} diff --git a/services/release/src/main/java/kr/magicbox/release/domain/enums/ReleaseStatus.java b/services/release/src/main/java/kr/magicbox/release/domain/enums/ReleaseStatus.java new file mode 100644 index 00000000..6ee0b6da --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/enums/ReleaseStatus.java @@ -0,0 +1,8 @@ +package kr.magicbox.release.domain.enums; + +public enum ReleaseStatus { + SCHEDULED, // 판매 예정 + ON_SALE, // 판매 중 + SOLD_OUT, // 매진 + ENDED // 판매 종료 +} diff --git a/services/release/src/main/java/kr/magicbox/release/domain/exception/InvalidFieldException.java b/services/release/src/main/java/kr/magicbox/release/domain/exception/InvalidFieldException.java new file mode 100644 index 00000000..0fa73e67 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/exception/InvalidFieldException.java @@ -0,0 +1,11 @@ +package kr.magicbox.release.domain.exception; + +import kr.magicbox.release.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class InvalidFieldException extends BusinessException { + + public InvalidFieldException(String message) { + super(message, HttpStatus.BAD_REQUEST); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/domain/exception/ReleaseNotFoundException.java b/services/release/src/main/java/kr/magicbox/release/domain/exception/ReleaseNotFoundException.java new file mode 100644 index 00000000..bd05fb89 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/exception/ReleaseNotFoundException.java @@ -0,0 +1,12 @@ +package kr.magicbox.release.domain.exception; + +import kr.magicbox.release.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class ReleaseNotFoundException extends BusinessException { + + public ReleaseNotFoundException() { + super("릴리즈를 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/domain/exception/ReleaseStatusConflictException.java b/services/release/src/main/java/kr/magicbox/release/domain/exception/ReleaseStatusConflictException.java new file mode 100644 index 00000000..8bf75f8e --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/exception/ReleaseStatusConflictException.java @@ -0,0 +1,12 @@ +package kr.magicbox.release.domain.exception; + +import kr.magicbox.release.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class ReleaseStatusConflictException extends BusinessException { + + public ReleaseStatusConflictException(String message) { + super(message, HttpStatus.CONFLICT); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/domain/vo/CreatorId.java b/services/release/src/main/java/kr/magicbox/release/domain/vo/CreatorId.java new file mode 100644 index 00000000..f689b6c7 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/vo/CreatorId.java @@ -0,0 +1,14 @@ +package kr.magicbox.release.domain.vo; + +import kr.magicbox.release.domain.exception.InvalidFieldException; + +public record CreatorId(Long value) { + + public CreatorId { + if (value == null || value <= 0) throw new InvalidFieldException("크리에이터 ID는 양수여야 합니다."); + } + + public static CreatorId of(Long value) { + return new CreatorId(value); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/domain/vo/ReleaseId.java b/services/release/src/main/java/kr/magicbox/release/domain/vo/ReleaseId.java new file mode 100644 index 00000000..60f62afc --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/vo/ReleaseId.java @@ -0,0 +1,14 @@ +package kr.magicbox.release.domain.vo; + +import kr.magicbox.release.domain.exception.InvalidFieldException; + +public record ReleaseId(Long value) { + + public ReleaseId { + if (value == null || value <= 0) throw new InvalidFieldException("릴리즈 ID는 양수여야 합니다."); + } + + public static ReleaseId of(Long value) { + return new ReleaseId(value); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/domain/vo/UserId.java b/services/release/src/main/java/kr/magicbox/release/domain/vo/UserId.java new file mode 100644 index 00000000..e59408e4 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/domain/vo/UserId.java @@ -0,0 +1,14 @@ +package kr.magicbox.release.domain.vo; + +import kr.magicbox.release.domain.exception.InvalidFieldException; + +public record UserId(Long value) { + + public UserId { + if (value == null || value <= 0) throw new InvalidFieldException("사용자 ID는 양수여야 합니다."); + } + + public static UserId of(Long value) { + return new UserId(value); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/global/exception/BaseException.java b/services/release/src/main/java/kr/magicbox/release/global/exception/BaseException.java new file mode 100644 index 00000000..b16151ea --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/global/exception/BaseException.java @@ -0,0 +1,20 @@ +package kr.magicbox.release.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class BaseException extends RuntimeException { + + private final HttpStatus status; + + public BaseException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public BaseException(String message, HttpStatus status, Throwable cause) { + super(message, cause); + this.status = status; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/global/exception/BusinessException.java b/services/release/src/main/java/kr/magicbox/release/global/exception/BusinessException.java new file mode 100644 index 00000000..71db25fb --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/global/exception/BusinessException.java @@ -0,0 +1,17 @@ +package kr.magicbox.release.global.exception; + +import org.springframework.http.HttpStatus; + +public class BusinessException extends BaseException { + + public BusinessException(String message, HttpStatus status) { + super(message, validateStatus(status)); + } + + private static HttpStatus validateStatus(HttpStatus status) { + if (!status.is4xxClientError()) { + throw new SystemError("클라이언트 에러가 아닙니다.", HttpStatus.INTERNAL_SERVER_ERROR); + } + return status; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/global/exception/SystemError.java b/services/release/src/main/java/kr/magicbox/release/global/exception/SystemError.java new file mode 100644 index 00000000..d910143b --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/global/exception/SystemError.java @@ -0,0 +1,21 @@ +package kr.magicbox.release.global.exception; + +import org.springframework.http.HttpStatus; + +public class SystemError extends BaseException { + + public SystemError(String message, HttpStatus status) { + super(message, validateStatus(status)); + } + + public SystemError(String message, HttpStatus status, Throwable cause) { + super(message, validateStatus(status), cause); + } + + private static HttpStatus validateStatus(HttpStatus status) { + if (!status.is5xxServerError()) { + throw new SystemError("서버에러가 아닙니다.", HttpStatus.INTERNAL_SERVER_ERROR); + } + return status; + } +} From 87cf732a293c44e898f9ea23292bcc2f5a3f0f63 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:39 +0900 Subject: [PATCH 006/116] =?UTF-8?q?feat/117=20::=20Release=20UseCase,=20Po?= =?UTF-8?q?rt,=20Service,=20DTO=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dto/command/RegisterReleaseCommand.java | 19 ++++++++ .../dto/query/GetReleaseQuery.java | 6 +++ .../application/dto/result/ReleaseResult.java | 43 +++++++++++++++++++ .../port/in/AutoStartSaleUseCase.java | 5 +++ .../application/port/in/EndSaleUseCase.java | 7 +++ .../in/GetReleaseCountByCreatorUseCase.java | 7 +++ .../in/GetReleaseListByCreatorUseCase.java | 10 +++++ .../port/in/GetReleaseUseCase.java | 8 ++++ .../port/in/IncreaseSoldQuantityUseCase.java | 7 +++ .../port/in/RegisterReleaseUseCase.java | 7 +++ .../application/port/in/StartSaleUseCase.java | 7 +++ .../port/out/CreatorIdQueryPort.java | 8 ++++ .../port/out/ReleaseRepositoryPort.java | 23 ++++++++++ .../service/AutoStartSaleService.java | 28 ++++++++++++ .../application/service/EndSaleService.java | 24 +++++++++++ .../GetReleaseCountByCreatorService.java | 21 +++++++++ .../GetReleaseListByCreatorService.java | 27 ++++++++++++ .../service/GetReleaseService.java | 25 +++++++++++ .../service/IncreaseSoldQuantityService.java | 24 +++++++++++ .../service/RegisterReleaseService.java | 37 ++++++++++++++++ .../application/service/StartSaleService.java | 24 +++++++++++ 21 files changed, 367 insertions(+) create mode 100644 services/release/src/main/java/kr/magicbox/release/application/dto/command/RegisterReleaseCommand.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/dto/query/GetReleaseQuery.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/dto/result/ReleaseResult.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/AutoStartSaleUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/EndSaleUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseCountByCreatorUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseListByCreatorUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/IncreaseSoldQuantityUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/RegisterReleaseUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/StartSaleUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/out/CreatorIdQueryPort.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/out/ReleaseRepositoryPort.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleService.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/EndSaleService.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseCountByCreatorService.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseListByCreatorService.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseService.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/IncreaseSoldQuantityService.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseService.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/StartSaleService.java diff --git a/services/release/src/main/java/kr/magicbox/release/application/dto/command/RegisterReleaseCommand.java b/services/release/src/main/java/kr/magicbox/release/application/dto/command/RegisterReleaseCommand.java new file mode 100644 index 00000000..5d7598d0 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/dto/command/RegisterReleaseCommand.java @@ -0,0 +1,19 @@ +package kr.magicbox.release.application.dto.command; + +import kr.magicbox.release.domain.enums.ReleaseLevel; +import kr.magicbox.release.domain.vo.UserId; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record RegisterReleaseCommand( + UserId userId, + String title, + String description, + String thumbnailUrl, + ReleaseLevel level, + Long price, + Integer limitedQuantity, + Instant scheduledAt +) {} diff --git a/services/release/src/main/java/kr/magicbox/release/application/dto/query/GetReleaseQuery.java b/services/release/src/main/java/kr/magicbox/release/application/dto/query/GetReleaseQuery.java new file mode 100644 index 00000000..87054ec9 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/dto/query/GetReleaseQuery.java @@ -0,0 +1,6 @@ +package kr.magicbox.release.application.dto.query; + +import lombok.Builder; + +@Builder +public record GetReleaseQuery(Long releaseId) {} diff --git a/services/release/src/main/java/kr/magicbox/release/application/dto/result/ReleaseResult.java b/services/release/src/main/java/kr/magicbox/release/application/dto/result/ReleaseResult.java new file mode 100644 index 00000000..68486365 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/dto/result/ReleaseResult.java @@ -0,0 +1,43 @@ +package kr.magicbox.release.application.dto.result; + +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.enums.ReleaseLevel; +import kr.magicbox.release.domain.enums.ReleaseStatus; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record ReleaseResult( + Long releaseId, + Long creatorId, + String title, + String description, + String thumbnailUrl, + ReleaseLevel level, + ReleaseStatus status, + Long price, + Integer limitedQuantity, + Integer soldQuantity, + Instant scheduledAt, + Instant createdAt, + Instant updatedAt +) { + public static ReleaseResult from(Release release) { + return ReleaseResult.builder() + .releaseId(release.getId().value()) + .creatorId(release.getCreatorId().value()) + .title(release.getTitle()) + .description(release.getDescription()) + .thumbnailUrl(release.getThumbnailUrl()) + .level(release.getLevel()) + .status(release.getStatus()) + .price(release.getPrice()) + .limitedQuantity(release.getLimitedQuantity()) + .soldQuantity(release.getSoldQuantity()) + .scheduledAt(release.getScheduledAt()) + .createdAt(release.getCreatedAt()) + .updatedAt(release.getUpdatedAt()) + .build(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/AutoStartSaleUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/AutoStartSaleUseCase.java new file mode 100644 index 00000000..899edf5a --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/AutoStartSaleUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.release.application.port.in; + +public interface AutoStartSaleUseCase { + void autoStartScheduledReleases(); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/EndSaleUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/EndSaleUseCase.java new file mode 100644 index 00000000..1283099b --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/EndSaleUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.release.application.port.in; + +import kr.magicbox.release.domain.vo.ReleaseId; + +public interface EndSaleUseCase { + void endSale(ReleaseId releaseId); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseCountByCreatorUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseCountByCreatorUseCase.java new file mode 100644 index 00000000..4c2dd633 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseCountByCreatorUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.release.application.port.in; + +import kr.magicbox.release.domain.vo.CreatorId; + +public interface GetReleaseCountByCreatorUseCase { + long getReleaseCount(CreatorId creatorId); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseListByCreatorUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseListByCreatorUseCase.java new file mode 100644 index 00000000..fe5aae6e --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseListByCreatorUseCase.java @@ -0,0 +1,10 @@ +package kr.magicbox.release.application.port.in; + +import kr.magicbox.release.application.dto.result.ReleaseResult; +import kr.magicbox.release.domain.vo.CreatorId; + +import java.util.List; + +public interface GetReleaseListByCreatorUseCase { + List getReleaseListByCreator(CreatorId creatorId); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseUseCase.java new file mode 100644 index 00000000..16853f55 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/GetReleaseUseCase.java @@ -0,0 +1,8 @@ +package kr.magicbox.release.application.port.in; + +import kr.magicbox.release.application.dto.query.GetReleaseQuery; +import kr.magicbox.release.application.dto.result.ReleaseResult; + +public interface GetReleaseUseCase { + ReleaseResult getRelease(GetReleaseQuery query); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/IncreaseSoldQuantityUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/IncreaseSoldQuantityUseCase.java new file mode 100644 index 00000000..375d4333 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/IncreaseSoldQuantityUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.release.application.port.in; + +import kr.magicbox.release.domain.vo.ReleaseId; + +public interface IncreaseSoldQuantityUseCase { + void increaseSoldQuantity(ReleaseId releaseId); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/RegisterReleaseUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/RegisterReleaseUseCase.java new file mode 100644 index 00000000..71dfc3ab --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/RegisterReleaseUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.release.application.port.in; + +import kr.magicbox.release.application.dto.command.RegisterReleaseCommand; + +public interface RegisterReleaseUseCase { + Long registerRelease(RegisterReleaseCommand command); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/StartSaleUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/StartSaleUseCase.java new file mode 100644 index 00000000..774ec470 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/StartSaleUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.release.application.port.in; + +import kr.magicbox.release.domain.vo.ReleaseId; + +public interface StartSaleUseCase { + void startSale(ReleaseId releaseId); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/out/CreatorIdQueryPort.java b/services/release/src/main/java/kr/magicbox/release/application/port/out/CreatorIdQueryPort.java new file mode 100644 index 00000000..01ef54d5 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/out/CreatorIdQueryPort.java @@ -0,0 +1,8 @@ +package kr.magicbox.release.application.port.out; + +import kr.magicbox.release.domain.vo.CreatorId; +import kr.magicbox.release.domain.vo.UserId; + +public interface CreatorIdQueryPort { + CreatorId getCreatorId(UserId userId); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/out/ReleaseRepositoryPort.java b/services/release/src/main/java/kr/magicbox/release/application/port/out/ReleaseRepositoryPort.java new file mode 100644 index 00000000..f340d839 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/out/ReleaseRepositoryPort.java @@ -0,0 +1,23 @@ +package kr.magicbox.release.application.port.out; + +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.CreatorId; +import kr.magicbox.release.domain.vo.ReleaseId; + +import java.time.Instant; +import java.util.List; + +public interface ReleaseRepositoryPort { + + Long save(Release release); + + void update(Release release); + + Release findById(ReleaseId id); + + List findByCreatorId(CreatorId creatorId); + + long countByCreatorId(CreatorId creatorId); + + List findScheduledBefore(Instant scheduledAt); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleService.java b/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleService.java new file mode 100644 index 00000000..56ca85d4 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleService.java @@ -0,0 +1,28 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.port.in.AutoStartSaleUseCase; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AutoStartSaleService implements AutoStartSaleUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Override + @Transactional + public void autoStartScheduledReleases() { + List releases = releaseRepositoryPort.findScheduledBefore(Instant.now()); + releases.forEach(release -> { + release.startSale(); + releaseRepositoryPort.update(release); + }); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/EndSaleService.java b/services/release/src/main/java/kr/magicbox/release/application/service/EndSaleService.java new file mode 100644 index 00000000..941ee95b --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/EndSaleService.java @@ -0,0 +1,24 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.port.in.EndSaleUseCase; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.ReleaseId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class EndSaleService implements EndSaleUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Override + @Transactional + public void endSale(ReleaseId releaseId) { + Release release = releaseRepositoryPort.findById(releaseId); + release.endSale(); + releaseRepositoryPort.update(release); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseCountByCreatorService.java b/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseCountByCreatorService.java new file mode 100644 index 00000000..b215973f --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseCountByCreatorService.java @@ -0,0 +1,21 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.port.in.GetReleaseCountByCreatorUseCase; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.vo.CreatorId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GetReleaseCountByCreatorService implements GetReleaseCountByCreatorUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Override + @Transactional(readOnly = true) + public long getReleaseCount(CreatorId creatorId) { + return releaseRepositoryPort.countByCreatorId(creatorId); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseListByCreatorService.java b/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseListByCreatorService.java new file mode 100644 index 00000000..773b5460 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseListByCreatorService.java @@ -0,0 +1,27 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.dto.result.ReleaseResult; +import kr.magicbox.release.application.port.in.GetReleaseListByCreatorUseCase; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.vo.CreatorId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class GetReleaseListByCreatorService implements GetReleaseListByCreatorUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Override + @Transactional(readOnly = true) + public List getReleaseListByCreator(CreatorId creatorId) { + return releaseRepositoryPort.findByCreatorId(creatorId) + .stream() + .map(ReleaseResult::from) + .toList(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseService.java b/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseService.java new file mode 100644 index 00000000..7705ca34 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/GetReleaseService.java @@ -0,0 +1,25 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.dto.query.GetReleaseQuery; +import kr.magicbox.release.application.dto.result.ReleaseResult; +import kr.magicbox.release.application.port.in.GetReleaseUseCase; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.ReleaseId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GetReleaseService implements GetReleaseUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Override + @Transactional(readOnly = true) + public ReleaseResult getRelease(GetReleaseQuery query) { + Release release = releaseRepositoryPort.findById(ReleaseId.of(query.releaseId())); + return ReleaseResult.from(release); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/IncreaseSoldQuantityService.java b/services/release/src/main/java/kr/magicbox/release/application/service/IncreaseSoldQuantityService.java new file mode 100644 index 00000000..49516a43 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/IncreaseSoldQuantityService.java @@ -0,0 +1,24 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.port.in.IncreaseSoldQuantityUseCase; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.ReleaseId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class IncreaseSoldQuantityService implements IncreaseSoldQuantityUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Override + @Transactional + public void increaseSoldQuantity(ReleaseId releaseId) { + Release release = releaseRepositoryPort.findById(releaseId); + release.increaseSoldQuantity(); + releaseRepositoryPort.update(release); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseService.java b/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseService.java new file mode 100644 index 00000000..844dbecd --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseService.java @@ -0,0 +1,37 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.dto.command.RegisterReleaseCommand; +import kr.magicbox.release.application.port.in.RegisterReleaseUseCase; +import kr.magicbox.release.application.port.out.CreatorIdQueryPort; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.CreatorId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RegisterReleaseService implements RegisterReleaseUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + private final CreatorIdQueryPort creatorIdQueryPort; + + @Override + @Transactional + public Long registerRelease(RegisterReleaseCommand command) { + CreatorId creatorId = creatorIdQueryPort.getCreatorId(command.userId()); + + Release release = Release.createBuilder() + .creatorId(creatorId) + .title(command.title()) + .description(command.description()) + .thumbnailUrl(command.thumbnailUrl()) + .level(command.level()) + .price(command.price()) + .limitedQuantity(command.limitedQuantity()) + .scheduledAt(command.scheduledAt()) + .build(); + return releaseRepositoryPort.save(release); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/StartSaleService.java b/services/release/src/main/java/kr/magicbox/release/application/service/StartSaleService.java new file mode 100644 index 00000000..303babdd --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/StartSaleService.java @@ -0,0 +1,24 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.port.in.StartSaleUseCase; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.ReleaseId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class StartSaleService implements StartSaleUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Override + @Transactional + public void startSale(ReleaseId releaseId) { + Release release = releaseRepositoryPort.findById(releaseId); + release.startSale(); + releaseRepositoryPort.update(release); + } +} From 56edccfc58832cef7f46cef17fb328c3502011e7 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:39 +0900 Subject: [PATCH 007/116] =?UTF-8?q?feat/117=20::=20Release=20=EC=96=B4?= =?UTF-8?q?=EB=8C=91=ED=84=B0=20(Web/gRPC/Persistence/Security/Scheduler)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../in/grpc/GrpcExceptionInterceptor.java | 47 +++++++ .../adapter/in/grpc/ReleaseGrpcService.java | 122 ++++++++++++++++++ .../in/scheduler/SchedulerConfiguration.java | 9 ++ .../in/scheduler/StartSaleScheduler.java | 22 ++++ .../configuration/SecurityConfiguration.java | 46 +++++++ .../filter/UserInfoExtractFilter.java | 53 ++++++++ .../properties/TrustedIpProperties.java | 14 ++ .../in/web/AdminReleaseCommandController.java | 32 +++++ .../in/web/ReleaseCommandController.java | 33 +++++ .../in/web/ReleaseQueryController.java | 24 ++++ .../dto/request/RegisterReleaseRequest.java | 37 ++++++ .../in/web/dto/response/ReleaseResponse.java | 43 ++++++ .../web/exception/handler/ErrorResponse.java | 15 +++ .../handler/GlobalExceptionHandler.java | 87 +++++++++++++ .../ScheduledAtMultipleOfTenMinutes.java | 16 +++ ...eduledAtMultipleOfTenMinutesValidator.java | 20 +++ .../out/communication/ServiceHost.java | 12 ++ .../grpc/CreatorGrpcAdapter.java | 51 ++++++++ .../communication/grpc/GrpcConfiguration.java | 16 +++ .../exception/CreatorNotFoundException.java | 12 ++ .../CreatorServiceUnavailableException.java | 12 ++ .../out/persistence/ReleaseJpaAdapter.java | 66 ++++++++++ .../configuration/JpaConfiguration.java | 9 ++ .../out/persistence/entity/BaseEntity.java | 31 +++++ .../out/persistence/entity/ReleaseEntity.java | 74 +++++++++++ .../out/persistence/mapper/ReleaseMapper.java | 44 +++++++ .../repository/ReleaseJpaRepository.java | 20 +++ services/release/src/main/proto/creator.proto | 19 +++ services/release/src/main/proto/release.proto | 73 +++++++++++ 29 files changed, 1059 insertions(+) create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/GrpcExceptionInterceptor.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/SchedulerConfiguration.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/StartSaleScheduler.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/security/configuration/SecurityConfiguration.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/security/filter/UserInfoExtractFilter.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/security/properties/TrustedIpProperties.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/AdminReleaseCommandController.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/ReleaseCommandController.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/ReleaseQueryController.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/dto/request/RegisterReleaseRequest.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/dto/response/ReleaseResponse.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/exception/handler/ErrorResponse.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/exception/handler/GlobalExceptionHandler.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutes.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/communication/ServiceHost.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/CreatorGrpcAdapter.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/GrpcConfiguration.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/exception/CreatorNotFoundException.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/exception/CreatorServiceUnavailableException.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/ReleaseJpaAdapter.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/configuration/JpaConfiguration.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/BaseEntity.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseEntity.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/mapper/ReleaseMapper.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseJpaRepository.java create mode 100644 services/release/src/main/proto/creator.proto create mode 100644 services/release/src/main/proto/release.proto diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/GrpcExceptionInterceptor.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/GrpcExceptionInterceptor.java new file mode 100644 index 00000000..cc66b0e1 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/GrpcExceptionInterceptor.java @@ -0,0 +1,47 @@ +package kr.magicbox.release.adapter.in.grpc; + +import io.grpc.*; +import kr.magicbox.release.global.exception.BusinessException; +import kr.magicbox.release.global.exception.SystemError; +import lombok.extern.slf4j.Slf4j; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@GlobalServerInterceptor +public class GrpcExceptionInterceptor implements ServerInterceptor { + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + ServerCall.Listener delegate = next.startCall(call, headers); + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(delegate) { + @Override + public void onHalfClose() { + try { + super.onHalfClose(); + } catch (BusinessException e) { + log.warn("gRPC 비즈니스 예외: {}", e.getMessage()); + call.close(toGrpcStatus(e).withDescription(e.getMessage()), new Metadata()); + } catch (SystemError e) { + log.error("gRPC 시스템 오류: {}", e.getMessage(), e); + call.close(Status.INTERNAL.withDescription("서버 내부 오류가 발생했습니다."), new Metadata()); + } catch (Exception e) { + log.error("gRPC 예상치 못한 예외: {}", e.getMessage(), e); + call.close(Status.UNKNOWN.withDescription("예상치 못한 오류가 발생했습니다."), new Metadata()); + } + } + }; + } + + private Status toGrpcStatus(BusinessException e) { + return switch (e.getStatus()) { + case NOT_FOUND -> Status.NOT_FOUND; + case CONFLICT -> Status.ALREADY_EXISTS; + case FORBIDDEN -> Status.PERMISSION_DENIED; + case UNAUTHORIZED -> Status.UNAUTHENTICATED; + default -> Status.INVALID_ARGUMENT; + }; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java new file mode 100644 index 00000000..ff340355 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java @@ -0,0 +1,122 @@ +package kr.magicbox.release.adapter.in.grpc; + +import com.google.protobuf.Timestamp; +import io.grpc.stub.StreamObserver; +import kr.magicbox.release.application.dto.query.GetReleaseQuery; +import kr.magicbox.release.application.dto.result.ReleaseResult; +import kr.magicbox.release.application.port.in.GetReleaseCountByCreatorUseCase; +import kr.magicbox.release.application.port.in.GetReleaseListByCreatorUseCase; +import kr.magicbox.release.application.port.in.GetReleaseUseCase; +import kr.magicbox.release.application.port.in.IncreaseSoldQuantityUseCase; +import kr.magicbox.release.domain.enums.ReleaseStatus; +import kr.magicbox.release.domain.vo.CreatorId; +import kr.magicbox.release.domain.vo.ReleaseId; +import kr.magicbox.release.grpc.release.GetReleaseCountRequest; +import kr.magicbox.release.grpc.release.GetReleaseCountResponse; +import kr.magicbox.release.grpc.release.GetReleasesByCreatorIdRequest; +import kr.magicbox.release.grpc.release.GetReleasesByCreatorIdResponse; +import kr.magicbox.release.grpc.release.GetRemainingQuantityRequest; +import kr.magicbox.release.grpc.release.GetRemainingQuantityResponse; +import kr.magicbox.release.grpc.release.IncreaseSoldQuantityRequest; +import kr.magicbox.release.grpc.release.IncreaseSoldQuantityResponse; +import kr.magicbox.release.grpc.release.IsReleaseOnSaleRequest; +import kr.magicbox.release.grpc.release.IsReleaseOnSaleResponse; +import kr.magicbox.release.grpc.release.Release; +import kr.magicbox.release.grpc.release.ReleaseLevel; +import kr.magicbox.release.grpc.release.ReleaseServiceGrpc; +import lombok.RequiredArgsConstructor; +import org.springframework.grpc.server.service.GrpcService; + +import java.time.Instant; +import java.util.List; + +@GrpcService +@RequiredArgsConstructor +public class ReleaseGrpcService extends ReleaseServiceGrpc.ReleaseServiceImplBase { + + private final GetReleaseCountByCreatorUseCase getReleaseCountByCreatorUseCase; + private final GetReleaseListByCreatorUseCase getReleaseListByCreatorUseCase; + private final GetReleaseUseCase getReleaseUseCase; + private final IncreaseSoldQuantityUseCase increaseSoldQuantityUseCase; + + @Override + public void getReleaseCount(GetReleaseCountRequest request, + StreamObserver responseObserver) { + long count = getReleaseCountByCreatorUseCase.getReleaseCount(CreatorId.of(request.getCreatorId())); + responseObserver.onNext(GetReleaseCountResponse.newBuilder() + .setReleaseCount(count) + .build()); + responseObserver.onCompleted(); + } + + @Override + public void getReleasesByCreatorId(GetReleasesByCreatorIdRequest request, + StreamObserver responseObserver) { + List results = getReleaseListByCreatorUseCase.getReleaseListByCreator( + CreatorId.of(request.getCreatorId())); + + List releases = results.stream() + .map(this::toProtoRelease) + .toList(); + + responseObserver.onNext(GetReleasesByCreatorIdResponse.newBuilder() + .addAllReleases(releases) + .build()); + responseObserver.onCompleted(); + } + + @Override + public void isReleaseOnSale(IsReleaseOnSaleRequest request, + StreamObserver responseObserver) { + ReleaseResult result = getReleaseUseCase.getRelease( + GetReleaseQuery.builder().releaseId(request.getReleaseId()).build()); + boolean onSale = result.status() == ReleaseStatus.ON_SALE; + responseObserver.onNext(IsReleaseOnSaleResponse.newBuilder() + .setOnSale(onSale) + .build()); + responseObserver.onCompleted(); + } + + @Override + public void getRemainingQuantity(GetRemainingQuantityRequest request, + StreamObserver responseObserver) { + ReleaseResult result = getReleaseUseCase.getRelease( + GetReleaseQuery.builder().releaseId(request.getReleaseId()).build()); + int remaining = result.limitedQuantity() - result.soldQuantity(); + responseObserver.onNext(GetRemainingQuantityResponse.newBuilder() + .setRemainingQuantity(remaining) + .build()); + responseObserver.onCompleted(); + } + + @Override + public void increaseSoldQuantity(IncreaseSoldQuantityRequest request, + StreamObserver responseObserver) { + increaseSoldQuantityUseCase.increaseSoldQuantity(ReleaseId.of(request.getReleaseId())); + responseObserver.onNext(IncreaseSoldQuantityResponse.newBuilder().build()); + responseObserver.onCompleted(); + } + + private Release toProtoRelease(ReleaseResult result) { + Instant scheduledAt = result.scheduledAt(); + return Release.newBuilder() + .setReleaseId(result.releaseId()) + .setTitle(result.title()) + .setThumbnailUrl(result.thumbnailUrl() != null ? result.thumbnailUrl() : "") + .setLevel(toProtoLevel(result.level())) + .setPrice(result.price()) + .setCreatedAt(Timestamp.newBuilder() + .setSeconds(scheduledAt.getEpochSecond()) + .setNanos(scheduledAt.getNano()) + .build()) + .build(); + } + + private ReleaseLevel toProtoLevel(kr.magicbox.release.domain.enums.ReleaseLevel level) { + return switch (level) { + case BEGINNER -> ReleaseLevel.BEGINNER; + case INTERMEDIATE -> ReleaseLevel.INTERMEDIATE; + case ADVANCED -> ReleaseLevel.ADVANCED; + }; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/SchedulerConfiguration.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/SchedulerConfiguration.java new file mode 100644 index 00000000..6c90151f --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/SchedulerConfiguration.java @@ -0,0 +1,9 @@ +package kr.magicbox.release.adapter.in.scheduler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfiguration { +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/StartSaleScheduler.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/StartSaleScheduler.java new file mode 100644 index 00000000..474a079d --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/StartSaleScheduler.java @@ -0,0 +1,22 @@ +package kr.magicbox.release.adapter.in.scheduler; + +import kr.magicbox.release.application.port.in.AutoStartSaleUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StartSaleScheduler { + + private final AutoStartSaleUseCase autoStartSaleUseCase; + + @Scheduled(fixedDelay = 60000) + public void autoStartScheduledReleases() { + log.info("[Scheduler] 판매 예정 릴리즈 자동 오픈 시작"); + autoStartSaleUseCase.autoStartScheduledReleases(); + log.info("[Scheduler] 판매 예정 릴리즈 자동 오픈 완료"); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/security/configuration/SecurityConfiguration.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/security/configuration/SecurityConfiguration.java new file mode 100644 index 00000000..0171e2e6 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/security/configuration/SecurityConfiguration.java @@ -0,0 +1,46 @@ +package kr.magicbox.release.adapter.in.security.configuration; + +import kr.magicbox.release.adapter.in.security.filter.UserInfoExtractFilter; +import kr.magicbox.release.adapter.in.security.properties.TrustedIpProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.filter.ForwardedHeaderFilter; + +@Configuration +@EnableWebSecurity +@EnableConfigurationProperties(TrustedIpProperties.class) +@RequiredArgsConstructor +public class SecurityConfiguration { + + private final TrustedIpProperties trustedIpProperties; + + @Bean + public ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + @Bean + public UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(new UserInfoExtractFilter(trustedIpProperties), UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/security/filter/UserInfoExtractFilter.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/security/filter/UserInfoExtractFilter.java new file mode 100644 index 00000000..38bad241 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/security/filter/UserInfoExtractFilter.java @@ -0,0 +1,53 @@ +package kr.magicbox.release.adapter.in.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.magicbox.release.adapter.in.security.properties.TrustedIpProperties; +import kr.magicbox.release.domain.vo.UserId; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class UserInfoExtractFilter extends OncePerRequestFilter { + + private final TrustedIpProperties trustedIpProperties; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String clientIp = request.getRemoteAddr(); + + if (!trustedIpProperties.getIps().contains(clientIp)) { + filterChain.doFilter(request, response); + return; + } + + String userIdHeader = request.getHeader("X-User-Id"); + + if (!isValidUserId(userIdHeader)) { + filterChain.doFilter(request, response); + return; + } + + UserId userId = UserId.of(Long.valueOf(userIdHeader)); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userId, null); + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } + + private boolean isValidUserId(String userIdHeader) { + try { + return Long.parseLong(userIdHeader) > 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/security/properties/TrustedIpProperties.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/security/properties/TrustedIpProperties.java new file mode 100644 index 00000000..9af0a3f2 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/security/properties/TrustedIpProperties.java @@ -0,0 +1,14 @@ +package kr.magicbox.release.adapter.in.security.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "security.trusted") +public class TrustedIpProperties { + private final List ips; +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/AdminReleaseCommandController.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/AdminReleaseCommandController.java new file mode 100644 index 00000000..4177509b --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/AdminReleaseCommandController.java @@ -0,0 +1,32 @@ +package kr.magicbox.release.adapter.in.web; + +import kr.magicbox.release.application.port.in.EndSaleUseCase; +import kr.magicbox.release.application.port.in.StartSaleUseCase; +import kr.magicbox.release.domain.vo.ReleaseId; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/release") +@RequiredArgsConstructor +public class AdminReleaseCommandController { + + private final StartSaleUseCase startSaleUseCase; + private final EndSaleUseCase endSaleUseCase; + + @PostMapping("/{releaseId}/start-sale") + public ResponseEntity startSale(@PathVariable Long releaseId) { + startSaleUseCase.startSale(ReleaseId.of(releaseId)); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{releaseId}/end-sale") + public ResponseEntity endSale(@PathVariable Long releaseId) { + endSaleUseCase.endSale(ReleaseId.of(releaseId)); + return ResponseEntity.noContent().build(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/ReleaseCommandController.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/ReleaseCommandController.java new file mode 100644 index 00000000..4592dd2e --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/ReleaseCommandController.java @@ -0,0 +1,33 @@ +package kr.magicbox.release.adapter.in.web; + +import jakarta.validation.Valid; +import kr.magicbox.release.adapter.in.web.dto.request.RegisterReleaseRequest; +import kr.magicbox.release.application.port.in.RegisterReleaseUseCase; +import kr.magicbox.release.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +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.RestController; + +@RestController +@RequestMapping("/release") +@RequiredArgsConstructor +@Validated +public class ReleaseCommandController { + + private final RegisterReleaseUseCase registerReleaseUseCase; + + @PostMapping + public ResponseEntity registerRelease( + @AuthenticationPrincipal UserId userId, + @Valid @RequestBody RegisterReleaseRequest request + ) { + registerReleaseUseCase.registerRelease(request.toCommand(userId)); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/ReleaseQueryController.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/ReleaseQueryController.java new file mode 100644 index 00000000..a39aa585 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/ReleaseQueryController.java @@ -0,0 +1,24 @@ +package kr.magicbox.release.adapter.in.web; + +import kr.magicbox.release.adapter.in.web.dto.response.ReleaseResponse; +import kr.magicbox.release.application.dto.query.GetReleaseQuery; +import kr.magicbox.release.application.dto.result.ReleaseResult; +import kr.magicbox.release.application.port.in.GetReleaseUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/release") +@RequiredArgsConstructor +public class ReleaseQueryController { + + private final GetReleaseUseCase getReleaseUseCase; + + @GetMapping("/{releaseId}") + public ResponseEntity getRelease(@PathVariable Long releaseId) { + ReleaseResult result = getReleaseUseCase.getRelease( + GetReleaseQuery.builder().releaseId(releaseId).build()); + return ResponseEntity.ok(ReleaseResponse.from(result)); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/dto/request/RegisterReleaseRequest.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/dto/request/RegisterReleaseRequest.java new file mode 100644 index 00000000..3839ba60 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/dto/request/RegisterReleaseRequest.java @@ -0,0 +1,37 @@ +package kr.magicbox.release.adapter.in.web.dto.request; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import kr.magicbox.release.adapter.in.web.validation.ScheduledAtMultipleOfTenMinutes; +import kr.magicbox.release.application.dto.command.RegisterReleaseCommand; +import kr.magicbox.release.domain.enums.ReleaseLevel; +import kr.magicbox.release.domain.vo.UserId; + +import java.time.Instant; + +public record RegisterReleaseRequest( + @NotBlank(message = "제목은 필수입니다.") String title, + String description, + String thumbnailUrl, + ReleaseLevel level, + @NotNull(message = "가격은 필수입니다.") @Min(value = 1, message = "가격은 1원 이상이어야 합니다.") Long price, + @NotNull(message = "한정 수량은 필수입니다.") @Min(value = 1, message = "한정 수량은 1 이상이어야 합니다.") Integer limitedQuantity, + @NotNull(message = "판매 예정 시각은 필수입니다.") + @Future(message = "판매 예정 시각은 미래여야 합니다.") + @ScheduledAtMultipleOfTenMinutes Instant scheduledAt +) { + public RegisterReleaseCommand toCommand(UserId userId) { + return RegisterReleaseCommand.builder() + .userId(userId) + .title(title) + .description(description) + .thumbnailUrl(thumbnailUrl) + .level(level) + .price(price) + .limitedQuantity(limitedQuantity) + .scheduledAt(scheduledAt) + .build(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/dto/response/ReleaseResponse.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/dto/response/ReleaseResponse.java new file mode 100644 index 00000000..9d8460f8 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/dto/response/ReleaseResponse.java @@ -0,0 +1,43 @@ +package kr.magicbox.release.adapter.in.web.dto.response; + +import kr.magicbox.release.application.dto.result.ReleaseResult; +import kr.magicbox.release.domain.enums.ReleaseLevel; +import kr.magicbox.release.domain.enums.ReleaseStatus; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record ReleaseResponse( + Long releaseId, + Long creatorId, + String title, + String description, + String thumbnailUrl, + ReleaseLevel level, + ReleaseStatus status, + Long price, + Integer limitedQuantity, + Integer soldQuantity, + Instant scheduledAt, + Instant createdAt, + Instant updatedAt +) { + public static ReleaseResponse from(ReleaseResult result) { + return ReleaseResponse.builder() + .releaseId(result.releaseId()) + .creatorId(result.creatorId()) + .title(result.title()) + .description(result.description()) + .thumbnailUrl(result.thumbnailUrl()) + .level(result.level()) + .status(result.status()) + .price(result.price()) + .limitedQuantity(result.limitedQuantity()) + .soldQuantity(result.soldQuantity()) + .scheduledAt(result.scheduledAt()) + .createdAt(result.createdAt()) + .updatedAt(result.updatedAt()) + .build(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/exception/handler/ErrorResponse.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/exception/handler/ErrorResponse.java new file mode 100644 index 00000000..4b895122 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/exception/handler/ErrorResponse.java @@ -0,0 +1,15 @@ +package kr.magicbox.release.adapter.in.web.exception.handler; + +import lombok.Builder; +import org.springframework.http.HttpStatus; + +@Builder +public record ErrorResponse(int status, String message) { + + public static ErrorResponse of(HttpStatus status, String message) { + return ErrorResponse.builder() + .status(status.value()) + .message(message) + .build(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/exception/handler/GlobalExceptionHandler.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..370ada45 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,87 @@ +package kr.magicbox.release.adapter.in.web.exception.handler; + +import jakarta.validation.ConstraintViolationException; +import kr.magicbox.release.global.exception.BaseException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException() { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ErrorResponse.of(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다.")); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("요청 본문을 읽을 수 없습니다: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, "요청 본문을 읽을 수 없습니다.")); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String errorMessage = e.getBindingResult().getFieldError() != null ? + e.getBindingResult().getFieldError().getDefaultMessage() : "인자값이 유효하지 않습니다."; + log.error("요청 데이터 유효성 검증 실패: {}", errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, errorMessage)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { + String errorMessage = e.getConstraintViolations().isEmpty() ? + "유효성 검증에 실패했습니다." : + e.getConstraintViolations().iterator().next().getMessage(); + log.error("유효성 검증 실패: {}", errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, errorMessage)); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.error("지원되지 않는 HTTP 메서드: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED, "지원되지 않는 HTTP 메서드입니다.")); + } + + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + public ResponseEntity handleOptimisticLockingFailureException(ObjectOptimisticLockingFailureException e) { + log.warn("동시 수정 충돌 발생: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ErrorResponse.of(HttpStatus.CONFLICT, "다른 요청과 충돌이 발생했습니다. 다시 시도해주세요.")); + } + + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(BaseException e) { + HttpStatus status = e.getStatus(); + return ResponseEntity + .status(status) + .body(ErrorResponse.of(status, e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("예상하지 못한 오류 발생: {}", e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 오류가 발생했습니다.")); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutes.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutes.java new file mode 100644 index 00000000..86ebc41b --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutes.java @@ -0,0 +1,16 @@ +package kr.magicbox.release.adapter.in.web.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = ScheduledAtMultipleOfTenMinutesValidator.class) +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ScheduledAtMultipleOfTenMinutes { + String message() default "판매 시작 시각은 10분 단위여야 합니다. (예: 14:00, 14:10, 14:20)"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java new file mode 100644 index 00000000..89e38254 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java @@ -0,0 +1,20 @@ +package kr.magicbox.release.adapter.in.web.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.time.Instant; +import java.time.ZoneOffset; + +public class ScheduledAtMultipleOfTenMinutesValidator + implements ConstraintValidator { + + @Override + public boolean isValid(Instant value, ConstraintValidatorContext context) { + if (value == null) return true; // null 체크는 @NotNull이 담당 + int minute = value.atZone(ZoneOffset.UTC).getMinute(); + int second = value.atZone(ZoneOffset.UTC).getSecond(); + int nano = value.getNano(); + return minute % 10 == 0 && second == 0 && nano == 0; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/ServiceHost.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/ServiceHost.java new file mode 100644 index 00000000..2a158e50 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/ServiceHost.java @@ -0,0 +1,12 @@ +package kr.magicbox.release.adapter.out.communication; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ServiceHost { + CREATOR("creator-service"); + + private final String hostName; +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/CreatorGrpcAdapter.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/CreatorGrpcAdapter.java new file mode 100644 index 00000000..bfc415fa --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/CreatorGrpcAdapter.java @@ -0,0 +1,51 @@ +package kr.magicbox.release.adapter.out.communication.grpc; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.grpc.ManagedChannel; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import kr.magicbox.release.adapter.out.communication.grpc.exception.CreatorNotFoundException; +import kr.magicbox.release.adapter.out.communication.grpc.exception.CreatorServiceUnavailableException; +import kr.magicbox.release.application.port.out.CreatorIdQueryPort; +import kr.magicbox.release.domain.vo.CreatorId; +import kr.magicbox.release.domain.vo.UserId; +import kr.magicbox.release.grpc.creator.CreatorServiceGrpc; +import kr.magicbox.release.grpc.creator.GetCreatorIdByUserIdRequest; +import kr.magicbox.release.grpc.creator.GetCreatorIdByUserIdResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CreatorGrpcAdapter implements CreatorIdQueryPort { + + private final ManagedChannel creatorManagedChannel; + + @Override + @CircuitBreaker(name = "creatorService", fallbackMethod = "getCreatorIdFallback") + public CreatorId getCreatorId(UserId userId) { + GetCreatorIdByUserIdRequest request = GetCreatorIdByUserIdRequest.newBuilder() + .setUserId(userId.value()) + .build(); + + CreatorServiceGrpc.CreatorServiceBlockingStub stub = CreatorServiceGrpc.newBlockingStub(creatorManagedChannel) + .withDeadlineAfter(2, TimeUnit.SECONDS); + GetCreatorIdByUserIdResponse response = stub.getCreatorIdByUserId(request); + + return new CreatorId(response.getCreatorId()); + } + + @SuppressWarnings("unused") + private CreatorId getCreatorIdFallback(UserId userId, Throwable throwable) { + if (throwable instanceof StatusRuntimeException statusException + && statusException.getStatus().getCode() == Status.Code.NOT_FOUND) { + throw new CreatorNotFoundException(); + } + log.warn("크리에이터 서비스 연결 실패"); + throw new CreatorServiceUnavailableException(throwable); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/GrpcConfiguration.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/GrpcConfiguration.java new file mode 100644 index 00000000..f0bd833a --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/GrpcConfiguration.java @@ -0,0 +1,16 @@ +package kr.magicbox.release.adapter.out.communication.grpc; + +import io.grpc.ManagedChannel; +import kr.magicbox.release.adapter.out.communication.ServiceHost; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.GrpcChannelFactory; + +@Configuration +public class GrpcConfiguration { + + @Bean + public ManagedChannel creatorManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.CREATOR.getHostName()); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/exception/CreatorNotFoundException.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/exception/CreatorNotFoundException.java new file mode 100644 index 00000000..3e8ba93b --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/exception/CreatorNotFoundException.java @@ -0,0 +1,12 @@ +package kr.magicbox.release.adapter.out.communication.grpc.exception; + +import kr.magicbox.release.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class CreatorNotFoundException extends BusinessException { + + public CreatorNotFoundException() { + super("크리에이터를 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/exception/CreatorServiceUnavailableException.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/exception/CreatorServiceUnavailableException.java new file mode 100644 index 00000000..748554fd --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/communication/grpc/exception/CreatorServiceUnavailableException.java @@ -0,0 +1,12 @@ +package kr.magicbox.release.adapter.out.communication.grpc.exception; + +import kr.magicbox.release.global.exception.SystemError; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class CreatorServiceUnavailableException extends SystemError { + + public CreatorServiceUnavailableException(Throwable cause) { + super("크리에이터 서비스에 연결할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE, cause); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/ReleaseJpaAdapter.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/ReleaseJpaAdapter.java new file mode 100644 index 00000000..23993608 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/ReleaseJpaAdapter.java @@ -0,0 +1,66 @@ +package kr.magicbox.release.adapter.out.persistence; + +import kr.magicbox.release.adapter.out.persistence.entity.ReleaseEntity; +import kr.magicbox.release.adapter.out.persistence.mapper.ReleaseMapper; +import kr.magicbox.release.adapter.out.persistence.repository.ReleaseJpaRepository; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.enums.ReleaseStatus; +import kr.magicbox.release.domain.exception.ReleaseNotFoundException; +import kr.magicbox.release.domain.vo.CreatorId; +import kr.magicbox.release.domain.vo.ReleaseId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ReleaseJpaAdapter implements ReleaseRepositoryPort { + + private final ReleaseJpaRepository releaseJpaRepository; + private final ReleaseMapper releaseMapper; + + @Override + public Long save(Release release) { + ReleaseEntity entity = releaseJpaRepository.save(releaseMapper.toEntity(release)); + return entity.getId(); + } + + @Override + public void update(Release release) { + ReleaseEntity entity = releaseJpaRepository.findById(release.getId().value()) + .orElseThrow(ReleaseNotFoundException::new); + entity.update(release.getStatus(), release.getSoldQuantity()); + releaseJpaRepository.save(entity); + } + + @Override + public Release findById(ReleaseId id) { + ReleaseEntity entity = releaseJpaRepository.findById(id.value()) + .orElseThrow(ReleaseNotFoundException::new); + return releaseMapper.toDomain(entity); + } + + @Override + public List findByCreatorId(CreatorId creatorId) { + return releaseJpaRepository.findByCreatorId(creatorId.value()) + .stream() + .map(releaseMapper::toDomain) + .toList(); + } + + @Override + public long countByCreatorId(CreatorId creatorId) { + return releaseJpaRepository.countByCreatorId(creatorId.value()); + } + + @Override + public List findScheduledBefore(Instant scheduledAt) { + return releaseJpaRepository.findByStatusAndScheduledAtBefore(ReleaseStatus.SCHEDULED, scheduledAt) + .stream() + .map(releaseMapper::toDomain) + .toList(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/configuration/JpaConfiguration.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/configuration/JpaConfiguration.java new file mode 100644 index 00000000..1323dcaf --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/configuration/JpaConfiguration.java @@ -0,0 +1,9 @@ +package kr.magicbox.release.adapter.out.persistence.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfiguration { +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/BaseEntity.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/BaseEntity.java new file mode 100644 index 00000000..2fa6df54 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/BaseEntity.java @@ -0,0 +1,31 @@ +package kr.magicbox.release.adapter.out.persistence.entity; + +import com.github.lian2945.sonyflake.annotation.SonyflakeId; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @Id + @SonyflakeId + private Long id; + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseEntity.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseEntity.java new file mode 100644 index 00000000..7a18e671 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseEntity.java @@ -0,0 +1,74 @@ +package kr.magicbox.release.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import kr.magicbox.release.domain.enums.ReleaseLevel; +import kr.magicbox.release.domain.enums.ReleaseStatus; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "releases") +public class ReleaseEntity extends BaseEntity { + + @Version + private Long version; + + @Column(name = "creator_id", nullable = false) + private Long creatorId; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "thumbnail_url") + private String thumbnailUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "level", nullable = false) + private ReleaseLevel level; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private ReleaseStatus status; + + @Column(name = "price", nullable = false) + private Long price; + + @Column(name = "limited_quantity", nullable = false) + private Integer limitedQuantity; + + @Column(name = "sold_quantity", nullable = false) + private Integer soldQuantity; + + @Column(name = "scheduled_at", nullable = false) + private Instant scheduledAt; + + @Builder + public ReleaseEntity(Long creatorId, String title, String description, String thumbnailUrl, + ReleaseLevel level, ReleaseStatus status, Long price, + Integer limitedQuantity, Integer soldQuantity, Instant scheduledAt) { + this.creatorId = creatorId; + this.title = title; + this.description = description; + this.thumbnailUrl = thumbnailUrl; + this.level = level; + this.status = status; + this.price = price; + this.limitedQuantity = limitedQuantity; + this.soldQuantity = soldQuantity; + this.scheduledAt = scheduledAt; + } + + public void update(ReleaseStatus status, Integer soldQuantity) { + this.status = status; + this.soldQuantity = soldQuantity; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/mapper/ReleaseMapper.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/mapper/ReleaseMapper.java new file mode 100644 index 00000000..4566c4b2 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/mapper/ReleaseMapper.java @@ -0,0 +1,44 @@ +package kr.magicbox.release.adapter.out.persistence.mapper; + +import kr.magicbox.release.adapter.out.persistence.entity.ReleaseEntity; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.CreatorId; +import kr.magicbox.release.domain.vo.ReleaseId; +import org.springframework.stereotype.Component; + +@Component +public class ReleaseMapper { + + public ReleaseEntity toEntity(Release domain) { + return ReleaseEntity.builder() + .creatorId(domain.getCreatorId().value()) + .title(domain.getTitle()) + .description(domain.getDescription()) + .thumbnailUrl(domain.getThumbnailUrl()) + .level(domain.getLevel()) + .status(domain.getStatus()) + .price(domain.getPrice()) + .limitedQuantity(domain.getLimitedQuantity()) + .soldQuantity(domain.getSoldQuantity()) + .scheduledAt(domain.getScheduledAt()) + .build(); + } + + public Release toDomain(ReleaseEntity entity) { + return Release.reconstructBuilder() + .id(ReleaseId.of(entity.getId())) + .creatorId(CreatorId.of(entity.getCreatorId())) + .title(entity.getTitle()) + .description(entity.getDescription()) + .thumbnailUrl(entity.getThumbnailUrl()) + .level(entity.getLevel()) + .status(entity.getStatus()) + .price(entity.getPrice()) + .limitedQuantity(entity.getLimitedQuantity()) + .soldQuantity(entity.getSoldQuantity()) + .scheduledAt(entity.getScheduledAt()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseJpaRepository.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseJpaRepository.java new file mode 100644 index 00000000..aadf74da --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseJpaRepository.java @@ -0,0 +1,20 @@ +package kr.magicbox.release.adapter.out.persistence.repository; + +import kr.magicbox.release.adapter.out.persistence.entity.ReleaseEntity; +import kr.magicbox.release.domain.enums.ReleaseStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface ReleaseJpaRepository extends JpaRepository { + + Optional findById(Long id); + + List findByCreatorId(Long creatorId); + + long countByCreatorId(Long creatorId); + + List findByStatusAndScheduledAtBefore(ReleaseStatus status, Instant scheduledAt); +} diff --git a/services/release/src/main/proto/creator.proto b/services/release/src/main/proto/creator.proto new file mode 100644 index 00000000..b9938469 --- /dev/null +++ b/services/release/src/main/proto/creator.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package magicbox.creator.v1; + +option java_package = "kr.magicbox.release.grpc.creator"; +option java_outer_classname = "CreatorServiceProto"; +option java_multiple_files = true; + +service CreatorService { + rpc GetCreatorIdByUserId(GetCreatorIdByUserIdRequest) returns (GetCreatorIdByUserIdResponse); +} + +message GetCreatorIdByUserIdRequest { + int64 user_id = 1; +} + +message GetCreatorIdByUserIdResponse { + int64 creator_id = 1; +} diff --git a/services/release/src/main/proto/release.proto b/services/release/src/main/proto/release.proto new file mode 100644 index 00000000..faed45ac --- /dev/null +++ b/services/release/src/main/proto/release.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; + +package magicbox.release.v1; + +option java_package = "kr.magicbox.release.grpc.release"; +option java_outer_classname = "ReleaseServiceProto"; +option java_multiple_files = true; + +import "google/protobuf/timestamp.proto"; + +service ReleaseService { + rpc GetReleaseCount(GetReleaseCountRequest) returns (GetReleaseCountResponse); + rpc GetReleasesByCreatorId(GetReleasesByCreatorIdRequest) returns (GetReleasesByCreatorIdResponse); + rpc IsReleaseOnSale(IsReleaseOnSaleRequest) returns (IsReleaseOnSaleResponse); + rpc GetRemainingQuantity(GetRemainingQuantityRequest) returns (GetRemainingQuantityResponse); + rpc IncreaseSoldQuantity(IncreaseSoldQuantityRequest) returns (IncreaseSoldQuantityResponse); +} + +message GetReleaseCountRequest { + int64 creator_id = 1; +} + +message GetReleaseCountResponse { + int64 release_count = 1; +} + +message GetReleasesByCreatorIdRequest { + int64 creator_id = 1; +} + +message GetReleasesByCreatorIdResponse { + repeated Release releases = 1; +} + +enum ReleaseLevel { + BEGINNER = 0; + INTERMEDIATE = 1; + ADVANCED = 2; +} + +message IsReleaseOnSaleRequest { + int64 release_id = 1; +} + +message IsReleaseOnSaleResponse { + bool on_sale = 1; +} + +message GetRemainingQuantityRequest { + int64 release_id = 1; +} + +message GetRemainingQuantityResponse { + int32 remaining_quantity = 1; +} + +message IncreaseSoldQuantityRequest { + int64 release_id = 1; +} + +message IncreaseSoldQuantityResponse { + bool sold_out = 1; +} + +message Release { + int64 release_id = 1; + string title = 2; + string thumbnail_url = 3; + ReleaseLevel level = 4; + string creator_nickname = 5; + int64 price = 6; + google.protobuf.Timestamp created_at = 7; +} From 0c59c1709d2b40fa7ebbddc765245b019d29ec5a Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:39 +0900 Subject: [PATCH 008/116] =?UTF-8?q?feat/117=20::=20Release=20application-*?= =?UTF-8?q?.yml,=20Dockerfile,=20build.gradle=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- services/release/Dockerfile | 4 +- services/release/build.gradle | 42 ++++++++++++++++- .../src/main/resources/application-dev.yml | 46 +++++++++++++++++++ .../src/main/resources/application-local.yml | 45 +++++++++++++++++- 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 services/release/src/main/resources/application-dev.yml diff --git a/services/release/Dockerfile b/services/release/Dockerfile index a8e8e580..7c429075 100644 --- a/services/release/Dockerfile +++ b/services/release/Dockerfile @@ -1,6 +1,6 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu -ARG JAR_FILE=build/libs/*.jar +ARG JAR_FILE=services/release/build/libs/*.jar WORKDIR /app COPY ${JAR_FILE} app.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/release/build.gradle b/services/release/build.gradle index 807f58a1..841f0978 100644 --- a/services/release/build.gradle +++ b/services/release/build.gradle @@ -1,5 +1,45 @@ +plugins { + id 'com.google.protobuf' version '0.9.6' +} + +ext { + springGrpcVersion = "1.0.2" + springCloudVersion = "2025.1.0" +} version = '0.0.1' description = 'release' dependencies { -} \ No newline at end of file + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.grpc:spring-grpc-server-spring-boot-starter' + implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + runtimeOnly 'com.mysql:mysql-connector-j' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.grpc:spring-grpc-dependencies:$springGrpcVersion" + mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion" + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.34.0" + } + plugins { + grpc { + artifact = 'io.grpc:protoc-gen-grpc-java:1.79.0' + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} diff --git a/services/release/src/main/resources/application-dev.yml b/services/release/src/main/resources/application-dev.yml new file mode 100644 index 00000000..c52297d5 --- /dev/null +++ b/services/release/src/main/resources/application-dev.yml @@ -0,0 +1,46 @@ +spring: + application: + name: release-dev + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + server: + port: ${GRPC_SERVER_PORT} + client: + channels: + creator-service: + address: ${CREATOR_SERVICE_URL} + negotiation-type: plaintext + keep-alive-time: 30s + keep-alive-timeout: 5s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + +security: + trusted: + ips: + - 127.0.0.1 + - 0:0:0:0:0:0:0:1 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s diff --git a/services/release/src/main/resources/application-local.yml b/services/release/src/main/resources/application-local.yml index b3ad68a1..cac2b0e9 100644 --- a/services/release/src/main/resources/application-local.yml +++ b/services/release/src/main/resources/application-local.yml @@ -1,9 +1,52 @@ spring: application: name: release-local + jackson: + property-naming-strategy: SNAKE_CASE config: import: - file:services/release/env/local.env[.properties] + grpc: + server: + port: ${GRPC_SERVER_PORT} + client: + channels: + creator-service: + address: ${CREATOR_SERVICE_URL} + negotiation-type: plaintext + keep-alive-time: 30s + keep-alive-timeout: 5s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false server: - port: ${SERVER_PORT} \ No newline at end of file + port: ${SERVER_PORT} + +security: + trusted: + ips: + - 127.0.0.1 + - 0:0:0:0:0:0:0:1 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s From 58a04d016d73499abc6b11c000d0cdd43925e9c9 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:09:24 +0900 Subject: [PATCH 009/116] =?UTF-8?q?feat/120=20::=20Orchestrator=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8,=20=EA=B8=80=EB=A1=9C=EB=B2=8C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EA=B3=84=EC=B8=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../event/OrchestratorCommandEvent.java | 6 ++++ .../event/OrchestratorCommandEventType.java | 17 ++++++++++ .../event/OrderPrepareConfirmedCommand.java | 23 +++++++++++++ .../domain/event/PaymentApproveCommand.java | 25 ++++++++++++++ .../domain/event/PaymentCancelCommand.java | 24 ++++++++++++++ .../domain/event/SettlementReadyCommand.java | 24 ++++++++++++++ .../domain/event/SettlementSettleCommand.java | 26 +++++++++++++++ .../domain/event/StockReserveCommand.java | 33 +++++++++++++++++++ .../global/exception/BaseException.java | 20 +++++++++++ .../global/exception/BusinessException.java | 17 ++++++++++ .../global/exception/SystemError.java | 10 ++++++ 11 files changed, 225 insertions(+) create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrchestratorCommandEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrchestratorCommandEventType.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrderPrepareConfirmedCommand.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/PaymentApproveCommand.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/PaymentCancelCommand.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/SettlementReadyCommand.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/SettlementSettleCommand.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/StockReserveCommand.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/BaseException.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/BusinessException.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/SystemError.java diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrchestratorCommandEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrchestratorCommandEvent.java new file mode 100644 index 00000000..0fd2323e --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrchestratorCommandEvent.java @@ -0,0 +1,6 @@ +package kr.magicbox.orchestrator.domain.event; + +public interface OrchestratorCommandEvent { + String key(); + OrchestratorCommandEventType eventType(); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrchestratorCommandEventType.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrchestratorCommandEventType.java new file mode 100644 index 00000000..7f8e79ad --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrchestratorCommandEventType.java @@ -0,0 +1,17 @@ +package kr.magicbox.orchestrator.domain.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OrchestratorCommandEventType { + STOCK_RESERVE("stock-reserve"), + PAYMENT_APPROVE("payment-approve"), + ORDER_PREPARE_CONFIRMED("order-prepare-confirmed"), + PAYMENT_CANCEL("payment-cancel"), + SETTLEMENT_READY("settlement-ready"), + SETTLEMENT_SETTLE("settlement-settle"); + + private final String topicSuffix; +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrderPrepareConfirmedCommand.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrderPrepareConfirmedCommand.java new file mode 100644 index 00000000..22636670 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/OrderPrepareConfirmedCommand.java @@ -0,0 +1,23 @@ +package kr.magicbox.orchestrator.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderPrepareConfirmedCommand( + @JsonProperty("order_id") Long orderId, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrchestratorCommandEvent { + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrchestratorCommandEventType eventType() { + return OrchestratorCommandEventType.ORDER_PREPARE_CONFIRMED; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/PaymentApproveCommand.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/PaymentApproveCommand.java new file mode 100644 index 00000000..d5c9fb8d --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/PaymentApproveCommand.java @@ -0,0 +1,25 @@ +package kr.magicbox.orchestrator.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record PaymentApproveCommand( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("amount") long amount, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrchestratorCommandEvent { + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrchestratorCommandEventType eventType() { + return OrchestratorCommandEventType.PAYMENT_APPROVE; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/PaymentCancelCommand.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/PaymentCancelCommand.java new file mode 100644 index 00000000..4c6cc265 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/PaymentCancelCommand.java @@ -0,0 +1,24 @@ +package kr.magicbox.orchestrator.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record PaymentCancelCommand( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrchestratorCommandEvent { + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrchestratorCommandEventType eventType() { + return OrchestratorCommandEventType.PAYMENT_CANCEL; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/SettlementReadyCommand.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/SettlementReadyCommand.java new file mode 100644 index 00000000..0b5c7ca6 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/SettlementReadyCommand.java @@ -0,0 +1,24 @@ +package kr.magicbox.orchestrator.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record SettlementReadyCommand( + @JsonProperty("order_id") Long orderId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrchestratorCommandEvent { + + @Override + public String key() { + return orderLineId.toString(); + } + + @Override + public OrchestratorCommandEventType eventType() { + return OrchestratorCommandEventType.SETTLEMENT_READY; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/SettlementSettleCommand.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/SettlementSettleCommand.java new file mode 100644 index 00000000..9072dc49 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/SettlementSettleCommand.java @@ -0,0 +1,26 @@ +package kr.magicbox.orchestrator.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record SettlementSettleCommand( + @JsonProperty("order_id") Long orderId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("gross_amount") long grossAmount, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrchestratorCommandEvent { + + @Override + public String key() { + return orderLineId.toString(); + } + + @Override + public OrchestratorCommandEventType eventType() { + return OrchestratorCommandEventType.SETTLEMENT_SETTLE; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/StockReserveCommand.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/StockReserveCommand.java new file mode 100644 index 00000000..e1fd1762 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/domain/event/StockReserveCommand.java @@ -0,0 +1,33 @@ +package kr.magicbox.orchestrator.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record StockReserveCommand( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("items") List items, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrchestratorCommandEvent { + + @Builder + public record ItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity, + @JsonProperty("unit_price") long unitPrice + ) {} + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrchestratorCommandEventType eventType() { + return OrchestratorCommandEventType.STOCK_RESERVE; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/BaseException.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/BaseException.java new file mode 100644 index 00000000..92fa9d59 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/BaseException.java @@ -0,0 +1,20 @@ +package kr.magicbox.orchestrator.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class BaseException extends RuntimeException { + + private final HttpStatus status; + + public BaseException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public BaseException(String message, HttpStatus status, Throwable cause) { + super(message, cause); + this.status = status; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/BusinessException.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/BusinessException.java new file mode 100644 index 00000000..e8621026 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/BusinessException.java @@ -0,0 +1,17 @@ +package kr.magicbox.orchestrator.global.exception; + +import org.springframework.http.HttpStatus; + +public class BusinessException extends BaseException { + + public BusinessException(String message, HttpStatus status) { + super(message, validateStatus(status)); + } + + private static HttpStatus validateStatus(HttpStatus status) { + if (!status.is4xxClientError()) { + throw new SystemError("클라이언트 에러가 아닙니다.", HttpStatus.INTERNAL_SERVER_ERROR); + } + return status; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/SystemError.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/SystemError.java new file mode 100644 index 00000000..f745af0e --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/global/exception/SystemError.java @@ -0,0 +1,10 @@ +package kr.magicbox.orchestrator.global.exception; + +import org.springframework.http.HttpStatus; + +public class SystemError extends BaseException { + + public SystemError(String message, HttpStatus status) { + super(message, status); + } +} From 2a9bb5bbe1abae660ebb7a50a793dca847620d2f Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:09:24 +0900 Subject: [PATCH 010/116] =?UTF-8?q?feat/120=20::=20Orchestrator=20UseCase,?= =?UTF-8?q?=20Port,=20Service=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../in/HandleDeliveryCompletedUseCase.java | 5 +++ .../in/HandleOrderAutoConfirmedUseCase.java | 5 +++ .../port/in/HandleOrderCancelUseCase.java | 5 +++ .../port/in/HandleOrderConfirmedUseCase.java | 5 +++ .../port/in/HandleOrderPrepareUseCase.java | 5 +++ .../HandleOrderPurchaseConfirmedUseCase.java | 5 +++ .../HandlePaymentCancelSucceededUseCase.java | 5 +++ .../port/in/HandlePaymentFailedUseCase.java | 5 +++ .../in/HandlePaymentSucceededUseCase.java | 5 +++ .../in/HandleStockReserveFailedUseCase.java | 5 +++ .../HandleStockReserveSucceededUseCase.java | 5 +++ .../port/out/OrchestratorOutboxPort.java | 7 +++ .../HandleDeliveryCompletedService.java | 23 ++++++++++ .../HandleOrderAutoConfirmedService.java | 45 +++++++++++++++++++ .../service/HandleOrderCancelService.java | 34 ++++++++++++++ .../service/HandleOrderConfirmedService.java | 25 +++++++++++ .../service/HandleOrderPrepareService.java | 35 +++++++++++++++ .../HandleOrderPurchaseConfirmedService.java | 45 +++++++++++++++++++ .../HandlePaymentCancelSucceededService.java | 25 +++++++++++ .../service/HandlePaymentFailedService.java | 25 +++++++++++ .../HandlePaymentSucceededService.java | 33 ++++++++++++++ .../HandleStockReserveFailedService.java | 25 +++++++++++ .../HandleStockReserveSucceededService.java | 35 +++++++++++++++ 23 files changed, 412 insertions(+) create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleDeliveryCompletedUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderAutoConfirmedUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderCancelUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderConfirmedUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderPrepareUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderPurchaseConfirmedUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentCancelSucceededUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentFailedUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentSucceededUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleStockReserveFailedUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleStockReserveSucceededUseCase.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/out/OrchestratorOutboxPort.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleDeliveryCompletedService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderAutoConfirmedService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderCancelService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderConfirmedService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderPrepareService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderPurchaseConfirmedService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentCancelSucceededService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentFailedService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentSucceededService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleStockReserveFailedService.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleStockReserveSucceededService.java diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleDeliveryCompletedUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleDeliveryCompletedUseCase.java new file mode 100644 index 00000000..fbc58ae7 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleDeliveryCompletedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandleDeliveryCompletedUseCase { + void handleDeliveryCompleted(Long orderId, Long orderLineId); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderAutoConfirmedUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderAutoConfirmedUseCase.java new file mode 100644 index 00000000..3dcad0a1 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderAutoConfirmedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandleOrderAutoConfirmedUseCase { + void handleOrderAutoConfirmed(Long orderId, Long orderLineId, Long sellerId, long grossAmount); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderCancelUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderCancelUseCase.java new file mode 100644 index 00000000..96ac8635 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderCancelUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandleOrderCancelUseCase { + void handleOrderCancel(Long orderId, Long customerId); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderConfirmedUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderConfirmedUseCase.java new file mode 100644 index 00000000..637425b6 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderConfirmedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandleOrderConfirmedUseCase { + void handleOrderConfirmed(Long orderId, Long customerId, Long sellerId, Long totalAmount); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderPrepareUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderPrepareUseCase.java new file mode 100644 index 00000000..e103345b --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderPrepareUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandleOrderPrepareUseCase { + void handleOrderPrepare(Long orderId, Long customerId, Long sellerId, Long totalAmount); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderPurchaseConfirmedUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderPurchaseConfirmedUseCase.java new file mode 100644 index 00000000..4666fed0 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleOrderPurchaseConfirmedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandleOrderPurchaseConfirmedUseCase { + void handleOrderPurchaseConfirmed(Long orderId, Long orderLineId, Long sellerId, long grossAmount); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentCancelSucceededUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentCancelSucceededUseCase.java new file mode 100644 index 00000000..f22e0e57 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentCancelSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandlePaymentCancelSucceededUseCase { + void handlePaymentCancelSucceeded(Long orderId); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentFailedUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentFailedUseCase.java new file mode 100644 index 00000000..980b7780 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentFailedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandlePaymentFailedUseCase { + void handlePaymentFailed(Long orderId); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentSucceededUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentSucceededUseCase.java new file mode 100644 index 00000000..904f2154 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandlePaymentSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandlePaymentSucceededUseCase { + void handlePaymentSucceeded(Long orderId, Long customerId, Long sellerId); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleStockReserveFailedUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleStockReserveFailedUseCase.java new file mode 100644 index 00000000..4017b79b --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleStockReserveFailedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandleStockReserveFailedUseCase { + void handleStockReserveFailed(Long orderId); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleStockReserveSucceededUseCase.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleStockReserveSucceededUseCase.java new file mode 100644 index 00000000..d24f999e --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/in/HandleStockReserveSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.orchestrator.application.port.in; + +public interface HandleStockReserveSucceededUseCase { + void handleStockReserveSucceeded(Long orderId, Long customerId, Long totalAmount); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/out/OrchestratorOutboxPort.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/out/OrchestratorOutboxPort.java new file mode 100644 index 00000000..1df734d6 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/port/out/OrchestratorOutboxPort.java @@ -0,0 +1,7 @@ +package kr.magicbox.orchestrator.application.port.out; + +import kr.magicbox.orchestrator.domain.event.OrchestratorCommandEvent; + +public interface OrchestratorOutboxPort { + void save(OrchestratorCommandEvent event); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleDeliveryCompletedService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleDeliveryCompletedService.java new file mode 100644 index 00000000..b01088a2 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleDeliveryCompletedService.java @@ -0,0 +1,23 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandleDeliveryCompletedUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * delivery.completed 이벤트 처리 + * 정산은 구매확정(수동) 또는 자동확정(7일) 시점에 시작되므로 여기서는 별도 처리 없음 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandleDeliveryCompletedService implements HandleDeliveryCompletedUseCase { + + @Override + @Transactional + public void handleDeliveryCompleted(Long orderId, Long orderLineId) { + log.info("[Orchestrator] delivery.completed 처리. orderId={}, orderLineId={}", orderId, orderLineId); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderAutoConfirmedService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderAutoConfirmedService.java new file mode 100644 index 00000000..bc9312a8 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderAutoConfirmedService.java @@ -0,0 +1,45 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandleOrderAutoConfirmedUseCase; +import kr.magicbox.orchestrator.application.port.out.OrchestratorOutboxPort; +import kr.magicbox.orchestrator.domain.event.SettlementReadyCommand; +import kr.magicbox.orchestrator.domain.event.SettlementSettleCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * order.auto_confirmed 이벤트 처리 (7일 자동 구매확정, orderLine 단위) + * 플로우: order.auto_confirmed 수신 + * → settlement-ready 커맨드 발행 (orderLine 단위) + * → settlement-settle 커맨드 발행 (orderLine 단위) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandleOrderAutoConfirmedService implements HandleOrderAutoConfirmedUseCase { + + private final OrchestratorOutboxPort orchestratorOutboxPort; + + @Override + @Transactional + public void handleOrderAutoConfirmed(Long orderId, Long orderLineId, Long sellerId, long grossAmount) { + log.info("[Orchestrator] order.auto_confirmed 처리. orderId={}, orderLineId={}", orderId, orderLineId); + Instant now = Instant.now(); + orchestratorOutboxPort.save(SettlementReadyCommand.builder() + .orderId(orderId) + .orderLineId(orderLineId) + .occurredAt(now) + .build()); + orchestratorOutboxPort.save(SettlementSettleCommand.builder() + .orderId(orderId) + .orderLineId(orderLineId) + .sellerId(sellerId) + .grossAmount(grossAmount) + .occurredAt(now) + .build()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderCancelService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderCancelService.java new file mode 100644 index 00000000..3f414c35 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderCancelService.java @@ -0,0 +1,34 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandleOrderCancelUseCase; +import kr.magicbox.orchestrator.application.port.out.OrchestratorOutboxPort; +import kr.magicbox.orchestrator.domain.event.PaymentCancelCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * order.cancel 이벤트 처리 + * 플로우: order.cancel 수신 → payment-cancel 커맨드 발행 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandleOrderCancelService implements HandleOrderCancelUseCase { + + private final OrchestratorOutboxPort orchestratorOutboxPort; + + @Override + @Transactional + public void handleOrderCancel(Long orderId, Long customerId) { + log.info("[Orchestrator] order.cancel 처리. orderId={}", orderId); + orchestratorOutboxPort.save(PaymentCancelCommand.builder() + .orderId(orderId) + .customerId(customerId) + .occurredAt(Instant.now()) + .build()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderConfirmedService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderConfirmedService.java new file mode 100644 index 00000000..eab09d23 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderConfirmedService.java @@ -0,0 +1,25 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandleOrderConfirmedUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * order.confirmed 이벤트 처리 + * 플로우: order.confirmed 수신 → 상태만 확인 + * 주의: 주문 confirm은 배송 시작을 의미하지 않으며, + * 배송 시작은 delivery 서비스의 명시적 start 요청으로만 진행된다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandleOrderConfirmedService implements HandleOrderConfirmedUseCase { + + @Override + @Transactional + public void handleOrderConfirmed(Long orderId, Long customerId, Long sellerId, Long totalAmount) { + log.info("[Orchestrator] order.confirmed 처리. orderId={}, 배송 시작 커맨드 발행 없음", orderId); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderPrepareService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderPrepareService.java new file mode 100644 index 00000000..d7609510 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderPrepareService.java @@ -0,0 +1,35 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandleOrderPrepareUseCase; +import kr.magicbox.orchestrator.application.port.out.OrchestratorOutboxPort; +import kr.magicbox.orchestrator.domain.event.StockReserveCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * order.prepare 이벤트 처리 + * 플로우: order.prepare 수신 → stock-reserve 커맨드 발행 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandleOrderPrepareService implements HandleOrderPrepareUseCase { + + private final OrchestratorOutboxPort orchestratorOutboxPort; + + @Override + @Transactional + public void handleOrderPrepare(Long orderId, Long customerId, Long sellerId, Long totalAmount) { + log.info("[Orchestrator] order.prepare 처리. orderId={}", orderId); + orchestratorOutboxPort.save(StockReserveCommand.builder() + .orderId(orderId) + .customerId(customerId) + .items(null) + .occurredAt(Instant.now()) + .build()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderPurchaseConfirmedService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderPurchaseConfirmedService.java new file mode 100644 index 00000000..ae4d9bc6 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleOrderPurchaseConfirmedService.java @@ -0,0 +1,45 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandleOrderPurchaseConfirmedUseCase; +import kr.magicbox.orchestrator.application.port.out.OrchestratorOutboxPort; +import kr.magicbox.orchestrator.domain.event.SettlementReadyCommand; +import kr.magicbox.orchestrator.domain.event.SettlementSettleCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * order.purchase_confirmed 이벤트 처리 (orderLine 단위) + * 플로우: order.purchase_confirmed 수신 + * → settlement-ready 커맨드 발행 (orderLine 단위) + * → settlement-settle 커맨드 발행 (orderLine 단위) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandleOrderPurchaseConfirmedService implements HandleOrderPurchaseConfirmedUseCase { + + private final OrchestratorOutboxPort orchestratorOutboxPort; + + @Override + @Transactional + public void handleOrderPurchaseConfirmed(Long orderId, Long orderLineId, Long sellerId, long grossAmount) { + log.info("[Orchestrator] order.purchase_confirmed 처리. orderId={}, orderLineId={}", orderId, orderLineId); + Instant now = Instant.now(); + orchestratorOutboxPort.save(SettlementReadyCommand.builder() + .orderId(orderId) + .orderLineId(orderLineId) + .occurredAt(now) + .build()); + orchestratorOutboxPort.save(SettlementSettleCommand.builder() + .orderId(orderId) + .orderLineId(orderLineId) + .sellerId(sellerId) + .grossAmount(grossAmount) + .occurredAt(now) + .build()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentCancelSucceededService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentCancelSucceededService.java new file mode 100644 index 00000000..1e86212c --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentCancelSucceededService.java @@ -0,0 +1,25 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandlePaymentCancelSucceededUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * payment.cancel.succeeded 이벤트 처리 + * 플로우: payment.cancel.succeeded 수신 → Order 서비스가 자체적으로 CANCELLED 처리 (Orchestrator 추가 액션 없음) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandlePaymentCancelSucceededService implements HandlePaymentCancelSucceededUseCase { + + @Override + @Transactional + public void handlePaymentCancelSucceeded(Long orderId) { + log.info("[Orchestrator] payment.cancel.succeeded 처리. orderId={}", orderId); + // Order 서비스가 payment.cancel.succeeded 이벤트를 직접 구독하여 CANCELLED로 전이함 + // 정산 취소는 Settlement 서비스가 payment.cancel.succeeded를 직접 구독하여 처리 + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentFailedService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentFailedService.java new file mode 100644 index 00000000..ccca5143 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentFailedService.java @@ -0,0 +1,25 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandlePaymentFailedUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * payment.failed 이벤트 처리 + * 플로우: payment.failed 수신 → Order 서비스가 자체적으로 PAYMENT_FAILED 처리 (Orchestrator 추가 액션 없음) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandlePaymentFailedService implements HandlePaymentFailedUseCase { + + @Override + @Transactional + public void handlePaymentFailed(Long orderId) { + log.info("[Orchestrator] payment.failed 처리. orderId={}", orderId); + // Order 서비스가 payment.failed 이벤트를 직접 구독하여 PAYMENT_FAILED로 전이함 + // Orchestrator는 추가 커맨드를 발행하지 않음 + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentSucceededService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentSucceededService.java new file mode 100644 index 00000000..6ad62c49 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandlePaymentSucceededService.java @@ -0,0 +1,33 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandlePaymentSucceededUseCase; +import kr.magicbox.orchestrator.application.port.out.OrchestratorOutboxPort; +import kr.magicbox.orchestrator.domain.event.OrderPrepareConfirmedCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * payment.succeeded 이벤트 처리 + * 플로우: payment.succeeded 수신 → order-prepare-confirmed 커맨드 발행 (Order: PAYMENT_COMPLETED → PREPARING) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandlePaymentSucceededService implements HandlePaymentSucceededUseCase { + + private final OrchestratorOutboxPort orchestratorOutboxPort; + + @Override + @Transactional + public void handlePaymentSucceeded(Long orderId, Long customerId, Long sellerId) { + log.info("[Orchestrator] payment.succeeded 처리. orderId={}", orderId); + orchestratorOutboxPort.save(OrderPrepareConfirmedCommand.builder() + .orderId(orderId) + .occurredAt(Instant.now()) + .build()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleStockReserveFailedService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleStockReserveFailedService.java new file mode 100644 index 00000000..6ad242f2 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleStockReserveFailedService.java @@ -0,0 +1,25 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandleStockReserveFailedUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * stock.reserve.failed 이벤트 처리 + * 플로우: stock.reserve.failed 수신 → Order 서비스가 자체적으로 STOCK_FAILED 처리 (Orchestrator 추가 액션 없음) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandleStockReserveFailedService implements HandleStockReserveFailedUseCase { + + @Override + @Transactional + public void handleStockReserveFailed(Long orderId) { + log.info("[Orchestrator] stock.reserve.failed 처리. orderId={}", orderId); + // Order 서비스가 stock.reserve.failed 이벤트를 직접 구독하여 STOCK_FAILED로 전이함 + // Orchestrator는 추가 커맨드를 발행하지 않음 + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleStockReserveSucceededService.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleStockReserveSucceededService.java new file mode 100644 index 00000000..70294a9a --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/application/service/HandleStockReserveSucceededService.java @@ -0,0 +1,35 @@ +package kr.magicbox.orchestrator.application.service; + +import kr.magicbox.orchestrator.application.port.in.HandleStockReserveSucceededUseCase; +import kr.magicbox.orchestrator.application.port.out.OrchestratorOutboxPort; +import kr.magicbox.orchestrator.domain.event.PaymentApproveCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * stock.reserve.succeeded 이벤트 처리 + * 플로우: stock.reserve.succeeded 수신 → payment-approve 커맨드 발행 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HandleStockReserveSucceededService implements HandleStockReserveSucceededUseCase { + + private final OrchestratorOutboxPort orchestratorOutboxPort; + + @Override + @Transactional + public void handleStockReserveSucceeded(Long orderId, Long customerId, Long totalAmount) { + log.info("[Orchestrator] stock.reserve.succeeded 처리. orderId={}", orderId); + orchestratorOutboxPort.save(PaymentApproveCommand.builder() + .orderId(orderId) + .customerId(customerId) + .amount(totalAmount) + .occurredAt(Instant.now()) + .build()); + } +} From 7acedf09efba90684ba604522aa24102e78f2f23 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:09:24 +0900 Subject: [PATCH 011/116] =?UTF-8?q?feat/120=20::=20Orchestrator=20?= =?UTF-8?q?=EC=96=B4=EB=8C=91=ED=84=B0=20(Kafka=20Listener/Inbox/Outbox/Pe?= =?UTF-8?q?rsistence)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../orchestrator/OrchestratorApplication.java | 13 +++ .../in/kafka/DeliveryEventKafkaListener.java | 28 ++++++ .../in/kafka/OrderEventKafkaListener.java | 72 ++++++++++++++ .../in/kafka/PaymentEventKafkaListener.java | 51 ++++++++++ .../in/kafka/StockEventKafkaListener.java | 40 ++++++++ .../in/kafka/annotation/Idempotent.java | 8 ++ .../in/kafka/aop/IdempotentAspect.java | 97 +++++++++++++++++++ .../configuration/KafkaConfiguration.java | 22 +++++ .../kafka/event/DeliveryCompletedEvent.java | 12 +++ .../kafka/event/OrderAutoConfirmedEvent.java | 16 +++ .../in/kafka/event/OrderCancelEvent.java | 13 +++ .../in/kafka/event/OrderConfirmedEvent.java | 15 +++ .../in/kafka/event/OrderPrepareEvent.java | 15 +++ .../event/OrderPurchaseConfirmedEvent.java | 16 +++ .../event/PaymentCancelSucceededEvent.java | 13 +++ .../in/kafka/event/PaymentFailedEvent.java | 12 +++ .../in/kafka/event/PaymentSucceededEvent.java | 15 +++ .../kafka/event/StockReserveFailedEvent.java | 12 +++ .../event/StockReserveSucceededEvent.java | 14 +++ .../in/kafka/properties/InboxProperties.java | 12 +++ .../OrchestratorOutboxAdapter.java | 25 +++++ .../configuration/JpaConfiguration.java | 9 ++ .../out/persistence/entity/BaseEntity.java | 31 ++++++ .../entity/OrchestratorInboxEntity.java | 53 ++++++++++ .../entity/OrchestratorInboxStatus.java | 7 ++ .../entity/OrchestratorOutboxEntity.java | 28 ++++++ .../OrchestratorInboxRepository.java | 12 +++ .../OrchestratorOutboxRepository.java | 7 ++ 28 files changed, 668 insertions(+) create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/OrchestratorApplication.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/DeliveryEventKafkaListener.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/OrderEventKafkaListener.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/PaymentEventKafkaListener.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/StockEventKafkaListener.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/annotation/Idempotent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/configuration/KafkaConfiguration.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/DeliveryCompletedEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderAutoConfirmedEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderCancelEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderConfirmedEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPrepareEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPurchaseConfirmedEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentCancelSucceededEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentFailedEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentSucceededEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveFailedEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveSucceededEvent.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/OrchestratorOutboxAdapter.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/configuration/JpaConfiguration.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/BaseEntity.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorInboxEntity.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorInboxStatus.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorOutboxEntity.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/repository/OrchestratorInboxRepository.java create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/repository/OrchestratorOutboxRepository.java diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/OrchestratorApplication.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/OrchestratorApplication.java new file mode 100644 index 00000000..cb6864d5 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/OrchestratorApplication.java @@ -0,0 +1,13 @@ +package kr.magicbox.orchestrator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OrchestratorApplication { + + public static void main(String[] args) { + SpringApplication.run(OrchestratorApplication.class, args); + } + +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/DeliveryEventKafkaListener.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/DeliveryEventKafkaListener.java new file mode 100644 index 00000000..f64912b2 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/DeliveryEventKafkaListener.java @@ -0,0 +1,28 @@ +package kr.magicbox.orchestrator.adapter.in.kafka; + +import kr.magicbox.orchestrator.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.orchestrator.adapter.in.kafka.event.DeliveryCompletedEvent; +import kr.magicbox.orchestrator.application.port.in.HandleDeliveryCompletedUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeliveryEventKafkaListener { + + private final HandleDeliveryCompletedUseCase handleDeliveryCompletedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.delivery-completed", groupId = "orchestrator-service") + public void handleDeliveryCompleted(ConsumerRecord consumerRecord) { + log.info("[Inbox] delivery.completed 이벤트 수신. eventId={}", consumerRecord.key()); + DeliveryCompletedEvent event = consumerRecord.value(); + handleDeliveryCompletedUseCase.handleDeliveryCompleted(event.orderId(), event.orderLineId()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/OrderEventKafkaListener.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/OrderEventKafkaListener.java new file mode 100644 index 00000000..96c2c2d9 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/OrderEventKafkaListener.java @@ -0,0 +1,72 @@ +package kr.magicbox.orchestrator.adapter.in.kafka; + +import kr.magicbox.orchestrator.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.orchestrator.adapter.in.kafka.event.*; +import kr.magicbox.orchestrator.application.port.in.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventKafkaListener { + + private final HandleOrderPrepareUseCase handleOrderPrepareUseCase; + private final HandleOrderConfirmedUseCase handleOrderConfirmedUseCase; + private final HandleOrderCancelUseCase handleOrderCancelUseCase; + private final HandleOrderPurchaseConfirmedUseCase handleOrderPurchaseConfirmedUseCase; + private final HandleOrderAutoConfirmedUseCase handleOrderAutoConfirmedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.order-prepare", groupId = "orchestrator-service") + public void handleOrderPrepare(ConsumerRecord consumerRecord) { + log.info("[Inbox] order.prepare 이벤트 수신. eventId={}", consumerRecord.key()); + OrderPrepareEvent event = consumerRecord.value(); + handleOrderPrepareUseCase.handleOrderPrepare( + event.orderId(), event.customerId(), event.sellerId(), event.totalAmount()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.order-confirmed", groupId = "orchestrator-service") + public void handleOrderConfirmed(ConsumerRecord consumerRecord) { + log.info("[Inbox] order.confirmed 이벤트 수신. eventId={}", consumerRecord.key()); + OrderConfirmedEvent event = consumerRecord.value(); + handleOrderConfirmedUseCase.handleOrderConfirmed( + event.orderId(), event.customerId(), event.sellerId(), event.totalAmount()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.order-cancel", groupId = "orchestrator-service") + public void handleOrderCancel(ConsumerRecord consumerRecord) { + log.info("[Inbox] order.cancel 이벤트 수신. eventId={}", consumerRecord.key()); + OrderCancelEvent event = consumerRecord.value(); + handleOrderCancelUseCase.handleOrderCancel(event.orderId(), event.customerId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.order-purchase-confirmed", groupId = "orchestrator-service") + public void handleOrderPurchaseConfirmed(ConsumerRecord consumerRecord) { + log.info("[Inbox] order.purchase_confirmed 이벤트 수신. eventId={}", consumerRecord.key()); + OrderPurchaseConfirmedEvent event = consumerRecord.value(); + handleOrderPurchaseConfirmedUseCase.handleOrderPurchaseConfirmed( + event.orderId(), event.orderLineId(), event.sellerId(), event.grossAmount()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.order-auto-confirmed", groupId = "orchestrator-service") + public void handleOrderAutoConfirmed(ConsumerRecord consumerRecord) { + log.info("[Inbox] order.auto_confirmed 이벤트 수신. eventId={}", consumerRecord.key()); + OrderAutoConfirmedEvent event = consumerRecord.value(); + handleOrderAutoConfirmedUseCase.handleOrderAutoConfirmed( + event.orderId(), event.orderLineId(), event.sellerId(), event.grossAmount()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/PaymentEventKafkaListener.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/PaymentEventKafkaListener.java new file mode 100644 index 00000000..058200dd --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/PaymentEventKafkaListener.java @@ -0,0 +1,51 @@ +package kr.magicbox.orchestrator.adapter.in.kafka; + +import kr.magicbox.orchestrator.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentCancelSucceededEvent; +import kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentFailedEvent; +import kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentSucceededEvent; +import kr.magicbox.orchestrator.application.port.in.HandlePaymentCancelSucceededUseCase; +import kr.magicbox.orchestrator.application.port.in.HandlePaymentFailedUseCase; +import kr.magicbox.orchestrator.application.port.in.HandlePaymentSucceededUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventKafkaListener { + + private final HandlePaymentSucceededUseCase handlePaymentSucceededUseCase; + private final HandlePaymentFailedUseCase handlePaymentFailedUseCase; + private final HandlePaymentCancelSucceededUseCase handlePaymentCancelSucceededUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-succeeded", groupId = "orchestrator-service") + public void handlePaymentSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + PaymentSucceededEvent event = consumerRecord.value(); + handlePaymentSucceededUseCase.handlePaymentSucceeded( + event.orderId(), event.customerId(), event.sellerId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-failed", groupId = "orchestrator-service") + public void handlePaymentFailed(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.failed 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentFailedUseCase.handlePaymentFailed(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-cancel-succeeded", groupId = "orchestrator-service") + public void handlePaymentCancelSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.cancel.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentCancelSucceededUseCase.handlePaymentCancelSucceeded(consumerRecord.value().orderId()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/StockEventKafkaListener.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/StockEventKafkaListener.java new file mode 100644 index 00000000..e0e831f4 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/StockEventKafkaListener.java @@ -0,0 +1,40 @@ +package kr.magicbox.orchestrator.adapter.in.kafka; + +import kr.magicbox.orchestrator.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.orchestrator.adapter.in.kafka.event.StockReserveFailedEvent; +import kr.magicbox.orchestrator.adapter.in.kafka.event.StockReserveSucceededEvent; +import kr.magicbox.orchestrator.application.port.in.HandleStockReserveFailedUseCase; +import kr.magicbox.orchestrator.application.port.in.HandleStockReserveSucceededUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StockEventKafkaListener { + + private final HandleStockReserveSucceededUseCase handleStockReserveSucceededUseCase; + private final HandleStockReserveFailedUseCase handleStockReserveFailedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.stock-reserve-succeeded", groupId = "orchestrator-service") + public void handleStockReserveSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] stock.reserve.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + StockReserveSucceededEvent event = consumerRecord.value(); + handleStockReserveSucceededUseCase.handleStockReserveSucceeded( + event.orderId(), event.customerId(), event.totalAmount()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.stock-reserve-failed", groupId = "orchestrator-service") + public void handleStockReserveFailed(ConsumerRecord consumerRecord) { + log.info("[Inbox] stock.reserve.failed 이벤트 수신. eventId={}", consumerRecord.key()); + handleStockReserveFailedUseCase.handleStockReserveFailed(consumerRecord.value().orderId()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/annotation/Idempotent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/annotation/Idempotent.java new file mode 100644 index 00000000..1d866fe4 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/annotation/Idempotent.java @@ -0,0 +1,8 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.annotation; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Idempotent { +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..0439649e --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,97 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.aop; + +import kr.magicbox.orchestrator.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.orchestrator.adapter.out.persistence.entity.OrchestratorInboxEntity; +import kr.magicbox.orchestrator.adapter.out.persistence.entity.OrchestratorInboxStatus; +import kr.magicbox.orchestrator.adapter.out.persistence.repository.OrchestratorInboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final OrchestratorInboxRepository orchestratorInboxRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.orchestrator.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + Long eventId = Long.parseLong(consumerRecord.key()); + Instant occurredAt = extractOccurredAt(consumerRecord.value()); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } + + return transactionTemplate.execute(status -> { + if (orchestratorInboxRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + OrchestratorInboxEntity inbox = orchestratorInboxRepository.save(OrchestratorInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(OrchestratorInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + private Instant extractOccurredAt(Object payload) { + if (payload == null) { + return Instant.now(); + } + try { + for (RecordComponent component : payload.getClass().getRecordComponents()) { + if (component.getName().equals("occurredAt")) { + Object value = component.getAccessor().invoke(payload); + if (value instanceof Instant instant) { + return instant; + } + } + } + } catch (Exception e) { + log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); + } + return Instant.now(); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/configuration/KafkaConfiguration.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/configuration/KafkaConfiguration.java new file mode 100644 index 00000000..e0d451e5 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/configuration/KafkaConfiguration.java @@ -0,0 +1,22 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.configuration; + +import kr.magicbox.orchestrator.adapter.in.kafka.properties.InboxProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@EnableKafkaRetryTopic +@EnableConfigurationProperties(InboxProperties.class) +@Configuration +public class KafkaConfiguration { + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/DeliveryCompletedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/DeliveryCompletedEvent.java new file mode 100644 index 00000000..e16045a9 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/DeliveryCompletedEvent.java @@ -0,0 +1,12 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record DeliveryCompletedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("delivery_id") Long deliveryId, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderAutoConfirmedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderAutoConfirmedEvent.java new file mode 100644 index 00000000..dc18a80b --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderAutoConfirmedEvent.java @@ -0,0 +1,16 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderAutoConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("gross_amount") long grossAmount, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderCancelEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderCancelEvent.java new file mode 100644 index 00000000..0b56e228 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderCancelEvent.java @@ -0,0 +1,13 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderCancelEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderConfirmedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderConfirmedEvent.java new file mode 100644 index 00000000..83ad390a --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderConfirmedEvent.java @@ -0,0 +1,15 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("total_amount") Long totalAmount, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPrepareEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPrepareEvent.java new file mode 100644 index 00000000..c5ae46ce --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPrepareEvent.java @@ -0,0 +1,15 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderPrepareEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("total_amount") Long totalAmount, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPurchaseConfirmedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPurchaseConfirmedEvent.java new file mode 100644 index 00000000..58f718b9 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPurchaseConfirmedEvent.java @@ -0,0 +1,16 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderPurchaseConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("gross_amount") long grossAmount, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentCancelSucceededEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentCancelSucceededEvent.java new file mode 100644 index 00000000..66c5cbc5 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentCancelSucceededEvent.java @@ -0,0 +1,13 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record PaymentCancelSucceededEvent( + @JsonProperty("payment_id") Long paymentId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentFailedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentFailedEvent.java new file mode 100644 index 00000000..035e0f68 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentFailedEvent.java @@ -0,0 +1,12 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record PaymentFailedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentSucceededEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentSucceededEvent.java new file mode 100644 index 00000000..c3b8341c --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentSucceededEvent.java @@ -0,0 +1,15 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record PaymentSucceededEvent( + @JsonProperty("payment_id") Long paymentId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveFailedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveFailedEvent.java new file mode 100644 index 00000000..b61b3f84 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveFailedEvent.java @@ -0,0 +1,12 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record StockReserveFailedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveSucceededEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveSucceededEvent.java new file mode 100644 index 00000000..482e4599 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveSucceededEvent.java @@ -0,0 +1,14 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record StockReserveSucceededEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("total_amount") Long totalAmount, + @JsonProperty("occurred_at") Instant occurredAt +) {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/properties/InboxProperties.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..3466b488 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/OrchestratorOutboxAdapter.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/OrchestratorOutboxAdapter.java new file mode 100644 index 00000000..0ed79f5f --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/OrchestratorOutboxAdapter.java @@ -0,0 +1,25 @@ +package kr.magicbox.orchestrator.adapter.out.persistence; + +import tools.jackson.databind.ObjectMapper; +import kr.magicbox.orchestrator.adapter.out.persistence.entity.OrchestratorOutboxEntity; +import kr.magicbox.orchestrator.adapter.out.persistence.repository.OrchestratorOutboxRepository; +import kr.magicbox.orchestrator.application.port.out.OrchestratorOutboxPort; +import kr.magicbox.orchestrator.domain.event.OrchestratorCommandEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrchestratorOutboxAdapter implements OrchestratorOutboxPort { + + private final OrchestratorOutboxRepository orchestratorOutboxRepository; + private final ObjectMapper objectMapper; + + @Override + public void save(OrchestratorCommandEvent event) { + orchestratorOutboxRepository.save(OrchestratorOutboxEntity.builder() + .eventType(event.eventType().getTopicSuffix()) + .payload(objectMapper.writeValueAsString(event)) + .build()); + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/configuration/JpaConfiguration.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/configuration/JpaConfiguration.java new file mode 100644 index 00000000..f8baa0e7 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/configuration/JpaConfiguration.java @@ -0,0 +1,9 @@ +package kr.magicbox.orchestrator.adapter.out.persistence.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@Configuration +public class JpaConfiguration { +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/BaseEntity.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/BaseEntity.java new file mode 100644 index 00000000..2e2ab8ed --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/BaseEntity.java @@ -0,0 +1,31 @@ +package kr.magicbox.orchestrator.adapter.out.persistence.entity; + +import com.github.lian2945.sonyflake.annotation.SonyflakeId; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @Id + @SonyflakeId + private Long id; + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorInboxEntity.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorInboxEntity.java new file mode 100644 index 00000000..4b25fdfd --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.orchestrator.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "orchestrator_inbox") +public class OrchestratorInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrchestratorInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public OrchestratorInboxEntity(Long eventId, String topic, Integer partition, Long offset, OrchestratorInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = OrchestratorInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = OrchestratorInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorInboxStatus.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorInboxStatus.java new file mode 100644 index 00000000..405520e9 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.orchestrator.adapter.out.persistence.entity; + +public enum OrchestratorInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorOutboxEntity.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorOutboxEntity.java new file mode 100644 index 00000000..1f41b201 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/entity/OrchestratorOutboxEntity.java @@ -0,0 +1,28 @@ +package kr.magicbox.orchestrator.adapter.out.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "orchestrator_outbox") +public class OrchestratorOutboxEntity extends BaseEntity { + + @Column(name = "event_type", nullable = false) + private String eventType; + + @Column(columnDefinition = "JSON", nullable = false) + private String payload; + + @Builder + public OrchestratorOutboxEntity(String eventType, String payload) { + this.eventType = eventType; + this.payload = payload; + } +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/repository/OrchestratorInboxRepository.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/repository/OrchestratorInboxRepository.java new file mode 100644 index 00000000..9768c204 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/repository/OrchestratorInboxRepository.java @@ -0,0 +1,12 @@ +package kr.magicbox.orchestrator.adapter.out.persistence.repository; + +import kr.magicbox.orchestrator.adapter.out.persistence.entity.OrchestratorInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface OrchestratorInboxRepository extends JpaRepository { + + @Query("SELECT CASE WHEN EXISTS (SELECT i FROM OrchestratorInboxEntity i WHERE i.eventId = :eventId) THEN true ELSE false END") + boolean existsByEventId(@Param("eventId") Long eventId); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/repository/OrchestratorOutboxRepository.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/repository/OrchestratorOutboxRepository.java new file mode 100644 index 00000000..dff7202b --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/out/persistence/repository/OrchestratorOutboxRepository.java @@ -0,0 +1,7 @@ +package kr.magicbox.orchestrator.adapter.out.persistence.repository; + +import kr.magicbox.orchestrator.adapter.out.persistence.entity.OrchestratorOutboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrchestratorOutboxRepository extends JpaRepository { +} From 00d5ee9d31ee6e872dcf1aba642b183d5aeeb856 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:09:24 +0900 Subject: [PATCH 012/116] feat/120 :: Orchestrator application-*.yml, Dockerfile, build.gradle, Gradle Wrapper Co-Authored-By: Claude Opus 4.6 (1M context) --- services/orchestrator/.gitattributes | 3 + services/orchestrator/.gitignore | 37 +++ services/orchestrator/Dockerfile | 6 + services/orchestrator/build.gradle | 13 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + services/orchestrator/gradlew | 248 ++++++++++++++++++ services/orchestrator/gradlew.bat | 93 +++++++ .../src/main/resources/application-dev.yml | 37 +++ .../src/main/resources/application-local.yml | 43 +++ .../src/main/resources/application-prod.yml | 43 +++ .../src/main/resources/application.yml | 3 + .../OrchestratorApplicationTests.java | 13 + settings.gradle | 3 +- 14 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 services/orchestrator/.gitattributes create mode 100644 services/orchestrator/.gitignore create mode 100644 services/orchestrator/Dockerfile create mode 100644 services/orchestrator/build.gradle create mode 100644 services/orchestrator/gradle/wrapper/gradle-wrapper.jar create mode 100644 services/orchestrator/gradle/wrapper/gradle-wrapper.properties create mode 100755 services/orchestrator/gradlew create mode 100644 services/orchestrator/gradlew.bat create mode 100644 services/orchestrator/src/main/resources/application-dev.yml create mode 100644 services/orchestrator/src/main/resources/application-local.yml create mode 100644 services/orchestrator/src/main/resources/application-prod.yml create mode 100644 services/orchestrator/src/main/resources/application.yml create mode 100644 services/orchestrator/src/test/java/kr/magicbox/orchestrator/OrchestratorApplicationTests.java diff --git a/services/orchestrator/.gitattributes b/services/orchestrator/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/services/orchestrator/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/services/orchestrator/.gitignore b/services/orchestrator/.gitignore new file mode 100644 index 00000000..66d3b618 --- /dev/null +++ b/services/orchestrator/.gitignore @@ -0,0 +1,37 @@ +env +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/services/orchestrator/Dockerfile b/services/orchestrator/Dockerfile new file mode 100644 index 00000000..2bdb1905 --- /dev/null +++ b/services/orchestrator/Dockerfile @@ -0,0 +1,6 @@ +FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu +ARG JAR_FILE=services/orchestrator/build/libs/*.jar +WORKDIR /app +COPY ${JAR_FILE} app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/orchestrator/build.gradle b/services/orchestrator/build.gradle new file mode 100644 index 00000000..f738d846 --- /dev/null +++ b/services/orchestrator/build.gradle @@ -0,0 +1,13 @@ +version = '0.0.1' +description = 'orchestrator' + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework:spring-aspects' + implementation 'org.aspectj:aspectjweaver' + implementation 'org.springframework.boot:spring-boot-starter-kafka' + runtimeOnly 'com.mysql:mysql-connector-j' + + testImplementation 'org.springframework.kafka:spring-kafka-test' +} \ No newline at end of file diff --git a/services/orchestrator/gradle/wrapper/gradle-wrapper.jar b/services/orchestrator/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu z%-lD<@61@6?qUPjUg|mF7!P7`hx+st`i!^L7HVHtzwnM z)LuOANIzT#9tU4)C^WIXhZWqrO;jr_O5aErkklzt)R-JmAh8xHMJ>x>OvTiuRi}FY z-o@0kFwwl7p|ro=*2q*cFRX5GCq-v!LPD)Sq+Uz~UkOwx-?X&!Q^4H)$|;=n9{idC z0mJl`tCTs3+e_EFVzQ}s`f_4fijsucWy5y zarHoT>Q06Z4yI1RPNpW`@4hSzZT|J`MU3i(GqNhm*9O@MndJ{31uA^i zXo&^c`EZ}5W)(|YMl##@MuSK#wyZ3dwJEz*n@C(Ry$|d`^D=thayXFqxt*WW&sWdI zdm1wv#VCKa<7d2Qc#qzvUvivhK5wq*djL7Wqjvf}-c~}d#G)eG`(u<`NGei`BFe4Q ztTSs?Gc8Ff%_5T4ce&J0v*FT`y_9r!Po=sPtHs5~BlV6VEUNzxU+)+sX}ffdPTRI^ z+qP}ns9yQgjY^t0ddMx1Yd`|OB{sHnUC-B;qum1|`tR#P_@llx>d z=qpNN&?nZib(t90A9F*U%1GbB+O;dq!cNgmmdCrK=(zS1zg*9(7VMfv)QMkt_F=wz zHX2p4X-R*=tJI4A)3SrL`H^peBNHh&XC#sVR3D zt17qeF>BaCZNlQO7n@@BuWs&l(FtRjaVn~wW^x-GsjpFH!ETyl7Od{Wf;4=bzL5nj zW9c^ZodMnN{3Jkz2j2;qhCm1ede*6891vR9?(Dy)N|iENw}HKLIOrjB0x)pEs-aS{ zZR$tEyZxbP(;(l43^KjRtSuirNmw~Bg&6p;)vqM*>S#L>0+Pw5CU%4@&)8OX2ykYQ z^f^hk-5%!QzuzYniL*1Gs#S5Kp_*ld1EAmkInP+^w?#(?rbC2Bm&0c5Ko@6`_ zi!Nvd391nu^@AmpZ$_0fPR2~kQGJS7lSGwA7U>s@+!d_`(P5y;MT#U~_ONSo9d+bf zVj6MgWN=|%#Qn;vl*TNLE$Mw|*89{yJ=WN>j{?T*vqa$U$2_dg46R)8wl&CNS&iK{ z>HDBC9e3b3roJd}gK!T>takKP);KLj_9T;%knG_fN^S$4hb`E|)qy__^=mm&Z{~CF zhc*PxdrJ@xRkQ-8lbh3Ys@2ZaR)Q3z**-VSgeMHE>c5AH1bpSUor&dgTiMd5Wn|(# z8Rwb{#uWZG(Jo0co98|mg5zF}M*d>gAg|Zdex@}Ps&`51({MmNyHF;GD4EBT`oP|X zd=Tq9JYz*IP%@2oujruVrK#jAT97|%ww60Ov2He^5zA4)VihJ$-bxoaqE7zU$rmK) z#O!xp&k$!TOEiC8+p6`Q)uNg4u8*chnx*aw=#oP~05DS&8gnL>^zpBkqqiSQA{Ita z%-)qosk1^`p&aB@rZ#)&3_|u{QqZO z{f{A3)XMprL}2{=pM$*`z*fY;{=4e=u7&=s+zI)ANd+V!L%#^2hpy@#N-WbB%U2Zl zgD_E0AVVWdMiFi_u2qqxeAsRzD%>l|g-|#$ayD3wHoT{EUS2Qe zEq=ryLi%iMZ`b}tSYzHInTJ{mY{OXy0)T&Rly3ippqpTk%A{T+e?K}j zURM^%!ZIWxW$32?Z&q9)Rao;#KQuLv+^ft>o|6c@QD=_}ql%5Th=cR{P)_51Qxjh# zRJW<|qmpRn3(K1lMwU-ayxjsgKS`Q7J5m0kw|LQb=CbyahnoQTWY z?g8-#_J+=*r`Jc|A0(MOvTc0kT-tBLIIFCd6Y5iCr>cqubJu0`Ox+FkDWs^L{;0mc zxk-nf?rxh(N<1B;<;9PSrR4D<*5!DvA()O7{vl9sps3x_-Y_w>qC3OI!_Wyza8K|E zAvJvWYyu)(z*TK7e+Q#dFWd_7%;fn4Ex*lEY2$X%SP9K9d6yWC2M!3>3>tu}g4R*V zRMC!~oYyF#Izu$lGjfQ?q}KD$rpDMRjF?f>6kuBlE`z4Yxy(Y(Y+Dr#PKA}UsSWD? zm|ER_O==Y22{m%cO1jhu`8bQ05@MlII86NP>-_`<|Q4g1f7Jh*4%=yY_ zafIlUJ2zA?dT8&WTGLE&gvPl|<0zKa=DLzzPOU7i#nate!Z3u|9R6E(6FZ|(EZ%+b zsB!MEkGz1K*oXGdp^tGOWyF0SI{tq>^nbgX|L>uTert_v9gIv#Ma|5OTy0(c_qQUz z!2+;T+eysD^IV+aC=aX$FPzbq+lZ7Gsa%r9l;b5{L-%qurFp89kpztdmZa8Uo!Btl zu7_NZMXQ=6T6+OFOCou6Xc_6tf!t+bSBNk)mLTlQ5ftr247OV6Mc0v+;x&BNW0wvJ zjRR9TWG^(<$&{@;eSs-b796_N#nMB4$rfzYM1jb>Gu$tEpL8-n>zGXVye2xB-qpV z&IZjhW#ka?h8F{QJqaK&xT~T;$AcKQD$V>$$-$x~1&qfWks(mJ8#7v7m4zpWw(NS( z5j0d&Bs4g)>{7yzl-7Fw`07Sj6{vw5nwVyVt8`;Rg5bzISP26=y}0htlPKRa8CaG# z=gw7__ltw`BWvICf>5(LFDFzC7u-Ij7*OKwd7685%wb6a=QD1CjpQs$^2~cx`@xS` zNMz6?Q4OgIR8LYa&m`q*QJ%!CbD#=ha?38!M&7yLA1Wn}M{$nV3-G0@@bD#WjCYI) zKFZ`bf$tFF#}GYZ7MK2U4AKI-GY*y(&DCt~4F1!3!{>cK+7XAfKw<)Jv$b1vHkpC;gl=VNy?f-RI(r=&j z@Dy@&vHYi$GBI*-`1j-=qpI@{qwt%et&>`VuG+PYzF>DUM1!h|8sz~*0>sA7|IH_y zskL`MJ4Yw|Ru~}gzgCOOEDSyuM+ivsjt@13h-SLD|INP2zRO|RKEDz$_zlt)ZWYQg zKHk`_;gygz9b$7*)WKC(<}zQUY8M94a#Tu_OEyX$Lej=Cs`b}zjTYvv-Jt6E^_bV) zCt>gvm2{y2tK8Uy*;ruhTa_?lSIlV;r8b zX?jME!z32pO8`g9ga%`RQ*v=F0O`bnPZebx@b#ZfQWvqZPAb@zl>ORo<_o7Dp&F?6 zP(tBH@~c-Zfx?Ulkb{F`C1S8y3F;;)^MwWBiBPQ1D=;yC{M-i~ILSfh3K!Ai{5c?J zdLm0OmDsWuV>%}MT*Qf<$UT+M=7pMVdJGRi-rdW>7iM&2UO%v@>_!inA`JD)lrKC& z75Y)Lg~PVq0Ge}-g$8cy0w@sHjUuwMm1|~u6X!*fGG>%bAbv5cEU3nR6&6o03J2ff z)*M)kj|gyvZ6Md8Y!m#IuWuP0<9daW2gPDp*=aQA2qm)VLJ($UUQ>-4&3LX|)=-g5 zDTzngTm?JwMM46$Z22o7jlr3Vp3K15k^@=c7JJx9WQg*XbLRkdC zYapmoZr8J8X5n5}a2xjY35bC^@Ez{}9JA&aex@>JiMr#&GtJGn$)Tt=HVKx@B+w50tPaNkh{N0!^9>r<#h(fr3kP@a(N1!O)$rdf&Dd!hhJNtXD zIbx!f3YSHV50oNza38Kzd9Vze|NZlyBd{fKzZOSB7NqO*qDh)*>XW~VnmJ^ zji(MF3D>tHCk-^y37b-c7t1Zrt)VBlefNnY+NH0u=9IPbDZ1z8XbK{5_W?~aGs@o& zTbi2gdn~PB;M%^{Q*d9xWhw;xy?E}nCbBs0rn@{51pJ@6e=LQg2dvlq_FM0;Iel9= zz?V~4Y+a&wJIgvt5@%1FDtB9(A<-f!NpP^nl51v_hp$v8$w{ z=Rh2*Y?stNGlx7wbOLqrFbxg3lqpaaN{@9c)nNxe#D=Xouh@g7Wd}stZ!B8jrc4HPmOW%Xt^a!LcN8M4^efD8wWziBkha6&KggDq^9beRoiLH_z9 zGUiqkIvsoqX!3F)6qr+_HfB$D%@)T=XV3YUews|Tg-Hwn^wh3)q=N>FC*4nHJ+L$K zpR;I6Gt%?U%!6mxrP$mlEEiT&BVf$x(VJRuEIXdqtS+qfX^-@UKefF=?Q z(jc2Y2oyEyr3_bP|F%)C?~RzdfbNXgw%b_zaAs2QbA_QL+IyP^@l+{#{17?2dn80k zljl~W{3$~wO4E?SSij&`vnbpKCUzN%8GY^!-wNR8=XKiz>yng^Xj99@bTW|TDw5XGfDje2@E z*~-mJF8z}cI1eTpHlg*7?K(U5q3H%{y84gCiDbksT+HB=ca!YVTu zgPDuJzB@76rs{is=F^_95WD#mg}F*~wRr~vgN4^*Gy=hUUD_~f0QPh!&J7XP9zv&H zY}Zm4O#rej< zQmBNK_0>1jXd)Y3cJi(*1U|!mL(;nU#j_WV33)oK-!s$XS(mQqWqQ7&ZZ54iT5+r| zi|MH>VJs`1ZQr<{eTMqC#Y~41>Ga4BuQynUV!QuZeaFa6aP(B)SxC~V-r0K5 z5BJ<3nuAkX12%0k5qI=#D*PNg{NNjn>VUnvH!{DfD}FX=e%E5lw-IZgDqD$1an(zv z95TXS9wGg?Bl{w91nOC8HvvD1&ENr~L>4u{^bNaBD>ZHXIw1Ko!;wjz1%zZMbWE8# z7f5xlDTQWK%rH+)0KY&O>*EHs@Ha5t9ltEE{qv`K0tO?W=jgzciZhHZ4As;i<7{@M(!#&K$4UGQ?~d6rbu|rCYd`D!Bgha2*v# z?6){N62Wq7br9`S=y(rk$xKExQsyv0H~Z<~f!Z7~Wt6SlJBO4_KeNahC?2rxh%Z14 z{6vx|=@Pd?8vwjCEbf?V*zgc>36eg4u4w8WMluPe+qB=i60{qnN+XKmud{LfKvd^Rf{8@jDa#RaXtvGeC92KvnMDV3m2 z4Xt7QB96VazV=Z?RrMXb$#mb85@y7X+OE;c6PL94T|ssUhD|n8IM`GhqU%%}=6E(! z@O+LF*%Uy084M_#De*pBSU<)G3|%go1vt<|<(ZKk{3&*44f?ftxS-a(+@u_92o7ot zYq%I+Ztyt1x5RPt_1it>&+05XbK1B{-T~aA+FN6BiF@>|QCJ`#y*u z@e*p+J|+Jzl4qtDnLJPde6Gl8Qfu5eP#Lr_}cyBzGaR912ca0h5s# zbgocm38uvIstvyAPMEgVj^>{XqR&db7$(XJRTRiR@!lH>>CTe{+zRJEgcn{?M627> zsw6}Y)J+s3)u#g*Mo19)oWp785&T@;fee1**^o5#bgS4epuPWP>~Y2v-~{)-me7SK zd!AQUXsd{A=;C;8>vRTE5Dol&>XJ&AYMijyXV3|_46Fr#lz`uF9dT^PhX2e>lDN?r z>wx*9-Pr~siloVs7@`dn*kGmY0xP)2odnz6S437Hi&}MSb1iiwEiwfy=f;yg# zDZojIe7{n|lnmh@$rU>6-%oUGrG#^0y%z_Niq4LG38Yq&Dq<~B-3qLMHLbL;&A)i3w zq0}L%{J2P1a z2OC$%f4j5C`~!#oBU=IP{19v?%zqxLR77sUDKZWk1TEdClEz1yHB10F7>l{;9l0L|=ADc&?i zK#F90YE|)m(u4LGC%M^0?53NrH3M`xl2{P!5+fC(H)Yt|t=X~m+os4b6}Wj|nDvL8 z8n=Bhi`Mq$&2sm(8n4F2)~_ylMf-R2rn!V)Bfzhv7v2SF{79o}>ITpgUpe=zcRpds zp^3fse>q!&ohi{7gYJM|qD$1?s^vyP1XP=26O)1AFu)?|OCYHCJm*LP4*zJ8Raq1u z)9(U+oYRkni_C&!f4&%ORK?w$g6<;rT((@LunPCC_#2P zxJ&Q13mCI_U+H?IvV89Y)i_#NnNt!>xavHwF$|O zXuHG5oCo;G6F&W`KV4I0A-(zyjQ;ws!05mAr~eli{U77e_#bTiA4Hr~$mBnaBxQ^3 zlOJG&4aI|YIUi&Z#TBHjLS(GmY^z5R28NolKW$l^Ym#0I3|0lI-ggSR?CgqX8f;MBaPl&YzSG} z4(9gprQ%M^N3g+r;f^a0BNw0BQ9}e{Op$ssU!0cTdbP z1%BNUh*RkAe#+jya`#(*p*uQ|spESDMarSs8h3e`E#gtvYi=8d#ADvy9g>R@*^D~F z2t#h@kzA0JK)w;AMPg^lWi2XAU}jpiDF!akXK|rSi6}wmaK)KT*81I6M}f%l3XCMR z-&LC;?s53?Q?B;UuDeB{5^S+oOfSGE^CnkvgEc9^13~<4(iGap$VY8}3$6;-sL}t1 z4d0l&nxB@pZuYHH` z{ONm|SH}iy2^)Zg%Ou?*Q?I+u&ZmckE<;nVG0STB`M9GzLE5UAMeRQQJzJxXBBwA&_T6LHe4yGpP7i~lax~#Ub5BlJE zg>YF0Yn0Wcsv`EJIW^d7i>M?PO5_+)OxDS;9?zPfCH;#_rpR4-*9!|aogttErPHlR zUf2d~4Xa7AEaZSe)Mn9=Nd;=@JUDKUaJU-Rx~HXERZPZJTiBwHdXup>tP-Z$yw6H? z{D8e~w09((x@w&~)75oSpJ7o&u#DUKXAP}9afG;3qf=+XWeC!=Ip8PJvw~{@B3H)k zZr>U-w?x^Y3%$zAfoF_*V2Mlr?I=_C57F2k-rurm=_3`CHmW^yY`ye5aJG#E#oU&y z^R4vJ!2z7aF;V5BD1dbHn6(R25;-0cu1Cet+$J~Uw}=H_%79gf!-W2#1g=S`%zSN- zwVT1}5o>Hi-DpkU76(;YW&Y92O;@cEU^coXt>XfiRWI$}_*t&RQ_K?A8!$gpQKZe> z6VsBW458Q0>X1E#m*K&U%))^SmEntSPBAZb7VW{C@EA7Plo3r-`7EMb;;WeQn0bRTSxW7MTSYNoW=(qCsKsMVCbY?$#Z{|k#%NHM zA*6=sc(VKVE`UVqumIooHMGYRSh$SD{ErAy8%i_*n<=4ODdFErVql6WIx-X4fyaoz&jU+aYlbi=W`&5GJ~zS*@5IRv9cn<|il?|!d8>N94!OI0)aLF!Q0nlhtv zV$SFv61Ek9=p#mMT*~J{BfjK)?1ss~7B8LE@RPM6>=Q&sCt<9ZWOlek61x3T53zDy z_Ki;P_XP~dr)aCdrp;^Xx&4zy791bkXYcFE&ul#uoMVnctVZzl-Azp*+fw1N@S40^ zWBY6U4w+j|T8!q!)5)=7rk~;72u(J{qztk$Rb^WOCbU62Z^s|pn=)TqT4{gYcX?y1 z?|~>Cvir?R7Ga#&UI_thW{axhKZmGsOKK2*Z5|H*2nrEoD6q0cA?LAuQGqE#iVxT) zkKFW#vDut&E=}&^_xyn@nKhBk4S$!WNK~%$ z0c&2{SDdyuxlzV0ph!Peph$e2NH|n4;u};Z5-fDRQCkV`hd9~Qhw#l z5yeB&7zlX?y>QU?3e8P%Gzk1X934Q9LPIvcZi~Q>$tU#A^%^O!FsqRvO1M){#{wo# zBk9bs(!8G_zMYJ-^KkkOmXlld6&M}R+at4#TYfha^(?3_OqFsw=T6Gudap+sqFPF0 z*6D8MYBS6E;rkj8{7GbNPpnUPv9*l#u0T^M#yAbod>pw)srdC}u6;9n!}f|*m@!$~ z1aL-1&ei+i_Mkf0!?>5p@ss}z+(4GaIZ0Tu^mr{+M1{}bS8k3r~HKz!?C`p>TW)1H#Yg*vr z7Y{a{9Z}e1N<7QR%urOa_cLshyVKNaKNU@l7j~j>PeI7MIZZ|r0*YSjU6P_&ia|jH zDoChFYF-JCkoNDw*&*{QG3x+J%2L5_4`n1Tg9hatvloFoYL01#hFFj~!}MRSdgSSl z=m-yq{#uwWUIpuCs@%BEy5ob11|s~&TVX8~-XV)oMfeNdXD?Z9E10-tP#Krhiv$@dBpKj5J%t@Y2xI!*8s~Z z29}0zR`_9s&89Brq4Tru3F{G&uQu{ujBFqN`NY$Hb>qnXc(a!g%hbv!R@n6sNonM) zg649UVVIiIE)_J6eMZ?R^6HGdRMn-UD36*c8_Z2r&xc^Cs2p^v6x-_j{J)k91n!wt9I-~_PA$GNiLi=u7ixtk`YUQ4uIF+`SI~U z1J;MiD+DHLSA)nBsc8CJW1Z4F5uFXI0GzFHhs4egAoxF&>1&8*Nl_OA^!wW4GJCRO zwS%7>sOyj*5EN! zUpux=mBP|Q*_J!@%f6V&EZf{?`H}D&1^^@HO#Gta8P{W+FkdO5OW;fnD1|4&tlh3} z@YGnJ3d(Y0t#ep+bksNs#e?8*u-V=@#Dvz21#EB=jam5x3MtG&IuRHU$pr(K+Y-AX zn7FqKEk!?hw{HWBS~^ioY8Dbe(VtwFva+1h5$-}M9!~UYHGIL>zwFFN1`lcLe zwaMY%;tKHw`EL=C_^}jKY3YhWzg-&!anlG&@4E|`Vl}0q!EvCtT1I@}=Ug2;8OzB) zmllrTJ}RHtO2N@|-7)oaf*v0`{>2c|j?-t&WbDWOUDsBIUR24HnS0{I;>(%9+r)y* zg2K$nGPerx{E6HXH@h?eRQC~Y44A2^$`xKRwnOj_7pT5_!?K%>JT+F+ z6(@ZUF%FqvCBG2v8WL04A5>D=m|;&N?Hzcdj=|%{4JK2j_;hMKOfU}I+5PVH87xo# zc>v2%1gFE>V^6x3$7#ymLM62}*)(ex+`ImB7=eUwa2O&zcN_th9iPz)#fXNbq_VnK zg>+Fagfb53(>-Y^v23^|gST@kT%3pG*YUyrd-zn|F0Cr_;Qh)MO;mTE$%x&%B^Oc= zO-<|3$Nplt0sdxXQO`|RVIbVxm_^24G_6XuTxk&{Yyl+?OeXa-!t}8&fuTGLZpS|{?$S9qu^8TDrgtdOu`4*Sqx20lCJ(;z6u7&0EbrB@495}e zvjfw8yG7#Eo7QX+`k$3*tbTCwGm9LGOvTam&Kk&4&(T!!b0d-h(+s160p@Pn+_M|) zwasiA7r)El>t5DJfiBLb@2=gQDN0N*FfYuh&F<6BNcc)=oqju*S(+ucbzy4pyN1%s zgS@}T`xoCKJdeoM>hW-Zt9xSNRYI8RfX^{UPSJ}y8$_k~4-2G8KZDJQl``0lf>>)j z^q^y@`VIX~W%W-QAF*8U#?c|>tGQ{a09;)CL{-NfEv_2<$o(R8`V7xFRTl$)d~KX! zxG^v#xd(Z9R*`P* z8NwYSrl;qaYDzF0iB%{|A(v0($}TDr##;!y6paThkw{fnuKExakKusCdM>46hESJo z6Z4inrJpt`IzSB{l1R?`XS)o3@M9OZsiP&{y4g5QBH!U*Fvdd|9inn^a}Nz>2&)`? zh!|tcpGBMA4e|H2Y3)~7iyNUBsc|aN0$HM9Uc2MDIL(61;J!I)NmIwv>&&25`&+6M zq1}!I%Azc>=L(6nYlCWwU59Ea*szPa>sE|5)2pJsAnOmce3ZqxF(4^b@uZ6D1K#-5 zD6|eu@+l+j4}V7yxluQ@oX?sla^=5dw}yP&j6E+69hswg1L1c=)OyvZ7^wHQJl;ml z_2lX#$i;=Fs}vkh=ukc4y2Vj2Lu7vAHQ*E%@5?3`^a{BzDVU zF)O4|`;uuAO@)kfdwp~fqS#rR$4Oj@c*zBS`-fL6qu8<7qzl8rl--^kjiCV!(vbxC2vIdMo2I^X@+ID zcT&$52_`~JOBXh&mXX+ceO*m*0_=9ArqG>xjMR;+M=q{e-N#QEj-BCAzAVeGSrXNh zCV`uX4qS?7l$u+*J~5P?9xlU2%6rgo30lJ)cd|FHtEmloD@8tO@5y7N5t*NZN|hrm z*0FP5k0_1u5$>dp#I>8az>my1NoIAqBZ!Lx(!ohP^U@&Vmqd8 zH=75V+`}JpR;Wj8!j6BT1WSjMs>H+3_*52JYs(04P<@$3WEVZ7V%N-CLN$onNB~*- za-hT{!s~K{EUyaw7zDbp7n5T~SRV3$*>Zhpg-*51L=Zj|oeHx)1Mr4juj_5;_<5%8 ziMWWR&MhgdLq0$}U0q=ol1xb)TQBdcV!(3$iF4x~ue+F-gFAGMn^|`*YBjuP=jx!~ z06>UuQAq?Ix&zn0^To|<4!CSXZW7o6VrM}5dYxV+Q~8-h^Y9DzNs{5%+kyFy5cysy za}2EkZyRxQ^Rgq)T6r=({uw7y@%D4S?wd{Ck@D0(;mjg4NbY$Z$xd6rCGrNITO04Y zO%6aZ!9hMp%kU=V6dLc($d`AHMbf`&G9BXY%xr$$hovCbBj@|K2-4_HjW4Xn{knIL zaKV)PQkC?JIKYK?u)1`rzd)G(eO222!%q#U6QaT;SUl*MO9AvJ_$WC-@uTOjb58L_ zQo63V8+G)0D~=S&a%3>qqG`7N+Wfi$Logc=SXGBq3&TV|=!!;Nzi4VeqP9=hV>H5k ziX8p2v_i>9nc1rQm(7T8t#sTSGnI9T#Ms(_k_%sm3mT6gc=YrdUm@Ip6xRqL0H93*Yx0O!3Qw+_Y!81*n-ovS%iBlXx62TFNbk8K-j=LOV=1s zwc7i_TsS%sk!R7r81r4v*Ec`Rrl_m zr2$@wBrDGJ1`%wG6Ar259e%+MkZzK88-X>M^WgfA@HcWJmPUeFdO?d0>gvCTn0-ZWgb;$}~gdQiffS0?*jk$T`izb=V-&N#O_U4yp?Y!Mdlk09!o82t}+5dEvSj%vN5 zCBperFlf(sXr6C$n?zYvm=YYyz=~W1tkhvu1wODh>tKoBEiRB9*Py%96luTxm11-k?Q=g$c>y=q9%J< zVbw|kc=&DAiz8G*&G@8XlevEthbWV6a7nM1@VjKNkP|sl%x3(c9h#|9HIdVuC_??C z!MaVTrRI4=oMEugDa}D)#f1zPsr&vLR0Zy!7;QA4?x1w?=X%tH7o_(2z@8LjA`t^# zft3pe@**E=P;MFXEB+)Zh$?+;5%i6ECfT?A^~N`o&QHR5@V8a13HuA~omH+0(xm&s zJn#ru(@aCcl%uY66t2-NPi-*^o`hAyJ}I5kdqib+qh*CNP|jg>f!Wj#HJ<4r?4uCX zvkf`dDbhurH>#bk@3|Ap%0+kV-0PkcrZb0Q6)EJKBfaiae*!zLC7wkQ?cY#avSAHH z-b1`V^N9SgFL7-JrVQZS2rsHMA5v)j^@ga==T4XfE9yy6w7~pXILh8O)Le{Zg)9`|o`-$nca zc~hvlgOB$pGXop$oW3PzOuUbE^uRf@bo%^%%GEHQ}3uc0E<9SxbN+Fk6DEin>4 zHcD4f(K{ENOe$J0HJ#urqwE!{iYCcrgQT6kUmRQ&pZsx(U*x5m938GK3cceA-25P7 z?4_>Rtm;@LOJc>-Es0d2lZed7(#_R8eGm|eZ(xhjbvF{TQvs1jaS#K%R>_hqN0n}TZ* zkc089?X9=$pO*FdJ8a~1LwKU&Tl*+PUpFFBdK=aX&m5jxjDg5G1pXXNL&FXtQoDIi z%I2VE+_J15PN$4XB^X2Yje8=^qT3Q6Up)7auJ|SXIn8t2lJM#_5ql$SZ|nXfb&U<5 z+WD;cxsrkAy@tew0gl8PHWX0(qf>97u#=sJz7BD=`gp*W%GmlPa|+rCER@9rjcWg_ zl26OYrAyJyc>(x*jhp9DekXff;UF2NN;Ui}MJ?5ICzv@f9ALbJ?E#ZUr9Ic3 zzA*o$&I=Ta@JfZOEAMmeNUz9k93p!8X=>FBD$#aW*rJBSOJG_{E4u;M3A)vn3ZA*FCGn+Fg(4w7}cEUuvHYjNe3srT? zjGbTt%LY~=@?&|zrxYJ%v<6_xj4<+!VwleU+BF+z4)}b&?KFik zy?KZ%qJSTxm)WSC(-)vC z_LTIFihr!^y%i5PBEEPCOyW1(0O<=Ad}++TAQlUVUet+p^E3c}!Hm6Ker0kttjBIWHFAYVE28@r68QPb>)Vg<;d0ndg zIOg|&%Z^&B5koUj%;;F55>#Cd>y`X1^41GHDSIjVmR%4uBt$XKaBh6+p3un1m6DKK zM5nC$KuQFHa!O+A!tnBN$&WmSvCPz#nQaEXC!g(?sW+Y@AB1kdg2dM^(Gjmzs6*J zi>IYc&r4tXJ{{+;xx*UGux7GmUyf}GKo{&yc+i^CQk+fM5xwnR=XN< z!u~>Gl{|8NtTsKC_us}+!JbSFv?wd*)?I^VPt2vT`c;a6orPS2Qhe`>N1KB~dB}yP zspLQzZ>`?Hbq-7qJC#l@Vh{gOd0-=i*!QkM8LpL1X8-}g1mS#mh6v^#lwH+V0EAht zLRoZn@;eAS)m=80s0Jn#+sLq@zuIq|XFXByZxLIoN4=#LqQuVVkJJJoqdv}YdIi8` za&=Ppx)n$aP&MKW_^PY6l=m-iPXIGakyd*1%=})EsxHySwRk^AE?qcrR8hTjF`nFh z)+UT>wL0VXkVCY=24X|7B}!a=Gf)c2+1jXZ;lwogP%J5l_LHb4lWDj;(dv}Vr1IJ% zBzmFhafX~i#<1bqv&puIYKuHOPY|K%X&v{<{=yTL{$8uDcy(HHi}VDVjHC}Z7W0`b zEvA9p60jBWkkB5Rk#%5BJPS(P7jy(H&ZM=!PzvrzF1=cb@j0B{!WqXMl>4hvAUG#n zJd@sf-hvm66(tgSb~I9O>_*OH9ggr<9(jkPzpUP5U;9oi{-`RXFkT6&7UzshGl7YK z=w!GA{fajfE6<@$!92K|Md|hQp!i-X2J~nt=D;7#M2;}9l3LG<6`3C2w+L(}Swn*C-B*?`-k7j87(HI0e zOg>|2NSSo0G$Db|yJ=}l3XfUHc3P)1NIM4OhMgn9utTLY8mQE#BnS7N{&WXwxbPTC zj>^Vmu=6JO$5zNwB5NNSl0w;}jb@J-VA6wNi{X~PSBBYYx)&mpWiwGyMd~%>340*O<^m+;13xv+nsl@@4vWer8?fJpf?QLDsIAYG$AW; zLaEVbXdlU68j5l)of@<#27i#8e9acN)RqV5SD02bMKnOYW!RB{72(fvCCTBSVi?ru zbgDA#*GRW68N(c0E>5u>u(SP<+gV#x)7`Bp@SBKiVu<5JAQnY_TkLETuOirHXdSvS zvj3FIepQF6dAlF4aI!UHW_6)6yAM7CrBvn^#Qb^(|KMPUas1SycQijlWVnLIlvayxabGnXVuaQ^dHa@y9)=$QZH>SPegN=OO*~ zE)SFDbmX`%K>u)QKvO4)0Q6_1yp?lfgooarhtt<$z~YTO+(JVl(~ASc`owLsRkis`U_?MIJW!nR@Mo{TY+o9Pv7gjq0Br6 z69CC^k3Y>byZiTYSu$_l7lJPB2#srl$j1$McL;9;1JwOOnTj&h4}mWH-Vn?pBA#s3 zjm-omv~5W85u0g%GVKXOn)WQaVM*sXOrslhX;tKH6?3k};k`m#5;f?oYG{A|jfzVI zEawoElA5$S+%=j>B{ljl6OB6dMOtiz$z|zws<7A7tg64qMADNf&^>0E_v(v4Xo_qH zV^U-nQmvG1&4lmI`ITySApjtTHJlbWG-M3T*jAxeFp8eXd~QuT_;Rtxq6gbbb-=tw zoQ(PY91W&wSS2@?%S!N+c&XI*-Qe>8h;>EoRGL|8iL5JVmPFo`8mCcY@G7$%vVy7X z7@ReiXO;L?;tk6Mm3?VrP%a+9@9N45(_m|XD$^pZCLI=|=N&b3Eye{UTf~qseLt&P z!#sl$Vu>mfVC$4UM*S1iA&A8WT0&j2yWtx^d_y<4cNyNemon|ChjXI5IDRb_6+)L6 zHL>y7N+Zt&p4YiL#W9q4j^;U#_Uo|iALm532s#R|g|RtF1ga%u9(|3q*VEV07-Y_# z={jfTg|b)%84CRox5B4Px#rve>wV`e>F+Ihvw2o<_Q-Nv6Oskz6Xf0(P5Qe*HQ7l- zcH%D^p0}1DkU?Oh5Luxsh!wO zKUM!6-)%F>W(*eN%I<=x(m0rDftloG$@?ufi_0FJPvZ3#aSQ)qBP??BlZ)n3kR!u( ztnUxe)+T0*JsBGnx*NQaQ*rbN@u7$&a*QhLA>#~Ru<77+YbIJviqYiex1fq>1{FT# zFdi=DsQwOIHD+foydCEv&;U6m{f)}zJS3hga=b91my!N=YxAFN>}t3rbzl6j(22F3 zN=wsJ^$u!O$eS~g%{1`E%Z4(MfN(74t3fvCmpBFL^Zwb}W|;;%1`>f&|3*$y)Z>cJ zb4L4u3{QiD>q8`;X78t!poKbPNQ3F!N5@gjzIaM@VHUUjjLWq@kvi9sqbqS?nXGE8 z#+GiOoSb3agPl)kT>OYk63q+oSkS>R1&~Kn8mWrR@Ghg2kK(O=B0gr7cqQS&ZU#=n z!fuWk@yB<^!ZQXKgv|$6V&t7P%_Pw;Z6eX>n7u0VO2tT?Md1A_{XTzc4f!^fy@J`@ zL_xHu4pQ2%+0gi2MYpK?iQ^gAY+ZY~Gl4zpRA+4JCqhte=){_!sS#6~-(u2O33{G&qyu-3N|Q&_I& zrYu8ewgXs?(VGq;pSXyDqUfrqm8MV7=*kn-gajV?A&2rCKCU2b%V#8DjIS?*Vby zKbhSHwl(aey@M#B8n8X&2S?C9fc+T=k|2m>1p1jE^8a*p7GPC1+y5t}yFEv0biZjerCkVf)}=vc*AQeLaes5@b#F77Z6qAz%l-99zN7!krPb@WE@*haV*6;&%ac`t z$p+!J!?T5Q(0fA5a}OU8+PZ!Ndhf30kT((m^9FiJ79WS^vcFZ6gGuSj{S`e2Q%u8$ z*$=`FNUwnT3MQXg2wm@iypIy_wtTRvyLm345nt~Hjh{W&yk9bNXi)x$TYOmqRkBjR z62UrkX=#b5CsQ=dI{nd9hLOmmydWim_?39xb1J`JjsCP(>wNM~^8+bwt(VJK^`0=s z%97EYPT=bjs((ZFX-|N_y>DS zvWRyIuDcghz}MpyZE#*nQw|a4uW0zgqtA>*CLBdpjUhRD`mJFRa&;l=cRkT3S(l<+ zO8=_HSCLh~y|ftK(ajUECd|EE=Wy?Hb%c%#nHYPZLw9akcR7u!w5#-PioD>8RhE)< zt{&UjCzWN|o#^vd8j;6KXf=4}kMkCW| zVSxvE=u0vh*r$0-S(9P7Q5CW%^7bKVu=| zk>ZOJ}2*@xw z%?i%k;pi|RUQ44_+hrd+)y{B|7lfBZp}F!E)I)8)h6ld30f2zQD zTA+dMr02cDX+vCzfK9iwIK=x(6Jyzg^uR7;c;;@nWi3y`O@AqwhJ>;X- zN7gfZGgG5gwbGh~E(12E`qln~DWZnEFRDh%yxmP)2=<8>_4(`U0+5>T-4EU{^0T?< z`+eP>KTJFH+2mikxF_l^Z@%c<4BZl2RS?NPZ1r~7eLM)%xk}0y=Acd)Cm(z~Xvwb0 zQk7zx^wnc%U@M7vM_a$zg(1pPLqISuKU(`;+GHB;XjQ`ED5yW)tP!0z#M2FKs+Ds` z@d($Yzm}Bw#6VTT%Ge5*n?cNZ-1wB^I44Q442Ll-=xb?uqN`n``RUrAJG2xmJW}#I zW1SCEJv%R%*ur!4a{!F-lTBUWI$4=GO;;xgrKZ*Jp3sa<>ilJ{rnNT~(~B#*XEmiU z1~Ed`QBgYpk>YsHbLx#%E)o9--i+ZC9f^_7T3q*re!~_iq1d4WhP8%?V(#=QM(g^7 z>2+F74STNRx~BuypUTi!+)M{gS@jyMH($ZDu zKjsY7wy_tY=^3B$W08}!&<@2c!l~K6&#D)VB-K$kGlCyqCHZOrNP@szFIP8$SAP6l zAIjazY5FRXfEyma)Kg?SYc6gqIrvj&$otnW`!RzBpQi4fq)s=P5CdQP@)yndY7bUH zan{vp_Qu7}wY$KTn$j1%Y@h6=n?MZNqDJhm%WboRANR6CQby3{gRzTJfUkwKimRra z>v20v{=}dJ`%D)e01bVn*OnnAnvxkDMidvnnJEF&DTbM&P+`Ujq+6c9syhcdm!joG z*1W2nVX)Y4=7jc_kF3u24hP6*6e_ugdd-Zx2G;^;ugxy^C3B;tZE{9i)S#}n+Tm^Wl z^%KpO#g^>$))G%Ak1-6LUD#ZTRTn(7!9<4(>I$Q9zeW_j9T{_T6J6i{a*yI=rhgd@ z)gG{9+1{|l$zFGeY|`t&%G=$#LakN(kclKjR)UF-Ix%+c&+>+~j$d4Qmb}LruYMO@ z`qpSxlDi`75!wy{eqU`gG<%ZOL3iz#AK@!h!=>|j1B+Oe$GKu9eUZ!k_(1T+S7_kA zbJn;fO_sAts`Puo#$t6E;ze2?q_a>$w#+0nuk}*bYY8_IQmYk^aF^PtEnm9%vS?g- zl=f(*i$v;};DFLu)Ie}{;wBfYcRZ;#gqu}?q$J)G2lLswTD<(sxB!k1pp9in$Y8=k z^3JyAcETT9MmAB~bYMX>W~mpKeS-AdzQ{3eH)NL0Fva9G(r77Eq^5@T^jqfFHlZW6 zX`)orA@BS6J(?KBp+#ABTs)dY-6)A)m=B$=fl;)gp0w5h=kVgFEy%>zT==t#)Oswq zTr?{tmWGWFbDOksn&?;8ZO@~z1|4maoHqnx;)hZai1Oa97qKZ2`=>=Tqbi7E&k^Na zZ{=(CC~B6eo5t-^lBcfd9J7-)zKvBA>K}~;QMU(%+w1B)Tm0HTIfLh#lU;3Yn~+}d zUP0S|jo8kZ7+vu!d=$BZlVeRdZn#XTYejHx3KQ;O9%HU#dW(r^FcXBZC(y~Sm~%N} z2AJNk$S5a5XzSgPM7Rj`gO_&{#IQ+BaJI7%Cg(lRcrdBsB{DM zT8d*WSa9l7$|3s+xddzetVv2FvHpTmi>HO0ST5olCxQvl(GCf3Q9y&j7i|TuS52RC z$Mq$-RNqf4At8+FuTKP}#H=tDX#`r?5dsa5dEA@$R5+ZaAl)jTIpWtmtDot`nN#*n zhU~NvwXJ2@?Ng4=Ga)ngqKekQp9>riEd9DzgA}4BUwqIm0%Wss9jHUl$nKYqO;2N7 zknpSn9IQrcJR>i>8i4TbCiE{yOjELbLUDeF)~y3Xq^W(@CXkZSMd`R;HHADm=DLkJ zS;1I$?g$Acj(p>KT3D?`z_4LUo}Uvij?k=_H9S~+>bx^)AG{@fB`}K$xi6WJ!FPJGW zB~LoXg!SC`+S#|tF_WQeoMF^8u?W?f)9v=3VwpXM#@dD`br&6k3%WzaC(pjfR0`fM zChRRAn~rhB-s|T5e1XI1$7!j+-kyB4Yw?uPR@@9KfpTk%nATjRS13yeX_R>U?NRR* zYr(<$9=%ADVmjc*1V?@FRwNrtIjAjb6~xw zC-sWFLtc2tkj`HGvT-)9R$lY{zLj=HPa%BG;Eej@!{!SgZ7uQSkiTpuyam5P z5rGi-YQWO|GMX=FapkU`5NRBgpyZCbC47f9)TZ5%PIz1ivCfeoh~;Vbi@p|Pw7gM> zwb+um?aH84>hd{#m`B&9Hw?kAeS3;L=R7r;t*zfqC&7JCTJ}UUynqaE9fG)Oeo+9~ z<)#K&_ox+Nw&lB+9i|2E!p?w#If|`6#-*70{+ZT9cyNps75*mHJhbjb(M$RiL#Im7 zkt@=c&>5xhMt!=^u@mJ>AD$D_6u+1VyRkNNNm4B-5;&h9$MT0M8s71AN$h*tvfb!k&(H`x-=+RpQI>om@b>eBy%{M}3KN2#u_7ZsoV&Xy#uDxoRl2 zhZ9oKR?*q};PbY(m7gWgt{z{7YV^%w zc`Y^X^W2*`zFzR@pZ`FAYXD7ajJxrE>}I9XGO?tURZlH3Izhh)mjN#;L|i9=q<*Nz zeJ$l3es%o;Vkm2YSg0p_sEJfD;4905eJ~)3KL*>sr?_0fwyGKtmV*Mx?gOY(=^nPy z75*rmkv2($3TAtHYhv>G)jB4hBOwj?+DEI7B7nKguhhz2Yd1 z5R{LN%C|hj+rB0#%?eMKUp2KkGARiM^w%6HC3B_ajcD)SC*>BKm^LzSenJ0Ao&OwF zP*SjP9n;qLfKIW#zSsN6#KjQ=N9BF<<&EVWEqo{0Wy95oba_&mA2}DQZ?GFIAE4+$ zTSWyjBPuJ{I>+2{`XjGQUK|-8z?*tIei@>sC0eceal?yJ)H4CGLcpm&tzj$W8yN`# zWW`Z58t<@KB$*M=mUB3S1Ewuu;KvZt)Q44I^sc9(<6KD zz8jzDcL^6W2q>?&+~@GAhGm!bSVyKo4FcZIG@w+Qpt=z*Ug35;iTEV_r3KuuIY@AP z86i%AyiC(GJ?msLDzV2q&uEWf<036blx`(bK34rhL@TD$CD~KAPmc@j?tv4i(U$`9 zcWk#E6!Y?LEsmMJ0&nlU1XdZxd)a(3uMfNLXuUp;?^_>tzV(jaTa$0?-?6+ps6I8M z^B+WMTXsb|tcon?N_dCOn5B9n=!X7x%?0 zTWoPArre~5nAqwvGIZK;G@h1ctA0q9aR>+@?}8?$AnXuMICs=!+GRwXA9E?Tb*cs~c2&|aJbq|eJ7f#q| zoxW$gW$NCNCCs5dI)Z^%IkU1tA%66_qyJRWe0$h5=C+eor|YD9VtX=mo9i~)qd6;iM;BM3`Er9%Vbh*xkQP$9s^g?<6<&loxpnjh84ZhlM9LxMJBc zLXJ0K3!L}(&LVO@gM{JDV-#1QVN~`dv!T2 z2Qn;Li&$}sd(ekuw=gm4*!C?zfH%!{5U? zO_#Y7qV!K-j*(lr3xK97+d&CUgC{~Jh<6M)O$r&FwN{1 z20nbi=4jRBh^n!*wjSy8azByNjBI_hrIYM>2DjX@lKe#Cjb~HNQHwH_8rD&4I!0l; z_yD1aD4HlIRpaTe{;-Dp(o62$P92GK;Vp2_eF?x?niw86wX|gzR^&6S9>(;XlZu!P zg%R|xezBab&$a_p^tvy_W@JtUC?XN}cgE^{$r@Jj0O-eGw1y~*_g%tgOnARkghNuL z-{~{vK;QbpL8{T(kM6bO^)h}ux~es@-LTd;R=9)sxy<}5O;v>vrHj%91Z$l;<`Y(w zbdlOcHl_DeY2!3@#q;ILT9*;B7%PjE-TI@nj;lVk>o~L@x38XcbQ>sb4Q_ergjle2 z=1TP)RfEaI9>j4(%Pj#eMlOU;E^SAsx1HlY$8Ha+YL5x9-9of5SP~`Q!TTkHjuEe( z^@Be9fgW2rMRKH_{6?-ncAL`peXi#-uUai?&<79D<|qcq#{*VhfR0^Bu#$m}waU-a zf?oVYeZ&@3KR+@Wsj@7H(vYJuPF8)?g;g1qgAbPp;Ih|4hUftITYkRimR-QPGaWd7JcGhKSRpMGT&ZPF3KZi+UYK+VsaLymr zv>(Eeqzvw$N+M$wu# z>3e49=_k#bazg|41_rGVT0nT<(dcOP7(s1Ur0>eqr0e92dZHT8*{A<=?8f_)wMpo0 z{|aanXhtrN0z4$6y^uuRVHQ*`pV$MvaOW$EvoxJGG@+{pg z{B(^TDMUY~v>>L4)O#sr#wBegOIOE&*2iEbQW`BhEFF0u>@prRi!1xGtL|1g#KAS$ z2z`cSn6L;ja0_%*HV*2mK3AE;kjTw^YqTooD;21_$*D_&YbZt7kr0YIgDiIM+h3av zgXsG{{f0}-p6NrnC_K3|jZ}V2#|Q~}&q&yQGGhGuzGQpOxN92O13je4X(I|k==cr~ z){SHv(u91WcbB0wZRt+%i7bMlv;!;=?yyQRrb<4vGj{OKNm9nxng!4NsvZZwIjObb z@KC~nsdPY69@6BqZ5_xo2)t2U7f?&S-~;ZL?M-P+2NvUqJyv1rd0k&{^ggm|X#DvU zA1-EY8=0$XfC4GdfipYcF7$esav-K`gw%(SpA#*Orbj6niv@8kHC8^~J1)}`9(X#r zWe+dN@#5LahIxdUkkOvtdVCuX)hsK*ev-=yc~?~I&5QnUdA&FOi2aQH#JHqpMANea zI;p)iNmoZdlH(Y%N7`Q z$tJQ{7&y_+s7g)E&Jh({721M{ps2~O(9SBcraCmcZ0}dc5$rEJ!v9Pbl&6ubxH@S& ztYob|2_`2;c^Oa>H*AXv!H4p7jIMDi7;0~m>)a$fmh^tqSUKkGutJV0J%@winXVE} z1%Efz)uZZ}4@jH2eb^k(9K)`8{RrURx2bPm4BcAoetOQG1Yd9lGtN|#HSUjX16N>h zgp&z_RHqL2#CB%Ab+D{k$HbPfS>)o3Tge}(!1u2$?BrpEgXExq>_cGo??dcNzwR(V z`2az=)m9(}T9VsMQ)TcvTmoO*co=y?Ehmv68vM8`XAYc}We zjk&~={oCs$W&`ksP}g8;6e0#Qzfi1(I;sI<8?wAN#=S{q>b48Z8FtBqMe3Lo?t!EY z^itX@b~44Vwu5KIb~f1^NSYKTZoKLnZZe6uiSTR9JbuYG=>r+hd$|$O8?Z9?6eW!k zTvcHux%(;faiU}^r84lESQ4bMI=%MtQE>xOs(mCe>RrTGIvDfQnE0D5LQjK%wz@pq z{80dAMVzvl{BgUGwK)lIPb$1`LijJNSCwa+)WkhJcWqqlj9V`-C$fYU5EheRA zYafq_r_hB0^C}Z2UoB0XSs!8%AUq)yVUO) zwX6RI_&)zfJ?O}QN})B zszeLFN+26+QHH@RthaWS#8B>Gj$1KjY3qnj(efg95O48)}Hn;x28!H&jZ`_1+LeOo1{$L zw1a-o%V@mzgD3f2q79xeeEC1aKOyC7B61gS*S?_Zh`&^p>&?}@RO{q0!(DW^ec6;M zYT#36iu`t^u4YK394UnkPHrG6(vS#2#W7^a)DseTl(SK{_mRx$SSO(;R_bGn<;tZ{ z)`77$`ig8YMyqtHF!Oe^VW=Tk_L10)5Fg6Lmp5r4<(4)Vuimrx8er5B(n2pC(7r5? z#p<4o`2yc+!ZWADaFv&@35Yi_ve!%T@*JOz%$|SD0Vg&dWx_ie8OD<1#3l8(_F|Jo zCmXF1Uv%5xfF-Fk3?4k)4sbvl&!T!idJn0sbY#s!A+COh21I8hGu6fXK(MHhwc<^7 zjk#}tUy&wBpV8PzVY|f#+K#Y!YbCTm*g~AP zgs!E>RURoH8CYZ1E6;(H%K|7or+2N9^-bbqr-9b9nv)Xdd--LXSApu89O>+r&{j(e zsoCK3=YM5>U@;s1%m%t8n8Ez6Tl$-szkla^0A(mQvov>gGWtbU4d3`(1<+GX_por* zJEnKK!ZAfXWakj?oanK>w98Y9u$CH^O}GD3ny%d#s%lo*wAAtBn7P_V4@?f6B`EFdP27|nUbv{J6fxz z&di#|ozz#*%c7NKR-|Rr$zJ`G^W7UZb$KrG$#u0iQ!4Pom1;dBDrR`K5>p%fuIim| z)uO7-JkL@}EF$p2sMc%(@TkgyPCk7K`eakofj`y_h6>Tv{FFOv?|n8K1nWY~c$J7O zo$OnJ8VwVPt8`m#*V2+6*PL2&p-b36MazIZ^`hSGmUdct9ltF~lGm8yY_CPrcVPqF zbm=0sw{Pc%=v4NPkOWx#dk#Lxd4?Z0s9pr?U_k))RlmZg8}zO3szcme$P5m32;ToK?74f|_(j%4_CBhdvdOZ zAAS*wBz1AnzmDxfU@^OsTn#5a;%Jrku_al3e{

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/services/orchestrator/gradle/wrapper/gradle-wrapper.properties b/services/orchestrator/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c61a118f --- /dev/null +++ b/services/orchestrator/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/services/orchestrator/gradlew b/services/orchestrator/gradlew new file mode 100755 index 00000000..739907df --- /dev/null +++ b/services/orchestrator/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/services/orchestrator/gradlew.bat b/services/orchestrator/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/services/orchestrator/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/services/orchestrator/src/main/resources/application-dev.yml b/services/orchestrator/src/main/resources/application-dev.yml new file mode 100644 index 00000000..baeaeefe --- /dev/null +++ b/services/orchestrator/src/main/resources/application-dev.yml @@ -0,0 +1,37 @@ +spring: + application: + name: orchestrator-dev + jackson: + property-naming-strategy: SNAKE_CASE + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: orchestrator-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.orchestrator.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderPrepareEvent,order-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderConfirmedEvent,order-cancel:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderCancelEvent,order-purchase-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderPurchaseConfirmedEvent,order-auto-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderAutoConfirmedEvent,delivery-completed:kr.magicbox.orchestrator.adapter.in.kafka.event.DeliveryCompletedEvent,payment-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentSucceededEvent,payment-failed:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentCancelSucceededEvent,stock-reserve-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.StockReserveSucceededEvent,stock-reserve-failed:kr.magicbox.orchestrator.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + +inbox: + max-event-age-minutes: 5 diff --git a/services/orchestrator/src/main/resources/application-local.yml b/services/orchestrator/src/main/resources/application-local.yml new file mode 100644 index 00000000..9da32394 --- /dev/null +++ b/services/orchestrator/src/main/resources/application-local.yml @@ -0,0 +1,43 @@ +spring: + application: + name: orchestrator-local + jackson: + property-naming-strategy: SNAKE_CASE + config: + import: + - file:services/orchestrator/env/local.env[.properties] + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: orchestrator-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.orchestrator.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderPrepareEvent,order-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderConfirmedEvent,order-cancel:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderCancelEvent,order-purchase-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderPurchaseConfirmedEvent,order-auto-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderAutoConfirmedEvent,delivery-completed:kr.magicbox.orchestrator.adapter.in.kafka.event.DeliveryCompletedEvent,payment-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentSucceededEvent,payment-failed:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentCancelSucceededEvent,stock-reserve-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.StockReserveSucceededEvent,stock-reserve-failed:kr.magicbox.orchestrator.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + +inbox: + max-event-age-minutes: 5 + +server: + port: ${SERVER_PORT} diff --git a/services/orchestrator/src/main/resources/application-prod.yml b/services/orchestrator/src/main/resources/application-prod.yml new file mode 100644 index 00000000..f1ce8532 --- /dev/null +++ b/services/orchestrator/src/main/resources/application-prod.yml @@ -0,0 +1,43 @@ +spring: + application: + name: orchestrator-prod + jackson: + property-naming-strategy: SNAKE_CASE + config: + import: + - file:services/orchestrator/env/prod.env[.properties] + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: true + consumer: + group-id: orchestrator-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.orchestrator.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderPrepareEvent,order-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderConfirmedEvent,order-cancel:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderCancelEvent,order-purchase-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderPurchaseConfirmedEvent,order-auto-confirmed:kr.magicbox.orchestrator.adapter.in.kafka.event.OrderAutoConfirmedEvent,delivery-completed:kr.magicbox.orchestrator.adapter.in.kafka.event.DeliveryCompletedEvent,payment-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentSucceededEvent,payment-failed:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.PaymentCancelSucceededEvent,stock-reserve-succeeded:kr.magicbox.orchestrator.adapter.in.kafka.event.StockReserveSucceededEvent,stock-reserve-failed:kr.magicbox.orchestrator.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 5 + backoff: + delay: 1s + max-delay: 10s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + +inbox: + max-event-age-minutes: 5 + +server: + port: ${SERVER_PORT} diff --git a/services/orchestrator/src/main/resources/application.yml b/services/orchestrator/src/main/resources/application.yml new file mode 100644 index 00000000..8a8513aa --- /dev/null +++ b/services/orchestrator/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: ${ENVIRONMENT:local} diff --git a/services/orchestrator/src/test/java/kr/magicbox/orchestrator/OrchestratorApplicationTests.java b/services/orchestrator/src/test/java/kr/magicbox/orchestrator/OrchestratorApplicationTests.java new file mode 100644 index 00000000..0474d13e --- /dev/null +++ b/services/orchestrator/src/test/java/kr/magicbox/orchestrator/OrchestratorApplicationTests.java @@ -0,0 +1,13 @@ +package kr.magicbox.orchestrator; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class OrchestratorApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/settings.gradle b/settings.gradle index 91bea0e3..5ffaa7f2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,4 +9,5 @@ include 'services:shopping-cart' include 'services:release' include 'services:waiting' include 'services:order' -include 'services:search' \ No newline at end of file +include 'services:search' +include 'services:orchestrator' From c19ab1ba87f0baf3895a09784eda0b2e4eae4188 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:53:11 +0900 Subject: [PATCH 013/116] =?UTF-8?q?feat/120=20::=20OrchestratorApplication?= =?UTF-8?q?Tests=20=EB=B9=88=20=EB=A9=94=EC=84=9C=EB=93=9C=20SonarCloud=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kr/magicbox/orchestrator/OrchestratorApplicationTests.java | 1 + 1 file changed, 1 insertion(+) diff --git a/services/orchestrator/src/test/java/kr/magicbox/orchestrator/OrchestratorApplicationTests.java b/services/orchestrator/src/test/java/kr/magicbox/orchestrator/OrchestratorApplicationTests.java index 0474d13e..05abc4a6 100644 --- a/services/orchestrator/src/test/java/kr/magicbox/orchestrator/OrchestratorApplicationTests.java +++ b/services/orchestrator/src/test/java/kr/magicbox/orchestrator/OrchestratorApplicationTests.java @@ -8,6 +8,7 @@ class OrchestratorApplicationTests { @Test void contextLoads() { + // Spring Boot 컨텍스트 정상 로드 검증 } } From 502efcbbbe824ea4f9e47b93d12c506b2368cd36 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:56:17 +0900 Subject: [PATCH 014/116] =?UTF-8?q?feat/120=20::=20Dockerfile=20non-root?= =?UTF-8?q?=20=EC=9C=A0=EC=A0=80=20=EC=8B=A4=ED=96=89=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- services/orchestrator/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/orchestrator/Dockerfile b/services/orchestrator/Dockerfile index 2bdb1905..87a925ef 100644 --- a/services/orchestrator/Dockerfile +++ b/services/orchestrator/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=services/orchestrator/build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] From 503d3c337881b3930c5bd5fb5c1918e7844abcf1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 18:04:17 +0900 Subject: [PATCH 015/116] =?UTF-8?q?feat/116=20::=20SonarCloud=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=ED=95=B4=EA=B2=B0=20(=EB=B9=88=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D,=20Dockerfile=20non-root=20=EC=9C=A0=EC=A0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- services/order/Dockerfile | 5 +++-- .../test/java/kr/magicbox/order/OrderApplicationTests.java | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/services/order/Dockerfile b/services/order/Dockerfile index d62c9f15..b29fd3ef 100644 --- a/services/order/Dockerfile +++ b/services/order/Dockerfile @@ -1,6 +1,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=services/order/build/libs/*.jar -WORKDIR /app +RUN groupadd -r appuser && useradd -r -g appuser appuserWORKDIR /app COPY ${JAR_FILE} app.jar -EXPOSE 8080 +RUN chown -R appuser:appuser /app +USER appuserEXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/order/src/test/java/kr/magicbox/order/OrderApplicationTests.java b/services/order/src/test/java/kr/magicbox/order/OrderApplicationTests.java index eaf2456e..6189433a 100644 --- a/services/order/src/test/java/kr/magicbox/order/OrderApplicationTests.java +++ b/services/order/src/test/java/kr/magicbox/order/OrderApplicationTests.java @@ -8,6 +8,7 @@ class OrderApplicationTests { @Test void contextLoads() { + // Spring Boot 컨텍스트 정상 로드 검증 } } From f334e5999e138acb99b68dba2cedaa6fe81fedf9 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 18:04:33 +0900 Subject: [PATCH 016/116] =?UTF-8?q?feat/117=20::=20SonarCloud=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=ED=95=B4=EA=B2=B0=20(=EB=B9=88=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D,=20Dockerfile=20non-root=20=EC=9C=A0=EC=A0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- services/release/Dockerfile | 5 +++-- .../java/kr/magicbox/release/ReleaseApplicationTests.java | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/services/release/Dockerfile b/services/release/Dockerfile index 7c429075..f3486a8a 100644 --- a/services/release/Dockerfile +++ b/services/release/Dockerfile @@ -1,6 +1,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=services/release/build/libs/*.jar -WORKDIR /app +RUN groupadd -r appuser && useradd -r -g appuser appuserWORKDIR /app COPY ${JAR_FILE} app.jar -EXPOSE 8080 +RUN chown -R appuser:appuser /app +USER appuserEXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/release/src/test/java/kr/magicbox/release/ReleaseApplicationTests.java b/services/release/src/test/java/kr/magicbox/release/ReleaseApplicationTests.java index e7ac6661..d281931c 100644 --- a/services/release/src/test/java/kr/magicbox/release/ReleaseApplicationTests.java +++ b/services/release/src/test/java/kr/magicbox/release/ReleaseApplicationTests.java @@ -8,6 +8,7 @@ class ReleaseApplicationTests { @Test void contextLoads() { + // Spring Boot 컨텍스트 정상 로드 검증 } } From a3bcfad9db223b40b527e94bef70820a3c038674 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:25:14 +0900 Subject: [PATCH 017/116] =?UTF-8?q?feat/120=20::=20orchestrator=20applicat?= =?UTF-8?q?ion-dev.yml=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/orchestrator/src/main/resources/application-dev.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/orchestrator/src/main/resources/application-dev.yml b/services/orchestrator/src/main/resources/application-dev.yml index baeaeefe..c08b4f9a 100644 --- a/services/orchestrator/src/main/resources/application-dev.yml +++ b/services/orchestrator/src/main/resources/application-dev.yml @@ -35,3 +35,8 @@ spring: inbox: max-event-age-minutes: 5 + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} From 6e3c69066025b814b324fd28682c5b7aa649ee65 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:26:50 +0900 Subject: [PATCH 018/116] =?UTF-8?q?feat/117=20::=20release=20application-d?= =?UTF-8?q?ev.yml=20trusted.ips=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/release/src/main/resources/application-dev.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/release/src/main/resources/application-dev.yml b/services/release/src/main/resources/application-dev.yml index c52297d5..f336d5ed 100644 --- a/services/release/src/main/resources/application-dev.yml +++ b/services/release/src/main/resources/application-dev.yml @@ -26,8 +26,7 @@ spring: security: trusted: ips: - - 127.0.0.1 - - 0:0:0:0:0:0:0:1 + - ${TRUSTED_IP_GATEWAY} resilience4j: circuitbreaker: From 59650b6f894d0e83941f8098c4006b595283ae5f Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:27:23 +0900 Subject: [PATCH 019/116] =?UTF-8?q?feat/116=20::=20order=20application-dev?= =?UTF-8?q?.yml=20trusted.ips=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/order/src/main/resources/application-dev.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/order/src/main/resources/application-dev.yml b/services/order/src/main/resources/application-dev.yml index 312dc09f..09f8094b 100644 --- a/services/order/src/main/resources/application-dev.yml +++ b/services/order/src/main/resources/application-dev.yml @@ -45,8 +45,7 @@ spring: security: trusted: ips: - - 127.0.0.1 - - 0:0:0:0:0:0:0:1 + - ${TRUSTED_IP_GATEWAY} inbox: max-event-age-minutes: 5 From 325cc2cf5bd542909c8592ede55cb44397d8138c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:35:50 +0900 Subject: [PATCH 020/116] =?UTF-8?q?feat/120=20::=20orchestrator=20Dockerfi?= =?UTF-8?q?le=20=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/orchestrator/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/orchestrator/Dockerfile b/services/orchestrator/Dockerfile index 87a925ef..c7049a93 100644 --- a/services/orchestrator/Dockerfile +++ b/services/orchestrator/Dockerfile @@ -1,5 +1,5 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu -ARG JAR_FILE=services/orchestrator/build/libs/*.jar +ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar From 6d817f653b84db7f24b900e406baf2b5f87b7fa1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:35:50 +0900 Subject: [PATCH 021/116] =?UTF-8?q?feat/117=20::=20release=20Dockerfile=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/release/Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/release/Dockerfile b/services/release/Dockerfile index f3486a8a..c7049a93 100644 --- a/services/release/Dockerfile +++ b/services/release/Dockerfile @@ -1,7 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu -ARG JAR_FILE=services/release/build/libs/*.jar -RUN groupadd -r appuser && useradd -r -g appuser appuserWORKDIR /app +ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser +WORKDIR /app COPY ${JAR_FILE} app.jar RUN chown -R appuser:appuser /app -USER appuserEXPOSE 8080 +USER appuser +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] From 7452d158c1f3e8139d005db7f69c7bb999f89ccd Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:35:50 +0900 Subject: [PATCH 022/116] =?UTF-8?q?feat/116=20::=20order=20Dockerfile=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/order/Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/order/Dockerfile b/services/order/Dockerfile index b29fd3ef..c7049a93 100644 --- a/services/order/Dockerfile +++ b/services/order/Dockerfile @@ -1,7 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu -ARG JAR_FILE=services/order/build/libs/*.jar -RUN groupadd -r appuser && useradd -r -g appuser appuserWORKDIR /app +ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser +WORKDIR /app COPY ${JAR_FILE} app.jar RUN chown -R appuser:appuser /app -USER appuserEXPOSE 8080 +USER appuser +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] From 03235b2d27d2e587882682e382a02742ae8be349 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 08:51:30 +0900 Subject: [PATCH 023/116] =?UTF-8?q?feat/120=20::=20orchestrator=20applicat?= =?UTF-8?q?ion-prod.yml=20config.import/server.port=20=EC=A0=9C=EA=B1=B0,?= =?UTF-8?q?=20security.trusted.ips=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../orchestrator/src/main/resources/application-prod.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/services/orchestrator/src/main/resources/application-prod.yml b/services/orchestrator/src/main/resources/application-prod.yml index f1ce8532..b5c65cbc 100644 --- a/services/orchestrator/src/main/resources/application-prod.yml +++ b/services/orchestrator/src/main/resources/application-prod.yml @@ -3,9 +3,6 @@ spring: name: orchestrator-prod jackson: property-naming-strategy: SNAKE_CASE - config: - import: - - file:services/orchestrator/env/prod.env[.properties] kafka: bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} listener: @@ -39,5 +36,7 @@ spring: inbox: max-event-age-minutes: 5 -server: - port: ${SERVER_PORT} +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} From 831584a774ae1f15be076841b5807bd7418f8005 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 08:53:17 +0900 Subject: [PATCH 024/116] =?UTF-8?q?feat/117=20::=20release=20application-p?= =?UTF-8?q?rod.yml=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-prod.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 services/release/src/main/resources/application-prod.yml diff --git a/services/release/src/main/resources/application-prod.yml b/services/release/src/main/resources/application-prod.yml new file mode 100644 index 00000000..121992e2 --- /dev/null +++ b/services/release/src/main/resources/application-prod.yml @@ -0,0 +1,45 @@ +spring: + application: + name: release-prod + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + server: + port: ${GRPC_SERVER_PORT} + client: + channels: + creator-service: + address: ${CREATOR_SERVICE_URL} + negotiation-type: plaintext + keep-alive-time: 30s + keep-alive-timeout: 5s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s From e92585cc5419519ac6666f90b3c30e8a99cfbf24 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 08:53:53 +0900 Subject: [PATCH 025/116] =?UTF-8?q?feat/116=20::=20order=20application-pro?= =?UTF-8?q?d.yml=20=EC=88=98=EC=A0=95=20(server.port/TRUSTED=5FIPS=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20grpc=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../order/src/main/resources/application-prod.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/services/order/src/main/resources/application-prod.yml b/services/order/src/main/resources/application-prod.yml index eca07210..12e7df88 100644 --- a/services/order/src/main/resources/application-prod.yml +++ b/services/order/src/main/resources/application-prod.yml @@ -3,6 +3,15 @@ spring: name: order-prod jackson: property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext kafka: bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} listener: @@ -33,12 +42,10 @@ spring: ddl-auto: validate open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: - ips: ${TRUSTED_IPS} + ips: + - ${TRUSTED_IP_GATEWAY} inbox: max-event-age-minutes: 5 From 6f90fdb87147640c85164251a17d451be3c6f7bf Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:03:09 +0900 Subject: [PATCH 026/116] =?UTF-8?q?feat/116=20::=20subscribe/creator=20app?= =?UTF-8?q?lication-dev/prod.yml=20server.port=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/creator/src/main/resources/application-dev.yml | 3 --- services/creator/src/main/resources/application-prod.yml | 3 --- services/subscribe/src/main/resources/application-dev.yml | 3 --- services/subscribe/src/main/resources/application-prod.yml | 3 --- 4 files changed, 12 deletions(-) diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index 94560d8d..43ada9b8 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -60,9 +60,6 @@ spring: hibernate: ddl-auto: update open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index 3cf3755e..5bea406a 100644 --- a/services/creator/src/main/resources/application-prod.yml +++ b/services/creator/src/main/resources/application-prod.yml @@ -60,9 +60,6 @@ spring: ddl-auto: validate open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index 9d3dec79..bb00bb52 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -40,9 +40,6 @@ spring: ddl-auto: update open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 5986dd1a..87d79b19 100644 --- a/services/subscribe/src/main/resources/application-prod.yml +++ b/services/subscribe/src/main/resources/application-prod.yml @@ -40,9 +40,6 @@ spring: ddl-auto: validate open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: From 23ac3b3ff5001584b3920b029ba18e095b4b8456 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:03:09 +0900 Subject: [PATCH 027/116] =?UTF-8?q?feat/117=20::=20subscribe/creator=20app?= =?UTF-8?q?lication-dev/prod.yml=20server.port=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/creator/src/main/resources/application-dev.yml | 3 --- services/creator/src/main/resources/application-prod.yml | 3 --- services/subscribe/src/main/resources/application-dev.yml | 3 --- services/subscribe/src/main/resources/application-prod.yml | 3 --- 4 files changed, 12 deletions(-) diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index 94560d8d..43ada9b8 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -60,9 +60,6 @@ spring: hibernate: ddl-auto: update open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index 3cf3755e..5bea406a 100644 --- a/services/creator/src/main/resources/application-prod.yml +++ b/services/creator/src/main/resources/application-prod.yml @@ -60,9 +60,6 @@ spring: ddl-auto: validate open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index 9d3dec79..bb00bb52 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -40,9 +40,6 @@ spring: ddl-auto: update open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 5986dd1a..87d79b19 100644 --- a/services/subscribe/src/main/resources/application-prod.yml +++ b/services/subscribe/src/main/resources/application-prod.yml @@ -40,9 +40,6 @@ spring: ddl-auto: validate open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: From a2cfc864d6d5db340b4d27db053efb602191dccb Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:03:10 +0900 Subject: [PATCH 028/116] =?UTF-8?q?feat/120=20::=20subscribe/creator=20app?= =?UTF-8?q?lication-dev/prod.yml=20server.port=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/creator/src/main/resources/application-dev.yml | 3 --- services/creator/src/main/resources/application-prod.yml | 3 --- services/subscribe/src/main/resources/application-dev.yml | 3 --- services/subscribe/src/main/resources/application-prod.yml | 3 --- 4 files changed, 12 deletions(-) diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index 94560d8d..43ada9b8 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -60,9 +60,6 @@ spring: hibernate: ddl-auto: update open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index 3cf3755e..5bea406a 100644 --- a/services/creator/src/main/resources/application-prod.yml +++ b/services/creator/src/main/resources/application-prod.yml @@ -60,9 +60,6 @@ spring: ddl-auto: validate open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index 9d3dec79..bb00bb52 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -40,9 +40,6 @@ spring: ddl-auto: update open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: diff --git a/services/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 5986dd1a..87d79b19 100644 --- a/services/subscribe/src/main/resources/application-prod.yml +++ b/services/subscribe/src/main/resources/application-prod.yml @@ -40,9 +40,6 @@ spring: ddl-auto: validate open-in-view: false -server: - port: ${SERVER_PORT} - security: trusted: ips: From ccb36b79f24be6108a40fe9fae981c678efca1ca Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:08:47 +0900 Subject: [PATCH 029/116] =?UTF-8?q?feat/fix=20::=20auth/user/creator=20inb?= =?UTF-8?q?ox=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80,=20trusted.ips=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/auth/src/main/resources/application-dev.yml | 5 ++++- services/auth/src/main/resources/application-prod.yml | 3 +++ services/creator/src/main/resources/application-dev.yml | 3 +++ services/creator/src/main/resources/application-prod.yml | 3 +++ services/user/src/main/resources/application-dev.yml | 3 +++ services/user/src/main/resources/application-prod.yml | 3 +++ 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/services/auth/src/main/resources/application-dev.yml b/services/auth/src/main/resources/application-dev.yml index 7ba4f97b..455ca318 100644 --- a/services/auth/src/main/resources/application-dev.yml +++ b/services/auth/src/main/resources/application-dev.yml @@ -107,4 +107,7 @@ security: logging: level: org.springframework.web: INFO - org.springframework.data.redis: INFO \ No newline at end of file + org.springframework.data.redis: INFO + +inbox: + max-event-age-minutes: 5 diff --git a/services/auth/src/main/resources/application-prod.yml b/services/auth/src/main/resources/application-prod.yml index fc62bd8d..a0f2357a 100644 --- a/services/auth/src/main/resources/application-prod.yml +++ b/services/auth/src/main/resources/application-prod.yml @@ -109,3 +109,6 @@ logging: org.springframework.web: WARN org.springframework.data.redis: WARN root: INFO + +inbox: + max-event-age-minutes: 5 diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index 43ada9b8..f4ff366c 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -65,3 +65,6 @@ security: ips: - 127.0.0.1 - 0:0:0:0:0:0:0:1 + +inbox: + max-event-age-minutes: 5 diff --git a/services/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index 5bea406a..37fdecf5 100644 --- a/services/creator/src/main/resources/application-prod.yml +++ b/services/creator/src/main/resources/application-prod.yml @@ -65,3 +65,6 @@ security: ips: - 127.0.0.1 - 0:0:0:0:0:0:0:1 + +inbox: + max-event-age-minutes: 5 diff --git a/services/user/src/main/resources/application-dev.yml b/services/user/src/main/resources/application-dev.yml index 92d6c728..7557cb9e 100644 --- a/services/user/src/main/resources/application-dev.yml +++ b/services/user/src/main/resources/application-dev.yml @@ -69,3 +69,6 @@ resilience4j: user: default-profile-image-url: ${USER_PROFILE_DEFAULT_IMAGE_URL} + +inbox: + max-event-age-minutes: 5 diff --git a/services/user/src/main/resources/application-prod.yml b/services/user/src/main/resources/application-prod.yml index 800287ae..aded7ee2 100644 --- a/services/user/src/main/resources/application-prod.yml +++ b/services/user/src/main/resources/application-prod.yml @@ -69,3 +69,6 @@ resilience4j: user: default-profile-image-url: ${USER_PROFILE_DEFAULT_IMAGE_URL} + +inbox: + max-event-age-minutes: 5 From 232801ac1aec81f7ee0c50830f9c4fdaec6d59f8 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:21:50 +0900 Subject: [PATCH 030/116] =?UTF-8?q?fix=20::=20subscribe/general-goods=20In?= =?UTF-8?q?box=20=ED=8C=A8=ED=84=B4=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20inb?= =?UTF-8?q?ox=20yml=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/kafka/CreatorEventKafkaListener.java | 4 + .../in/kafka/annotation/Idempotent.java | 11 +++ .../in/kafka/aop/IdempotentAspect.java | 97 +++++++++++++++++++ .../adapter/in/kafka/event/InboxEvent.java | 5 + .../in/kafka/properties/InboxProperties.java | 12 +++ .../entity/GeneralGoodsInboxEntity.java | 53 ++++++++++ .../entity/GeneralGoodsInboxStatus.java | 7 ++ .../GeneralGoodsInboxRepository.java | 13 +++ .../src/main/resources/application-dev.yml | 4 + .../src/main/resources/application-local.yml | 5 +- .../src/main/resources/application-prod.yml | 4 + .../in/kafka/CreatorEventKafkaListener.java | 4 + .../in/kafka/UserEventKafkaListener.java | 6 ++ .../in/kafka/annotation/Idempotent.java | 11 +++ .../in/kafka/aop/IdempotentAspect.java | 97 +++++++++++++++++++ .../adapter/in/kafka/event/InboxEvent.java | 5 + .../in/kafka/properties/InboxProperties.java | 12 +++ .../entity/SubscribeInboxEntity.java | 53 ++++++++++ .../entity/SubscribeInboxStatus.java | 7 ++ .../repository/SubscribeInboxRepository.java | 13 +++ .../src/main/resources/application-dev.yml | 3 + .../src/main/resources/application-local.yml | 3 + .../src/main/resources/application-prod.yml | 3 + 23 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/annotation/Idempotent.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/annotation/Idempotent.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java index ccdb5564..e60b5735 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java @@ -1,11 +1,13 @@ package kr.magicbox.generalgoods.adapter.in.kafka; +import kr.magicbox.generalgoods.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent; import kr.magicbox.generalgoods.application.dto.command.HandleCreatorRevokedCommand; import kr.magicbox.generalgoods.application.port.in.HandleCreatorRevokedUseCase; import lombok.RequiredArgsConstructor; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Component @@ -13,6 +15,8 @@ public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; + @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "general-goods-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/annotation/Idempotent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/annotation/Idempotent.java new file mode 100644 index 00000000..51f1f6e7 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/annotation/Idempotent.java @@ -0,0 +1,11 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Idempotent { +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..368a2f30 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,97 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.aop; + +import kr.magicbox.generalgoods.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxEntity; +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxStatus; +import kr.magicbox.generalgoods.adapter.out.persistence.repository.GeneralGoodsInboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final GeneralGoodsInboxRepository generalGoodsInboxRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.generalgoods.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + Long eventId = Long.parseLong(consumerRecord.key()); + Instant occurredAt = extractOccurredAt(consumerRecord.value()); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } + + return transactionTemplate.execute(status -> { + if (generalGoodsInboxRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + GeneralGoodsInboxEntity inbox = generalGoodsInboxRepository.save(GeneralGoodsInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(GeneralGoodsInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + private Instant extractOccurredAt(Object payload) { + if (payload == null) { + return Instant.now(); + } + try { + for (RecordComponent component : payload.getClass().getRecordComponents()) { + if (component.getName().equals("occurredAt")) { + Object value = component.getAccessor().invoke(payload); + if (value instanceof Instant instant) { + return instant; + } + } + } + } catch (Exception e) { + log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); + } + return Instant.now(); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..2c891533 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,5 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.event; + +public interface InboxEvent { + Long eventId(); +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..01abaf64 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java new file mode 100644 index 00000000..69113877 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "general_goods_inbox") +public class GeneralGoodsInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private GeneralGoodsInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public GeneralGoodsInboxEntity(Long eventId, String topic, Integer partition, Long offset, GeneralGoodsInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = GeneralGoodsInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = GeneralGoodsInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java new file mode 100644 index 00000000..8470d068 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.entity; + +public enum GeneralGoodsInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java new file mode 100644 index 00000000..d4768db1 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java @@ -0,0 +1,13 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.repository; + +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface GeneralGoodsInboxRepository extends JpaRepository { + + boolean existsByEventId(Long eventId); + + Optional findByTopicAndPartitionAndOffset(String topic, Integer partition, Long offset); +} diff --git a/services/general-goods/src/main/resources/application-dev.yml b/services/general-goods/src/main/resources/application-dev.yml index e69de29b..1a23b19b 100644 --- a/services/general-goods/src/main/resources/application-dev.yml +++ b/services/general-goods/src/main/resources/application-dev.yml @@ -0,0 +1,4 @@ + + +inbox: + max-event-age-minutes: 5 diff --git a/services/general-goods/src/main/resources/application-local.yml b/services/general-goods/src/main/resources/application-local.yml index 12863783..911bf924 100644 --- a/services/general-goods/src/main/resources/application-local.yml +++ b/services/general-goods/src/main/resources/application-local.yml @@ -6,4 +6,7 @@ spring: - file:services/general-goods/env/local.env[.properties] server: - port: ${SERVER_PORT} \ No newline at end of file + port: ${SERVER_PORT} + +inbox: + max-event-age-minutes: 5 diff --git a/services/general-goods/src/main/resources/application-prod.yml b/services/general-goods/src/main/resources/application-prod.yml index e69de29b..1a23b19b 100644 --- a/services/general-goods/src/main/resources/application-prod.yml +++ b/services/general-goods/src/main/resources/application-prod.yml @@ -0,0 +1,4 @@ + + +inbox: + max-event-age-minutes: 5 diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java index 7a9d895a..6f3b8cda 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java @@ -1,11 +1,13 @@ package kr.magicbox.subscribe.adapter.in.kafka; +import kr.magicbox.subscribe.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent; import kr.magicbox.subscribe.application.dto.command.HandleCreatorRevokedCommand; import kr.magicbox.subscribe.application.port.in.HandleCreatorRevokedUseCase; import lombok.RequiredArgsConstructor; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Component @@ -13,6 +15,8 @@ public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; + @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "subscribe-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java index 6966b777..c9dddefb 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka; +import kr.magicbox.subscribe.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent; import kr.magicbox.subscribe.application.dto.command.HandleUserRevokedCommand; @@ -8,6 +9,7 @@ import lombok.RequiredArgsConstructor; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Component @@ -15,6 +17,8 @@ public class UserEventKafkaListener { private final HandleUserRevokedUseCase handleUserRevokedUseCase; + @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "subscribe-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { UserWithdrawnEvent event = consumerRecord.value(); @@ -23,6 +27,8 @@ public void handleUserWithdrawnEvent(ConsumerRecord ); } + @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-banned", groupId = "subscribe-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { UserBannedEvent event = consumerRecord.value(); diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/annotation/Idempotent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/annotation/Idempotent.java new file mode 100644 index 00000000..dd2675a8 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/annotation/Idempotent.java @@ -0,0 +1,11 @@ +package kr.magicbox.subscribe.adapter.in.kafka.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Idempotent { +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..4e9fba9d --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,97 @@ +package kr.magicbox.subscribe.adapter.in.kafka.aop; + +import kr.magicbox.subscribe.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxEntity; +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxStatus; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final SubscribeInboxRepository subscribeInboxRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.subscribe.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + Long eventId = Long.parseLong(consumerRecord.key()); + Instant occurredAt = extractOccurredAt(consumerRecord.value()); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } + + return transactionTemplate.execute(status -> { + if (subscribeInboxRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + SubscribeInboxEntity inbox = subscribeInboxRepository.save(SubscribeInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(SubscribeInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + private Instant extractOccurredAt(Object payload) { + if (payload == null) { + return Instant.now(); + } + try { + for (RecordComponent component : payload.getClass().getRecordComponents()) { + if (component.getName().equals("occurredAt")) { + Object value = component.getAccessor().invoke(payload); + if (value instanceof Instant instant) { + return instant; + } + } + } + } catch (Exception e) { + log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); + } + return Instant.now(); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..6930568e --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,5 @@ +package kr.magicbox.subscribe.adapter.in.kafka.event; + +public interface InboxEvent { + Long eventId(); +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..67beb67d --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.subscribe.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java new file mode 100644 index 00000000..77e696ee --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.subscribe.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "subscribe_inbox") +public class SubscribeInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SubscribeInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public SubscribeInboxEntity(Long eventId, String topic, Integer partition, Long offset, SubscribeInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = SubscribeInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = SubscribeInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java new file mode 100644 index 00000000..23b41857 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.subscribe.adapter.out.persistence.entity; + +public enum SubscribeInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java new file mode 100644 index 00000000..183c0aa7 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java @@ -0,0 +1,13 @@ +package kr.magicbox.subscribe.adapter.out.persistence.repository; + +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SubscribeInboxRepository extends JpaRepository { + + boolean existsByEventId(Long eventId); + + Optional findByTopicAndPartitionAndOffset(String topic, Integer partition, Long offset); +} diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index bb00bb52..a0262e19 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -44,3 +44,6 @@ security: trusted: ips: - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 diff --git a/services/subscribe/src/main/resources/application-local.yml b/services/subscribe/src/main/resources/application-local.yml index dc42fc0d..213b5ab9 100644 --- a/services/subscribe/src/main/resources/application-local.yml +++ b/services/subscribe/src/main/resources/application-local.yml @@ -51,3 +51,6 @@ security: ips: - 127.0.0.1 - 0:0:0:0:0:0:0:1 + +inbox: + max-event-age-minutes: 5 diff --git a/services/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 87d79b19..5f6b3529 100644 --- a/services/subscribe/src/main/resources/application-prod.yml +++ b/services/subscribe/src/main/resources/application-prod.yml @@ -44,3 +44,6 @@ security: trusted: ips: - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 From 6c42782396b7720767392ebe546bab12ccecbf99 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:30:08 +0900 Subject: [PATCH 031/116] =?UTF-8?q?chore=20::=20shopping-cart=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/shopping-cart/.gitattributes | 3 - services/shopping-cart/.gitignore | 37 --- services/shopping-cart/Dockerfile | 6 - services/shopping-cart/build.gradle | 5 - .../gradle/wrapper/gradle-wrapper.jar | Bin 46175 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - services/shopping-cart/gradlew | 248 ------------------ services/shopping-cart/gradlew.bat | 93 ------- .../shoppingcart/ShoppingCartApplication.java | 13 - .../src/main/resources/application-local.yml | 9 - .../src/main/resources/application.yml | 3 - .../ShoppingCartApplicationTests.java | 13 - 12 files changed, 437 deletions(-) delete mode 100644 services/shopping-cart/.gitattributes delete mode 100644 services/shopping-cart/.gitignore delete mode 100644 services/shopping-cart/Dockerfile delete mode 100644 services/shopping-cart/build.gradle delete mode 100644 services/shopping-cart/gradle/wrapper/gradle-wrapper.jar delete mode 100644 services/shopping-cart/gradle/wrapper/gradle-wrapper.properties delete mode 100755 services/shopping-cart/gradlew delete mode 100644 services/shopping-cart/gradlew.bat delete mode 100644 services/shopping-cart/src/main/java/kr/magicbox/shoppingcart/ShoppingCartApplication.java delete mode 100644 services/shopping-cart/src/main/resources/application-local.yml delete mode 100644 services/shopping-cart/src/main/resources/application.yml delete mode 100644 services/shopping-cart/src/test/java/kr/magicbox/shoppingcart/ShoppingCartApplicationTests.java diff --git a/services/shopping-cart/.gitattributes b/services/shopping-cart/.gitattributes deleted file mode 100644 index 8af972cd..00000000 --- a/services/shopping-cart/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/services/shopping-cart/.gitignore b/services/shopping-cart/.gitignore deleted file mode 100644 index 66d3b618..00000000 --- a/services/shopping-cart/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -env -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/services/shopping-cart/Dockerfile b/services/shopping-cart/Dockerfile deleted file mode 100644 index a8e8e580..00000000 --- a/services/shopping-cart/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu -ARG JAR_FILE=build/libs/*.jar -WORKDIR /app -COPY ${JAR_FILE} app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/services/shopping-cart/build.gradle b/services/shopping-cart/build.gradle deleted file mode 100644 index 082c2bf9..00000000 --- a/services/shopping-cart/build.gradle +++ /dev/null @@ -1,5 +0,0 @@ -version = '0.0.1' -description = 'shopping-cart' - -dependencies { -} \ No newline at end of file diff --git a/services/shopping-cart/gradle/wrapper/gradle-wrapper.jar b/services/shopping-cart/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 61285a659d17295f1de7c53e24fdf13ad755c379..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46175 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

pVX@HRij*jw$g4?}Pn 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 diff --git a/services/shopping-cart/gradle/wrapper/gradle-wrapper.properties b/services/shopping-cart/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 37f78a6a..00000000 --- a/services/shopping-cart/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/services/shopping-cart/gradlew b/services/shopping-cart/gradlew deleted file mode 100755 index adff685a..00000000 --- a/services/shopping-cart/gradlew +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/services/shopping-cart/gradlew.bat b/services/shopping-cart/gradlew.bat deleted file mode 100644 index c4bdd3ab..00000000 --- a/services/shopping-cart/gradlew.bat +++ /dev/null @@ -1,93 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/services/shopping-cart/src/main/java/kr/magicbox/shoppingcart/ShoppingCartApplication.java b/services/shopping-cart/src/main/java/kr/magicbox/shoppingcart/ShoppingCartApplication.java deleted file mode 100644 index f5fca041..00000000 --- a/services/shopping-cart/src/main/java/kr/magicbox/shoppingcart/ShoppingCartApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.magicbox.shoppingcart; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class ShoppingCartApplication { - - public static void main(String[] args) { - SpringApplication.run(ShoppingCartApplication.class, args); - } - -} diff --git a/services/shopping-cart/src/main/resources/application-local.yml b/services/shopping-cart/src/main/resources/application-local.yml deleted file mode 100644 index b8c8ce7b..00000000 --- a/services/shopping-cart/src/main/resources/application-local.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - application: - name: shopping-cart-local - config: - import: - - file:services/shopping-cart/env/local.env[.properties] - -server: - port: ${SERVER_PORT} \ No newline at end of file diff --git a/services/shopping-cart/src/main/resources/application.yml b/services/shopping-cart/src/main/resources/application.yml deleted file mode 100644 index 8a8513aa..00000000 --- a/services/shopping-cart/src/main/resources/application.yml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - profiles: - active: ${ENVIRONMENT:local} diff --git a/services/shopping-cart/src/test/java/kr/magicbox/shoppingcart/ShoppingCartApplicationTests.java b/services/shopping-cart/src/test/java/kr/magicbox/shoppingcart/ShoppingCartApplicationTests.java deleted file mode 100644 index b39f5020..00000000 --- a/services/shopping-cart/src/test/java/kr/magicbox/shoppingcart/ShoppingCartApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.magicbox.shoppingcart; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ShoppingCartApplicationTests { - - @Test - void contextLoads() { - } - -} From 377ea1969b450fa06f0fbcb9b712b0ac0a9afbe7 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:39:26 +0900 Subject: [PATCH 032/116] =?UTF-8?q?fix=20::=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=83=80=EC=9E=84=EC=8A=A4=ED=83=AC=ED=94=84=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20occurredAt=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=8F=20@JsonAlias=20=ED=95=98=EC=9C=84=20=ED=98=B8?= =?UTF-8?q?=ED=99=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor/115 변경사항 반영 - Producer domain event: bannedAt/withdrawnAt/revokedAt/createdAt → occurredAt - Consumer kafka event: @JsonProperty(occurred_at) + @JsonAlias(구버전 필드명) - 기존 토픽 메시지 역직렬화 하위 호환 보장 --- .../auth/adapter/in/kafka/event/UserBannedEvent.java | 5 +++-- .../auth/adapter/in/kafka/event/UserWithdrawnEvent.java | 5 +++-- .../magicbox/auth/application/service/LoginService.java | 6 +++--- .../magicbox/auth/application/service/LogoutService.java | 2 +- .../magicbox/auth/domain/event/DuplicateLoginEvent.java | 4 ++-- .../java/kr/magicbox/auth/domain/event/LoginEvent.java | 4 ++-- .../java/kr/magicbox/auth/domain/event/LogoutEvent.java | 2 +- .../creator/adapter/in/kafka/event/UserBannedEvent.java | 5 +++-- .../adapter/in/kafka/event/UserWithdrawnEvent.java | 5 +++-- .../creator/application/service/BanCreatorService.java | 6 +++--- .../application/service/HandleUserBannedService.java | 6 +++--- .../application/service/HandleUserWithdrawnService.java | 8 ++++---- .../application/service/WithdrawCreatorService.java | 6 +++--- .../creator/domain/event/CreatorRevokedEvent.java | 3 ++- .../adapter/in/kafka/event/CreatorRevokedEvent.java | 3 ++- .../adapter/in/kafka/event/CreatorRevokedEvent.java | 3 ++- .../subscribe/adapter/in/kafka/event/UserBannedEvent.java | 3 ++- .../adapter/in/kafka/event/UserWithdrawnEvent.java | 3 ++- .../user/adapter/in/kafka/AuthEventKafkaListener.java | 4 ++-- .../magicbox/user/adapter/in/kafka/event/LoginEvent.java | 5 +++-- .../magicbox/user/adapter/in/kafka/event/LogoutEvent.java | 5 +++-- .../magicbox/user/application/service/BanUserService.java | 2 +- .../user/application/service/WithdrawUserService.java | 2 +- .../kr/magicbox/user/domain/event/UserBannedEvent.java | 3 ++- .../kr/magicbox/user/domain/event/UserWithdrawnEvent.java | 3 ++- 25 files changed, 58 insertions(+), 45 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java index 1b80b892..0c497b1f 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; @@ -9,5 +10,5 @@ public record UserBannedEvent( @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") Long userId, - @JsonProperty("banned_at") Instant bannedAt -) implements InboxEvent {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java index 4b500a84..7d50a150 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; @@ -9,5 +10,5 @@ public record UserWithdrawnEvent( @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") Long userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt -) implements InboxEvent {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java b/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java index 9e6baf38..42e482fb 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java +++ b/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java @@ -63,14 +63,14 @@ private void saveLoginEvent(UserId userId) { boolean isDuplicate = userStatusPort.isActive(userId.value()); AuthDomainEvent event = isDuplicate - ? DuplicateLoginEvent.builder().userId(userId).createdAt(now).build() - : LoginEvent.builder().userId(userId).createdAt(now).build(); + ? DuplicateLoginEvent.builder().userId(userId).occurredAt(now).build() + : LoginEvent.builder().userId(userId).occurredAt(now).build(); authOutboxPort.save(event); } private void saveRefreshToken(UserId userId, RefreshTokenValue refreshTokenValue) { Instant expiresAt = Instant.now().plusMillis(tokenManager.getRefreshTokenExpiration()); - RefreshToken refreshToken = RefreshToken.builder() + RefreshToken refreshToken = RefreshToken.createBuilder() .refreshTokenValue(refreshTokenValue) .userId(userId) .expiresAt(expiresAt) diff --git a/services/auth/src/main/java/kr/magicbox/auth/application/service/LogoutService.java b/services/auth/src/main/java/kr/magicbox/auth/application/service/LogoutService.java index aa066ab8..2c654aa6 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/application/service/LogoutService.java +++ b/services/auth/src/main/java/kr/magicbox/auth/application/service/LogoutService.java @@ -32,7 +32,7 @@ public void logout(LogoutCommand command) { // OutBox Pattern Applies LogoutEvent loggedOutEvent = LogoutEvent.builder() .userId(userId) - .createdAt(Instant.now()) + .occurredAt(Instant.now()) .build(); authOutboxPort.save(loggedOutEvent); } diff --git a/services/auth/src/main/java/kr/magicbox/auth/domain/event/DuplicateLoginEvent.java b/services/auth/src/main/java/kr/magicbox/auth/domain/event/DuplicateLoginEvent.java index e392a891..c7c4b33c 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/domain/event/DuplicateLoginEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/domain/event/DuplicateLoginEvent.java @@ -6,10 +6,10 @@ import java.time.Instant; @Builder -public record DuplicateLoginEvent(UserId userId, Instant createdAt) implements AuthDomainEvent { +public record DuplicateLoginEvent(UserId userId, Instant occurredAt) implements AuthDomainEvent { @Override public AuthDomainEventType eventType() { return AuthDomainEventType.USER_DUPLICATE_LOGGED_IN; } -} \ No newline at end of file +} diff --git a/services/auth/src/main/java/kr/magicbox/auth/domain/event/LoginEvent.java b/services/auth/src/main/java/kr/magicbox/auth/domain/event/LoginEvent.java index 823f37c5..336fcac3 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/domain/event/LoginEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/domain/event/LoginEvent.java @@ -6,10 +6,10 @@ import java.time.Instant; @Builder -public record LoginEvent(UserId userId, Instant createdAt) implements AuthDomainEvent { +public record LoginEvent(UserId userId, Instant occurredAt) implements AuthDomainEvent { @Override public AuthDomainEventType eventType() { return AuthDomainEventType.USER_LOGGED_IN; } -} \ No newline at end of file +} diff --git a/services/auth/src/main/java/kr/magicbox/auth/domain/event/LogoutEvent.java b/services/auth/src/main/java/kr/magicbox/auth/domain/event/LogoutEvent.java index dd4bc070..ee70ecd6 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/domain/event/LogoutEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/domain/event/LogoutEvent.java @@ -6,7 +6,7 @@ import java.time.Instant; @Builder -public record LogoutEvent(UserId userId, Instant createdAt) implements AuthDomainEvent { +public record LogoutEvent(UserId userId, Instant occurredAt) implements AuthDomainEvent { @Override public AuthDomainEventType eventType() { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java index 8391c3b1..47839eae 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -9,5 +10,5 @@ @Builder public record UserBannedEvent( @JsonProperty("user_id") UserId userId, - @JsonProperty("banned_at") Instant bannedAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt +) {} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java index 54f19a8e..d782ad64 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -9,5 +10,5 @@ @Builder public record UserWithdrawnEvent( @JsonProperty("user_id") UserId userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt +) {} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java index 7cf05fce..d6cbff9a 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.BanCreatorCommand; import kr.magicbox.creator.application.port.in.BanCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class BanCreatorService implements BanCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Override @Transactional @@ -31,7 +31,7 @@ public void banCreator(BanCreatorCommand command) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java index 5cc7bd1a..e51acc7c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserBannedUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserBannedService implements HandleUserBannedUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Override @Transactional @@ -36,7 +36,7 @@ public void handleUserBanned(UserId userId) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java index 38c58c80..a345fb9b 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserWithdrawnUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserWithdrawnService implements HandleUserWithdrawnUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort creatorDomainEventRepositoryPort; + private final CreatorOutboxRepositoryPort creatorOutboxRepositoryPort; @Override @Transactional @@ -28,10 +28,10 @@ public void handleUserWithdrawn(UserId userId) { Creator creator = creatorOpt.get(); creator.delete(); creatorRepositoryPort.update(creator); - creatorDomainEventRepositoryPort.save( + creatorOutboxRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java index f18f6caf..c8ef6925 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.WithdrawCreatorCommand; import kr.magicbox.creator.application.port.in.WithdrawCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class WithdrawCreatorService implements WithdrawCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Transactional @Override @@ -29,7 +29,7 @@ public void withdrawCreator(WithdrawCreatorCommand command) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java index 902cd5af..113c5348 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorId; import lombok.Builder; @@ -8,7 +9,7 @@ @Builder public record CreatorRevokedEvent( CreatorId creatorId, - Instant revokedAt + Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java index 1826004a..9ccccdf3 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.generalgoods.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.generalgoods.domain.vo.CreatorId; import lombok.Builder; @@ -9,6 +10,6 @@ @Builder public record CreatorRevokedEvent( @JsonProperty("creator_id") CreatorId creatorId, - @JsonProperty("revoked_at") Instant revokedAt + @JsonProperty("occurred_at") @JsonAlias("revoked_at") Instant occurredAt ) { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java index c6bb5954..35ff2483 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.CreatorId; import lombok.Builder; @@ -9,6 +10,6 @@ @Builder public record CreatorRevokedEvent( @JsonProperty("creator_id") CreatorId creatorId, - @JsonProperty("revoked_at") Instant revokedAt + @JsonProperty("occurred_at") @JsonAlias("revoked_at") Instant occurredAt ) { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java index bd638dad..d510a661 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.UserId; import lombok.Builder; @@ -9,6 +10,6 @@ @Builder public record UserBannedEvent( @JsonProperty("user_id") UserId userId, - @JsonProperty("banned_at") Instant bannedAt + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt ) { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java index df87832c..9f81d36c 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.UserId; import lombok.Builder; @@ -9,6 +10,6 @@ @Builder public record UserWithdrawnEvent( @JsonProperty("user_id") UserId userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt ) { } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java index 19cd4970..692f060e 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java @@ -22,13 +22,13 @@ public class AuthEventKafkaListener { @KafkaListener(topics = "outbox.event.user-logged-in", groupId = "user-service") public void handleLoginEvent(ConsumerRecord record) { LoginEvent event = record.value(); - manageUserSessionUseCase.startSession(StartSessionCommand.of(event.userId(), event.createdAt())); + manageUserSessionUseCase.startSession(StartSessionCommand.of(event.userId(), event.occurredAt())); } @Idempotent @KafkaListener(topics = "outbox.event.user-logged-out", groupId = "user-service") public void handleLogoutEvent(ConsumerRecord record) { LogoutEvent event = record.value(); - manageUserSessionUseCase.endSession(EndSessionCommand.of(event.userId(), event.createdAt())); + manageUserSessionUseCase.endSession(EndSessionCommand.of(event.userId(), event.occurredAt())); } } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java index 3f7144cc..7caf442d 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.user.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.user.domain.vo.UserId; import lombok.Builder; @@ -9,5 +10,5 @@ @Builder public record LoginEvent( @JsonProperty("user_id") UserId userId, - @JsonProperty("created_at") Instant createdAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("created_at") Instant occurredAt +) {} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java index ee08beb1..f00c4182 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.user.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.user.domain.vo.UserId; import lombok.Builder; @@ -9,5 +10,5 @@ @Builder public record LogoutEvent( @JsonProperty("user_id") UserId userId, - @JsonProperty("created_at") Instant createdAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("created_at") Instant occurredAt +) {} diff --git a/services/user/src/main/java/kr/magicbox/user/application/service/BanUserService.java b/services/user/src/main/java/kr/magicbox/user/application/service/BanUserService.java index 47c529a8..900e07a9 100644 --- a/services/user/src/main/java/kr/magicbox/user/application/service/BanUserService.java +++ b/services/user/src/main/java/kr/magicbox/user/application/service/BanUserService.java @@ -31,7 +31,7 @@ public void banUser(BanUserCommand command) { UserBannedEvent event = UserBannedEvent.builder() .userId(user.getId()) - .bannedAt(Instant.now()) + .occurredAt(Instant.now()) .build(); userOutboxPort.save(event); } diff --git a/services/user/src/main/java/kr/magicbox/user/application/service/WithdrawUserService.java b/services/user/src/main/java/kr/magicbox/user/application/service/WithdrawUserService.java index 57896298..7121ede8 100644 --- a/services/user/src/main/java/kr/magicbox/user/application/service/WithdrawUserService.java +++ b/services/user/src/main/java/kr/magicbox/user/application/service/WithdrawUserService.java @@ -32,7 +32,7 @@ public void withdrawUser(WithdrawUserCommand command) { eventRepositoryPort.save( UserWithdrawnEvent.builder() .userId(command.userId()) - .withdrawnAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/user/src/main/java/kr/magicbox/user/domain/event/UserBannedEvent.java b/services/user/src/main/java/kr/magicbox/user/domain/event/UserBannedEvent.java index d8922ec9..70400c44 100644 --- a/services/user/src/main/java/kr/magicbox/user/domain/event/UserBannedEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/domain/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.user.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.user.domain.vo.UserId; import lombok.Builder; @@ -8,7 +9,7 @@ @Builder public record UserBannedEvent( UserId userId, - Instant bannedAt + Instant occurredAt ) implements UserDomainEvent { @Override diff --git a/services/user/src/main/java/kr/magicbox/user/domain/event/UserWithdrawnEvent.java b/services/user/src/main/java/kr/magicbox/user/domain/event/UserWithdrawnEvent.java index 25a2f38f..3f9afe75 100644 --- a/services/user/src/main/java/kr/magicbox/user/domain/event/UserWithdrawnEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/domain/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.user.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.user.domain.vo.UserId; import lombok.Builder; @@ -8,7 +9,7 @@ @Builder public record UserWithdrawnEvent( UserId userId, - Instant withdrawnAt + Instant occurredAt ) implements UserDomainEvent { @Override From 4d45d8131d4c75bbd3d2e6d5246cef9034eaa1ab Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:49:58 +0900 Subject: [PATCH 033/116] =?UTF-8?q?feat(user):=20Inbox=20AOP=EC=97=90=20is?= =?UTF-8?q?TooOld/extractOccurredAt=20=EC=B6=94=EA=B0=80=20(InboxPropertie?= =?UTF-8?q?s=20=EC=97=B0=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IdempotentAspect에 InboxProperties 의존성 추가 - isTooOld: maxEventAgeMinutes 기준 만료 이벤트 폐기 - extractOccurredAt: RecordComponent reflection으로 occurredAt 추출 - UserInboxEntity.builder에 occurredAt 필드 포함 Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/annotation/Idempotent.java | 2 +- .../in/kafka/aop/IdempotentAspect.java | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/annotation/Idempotent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/annotation/Idempotent.java index c2f660ce..91860f49 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/annotation/Idempotent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/annotation/Idempotent.java @@ -8,4 +8,4 @@ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { -} \ No newline at end of file +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index a0d80254..1ece5925 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.user.adapter.in.kafka.aop; +import kr.magicbox.user.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.user.adapter.out.persistence.entity.UserInboxEntity; import kr.magicbox.user.adapter.out.persistence.entity.UserInboxStatus; import kr.magicbox.user.adapter.out.persistence.repository.UserInboxRepository; @@ -12,6 +13,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,18 @@ public class IdempotentAspect { private final UserInboxRepository userInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.user.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); Long eventId = Long.parseLong(consumerRecord.key()); + Instant occurredAt = extractOccurredAt(consumerRecord.value()); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (userInboxRepository.existsByEventId(eventId)) { @@ -39,6 +50,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(UserInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +63,29 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + private Instant extractOccurredAt(Object payload) { + if (payload == null) { + return Instant.now(); + } + try { + for (RecordComponent component : payload.getClass().getRecordComponents()) { + if (component.getName().equals("occurredAt")) { + Object value = component.getAccessor().invoke(payload); + if (value instanceof Instant instant) { + return instant; + } + } + } + } catch (Exception e) { + log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); + } + return Instant.now(); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) @@ -59,4 +94,4 @@ public Object around(ProceedingJoinPoint pjp) { .findFirst() .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); } -} \ No newline at end of file +} From 1474d514ea6d50704fd1b7c10f8bb6a01f7bc0ae Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:04:41 +0900 Subject: [PATCH 034/116] =?UTF-8?q?chore:=20settings.gradle=EC=97=90?= =?UTF-8?q?=EC=84=9C=20shopping-cart=20=EB=AA=A8=EB=93=88=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services:shopping-cart 디렉토리 미존재로 Gradle 빌드 실패 - include 항목 제거 Co-Authored-By: Claude Sonnet 4.6 --- settings.gradle | 2 -- 1 file changed, 2 deletions(-) diff --git a/settings.gradle b/settings.gradle index 5ffaa7f2..9255a500 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,9 +5,7 @@ include 'services:auth' include 'services:subscribe' include 'services:creator' include 'services:general-goods' -include 'services:shopping-cart' include 'services:release' include 'services:waiting' include 'services:order' include 'services:search' -include 'services:orchestrator' From f7576dc4028d31e46df8be26e6fd17a9b053785d Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:07:24 +0900 Subject: [PATCH 035/116] =?UTF-8?q?fix(auth):=20RefreshToken.createBuilder?= =?UTF-8?q?()=20=E2=86=92=20builder()=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../java/kr/magicbox/auth/application/service/LoginService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java b/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java index 42e482fb..12311503 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java +++ b/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java @@ -70,7 +70,7 @@ private void saveLoginEvent(UserId userId) { private void saveRefreshToken(UserId userId, RefreshTokenValue refreshTokenValue) { Instant expiresAt = Instant.now().plusMillis(tokenManager.getRefreshTokenExpiration()); - RefreshToken refreshToken = RefreshToken.createBuilder() + RefreshToken refreshToken = RefreshToken.builder() .refreshTokenValue(refreshTokenValue) .userId(userId) .expiresAt(expiresAt) From 7d15349851f2cd16de130e6d8fb5f00c7f7263f1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:14:14 +0900 Subject: [PATCH 036/116] =?UTF-8?q?fix(auth):=20RefreshToken=20createBuild?= =?UTF-8?q?er/reconstructBuilder=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20LoginS?= =?UTF-8?q?ervice=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RefreshToken: @Builder(builderMethodName="createBuilder") - 신규 생성 (validation 포함) - RefreshToken: @Builder(builderMethodName="reconstructBuilder") - DB 복원 (validation 없음) - LoginService: builder() → createBuilder() 복구 Co-Authored-By: Claude Sonnet 4.6 --- .../auth/application/service/LoginService.java | 2 +- .../auth/domain/aggregate/RefreshToken.java | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java b/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java index 12311503..42e482fb 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java +++ b/services/auth/src/main/java/kr/magicbox/auth/application/service/LoginService.java @@ -70,7 +70,7 @@ private void saveLoginEvent(UserId userId) { private void saveRefreshToken(UserId userId, RefreshTokenValue refreshTokenValue) { Instant expiresAt = Instant.now().plusMillis(tokenManager.getRefreshTokenExpiration()); - RefreshToken refreshToken = RefreshToken.builder() + RefreshToken refreshToken = RefreshToken.createBuilder() .refreshTokenValue(refreshTokenValue) .userId(userId) .expiresAt(expiresAt) diff --git a/services/auth/src/main/java/kr/magicbox/auth/domain/aggregate/RefreshToken.java b/services/auth/src/main/java/kr/magicbox/auth/domain/aggregate/RefreshToken.java index 60aac573..b48cb0bf 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/domain/aggregate/RefreshToken.java +++ b/services/auth/src/main/java/kr/magicbox/auth/domain/aggregate/RefreshToken.java @@ -18,15 +18,25 @@ public class RefreshToken { private final Instant createdAt; private boolean isRevoked; - @Builder - public RefreshToken(RefreshTokenValue refreshTokenValue, UserId userId, Instant expiresAt, Boolean isRevoked) { + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public RefreshToken(RefreshTokenValue refreshTokenValue, UserId userId, Instant expiresAt) { validateFields(refreshTokenValue, userId, expiresAt); this.refreshTokenValue = refreshTokenValue; this.userId = userId; this.expiresAt = expiresAt; this.createdAt = Instant.now(); - this.isRevoked = isRevoked != null ? isRevoked : false; + this.isRevoked = false; + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public RefreshToken(RefreshTokenValue refreshTokenValue, UserId userId, Instant expiresAt, + Instant createdAt, boolean isRevoked) { + this.refreshTokenValue = refreshTokenValue; + this.userId = userId; + this.expiresAt = expiresAt; + this.createdAt = createdAt; + this.isRevoked = isRevoked; } public boolean isExpired() { From 7cdf7943253f288e0b0e5b67dc80f35e4f00a837 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:17:41 +0900 Subject: [PATCH 037/116] =?UTF-8?q?fix(auth):=20RefreshTokenService/Mapper?= =?UTF-8?q?=20builder()=20=E2=86=92=20createBuilder/reconstructBuilder=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RefreshTokenService: builder() → createBuilder() - RefreshTokenMapper: builder() → reconstructBuilder(), createdAt 필드 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../auth/adapter/out/cache/mapper/RefreshTokenMapper.java | 3 ++- .../magicbox/auth/application/service/RefreshTokenService.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/cache/mapper/RefreshTokenMapper.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/cache/mapper/RefreshTokenMapper.java index afc158d2..9a4e9f90 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/cache/mapper/RefreshTokenMapper.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/cache/mapper/RefreshTokenMapper.java @@ -20,10 +20,11 @@ public RefreshTokenEntity toEntity(RefreshToken refreshToken) { } public RefreshToken toDomain(RefreshTokenEntity entity) { - return RefreshToken.builder() + return RefreshToken.reconstructBuilder() .refreshTokenValue(RefreshTokenValue.of(entity.getToken())) .userId(UserId.of(entity.getUserId())) .expiresAt(entity.getExpiresAt()) + .createdAt(entity.getCreatedAt()) .isRevoked(entity.isRevoked()) .build(); } diff --git a/services/auth/src/main/java/kr/magicbox/auth/application/service/RefreshTokenService.java b/services/auth/src/main/java/kr/magicbox/auth/application/service/RefreshTokenService.java index a65fbcf9..196ebf81 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/application/service/RefreshTokenService.java +++ b/services/auth/src/main/java/kr/magicbox/auth/application/service/RefreshTokenService.java @@ -49,7 +49,7 @@ private void validateStoredRefreshToken(UserId userId) { private void rotateRefreshToken(UserId userId, RefreshTokenValue refreshTokenValue) { Instant expiresAt = Instant.now().plusMillis(tokenManager.getRefreshTokenExpiration()); - RefreshToken newRefreshToken = RefreshToken.builder() + RefreshToken newRefreshToken = RefreshToken.createBuilder() .refreshTokenValue(refreshTokenValue) .userId(userId) .expiresAt(expiresAt) From fe2a58565d844f47d3aa603794af3e935cbe0659 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:19:41 +0900 Subject: [PATCH 038/116] =?UTF-8?q?fix(user):=20InboxProperties=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/in/kafka/properties/InboxProperties.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..50022a72 --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.user.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} From e52bd3f610aa7abdcae226698edc93302b45e35e Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:20:05 +0900 Subject: [PATCH 039/116] =?UTF-8?q?fix(user):=20SseConnectedKafkaListener/?= =?UTF-8?q?SseDisconnectedKafkaListener=20=EB=88=84=EB=9D=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/SseConnectedKafkaListener.java | 27 +++++++++++++++++++ .../kafka/SseDisconnectedKafkaListener.java | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java new file mode 100644 index 00000000..f23f14e2 --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java @@ -0,0 +1,27 @@ +package kr.magicbox.user.adapter.in.kafka; + +import kr.magicbox.user.application.dto.command.StartSessionCommand; +import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; +import kr.magicbox.user.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SseConnectedKafkaListener { + + private final ManageUserSessionUseCase manageUserSessionUseCase; + + @KafkaListener(topics = "sse.connected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") + public void handleConnected(ConsumerRecord record) { + Long userId = Long.parseLong(record.key()); + log.debug("sse.connected 수신 userId={}", userId); + manageUserSessionUseCase.startSession(StartSessionCommand.of(UserId.of(userId), Instant.now())); + } +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java new file mode 100644 index 00000000..8a09eb27 --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java @@ -0,0 +1,27 @@ +package kr.magicbox.user.adapter.in.kafka; + +import kr.magicbox.user.application.dto.command.EndSessionCommand; +import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; +import kr.magicbox.user.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SseDisconnectedKafkaListener { + + private final ManageUserSessionUseCase manageUserSessionUseCase; + + @KafkaListener(topics = "sse.disconnected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") + public void handleDisconnected(ConsumerRecord record) { + Long userId = Long.parseLong(record.key()); + log.debug("sse.disconnected 수신 userId={}", userId); + manageUserSessionUseCase.endSession(EndSessionCommand.of(UserId.of(userId), Instant.now())); + } +} From f06974c2b7e012953cd44b48fc08a9665d48aeb2 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:20:59 +0900 Subject: [PATCH 040/116] =?UTF-8?q?fix(auth/creator):=20IdempotentAspect?= =?UTF-8?q?=20InboxProperties=20=EC=97=B0=EB=8F=99=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20=EB=B0=8F=20InboxProp?= =?UTF-8?q?erties=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth/creator IdempotentAspect: isTooOld/extractOccurredAt + InboxProperties 포함 버전으로 교체 - auth/creator InboxProperties 누락 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/aop/IdempotentAspect.java | 35 +++++++++++++++++++ .../in/kafka/properties/InboxProperties.java | 12 +++++++ .../in/kafka/aop/IdempotentAspect.java | 35 +++++++++++++++++++ .../in/kafka/properties/InboxProperties.java | 12 +++++++ 4 files changed, 94 insertions(+) create mode 100644 services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 7cee29e3..0dcae7a2 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.auth.adapter.in.kafka.aop; +import kr.magicbox.auth.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.auth.adapter.out.persistence.entity.AuthInboxEntity; import kr.magicbox.auth.adapter.out.persistence.entity.AuthInboxStatus; import kr.magicbox.auth.adapter.out.persistence.repository.AuthInboxRepository; @@ -12,6 +13,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,18 @@ public class IdempotentAspect { private final AuthInboxRepository authInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.auth.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); Long eventId = Long.parseLong(consumerRecord.key()); + Instant occurredAt = extractOccurredAt(consumerRecord.value()); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (authInboxRepository.existsByEventId(eventId)) { @@ -39,6 +50,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(AuthInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +63,29 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + private Instant extractOccurredAt(Object payload) { + if (payload == null) { + return Instant.now(); + } + try { + for (RecordComponent component : payload.getClass().getRecordComponents()) { + if (component.getName().equals("occurredAt")) { + Object value = component.getAccessor().invoke(payload); + if (value instanceof Instant instant) { + return instant; + } + } + } + } catch (Exception e) { + log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); + } + return Instant.now(); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..71692815 --- /dev/null +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.auth.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index ff871e4e..79761501 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.adapter.in.kafka.aop; +import kr.magicbox.creator.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.creator.adapter.out.persistence.entity.CreatorInboxEntity; import kr.magicbox.creator.adapter.out.persistence.entity.CreatorInboxStatus; import kr.magicbox.creator.adapter.out.persistence.repository.CreatorInboxRepository; @@ -12,6 +13,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,18 @@ public class IdempotentAspect { private final CreatorInboxRepository creatorInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.creator.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); Long eventId = Long.parseLong(consumerRecord.key()); + Instant occurredAt = extractOccurredAt(consumerRecord.value()); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (creatorInboxRepository.existsByEventId(eventId)) { @@ -39,6 +50,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(CreatorInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +63,29 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + private Instant extractOccurredAt(Object payload) { + if (payload == null) { + return Instant.now(); + } + try { + for (RecordComponent component : payload.getClass().getRecordComponents()) { + if (component.getName().equals("occurredAt")) { + Object value = component.getAccessor().invoke(payload); + if (value instanceof Instant instant) { + return instant; + } + } + } + } catch (Exception e) { + log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); + } + return Instant.now(); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..bfce8939 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.creator.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} From 18db663e632f8580acbc113eadffe01eeb408575 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:23:26 +0900 Subject: [PATCH 041/116] =?UTF-8?q?fix(inbox):=20InboxEntity=20occurredAt?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EB=88=84=EB=9D=BD=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(auth/creator/subscribe/user)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/out/persistence/entity/AuthInboxEntity.java | 8 +++++++- .../out/persistence/entity/CreatorInboxEntity.java | 8 +++++++- .../adapter/out/persistence/entity/UserInboxEntity.java | 8 +++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java index b879a44f..33a4f8d1 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class AuthInboxEntity extends BaseEntity { @Column(nullable = false) private AuthInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public AuthInboxEntity(Long eventId, String topic, Integer partition, Long offset, AuthInboxStatus status) { + public AuthInboxEntity(Long eventId, String topic, Integer partition, Long offset, AuthInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java index 9249c18e..361c77de 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class CreatorInboxEntity extends BaseEntity { @Column(nullable = false) private CreatorInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public CreatorInboxEntity(Long eventId, String topic, Integer partition, Long offset, CreatorInboxStatus status) { + public CreatorInboxEntity(Long eventId, String topic, Integer partition, Long offset, CreatorInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java b/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java index 508806c4..aa8305bc 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class UserInboxEntity extends BaseEntity { @Column(nullable = false) private UserInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public UserInboxEntity(Long eventId, String topic, Integer partition, Long offset, UserInboxStatus status) { + public UserInboxEntity(Long eventId, String topic, Integer partition, Long offset, UserInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { From 6e0e8a14ac7ad999e3b33ad4eda18b654b9d96ac Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:26:20 +0900 Subject: [PATCH 042/116] =?UTF-8?q?fix(creator):=20CreatorOutboxRepository?= =?UTF-8?q?Port=20=E2=86=92=20CreatorDomainEventRepositoryPort=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../creator/application/service/BanCreatorService.java | 4 ++-- .../application/service/HandleUserBannedService.java | 4 ++-- .../application/service/HandleUserWithdrawnService.java | 6 +++--- .../creator/application/service/WithdrawCreatorService.java | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java index d6cbff9a..202dbfa0 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.BanCreatorCommand; import kr.magicbox.creator.application.port.in.BanCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class BanCreatorService implements BanCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorOutboxRepositoryPort eventRepositoryPort; + private final CreatorDomainEventRepositoryPort eventRepositoryPort; @Override @Transactional diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java index e51acc7c..74646415 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserBannedUseCase; -import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserBannedService implements HandleUserBannedUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorOutboxRepositoryPort eventRepositoryPort; + private final CreatorDomainEventRepositoryPort eventRepositoryPort; @Override @Transactional diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java index a345fb9b..db431525 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserWithdrawnUseCase; -import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserWithdrawnService implements HandleUserWithdrawnUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorOutboxRepositoryPort creatorOutboxRepositoryPort; + private final CreatorDomainEventRepositoryPort creatorDomainEventRepositoryPort; @Override @Transactional @@ -28,7 +28,7 @@ public void handleUserWithdrawn(UserId userId) { Creator creator = creatorOpt.get(); creator.delete(); creatorRepositoryPort.update(creator); - creatorOutboxRepositoryPort.save( + creatorDomainEventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) .occurredAt(Instant.now()) diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java index c8ef6925..57214f1e 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.WithdrawCreatorCommand; import kr.magicbox.creator.application.port.in.WithdrawCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class WithdrawCreatorService implements WithdrawCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorOutboxRepositoryPort eventRepositoryPort; + private final CreatorDomainEventRepositoryPort eventRepositoryPort; @Transactional @Override From 792a9b8feb2fd1e99d6c8966f25277588ec06dd1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:28:09 +0900 Subject: [PATCH 043/116] =?UTF-8?q?fix(creator):=20CreatorDomainEvent*=20?= =?UTF-8?q?=E2=86=92=20CreatorOutbox*=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20(auth/user=EC=99=80=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=EC=84=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ...EventAdapter.java => CreatorOutboxAdapter.java} | 14 +++++++------- ...inEventEntity.java => CreatorOutboxEntity.java} | 6 +++--- ...epository.java => CreatorOutboxRepository.java} | 6 +++--- ...yPort.java => CreatorOutboxRepositoryPort.java} | 4 ++-- .../application/service/BanCreatorService.java | 4 ++-- .../service/HandleUserBannedService.java | 4 ++-- .../service/HandleUserWithdrawnService.java | 6 +++--- .../service/WithdrawCreatorService.java | 4 ++-- 8 files changed, 24 insertions(+), 24 deletions(-) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/{CreatorDomainEventAdapter.java => CreatorOutboxAdapter.java} (66%) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/{CreatorDomainEventEntity.java => CreatorOutboxEntity.java} (83%) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/{CreatorDomainEventRepository.java => CreatorOutboxRepository.java} (60%) rename services/creator/src/main/java/kr/magicbox/creator/application/port/out/{CreatorDomainEventRepositoryPort.java => CreatorOutboxRepositoryPort.java} (74%) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java similarity index 66% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java index b3c5851e..830dceb4 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java @@ -1,8 +1,8 @@ package kr.magicbox.creator.adapter.out.persistence; -import kr.magicbox.creator.adapter.out.persistence.entity.CreatorDomainEventEntity; -import kr.magicbox.creator.adapter.out.persistence.repository.CreatorDomainEventRepository; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.adapter.out.persistence.entity.CreatorOutboxEntity; +import kr.magicbox.creator.adapter.out.persistence.repository.CreatorOutboxRepository; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.domain.event.CreatorDomainEvent; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -10,18 +10,18 @@ @Repository @RequiredArgsConstructor -public class CreatorDomainEventAdapter implements CreatorDomainEventRepositoryPort { +public class CreatorOutboxAdapter implements CreatorOutboxRepositoryPort { - private final CreatorDomainEventRepository creatorDomainEventRepository; + private final CreatorOutboxRepository creatorOutboxRepository; private final ObjectMapper objectMapper; @Override public void save(CreatorDomainEvent event) { String payload = objectMapper.writeValueAsString(event); - creatorDomainEventRepository.save(CreatorDomainEventEntity.builder() + creatorOutboxRepository.save(CreatorOutboxEntity.builder() .eventType(event.eventType().getValue()) .key(event.key()) .payload(payload) .build()); } -} \ No newline at end of file +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java similarity index 83% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java index 252857a0..bed07ba5 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java @@ -12,7 +12,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Table(name = "creator_domain_event") -public class CreatorDomainEventEntity extends BaseEntity { +public class CreatorOutboxEntity extends BaseEntity { @Column(nullable = false) private String eventType; @@ -24,9 +24,9 @@ public class CreatorDomainEventEntity extends BaseEntity { private String payload; @Builder - public CreatorDomainEventEntity(String eventType, String key, String payload) { + public CreatorOutboxEntity(String eventType, String key, String payload) { this.eventType = eventType; this.key = key; this.payload = payload; } -} \ No newline at end of file +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java similarity index 60% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java index 2352aa0b..d904b44d 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.adapter.out.persistence.repository; -import kr.magicbox.creator.adapter.out.persistence.entity.CreatorDomainEventEntity; +import kr.magicbox.creator.adapter.out.persistence.entity.CreatorOutboxEntity; import org.springframework.data.jpa.repository.JpaRepository; -public interface CreatorDomainEventRepository extends JpaRepository { -} \ No newline at end of file +public interface CreatorOutboxRepository extends JpaRepository { +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java b/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java similarity index 74% rename from services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java rename to services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java index 52e075ea..c936b30c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java @@ -2,6 +2,6 @@ import kr.magicbox.creator.domain.event.CreatorDomainEvent; -public interface CreatorDomainEventRepositoryPort { +public interface CreatorOutboxRepositoryPort { void save(CreatorDomainEvent event); -} \ No newline at end of file +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java index 202dbfa0..d6cbff9a 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.BanCreatorCommand; import kr.magicbox.creator.application.port.in.BanCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class BanCreatorService implements BanCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Override @Transactional diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java index 74646415..e51acc7c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserBannedUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserBannedService implements HandleUserBannedUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Override @Transactional diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java index db431525..a345fb9b 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserWithdrawnUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserWithdrawnService implements HandleUserWithdrawnUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort creatorDomainEventRepositoryPort; + private final CreatorOutboxRepositoryPort creatorOutboxRepositoryPort; @Override @Transactional @@ -28,7 +28,7 @@ public void handleUserWithdrawn(UserId userId) { Creator creator = creatorOpt.get(); creator.delete(); creatorRepositoryPort.update(creator); - creatorDomainEventRepositoryPort.save( + creatorOutboxRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) .occurredAt(Instant.now()) diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java index 57214f1e..c8ef6925 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.WithdrawCreatorCommand; import kr.magicbox.creator.application.port.in.WithdrawCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class WithdrawCreatorService implements WithdrawCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Transactional @Override From cc4fab01d1168fbc024183f6e65f71c059857e63 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 10:30:36 +0900 Subject: [PATCH 044/116] =?UTF-8?q?fix(creator):=20UnbanCreatorService/Rev?= =?UTF-8?q?iewCreatorCertificationService=20=ED=8F=AC=ED=8A=B8=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreatorDomainEventRepositoryPort → CreatorOutboxRepositoryPort Co-Authored-By: Claude Sonnet 4.6 --- .../creator/application/service/UnbanCreatorService.java | 6 +++--- .../certification/ReviewCreatorCertificationService.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java index bcc1c103..b71d9f17 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.UnbanCreatorCommand; import kr.magicbox.creator.application.port.in.UnbanCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorUnbannedEvent; @@ -18,7 +18,7 @@ public class UnbanCreatorService implements UnbanCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort creatorDomainEventRepositoryPort; + private final CreatorOutboxRepositoryPort creatorOutboxRepositoryPort; @Override @Transactional @@ -33,6 +33,6 @@ public void unbanCreator(UnbanCreatorCommand command) { .creatorId(creator.getId()) .unbannedAt(Instant.now()) .build(); - creatorDomainEventRepositoryPort.save(event); + creatorOutboxRepositoryPort.save(event); } } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java index 10c1ebb0..1db19e53 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java @@ -3,7 +3,7 @@ import kr.magicbox.creator.application.dto.command.ReviewCreatorCertificationCommand; import kr.magicbox.creator.application.port.in.ReviewCreatorCertificationUseCase; import kr.magicbox.creator.application.port.out.CreatorCertificationRepositoryPort; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.application.port.out.UserNicknameQueryPort; import kr.magicbox.creator.domain.aggregate.Creator; @@ -24,7 +24,7 @@ public class ReviewCreatorCertificationService implements ReviewCreatorCertifica private final CreatorCertificationRepositoryPort certificationRepositoryPort; private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; private final UserNicknameQueryPort userNicknameQueryPort; @Transactional From 623f12d1eb6291cdbb946189039d650d77392866 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 11:25:35 +0900 Subject: [PATCH 045/116] =?UTF-8?q?fix(grpc):=20=EC=A0=84=20GrpcAdapter?= =?UTF-8?q?=EC=97=90=20withDeadlineAfter(2s)=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../out/communication/grpc/UserGrpcAdapter.java | 7 +++++-- .../out/communication/grpc/UserStatusGrpcAdapter.java | 7 +++++-- .../communication/grpc/ReleaseQueryGrpcAdapter.java | 9 +++++++-- .../out/communication/grpc/ReviewQueryGrpcAdapter.java | 6 +++++- .../communication/grpc/ShortformQueryGrpcAdapter.java | 5 ++++- .../out/communication/grpc/SubscribeGrpcAdapter.java | 10 ++++++++-- .../grpc/UserNicknameQueryGrpcAdapter.java | 6 +++++- .../out/communication/grpc/ReviewQueryGrpcAdapter.java | 5 ++++- 8 files changed, 43 insertions(+), 12 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java index 9ab16250..284401ca 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java @@ -17,6 +17,8 @@ import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + @Slf4j @Component @RequiredArgsConstructor @@ -33,8 +35,9 @@ public UserResult loadCredential(String oauth2Id, GrpcOAuth2Provider provider, S .setProfileImage(profileImage != null ? profileImage : "") .build(); - UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub( - grpcChannelFactory.createChannel(ServiceHost.USER.getHostName())); + UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc + .newBlockingStub(grpcChannelFactory.createChannel(ServiceHost.USER.getHostName())) + .withDeadlineAfter(2, TimeUnit.SECONDS); LoadUserCredentialResponse response = stub.loadUserCredential(request); return new UserResult(UserId.of(response.getUserId()), toUserRole(response.getUserRole())); diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java index 11f0cab0..c9f9f0d6 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java @@ -11,6 +11,8 @@ import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + @Slf4j @Component @RequiredArgsConstructor @@ -25,8 +27,9 @@ public boolean isActive(Long userId) { .setUserId(userId) .build(); - UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub( - grpcChannelFactory.createChannel(ServiceHost.USER.getHostName())); + UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc + .newBlockingStub(grpcChannelFactory.createChannel(ServiceHost.USER.getHostName())) + .withDeadlineAfter(2, TimeUnit.SECONDS); CheckUserActiveResponse response = stub.checkUserActive(request); return response.getActive(); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java index 4fd8cdef..fdeb06c2 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -34,7 +35,9 @@ public long getReleaseCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel); + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc + .newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleaseCountResponse response = stub.getReleaseCount(request); return response.getReleaseCount(); @@ -48,7 +51,9 @@ public List getReleases(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel); + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc + .newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleasesByCreatorIdResponse response = stub.getReleasesByCreatorId(request); return response.getReleasesList().stream() diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index 807a713e..0181628a 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -14,6 +14,8 @@ import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + @Component @RequiredArgsConstructor @Slf4j @@ -28,7 +30,9 @@ public ReviewRating getReviewRating(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); - ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc.newBlockingStub(channel); + ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc + .newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReviewRatingResponse response = stub.getReviewRating(request); return ReviewRating.of(response.getRating()); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java index eee33da1..61d133f5 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -31,7 +32,9 @@ public List getShortforms(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SHORTFORM.getHostName()); - ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc.newBlockingStub(channel); + ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc + .newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetShortformsByCreatorIdResponse response = stub.getShortformsByCreatorId(request); return response.getShortformsList().stream() diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java index 31978319..1aa48ed6 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java @@ -15,6 +15,8 @@ import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + @Component @RequiredArgsConstructor @Slf4j @@ -29,7 +31,9 @@ public long getSubscriberCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel); + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc + .newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetSubscriberCountResponse response = stub.getSubscriberCount(request); return response.getSubscriberCount(); @@ -44,7 +48,9 @@ public boolean isSubscribed(Long creatorId, Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel); + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc + .newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); IsSubscribedResponse response = stub.isSubscribed(request); return response.getSubscribed(); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java index 5420e58d..bd9e7cb1 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java @@ -14,6 +14,8 @@ import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + @Component @RequiredArgsConstructor @Slf4j @@ -28,7 +30,9 @@ public String getNickname(UserId userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.USER.getHostName()); - UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel); + UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc + .newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetUserNicknameResponse response = stub.getUserNickname(request); return response.getNickname(); diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index 1329ac10..fa3fcd5f 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -17,6 +17,7 @@ import java.time.Instant; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -32,7 +33,9 @@ public List getAllReviewsByUserId(Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); - ReviewServiceGrpc.ReviewServiceBlockingStub reviewStub = ReviewServiceGrpc.newBlockingStub(channel); + ReviewServiceGrpc.ReviewServiceBlockingStub reviewStub = ReviewServiceGrpc + .newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetAllReviewsByUserIdResponse response = reviewStub.getAllReviewsByUserId(request); return response.getReviewsList().stream() From 613a3e6420ece71b1e49ed6b5a53006daf1255a5 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 11:33:42 +0900 Subject: [PATCH 046/116] =?UTF-8?q?fix(grpc):=20GrpcAdapter=20withDeadline?= =?UTF-8?q?After(2s)=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/out/communication/grpc/UserGrpcAdapter.java | 1 + .../out/communication/grpc/UserStatusGrpcAdapter.java | 1 + .../out/communication/grpc/ReleaseQueryGrpcAdapter.java | 7 +++++-- .../out/communication/grpc/ReviewQueryGrpcAdapter.java | 4 +++- .../out/communication/grpc/ShortformQueryGrpcAdapter.java | 4 +++- .../out/communication/grpc/SubscribeGrpcAdapter.java | 7 +++++-- .../communication/grpc/UserNicknameQueryGrpcAdapter.java | 4 +++- .../adapter/out/communication/grpc/CreatorGrpcAdapter.java | 4 +++- .../adapter/out/communication/grpc/ReleaseGrpcAdapter.java | 4 +++- .../adapter/out/communication/grpc/WaitingGrpcAdapter.java | 4 +++- .../adapter/out/communication/grpc/CreatorGrpcAdapter.java | 1 + .../out/communication/grpc/ReviewQueryGrpcAdapter.java | 4 +++- 12 files changed, 34 insertions(+), 11 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java index 9ab16250..dcc47b0b 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java @@ -16,6 +16,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Slf4j @Component diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java index 11f0cab0..52f1ca41 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java @@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Slf4j @Component diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java index 4fd8cdef..16b69971 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -34,7 +35,8 @@ public long getReleaseCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel); + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleaseCountResponse response = stub.getReleaseCount(request); return response.getReleaseCount(); @@ -48,7 +50,8 @@ public List getReleases(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel); + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleasesByCreatorIdResponse response = stub.getReleasesByCreatorId(request); return response.getReleasesList().stream() diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index 807a713e..333358e2 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -28,7 +29,8 @@ public ReviewRating getReviewRating(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); - ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc.newBlockingStub(channel); + ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReviewRatingResponse response = stub.getReviewRating(request); return ReviewRating.of(response.getRating()); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java index eee33da1..ddf0f805 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -31,7 +32,8 @@ public List getShortforms(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SHORTFORM.getHostName()); - ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc.newBlockingStub(channel); + ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetShortformsByCreatorIdResponse response = stub.getShortformsByCreatorId(request); return response.getShortformsList().stream() diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java index 31978319..8bbdcc47 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -29,7 +30,8 @@ public long getSubscriberCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel); + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetSubscriberCountResponse response = stub.getSubscriberCount(request); return response.getSubscriberCount(); @@ -44,7 +46,8 @@ public boolean isSubscribed(Long creatorId, Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel); + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); IsSubscribedResponse response = stub.isSubscribed(request); return response.getSubscribed(); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java index 5420e58d..0d72c3ef 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -28,7 +29,8 @@ public String getNickname(UserId userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.USER.getHostName()); - UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel); + UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetUserNicknameResponse response = stub.getUserNickname(request); return response.getNickname(); diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java index cbaa20fe..becc109b 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -32,7 +33,8 @@ public CreatorId getCreatorId(UserId userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.CREATOR.getHostName()); - GeneralGoodsServiceGrpc.GeneralGoodsServiceBlockingStub stub = GeneralGoodsServiceGrpc.newBlockingStub(channel); + GeneralGoodsServiceGrpc.GeneralGoodsServiceBlockingStub stub = GeneralGoodsServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetCreatorIdByUserIdResponse response = stub.getCreatorIdByUserId(request); return new CreatorId(response.getCreatorId()); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java index ff6fab63..3c7ac6f6 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -24,7 +25,8 @@ public void increaseSoldQuantity(Long releaseId) { .setReleaseId(releaseId) .build(); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(releaseManagedChannel); + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(releaseManagedChannel) + .withDeadlineAfter(2, TimeUnit.SECONDS); stub.increaseSoldQuantity(request); } diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java index a50e0325..941ad756 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -27,7 +28,8 @@ public boolean validate(Long releaseId, Long userId, String purchaseToken) { .setPurchaseToken(purchaseToken) .build(); - WaitingServiceGrpc.WaitingServiceBlockingStub stub = WaitingServiceGrpc.newBlockingStub(waitingManagedChannel); + WaitingServiceGrpc.WaitingServiceBlockingStub stub = WaitingServiceGrpc.newBlockingStub(waitingManagedChannel) + .withDeadlineAfter(2, TimeUnit.SECONDS); ValidatePurchaseTokenResponse response = stub.validatePurchaseToken(request); return response.getValid(); diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/communication/grpc/CreatorGrpcAdapter.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/communication/grpc/CreatorGrpcAdapter.java index d9291f6c..58a3b931 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/communication/grpc/CreatorGrpcAdapter.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/communication/grpc/CreatorGrpcAdapter.java @@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @Slf4j diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index 1329ac10..7725fe68 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -17,6 +17,7 @@ import java.time.Instant; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -32,7 +33,8 @@ public List getAllReviewsByUserId(Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); - ReviewServiceGrpc.ReviewServiceBlockingStub reviewStub = ReviewServiceGrpc.newBlockingStub(channel); + ReviewServiceGrpc.ReviewServiceBlockingStub reviewStub = ReviewServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetAllReviewsByUserIdResponse response = reviewStub.getAllReviewsByUserId(request); return response.getReviewsList().stream() From b68418bc34a5db91190aa1b950549bd7fdd36be8 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 11:33:42 +0900 Subject: [PATCH 047/116] =?UTF-8?q?fix(grpc):=20GrpcAdapter=20withDeadline?= =?UTF-8?q?After(2s)=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/out/communication/grpc/UserGrpcAdapter.java | 1 + .../out/communication/grpc/UserStatusGrpcAdapter.java | 1 + .../out/communication/grpc/ReleaseQueryGrpcAdapter.java | 7 +++++-- .../out/communication/grpc/ReviewQueryGrpcAdapter.java | 4 +++- .../out/communication/grpc/ShortformQueryGrpcAdapter.java | 4 +++- .../out/communication/grpc/SubscribeGrpcAdapter.java | 7 +++++-- .../communication/grpc/UserNicknameQueryGrpcAdapter.java | 4 +++- .../adapter/out/communication/grpc/CreatorGrpcAdapter.java | 4 +++- .../adapter/out/communication/grpc/CreatorGrpcAdapter.java | 1 + .../out/communication/grpc/ReviewQueryGrpcAdapter.java | 4 +++- 10 files changed, 28 insertions(+), 9 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java index 9ab16250..dcc47b0b 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserGrpcAdapter.java @@ -16,6 +16,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Slf4j @Component diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java index 11f0cab0..52f1ca41 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/communication/grpc/UserStatusGrpcAdapter.java @@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Slf4j @Component diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java index 4fd8cdef..16b69971 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -34,7 +35,8 @@ public long getReleaseCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel); + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleaseCountResponse response = stub.getReleaseCount(request); return response.getReleaseCount(); @@ -48,7 +50,8 @@ public List getReleases(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel); + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleasesByCreatorIdResponse response = stub.getReleasesByCreatorId(request); return response.getReleasesList().stream() diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index 807a713e..333358e2 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -28,7 +29,8 @@ public ReviewRating getReviewRating(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); - ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc.newBlockingStub(channel); + ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetReviewRatingResponse response = stub.getReviewRating(request); return ReviewRating.of(response.getRating()); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java index eee33da1..ddf0f805 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -31,7 +32,8 @@ public List getShortforms(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SHORTFORM.getHostName()); - ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc.newBlockingStub(channel); + ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetShortformsByCreatorIdResponse response = stub.getShortformsByCreatorId(request); return response.getShortformsList().stream() diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java index 31978319..8bbdcc47 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -29,7 +30,8 @@ public long getSubscriberCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel); + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetSubscriberCountResponse response = stub.getSubscriberCount(request); return response.getSubscriberCount(); @@ -44,7 +46,8 @@ public boolean isSubscribed(Long creatorId, Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel); + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); IsSubscribedResponse response = stub.isSubscribed(request); return response.getSubscribed(); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java index 5420e58d..0d72c3ef 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -28,7 +29,8 @@ public String getNickname(UserId userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.USER.getHostName()); - UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel); + UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetUserNicknameResponse response = stub.getUserNickname(request); return response.getNickname(); diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java index cbaa20fe..becc109b 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -32,7 +33,8 @@ public CreatorId getCreatorId(UserId userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.CREATOR.getHostName()); - GeneralGoodsServiceGrpc.GeneralGoodsServiceBlockingStub stub = GeneralGoodsServiceGrpc.newBlockingStub(channel); + GeneralGoodsServiceGrpc.GeneralGoodsServiceBlockingStub stub = GeneralGoodsServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetCreatorIdByUserIdResponse response = stub.getCreatorIdByUserId(request); return new CreatorId(response.getCreatorId()); diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/communication/grpc/CreatorGrpcAdapter.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/communication/grpc/CreatorGrpcAdapter.java index d9291f6c..58a3b931 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/communication/grpc/CreatorGrpcAdapter.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/communication/grpc/CreatorGrpcAdapter.java @@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; @Component @Slf4j diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index 1329ac10..7725fe68 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -17,6 +17,7 @@ import java.time.Instant; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor @@ -32,7 +33,8 @@ public List getAllReviewsByUserId(Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); - ReviewServiceGrpc.ReviewServiceBlockingStub reviewStub = ReviewServiceGrpc.newBlockingStub(channel); + ReviewServiceGrpc.ReviewServiceBlockingStub reviewStub = ReviewServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(2, TimeUnit.SECONDS); GetAllReviewsByUserIdResponse response = reviewStub.getAllReviewsByUserId(request); return response.getReviewsList().stream() From 613fefc2ead50dce882815e4f65a937be2813331 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 13:03:43 +0900 Subject: [PATCH 048/116] =?UTF-8?q?fix(inbox):=20@DltHandler=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=A1=9C=20DLT=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20Inb?= =?UTF-8?q?ox=20DEAD=5FLETTERED=20=EC=83=81=ED=83=9C=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @RetryableTopic 전환 이후 DLT 발생 시 Inbox 상태가 업데이트되지 않던 문제를 해결합니다. 각 리스너에 @DltHandler를 추가하여 재시도 소진 후 DLT로 전환될 때 해당 Inbox 레코드를 DEAD_LETTERED로 마킹합니다. Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/in/kafka/UserEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/UserEventKafkaListener.java | 10 ++++++++++ .../adapter/in/kafka/CreatorEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/CreatorEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/UserEventKafkaListener.java | 12 ++++++++++++ 5 files changed, 58 insertions(+) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java index e4efd928..32ab822a 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java @@ -3,20 +3,25 @@ import kr.magicbox.auth.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.auth.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.auth.adapter.in.kafka.event.UserWithdrawnEvent; +import kr.magicbox.auth.adapter.out.persistence.repository.AuthInboxRepository; import kr.magicbox.auth.application.port.in.HandleUserBannedUseCase; import kr.magicbox.auth.application.port.in.HandleUserWithdrawnUseCase; import kr.magicbox.auth.domain.vo.UserId; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class UserEventKafkaListener { private final HandleUserWithdrawnUseCase handleUserWithdrawnUseCase; private final HandleUserBannedUseCase handleUserBannedUseCase; + private final AuthInboxRepository authInboxRepository; @Idempotent @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "auth-service") @@ -31,4 +36,11 @@ public void handleUserBannedEvent(ConsumerRecord consum UserBannedEvent event = consumerRecord.value(); handleUserBannedUseCase.handleUserBanned(UserId.of(event.userId())); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + authInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } \ No newline at end of file diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java index 6be9a1b7..55c324b7 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java @@ -3,11 +3,13 @@ import kr.magicbox.creator.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.creator.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.creator.adapter.in.kafka.event.UserWithdrawnEvent; +import kr.magicbox.creator.adapter.out.persistence.repository.CreatorInboxRepository; import kr.magicbox.creator.application.port.in.HandleUserBannedUseCase; import kr.magicbox.creator.application.port.in.HandleUserWithdrawnUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; @@ -18,6 +20,7 @@ public class UserEventKafkaListener { private final HandleUserWithdrawnUseCase handleUserWithdrawnUseCase; private final HandleUserBannedUseCase handleUserBannedUseCase; + private final CreatorInboxRepository creatorInboxRepository; @Idempotent @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "creator-service") @@ -32,4 +35,11 @@ public void handleUserBannedEvent(ConsumerRecord consum log.info("[Inbox] user-banned 이벤트 수신. eventId={}", consumerRecord.key()); handleUserBannedUseCase.handleUserBanned(consumerRecord.value().userId()); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + creatorInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java index ccdb5564..dcb3ef6e 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java @@ -2,20 +2,32 @@ import kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent; import kr.magicbox.generalgoods.application.dto.command.HandleCreatorRevokedCommand; +import kr.magicbox.generalgoods.adapter.out.persistence.repository.GeneralGoodsInboxRepository; import kr.magicbox.generalgoods.application.port.in.HandleCreatorRevokedUseCase; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; + private final GeneralGoodsInboxRepository generalGoodsInboxRepository; @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "general-goods-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); handleCreatorRevokedUseCase.handleCreatorRevoked(HandleCreatorRevokedCommand.of(event.creatorId())); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + generalGoodsInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java index 7a9d895a..bc1b7506 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java @@ -2,20 +2,32 @@ import kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent; import kr.magicbox.subscribe.application.dto.command.HandleCreatorRevokedCommand; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; import kr.magicbox.subscribe.application.port.in.HandleCreatorRevokedUseCase; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; + private final SubscribeInboxRepository subscribeInboxRepository; @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "subscribe-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); handleCreatorRevokedUseCase.handleCreatorRevoked(HandleCreatorRevokedCommand.of(event.creatorId())); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + subscribeInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java index 6966b777..d6dc8abc 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java @@ -3,17 +3,22 @@ import kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent; import kr.magicbox.subscribe.application.dto.command.HandleUserRevokedCommand; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; import kr.magicbox.subscribe.application.port.in.HandleUserRevokedUseCase; import kr.magicbox.subscribe.domain.vo.SubscriberId; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class UserEventKafkaListener { private final HandleUserRevokedUseCase handleUserRevokedUseCase; + private final SubscribeInboxRepository subscribeInboxRepository; @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "subscribe-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { @@ -30,4 +35,11 @@ public void handleUserBannedEvent(ConsumerRecord consum HandleUserRevokedCommand.of(SubscriberId.of(event.userId().value())) ); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + subscribeInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } From 45d146bec7d7247f4a2c4dcd5ec9f33488710c20 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 13:03:43 +0900 Subject: [PATCH 049/116] =?UTF-8?q?fix(inbox):=20@DltHandler=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=A1=9C=20DLT=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20Inb?= =?UTF-8?q?ox=20DEAD=5FLETTERED=20=EC=83=81=ED=83=9C=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @RetryableTopic 전환 이후 DLT 발생 시 Inbox 상태가 업데이트되지 않던 문제를 해결합니다. 각 리스너에 @DltHandler를 추가하여 재시도 소진 후 DLT로 전환될 때 해당 Inbox 레코드를 DEAD_LETTERED로 마킹합니다. Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/in/kafka/UserEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/UserEventKafkaListener.java | 10 ++++++++++ .../adapter/in/kafka/CreatorEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/CreatorEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/UserEventKafkaListener.java | 12 ++++++++++++ 5 files changed, 58 insertions(+) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java index e4efd928..32ab822a 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java @@ -3,20 +3,25 @@ import kr.magicbox.auth.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.auth.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.auth.adapter.in.kafka.event.UserWithdrawnEvent; +import kr.magicbox.auth.adapter.out.persistence.repository.AuthInboxRepository; import kr.magicbox.auth.application.port.in.HandleUserBannedUseCase; import kr.magicbox.auth.application.port.in.HandleUserWithdrawnUseCase; import kr.magicbox.auth.domain.vo.UserId; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class UserEventKafkaListener { private final HandleUserWithdrawnUseCase handleUserWithdrawnUseCase; private final HandleUserBannedUseCase handleUserBannedUseCase; + private final AuthInboxRepository authInboxRepository; @Idempotent @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "auth-service") @@ -31,4 +36,11 @@ public void handleUserBannedEvent(ConsumerRecord consum UserBannedEvent event = consumerRecord.value(); handleUserBannedUseCase.handleUserBanned(UserId.of(event.userId())); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + authInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } \ No newline at end of file diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java index 6be9a1b7..55c324b7 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java @@ -3,11 +3,13 @@ import kr.magicbox.creator.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.creator.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.creator.adapter.in.kafka.event.UserWithdrawnEvent; +import kr.magicbox.creator.adapter.out.persistence.repository.CreatorInboxRepository; import kr.magicbox.creator.application.port.in.HandleUserBannedUseCase; import kr.magicbox.creator.application.port.in.HandleUserWithdrawnUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; @@ -18,6 +20,7 @@ public class UserEventKafkaListener { private final HandleUserWithdrawnUseCase handleUserWithdrawnUseCase; private final HandleUserBannedUseCase handleUserBannedUseCase; + private final CreatorInboxRepository creatorInboxRepository; @Idempotent @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "creator-service") @@ -32,4 +35,11 @@ public void handleUserBannedEvent(ConsumerRecord consum log.info("[Inbox] user-banned 이벤트 수신. eventId={}", consumerRecord.key()); handleUserBannedUseCase.handleUserBanned(consumerRecord.value().userId()); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + creatorInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java index ccdb5564..dcb3ef6e 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java @@ -2,20 +2,32 @@ import kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent; import kr.magicbox.generalgoods.application.dto.command.HandleCreatorRevokedCommand; +import kr.magicbox.generalgoods.adapter.out.persistence.repository.GeneralGoodsInboxRepository; import kr.magicbox.generalgoods.application.port.in.HandleCreatorRevokedUseCase; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; + private final GeneralGoodsInboxRepository generalGoodsInboxRepository; @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "general-goods-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); handleCreatorRevokedUseCase.handleCreatorRevoked(HandleCreatorRevokedCommand.of(event.creatorId())); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + generalGoodsInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java index 7a9d895a..bc1b7506 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java @@ -2,20 +2,32 @@ import kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent; import kr.magicbox.subscribe.application.dto.command.HandleCreatorRevokedCommand; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; import kr.magicbox.subscribe.application.port.in.HandleCreatorRevokedUseCase; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; + private final SubscribeInboxRepository subscribeInboxRepository; @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "subscribe-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); handleCreatorRevokedUseCase.handleCreatorRevoked(HandleCreatorRevokedCommand.of(event.creatorId())); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + subscribeInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java index 6966b777..d6dc8abc 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java @@ -3,17 +3,22 @@ import kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent; import kr.magicbox.subscribe.application.dto.command.HandleUserRevokedCommand; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; import kr.magicbox.subscribe.application.port.in.HandleUserRevokedUseCase; import kr.magicbox.subscribe.domain.vo.SubscriberId; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class UserEventKafkaListener { private final HandleUserRevokedUseCase handleUserRevokedUseCase; + private final SubscribeInboxRepository subscribeInboxRepository; @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "subscribe-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { @@ -30,4 +35,11 @@ public void handleUserBannedEvent(ConsumerRecord consum HandleUserRevokedCommand.of(SubscriberId.of(event.userId().value())) ); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + subscribeInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } From c075123bb4282ddb806def4598fa97a9f90b9ab1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 13:03:43 +0900 Subject: [PATCH 050/116] =?UTF-8?q?fix(inbox):=20@DltHandler=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=A1=9C=20DLT=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20Inb?= =?UTF-8?q?ox=20DEAD=5FLETTERED=20=EC=83=81=ED=83=9C=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @RetryableTopic 전환 이후 DLT 발생 시 Inbox 상태가 업데이트되지 않던 문제를 해결합니다. 각 리스너에 @DltHandler를 추가하여 재시도 소진 후 DLT로 전환될 때 해당 Inbox 레코드를 DEAD_LETTERED로 마킹합니다. Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/in/kafka/UserEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/UserEventKafkaListener.java | 10 ++++++++++ .../adapter/in/kafka/CreatorEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/CreatorEventKafkaListener.java | 12 ++++++++++++ .../adapter/in/kafka/UserEventKafkaListener.java | 12 ++++++++++++ 5 files changed, 58 insertions(+) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java index e4efd928..32ab822a 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java @@ -3,20 +3,25 @@ import kr.magicbox.auth.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.auth.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.auth.adapter.in.kafka.event.UserWithdrawnEvent; +import kr.magicbox.auth.adapter.out.persistence.repository.AuthInboxRepository; import kr.magicbox.auth.application.port.in.HandleUserBannedUseCase; import kr.magicbox.auth.application.port.in.HandleUserWithdrawnUseCase; import kr.magicbox.auth.domain.vo.UserId; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class UserEventKafkaListener { private final HandleUserWithdrawnUseCase handleUserWithdrawnUseCase; private final HandleUserBannedUseCase handleUserBannedUseCase; + private final AuthInboxRepository authInboxRepository; @Idempotent @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "auth-service") @@ -31,4 +36,11 @@ public void handleUserBannedEvent(ConsumerRecord consum UserBannedEvent event = consumerRecord.value(); handleUserBannedUseCase.handleUserBanned(UserId.of(event.userId())); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + authInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } \ No newline at end of file diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java index 6be9a1b7..55c324b7 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java @@ -3,11 +3,13 @@ import kr.magicbox.creator.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.creator.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.creator.adapter.in.kafka.event.UserWithdrawnEvent; +import kr.magicbox.creator.adapter.out.persistence.repository.CreatorInboxRepository; import kr.magicbox.creator.application.port.in.HandleUserBannedUseCase; import kr.magicbox.creator.application.port.in.HandleUserWithdrawnUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; @@ -18,6 +20,7 @@ public class UserEventKafkaListener { private final HandleUserWithdrawnUseCase handleUserWithdrawnUseCase; private final HandleUserBannedUseCase handleUserBannedUseCase; + private final CreatorInboxRepository creatorInboxRepository; @Idempotent @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "creator-service") @@ -32,4 +35,11 @@ public void handleUserBannedEvent(ConsumerRecord consum log.info("[Inbox] user-banned 이벤트 수신. eventId={}", consumerRecord.key()); handleUserBannedUseCase.handleUserBanned(consumerRecord.value().userId()); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + creatorInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java index e60b5735..300017df 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java @@ -3,17 +3,22 @@ import kr.magicbox.generalgoods.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent; import kr.magicbox.generalgoods.application.dto.command.HandleCreatorRevokedCommand; +import kr.magicbox.generalgoods.adapter.out.persistence.repository.GeneralGoodsInboxRepository; import kr.magicbox.generalgoods.application.port.in.HandleCreatorRevokedUseCase; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; + private final GeneralGoodsInboxRepository generalGoodsInboxRepository; @Idempotent @RetryableTopic @@ -22,4 +27,11 @@ public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + generalGoodsInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java index 6f3b8cda..eda8189b 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java @@ -3,17 +3,22 @@ import kr.magicbox.subscribe.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent; import kr.magicbox.subscribe.application.dto.command.HandleCreatorRevokedCommand; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; import kr.magicbox.subscribe.application.port.in.HandleCreatorRevokedUseCase; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; + private final SubscribeInboxRepository subscribeInboxRepository; @Idempotent @RetryableTopic @@ -22,4 +27,11 @@ public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + subscribeInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java index c9dddefb..d46a2885 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java @@ -4,18 +4,23 @@ import kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent; import kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent; import kr.magicbox.subscribe.application.dto.command.HandleUserRevokedCommand; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; import kr.magicbox.subscribe.application.port.in.HandleUserRevokedUseCase; import kr.magicbox.subscribe.domain.vo.SubscriberId; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class UserEventKafkaListener { private final HandleUserRevokedUseCase handleUserRevokedUseCase; + private final SubscribeInboxRepository subscribeInboxRepository; @Idempotent @RetryableTopic @@ -36,4 +41,11 @@ public void handleUserBannedEvent(ConsumerRecord consum HandleUserRevokedCommand.of(SubscriberId.of(event.userId().value())) ); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + subscribeInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } From fff651367716bea58de931e59d87d7d45cd2aaa8 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:09:01 +0900 Subject: [PATCH 051/116] =?UTF-8?q?fix(user/kafka):=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=EB=84=88=EC=97=90=20@RetryableTopic=20+?= =?UTF-8?q?=20@DltHandler=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthEventKafkaListener: @RetryableTopic, @DltHandler 추가 및 UserInboxRepository 주입 - SseConnectedKafkaListener, SseDisconnectedKafkaListener: @RetryableTopic 추가 - KafkaConfiguration: @EnableKafkaRetryTopic, ThreadPoolTaskScheduler 빈 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/AuthEventKafkaListener.java | 18 ++++- .../adapter/in/kafka/KafkaConfiguration.java | 70 ++++++++++--------- .../in/kafka/SseConnectedKafkaListener.java | 29 ++++++++ .../kafka/SseDisconnectedKafkaListener.java | 29 ++++++++ 4 files changed, 111 insertions(+), 35 deletions(-) create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java index 19cd4970..72e29f30 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java @@ -3,32 +3,46 @@ import kr.magicbox.user.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.user.adapter.in.kafka.event.LoginEvent; import kr.magicbox.user.adapter.in.kafka.event.LogoutEvent; +import kr.magicbox.user.adapter.out.persistence.repository.UserInboxRepository; import kr.magicbox.user.application.dto.command.EndSessionCommand; import kr.magicbox.user.application.dto.command.StartSessionCommand; import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class AuthEventKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; + private final UserInboxRepository userInboxRepository; @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-logged-in", groupId = "user-service") public void handleLoginEvent(ConsumerRecord record) { LoginEvent event = record.value(); - manageUserSessionUseCase.startSession(StartSessionCommand.of(event.userId(), event.createdAt())); + manageUserSessionUseCase.startSession(StartSessionCommand.of(event.userId(), event.occurredAt())); } @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-logged-out", groupId = "user-service") public void handleLogoutEvent(ConsumerRecord record) { LogoutEvent event = record.value(); - manageUserSessionUseCase.endSession(EndSessionCommand.of(event.userId(), event.createdAt())); + manageUserSessionUseCase.endSession(EndSessionCommand.of(event.userId(), event.occurredAt())); + } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + userInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); } } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java index 30f8178d..a9914fe8 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java @@ -1,50 +1,54 @@ package kr.magicbox.user.adapter.in.kafka; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.common.TopicPartition; -import org.springframework.boot.kafka.autoconfigure.KafkaProperties; +import kr.magicbox.user.adapter.in.kafka.properties.InboxProperties; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.ConsumerFactory; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.kafka.listener.CommonErrorHandler; -import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; -import org.springframework.kafka.listener.DefaultErrorHandler; -import org.springframework.util.backoff.ExponentialBackOff; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -@Slf4j +import java.util.HashMap; +import java.util.Map; + +@EnableKafkaRetryTopic @Configuration -@RequiredArgsConstructor +@EnableConfigurationProperties(InboxProperties.class) public class KafkaConfiguration { - private final KafkaProperties kafkaProperties; + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; @Bean - public CommonErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { - KafkaProperties.Retry.Topic retryTopic = kafkaProperties.getRetry().getTopic(); - ExponentialBackOff backOff = new ExponentialBackOff( - retryTopic.getBackoff().getDelay().toMillis(), - retryTopic.getBackoff().getMultiplier() - ); - backOff.setMaxAttempts(retryTopic.getAttempts()); + public ConsumerFactory stringConsumerFactory() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, "user-service"); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + return new DefaultKafkaConsumerFactory<>(props); + } - DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate, - (ConsumerRecord record, Exception ex) -> { - log.error("[DLT] 메시지 처리 실패, DLT 전송합니다. topic={}, offset={}, exception={}", record.topic(), record.offset(), ex.getMessage()); - return new TopicPartition(record.topic() + "-dlt", record.partition()); - }); - return new DefaultErrorHandler(recoverer, backOff); + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; } @Bean - public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( - ConsumerFactory consumerFactory, - CommonErrorHandler errorHandler) { - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); - factory.setCommonErrorHandler(errorHandler); + public ConcurrentKafkaListenerContainerFactory stringKafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(stringConsumerFactory()); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD); return factory; } -} \ No newline at end of file +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java new file mode 100644 index 00000000..a4a222c0 --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java @@ -0,0 +1,29 @@ +package kr.magicbox.user.adapter.in.kafka; + +import kr.magicbox.user.application.dto.command.StartSessionCommand; +import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; +import kr.magicbox.user.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SseConnectedKafkaListener { + + private final ManageUserSessionUseCase manageUserSessionUseCase; + + @RetryableTopic + @KafkaListener(topics = "sse.connected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") + public void handleConnected(ConsumerRecord record) { + Long userId = Long.parseLong(record.key()); + log.debug("sse.connected 수신 userId={}", userId); + manageUserSessionUseCase.startSession(StartSessionCommand.of(UserId.of(userId), Instant.now())); + } +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java new file mode 100644 index 00000000..1b8d334f --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java @@ -0,0 +1,29 @@ +package kr.magicbox.user.adapter.in.kafka; + +import kr.magicbox.user.application.dto.command.EndSessionCommand; +import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; +import kr.magicbox.user.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SseDisconnectedKafkaListener { + + private final ManageUserSessionUseCase manageUserSessionUseCase; + + @RetryableTopic + @KafkaListener(topics = "sse.disconnected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") + public void handleDisconnected(ConsumerRecord record) { + Long userId = Long.parseLong(record.key()); + log.debug("sse.disconnected 수신 userId={}", userId); + manageUserSessionUseCase.endSession(EndSessionCommand.of(UserId.of(userId), Instant.now())); + } +} From 1ec5ebcb57312e95ef0e0af49bcfbb795b703705 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:09:01 +0900 Subject: [PATCH 052/116] =?UTF-8?q?fix(user/kafka):=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=EB=84=88=EC=97=90=20@RetryableTopic=20+?= =?UTF-8?q?=20@DltHandler=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthEventKafkaListener: @RetryableTopic, @DltHandler 추가 및 UserInboxRepository 주입 - SseConnectedKafkaListener, SseDisconnectedKafkaListener: @RetryableTopic 추가 - KafkaConfiguration: @EnableKafkaRetryTopic, ThreadPoolTaskScheduler 빈 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/AuthEventKafkaListener.java | 18 ++++- .../adapter/in/kafka/KafkaConfiguration.java | 70 ++++++++++--------- .../in/kafka/SseConnectedKafkaListener.java | 29 ++++++++ .../kafka/SseDisconnectedKafkaListener.java | 29 ++++++++ 4 files changed, 111 insertions(+), 35 deletions(-) create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java index 19cd4970..72e29f30 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java @@ -3,32 +3,46 @@ import kr.magicbox.user.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.user.adapter.in.kafka.event.LoginEvent; import kr.magicbox.user.adapter.in.kafka.event.LogoutEvent; +import kr.magicbox.user.adapter.out.persistence.repository.UserInboxRepository; import kr.magicbox.user.application.dto.command.EndSessionCommand; import kr.magicbox.user.application.dto.command.StartSessionCommand; import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class AuthEventKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; + private final UserInboxRepository userInboxRepository; @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-logged-in", groupId = "user-service") public void handleLoginEvent(ConsumerRecord record) { LoginEvent event = record.value(); - manageUserSessionUseCase.startSession(StartSessionCommand.of(event.userId(), event.createdAt())); + manageUserSessionUseCase.startSession(StartSessionCommand.of(event.userId(), event.occurredAt())); } @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-logged-out", groupId = "user-service") public void handleLogoutEvent(ConsumerRecord record) { LogoutEvent event = record.value(); - manageUserSessionUseCase.endSession(EndSessionCommand.of(event.userId(), event.createdAt())); + manageUserSessionUseCase.endSession(EndSessionCommand.of(event.userId(), event.occurredAt())); + } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + userInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); } } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java index 30f8178d..a9914fe8 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java @@ -1,50 +1,54 @@ package kr.magicbox.user.adapter.in.kafka; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.common.TopicPartition; -import org.springframework.boot.kafka.autoconfigure.KafkaProperties; +import kr.magicbox.user.adapter.in.kafka.properties.InboxProperties; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.ConsumerFactory; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.kafka.listener.CommonErrorHandler; -import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; -import org.springframework.kafka.listener.DefaultErrorHandler; -import org.springframework.util.backoff.ExponentialBackOff; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -@Slf4j +import java.util.HashMap; +import java.util.Map; + +@EnableKafkaRetryTopic @Configuration -@RequiredArgsConstructor +@EnableConfigurationProperties(InboxProperties.class) public class KafkaConfiguration { - private final KafkaProperties kafkaProperties; + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; @Bean - public CommonErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { - KafkaProperties.Retry.Topic retryTopic = kafkaProperties.getRetry().getTopic(); - ExponentialBackOff backOff = new ExponentialBackOff( - retryTopic.getBackoff().getDelay().toMillis(), - retryTopic.getBackoff().getMultiplier() - ); - backOff.setMaxAttempts(retryTopic.getAttempts()); + public ConsumerFactory stringConsumerFactory() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, "user-service"); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + return new DefaultKafkaConsumerFactory<>(props); + } - DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate, - (ConsumerRecord record, Exception ex) -> { - log.error("[DLT] 메시지 처리 실패, DLT 전송합니다. topic={}, offset={}, exception={}", record.topic(), record.offset(), ex.getMessage()); - return new TopicPartition(record.topic() + "-dlt", record.partition()); - }); - return new DefaultErrorHandler(recoverer, backOff); + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; } @Bean - public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( - ConsumerFactory consumerFactory, - CommonErrorHandler errorHandler) { - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); - factory.setCommonErrorHandler(errorHandler); + public ConcurrentKafkaListenerContainerFactory stringKafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(stringConsumerFactory()); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD); return factory; } -} \ No newline at end of file +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java new file mode 100644 index 00000000..a4a222c0 --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java @@ -0,0 +1,29 @@ +package kr.magicbox.user.adapter.in.kafka; + +import kr.magicbox.user.application.dto.command.StartSessionCommand; +import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; +import kr.magicbox.user.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SseConnectedKafkaListener { + + private final ManageUserSessionUseCase manageUserSessionUseCase; + + @RetryableTopic + @KafkaListener(topics = "sse.connected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") + public void handleConnected(ConsumerRecord record) { + Long userId = Long.parseLong(record.key()); + log.debug("sse.connected 수신 userId={}", userId); + manageUserSessionUseCase.startSession(StartSessionCommand.of(UserId.of(userId), Instant.now())); + } +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java new file mode 100644 index 00000000..1b8d334f --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java @@ -0,0 +1,29 @@ +package kr.magicbox.user.adapter.in.kafka; + +import kr.magicbox.user.application.dto.command.EndSessionCommand; +import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; +import kr.magicbox.user.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SseDisconnectedKafkaListener { + + private final ManageUserSessionUseCase manageUserSessionUseCase; + + @RetryableTopic + @KafkaListener(topics = "sse.disconnected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") + public void handleDisconnected(ConsumerRecord record) { + Long userId = Long.parseLong(record.key()); + log.debug("sse.disconnected 수신 userId={}", userId); + manageUserSessionUseCase.endSession(EndSessionCommand.of(UserId.of(userId), Instant.now())); + } +} From 03679243bce2121b749e0cceda8cb9ae07bf5062 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:09:02 +0900 Subject: [PATCH 053/116] =?UTF-8?q?fix(user/kafka):=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=EB=84=88=EC=97=90=20@RetryableTopic=20+?= =?UTF-8?q?=20@DltHandler=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthEventKafkaListener: @RetryableTopic, @DltHandler 추가 및 UserInboxRepository 주입 - SseConnectedKafkaListener, SseDisconnectedKafkaListener: @RetryableTopic 추가 - KafkaConfiguration: @EnableKafkaRetryTopic, ThreadPoolTaskScheduler 빈 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/AuthEventKafkaListener.java | 14 ++++ .../adapter/in/kafka/KafkaConfiguration.java | 70 ++++++++++--------- .../in/kafka/SseConnectedKafkaListener.java | 2 + .../kafka/SseDisconnectedKafkaListener.java | 2 + 4 files changed, 55 insertions(+), 33 deletions(-) diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java index 692f060e..72e29f30 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java @@ -3,22 +3,28 @@ import kr.magicbox.user.adapter.in.kafka.annotation.Idempotent; import kr.magicbox.user.adapter.in.kafka.event.LoginEvent; import kr.magicbox.user.adapter.in.kafka.event.LogoutEvent; +import kr.magicbox.user.adapter.out.persistence.repository.UserInboxRepository; import kr.magicbox.user.application.dto.command.EndSessionCommand; import kr.magicbox.user.application.dto.command.StartSessionCommand; import kr.magicbox.user.application.port.in.ManageUserSessionUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class AuthEventKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; + private final UserInboxRepository userInboxRepository; @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-logged-in", groupId = "user-service") public void handleLoginEvent(ConsumerRecord record) { LoginEvent event = record.value(); @@ -26,9 +32,17 @@ public void handleLoginEvent(ConsumerRecord record) { } @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-logged-out", groupId = "user-service") public void handleLogoutEvent(ConsumerRecord record) { LogoutEvent event = record.value(); manageUserSessionUseCase.endSession(EndSessionCommand.of(event.userId(), event.occurredAt())); } + + @DltHandler + public void handleDlt(ConsumerRecord consumerRecord) { + log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()); + userInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset()) + .ifPresent(inbox -> inbox.markDeadLettered()); + } } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java index 30f8178d..a9914fe8 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/KafkaConfiguration.java @@ -1,50 +1,54 @@ package kr.magicbox.user.adapter.in.kafka; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.common.TopicPartition; -import org.springframework.boot.kafka.autoconfigure.KafkaProperties; +import kr.magicbox.user.adapter.in.kafka.properties.InboxProperties; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.ConsumerFactory; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.kafka.listener.CommonErrorHandler; -import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; -import org.springframework.kafka.listener.DefaultErrorHandler; -import org.springframework.util.backoff.ExponentialBackOff; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -@Slf4j +import java.util.HashMap; +import java.util.Map; + +@EnableKafkaRetryTopic @Configuration -@RequiredArgsConstructor +@EnableConfigurationProperties(InboxProperties.class) public class KafkaConfiguration { - private final KafkaProperties kafkaProperties; + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; @Bean - public CommonErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { - KafkaProperties.Retry.Topic retryTopic = kafkaProperties.getRetry().getTopic(); - ExponentialBackOff backOff = new ExponentialBackOff( - retryTopic.getBackoff().getDelay().toMillis(), - retryTopic.getBackoff().getMultiplier() - ); - backOff.setMaxAttempts(retryTopic.getAttempts()); + public ConsumerFactory stringConsumerFactory() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, "user-service"); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + return new DefaultKafkaConsumerFactory<>(props); + } - DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate, - (ConsumerRecord record, Exception ex) -> { - log.error("[DLT] 메시지 처리 실패, DLT 전송합니다. topic={}, offset={}, exception={}", record.topic(), record.offset(), ex.getMessage()); - return new TopicPartition(record.topic() + "-dlt", record.partition()); - }); - return new DefaultErrorHandler(recoverer, backOff); + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; } @Bean - public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( - ConsumerFactory consumerFactory, - CommonErrorHandler errorHandler) { - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); - factory.setCommonErrorHandler(errorHandler); + public ConcurrentKafkaListenerContainerFactory stringKafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(stringConsumerFactory()); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD); return factory; } -} \ No newline at end of file +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java index f23f14e2..a4a222c0 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; import java.time.Instant; @@ -18,6 +19,7 @@ public class SseConnectedKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; + @RetryableTopic @KafkaListener(topics = "sse.connected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") public void handleConnected(ConsumerRecord record) { Long userId = Long.parseLong(record.key()); diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java index 8a09eb27..1b8d334f 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; import java.time.Instant; @@ -18,6 +19,7 @@ public class SseDisconnectedKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; + @RetryableTopic @KafkaListener(topics = "sse.disconnected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") public void handleDisconnected(ConsumerRecord record) { Long userId = Long.parseLong(record.key()); From 12d17168a032a5e6a49cd1f771aaef94f38aa5d9 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:40:17 +0900 Subject: [PATCH 054/116] =?UTF-8?q?refactor(inbox):=20InboxEvent=EC=97=90?= =?UTF-8?q?=20occurredAt()=20=EC=B6=94=EA=B0=80,=20IdempotentAspect=20?= =?UTF-8?q?=EB=A6=AC=ED=94=8C=EB=A0=89=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/aop/IdempotentAspect.java | 19 ++++- .../adapter/in/kafka/event/InboxEvent.java | 3 + .../in/kafka/event/UserBannedEvent.java | 5 +- .../in/kafka/event/UserWithdrawnEvent.java | 5 +- .../in/kafka/aop/IdempotentAspect.java | 19 ++++- .../adapter/in/kafka/event/InboxEvent.java | 8 ++ .../in/kafka/event/UserBannedEvent.java | 6 +- .../in/kafka/event/UserWithdrawnEvent.java | 6 +- .../in/kafka/aop/IdempotentAspect.java | 79 +++++++++++++++++++ .../in/kafka/event/CreatorRevokedEvent.java | 6 +- .../adapter/in/kafka/event/InboxEvent.java | 8 ++ .../in/kafka/aop/IdempotentAspect.java | 79 +++++++++++++++++++ .../in/kafka/event/CreatorRevokedEvent.java | 6 +- .../adapter/in/kafka/event/InboxEvent.java | 8 ++ .../in/kafka/event/UserBannedEvent.java | 6 +- .../in/kafka/event/UserWithdrawnEvent.java | 6 +- .../in/kafka/aop/IdempotentAspect.java | 21 ++++- .../adapter/in/kafka/event/InboxEvent.java | 3 + .../adapter/in/kafka/event/LoginEvent.java | 6 +- .../adapter/in/kafka/event/LogoutEvent.java | 6 +- 20 files changed, 281 insertions(+), 24 deletions(-) create mode 100644 services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 7cee29e3..619a94a0 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,7 @@ package kr.magicbox.auth.adapter.in.kafka.aop; +import kr.magicbox.auth.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.auth.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.auth.adapter.out.persistence.entity.AuthInboxEntity; import kr.magicbox.auth.adapter.out.persistence.entity.AuthInboxStatus; import kr.magicbox.auth.adapter.out.persistence.repository.AuthInboxRepository; @@ -12,6 +14,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,19 @@ public class IdempotentAspect { private final AuthInboxRepository authInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.auth.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (authInboxRepository.existsByEventId(eventId)) { @@ -39,6 +51,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(AuthInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +64,10 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java index 281d3c67..15a7ff4a 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java @@ -1,5 +1,8 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import java.time.Instant; + public interface InboxEvent { Long eventId(); + Instant occurredAt(); } diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java index 1b80b892..0c497b1f 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; @@ -9,5 +10,5 @@ public record UserBannedEvent( @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") Long userId, - @JsonProperty("banned_at") Instant bannedAt -) implements InboxEvent {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java index 4b500a84..7d50a150 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; @@ -9,5 +10,5 @@ public record UserWithdrawnEvent( @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") Long userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt -) implements InboxEvent {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index ff871e4e..9ce8a731 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,7 @@ package kr.magicbox.creator.adapter.in.kafka.aop; +import kr.magicbox.creator.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.creator.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.creator.adapter.out.persistence.entity.CreatorInboxEntity; import kr.magicbox.creator.adapter.out.persistence.entity.CreatorInboxStatus; import kr.magicbox.creator.adapter.out.persistence.repository.CreatorInboxRepository; @@ -12,6 +14,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,19 @@ public class IdempotentAspect { private final CreatorInboxRepository creatorInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.creator.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (creatorInboxRepository.existsByEventId(eventId)) { @@ -39,6 +51,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(CreatorInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +64,10 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..3bf92198 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.creator.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java index 8391c3b1..a8cb62fe 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -8,6 +9,7 @@ @Builder public record UserBannedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("banned_at") Instant bannedAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java index 54f19a8e..e97ed525 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -8,6 +9,7 @@ @Builder public record UserWithdrawnEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..c44c8100 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,79 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.aop; + +import kr.magicbox.generalgoods.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.generalgoods.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxEntity; +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxStatus; +import kr.magicbox.generalgoods.adapter.out.persistence.repository.GeneralGoodsInboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final GeneralGoodsInboxRepository generalGoodsInboxRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.generalgoods.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } + + return transactionTemplate.execute(status -> { + if (generalGoodsInboxRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + GeneralGoodsInboxEntity inbox = generalGoodsInboxRepository.save(GeneralGoodsInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(GeneralGoodsInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java index 1826004a..a9dc5e15 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.generalgoods.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.generalgoods.domain.vo.CreatorId; import lombok.Builder; @@ -8,7 +9,8 @@ @Builder public record CreatorRevokedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("creator_id") CreatorId creatorId, - @JsonProperty("revoked_at") Instant revokedAt -) { + @JsonProperty("occurred_at") @JsonAlias("revoked_at") Instant occurredAt +) implements InboxEvent { } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..98e9300e --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..47dd7aa7 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,79 @@ +package kr.magicbox.subscribe.adapter.in.kafka.aop; + +import kr.magicbox.subscribe.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.subscribe.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxEntity; +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxStatus; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final SubscribeInboxRepository subscribeInboxRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.subscribe.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } + + return transactionTemplate.execute(status -> { + if (subscribeInboxRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + SubscribeInboxEntity inbox = subscribeInboxRepository.save(SubscribeInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(SubscribeInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java index c6bb5954..0089e1d8 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.CreatorId; import lombok.Builder; @@ -8,7 +9,8 @@ @Builder public record CreatorRevokedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("creator_id") CreatorId creatorId, - @JsonProperty("revoked_at") Instant revokedAt -) { + @JsonProperty("occurred_at") @JsonAlias("revoked_at") Instant occurredAt +) implements InboxEvent { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..7934ae2d --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.subscribe.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java index bd638dad..4a4fb38b 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.UserId; import lombok.Builder; @@ -8,7 +9,8 @@ @Builder public record UserBannedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("banned_at") Instant bannedAt -) { + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt +) implements InboxEvent { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java index df87832c..16bd5712 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.UserId; import lombok.Builder; @@ -8,7 +9,8 @@ @Builder public record UserWithdrawnEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt -) { + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt +) implements InboxEvent { } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index a0d80254..a90c15c9 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,7 @@ package kr.magicbox.user.adapter.in.kafka.aop; +import kr.magicbox.user.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.user.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.user.adapter.out.persistence.entity.UserInboxEntity; import kr.magicbox.user.adapter.out.persistence.entity.UserInboxStatus; import kr.magicbox.user.adapter.out.persistence.repository.UserInboxRepository; @@ -12,6 +14,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,19 @@ public class IdempotentAspect { private final UserInboxRepository userInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.user.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (userInboxRepository.existsByEventId(eventId)) { @@ -39,6 +51,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(UserInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +64,10 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) @@ -59,4 +76,4 @@ public Object around(ProceedingJoinPoint pjp) { .findFirst() .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); } -} \ No newline at end of file +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java index ceed5603..8f77091e 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java @@ -1,5 +1,8 @@ package kr.magicbox.user.adapter.in.kafka.event; +import java.time.Instant; + public interface InboxEvent { Long eventId(); + Instant occurredAt(); } \ No newline at end of file diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java index 3f7144cc..a70dce9e 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.user.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.user.domain.vo.UserId; import lombok.Builder; @@ -8,6 +9,7 @@ @Builder public record LoginEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("created_at") Instant createdAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("created_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java index ee08beb1..1ccdcce1 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.user.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.user.domain.vo.UserId; import lombok.Builder; @@ -8,6 +9,7 @@ @Builder public record LogoutEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("created_at") Instant createdAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("created_at") Instant occurredAt +) implements InboxEvent {} From af1f28429f6846bbee9f9b85336a3135bc62889d Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:40:17 +0900 Subject: [PATCH 055/116] =?UTF-8?q?refactor(inbox):=20InboxEvent=EC=97=90?= =?UTF-8?q?=20occurredAt()=20=EC=B6=94=EA=B0=80,=20IdempotentAspect=20?= =?UTF-8?q?=EB=A6=AC=ED=94=8C=EB=A0=89=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/aop/IdempotentAspect.java | 19 ++++- .../adapter/in/kafka/event/InboxEvent.java | 3 + .../in/kafka/event/UserBannedEvent.java | 5 +- .../in/kafka/event/UserWithdrawnEvent.java | 5 +- .../in/kafka/aop/IdempotentAspect.java | 19 ++++- .../adapter/in/kafka/event/InboxEvent.java | 8 ++ .../in/kafka/event/UserBannedEvent.java | 6 +- .../in/kafka/event/UserWithdrawnEvent.java | 6 +- .../in/kafka/aop/IdempotentAspect.java | 79 +++++++++++++++++++ .../in/kafka/event/CreatorRevokedEvent.java | 6 +- .../adapter/in/kafka/event/InboxEvent.java | 8 ++ .../in/kafka/aop/IdempotentAspect.java | 79 +++++++++++++++++++ .../in/kafka/event/CreatorRevokedEvent.java | 6 +- .../adapter/in/kafka/event/InboxEvent.java | 8 ++ .../in/kafka/event/UserBannedEvent.java | 6 +- .../in/kafka/event/UserWithdrawnEvent.java | 6 +- .../in/kafka/aop/IdempotentAspect.java | 21 ++++- .../adapter/in/kafka/event/InboxEvent.java | 3 + .../adapter/in/kafka/event/LoginEvent.java | 6 +- .../adapter/in/kafka/event/LogoutEvent.java | 6 +- 20 files changed, 281 insertions(+), 24 deletions(-) create mode 100644 services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 7cee29e3..619a94a0 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,7 @@ package kr.magicbox.auth.adapter.in.kafka.aop; +import kr.magicbox.auth.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.auth.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.auth.adapter.out.persistence.entity.AuthInboxEntity; import kr.magicbox.auth.adapter.out.persistence.entity.AuthInboxStatus; import kr.magicbox.auth.adapter.out.persistence.repository.AuthInboxRepository; @@ -12,6 +14,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,19 @@ public class IdempotentAspect { private final AuthInboxRepository authInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.auth.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (authInboxRepository.existsByEventId(eventId)) { @@ -39,6 +51,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(AuthInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +64,10 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java index 281d3c67..15a7ff4a 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java @@ -1,5 +1,8 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import java.time.Instant; + public interface InboxEvent { Long eventId(); + Instant occurredAt(); } diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java index 1b80b892..0c497b1f 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; @@ -9,5 +10,5 @@ public record UserBannedEvent( @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") Long userId, - @JsonProperty("banned_at") Instant bannedAt -) implements InboxEvent {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java index 4b500a84..7d50a150 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; @@ -9,5 +10,5 @@ public record UserWithdrawnEvent( @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") Long userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt -) implements InboxEvent {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index ff871e4e..9ce8a731 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,7 @@ package kr.magicbox.creator.adapter.in.kafka.aop; +import kr.magicbox.creator.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.creator.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.creator.adapter.out.persistence.entity.CreatorInboxEntity; import kr.magicbox.creator.adapter.out.persistence.entity.CreatorInboxStatus; import kr.magicbox.creator.adapter.out.persistence.repository.CreatorInboxRepository; @@ -12,6 +14,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,19 @@ public class IdempotentAspect { private final CreatorInboxRepository creatorInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.creator.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (creatorInboxRepository.existsByEventId(eventId)) { @@ -39,6 +51,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(CreatorInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +64,10 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..3bf92198 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.creator.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java index 8391c3b1..a8cb62fe 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -8,6 +9,7 @@ @Builder public record UserBannedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("banned_at") Instant bannedAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java index 54f19a8e..e97ed525 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -8,6 +9,7 @@ @Builder public record UserWithdrawnEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..c44c8100 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,79 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.aop; + +import kr.magicbox.generalgoods.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.generalgoods.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxEntity; +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxStatus; +import kr.magicbox.generalgoods.adapter.out.persistence.repository.GeneralGoodsInboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final GeneralGoodsInboxRepository generalGoodsInboxRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.generalgoods.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } + + return transactionTemplate.execute(status -> { + if (generalGoodsInboxRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + GeneralGoodsInboxEntity inbox = generalGoodsInboxRepository.save(GeneralGoodsInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(GeneralGoodsInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java index 1826004a..a9dc5e15 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.generalgoods.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.generalgoods.domain.vo.CreatorId; import lombok.Builder; @@ -8,7 +9,8 @@ @Builder public record CreatorRevokedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("creator_id") CreatorId creatorId, - @JsonProperty("revoked_at") Instant revokedAt -) { + @JsonProperty("occurred_at") @JsonAlias("revoked_at") Instant occurredAt +) implements InboxEvent { } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..98e9300e --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..47dd7aa7 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,79 @@ +package kr.magicbox.subscribe.adapter.in.kafka.aop; + +import kr.magicbox.subscribe.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.subscribe.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxEntity; +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxStatus; +import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final SubscribeInboxRepository subscribeInboxRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.subscribe.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } + + return transactionTemplate.execute(status -> { + if (subscribeInboxRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + SubscribeInboxEntity inbox = subscribeInboxRepository.save(SubscribeInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(SubscribeInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java index c6bb5954..0089e1d8 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.CreatorId; import lombok.Builder; @@ -8,7 +9,8 @@ @Builder public record CreatorRevokedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("creator_id") CreatorId creatorId, - @JsonProperty("revoked_at") Instant revokedAt -) { + @JsonProperty("occurred_at") @JsonAlias("revoked_at") Instant occurredAt +) implements InboxEvent { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..7934ae2d --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.subscribe.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java index bd638dad..4a4fb38b 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.UserId; import lombok.Builder; @@ -8,7 +9,8 @@ @Builder public record UserBannedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("banned_at") Instant bannedAt -) { + @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt +) implements InboxEvent { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java index df87832c..16bd5712 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.subscribe.domain.vo.UserId; import lombok.Builder; @@ -8,7 +9,8 @@ @Builder public record UserWithdrawnEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("withdrawn_at") Instant withdrawnAt -) { + @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt +) implements InboxEvent { } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index a0d80254..a90c15c9 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,7 @@ package kr.magicbox.user.adapter.in.kafka.aop; +import kr.magicbox.user.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.user.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.user.adapter.out.persistence.entity.UserInboxEntity; import kr.magicbox.user.adapter.out.persistence.entity.UserInboxStatus; import kr.magicbox.user.adapter.out.persistence.repository.UserInboxRepository; @@ -12,6 +14,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; @Slf4j @@ -22,11 +26,19 @@ public class IdempotentAspect { private final UserInboxRepository userInboxRepository; private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; @Around("@annotation(kr.magicbox.user.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + return null; + } return transactionTemplate.execute(status -> { if (userInboxRepository.existsByEventId(eventId)) { @@ -39,6 +51,7 @@ public Object around(ProceedingJoinPoint pjp) { .partition(consumerRecord.partition()) .offset(consumerRecord.offset()) .status(UserInboxStatus.PENDING) + .occurredAt(occurredAt) .build()); try { pjp.proceed(); @@ -51,6 +64,10 @@ public Object around(ProceedingJoinPoint pjp) { }); } + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) @@ -59,4 +76,4 @@ public Object around(ProceedingJoinPoint pjp) { .findFirst() .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); } -} \ No newline at end of file +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java index ceed5603..8f77091e 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java @@ -1,5 +1,8 @@ package kr.magicbox.user.adapter.in.kafka.event; +import java.time.Instant; + public interface InboxEvent { Long eventId(); + Instant occurredAt(); } \ No newline at end of file diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java index 3f7144cc..a70dce9e 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.user.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.user.domain.vo.UserId; import lombok.Builder; @@ -8,6 +9,7 @@ @Builder public record LoginEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("created_at") Instant createdAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("created_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java index ee08beb1..1ccdcce1 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.user.adapter.in.kafka.event; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.user.domain.vo.UserId; import lombok.Builder; @@ -8,6 +9,7 @@ @Builder public record LogoutEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, - @JsonProperty("created_at") Instant createdAt -) {} \ No newline at end of file + @JsonProperty("occurred_at") @JsonAlias("created_at") Instant occurredAt +) implements InboxEvent {} From 975caf0cf6865ebb806df31c16b4c9c502fb2aa6 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:40:18 +0900 Subject: [PATCH 056/116] =?UTF-8?q?refactor(inbox):=20InboxEvent=EC=97=90?= =?UTF-8?q?=20occurredAt()=20=EC=B6=94=EA=B0=80,=20IdempotentAspect=20?= =?UTF-8?q?=EB=A6=AC=ED=94=8C=EB=A0=89=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/aop/IdempotentAspect.java | 26 +++---------------- .../adapter/in/kafka/event/InboxEvent.java | 3 +++ .../in/kafka/aop/IdempotentAspect.java | 26 +++---------------- .../adapter/in/kafka/event/InboxEvent.java | 8 ++++++ .../in/kafka/event/UserBannedEvent.java | 3 ++- .../in/kafka/event/UserWithdrawnEvent.java | 3 ++- .../in/kafka/aop/IdempotentAspect.java | 26 +++---------------- .../in/kafka/event/CreatorRevokedEvent.java | 3 ++- .../adapter/in/kafka/event/InboxEvent.java | 3 +++ .../in/kafka/aop/IdempotentAspect.java | 26 +++---------------- .../in/kafka/event/CreatorRevokedEvent.java | 3 ++- .../adapter/in/kafka/event/InboxEvent.java | 3 +++ .../in/kafka/event/UserBannedEvent.java | 3 ++- .../in/kafka/event/UserWithdrawnEvent.java | 3 ++- .../in/kafka/aop/IdempotentAspect.java | 26 +++---------------- .../adapter/in/kafka/event/InboxEvent.java | 3 +++ .../adapter/in/kafka/event/LoginEvent.java | 3 ++- .../adapter/in/kafka/event/LogoutEvent.java | 3 ++- 18 files changed, 56 insertions(+), 118 deletions(-) create mode 100644 services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 0dcae7a2..619a94a0 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.auth.adapter.in.kafka.aop; +import kr.magicbox.auth.adapter.in.kafka.event.InboxEvent; import kr.magicbox.auth.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.auth.adapter.out.persistence.entity.AuthInboxEntity; import kr.magicbox.auth.adapter.out.persistence.entity.AuthInboxStatus; @@ -13,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; -import java.lang.reflect.RecordComponent; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -31,8 +31,9 @@ public class IdempotentAspect { @Around("@annotation(kr.magicbox.auth.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); - Instant occurredAt = extractOccurredAt(consumerRecord.value()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); @@ -67,25 +68,6 @@ private boolean isTooOld(Instant occurredAt) { return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); } - private Instant extractOccurredAt(Object payload) { - if (payload == null) { - return Instant.now(); - } - try { - for (RecordComponent component : payload.getClass().getRecordComponents()) { - if (component.getName().equals("occurredAt")) { - Object value = component.getAccessor().invoke(payload); - if (value instanceof Instant instant) { - return instant; - } - } - } - } catch (Exception e) { - log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); - } - return Instant.now(); - } - @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java index 281d3c67..15a7ff4a 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/event/InboxEvent.java @@ -1,5 +1,8 @@ package kr.magicbox.auth.adapter.in.kafka.event; +import java.time.Instant; + public interface InboxEvent { Long eventId(); + Instant occurredAt(); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index 79761501..9ce8a731 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.adapter.in.kafka.aop; +import kr.magicbox.creator.adapter.in.kafka.event.InboxEvent; import kr.magicbox.creator.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.creator.adapter.out.persistence.entity.CreatorInboxEntity; import kr.magicbox.creator.adapter.out.persistence.entity.CreatorInboxStatus; @@ -13,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; -import java.lang.reflect.RecordComponent; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -31,8 +31,9 @@ public class IdempotentAspect { @Around("@annotation(kr.magicbox.creator.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); - Instant occurredAt = extractOccurredAt(consumerRecord.value()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); @@ -67,25 +68,6 @@ private boolean isTooOld(Instant occurredAt) { return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); } - private Instant extractOccurredAt(Object payload) { - if (payload == null) { - return Instant.now(); - } - try { - for (RecordComponent component : payload.getClass().getRecordComponents()) { - if (component.getName().equals("occurredAt")) { - Object value = component.getAccessor().invoke(payload); - if (value instanceof Instant instant) { - return instant; - } - } - } - } catch (Exception e) { - log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); - } - return Instant.now(); - } - @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..3bf92198 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.creator.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java index 47839eae..a8cb62fe 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserBannedEvent.java @@ -9,6 +9,7 @@ @Builder public record UserBannedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java index d782ad64..e97ed525 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -9,6 +9,7 @@ @Builder public record UserWithdrawnEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java index 368a2f30..c44c8100 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.generalgoods.adapter.in.kafka.aop; +import kr.magicbox.generalgoods.adapter.in.kafka.event.InboxEvent; import kr.magicbox.generalgoods.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxEntity; import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxStatus; @@ -13,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; -import java.lang.reflect.RecordComponent; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -31,8 +31,9 @@ public class IdempotentAspect { @Around("@annotation(kr.magicbox.generalgoods.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); - Instant occurredAt = extractOccurredAt(consumerRecord.value()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); @@ -67,25 +68,6 @@ private boolean isTooOld(Instant occurredAt) { return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); } - private Instant extractOccurredAt(Object payload) { - if (payload == null) { - return Instant.now(); - } - try { - for (RecordComponent component : payload.getClass().getRecordComponents()) { - if (component.getName().equals("occurredAt")) { - Object value = component.getAccessor().invoke(payload); - if (value instanceof Instant instant) { - return instant; - } - } - } - } catch (Exception e) { - log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); - } - return Instant.now(); - } - @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java index 9ccccdf3..a9dc5e15 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/CreatorRevokedEvent.java @@ -9,7 +9,8 @@ @Builder public record CreatorRevokedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("creator_id") CreatorId creatorId, @JsonProperty("occurred_at") @JsonAlias("revoked_at") Instant occurredAt -) { +) implements InboxEvent { } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java index 2c891533..98e9300e 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/InboxEvent.java @@ -1,5 +1,8 @@ package kr.magicbox.generalgoods.adapter.in.kafka.event; +import java.time.Instant; + public interface InboxEvent { Long eventId(); + Instant occurredAt(); } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java index 4e9fba9d..47dd7aa7 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.subscribe.adapter.in.kafka.aop; +import kr.magicbox.subscribe.adapter.in.kafka.event.InboxEvent; import kr.magicbox.subscribe.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxEntity; import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxStatus; @@ -13,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; -import java.lang.reflect.RecordComponent; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -31,8 +31,9 @@ public class IdempotentAspect { @Around("@annotation(kr.magicbox.subscribe.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); - Instant occurredAt = extractOccurredAt(consumerRecord.value()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); @@ -67,25 +68,6 @@ private boolean isTooOld(Instant occurredAt) { return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); } - private Instant extractOccurredAt(Object payload) { - if (payload == null) { - return Instant.now(); - } - try { - for (RecordComponent component : payload.getClass().getRecordComponents()) { - if (component.getName().equals("occurredAt")) { - Object value = component.getAccessor().invoke(payload); - if (value instanceof Instant instant) { - return instant; - } - } - } - } catch (Exception e) { - log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); - } - return Instant.now(); - } - @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java index 35ff2483..0089e1d8 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/CreatorRevokedEvent.java @@ -9,7 +9,8 @@ @Builder public record CreatorRevokedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("creator_id") CreatorId creatorId, @JsonProperty("occurred_at") @JsonAlias("revoked_at") Instant occurredAt -) { +) implements InboxEvent { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java index 6930568e..7934ae2d 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/InboxEvent.java @@ -1,5 +1,8 @@ package kr.magicbox.subscribe.adapter.in.kafka.event; +import java.time.Instant; + public interface InboxEvent { Long eventId(); + Instant occurredAt(); } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java index d510a661..4a4fb38b 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserBannedEvent.java @@ -9,7 +9,8 @@ @Builder public record UserBannedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, @JsonProperty("occurred_at") @JsonAlias("banned_at") Instant occurredAt -) { +) implements InboxEvent { } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java index 9f81d36c..16bd5712 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/event/UserWithdrawnEvent.java @@ -9,7 +9,8 @@ @Builder public record UserWithdrawnEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, @JsonProperty("occurred_at") @JsonAlias("withdrawn_at") Instant occurredAt -) { +) implements InboxEvent { } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index 1ece5925..a90c15c9 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.user.adapter.in.kafka.aop; +import kr.magicbox.user.adapter.in.kafka.event.InboxEvent; import kr.magicbox.user.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.user.adapter.out.persistence.entity.UserInboxEntity; import kr.magicbox.user.adapter.out.persistence.entity.UserInboxStatus; @@ -13,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; -import java.lang.reflect.RecordComponent; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -31,8 +31,9 @@ public class IdempotentAspect { @Around("@annotation(kr.magicbox.user.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); - Instant occurredAt = extractOccurredAt(consumerRecord.value()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); @@ -67,25 +68,6 @@ private boolean isTooOld(Instant occurredAt) { return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); } - private Instant extractOccurredAt(Object payload) { - if (payload == null) { - return Instant.now(); - } - try { - for (RecordComponent component : payload.getClass().getRecordComponents()) { - if (component.getName().equals("occurredAt")) { - Object value = component.getAccessor().invoke(payload); - if (value instanceof Instant instant) { - return instant; - } - } - } - } catch (Exception e) { - log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); - } - return Instant.now(); - } - @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java index ceed5603..8f77091e 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/InboxEvent.java @@ -1,5 +1,8 @@ package kr.magicbox.user.adapter.in.kafka.event; +import java.time.Instant; + public interface InboxEvent { Long eventId(); + Instant occurredAt(); } \ No newline at end of file diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java index 7caf442d..a70dce9e 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LoginEvent.java @@ -9,6 +9,7 @@ @Builder public record LoginEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, @JsonProperty("occurred_at") @JsonAlias("created_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java index f00c4182..1ccdcce1 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/event/LogoutEvent.java @@ -9,6 +9,7 @@ @Builder public record LogoutEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("user_id") UserId userId, @JsonProperty("occurred_at") @JsonAlias("created_at") Instant occurredAt -) {} +) implements InboxEvent {} From a35f21178965d28df286de2870b9b54b4ec5f18a Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:48:47 +0900 Subject: [PATCH 057/116] =?UTF-8?q?refactor(inbox):=20InboxEvent=20occurre?= =?UTF-8?q?dAt()=20=EA=B3=84=EC=95=BD=20=EC=B6=94=EA=B0=80,=20IdempotentAs?= =?UTF-8?q?pect=20=EB=A6=AC=ED=94=8C=EB=A0=89=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/aop/IdempotentAspect.java | 26 +++---------------- .../kafka/event/DeliveryCompletedEvent.java | 3 ++- .../in/kafka/event/DeliveryStartedEvent.java | 3 ++- .../adapter/in/kafka/event/InboxEvent.java | 8 ++++++ .../in/kafka/event/OrderPrepareEventDto.java | 3 ++- .../kafka/event/PaymentCancelFailedEvent.java | 3 ++- .../event/PaymentCancelSucceededEvent.java | 3 ++- .../in/kafka/event/PaymentFailedEvent.java | 3 ++- .../in/kafka/event/PaymentSucceededEvent.java | 3 ++- .../kafka/event/StockReserveFailedEvent.java | 3 ++- .../event/StockReserveSucceededEvent.java | 3 ++- 11 files changed, 30 insertions(+), 31 deletions(-) create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/InboxEvent.java diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java index a768fc7c..59a3f276 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.order.adapter.in.kafka.aop; +import kr.magicbox.order.adapter.in.kafka.event.InboxEvent; import kr.magicbox.order.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxEntity; import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxStatus; @@ -13,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; -import java.lang.reflect.RecordComponent; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -31,8 +31,9 @@ public class IdempotentAspect { @Around("@annotation(kr.magicbox.order.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); - Instant occurredAt = extractOccurredAt(consumerRecord.value()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); @@ -67,25 +68,6 @@ private boolean isTooOld(Instant occurredAt) { return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); } - private Instant extractOccurredAt(Object payload) { - if (payload == null) { - return Instant.now(); - } - try { - for (RecordComponent component : payload.getClass().getRecordComponents()) { - if (component.getName().equals("occurredAt")) { - Object value = component.getAccessor().invoke(payload); - if (value instanceof Instant instant) { - return instant; - } - } - } - } catch (Exception e) { - log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); - } - return Instant.now(); - } - @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java index d93caceb..9d046067 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java @@ -6,6 +6,7 @@ import java.util.List; public record DeliveryCompletedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("order_line_id") Long orderLineId, @JsonProperty("customer_id") Long customerId, @@ -14,7 +15,7 @@ public record DeliveryCompletedEvent( @JsonProperty("delivered_at") Instant deliveredAt, @JsonProperty("items") List items, @JsonProperty("occurred_at") Instant occurredAt -) { +) implements InboxEvent { public record ItemPayload( @JsonProperty("product_id") Long productId, @JsonProperty("quantity") int quantity diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java index 5cd946a0..6e25582f 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java @@ -5,6 +5,7 @@ import java.time.Instant; public record DeliveryStartedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("order_line_id") Long orderLineId, @JsonProperty("customer_id") Long customerId, @@ -14,4 +15,4 @@ public record DeliveryStartedEvent( @JsonProperty("tracking_number") String trackingNumber, @JsonProperty("dispatched_at") Instant dispatchedAt, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/InboxEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..d3c4c8c6 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java index ef7e9082..09abb8d6 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java @@ -6,13 +6,14 @@ import java.util.List; public record OrderPrepareEventDto( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("seller_id") Long sellerId, @JsonProperty("items") List items, @JsonProperty("total_amount") long totalAmount, @JsonProperty("occurred_at") Instant occurredAt -) { +) implements InboxEvent { public record ItemPayload( @JsonProperty("product_id") Long productId, @JsonProperty("quantity") int quantity, diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java index 74aa199c..78564ff9 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java @@ -5,10 +5,11 @@ import java.time.Instant; public record PaymentCancelFailedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("reason") String reason, @JsonProperty("pg_code") String pgCode, @JsonProperty("pg_message") String pgMessage, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java index acdf76fc..81fa1a67 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java @@ -6,6 +6,7 @@ import java.util.List; public record PaymentCancelSucceededEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("pg_transaction_id") String pgTransactionId, @@ -14,7 +15,7 @@ public record PaymentCancelSucceededEvent( @JsonProperty("refunded_at") Instant refundedAt, @JsonProperty("items") List items, @JsonProperty("occurred_at") Instant occurredAt -) { +) implements InboxEvent { public record ItemPayload( @JsonProperty("product_id") Long productId, @JsonProperty("quantity") int quantity diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java index 97fe70b3..47e46b82 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java @@ -5,10 +5,11 @@ import java.time.Instant; public record PaymentFailedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("reason") String reason, @JsonProperty("pg_code") String pgCode, @JsonProperty("pg_message") String pgMessage, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java index 236b2d95..495a5190 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java @@ -5,7 +5,8 @@ import java.time.Instant; public record PaymentSucceededEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java index aeffa717..61cbc068 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java @@ -6,12 +6,13 @@ import java.util.List; public record StockReserveFailedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("reason") String reason, @JsonProperty("failed_items") List failedItems, @JsonProperty("occurred_at") Instant occurredAt -) { +) implements InboxEvent { public record FailedItemPayload( @JsonProperty("product_id") Long productId, @JsonProperty("requested") int requested, diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java index d8159fb2..4222cb26 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java @@ -5,7 +5,8 @@ import java.time.Instant; public record StockReserveSucceededEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} From 307cc45d4d4526b4bad5c3886b6818e43070000a Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:57:02 +0900 Subject: [PATCH 058/116] =?UTF-8?q?refactor(inbox):=20InboxEvent=20occurre?= =?UTF-8?q?dAt()=20=EA=B3=84=EC=95=BD=20=EC=B6=94=EA=B0=80,=20IdempotentAs?= =?UTF-8?q?pect=20=EB=A6=AC=ED=94=8C=EB=A0=89=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/aop/IdempotentAspect.java | 26 +++---------------- .../kafka/event/DeliveryCompletedEvent.java | 3 ++- .../adapter/in/kafka/event/InboxEvent.java | 8 ++++++ .../kafka/event/OrderAutoConfirmedEvent.java | 3 ++- .../in/kafka/event/OrderCancelEvent.java | 3 ++- .../in/kafka/event/OrderConfirmedEvent.java | 3 ++- .../in/kafka/event/OrderPrepareEvent.java | 3 ++- .../event/OrderPurchaseConfirmedEvent.java | 3 ++- .../event/PaymentCancelSucceededEvent.java | 3 ++- .../in/kafka/event/PaymentFailedEvent.java | 3 ++- .../in/kafka/event/PaymentSucceededEvent.java | 3 ++- .../kafka/event/StockReserveFailedEvent.java | 3 ++- .../event/StockReserveSucceededEvent.java | 3 ++- 13 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/InboxEvent.java diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java index 0439649e..0c927a2c 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java @@ -1,5 +1,6 @@ package kr.magicbox.orchestrator.adapter.in.kafka.aop; +import kr.magicbox.orchestrator.adapter.in.kafka.event.InboxEvent; import kr.magicbox.orchestrator.adapter.in.kafka.properties.InboxProperties; import kr.magicbox.orchestrator.adapter.out.persistence.entity.OrchestratorInboxEntity; import kr.magicbox.orchestrator.adapter.out.persistence.entity.OrchestratorInboxStatus; @@ -13,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionTemplate; -import java.lang.reflect.RecordComponent; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -31,8 +31,9 @@ public class IdempotentAspect { @Around("@annotation(kr.magicbox.orchestrator.adapter.in.kafka.annotation.Idempotent)") public Object around(ProceedingJoinPoint pjp) { ConsumerRecord consumerRecord = extractRecord(pjp); - Long eventId = Long.parseLong(consumerRecord.key()); - Instant occurredAt = extractOccurredAt(consumerRecord.value()); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); @@ -67,25 +68,6 @@ private boolean isTooOld(Instant occurredAt) { return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); } - private Instant extractOccurredAt(Object payload) { - if (payload == null) { - return Instant.now(); - } - try { - for (RecordComponent component : payload.getClass().getRecordComponents()) { - if (component.getName().equals("occurredAt")) { - Object value = component.getAccessor().invoke(payload); - if (value instanceof Instant instant) { - return instant; - } - } - } - } catch (Exception e) { - log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName()); - } - return Instant.now(); - } - @SuppressWarnings("unchecked") private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { return Arrays.stream(pjp.getArgs()) diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/DeliveryCompletedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/DeliveryCompletedEvent.java index e16045a9..86246906 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/DeliveryCompletedEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/DeliveryCompletedEvent.java @@ -5,8 +5,9 @@ import java.time.Instant; public record DeliveryCompletedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("order_line_id") Long orderLineId, @JsonProperty("delivery_id") Long deliveryId, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/InboxEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..3a48ef94 --- /dev/null +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.orchestrator.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderAutoConfirmedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderAutoConfirmedEvent.java index dc18a80b..86ba5cc5 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderAutoConfirmedEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderAutoConfirmedEvent.java @@ -7,10 +7,11 @@ @Builder public record OrderAutoConfirmedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("order_line_id") Long orderLineId, @JsonProperty("seller_id") Long sellerId, @JsonProperty("gross_amount") long grossAmount, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderCancelEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderCancelEvent.java index 0b56e228..b29266b8 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderCancelEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderCancelEvent.java @@ -7,7 +7,8 @@ @Builder public record OrderCancelEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderConfirmedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderConfirmedEvent.java index 83ad390a..61763bda 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderConfirmedEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderConfirmedEvent.java @@ -7,9 +7,10 @@ @Builder public record OrderConfirmedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("seller_id") Long sellerId, @JsonProperty("total_amount") Long totalAmount, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPrepareEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPrepareEvent.java index c5ae46ce..b92b8110 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPrepareEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPrepareEvent.java @@ -7,9 +7,10 @@ @Builder public record OrderPrepareEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("seller_id") Long sellerId, @JsonProperty("total_amount") Long totalAmount, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPurchaseConfirmedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPurchaseConfirmedEvent.java index 58f718b9..405f159b 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPurchaseConfirmedEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/OrderPurchaseConfirmedEvent.java @@ -7,10 +7,11 @@ @Builder public record OrderPurchaseConfirmedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("order_line_id") Long orderLineId, @JsonProperty("seller_id") Long sellerId, @JsonProperty("gross_amount") long grossAmount, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentCancelSucceededEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentCancelSucceededEvent.java index 66c5cbc5..525b00a9 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentCancelSucceededEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentCancelSucceededEvent.java @@ -7,7 +7,8 @@ @Builder public record PaymentCancelSucceededEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("payment_id") Long paymentId, @JsonProperty("order_id") Long orderId, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentFailedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentFailedEvent.java index 035e0f68..bb4dc1cf 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentFailedEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentFailedEvent.java @@ -7,6 +7,7 @@ @Builder public record PaymentFailedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentSucceededEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentSucceededEvent.java index c3b8341c..0dd633d5 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentSucceededEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/PaymentSucceededEvent.java @@ -7,9 +7,10 @@ @Builder public record PaymentSucceededEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("payment_id") Long paymentId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("seller_id") Long sellerId, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveFailedEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveFailedEvent.java index b61b3f84..b98a9772 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveFailedEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveFailedEvent.java @@ -7,6 +7,7 @@ @Builder public record StockReserveFailedEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveSucceededEvent.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveSucceededEvent.java index 482e4599..5ac0706f 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveSucceededEvent.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/event/StockReserveSucceededEvent.java @@ -7,8 +7,9 @@ @Builder public record StockReserveSucceededEvent( + @JsonProperty("event_id") Long eventId, @JsonProperty("order_id") Long orderId, @JsonProperty("customer_id") Long customerId, @JsonProperty("total_amount") Long totalAmount, @JsonProperty("occurred_at") Instant occurredAt -) {} +) implements InboxEvent {} From b5ba18e51c1f5def98dad612981d94f840deac57 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:30:03 +0900 Subject: [PATCH 059/116] =?UTF-8?q?fix(inbox):=20InboxProperties=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EB=B0=8F=20inbox=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth/user/creator/subscribe/general-goods InboxProperties.java 추가 - 각 서비스 application-dev.yml에 inbox.max-event-age-minutes: 5 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 4 +- .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 3 + .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 66 +++++++++++++++++++ .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 3 + .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 3 + 10 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..71692815 --- /dev/null +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.auth.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/auth/src/main/resources/application-dev.yml b/services/auth/src/main/resources/application-dev.yml index 7ba4f97b..87c3f183 100644 --- a/services/auth/src/main/resources/application-dev.yml +++ b/services/auth/src/main/resources/application-dev.yml @@ -107,4 +107,6 @@ security: logging: level: org.springframework.web: INFO - org.springframework.data.redis: INFO \ No newline at end of file + org.springframework.data.redis: INFO +inbox: + max-event-age-minutes: 5 diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..bfce8939 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.creator.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index 43ada9b8..f4ff366c 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -65,3 +65,6 @@ security: ips: - 127.0.0.1 - 0:0:0:0:0:0:0:1 + +inbox: + max-event-age-minutes: 5 diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..01abaf64 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/general-goods/src/main/resources/application-dev.yml b/services/general-goods/src/main/resources/application-dev.yml index e69de29b..97cea099 100644 --- a/services/general-goods/src/main/resources/application-dev.yml +++ b/services/general-goods/src/main/resources/application-dev.yml @@ -0,0 +1,66 @@ +spring: + application: + name: general-goods-dev + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + creator: + address: ${CREATOR_SERVICE_URL} + negotiation-type: plaintext + keep-alive-time: 30s + keep-alive-timeout: 5s + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: general-goods-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.generalgoods.adapter.in.kafka.event" + spring.json.type.mapping: creator-revoked:kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent + retry: + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..67beb67d --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.subscribe.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index bb00bb52..a0262e19 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -44,3 +44,6 @@ security: trusted: ips: - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..50022a72 --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.user.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/user/src/main/resources/application-dev.yml b/services/user/src/main/resources/application-dev.yml index 92d6c728..7557cb9e 100644 --- a/services/user/src/main/resources/application-dev.yml +++ b/services/user/src/main/resources/application-dev.yml @@ -69,3 +69,6 @@ resilience4j: user: default-profile-image-url: ${USER_PROFILE_DEFAULT_IMAGE_URL} + +inbox: + max-event-age-minutes: 5 From 481ceb472981b225dec22943ecae2cb3d2a4222c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:33:12 +0900 Subject: [PATCH 060/116] =?UTF-8?q?fix(inbox):=20InboxEntity=EC=97=90=20oc?= =?UTF-8?q?curredAt=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../persistence/entity/AuthInboxEntity.java | 8 ++- .../entity/CreatorInboxEntity.java | 8 ++- .../entity/GeneralGoodsInboxEntity.java | 53 +++++++++++++++++++ .../entity/SubscribeInboxEntity.java | 53 +++++++++++++++++++ .../persistence/entity/UserInboxEntity.java | 8 ++- 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java index b879a44f..33a4f8d1 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class AuthInboxEntity extends BaseEntity { @Column(nullable = false) private AuthInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public AuthInboxEntity(Long eventId, String topic, Integer partition, Long offset, AuthInboxStatus status) { + public AuthInboxEntity(Long eventId, String topic, Integer partition, Long offset, AuthInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java index 9249c18e..361c77de 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class CreatorInboxEntity extends BaseEntity { @Column(nullable = false) private CreatorInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public CreatorInboxEntity(Long eventId, String topic, Integer partition, Long offset, CreatorInboxStatus status) { + public CreatorInboxEntity(Long eventId, String topic, Integer partition, Long offset, CreatorInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java new file mode 100644 index 00000000..69113877 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "general_goods_inbox") +public class GeneralGoodsInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private GeneralGoodsInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public GeneralGoodsInboxEntity(Long eventId, String topic, Integer partition, Long offset, GeneralGoodsInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = GeneralGoodsInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = GeneralGoodsInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java new file mode 100644 index 00000000..77e696ee --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.subscribe.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "subscribe_inbox") +public class SubscribeInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SubscribeInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public SubscribeInboxEntity(Long eventId, String topic, Integer partition, Long offset, SubscribeInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = SubscribeInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = SubscribeInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java b/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java index 508806c4..aa8305bc 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class UserInboxEntity extends BaseEntity { @Column(nullable = false) private UserInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public UserInboxEntity(Long eventId, String topic, Integer partition, Long offset, UserInboxStatus status) { + public UserInboxEntity(Long eventId, String topic, Integer partition, Long offset, UserInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { From 7bfbddd844334c7fdbf48c2573f2a9bee6ed49b7 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:36:16 +0900 Subject: [PATCH 061/116] =?UTF-8?q?fix(inbox):=20InboxEntity=EC=97=90=20oc?= =?UTF-8?q?curredAt=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../persistence/entity/AuthInboxEntity.java | 8 ++- .../entity/CreatorInboxEntity.java | 8 ++- .../entity/GeneralGoodsInboxEntity.java | 53 +++++++++++++++++++ .../entity/SubscribeInboxEntity.java | 53 +++++++++++++++++++ .../persistence/entity/UserInboxEntity.java | 8 ++- 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java index b879a44f..33a4f8d1 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/out/persistence/entity/AuthInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class AuthInboxEntity extends BaseEntity { @Column(nullable = false) private AuthInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public AuthInboxEntity(Long eventId, String topic, Integer partition, Long offset, AuthInboxStatus status) { + public AuthInboxEntity(Long eventId, String topic, Integer partition, Long offset, AuthInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java index 9249c18e..361c77de 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class CreatorInboxEntity extends BaseEntity { @Column(nullable = false) private CreatorInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public CreatorInboxEntity(Long eventId, String topic, Integer partition, Long offset, CreatorInboxStatus status) { + public CreatorInboxEntity(Long eventId, String topic, Integer partition, Long offset, CreatorInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java new file mode 100644 index 00000000..69113877 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "general_goods_inbox") +public class GeneralGoodsInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private GeneralGoodsInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public GeneralGoodsInboxEntity(Long eventId, String topic, Integer partition, Long offset, GeneralGoodsInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = GeneralGoodsInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = GeneralGoodsInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java new file mode 100644 index 00000000..77e696ee --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.subscribe.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "subscribe_inbox") +public class SubscribeInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SubscribeInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public SubscribeInboxEntity(Long eventId, String topic, Integer partition, Long offset, SubscribeInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = SubscribeInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = SubscribeInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java b/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java index 508806c4..aa8305bc 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/out/persistence/entity/UserInboxEntity.java @@ -6,6 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Instant; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -28,13 +30,17 @@ public class UserInboxEntity extends BaseEntity { @Column(nullable = false) private UserInboxStatus status; + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + @Builder - public UserInboxEntity(Long eventId, String topic, Integer partition, Long offset, UserInboxStatus status) { + public UserInboxEntity(Long eventId, String topic, Integer partition, Long offset, UserInboxStatus status, Instant occurredAt) { this.eventId = eventId; this.topic = topic; this.partition = partition; this.offset = offset; this.status = status; + this.occurredAt = occurredAt; } public void markProcessed() { From 947d50a32a5e11f0e9924b69358f7c55f0bc3095 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:39:09 +0900 Subject: [PATCH 062/116] ci: trigger build From 1531aa1ad50a2dd77c61eea5324e7c710ec0a239 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:39:11 +0900 Subject: [PATCH 063/116] ci: trigger build From 92bede8e643b6acba677ab37d365f60ac154e6fd Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:42:55 +0900 Subject: [PATCH 064/116] =?UTF-8?q?fix(inbox):=20InboxProperties=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EB=B0=8F=20inbox=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 4 +- .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 3 + .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 66 +++++++++++++++++++ .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 3 + .../in/kafka/properties/InboxProperties.java | 12 ++++ .../src/main/resources/application-dev.yml | 3 + 10 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..71692815 --- /dev/null +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.auth.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/auth/src/main/resources/application-dev.yml b/services/auth/src/main/resources/application-dev.yml index 7ba4f97b..87c3f183 100644 --- a/services/auth/src/main/resources/application-dev.yml +++ b/services/auth/src/main/resources/application-dev.yml @@ -107,4 +107,6 @@ security: logging: level: org.springframework.web: INFO - org.springframework.data.redis: INFO \ No newline at end of file + org.springframework.data.redis: INFO +inbox: + max-event-age-minutes: 5 diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..bfce8939 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.creator.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index 43ada9b8..f4ff366c 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -65,3 +65,6 @@ security: ips: - 127.0.0.1 - 0:0:0:0:0:0:0:1 + +inbox: + max-event-age-minutes: 5 diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..01abaf64 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/general-goods/src/main/resources/application-dev.yml b/services/general-goods/src/main/resources/application-dev.yml index e69de29b..97cea099 100644 --- a/services/general-goods/src/main/resources/application-dev.yml +++ b/services/general-goods/src/main/resources/application-dev.yml @@ -0,0 +1,66 @@ +spring: + application: + name: general-goods-dev + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + creator: + address: ${CREATOR_SERVICE_URL} + negotiation-type: plaintext + keep-alive-time: 30s + keep-alive-timeout: 5s + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: general-goods-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.generalgoods.adapter.in.kafka.event" + spring.json.type.mapping: creator-revoked:kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent + retry: + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..67beb67d --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.subscribe.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index bb00bb52..a0262e19 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -44,3 +44,6 @@ security: trusted: ips: - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..50022a72 --- /dev/null +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.user.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/user/src/main/resources/application-dev.yml b/services/user/src/main/resources/application-dev.yml index 92d6c728..7557cb9e 100644 --- a/services/user/src/main/resources/application-dev.yml +++ b/services/user/src/main/resources/application-dev.yml @@ -69,3 +69,6 @@ resilience4j: user: default-profile-image-url: ${USER_PROFILE_DEFAULT_IMAGE_URL} + +inbox: + max-event-age-minutes: 5 From 08ae80646df43641efef2866fea45b185cd2d869 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:45:15 +0900 Subject: [PATCH 065/116] =?UTF-8?q?fix(inbox):=20SubscribeInboxStatus/Repo?= =?UTF-8?q?sitory,=20GeneralGoodsInboxStatus/Repository=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../persistence/entity/GeneralGoodsInboxStatus.java | 7 +++++++ .../repository/GeneralGoodsInboxRepository.java | 13 +++++++++++++ .../persistence/entity/SubscribeInboxStatus.java | 7 +++++++ .../repository/SubscribeInboxRepository.java | 13 +++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java new file mode 100644 index 00000000..8470d068 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.entity; + +public enum GeneralGoodsInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java new file mode 100644 index 00000000..d4768db1 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java @@ -0,0 +1,13 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.repository; + +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface GeneralGoodsInboxRepository extends JpaRepository { + + boolean existsByEventId(Long eventId); + + Optional findByTopicAndPartitionAndOffset(String topic, Integer partition, Long offset); +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java new file mode 100644 index 00000000..23b41857 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.subscribe.adapter.out.persistence.entity; + +public enum SubscribeInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java new file mode 100644 index 00000000..183c0aa7 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java @@ -0,0 +1,13 @@ +package kr.magicbox.subscribe.adapter.out.persistence.repository; + +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SubscribeInboxRepository extends JpaRepository { + + boolean existsByEventId(Long eventId); + + Optional findByTopicAndPartitionAndOffset(String topic, Integer partition, Long offset); +} From 2b276d1d6bd96f3e25c82436e61fde821be17c53 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:45:17 +0900 Subject: [PATCH 066/116] =?UTF-8?q?fix(inbox):=20SubscribeInboxStatus/Repo?= =?UTF-8?q?sitory,=20GeneralGoodsInboxStatus/Repository=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../persistence/entity/GeneralGoodsInboxStatus.java | 7 +++++++ .../repository/GeneralGoodsInboxRepository.java | 13 +++++++++++++ .../persistence/entity/SubscribeInboxStatus.java | 7 +++++++ .../repository/SubscribeInboxRepository.java | 13 +++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java create mode 100644 services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java create mode 100644 services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java new file mode 100644 index 00000000..8470d068 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/entity/GeneralGoodsInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.entity; + +public enum GeneralGoodsInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java new file mode 100644 index 00000000..d4768db1 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsInboxRepository.java @@ -0,0 +1,13 @@ +package kr.magicbox.generalgoods.adapter.out.persistence.repository; + +import kr.magicbox.generalgoods.adapter.out.persistence.entity.GeneralGoodsInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface GeneralGoodsInboxRepository extends JpaRepository { + + boolean existsByEventId(Long eventId); + + Optional findByTopicAndPartitionAndOffset(String topic, Integer partition, Long offset); +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java new file mode 100644 index 00000000..23b41857 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/entity/SubscribeInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.subscribe.adapter.out.persistence.entity; + +public enum SubscribeInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java new file mode 100644 index 00000000..183c0aa7 --- /dev/null +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/out/persistence/repository/SubscribeInboxRepository.java @@ -0,0 +1,13 @@ +package kr.magicbox.subscribe.adapter.out.persistence.repository; + +import kr.magicbox.subscribe.adapter.out.persistence.entity.SubscribeInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SubscribeInboxRepository extends JpaRepository { + + boolean existsByEventId(Long eventId); + + Optional findByTopicAndPartitionAndOffset(String topic, Integer partition, Long offset); +} From 4a78627ee790059b05bfd3973d75f6273890cabe Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:53:35 +0900 Subject: [PATCH 067/116] =?UTF-8?q?fix(creator):=20Outbox=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EB=AA=85=20creator=5Fdomain=5Fevent=20?= =?UTF-8?q?=E2=86=92=20creator=5Foutbox=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/out/persistence/entity/CreatorOutboxEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java index bed07ba5..2117f2c8 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java @@ -11,7 +11,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "creator_domain_event") +@Table(name = "creator_outbox") public class CreatorOutboxEntity extends BaseEntity { @Column(nullable = false) From 4109f1fb6e9b2115640705b7b395f455a9cc9a1f Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:54:02 +0900 Subject: [PATCH 068/116] =?UTF-8?q?fix(creator):=20CreatorDomainEvent=20?= =?UTF-8?q?=E2=86=92=20CreatorOutbox=20=EB=A6=AC=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EB=B0=8F=20=ED=85=8C=EC=9D=B4=EB=B8=94=EB=AA=85=20?= =?UTF-8?q?creator=5Foutbox=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ...EventAdapter.java => CreatorOutboxAdapter.java} | 14 +++++++------- ...inEventEntity.java => CreatorOutboxEntity.java} | 8 ++++---- ...epository.java => CreatorOutboxRepository.java} | 6 +++--- ...yPort.java => CreatorOutboxRepositoryPort.java} | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/{CreatorDomainEventAdapter.java => CreatorOutboxAdapter.java} (66%) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/{CreatorDomainEventEntity.java => CreatorOutboxEntity.java} (78%) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/{CreatorDomainEventRepository.java => CreatorOutboxRepository.java} (60%) rename services/creator/src/main/java/kr/magicbox/creator/application/port/out/{CreatorDomainEventRepositoryPort.java => CreatorOutboxRepositoryPort.java} (74%) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java similarity index 66% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java index b3c5851e..830dceb4 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java @@ -1,8 +1,8 @@ package kr.magicbox.creator.adapter.out.persistence; -import kr.magicbox.creator.adapter.out.persistence.entity.CreatorDomainEventEntity; -import kr.magicbox.creator.adapter.out.persistence.repository.CreatorDomainEventRepository; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.adapter.out.persistence.entity.CreatorOutboxEntity; +import kr.magicbox.creator.adapter.out.persistence.repository.CreatorOutboxRepository; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.domain.event.CreatorDomainEvent; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -10,18 +10,18 @@ @Repository @RequiredArgsConstructor -public class CreatorDomainEventAdapter implements CreatorDomainEventRepositoryPort { +public class CreatorOutboxAdapter implements CreatorOutboxRepositoryPort { - private final CreatorDomainEventRepository creatorDomainEventRepository; + private final CreatorOutboxRepository creatorOutboxRepository; private final ObjectMapper objectMapper; @Override public void save(CreatorDomainEvent event) { String payload = objectMapper.writeValueAsString(event); - creatorDomainEventRepository.save(CreatorDomainEventEntity.builder() + creatorOutboxRepository.save(CreatorOutboxEntity.builder() .eventType(event.eventType().getValue()) .key(event.key()) .payload(payload) .build()); } -} \ No newline at end of file +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java similarity index 78% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java index 252857a0..2117f2c8 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java @@ -11,8 +11,8 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "creator_domain_event") -public class CreatorDomainEventEntity extends BaseEntity { +@Table(name = "creator_outbox") +public class CreatorOutboxEntity extends BaseEntity { @Column(nullable = false) private String eventType; @@ -24,9 +24,9 @@ public class CreatorDomainEventEntity extends BaseEntity { private String payload; @Builder - public CreatorDomainEventEntity(String eventType, String key, String payload) { + public CreatorOutboxEntity(String eventType, String key, String payload) { this.eventType = eventType; this.key = key; this.payload = payload; } -} \ No newline at end of file +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java similarity index 60% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java index 2352aa0b..d904b44d 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.adapter.out.persistence.repository; -import kr.magicbox.creator.adapter.out.persistence.entity.CreatorDomainEventEntity; +import kr.magicbox.creator.adapter.out.persistence.entity.CreatorOutboxEntity; import org.springframework.data.jpa.repository.JpaRepository; -public interface CreatorDomainEventRepository extends JpaRepository { -} \ No newline at end of file +public interface CreatorOutboxRepository extends JpaRepository { +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java b/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java similarity index 74% rename from services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java rename to services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java index 52e075ea..c936b30c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java @@ -2,6 +2,6 @@ import kr.magicbox.creator.domain.event.CreatorDomainEvent; -public interface CreatorDomainEventRepositoryPort { +public interface CreatorOutboxRepositoryPort { void save(CreatorDomainEvent event); -} \ No newline at end of file +} From 320c66644f020646842ad25e5cbf895fe23bf38d Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:54:04 +0900 Subject: [PATCH 069/116] =?UTF-8?q?fix(creator):=20CreatorDomainEvent=20?= =?UTF-8?q?=E2=86=92=20CreatorOutbox=20=EB=A6=AC=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EB=B0=8F=20=ED=85=8C=EC=9D=B4=EB=B8=94=EB=AA=85=20?= =?UTF-8?q?creator=5Foutbox=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ...EventAdapter.java => CreatorOutboxAdapter.java} | 14 +++++++------- ...inEventEntity.java => CreatorOutboxEntity.java} | 8 ++++---- ...epository.java => CreatorOutboxRepository.java} | 6 +++--- ...yPort.java => CreatorOutboxRepositoryPort.java} | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/{CreatorDomainEventAdapter.java => CreatorOutboxAdapter.java} (66%) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/{CreatorDomainEventEntity.java => CreatorOutboxEntity.java} (78%) rename services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/{CreatorDomainEventRepository.java => CreatorOutboxRepository.java} (60%) rename services/creator/src/main/java/kr/magicbox/creator/application/port/out/{CreatorDomainEventRepositoryPort.java => CreatorOutboxRepositoryPort.java} (74%) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java similarity index 66% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java index b3c5851e..830dceb4 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorDomainEventAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/CreatorOutboxAdapter.java @@ -1,8 +1,8 @@ package kr.magicbox.creator.adapter.out.persistence; -import kr.magicbox.creator.adapter.out.persistence.entity.CreatorDomainEventEntity; -import kr.magicbox.creator.adapter.out.persistence.repository.CreatorDomainEventRepository; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.adapter.out.persistence.entity.CreatorOutboxEntity; +import kr.magicbox.creator.adapter.out.persistence.repository.CreatorOutboxRepository; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.domain.event.CreatorDomainEvent; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -10,18 +10,18 @@ @Repository @RequiredArgsConstructor -public class CreatorDomainEventAdapter implements CreatorDomainEventRepositoryPort { +public class CreatorOutboxAdapter implements CreatorOutboxRepositoryPort { - private final CreatorDomainEventRepository creatorDomainEventRepository; + private final CreatorOutboxRepository creatorOutboxRepository; private final ObjectMapper objectMapper; @Override public void save(CreatorDomainEvent event) { String payload = objectMapper.writeValueAsString(event); - creatorDomainEventRepository.save(CreatorDomainEventEntity.builder() + creatorOutboxRepository.save(CreatorOutboxEntity.builder() .eventType(event.eventType().getValue()) .key(event.key()) .payload(payload) .build()); } -} \ No newline at end of file +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java similarity index 78% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java index 252857a0..2117f2c8 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorDomainEventEntity.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/entity/CreatorOutboxEntity.java @@ -11,8 +11,8 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "creator_domain_event") -public class CreatorDomainEventEntity extends BaseEntity { +@Table(name = "creator_outbox") +public class CreatorOutboxEntity extends BaseEntity { @Column(nullable = false) private String eventType; @@ -24,9 +24,9 @@ public class CreatorDomainEventEntity extends BaseEntity { private String payload; @Builder - public CreatorDomainEventEntity(String eventType, String key, String payload) { + public CreatorOutboxEntity(String eventType, String key, String payload) { this.eventType = eventType; this.key = key; this.payload = payload; } -} \ No newline at end of file +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java similarity index 60% rename from services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java rename to services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java index 2352aa0b..d904b44d 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorDomainEventRepository.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorOutboxRepository.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.adapter.out.persistence.repository; -import kr.magicbox.creator.adapter.out.persistence.entity.CreatorDomainEventEntity; +import kr.magicbox.creator.adapter.out.persistence.entity.CreatorOutboxEntity; import org.springframework.data.jpa.repository.JpaRepository; -public interface CreatorDomainEventRepository extends JpaRepository { -} \ No newline at end of file +public interface CreatorOutboxRepository extends JpaRepository { +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java b/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java similarity index 74% rename from services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java rename to services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java index 52e075ea..c936b30c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorDomainEventRepositoryPort.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/port/out/CreatorOutboxRepositoryPort.java @@ -2,6 +2,6 @@ import kr.magicbox.creator.domain.event.CreatorDomainEvent; -public interface CreatorDomainEventRepositoryPort { +public interface CreatorOutboxRepositoryPort { void save(CreatorDomainEvent event); -} \ No newline at end of file +} From df2ab7aa175271ba670e21d7789b9e72b18432dc Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:55:21 +0900 Subject: [PATCH 070/116] =?UTF-8?q?fix(creator):=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A0=88=EC=9D=B4=EC=96=B4=20CreatorDomainEventRep?= =?UTF-8?q?ositoryPort=20=E2=86=92=20CreatorOutboxRepositoryPort=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../creator/application/service/BanCreatorService.java | 6 +++--- .../application/service/HandleUserBannedService.java | 6 +++--- .../service/HandleUserWithdrawnService.java | 8 ++++---- .../application/service/UnbanCreatorService.java | 8 ++++---- .../application/service/WithdrawCreatorService.java | 6 +++--- .../ReviewCreatorCertificationService.java | 10 +++++----- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java index 7cf05fce..d6cbff9a 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.BanCreatorCommand; import kr.magicbox.creator.application.port.in.BanCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class BanCreatorService implements BanCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Override @Transactional @@ -31,7 +31,7 @@ public void banCreator(BanCreatorCommand command) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java index 5cc7bd1a..e51acc7c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserBannedUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserBannedService implements HandleUserBannedUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Override @Transactional @@ -36,7 +36,7 @@ public void handleUserBanned(UserId userId) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java index 38c58c80..a345fb9b 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserWithdrawnUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserWithdrawnService implements HandleUserWithdrawnUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort creatorDomainEventRepositoryPort; + private final CreatorOutboxRepositoryPort creatorOutboxRepositoryPort; @Override @Transactional @@ -28,10 +28,10 @@ public void handleUserWithdrawn(UserId userId) { Creator creator = creatorOpt.get(); creator.delete(); creatorRepositoryPort.update(creator); - creatorDomainEventRepositoryPort.save( + creatorOutboxRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java index bcc1c103..0eaafc9f 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.UnbanCreatorCommand; import kr.magicbox.creator.application.port.in.UnbanCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorUnbannedEvent; @@ -18,7 +18,7 @@ public class UnbanCreatorService implements UnbanCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort creatorDomainEventRepositoryPort; + private final CreatorOutboxRepositoryPort creatorOutboxRepositoryPort; @Override @Transactional @@ -31,8 +31,8 @@ public void unbanCreator(UnbanCreatorCommand command) { CreatorUnbannedEvent event = CreatorUnbannedEvent.builder() .creatorId(creator.getId()) - .unbannedAt(Instant.now()) + .occurredAt(Instant.now()) .build(); - creatorDomainEventRepositoryPort.save(event); + creatorOutboxRepositoryPort.save(event); } } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java index f18f6caf..c8ef6925 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.WithdrawCreatorCommand; import kr.magicbox.creator.application.port.in.WithdrawCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class WithdrawCreatorService implements WithdrawCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Transactional @Override @@ -29,7 +29,7 @@ public void withdrawCreator(WithdrawCreatorCommand command) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java index 10c1ebb0..09d45430 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java @@ -3,7 +3,7 @@ import kr.magicbox.creator.application.dto.command.ReviewCreatorCertificationCommand; import kr.magicbox.creator.application.port.in.ReviewCreatorCertificationUseCase; import kr.magicbox.creator.application.port.out.CreatorCertificationRepositoryPort; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.application.port.out.UserNicknameQueryPort; import kr.magicbox.creator.domain.aggregate.Creator; @@ -24,7 +24,7 @@ public class ReviewCreatorCertificationService implements ReviewCreatorCertifica private final CreatorCertificationRepositoryPort certificationRepositoryPort; private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; private final UserNicknameQueryPort userNicknameQueryPort; @Transactional @@ -49,7 +49,7 @@ private CreatorCertificationApprovedEvent buildApprovedEvent(CreatorCertificatio return CreatorCertificationApprovedEvent.builder() .userId(certification.getUserId()) .certificationId(certification.getId()) - .reviewedAt(certification.getResult().reviewedAt()) + .occurredAt(certification.getResult().reviewedAt()) .build(); } @@ -58,7 +58,7 @@ private CreatorCertificationRejectedEvent buildRejectedEvent(CreatorCertificatio .userId(certification.getUserId()) .certificationId(certification.getId()) .reviewMessage(certification.getResult().reviewMessage()) - .reviewedAt(certification.getResult().reviewedAt()) + .occurredAt(certification.getResult().reviewedAt()) .build(); } @@ -68,7 +68,7 @@ private void createCreator(CreatorCertification certification) { } String nickname = userNicknameQueryPort.getNickname(certification.getUserId()); - Creator creator = Creator.builder() + Creator creator = Creator.createBuilder() .userId(certification.getUserId()) .nickname(Nickname.of(nickname)) .genres(certification.getRequest().genres()) From 04a04572292649e627ee03632e08b9bab5be159c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:55:23 +0900 Subject: [PATCH 071/116] =?UTF-8?q?fix(creator):=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A0=88=EC=9D=B4=EC=96=B4=20CreatorDomainEventRep?= =?UTF-8?q?ositoryPort=20=E2=86=92=20CreatorOutboxRepositoryPort=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../creator/application/service/BanCreatorService.java | 6 +++--- .../application/service/HandleUserBannedService.java | 6 +++--- .../service/HandleUserWithdrawnService.java | 8 ++++---- .../application/service/UnbanCreatorService.java | 8 ++++---- .../application/service/WithdrawCreatorService.java | 6 +++--- .../ReviewCreatorCertificationService.java | 10 +++++----- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java index 7cf05fce..d6cbff9a 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/BanCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.BanCreatorCommand; import kr.magicbox.creator.application.port.in.BanCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class BanCreatorService implements BanCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Override @Transactional @@ -31,7 +31,7 @@ public void banCreator(BanCreatorCommand command) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java index 5cc7bd1a..e51acc7c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserBannedService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserBannedUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserBannedService implements HandleUserBannedUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Override @Transactional @@ -36,7 +36,7 @@ public void handleUserBanned(UserId userId) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java index 38c58c80..a345fb9b 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/HandleUserWithdrawnService.java @@ -1,7 +1,7 @@ package kr.magicbox.creator.application.service; import kr.magicbox.creator.application.port.in.HandleUserWithdrawnUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -18,7 +18,7 @@ public class HandleUserWithdrawnService implements HandleUserWithdrawnUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort creatorDomainEventRepositoryPort; + private final CreatorOutboxRepositoryPort creatorOutboxRepositoryPort; @Override @Transactional @@ -28,10 +28,10 @@ public void handleUserWithdrawn(UserId userId) { Creator creator = creatorOpt.get(); creator.delete(); creatorRepositoryPort.update(creator); - creatorDomainEventRepositoryPort.save( + creatorOutboxRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java index bcc1c103..0eaafc9f 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/UnbanCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.UnbanCreatorCommand; import kr.magicbox.creator.application.port.in.UnbanCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorUnbannedEvent; @@ -18,7 +18,7 @@ public class UnbanCreatorService implements UnbanCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort creatorDomainEventRepositoryPort; + private final CreatorOutboxRepositoryPort creatorOutboxRepositoryPort; @Override @Transactional @@ -31,8 +31,8 @@ public void unbanCreator(UnbanCreatorCommand command) { CreatorUnbannedEvent event = CreatorUnbannedEvent.builder() .creatorId(creator.getId()) - .unbannedAt(Instant.now()) + .occurredAt(Instant.now()) .build(); - creatorDomainEventRepositoryPort.save(event); + creatorOutboxRepositoryPort.save(event); } } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java index f18f6caf..c8ef6925 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/WithdrawCreatorService.java @@ -2,7 +2,7 @@ import kr.magicbox.creator.application.dto.command.WithdrawCreatorCommand; import kr.magicbox.creator.application.port.in.WithdrawCreatorUseCase; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.domain.aggregate.Creator; import kr.magicbox.creator.domain.event.CreatorRevokedEvent; @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class WithdrawCreatorService implements WithdrawCreatorUseCase { private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; @Transactional @Override @@ -29,7 +29,7 @@ public void withdrawCreator(WithdrawCreatorCommand command) { eventRepositoryPort.save( CreatorRevokedEvent.builder() .creatorId(creator.getId()) - .revokedAt(Instant.now()) + .occurredAt(Instant.now()) .build() ); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java index 10c1ebb0..09d45430 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ReviewCreatorCertificationService.java @@ -3,7 +3,7 @@ import kr.magicbox.creator.application.dto.command.ReviewCreatorCertificationCommand; import kr.magicbox.creator.application.port.in.ReviewCreatorCertificationUseCase; import kr.magicbox.creator.application.port.out.CreatorCertificationRepositoryPort; -import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; +import kr.magicbox.creator.application.port.out.CreatorOutboxRepositoryPort; import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; import kr.magicbox.creator.application.port.out.UserNicknameQueryPort; import kr.magicbox.creator.domain.aggregate.Creator; @@ -24,7 +24,7 @@ public class ReviewCreatorCertificationService implements ReviewCreatorCertifica private final CreatorCertificationRepositoryPort certificationRepositoryPort; private final CreatorRepositoryPort creatorRepositoryPort; - private final CreatorDomainEventRepositoryPort eventRepositoryPort; + private final CreatorOutboxRepositoryPort eventRepositoryPort; private final UserNicknameQueryPort userNicknameQueryPort; @Transactional @@ -49,7 +49,7 @@ private CreatorCertificationApprovedEvent buildApprovedEvent(CreatorCertificatio return CreatorCertificationApprovedEvent.builder() .userId(certification.getUserId()) .certificationId(certification.getId()) - .reviewedAt(certification.getResult().reviewedAt()) + .occurredAt(certification.getResult().reviewedAt()) .build(); } @@ -58,7 +58,7 @@ private CreatorCertificationRejectedEvent buildRejectedEvent(CreatorCertificatio .userId(certification.getUserId()) .certificationId(certification.getId()) .reviewMessage(certification.getResult().reviewMessage()) - .reviewedAt(certification.getResult().reviewedAt()) + .occurredAt(certification.getResult().reviewedAt()) .build(); } @@ -68,7 +68,7 @@ private void createCreator(CreatorCertification certification) { } String nickname = userNicknameQueryPort.getNickname(certification.getUserId()); - Creator creator = Creator.builder() + Creator creator = Creator.createBuilder() .userId(certification.getUserId()) .nickname(Nickname.of(nickname)) .genres(certification.getRequest().genres()) From dada21fd6049dce03ecc341ecee4038317feb3a1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:06:33 +0900 Subject: [PATCH 072/116] =?UTF-8?q?fix(creator):=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20occurredAt=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Creator.c?= =?UTF-8?q?reateBuilder()=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../creator/domain/aggregate/Creator.java | 21 +++++++++++++--- .../aggregate/CreatorCertification.java | 24 +++++++++++-------- .../CreatorCertificationApprovedEvent.java | 7 +++--- .../CreatorCertificationRejectedEvent.java | 9 +++---- .../domain/event/CreatorRevokedEvent.java | 3 ++- .../domain/event/CreatorUnbannedEvent.java | 5 ++-- .../domain/vo/CreatorCertificationResult.java | 3 --- 7 files changed, 46 insertions(+), 26 deletions(-) diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/Creator.java b/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/Creator.java index 373c6d07..a529eb8f 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/Creator.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/Creator.java @@ -26,11 +26,26 @@ public class Creator { private Set genres; private CreatorStatus status; - @Builder + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public Creator(UserId userId, Nickname nickname, String tagline, + String profileImageUrl, String introduction, Set genres) { + validateFields(userId, nickname); + this.id = null; + this.userId = userId; + this.nickname = nickname; + this.tagline = tagline; + this.profileImageUrl = profileImageUrl; + this.introduction = introduction; + this.genres = genres; + this.status = CreatorStatus.ACTIVE; + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") public Creator(CreatorId id, UserId userId, Nickname nickname, String tagline, String profileImageUrl, String introduction, Set genres, CreatorStatus status) { - validateFields(userId, nickname); + if (id == null) throw new InvalidFieldException("크리에이터 ID는 필수 값입니다."); + if (status == null) throw new InvalidFieldException("상태는 필수 값입니다."); this.id = id; this.userId = userId; this.nickname = nickname; @@ -38,7 +53,7 @@ public Creator(CreatorId id, UserId userId, Nickname nickname, String tagline, this.profileImageUrl = profileImageUrl; this.introduction = introduction; this.genres = genres; - this.status = status != null ? status : CreatorStatus.ACTIVE; + this.status = status; } public Long getUserIdValue() { diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/CreatorCertification.java b/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/CreatorCertification.java index a67d95e6..002c95e6 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/CreatorCertification.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/CreatorCertification.java @@ -21,19 +21,23 @@ public class CreatorCertification { private CreatorCertificationStatus status; private CreatorCertificationResult result; - public static CreatorCertification create(UserId userId, CreatorCertificationRequest request) { - return CreatorCertification.builder() - .request(request) - .userId(userId) - .status(CreatorCertificationStatus.PENDING) - .build(); - } - - @Builder - public CreatorCertification(CreatorCertificationId id, UserId userId, CreatorCertificationRequest request, CreatorCertificationStatus status, CreatorCertificationResult result) { + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public CreatorCertification(UserId userId, CreatorCertificationRequest request) { if (request == null) { throw new InvalidFieldException("심사 신청 정보는 필수 값입니다."); } + this.id = null; + this.userId = userId; + this.request = request; + this.status = CreatorCertificationStatus.PENDING; + this.result = null; + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public CreatorCertification(CreatorCertificationId id, UserId userId, CreatorCertificationRequest request, + CreatorCertificationStatus status, CreatorCertificationResult result) { + if (id == null) throw new InvalidFieldException("인증 심사 ID는 필수 값입니다."); + if (request == null) throw new InvalidFieldException("심사 신청 정보는 필수 값입니다."); this.id = id; this.userId = userId; this.request = request; diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationApprovedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationApprovedEvent.java index 2487348e..e77d1bf3 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationApprovedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationApprovedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorCertificationId; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -8,9 +9,9 @@ @Builder public record CreatorCertificationApprovedEvent( - UserId userId, - CreatorCertificationId certificationId, - Instant reviewedAt + @JsonProperty("user_id") UserId userId, + @JsonProperty("certification_id") CreatorCertificationId certificationId, + @JsonProperty("occurred_at") Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationRejectedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationRejectedEvent.java index 45db73bf..fa87ef73 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationRejectedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationRejectedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorCertificationId; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -8,10 +9,10 @@ @Builder public record CreatorCertificationRejectedEvent( - UserId userId, - CreatorCertificationId certificationId, - String reviewMessage, - Instant reviewedAt + @JsonProperty("user_id") UserId userId, + @JsonProperty("certification_id") CreatorCertificationId certificationId, + @JsonProperty("review_message") String reviewMessage, + @JsonProperty("occurred_at") Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java index 902cd5af..113c5348 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorId; import lombok.Builder; @@ -8,7 +9,7 @@ @Builder public record CreatorRevokedEvent( CreatorId creatorId, - Instant revokedAt + Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorUnbannedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorUnbannedEvent.java index cd41f036..1f52a857 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorUnbannedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorUnbannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorId; import lombok.Builder; @@ -7,8 +8,8 @@ @Builder public record CreatorUnbannedEvent( - CreatorId creatorId, - Instant unbannedAt + @JsonProperty("creator_id") CreatorId creatorId, + @JsonProperty("occurred_at") Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/vo/CreatorCertificationResult.java b/services/creator/src/main/java/kr/magicbox/creator/domain/vo/CreatorCertificationResult.java index 2b57b49f..421849b9 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/vo/CreatorCertificationResult.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/vo/CreatorCertificationResult.java @@ -9,9 +9,6 @@ public record CreatorCertificationResult(UserId reviewerId, String reviewMessage @Builder public CreatorCertificationResult { - if (reviewerId == null) { - throw new InvalidFieldException("심사자는 필수 값입니다."); - } if (reviewMessage == null || reviewMessage.trim().isEmpty()) { throw new InvalidFieldException("심사 메시지는 필수 값입니다."); } From 7ca20c63081ee0f72e4447c731566e85e486b560 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:06:35 +0900 Subject: [PATCH 073/116] =?UTF-8?q?fix(creator):=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20occurredAt=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Creator.c?= =?UTF-8?q?reateBuilder()=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../creator/domain/aggregate/Creator.java | 21 +++++++++++++--- .../aggregate/CreatorCertification.java | 24 +++++++++++-------- .../CreatorCertificationApprovedEvent.java | 7 +++--- .../CreatorCertificationRejectedEvent.java | 9 +++---- .../domain/event/CreatorRevokedEvent.java | 3 ++- .../domain/event/CreatorUnbannedEvent.java | 5 ++-- .../domain/vo/CreatorCertificationResult.java | 3 --- 7 files changed, 46 insertions(+), 26 deletions(-) diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/Creator.java b/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/Creator.java index 373c6d07..a529eb8f 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/Creator.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/Creator.java @@ -26,11 +26,26 @@ public class Creator { private Set genres; private CreatorStatus status; - @Builder + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public Creator(UserId userId, Nickname nickname, String tagline, + String profileImageUrl, String introduction, Set genres) { + validateFields(userId, nickname); + this.id = null; + this.userId = userId; + this.nickname = nickname; + this.tagline = tagline; + this.profileImageUrl = profileImageUrl; + this.introduction = introduction; + this.genres = genres; + this.status = CreatorStatus.ACTIVE; + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") public Creator(CreatorId id, UserId userId, Nickname nickname, String tagline, String profileImageUrl, String introduction, Set genres, CreatorStatus status) { - validateFields(userId, nickname); + if (id == null) throw new InvalidFieldException("크리에이터 ID는 필수 값입니다."); + if (status == null) throw new InvalidFieldException("상태는 필수 값입니다."); this.id = id; this.userId = userId; this.nickname = nickname; @@ -38,7 +53,7 @@ public Creator(CreatorId id, UserId userId, Nickname nickname, String tagline, this.profileImageUrl = profileImageUrl; this.introduction = introduction; this.genres = genres; - this.status = status != null ? status : CreatorStatus.ACTIVE; + this.status = status; } public Long getUserIdValue() { diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/CreatorCertification.java b/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/CreatorCertification.java index a67d95e6..002c95e6 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/CreatorCertification.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/aggregate/CreatorCertification.java @@ -21,19 +21,23 @@ public class CreatorCertification { private CreatorCertificationStatus status; private CreatorCertificationResult result; - public static CreatorCertification create(UserId userId, CreatorCertificationRequest request) { - return CreatorCertification.builder() - .request(request) - .userId(userId) - .status(CreatorCertificationStatus.PENDING) - .build(); - } - - @Builder - public CreatorCertification(CreatorCertificationId id, UserId userId, CreatorCertificationRequest request, CreatorCertificationStatus status, CreatorCertificationResult result) { + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public CreatorCertification(UserId userId, CreatorCertificationRequest request) { if (request == null) { throw new InvalidFieldException("심사 신청 정보는 필수 값입니다."); } + this.id = null; + this.userId = userId; + this.request = request; + this.status = CreatorCertificationStatus.PENDING; + this.result = null; + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public CreatorCertification(CreatorCertificationId id, UserId userId, CreatorCertificationRequest request, + CreatorCertificationStatus status, CreatorCertificationResult result) { + if (id == null) throw new InvalidFieldException("인증 심사 ID는 필수 값입니다."); + if (request == null) throw new InvalidFieldException("심사 신청 정보는 필수 값입니다."); this.id = id; this.userId = userId; this.request = request; diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationApprovedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationApprovedEvent.java index 2487348e..e77d1bf3 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationApprovedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationApprovedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorCertificationId; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -8,9 +9,9 @@ @Builder public record CreatorCertificationApprovedEvent( - UserId userId, - CreatorCertificationId certificationId, - Instant reviewedAt + @JsonProperty("user_id") UserId userId, + @JsonProperty("certification_id") CreatorCertificationId certificationId, + @JsonProperty("occurred_at") Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationRejectedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationRejectedEvent.java index 45db73bf..fa87ef73 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationRejectedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorCertificationRejectedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorCertificationId; import kr.magicbox.creator.domain.vo.UserId; import lombok.Builder; @@ -8,10 +9,10 @@ @Builder public record CreatorCertificationRejectedEvent( - UserId userId, - CreatorCertificationId certificationId, - String reviewMessage, - Instant reviewedAt + @JsonProperty("user_id") UserId userId, + @JsonProperty("certification_id") CreatorCertificationId certificationId, + @JsonProperty("review_message") String reviewMessage, + @JsonProperty("occurred_at") Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java index 902cd5af..113c5348 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorRevokedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorId; import lombok.Builder; @@ -8,7 +9,7 @@ @Builder public record CreatorRevokedEvent( CreatorId creatorId, - Instant revokedAt + Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorUnbannedEvent.java b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorUnbannedEvent.java index cd41f036..1f52a857 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorUnbannedEvent.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/event/CreatorUnbannedEvent.java @@ -1,5 +1,6 @@ package kr.magicbox.creator.domain.event; +import com.fasterxml.jackson.annotation.JsonProperty; import kr.magicbox.creator.domain.vo.CreatorId; import lombok.Builder; @@ -7,8 +8,8 @@ @Builder public record CreatorUnbannedEvent( - CreatorId creatorId, - Instant unbannedAt + @JsonProperty("creator_id") CreatorId creatorId, + @JsonProperty("occurred_at") Instant occurredAt ) implements CreatorDomainEvent { @Override diff --git a/services/creator/src/main/java/kr/magicbox/creator/domain/vo/CreatorCertificationResult.java b/services/creator/src/main/java/kr/magicbox/creator/domain/vo/CreatorCertificationResult.java index 2b57b49f..421849b9 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/domain/vo/CreatorCertificationResult.java +++ b/services/creator/src/main/java/kr/magicbox/creator/domain/vo/CreatorCertificationResult.java @@ -9,9 +9,6 @@ public record CreatorCertificationResult(UserId reviewerId, String reviewMessage @Builder public CreatorCertificationResult { - if (reviewerId == null) { - throw new InvalidFieldException("심사자는 필수 값입니다."); - } if (reviewMessage == null || reviewMessage.trim().isEmpty()) { throw new InvalidFieldException("심사 메시지는 필수 값입니다."); } From 3e82824e270844a0def3d744c2ad89eb66e33215 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:11:28 +0900 Subject: [PATCH 074/116] =?UTF-8?q?fix(kafka):=20creator/subscribe=20retry?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20spring.kafka.retry.topic=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-dev.yml | 13 ++++++----- .../src/main/resources/application-prod.yml | 21 ++++++++++------- .../src/main/resources/application-dev.yml | 23 +++++++++++++++++-- .../src/main/resources/application-prod.yml | 12 +++++++--- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index f4ff366c..79a70604 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -48,9 +48,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.creator.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.creator.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.creator.adapter.in.kafka.event.UserBannedEvent retry: - interval-ms: 1000 - max-attempts: 3 - + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -59,12 +61,11 @@ spring: jpa: hibernate: ddl-auto: update - open-in-view: false + security: trusted: ips: - - 127.0.0.1 - - 0:0:0:0:0:0:0:1 + - ${TRUSTED_IP_GATEWAY} inbox: max-event-age-minutes: 5 diff --git a/services/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index 5bea406a..ad62acde 100644 --- a/services/creator/src/main/resources/application-prod.yml +++ b/services/creator/src/main/resources/application-prod.yml @@ -15,22 +15,22 @@ spring: keep-alive-timeout: 5s subscribe: address: ${SUBSCRIBE_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s review: address: ${REVIEW_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s shortform: address: ${SHORTFORM_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s release: address: ${RELEASE_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s kafka: @@ -48,8 +48,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.creator.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.creator.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.creator.adapter.in.kafka.event.UserBannedEvent retry: - interval-ms: 1000 - max-attempts: 3 + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -63,5 +66,7 @@ spring: security: trusted: ips: - - 127.0.0.1 - - 0:0:0:0:0:0:0:1 + - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index a0262e19..3d40a309 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -28,8 +28,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.subscribe.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent,creator-revoked:kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent retry: - interval-ms: 1000 - max-attempts: 3 + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -47,3 +50,19 @@ security: inbox: max-event-age-minutes: 5 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s diff --git a/services/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 87d79b19..20fb7224 100644 --- a/services/subscribe/src/main/resources/application-prod.yml +++ b/services/subscribe/src/main/resources/application-prod.yml @@ -10,7 +10,7 @@ spring: channels: creator: address: ${CREATOR_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s kafka: @@ -28,8 +28,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.subscribe.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent,creator-revoked:kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent retry: - interval-ms: 1000 - max-attempts: 3 + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -44,3 +47,6 @@ security: trusted: ips: - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 From 9d1b838358a3d9b6809d7453bf04edd16fb6d11e Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:11:30 +0900 Subject: [PATCH 075/116] =?UTF-8?q?fix(kafka):=20creator/subscribe=20retry?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20spring.kafka.retry.topic=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-dev.yml | 13 ++++++----- .../src/main/resources/application-prod.yml | 21 ++++++++++------- .../src/main/resources/application-dev.yml | 23 +++++++++++++++++-- .../src/main/resources/application-prod.yml | 12 +++++++--- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index f4ff366c..79a70604 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -48,9 +48,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.creator.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.creator.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.creator.adapter.in.kafka.event.UserBannedEvent retry: - interval-ms: 1000 - max-attempts: 3 - + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -59,12 +61,11 @@ spring: jpa: hibernate: ddl-auto: update - open-in-view: false + security: trusted: ips: - - 127.0.0.1 - - 0:0:0:0:0:0:0:1 + - ${TRUSTED_IP_GATEWAY} inbox: max-event-age-minutes: 5 diff --git a/services/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index 5bea406a..ad62acde 100644 --- a/services/creator/src/main/resources/application-prod.yml +++ b/services/creator/src/main/resources/application-prod.yml @@ -15,22 +15,22 @@ spring: keep-alive-timeout: 5s subscribe: address: ${SUBSCRIBE_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s review: address: ${REVIEW_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s shortform: address: ${SHORTFORM_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s release: address: ${RELEASE_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s kafka: @@ -48,8 +48,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.creator.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.creator.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.creator.adapter.in.kafka.event.UserBannedEvent retry: - interval-ms: 1000 - max-attempts: 3 + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -63,5 +66,7 @@ spring: security: trusted: ips: - - 127.0.0.1 - - 0:0:0:0:0:0:0:1 + - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index a0262e19..3d40a309 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -28,8 +28,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.subscribe.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent,creator-revoked:kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent retry: - interval-ms: 1000 - max-attempts: 3 + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -47,3 +50,19 @@ security: inbox: max-event-age-minutes: 5 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s diff --git a/services/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 87d79b19..20fb7224 100644 --- a/services/subscribe/src/main/resources/application-prod.yml +++ b/services/subscribe/src/main/resources/application-prod.yml @@ -10,7 +10,7 @@ spring: channels: creator: address: ${CREATOR_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s kafka: @@ -28,8 +28,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.subscribe.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent,creator-revoked:kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent retry: - interval-ms: 1000 - max-attempts: 3 + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -44,3 +47,6 @@ security: trusted: ips: - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 From dbafa8308d7a1730e4082663dbae3d68f1d4efcd Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:15:36 +0900 Subject: [PATCH 076/116] =?UTF-8?q?fix(creator):=20refactor/115=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20creator=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=A0=84=EC=B2=B4=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/in/grpc/CreatorGrpcService.java | 24 ++++++++++ .../adapter/in/kafka/KafkaConfiguration.java | 44 +++++-------------- .../in/kafka/UserEventKafkaListener.java | 3 ++ ...CreatorCertificationCommandController.java | 4 +- ...inCreatorCertificationQueryController.java | 2 +- .../in/web/AdminCreatorCommandController.java | 2 +- ...CreatorCertificationCommandController.java | 4 +- .../CreatorCertificationQueryController.java | 2 +- .../in/web/CreatorCommandController.java | 4 +- .../in/web/CreatorQueryController.java | 2 +- .../communication/grpc/GrpcConfiguration.java | 36 +++++++++++++++ .../grpc/ReleaseQueryGrpcAdapter.java | 6 ++- .../grpc/ReviewQueryGrpcAdapter.java | 4 +- .../grpc/ShortformQueryGrpcAdapter.java | 3 +- .../grpc/SubscribeGrpcAdapter.java | 7 ++- .../grpc/UserNicknameQueryGrpcAdapter.java | 4 +- .../mapper/CreatorCertificationMapper.java | 2 +- .../CreatorCertificationResultMapper.java | 3 ++ .../out/persistence/mapper/CreatorMapper.java | 2 +- .../CreatorCertificationJpaRepository.java | 9 +--- .../repository/CreatorJpaRepository.java | 8 +--- .../port/in/GetCreatorIdByUserIdUseCase.java | 8 ++++ .../service/GetCreatorIdByUserIdService.java | 26 +++++++++++ .../ApplyCreatorCertificationService.java | 5 ++- 24 files changed, 149 insertions(+), 65 deletions(-) create mode 100644 services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/GrpcConfiguration.java create mode 100644 services/creator/src/main/java/kr/magicbox/creator/application/port/in/GetCreatorIdByUserIdUseCase.java create mode 100644 services/creator/src/main/java/kr/magicbox/creator/application/service/GetCreatorIdByUserIdService.java diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/grpc/CreatorGrpcService.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/grpc/CreatorGrpcService.java index c1ed98ed..760f2688 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/grpc/CreatorGrpcService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/grpc/CreatorGrpcService.java @@ -1,11 +1,16 @@ package kr.magicbox.creator.adapter.in.grpc; +import io.grpc.Status; import io.grpc.stub.StreamObserver; import kr.magicbox.creator.application.dto.query.IsCreatorOwnedByUserQuery; +import kr.magicbox.creator.application.port.in.GetCreatorIdByUserIdUseCase; import kr.magicbox.creator.application.port.in.IsCreatorOwnedByUserUseCase; +import kr.magicbox.creator.domain.exception.CreatorNotFoundException; import kr.magicbox.creator.domain.vo.CreatorId; import kr.magicbox.creator.domain.vo.UserId; import kr.magicbox.creator.grpc.creator.CreatorServiceGrpc; +import kr.magicbox.creator.grpc.creator.GetCreatorIdByUserIdRequest; +import kr.magicbox.creator.grpc.creator.GetCreatorIdByUserIdResponse; import kr.magicbox.creator.grpc.creator.IsCreatorOwnedByUserRequest; import kr.magicbox.creator.grpc.creator.IsCreatorOwnedByUserResponse; import lombok.RequiredArgsConstructor; @@ -15,6 +20,7 @@ @RequiredArgsConstructor public class CreatorGrpcService extends CreatorServiceGrpc.CreatorServiceImplBase { private final IsCreatorOwnedByUserUseCase isCreatorOwnedByUserUseCase; + private final GetCreatorIdByUserIdUseCase getCreatorIdByUserIdUseCase; @Override public void isCreatorOwnedByUser(IsCreatorOwnedByUserRequest request, @@ -31,4 +37,22 @@ public void isCreatorOwnedByUser(IsCreatorOwnedByUserRequest request, .build()); responseObserver.onCompleted(); } + + @Override + public void getCreatorIdByUserId(GetCreatorIdByUserIdRequest request, + StreamObserver responseObserver) { + try { + CreatorId creatorId = getCreatorIdByUserIdUseCase.getCreatorIdByUserId( + UserId.of(request.getUserId()) + ); + responseObserver.onNext(GetCreatorIdByUserIdResponse.newBuilder() + .setCreatorId(creatorId.value()) + .build()); + responseObserver.onCompleted(); + } catch (CreatorNotFoundException e) { + responseObserver.onError(Status.NOT_FOUND + .withDescription("Creator not found for userId: " + request.getUserId()) + .asRuntimeException()); + } + } } \ No newline at end of file diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/KafkaConfiguration.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/KafkaConfiguration.java index 37e592bc..f0fb6d45 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/KafkaConfiguration.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/KafkaConfiguration.java @@ -1,44 +1,22 @@ package kr.magicbox.creator.adapter.in.kafka; -import kr.magicbox.creator.adapter.in.kafka.properties.KafkaRetryProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.common.TopicPartition; +import kr.magicbox.creator.adapter.in.kafka.properties.InboxProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; -import org.springframework.kafka.core.ConsumerFactory; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.kafka.listener.CommonErrorHandler; -import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; -import org.springframework.kafka.listener.DefaultErrorHandler; -import org.springframework.util.backoff.FixedBackOff; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -@Slf4j +@EnableKafkaRetryTopic @Configuration -@RequiredArgsConstructor +@EnableConfigurationProperties(InboxProperties.class) public class KafkaConfiguration { - private final KafkaRetryProperties kafkaRetryProperties; @Bean - public CommonErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { - DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate, - (ConsumerRecord failedRecord, Exception ex) -> { - String topic = failedRecord.topic() + "-dlt"; - log.error("[DLT] 메시지 처리 실패, DLT 전송합니다. topic={}, offset={}, exception={}", failedRecord.topic(), failedRecord.offset(), ex.getMessage()); - return new TopicPartition(topic, failedRecord.partition()); - }); - return new DefaultErrorHandler(recoverer, new FixedBackOff(kafkaRetryProperties.getIntervalMs(), kafkaRetryProperties.getMaxAttempts())); - } - - @Bean - public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( - ConsumerFactory consumerFactory, - CommonErrorHandler errorHandler) { - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); - factory.setCommonErrorHandler(errorHandler); - return factory; + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; } } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java index 55c324b7..fc9e2a06 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java @@ -11,6 +11,7 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Slf4j @@ -23,6 +24,7 @@ public class UserEventKafkaListener { private final CreatorInboxRepository creatorInboxRepository; @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "creator-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { log.info("[Inbox] user-withdrawn 이벤트 수신. eventId={}", consumerRecord.key()); @@ -30,6 +32,7 @@ public void handleUserWithdrawnEvent(ConsumerRecord } @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-banned", groupId = "creator-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { log.info("[Inbox] user-banned 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationCommandController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationCommandController.java index 08b679bb..ec9d94be 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationCommandController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationCommandController.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -14,8 +15,9 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/creator/certification") +@RequestMapping("/admin/creator/certification") @RequiredArgsConstructor +@Validated public class AdminCreatorCertificationCommandController { private final ReviewCreatorCertificationUseCase reviewCreatorCertificationUseCase; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationQueryController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationQueryController.java index 0d722995..a5200106 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationQueryController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationQueryController.java @@ -17,7 +17,7 @@ import java.util.List; @RestController -@RequestMapping("/api/creator/certification") +@RequestMapping("/admin/creator/certification") @RequiredArgsConstructor @Validated public class AdminCreatorCertificationQueryController { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCommandController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCommandController.java index 908235d6..8279f352 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCommandController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCommandController.java @@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/creator") +@RequestMapping("/admin/creator") @RequiredArgsConstructor public class AdminCreatorCommandController { private final BanCreatorUseCase banCreatorUseCase; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationCommandController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationCommandController.java index ea9a146a..911a455c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationCommandController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationCommandController.java @@ -10,11 +10,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/creator/certification") +@RequestMapping("/creator/certification") @RequiredArgsConstructor +@Validated public class CreatorCertificationCommandController { private final ApplyCreatorCertificationUseCase applyCreatorCertificationUseCase; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationQueryController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationQueryController.java index 0af8d981..fd49f3c6 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationQueryController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationQueryController.java @@ -19,7 +19,7 @@ import java.util.List; @RestController -@RequestMapping("/api/creator/certification") +@RequestMapping("/creator/certification") @RequiredArgsConstructor @Validated public class CreatorCertificationQueryController { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCommandController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCommandController.java index 73008ce6..ff521408 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCommandController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCommandController.java @@ -9,11 +9,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/creator") +@RequestMapping("/creator") @RequiredArgsConstructor +@Validated public class CreatorCommandController { private final UpdateCreatorProfileUseCase updateCreatorProfileUseCase; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorQueryController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorQueryController.java index 58427588..d19f3dae 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorQueryController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorQueryController.java @@ -33,7 +33,7 @@ import java.util.List; @RestController -@RequestMapping("/api/creator") +@RequestMapping("/creator") @RequiredArgsConstructor @Validated public class CreatorQueryController { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/GrpcConfiguration.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/GrpcConfiguration.java new file mode 100644 index 00000000..6b3db92f --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/GrpcConfiguration.java @@ -0,0 +1,36 @@ +package kr.magicbox.creator.adapter.out.communication.grpc; + +import io.grpc.ManagedChannel; +import kr.magicbox.creator.adapter.out.communication.ServiceHost; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.GrpcChannelFactory; + +@Configuration +public class GrpcConfiguration { + + @Bean + public ManagedChannel reviewManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); + } + + @Bean + public ManagedChannel subscribeManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); + } + + @Bean + public ManagedChannel releaseManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); + } + + @Bean + public ManagedChannel userManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.USER.getHostName()); + } + + @Bean + public ManagedChannel shortformManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.SHORTFORM.getHostName()); + } +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java index 16b69971..fdeb06c2 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java @@ -35,7 +35,8 @@ public long getReleaseCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel) + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleaseCountResponse response = stub.getReleaseCount(request); @@ -50,7 +51,8 @@ public List getReleases(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel) + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleasesByCreatorIdResponse response = stub.getReleasesByCreatorId(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index 333358e2..0181628a 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; + import java.util.concurrent.TimeUnit; @Component @@ -29,7 +30,8 @@ public ReviewRating getReviewRating(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); - ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc.newBlockingStub(channel) + ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetReviewRatingResponse response = stub.getReviewRating(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java index ddf0f805..61d133f5 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java @@ -32,7 +32,8 @@ public List getShortforms(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SHORTFORM.getHostName()); - ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc.newBlockingStub(channel) + ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetShortformsByCreatorIdResponse response = stub.getShortformsByCreatorId(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java index 8bbdcc47..1aa48ed6 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; + import java.util.concurrent.TimeUnit; @Component @@ -30,7 +31,8 @@ public long getSubscriberCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel) + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetSubscriberCountResponse response = stub.getSubscriberCount(request); @@ -46,7 +48,8 @@ public boolean isSubscribed(Long creatorId, Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel) + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); IsSubscribedResponse response = stub.isSubscribed(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java index 0d72c3ef..bd9e7cb1 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; + import java.util.concurrent.TimeUnit; @Component @@ -29,7 +30,8 @@ public String getNickname(UserId userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.USER.getHostName()); - UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel) + UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetUserNicknameResponse response = stub.getUserNickname(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationMapper.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationMapper.java index 314a1937..fb442842 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationMapper.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationMapper.java @@ -25,7 +25,7 @@ public CreatorCertification toDomain(CreatorCertificationEntity entity) { .map(creatorCertificationResultMapper::toDomain) .orElse(null); - return CreatorCertification.builder() + return CreatorCertification.reconstructBuilder() .id(CreatorCertificationId.of(entity.getId())) .userId(UserId.of(entity.getUserId())) .request(request) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationResultMapper.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationResultMapper.java index fcbf386b..d31f269e 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationResultMapper.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationResultMapper.java @@ -8,6 +8,9 @@ public class CreatorCertificationResultMapper { public CreatorCertificationResult toDomain(CreatorCertificationResultVO vo) { + if (vo == null || vo.getReviewMessage() == null) { + return null; + } return CreatorCertificationResult.builder() .reviewMessage(vo.getReviewMessage()) .reviewedAt(vo.getReviewedAt()) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorMapper.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorMapper.java index e5af061a..abbd18e3 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorMapper.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorMapper.java @@ -23,7 +23,7 @@ public CreatorEntity toEntity(Creator domain) { } public Creator toDomain(CreatorEntity entity) { - return Creator.builder() + return Creator.reconstructBuilder() .id(CreatorId.of(entity.getId())) .userId(UserId.of(entity.getUserId())) .nickname(Nickname.of(entity.getNickname())) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorCertificationJpaRepository.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorCertificationJpaRepository.java index f72a376d..aafe5b59 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorCertificationJpaRepository.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorCertificationJpaRepository.java @@ -22,13 +22,6 @@ List findAllByStatusWithCursor( @Param("size") int size ); - @Query(value = """ - SELECT EXISTS ( - SELECT 1 - FROM creator_certification c - WHERE c.user_id = :userId - AND c.status = :status - ) - """, nativeQuery = true) + @Query("SELECT COUNT(c) > 0 FROM CreatorCertificationEntity c WHERE c.userId = :userId AND c.status = :status") boolean existsByUserIdAndStatus(@Param("userId") Long userId, @Param("status") CreatorCertificationStatus status); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorJpaRepository.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorJpaRepository.java index 04c8e5ed..c4a7dd08 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorJpaRepository.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorJpaRepository.java @@ -22,12 +22,6 @@ public interface CreatorJpaRepository extends JpaRepository List searchByNickname(@Param("keyword") String keyword, @Param("cursorId") Long cursorId, Pageable limit); - @Query(value = """ - SELECT EXISTS ( - SELECT 1 - FROM creator c - WHERE c.user_id = :userId - ) - """, nativeQuery = true) + @Query("SELECT COUNT(c) > 0 FROM CreatorEntity c WHERE c.userId = :userId") boolean existsByUserId(@Param("userId") Long userId); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/port/in/GetCreatorIdByUserIdUseCase.java b/services/creator/src/main/java/kr/magicbox/creator/application/port/in/GetCreatorIdByUserIdUseCase.java new file mode 100644 index 00000000..118896d6 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/application/port/in/GetCreatorIdByUserIdUseCase.java @@ -0,0 +1,8 @@ +package kr.magicbox.creator.application.port.in; + +import kr.magicbox.creator.domain.vo.CreatorId; +import kr.magicbox.creator.domain.vo.UserId; + +public interface GetCreatorIdByUserIdUseCase { + CreatorId getCreatorIdByUserId(UserId userId); +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/GetCreatorIdByUserIdService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/GetCreatorIdByUserIdService.java new file mode 100644 index 00000000..2ce3644a --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/GetCreatorIdByUserIdService.java @@ -0,0 +1,26 @@ +package kr.magicbox.creator.application.service; + +import kr.magicbox.creator.application.port.in.GetCreatorIdByUserIdUseCase; +import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; +import kr.magicbox.creator.domain.aggregate.Creator; +import kr.magicbox.creator.domain.exception.CreatorNotFoundException; +import kr.magicbox.creator.domain.vo.CreatorId; +import kr.magicbox.creator.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GetCreatorIdByUserIdService implements GetCreatorIdByUserIdUseCase { + + private final CreatorRepositoryPort creatorRepositoryPort; + + @Transactional(readOnly = true) + @Override + public CreatorId getCreatorIdByUserId(UserId userId) { + Creator creator = creatorRepositoryPort.findByUserId(userId) + .orElseThrow(CreatorNotFoundException::new); + return creator.getId(); + } +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ApplyCreatorCertificationService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ApplyCreatorCertificationService.java index 52e2a272..6dca2bd7 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ApplyCreatorCertificationService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ApplyCreatorCertificationService.java @@ -29,7 +29,10 @@ public void applyCreatorCertification(ApplyCreatorCertificationCommand command) .portfolioUrl(command.portfolioUrl()) .build(); - CreatorCertification certification = CreatorCertification.create(command.userId(), request); + CreatorCertification certification = CreatorCertification.createBuilder() + .userId(command.userId()) + .request(request) + .build(); certificationRepositoryPort.save(certification); } From fbbf849b6d98c40e50ffd28e713422c51f1aa633 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:15:38 +0900 Subject: [PATCH 077/116] =?UTF-8?q?fix(creator):=20refactor/115=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20creator=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=A0=84=EC=B2=B4=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/in/grpc/CreatorGrpcService.java | 24 ++++++++++ .../adapter/in/kafka/KafkaConfiguration.java | 44 +++++-------------- .../in/kafka/UserEventKafkaListener.java | 3 ++ ...CreatorCertificationCommandController.java | 4 +- ...inCreatorCertificationQueryController.java | 2 +- .../in/web/AdminCreatorCommandController.java | 2 +- ...CreatorCertificationCommandController.java | 4 +- .../CreatorCertificationQueryController.java | 2 +- .../in/web/CreatorCommandController.java | 4 +- .../in/web/CreatorQueryController.java | 2 +- .../communication/grpc/GrpcConfiguration.java | 36 +++++++++++++++ .../grpc/ReleaseQueryGrpcAdapter.java | 6 ++- .../grpc/ReviewQueryGrpcAdapter.java | 4 +- .../grpc/ShortformQueryGrpcAdapter.java | 3 +- .../grpc/SubscribeGrpcAdapter.java | 7 ++- .../grpc/UserNicknameQueryGrpcAdapter.java | 4 +- .../mapper/CreatorCertificationMapper.java | 2 +- .../CreatorCertificationResultMapper.java | 3 ++ .../out/persistence/mapper/CreatorMapper.java | 2 +- .../CreatorCertificationJpaRepository.java | 9 +--- .../repository/CreatorJpaRepository.java | 8 +--- .../port/in/GetCreatorIdByUserIdUseCase.java | 8 ++++ .../service/GetCreatorIdByUserIdService.java | 26 +++++++++++ .../ApplyCreatorCertificationService.java | 5 ++- 24 files changed, 149 insertions(+), 65 deletions(-) create mode 100644 services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/GrpcConfiguration.java create mode 100644 services/creator/src/main/java/kr/magicbox/creator/application/port/in/GetCreatorIdByUserIdUseCase.java create mode 100644 services/creator/src/main/java/kr/magicbox/creator/application/service/GetCreatorIdByUserIdService.java diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/grpc/CreatorGrpcService.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/grpc/CreatorGrpcService.java index c1ed98ed..760f2688 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/grpc/CreatorGrpcService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/grpc/CreatorGrpcService.java @@ -1,11 +1,16 @@ package kr.magicbox.creator.adapter.in.grpc; +import io.grpc.Status; import io.grpc.stub.StreamObserver; import kr.magicbox.creator.application.dto.query.IsCreatorOwnedByUserQuery; +import kr.magicbox.creator.application.port.in.GetCreatorIdByUserIdUseCase; import kr.magicbox.creator.application.port.in.IsCreatorOwnedByUserUseCase; +import kr.magicbox.creator.domain.exception.CreatorNotFoundException; import kr.magicbox.creator.domain.vo.CreatorId; import kr.magicbox.creator.domain.vo.UserId; import kr.magicbox.creator.grpc.creator.CreatorServiceGrpc; +import kr.magicbox.creator.grpc.creator.GetCreatorIdByUserIdRequest; +import kr.magicbox.creator.grpc.creator.GetCreatorIdByUserIdResponse; import kr.magicbox.creator.grpc.creator.IsCreatorOwnedByUserRequest; import kr.magicbox.creator.grpc.creator.IsCreatorOwnedByUserResponse; import lombok.RequiredArgsConstructor; @@ -15,6 +20,7 @@ @RequiredArgsConstructor public class CreatorGrpcService extends CreatorServiceGrpc.CreatorServiceImplBase { private final IsCreatorOwnedByUserUseCase isCreatorOwnedByUserUseCase; + private final GetCreatorIdByUserIdUseCase getCreatorIdByUserIdUseCase; @Override public void isCreatorOwnedByUser(IsCreatorOwnedByUserRequest request, @@ -31,4 +37,22 @@ public void isCreatorOwnedByUser(IsCreatorOwnedByUserRequest request, .build()); responseObserver.onCompleted(); } + + @Override + public void getCreatorIdByUserId(GetCreatorIdByUserIdRequest request, + StreamObserver responseObserver) { + try { + CreatorId creatorId = getCreatorIdByUserIdUseCase.getCreatorIdByUserId( + UserId.of(request.getUserId()) + ); + responseObserver.onNext(GetCreatorIdByUserIdResponse.newBuilder() + .setCreatorId(creatorId.value()) + .build()); + responseObserver.onCompleted(); + } catch (CreatorNotFoundException e) { + responseObserver.onError(Status.NOT_FOUND + .withDescription("Creator not found for userId: " + request.getUserId()) + .asRuntimeException()); + } + } } \ No newline at end of file diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/KafkaConfiguration.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/KafkaConfiguration.java index 37e592bc..f0fb6d45 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/KafkaConfiguration.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/KafkaConfiguration.java @@ -1,44 +1,22 @@ package kr.magicbox.creator.adapter.in.kafka; -import kr.magicbox.creator.adapter.in.kafka.properties.KafkaRetryProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.common.TopicPartition; +import kr.magicbox.creator.adapter.in.kafka.properties.InboxProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; -import org.springframework.kafka.core.ConsumerFactory; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.kafka.listener.CommonErrorHandler; -import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; -import org.springframework.kafka.listener.DefaultErrorHandler; -import org.springframework.util.backoff.FixedBackOff; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -@Slf4j +@EnableKafkaRetryTopic @Configuration -@RequiredArgsConstructor +@EnableConfigurationProperties(InboxProperties.class) public class KafkaConfiguration { - private final KafkaRetryProperties kafkaRetryProperties; @Bean - public CommonErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { - DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate, - (ConsumerRecord failedRecord, Exception ex) -> { - String topic = failedRecord.topic() + "-dlt"; - log.error("[DLT] 메시지 처리 실패, DLT 전송합니다. topic={}, offset={}, exception={}", failedRecord.topic(), failedRecord.offset(), ex.getMessage()); - return new TopicPartition(topic, failedRecord.partition()); - }); - return new DefaultErrorHandler(recoverer, new FixedBackOff(kafkaRetryProperties.getIntervalMs(), kafkaRetryProperties.getMaxAttempts())); - } - - @Bean - public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( - ConsumerFactory consumerFactory, - CommonErrorHandler errorHandler) { - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); - factory.setCommonErrorHandler(errorHandler); - return factory; + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; } } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java index 55c324b7..fc9e2a06 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java @@ -11,6 +11,7 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Slf4j @@ -23,6 +24,7 @@ public class UserEventKafkaListener { private final CreatorInboxRepository creatorInboxRepository; @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "creator-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { log.info("[Inbox] user-withdrawn 이벤트 수신. eventId={}", consumerRecord.key()); @@ -30,6 +32,7 @@ public void handleUserWithdrawnEvent(ConsumerRecord } @Idempotent + @RetryableTopic @KafkaListener(topics = "outbox.event.user-banned", groupId = "creator-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { log.info("[Inbox] user-banned 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationCommandController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationCommandController.java index 08b679bb..ec9d94be 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationCommandController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationCommandController.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -14,8 +15,9 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/creator/certification") +@RequestMapping("/admin/creator/certification") @RequiredArgsConstructor +@Validated public class AdminCreatorCertificationCommandController { private final ReviewCreatorCertificationUseCase reviewCreatorCertificationUseCase; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationQueryController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationQueryController.java index 0d722995..a5200106 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationQueryController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCertificationQueryController.java @@ -17,7 +17,7 @@ import java.util.List; @RestController -@RequestMapping("/api/creator/certification") +@RequestMapping("/admin/creator/certification") @RequiredArgsConstructor @Validated public class AdminCreatorCertificationQueryController { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCommandController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCommandController.java index 908235d6..8279f352 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCommandController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/AdminCreatorCommandController.java @@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/creator") +@RequestMapping("/admin/creator") @RequiredArgsConstructor public class AdminCreatorCommandController { private final BanCreatorUseCase banCreatorUseCase; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationCommandController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationCommandController.java index ea9a146a..911a455c 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationCommandController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationCommandController.java @@ -10,11 +10,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/creator/certification") +@RequestMapping("/creator/certification") @RequiredArgsConstructor +@Validated public class CreatorCertificationCommandController { private final ApplyCreatorCertificationUseCase applyCreatorCertificationUseCase; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationQueryController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationQueryController.java index 0af8d981..fd49f3c6 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationQueryController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCertificationQueryController.java @@ -19,7 +19,7 @@ import java.util.List; @RestController -@RequestMapping("/api/creator/certification") +@RequestMapping("/creator/certification") @RequiredArgsConstructor @Validated public class CreatorCertificationQueryController { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCommandController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCommandController.java index 73008ce6..ff521408 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCommandController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorCommandController.java @@ -9,11 +9,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/creator") +@RequestMapping("/creator") @RequiredArgsConstructor +@Validated public class CreatorCommandController { private final UpdateCreatorProfileUseCase updateCreatorProfileUseCase; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorQueryController.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorQueryController.java index 58427588..d19f3dae 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorQueryController.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/web/CreatorQueryController.java @@ -33,7 +33,7 @@ import java.util.List; @RestController -@RequestMapping("/api/creator") +@RequestMapping("/creator") @RequiredArgsConstructor @Validated public class CreatorQueryController { diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/GrpcConfiguration.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/GrpcConfiguration.java new file mode 100644 index 00000000..6b3db92f --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/GrpcConfiguration.java @@ -0,0 +1,36 @@ +package kr.magicbox.creator.adapter.out.communication.grpc; + +import io.grpc.ManagedChannel; +import kr.magicbox.creator.adapter.out.communication.ServiceHost; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.GrpcChannelFactory; + +@Configuration +public class GrpcConfiguration { + + @Bean + public ManagedChannel reviewManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); + } + + @Bean + public ManagedChannel subscribeManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); + } + + @Bean + public ManagedChannel releaseManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); + } + + @Bean + public ManagedChannel userManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.USER.getHostName()); + } + + @Bean + public ManagedChannel shortformManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.SHORTFORM.getHostName()); + } +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java index 16b69971..fdeb06c2 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java @@ -35,7 +35,8 @@ public long getReleaseCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel) + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleaseCountResponse response = stub.getReleaseCount(request); @@ -50,7 +51,8 @@ public List getReleases(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel) + ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleasesByCreatorIdResponse response = stub.getReleasesByCreatorId(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index 333358e2..0181628a 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; + import java.util.concurrent.TimeUnit; @Component @@ -29,7 +30,8 @@ public ReviewRating getReviewRating(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); - ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc.newBlockingStub(channel) + ReviewServiceGrpc.ReviewServiceBlockingStub stub = ReviewServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetReviewRatingResponse response = stub.getReviewRating(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java index ddf0f805..61d133f5 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ShortformQueryGrpcAdapter.java @@ -32,7 +32,8 @@ public List getShortforms(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SHORTFORM.getHostName()); - ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc.newBlockingStub(channel) + ShortformServiceGrpc.ShortformServiceBlockingStub stub = ShortformServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetShortformsByCreatorIdResponse response = stub.getShortformsByCreatorId(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java index 8bbdcc47..1aa48ed6 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/SubscribeGrpcAdapter.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; + import java.util.concurrent.TimeUnit; @Component @@ -30,7 +31,8 @@ public long getSubscriberCount(Long creatorId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel) + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetSubscriberCountResponse response = stub.getSubscriberCount(request); @@ -46,7 +48,8 @@ public boolean isSubscribed(Long creatorId, Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.SUBSCRIBE.getHostName()); - SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc.newBlockingStub(channel) + SubscribeServiceGrpc.SubscribeServiceBlockingStub stub = SubscribeServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); IsSubscribedResponse response = stub.isSubscribed(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java index 0d72c3ef..bd9e7cb1 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/UserNicknameQueryGrpcAdapter.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; + import java.util.concurrent.TimeUnit; @Component @@ -29,7 +30,8 @@ public String getNickname(UserId userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.USER.getHostName()); - UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel) + UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc + .newBlockingStub(channel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetUserNicknameResponse response = stub.getUserNickname(request); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationMapper.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationMapper.java index 314a1937..fb442842 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationMapper.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationMapper.java @@ -25,7 +25,7 @@ public CreatorCertification toDomain(CreatorCertificationEntity entity) { .map(creatorCertificationResultMapper::toDomain) .orElse(null); - return CreatorCertification.builder() + return CreatorCertification.reconstructBuilder() .id(CreatorCertificationId.of(entity.getId())) .userId(UserId.of(entity.getUserId())) .request(request) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationResultMapper.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationResultMapper.java index fcbf386b..d31f269e 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationResultMapper.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorCertificationResultMapper.java @@ -8,6 +8,9 @@ public class CreatorCertificationResultMapper { public CreatorCertificationResult toDomain(CreatorCertificationResultVO vo) { + if (vo == null || vo.getReviewMessage() == null) { + return null; + } return CreatorCertificationResult.builder() .reviewMessage(vo.getReviewMessage()) .reviewedAt(vo.getReviewedAt()) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorMapper.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorMapper.java index e5af061a..abbd18e3 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorMapper.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/mapper/CreatorMapper.java @@ -23,7 +23,7 @@ public CreatorEntity toEntity(Creator domain) { } public Creator toDomain(CreatorEntity entity) { - return Creator.builder() + return Creator.reconstructBuilder() .id(CreatorId.of(entity.getId())) .userId(UserId.of(entity.getUserId())) .nickname(Nickname.of(entity.getNickname())) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorCertificationJpaRepository.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorCertificationJpaRepository.java index f72a376d..aafe5b59 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorCertificationJpaRepository.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorCertificationJpaRepository.java @@ -22,13 +22,6 @@ List findAllByStatusWithCursor( @Param("size") int size ); - @Query(value = """ - SELECT EXISTS ( - SELECT 1 - FROM creator_certification c - WHERE c.user_id = :userId - AND c.status = :status - ) - """, nativeQuery = true) + @Query("SELECT COUNT(c) > 0 FROM CreatorCertificationEntity c WHERE c.userId = :userId AND c.status = :status") boolean existsByUserIdAndStatus(@Param("userId") Long userId, @Param("status") CreatorCertificationStatus status); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorJpaRepository.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorJpaRepository.java index 04c8e5ed..c4a7dd08 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorJpaRepository.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/persistence/repository/CreatorJpaRepository.java @@ -22,12 +22,6 @@ public interface CreatorJpaRepository extends JpaRepository List searchByNickname(@Param("keyword") String keyword, @Param("cursorId") Long cursorId, Pageable limit); - @Query(value = """ - SELECT EXISTS ( - SELECT 1 - FROM creator c - WHERE c.user_id = :userId - ) - """, nativeQuery = true) + @Query("SELECT COUNT(c) > 0 FROM CreatorEntity c WHERE c.userId = :userId") boolean existsByUserId(@Param("userId") Long userId); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/port/in/GetCreatorIdByUserIdUseCase.java b/services/creator/src/main/java/kr/magicbox/creator/application/port/in/GetCreatorIdByUserIdUseCase.java new file mode 100644 index 00000000..118896d6 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/application/port/in/GetCreatorIdByUserIdUseCase.java @@ -0,0 +1,8 @@ +package kr.magicbox.creator.application.port.in; + +import kr.magicbox.creator.domain.vo.CreatorId; +import kr.magicbox.creator.domain.vo.UserId; + +public interface GetCreatorIdByUserIdUseCase { + CreatorId getCreatorIdByUserId(UserId userId); +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/GetCreatorIdByUserIdService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/GetCreatorIdByUserIdService.java new file mode 100644 index 00000000..2ce3644a --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/GetCreatorIdByUserIdService.java @@ -0,0 +1,26 @@ +package kr.magicbox.creator.application.service; + +import kr.magicbox.creator.application.port.in.GetCreatorIdByUserIdUseCase; +import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; +import kr.magicbox.creator.domain.aggregate.Creator; +import kr.magicbox.creator.domain.exception.CreatorNotFoundException; +import kr.magicbox.creator.domain.vo.CreatorId; +import kr.magicbox.creator.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GetCreatorIdByUserIdService implements GetCreatorIdByUserIdUseCase { + + private final CreatorRepositoryPort creatorRepositoryPort; + + @Transactional(readOnly = true) + @Override + public CreatorId getCreatorIdByUserId(UserId userId) { + Creator creator = creatorRepositoryPort.findByUserId(userId) + .orElseThrow(CreatorNotFoundException::new); + return creator.getId(); + } +} diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ApplyCreatorCertificationService.java b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ApplyCreatorCertificationService.java index 52e2a272..6dca2bd7 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ApplyCreatorCertificationService.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/service/certification/ApplyCreatorCertificationService.java @@ -29,7 +29,10 @@ public void applyCreatorCertification(ApplyCreatorCertificationCommand command) .portfolioUrl(command.portfolioUrl()) .build(); - CreatorCertification certification = CreatorCertification.create(command.userId(), request); + CreatorCertification certification = CreatorCertification.createBuilder() + .userId(command.userId()) + .request(request) + .build(); certificationRepositoryPort.save(certification); } From be3e263de67c4fa2175663e87a02411f1e517644 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:18:34 +0900 Subject: [PATCH 078/116] =?UTF-8?q?fix(creator):=20creator.proto=EC=97=90?= =?UTF-8?q?=20GetCreatorIdByUserId=20rpc=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/creator/src/main/proto/creator.proto | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/creator/src/main/proto/creator.proto b/services/creator/src/main/proto/creator.proto index 4d5aaaf1..d9fcfb0e 100644 --- a/services/creator/src/main/proto/creator.proto +++ b/services/creator/src/main/proto/creator.proto @@ -9,6 +9,8 @@ option java_multiple_files = true; service CreatorService { rpc IsCreatorOwnedByUser(IsCreatorOwnedByUserRequest) returns (IsCreatorOwnedByUserResponse); + rpc GetCreatorIdByUserId(GetCreatorIdByUserIdRequest) + returns (GetCreatorIdByUserIdResponse); } message IsCreatorOwnedByUserRequest { @@ -18,4 +20,12 @@ message IsCreatorOwnedByUserRequest { message IsCreatorOwnedByUserResponse { bool owned_by_user = 1; +} + +message GetCreatorIdByUserIdRequest { + int64 user_id = 1; +} + +message GetCreatorIdByUserIdResponse { + int64 creator_id = 1; } \ No newline at end of file From ae99e019383698b5a64d6790794469d44258e2ab Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:18:36 +0900 Subject: [PATCH 079/116] =?UTF-8?q?fix(creator):=20creator.proto=EC=97=90?= =?UTF-8?q?=20GetCreatorIdByUserId=20rpc=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/creator/src/main/proto/creator.proto | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/creator/src/main/proto/creator.proto b/services/creator/src/main/proto/creator.proto index 4d5aaaf1..d9fcfb0e 100644 --- a/services/creator/src/main/proto/creator.proto +++ b/services/creator/src/main/proto/creator.proto @@ -9,6 +9,8 @@ option java_multiple_files = true; service CreatorService { rpc IsCreatorOwnedByUser(IsCreatorOwnedByUserRequest) returns (IsCreatorOwnedByUserResponse); + rpc GetCreatorIdByUserId(GetCreatorIdByUserIdRequest) + returns (GetCreatorIdByUserIdResponse); } message IsCreatorOwnedByUserRequest { @@ -18,4 +20,12 @@ message IsCreatorOwnedByUserRequest { message IsCreatorOwnedByUserResponse { bool owned_by_user = 1; +} + +message GetCreatorIdByUserIdRequest { + int64 user_id = 1; +} + +message GetCreatorIdByUserIdResponse { + int64 creator_id = 1; } \ No newline at end of file From 1fea8c93df115567efd057b55237f6dc31ee4b3b Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 19:53:50 +0900 Subject: [PATCH 080/116] =?UTF-8?q?fix(docker):=20Dockerfile=20appuser=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=8C=A8=ED=84=B4=20=EB=B0=8F=20JAR=5FFIL?= =?UTF-8?q?E=20=EA=B2=BD=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/auth/Dockerfile | 5 ++++- services/creator/Dockerfile | 5 ++++- services/general-goods/Dockerfile | 5 ++++- services/order/Dockerfile | 5 ++++- services/search/Dockerfile | 5 ++++- services/shopping-cart/Dockerfile | 5 ++++- services/subscribe/Dockerfile | 5 ++++- services/user/Dockerfile | 5 ++++- services/waiting/Dockerfile | 5 ++++- 9 files changed, 36 insertions(+), 9 deletions(-) diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/creator/Dockerfile b/services/creator/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/creator/Dockerfile +++ b/services/creator/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/general-goods/Dockerfile b/services/general-goods/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/general-goods/Dockerfile +++ b/services/general-goods/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/order/Dockerfile b/services/order/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/order/Dockerfile +++ b/services/order/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/search/Dockerfile b/services/search/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/search/Dockerfile +++ b/services/search/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/shopping-cart/Dockerfile b/services/shopping-cart/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/shopping-cart/Dockerfile +++ b/services/shopping-cart/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/subscribe/Dockerfile b/services/subscribe/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/subscribe/Dockerfile +++ b/services/subscribe/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/user/Dockerfile b/services/user/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/user/Dockerfile +++ b/services/user/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/waiting/Dockerfile b/services/waiting/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/waiting/Dockerfile +++ b/services/waiting/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] From 19f12871355326bb26191f3eef8c0413441fef33 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 19:54:41 +0900 Subject: [PATCH 081/116] =?UTF-8?q?fix(docker):=20Dockerfile=20appuser=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=8C=A8=ED=84=B4=20=EB=B0=8F=20JAR=5FFIL?= =?UTF-8?q?E=20=EA=B2=BD=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/auth/Dockerfile | 5 ++++- services/creator/Dockerfile | 5 ++++- services/general-goods/Dockerfile | 5 ++++- services/release/Dockerfile | 5 ++++- services/search/Dockerfile | 5 ++++- services/shopping-cart/Dockerfile | 5 ++++- services/subscribe/Dockerfile | 5 ++++- services/user/Dockerfile | 5 ++++- services/waiting/Dockerfile | 5 ++++- 9 files changed, 36 insertions(+), 9 deletions(-) diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/creator/Dockerfile b/services/creator/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/creator/Dockerfile +++ b/services/creator/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/general-goods/Dockerfile b/services/general-goods/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/general-goods/Dockerfile +++ b/services/general-goods/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/release/Dockerfile b/services/release/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/release/Dockerfile +++ b/services/release/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/search/Dockerfile b/services/search/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/search/Dockerfile +++ b/services/search/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/shopping-cart/Dockerfile b/services/shopping-cart/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/shopping-cart/Dockerfile +++ b/services/shopping-cart/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/subscribe/Dockerfile b/services/subscribe/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/subscribe/Dockerfile +++ b/services/subscribe/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/user/Dockerfile b/services/user/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/user/Dockerfile +++ b/services/user/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/waiting/Dockerfile b/services/waiting/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/waiting/Dockerfile +++ b/services/waiting/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] From de24d5e996156159ec344789664e98380e404f24 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:02:37 +0900 Subject: [PATCH 082/116] =?UTF-8?q?fix(docker):=20Dockerfile=20appuser=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=8C=A8=ED=84=B4=20=EB=B0=8F=20JAR=5FFIL?= =?UTF-8?q?E=20=EA=B2=BD=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/auth/Dockerfile | 5 ++++- services/creator/Dockerfile | 5 ++++- services/general-goods/Dockerfile | 5 ++++- services/order/Dockerfile | 5 ++++- services/release/Dockerfile | 5 ++++- services/search/Dockerfile | 5 ++++- services/subscribe/Dockerfile | 5 ++++- services/user/Dockerfile | 5 ++++- services/waiting/Dockerfile | 5 ++++- 9 files changed, 36 insertions(+), 9 deletions(-) diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/creator/Dockerfile b/services/creator/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/creator/Dockerfile +++ b/services/creator/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/general-goods/Dockerfile b/services/general-goods/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/general-goods/Dockerfile +++ b/services/general-goods/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/order/Dockerfile b/services/order/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/order/Dockerfile +++ b/services/order/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/release/Dockerfile b/services/release/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/release/Dockerfile +++ b/services/release/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/search/Dockerfile b/services/search/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/search/Dockerfile +++ b/services/search/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/subscribe/Dockerfile b/services/subscribe/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/subscribe/Dockerfile +++ b/services/subscribe/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/user/Dockerfile b/services/user/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/user/Dockerfile +++ b/services/user/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/waiting/Dockerfile b/services/waiting/Dockerfile index a8e8e580..c7049a93 100644 --- a/services/waiting/Dockerfile +++ b/services/waiting/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar +RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY ${JAR_FILE} app.jar +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] From 525a95a6da1d6316372366471db0cd0fee29e2e2 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:09:45 +0900 Subject: [PATCH 083/116] =?UTF-8?q?fix(inbox):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=86=92=20DEAD=5FLETTERED=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IdempotentAspect#isTooOld 시 return null 대신 DEAD_LETTERED 상태로 저장하여 추적 가능하게 변경 - prod max-event-age-minutes: 5 → 60 (재시도·lag 여유 확보) Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-prod.yml | 5 +- .../src/main/resources/application-prod.yml | 66 +++++++++++++++++++ .../src/main/resources/application-prod.yml | 5 +- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/services/auth/src/main/resources/application-prod.yml b/services/auth/src/main/resources/application-prod.yml index fc62bd8d..80b78ee2 100644 --- a/services/auth/src/main/resources/application-prod.yml +++ b/services/auth/src/main/resources/application-prod.yml @@ -97,13 +97,16 @@ code: ttl-seconds: ${OAUTH2_CODE_TTL_SECONDS} frontend: - uri: ${FRONTEND_URI} + uri: ${FRONTEND_URL} security: trusted: ips: - ${TRUSTED_IP_GATEWAY} +inbox: + max-event-age-minutes: 5 + logging: level: org.springframework.web: WARN diff --git a/services/general-goods/src/main/resources/application-prod.yml b/services/general-goods/src/main/resources/application-prod.yml index e69de29b..4c7f074b 100644 --- a/services/general-goods/src/main/resources/application-prod.yml +++ b/services/general-goods/src/main/resources/application-prod.yml @@ -0,0 +1,66 @@ +spring: + application: + name: general-goods-prod + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + creator: + address: ${CREATOR_SERVICE_URL} + negotiation-type: plaintext + keep-alive-time: 30s + keep-alive-timeout: 5s + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: true + consumer: + group-id: general-goods-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.generalgoods.adapter.in.kafka.event" + spring.json.type.mapping: creator-revoked:kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent + retry: + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s diff --git a/services/user/src/main/resources/application-prod.yml b/services/user/src/main/resources/application-prod.yml index 800287ae..6bf5811f 100644 --- a/services/user/src/main/resources/application-prod.yml +++ b/services/user/src/main/resources/application-prod.yml @@ -8,7 +8,7 @@ spring: channels: review: address: ${REVIEW_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s @@ -69,3 +69,6 @@ resilience4j: user: default-profile-image-url: ${USER_PROFILE_DEFAULT_IMAGE_URL} + +inbox: + max-event-age-minutes: 5 From 24d24021d19de71691010ef98a9a3915b9334ead Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:09:46 +0900 Subject: [PATCH 084/116] =?UTF-8?q?fix(inbox):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=86=92=20DEAD=5FLETTERED=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IdempotentAspect#isTooOld 시 return null 대신 DEAD_LETTERED 상태로 저장하여 추적 가능하게 변경 - prod max-event-age-minutes: 5 → 60 (재시도·lag 여유 확보) Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-prod.yml | 5 +- .../src/main/resources/application-prod.yml | 66 +++++++++++++++++++ .../src/main/resources/application-prod.yml | 5 +- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/services/auth/src/main/resources/application-prod.yml b/services/auth/src/main/resources/application-prod.yml index fc62bd8d..80b78ee2 100644 --- a/services/auth/src/main/resources/application-prod.yml +++ b/services/auth/src/main/resources/application-prod.yml @@ -97,13 +97,16 @@ code: ttl-seconds: ${OAUTH2_CODE_TTL_SECONDS} frontend: - uri: ${FRONTEND_URI} + uri: ${FRONTEND_URL} security: trusted: ips: - ${TRUSTED_IP_GATEWAY} +inbox: + max-event-age-minutes: 5 + logging: level: org.springframework.web: WARN diff --git a/services/general-goods/src/main/resources/application-prod.yml b/services/general-goods/src/main/resources/application-prod.yml index e69de29b..4c7f074b 100644 --- a/services/general-goods/src/main/resources/application-prod.yml +++ b/services/general-goods/src/main/resources/application-prod.yml @@ -0,0 +1,66 @@ +spring: + application: + name: general-goods-prod + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + creator: + address: ${CREATOR_SERVICE_URL} + negotiation-type: plaintext + keep-alive-time: 30s + keep-alive-timeout: 5s + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: true + consumer: + group-id: general-goods-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.generalgoods.adapter.in.kafka.event" + spring.json.type.mapping: creator-revoked:kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent + retry: + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +inbox: + max-event-age-minutes: 5 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s diff --git a/services/user/src/main/resources/application-prod.yml b/services/user/src/main/resources/application-prod.yml index 800287ae..6bf5811f 100644 --- a/services/user/src/main/resources/application-prod.yml +++ b/services/user/src/main/resources/application-prod.yml @@ -8,7 +8,7 @@ spring: channels: review: address: ${REVIEW_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s @@ -69,3 +69,6 @@ resilience4j: user: default-profile-image-url: ${USER_PROFILE_DEFAULT_IMAGE_URL} + +inbox: + max-event-age-minutes: 5 From 4e03e9760b9493c7898c403dd765a917f9429706 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:09:52 +0900 Subject: [PATCH 085/116] =?UTF-8?q?fix(inbox):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=86=92=20DEAD=5FLETTERED=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IdempotentAspect#isTooOld 시 return null 대신 DEAD_LETTERED 상태로 저장하여 추적 가능하게 변경 - prod max-event-age-minutes: 5 → 60 (재시도·lag 여유 확보) Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-prod.yml | 8 +-- .../src/main/resources/application-prod.yml | 18 +++--- .../src/main/resources/application-prod.yml | 62 +++++++++++++++++++ .../src/main/resources/application-prod.yml | 9 ++- .../src/main/resources/application-prod.yml | 2 +- 5 files changed, 83 insertions(+), 16 deletions(-) diff --git a/services/auth/src/main/resources/application-prod.yml b/services/auth/src/main/resources/application-prod.yml index a0f2357a..80b78ee2 100644 --- a/services/auth/src/main/resources/application-prod.yml +++ b/services/auth/src/main/resources/application-prod.yml @@ -97,18 +97,18 @@ code: ttl-seconds: ${OAUTH2_CODE_TTL_SECONDS} frontend: - uri: ${FRONTEND_URI} + uri: ${FRONTEND_URL} security: trusted: ips: - ${TRUSTED_IP_GATEWAY} +inbox: + max-event-age-minutes: 5 + logging: level: org.springframework.web: WARN org.springframework.data.redis: WARN root: INFO - -inbox: - max-event-age-minutes: 5 diff --git a/services/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index 37fdecf5..ad62acde 100644 --- a/services/creator/src/main/resources/application-prod.yml +++ b/services/creator/src/main/resources/application-prod.yml @@ -15,22 +15,22 @@ spring: keep-alive-timeout: 5s subscribe: address: ${SUBSCRIBE_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s review: address: ${REVIEW_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s shortform: address: ${SHORTFORM_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s release: address: ${RELEASE_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s kafka: @@ -48,8 +48,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.creator.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.creator.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.creator.adapter.in.kafka.event.UserBannedEvent retry: - interval-ms: 1000 - max-attempts: 3 + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -63,8 +66,7 @@ spring: security: trusted: ips: - - 127.0.0.1 - - 0:0:0:0:0:0:0:1 + - ${TRUSTED_IP_GATEWAY} inbox: max-event-age-minutes: 5 diff --git a/services/general-goods/src/main/resources/application-prod.yml b/services/general-goods/src/main/resources/application-prod.yml index 1a23b19b..4c7f074b 100644 --- a/services/general-goods/src/main/resources/application-prod.yml +++ b/services/general-goods/src/main/resources/application-prod.yml @@ -1,4 +1,66 @@ +spring: + application: + name: general-goods-prod + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + creator: + address: ${CREATOR_SERVICE_URL} + negotiation-type: plaintext + keep-alive-time: 30s + keep-alive-timeout: 5s + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: true + consumer: + group-id: general-goods-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.generalgoods.adapter.in.kafka.event" + spring.json.type.mapping: creator-revoked:kr.magicbox.generalgoods.adapter.in.kafka.event.CreatorRevokedEvent + retry: + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} inbox: max-event-age-minutes: 5 + +resilience4j: + circuitbreaker: + instances: + creatorService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + minimum-number-of-calls: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + creatorService: + timeout-duration: 2s diff --git a/services/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 5f6b3529..20fb7224 100644 --- a/services/subscribe/src/main/resources/application-prod.yml +++ b/services/subscribe/src/main/resources/application-prod.yml @@ -10,7 +10,7 @@ spring: channels: creator: address: ${CREATOR_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s kafka: @@ -28,8 +28,11 @@ spring: spring.json.trusted.packages: "kr.magicbox.subscribe.adapter.in.kafka.event" spring.json.type.mapping: user-withdrawn:kr.magicbox.subscribe.adapter.in.kafka.event.UserWithdrawnEvent,user-banned:kr.magicbox.subscribe.adapter.in.kafka.event.UserBannedEvent,creator-revoked:kr.magicbox.subscribe.adapter.in.kafka.event.CreatorRevokedEvent retry: - interval-ms: 1000 - max-attempts: 3 + topic: + backoff: + multiplier: 2 + delay: 1s + attempts: 5 datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} diff --git a/services/user/src/main/resources/application-prod.yml b/services/user/src/main/resources/application-prod.yml index aded7ee2..6bf5811f 100644 --- a/services/user/src/main/resources/application-prod.yml +++ b/services/user/src/main/resources/application-prod.yml @@ -8,7 +8,7 @@ spring: channels: review: address: ${REVIEW_SERVICE_URL} - negotiation-type: tls + negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s From 95d0e351c35af7dfdde3e09fe8300a4c434b7e29 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:12:20 +0900 Subject: [PATCH 086/116] =?UTF-8?q?fix(inbox):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=86=92=20DEAD=5FLETTERED=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../auth/adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../user/adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 619a94a0..07fe8fc7 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + authInboxRepository.save(AuthInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(AuthInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index 9ce8a731..f87a2204 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + creatorInboxRepository.save(CreatorInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(CreatorInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java index c44c8100..2742a8e0 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + generalGoodsInboxRepository.save(GeneralGoodsInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(GeneralGoodsInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java index 47dd7aa7..756bb451 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + subscribeInboxRepository.save(SubscribeInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(SubscribeInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index a90c15c9..8cd8c481 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + userInboxRepository.save(UserInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(UserInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } From c15bac0d9f917575f2b49c9a157cdab26eb28495 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:14:15 +0900 Subject: [PATCH 087/116] =?UTF-8?q?fix(inbox):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=86=92=20DEAD=5FLETTERED=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../auth/adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../user/adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 619a94a0..07fe8fc7 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + authInboxRepository.save(AuthInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(AuthInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index 9ce8a731..f87a2204 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + creatorInboxRepository.save(CreatorInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(CreatorInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java index c44c8100..2742a8e0 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + generalGoodsInboxRepository.save(GeneralGoodsInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(GeneralGoodsInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java index 47dd7aa7..756bb451 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + subscribeInboxRepository.save(SubscribeInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(SubscribeInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index a90c15c9..8cd8c481 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + userInboxRepository.save(UserInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(UserInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } From 36d6dd1b565e39f78334833eac1e3bcf458a8176 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:14:21 +0900 Subject: [PATCH 088/116] =?UTF-8?q?fix(inbox):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=86=92=20DEAD=5FLETTERED=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../auth/adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../user/adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 619a94a0..07fe8fc7 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + authInboxRepository.save(AuthInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(AuthInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index 9ce8a731..f87a2204 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + creatorInboxRepository.save(CreatorInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(CreatorInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java index c44c8100..2742a8e0 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + generalGoodsInboxRepository.save(GeneralGoodsInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(GeneralGoodsInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java index 47dd7aa7..756bb451 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + subscribeInboxRepository.save(SubscribeInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(SubscribeInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index a90c15c9..8cd8c481 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + userInboxRepository.save(UserInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(UserInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } From 144aa5873ab11716bba7dbd894dde4446a45dff0 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:23:48 +0900 Subject: [PATCH 089/116] =?UTF-8?q?fix(inbox):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=86=92=20DEAD=5FLETTERED=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../src/main/resources/application-prod.yml | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java index 0c927a2c..a7e4e207 100644 --- a/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/orchestrator/src/main/java/kr/magicbox/orchestrator/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + orchestratorInboxRepository.save(OrchestratorInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(OrchestratorInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/orchestrator/src/main/resources/application-prod.yml b/services/orchestrator/src/main/resources/application-prod.yml index b5c65cbc..f130cca4 100644 --- a/services/orchestrator/src/main/resources/application-prod.yml +++ b/services/orchestrator/src/main/resources/application-prod.yml @@ -34,7 +34,7 @@ spring: open-in-view: false inbox: - max-event-age-minutes: 5 + max-event-age-minutes: 60 security: trusted: From ef6480a53098dc2f3b322d230b95d327d41987e6 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:24:21 +0900 Subject: [PATCH 090/116] =?UTF-8?q?fix(inbox):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=86=92=20DEAD=5FLETTERED=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../order/adapter/in/kafka/aop/IdempotentAspect.java | 12 +++++++++++- .../order/src/main/resources/application-prod.yml | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java index 59a3f276..8461e3c5 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java @@ -36,7 +36,17 @@ public Object around(ProceedingJoinPoint pjp) { Instant occurredAt = event.occurredAt(); if (isTooOld(occurredAt)) { - log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt); + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + orderInboxJpaRepository.save(OrderInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(OrderInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); return null; } diff --git a/services/order/src/main/resources/application-prod.yml b/services/order/src/main/resources/application-prod.yml index 12e7df88..d6ca9fb9 100644 --- a/services/order/src/main/resources/application-prod.yml +++ b/services/order/src/main/resources/application-prod.yml @@ -48,4 +48,4 @@ security: - ${TRUSTED_IP_GATEWAY} inbox: - max-event-age-minutes: 5 + max-event-age-minutes: 60 From 36b339ae541ea31ad1ee5058bf0ed728cd3be309 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:31:54 +0900 Subject: [PATCH 091/116] =?UTF-8?q?feat(order):=20Resilience4j=20CircuitBr?= =?UTF-8?q?eaker/TimeLimiter=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?releaseService,=20waitingService)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application-dev/prod/local.yml 에 releaseService, waitingService 인스턴스 설정 명시 - COUNT_BASED slidingWindowSize=20, failureRateThreshold=50%, waitDurationInOpenState=10s - TimeLimiter timeoutDuration=2s (gRPC withDeadlineAfter와 동일 기준) Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-dev.yml | 22 +++++++++++++++++++ .../src/main/resources/application-local.yml | 22 +++++++++++++++++++ .../src/main/resources/application-prod.yml | 22 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/services/order/src/main/resources/application-dev.yml b/services/order/src/main/resources/application-dev.yml index 09f8094b..80bd1906 100644 --- a/services/order/src/main/resources/application-dev.yml +++ b/services/order/src/main/resources/application-dev.yml @@ -47,5 +47,27 @@ security: ips: - ${TRUSTED_IP_GATEWAY} +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + inbox: max-event-age-minutes: 5 diff --git a/services/order/src/main/resources/application-local.yml b/services/order/src/main/resources/application-local.yml index 5bd5137b..f532420b 100644 --- a/services/order/src/main/resources/application-local.yml +++ b/services/order/src/main/resources/application-local.yml @@ -55,5 +55,27 @@ security: - 127.0.0.1 - 0:0:0:0:0:0:0:1 +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + inbox: max-event-age-minutes: 5 diff --git a/services/order/src/main/resources/application-prod.yml b/services/order/src/main/resources/application-prod.yml index d6ca9fb9..de8d3695 100644 --- a/services/order/src/main/resources/application-prod.yml +++ b/services/order/src/main/resources/application-prod.yml @@ -47,5 +47,27 @@ security: ips: - ${TRUSTED_IP_GATEWAY} +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + inbox: max-event-age-minutes: 60 From de9c5f89aa6880188f84eb81a3e0b87bfdcdfea6 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 21:47:35 +0900 Subject: [PATCH 092/116] =?UTF-8?q?feat(order):=20Resilience4j=20CircuitBr?= =?UTF-8?q?eaker/TimeLimiter=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?releaseService,=20waitingService)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application-dev/prod/local.yml 에 releaseService, waitingService 인스턴스 설정 명시 - COUNT_BASED slidingWindowSize=20, failureRateThreshold=50%, waitDurationInOpenState=10s - TimeLimiter timeoutDuration=2s (gRPC withDeadlineAfter와 동일 기준) Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-dev.yml | 73 ++++++++++++++++++ .../src/main/resources/application-local.yml | 74 ++++++++++++++++++- .../src/main/resources/application-prod.yml | 73 ++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 services/order/src/main/resources/application-dev.yml create mode 100644 services/order/src/main/resources/application-prod.yml diff --git a/services/order/src/main/resources/application-dev.yml b/services/order/src/main/resources/application-dev.yml new file mode 100644 index 00000000..80bd1906 --- /dev/null +++ b/services/order/src/main/resources/application-dev.yml @@ -0,0 +1,73 @@ +spring: + application: + name: order-dev + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + +inbox: + max-event-age-minutes: 5 diff --git a/services/order/src/main/resources/application-local.yml b/services/order/src/main/resources/application-local.yml index 04ab09ba..f532420b 100644 --- a/services/order/src/main/resources/application-local.yml +++ b/services/order/src/main/resources/application-local.yml @@ -1,9 +1,81 @@ spring: application: name: order-local + jackson: + property-naming-strategy: SNAKE_CASE config: import: - file:services/order/env/local.env[.properties] + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext server: - port: ${SERVER_PORT} \ No newline at end of file + port: ${SERVER_PORT} + +security: + trusted: + ips: + - 127.0.0.1 + - 0:0:0:0:0:0:0:1 + +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + +inbox: + max-event-age-minutes: 5 diff --git a/services/order/src/main/resources/application-prod.yml b/services/order/src/main/resources/application-prod.yml new file mode 100644 index 00000000..de8d3695 --- /dev/null +++ b/services/order/src/main/resources/application-prod.yml @@ -0,0 +1,73 @@ +spring: + application: + name: order-prod + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: true + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 5 + backoff: + delay: 1s + max-delay: 10s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + +inbox: + max-event-age-minutes: 60 From 08cbd46eaa97c8d6a1df3bf71adc99d81c8fc3bb Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 21:48:07 +0900 Subject: [PATCH 093/116] =?UTF-8?q?feat(order):=20Resilience4j=20CircuitBr?= =?UTF-8?q?eaker/TimeLimiter=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?releaseService,=20waitingService)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application-dev/prod/local.yml 에 releaseService, waitingService 인스턴스 설정 명시 - COUNT_BASED slidingWindowSize=20, failureRateThreshold=50%, waitDurationInOpenState=10s - TimeLimiter timeoutDuration=2s (gRPC withDeadlineAfter와 동일 기준) Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/application-dev.yml | 73 ++++++++++++++++++ .../src/main/resources/application-local.yml | 74 ++++++++++++++++++- .../src/main/resources/application-prod.yml | 73 ++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 services/order/src/main/resources/application-dev.yml create mode 100644 services/order/src/main/resources/application-prod.yml diff --git a/services/order/src/main/resources/application-dev.yml b/services/order/src/main/resources/application-dev.yml new file mode 100644 index 00000000..80bd1906 --- /dev/null +++ b/services/order/src/main/resources/application-dev.yml @@ -0,0 +1,73 @@ +spring: + application: + name: order-dev + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + +inbox: + max-event-age-minutes: 5 diff --git a/services/order/src/main/resources/application-local.yml b/services/order/src/main/resources/application-local.yml index 04ab09ba..f532420b 100644 --- a/services/order/src/main/resources/application-local.yml +++ b/services/order/src/main/resources/application-local.yml @@ -1,9 +1,81 @@ spring: application: name: order-local + jackson: + property-naming-strategy: SNAKE_CASE config: import: - file:services/order/env/local.env[.properties] + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext server: - port: ${SERVER_PORT} \ No newline at end of file + port: ${SERVER_PORT} + +security: + trusted: + ips: + - 127.0.0.1 + - 0:0:0:0:0:0:0:1 + +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + +inbox: + max-event-age-minutes: 5 diff --git a/services/order/src/main/resources/application-prod.yml b/services/order/src/main/resources/application-prod.yml new file mode 100644 index 00000000..de8d3695 --- /dev/null +++ b/services/order/src/main/resources/application-prod.yml @@ -0,0 +1,73 @@ +spring: + application: + name: order-prod + jackson: + property-naming-strategy: SNAKE_CASE + grpc: + client: + channels: + waiting-service: + address: ${WAITING_SERVICE_URL} + negotiation-type: plaintext + release-service: + address: ${RELEASE_SERVICE_URL} + negotiation-type: plaintext + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: true + consumer: + group-id: order-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.order.adapter.in.kafka.event" + spring.json.type.mapping: order-prepare:kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto,delivery-started:kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent,delivery-completed:kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent,payment-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent,payment-cancel-succeeded:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent,payment-cancel-failed:kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent,stock-reserve-failed:kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent + retry: + topic: + attempts: 5 + backoff: + delay: 1s + max-delay: 10s + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + +security: + trusted: + ips: + - ${TRUSTED_IP_GATEWAY} + +resilience4j: + circuitbreaker: + instances: + releaseService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + waitingService: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + minimumNumberOfCalls: 10 + timelimiter: + instances: + releaseService: + timeoutDuration: 2s + waitingService: + timeoutDuration: 2s + +inbox: + max-event-age-minutes: 60 From 26bc10ad3986dc8b6829d45041a42a7a242c4144 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 22:01:08 +0900 Subject: [PATCH 094/116] =?UTF-8?q?refactor(order):=20@Transactional=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EB=82=B4=20gRPC=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토큰 검증(waiting gRPC)을 @Transactional 이전에 호출하도록 분리 - increaseSoldQuantity gRPC 제거 → ReleaseSoldQuantityIncreaseEvent Outbox 비동기 발행으로 교체 - ReleaseGrpcAdapter, ReleaseIncreaseSoldQuantityPort, ReleaseServiceUnavailableException 삭제 - release-service gRPC 채널 설정 및 releaseService CircuitBreaker/TimeLimiter 설정 제거 Co-Authored-By: Claude Sonnet 4.6 --- .../out/communication/ServiceHost.java | 3 +- .../communication/grpc/GrpcConfiguration.java | 4 -- .../grpc/ReleaseGrpcAdapter.java | 38 ------------------- .../ReleaseServiceUnavailableException.java | 12 ------ .../out/ReleaseIncreaseSoldQuantityPort.java | 5 --- .../service/CreateReleaseOrderService.java | 12 +++--- .../domain/event/OrderDomainEventType.java | 3 +- .../ReleaseSoldQuantityIncreaseEvent.java | 32 ++++++++++++++++ .../src/main/resources/application-dev.yml | 11 ------ .../src/main/resources/application-local.yml | 11 ------ .../src/main/resources/application-prod.yml | 11 ------ 11 files changed, 42 insertions(+), 100 deletions(-) delete mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java delete mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/ReleaseServiceUnavailableException.java delete mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/out/ReleaseIncreaseSoldQuantityPort.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/ReleaseSoldQuantityIncreaseEvent.java diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java index 55b9a606..5c424ce6 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java @@ -6,8 +6,7 @@ @Getter @RequiredArgsConstructor public enum ServiceHost { - WAITING("waiting-service"), - RELEASE("release-service"); + WAITING("waiting-service"); private final String hostName; } diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java index 73f70241..e9009385 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java @@ -14,8 +14,4 @@ public ManagedChannel waitingManagedChannel(GrpcChannelFactory grpcChannelFactor return grpcChannelFactory.createChannel(ServiceHost.WAITING.getHostName()); } - @Bean - public ManagedChannel releaseManagedChannel(GrpcChannelFactory grpcChannelFactory) { - return grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); - } } diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java deleted file mode 100644 index 3c7ac6f6..00000000 --- a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/ReleaseGrpcAdapter.java +++ /dev/null @@ -1,38 +0,0 @@ -package kr.magicbox.order.adapter.out.communication.grpc; - -import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; -import io.grpc.ManagedChannel; -import kr.magicbox.order.adapter.out.communication.grpc.exception.ReleaseServiceUnavailableException; -import kr.magicbox.order.application.port.out.ReleaseIncreaseSoldQuantityPort; -import kr.magicbox.order.grpc.release.IncreaseSoldQuantityRequest; -import kr.magicbox.order.grpc.release.ReleaseServiceGrpc; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ReleaseGrpcAdapter implements ReleaseIncreaseSoldQuantityPort { - - private final ManagedChannel releaseManagedChannel; - - @Override - @CircuitBreaker(name = "releaseService", fallbackMethod = "increaseSoldQuantityFallback") - public void increaseSoldQuantity(Long releaseId) { - IncreaseSoldQuantityRequest request = IncreaseSoldQuantityRequest.newBuilder() - .setReleaseId(releaseId) - .build(); - - ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(releaseManagedChannel) - .withDeadlineAfter(2, TimeUnit.SECONDS); - stub.increaseSoldQuantity(request); - } - - @SuppressWarnings("unused") - private void increaseSoldQuantityFallback(Long releaseId, Throwable throwable) { - log.warn("릴리즈 서비스 연결 실패"); - throw new ReleaseServiceUnavailableException(throwable); - } -} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/ReleaseServiceUnavailableException.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/ReleaseServiceUnavailableException.java deleted file mode 100644 index 1c5a41b9..00000000 --- a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/ReleaseServiceUnavailableException.java +++ /dev/null @@ -1,12 +0,0 @@ -package kr.magicbox.order.adapter.out.communication.grpc.exception; - -import kr.magicbox.order.global.exception.SystemError; -import org.springframework.http.HttpStatus; - -@SuppressWarnings("java:S110") -public class ReleaseServiceUnavailableException extends SystemError { - - public ReleaseServiceUnavailableException(Throwable cause) { - super("릴리즈 서비스에 연결할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE, cause); - } -} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/ReleaseIncreaseSoldQuantityPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/ReleaseIncreaseSoldQuantityPort.java deleted file mode 100644 index 0ca2fcb6..00000000 --- a/services/order/src/main/java/kr/magicbox/order/application/port/out/ReleaseIncreaseSoldQuantityPort.java +++ /dev/null @@ -1,5 +0,0 @@ -package kr.magicbox.order.application.port.out; - -public interface ReleaseIncreaseSoldQuantityPort { - void increaseSoldQuantity(Long releaseId); -} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java index 13ccfa43..9042422b 100644 --- a/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java @@ -5,10 +5,10 @@ import kr.magicbox.order.application.port.out.OrderOutboxPort; import kr.magicbox.order.application.port.out.OrderRepositoryPort; import kr.magicbox.order.application.port.out.PurchaseTokenValidationPort; -import kr.magicbox.order.application.port.out.ReleaseIncreaseSoldQuantityPort; import kr.magicbox.order.domain.aggregate.Order; import kr.magicbox.order.domain.aggregate.OrderLine; import kr.magicbox.order.domain.event.OrderPrepareEvent; +import kr.magicbox.order.domain.event.ReleaseSoldQuantityIncreaseEvent; import kr.magicbox.order.domain.exception.InvalidPurchaseTokenException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,11 +21,9 @@ public class CreateReleaseOrderService implements CreateReleaseOrderUseCase { private final PurchaseTokenValidationPort purchaseTokenValidationPort; - private final ReleaseIncreaseSoldQuantityPort releaseIncreaseSoldQuantityPort; private final OrderRepositoryPort orderRepositoryPort; private final OrderOutboxPort orderOutboxPort; - @Transactional @Override public void createReleaseOrder(CreateReleaseOrderCommand command) { boolean valid = purchaseTokenValidationPort.validate( @@ -34,6 +32,11 @@ public void createReleaseOrder(CreateReleaseOrderCommand command) { throw new InvalidPurchaseTokenException(); } + saveOrderWithOutbox(command); + } + + @Transactional + protected void saveOrderWithOutbox(CreateReleaseOrderCommand command) { OrderLine orderLine = OrderLine.createBuilder() .productId(command.releaseId()) .sellerId(command.sellerId()) @@ -52,7 +55,6 @@ public void createReleaseOrder(CreateReleaseOrderCommand command) { Long savedOrderId = orderRepositoryPort.save(order); orderOutboxPort.save(OrderPrepareEvent.from(savedOrderId, order)); - - releaseIncreaseSoldQuantityPort.increaseSoldQuantity(command.releaseId()); + orderOutboxPort.save(ReleaseSoldQuantityIncreaseEvent.of(command.releaseId())); } } diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java index ccddb36c..2fe5578f 100644 --- a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java @@ -11,7 +11,8 @@ public enum OrderDomainEventType { ORDER_CANCEL("order-cancel"), ORDER_PURCHASE_CONFIRMED("order-purchase-confirmed"), ORDER_AUTO_CONFIRMED("order-auto-confirmed"), - ORDER_DELIVERED("order-delivered"); + ORDER_DELIVERED("order-delivered"), + RELEASE_SOLD_QUANTITY_INCREASE("release-sold-quantity-increase"); private final String value; } diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/ReleaseSoldQuantityIncreaseEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/ReleaseSoldQuantityIncreaseEvent.java new file mode 100644 index 00000000..2735b707 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/ReleaseSoldQuantityIncreaseEvent.java @@ -0,0 +1,32 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record ReleaseSoldQuantityIncreaseEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("release_id") Long releaseId, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static ReleaseSoldQuantityIncreaseEvent of(Long releaseId) { + return ReleaseSoldQuantityIncreaseEvent.builder() + .eventId(releaseId) + .releaseId(releaseId) + .occurredAt(Instant.now()) + .build(); + } + + @Override + public String key() { + return releaseId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.RELEASE_SOLD_QUANTITY_INCREASE; + } +} diff --git a/services/order/src/main/resources/application-dev.yml b/services/order/src/main/resources/application-dev.yml index 80bd1906..8a992e86 100644 --- a/services/order/src/main/resources/application-dev.yml +++ b/services/order/src/main/resources/application-dev.yml @@ -9,9 +9,6 @@ spring: waiting-service: address: ${WAITING_SERVICE_URL} negotiation-type: plaintext - release-service: - address: ${RELEASE_SERVICE_URL} - negotiation-type: plaintext kafka: bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} listener: @@ -50,12 +47,6 @@ security: resilience4j: circuitbreaker: instances: - releaseService: - slidingWindowType: COUNT_BASED - slidingWindowSize: 20 - failureRateThreshold: 50 - waitDurationInOpenState: 10s - minimumNumberOfCalls: 10 waitingService: slidingWindowType: COUNT_BASED slidingWindowSize: 20 @@ -64,8 +55,6 @@ resilience4j: minimumNumberOfCalls: 10 timelimiter: instances: - releaseService: - timeoutDuration: 2s waitingService: timeoutDuration: 2s diff --git a/services/order/src/main/resources/application-local.yml b/services/order/src/main/resources/application-local.yml index f532420b..7e944600 100644 --- a/services/order/src/main/resources/application-local.yml +++ b/services/order/src/main/resources/application-local.yml @@ -42,9 +42,6 @@ spring: waiting-service: address: ${WAITING_SERVICE_URL} negotiation-type: plaintext - release-service: - address: ${RELEASE_SERVICE_URL} - negotiation-type: plaintext server: port: ${SERVER_PORT} @@ -58,12 +55,6 @@ security: resilience4j: circuitbreaker: instances: - releaseService: - slidingWindowType: COUNT_BASED - slidingWindowSize: 20 - failureRateThreshold: 50 - waitDurationInOpenState: 10s - minimumNumberOfCalls: 10 waitingService: slidingWindowType: COUNT_BASED slidingWindowSize: 20 @@ -72,8 +63,6 @@ resilience4j: minimumNumberOfCalls: 10 timelimiter: instances: - releaseService: - timeoutDuration: 2s waitingService: timeoutDuration: 2s diff --git a/services/order/src/main/resources/application-prod.yml b/services/order/src/main/resources/application-prod.yml index de8d3695..cc95fcd6 100644 --- a/services/order/src/main/resources/application-prod.yml +++ b/services/order/src/main/resources/application-prod.yml @@ -9,9 +9,6 @@ spring: waiting-service: address: ${WAITING_SERVICE_URL} negotiation-type: plaintext - release-service: - address: ${RELEASE_SERVICE_URL} - negotiation-type: plaintext kafka: bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} listener: @@ -50,12 +47,6 @@ security: resilience4j: circuitbreaker: instances: - releaseService: - slidingWindowType: COUNT_BASED - slidingWindowSize: 20 - failureRateThreshold: 50 - waitDurationInOpenState: 10s - minimumNumberOfCalls: 10 waitingService: slidingWindowType: COUNT_BASED slidingWindowSize: 20 @@ -64,8 +55,6 @@ resilience4j: minimumNumberOfCalls: 10 timelimiter: instances: - releaseService: - timeoutDuration: 2s waitingService: timeoutDuration: 2s From aeef1e839b74b774b62c6f596952deb1f43d9e82 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 22:04:05 +0900 Subject: [PATCH 095/116] =?UTF-8?q?feat(release):=20Kafka=20consumer?= =?UTF-8?q?=EB=A1=9C=20soldQuantity=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20(Inbox=20=ED=8C=A8=ED=84=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - order 서비스가 발행하는 release-sold-quantity-increase Outbox 이벤트 수신 - Inbox 패턴 인프라 추가: @Idempotent AOP, ReleaseInboxEntity/Repository, IdempotentAspect - HandleReleaseSoldQuantityIncreaseService: 기존 IncreaseSoldQuantityService와 동일 도메인 로직 재사용 - build.gradle: spring-boot-starter-kafka, aspectj 의존성 추가 - application-dev/prod/local.yml: Kafka consumer 설정 및 inbox 설정 추가 Co-Authored-By: Claude Sonnet 4.6 --- services/release/build.gradle | 3 + .../in/kafka/OrderEventKafkaListener.java | 29 ++++++ .../in/kafka/annotation/Idempotent.java | 11 +++ .../in/kafka/aop/IdempotentAspect.java | 89 +++++++++++++++++++ .../configuration/KafkaConfiguration.java | 22 +++++ .../adapter/in/kafka/event/InboxEvent.java | 8 ++ .../ReleaseSoldQuantityIncreaseEvent.java | 12 +++ .../in/kafka/properties/InboxProperties.java | 12 +++ .../entity/ReleaseInboxEntity.java | 50 +++++++++++ .../entity/ReleaseInboxStatus.java | 7 ++ .../repository/ReleaseInboxRepository.java | 8 ++ ...dleReleaseSoldQuantityIncreaseUseCase.java | 5 ++ ...dleReleaseSoldQuantityIncreaseService.java | 24 +++++ .../src/main/resources/application-dev.yml | 23 +++++ .../src/main/resources/application-local.yml | 23 +++++ .../src/main/resources/application-prod.yml | 23 +++++ 16 files changed, 349 insertions(+) create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/OrderEventKafkaListener.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/annotation/Idempotent.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/configuration/KafkaConfiguration.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/event/InboxEvent.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/event/ReleaseSoldQuantityIncreaseEvent.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseInboxEntity.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseInboxStatus.java create mode 100644 services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseInboxRepository.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/HandleReleaseSoldQuantityIncreaseUseCase.java create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/HandleReleaseSoldQuantityIncreaseService.java diff --git a/services/release/build.gradle b/services/release/build.gradle index 841f0978..45a1ceb7 100644 --- a/services/release/build.gradle +++ b/services/release/build.gradle @@ -12,7 +12,10 @@ description = 'release' dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-kafka' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework:spring-aspects' + implementation 'org.aspectj:aspectjweaver' implementation 'org.springframework.grpc:spring-grpc-server-spring-boot-starter' implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter' implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/OrderEventKafkaListener.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/OrderEventKafkaListener.java new file mode 100644 index 00000000..8a01759f --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/OrderEventKafkaListener.java @@ -0,0 +1,29 @@ +package kr.magicbox.release.adapter.in.kafka; + +import kr.magicbox.release.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.release.adapter.in.kafka.event.ReleaseSoldQuantityIncreaseEvent; +import kr.magicbox.release.application.port.in.HandleReleaseSoldQuantityIncreaseUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventKafkaListener { + + private final HandleReleaseSoldQuantityIncreaseUseCase handleReleaseSoldQuantityIncreaseUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.release-sold-quantity-increase", groupId = "release-service") + public void handleReleaseSoldQuantityIncrease( + ConsumerRecord consumerRecord) { + log.info("[Inbox] release-sold-quantity-increase 이벤트 수신. eventId={}", consumerRecord.key()); + handleReleaseSoldQuantityIncreaseUseCase.handleReleaseSoldQuantityIncrease( + consumerRecord.value().releaseId()); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/annotation/Idempotent.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/annotation/Idempotent.java new file mode 100644 index 00000000..3b5e025b --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/annotation/Idempotent.java @@ -0,0 +1,11 @@ +package kr.magicbox.release.adapter.in.kafka.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Idempotent { +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/aop/IdempotentAspect.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..c52b5ea6 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,89 @@ +package kr.magicbox.release.adapter.in.kafka.aop; + +import kr.magicbox.release.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.release.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.release.adapter.out.persistence.entity.ReleaseInboxEntity; +import kr.magicbox.release.adapter.out.persistence.entity.ReleaseInboxStatus; +import kr.magicbox.release.adapter.out.persistence.repository.ReleaseInboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final ReleaseInboxRepository releaseInboxRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.release.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + releaseInboxRepository.save(ReleaseInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(ReleaseInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); + return null; + } + + return transactionTemplate.execute(status -> { + if (releaseInboxRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + ReleaseInboxEntity inbox = releaseInboxRepository.save(ReleaseInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(ReleaseInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/configuration/KafkaConfiguration.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/configuration/KafkaConfiguration.java new file mode 100644 index 00000000..61928b59 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/configuration/KafkaConfiguration.java @@ -0,0 +1,22 @@ +package kr.magicbox.release.adapter.in.kafka.configuration; + +import kr.magicbox.release.adapter.in.kafka.properties.InboxProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@EnableKafkaRetryTopic +@Configuration +@EnableConfigurationProperties(InboxProperties.class) +public class KafkaConfiguration { + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/event/InboxEvent.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..dd5ffcdf --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.release.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/event/ReleaseSoldQuantityIncreaseEvent.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/event/ReleaseSoldQuantityIncreaseEvent.java new file mode 100644 index 00000000..589cd193 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/event/ReleaseSoldQuantityIncreaseEvent.java @@ -0,0 +1,12 @@ +package kr.magicbox.release.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record ReleaseSoldQuantityIncreaseEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("release_id") Long releaseId, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent { +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/properties/InboxProperties.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..6baa7daa --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.release.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseInboxEntity.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseInboxEntity.java new file mode 100644 index 00000000..30a3507f --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseInboxEntity.java @@ -0,0 +1,50 @@ +package kr.magicbox.release.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "release_inbox") +public class ReleaseInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReleaseInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public ReleaseInboxEntity(Long eventId, String topic, Integer partition, Long offset, + ReleaseInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = ReleaseInboxStatus.PROCESSED; + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseInboxStatus.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseInboxStatus.java new file mode 100644 index 00000000..3b34ff19 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/entity/ReleaseInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.release.adapter.out.persistence.entity; + +public enum ReleaseInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseInboxRepository.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseInboxRepository.java new file mode 100644 index 00000000..253cd342 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseInboxRepository.java @@ -0,0 +1,8 @@ +package kr.magicbox.release.adapter.out.persistence.repository; + +import kr.magicbox.release.adapter.out.persistence.entity.ReleaseInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReleaseInboxRepository extends JpaRepository { + boolean existsByEventId(Long eventId); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/HandleReleaseSoldQuantityIncreaseUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/HandleReleaseSoldQuantityIncreaseUseCase.java new file mode 100644 index 00000000..9a67dbd4 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/port/in/HandleReleaseSoldQuantityIncreaseUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.release.application.port.in; + +public interface HandleReleaseSoldQuantityIncreaseUseCase { + void handleReleaseSoldQuantityIncrease(Long releaseId); +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/HandleReleaseSoldQuantityIncreaseService.java b/services/release/src/main/java/kr/magicbox/release/application/service/HandleReleaseSoldQuantityIncreaseService.java new file mode 100644 index 00000000..fd215d8a --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/HandleReleaseSoldQuantityIncreaseService.java @@ -0,0 +1,24 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.port.in.HandleReleaseSoldQuantityIncreaseUseCase; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.ReleaseId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleReleaseSoldQuantityIncreaseService implements HandleReleaseSoldQuantityIncreaseUseCase { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Transactional + @Override + public void handleReleaseSoldQuantityIncrease(Long releaseId) { + Release release = releaseRepositoryPort.findById(ReleaseId.of(releaseId)); + release.increaseSoldQuantity(); + releaseRepositoryPort.update(release); + } +} diff --git a/services/release/src/main/resources/application-dev.yml b/services/release/src/main/resources/application-dev.yml index f336d5ed..0b919cf2 100644 --- a/services/release/src/main/resources/application-dev.yml +++ b/services/release/src/main/resources/application-dev.yml @@ -13,6 +13,26 @@ spring: negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: release-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.release.adapter.in.kafka.event" + spring.json.type.mapping: release-sold-quantity-increase:kr.magicbox.release.adapter.in.kafka.event.ReleaseSoldQuantityIncreaseEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -43,3 +63,6 @@ resilience4j: instances: creatorService: timeout-duration: 2s + +inbox: + max-event-age-minutes: 5 diff --git a/services/release/src/main/resources/application-local.yml b/services/release/src/main/resources/application-local.yml index cac2b0e9..eb3995ca 100644 --- a/services/release/src/main/resources/application-local.yml +++ b/services/release/src/main/resources/application-local.yml @@ -16,6 +16,26 @@ spring: negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: false + consumer: + group-id: release-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.release.adapter.in.kafka.event" + spring.json.type.mapping: release-sold-quantity-increase:kr.magicbox.release.adapter.in.kafka.event.ReleaseSoldQuantityIncreaseEvent + retry: + topic: + attempts: 3 + backoff: + delay: 1s + max-delay: 3s datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -50,3 +70,6 @@ resilience4j: instances: creatorService: timeout-duration: 2s + +inbox: + max-event-age-minutes: 5 diff --git a/services/release/src/main/resources/application-prod.yml b/services/release/src/main/resources/application-prod.yml index 121992e2..36e41baf 100644 --- a/services/release/src/main/resources/application-prod.yml +++ b/services/release/src/main/resources/application-prod.yml @@ -13,6 +13,26 @@ spring: negotiation-type: plaintext keep-alive-time: 30s keep-alive-timeout: 5s + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + listener: + ack-mode: record + missing-topics-fatal: true + consumer: + group-id: release-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + spring.json.trusted.packages: "kr.magicbox.release.adapter.in.kafka.event" + spring.json.type.mapping: release-sold-quantity-increase:kr.magicbox.release.adapter.in.kafka.event.ReleaseSoldQuantityIncreaseEvent + retry: + topic: + attempts: 5 + backoff: + delay: 1s + max-delay: 10s datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -43,3 +63,6 @@ resilience4j: instances: creatorService: timeout-duration: 2s + +inbox: + max-event-age-minutes: 60 From 62aaa64b3ae685178776175565868764aecd8553 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 22:37:22 +0900 Subject: [PATCH 096/116] =?UTF-8?q?refactor(order):=20AutoConfirm=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B2=AD=ED=81=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20+=20=EB=B6=84=EC=82=B0=20=EB=9D=BD=20+=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=99=B8=EB=B6=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AUTO_CONFIRM_DAYS 하드코딩 제거 → order.auto-confirm.days/chunk-size 설정 외부화 - findDeliveredBefore에 limit(Pageable) 추가로 전체 로드 방지 - 단일 트랜잭션 전체 배치 → AutoConfirmOrderChunkService.confirmOne()으로 분리 self-invocation 문제 해결, 한 건 실패해도 다른 건 롤백 없이 계속 처리 - Redisson 분산 락 적용(@SchedulerLock): 멀티 인스턴스 중복 실행 방지 - spring.data.redis 설정 추가 Co-Authored-By: Claude Sonnet 4.6 --- services/order/build.gradle | 3 +++ .../scheduler/AutoConfirmOrderScheduler.java | 2 ++ .../in/scheduler/SchedulerConfiguration.java | 14 ++++++++++ .../properties/AutoConfirmProperties.java | 13 +++++++++ .../out/persistence/OrderJpaAdapter.java | 6 +++-- .../repository/OrderJpaRepository.java | 3 ++- .../port/out/OrderRepositoryPort.java | 2 +- .../service/AutoConfirmOrderChunkService.java | 27 +++++++++++++++++++ .../service/AutoConfirmOrderService.java | 25 +++++++---------- .../src/main/resources/application-dev.yml | 11 ++++++++ .../src/main/resources/application-local.yml | 11 ++++++++ .../src/main/resources/application-prod.yml | 11 ++++++++ 12 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java diff --git a/services/order/build.gradle b/services/order/build.gradle index 2f6923e9..125cd81e 100644 --- a/services/order/build.gradle +++ b/services/order/build.gradle @@ -18,6 +18,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter' implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2' + implementation 'net.javacrumbs.shedlock:shedlock-provider-redisson:6.9.2' + implementation 'org.redisson:redisson-spring-boot-starter:3.45.1' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.kafka:spring-kafka-test' diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java index 4ba934ad..62811f4f 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java @@ -3,6 +3,7 @@ import kr.magicbox.order.application.port.in.AutoConfirmOrderUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -14,6 +15,7 @@ public class AutoConfirmOrderScheduler { private final AutoConfirmOrderUseCase autoConfirmOrderUseCase; @Scheduled(cron = "0 0 2 * * *") + @SchedulerLock(name = "autoConfirmDeliveredOrders", lockAtMostFor = "PT1H", lockAtLeastFor = "PT10M") public void autoConfirmDeliveredOrders() { log.info("[Scheduler] 자동 구매 확정 스케줄러 시작"); autoConfirmOrderUseCase.autoConfirmDeliveredOrders(); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java index 93cba13e..7c06f32e 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java @@ -1,9 +1,23 @@ package kr.magicbox.order.adapter.in.scheduler; +import kr.magicbox.order.adapter.in.scheduler.properties.AutoConfirmProperties; +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.redisson.RedissonLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.redisson.api.RedissonClient; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling +@EnableSchedulerLock(defaultLockAtMostFor = "PT10M") +@EnableConfigurationProperties(AutoConfirmProperties.class) @Configuration public class SchedulerConfiguration { + + @Bean + public LockProvider lockProvider(RedissonClient redissonClient) { + return new RedissonLockProvider(redissonClient); + } } diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java new file mode 100644 index 00000000..78ff720c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java @@ -0,0 +1,13 @@ +package kr.magicbox.order.adapter.in.scheduler.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "order.auto-confirm") +public class AutoConfirmProperties { + private final int days; + private final int chunkSize; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java index 762e7356..a53ba462 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java @@ -12,6 +12,7 @@ import kr.magicbox.order.domain.exception.OrderNotFoundException; import kr.magicbox.order.domain.vo.OrderId; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; import java.time.Instant; @@ -89,8 +90,9 @@ public List findBySellerId(Long sellerId) { } @Override - public List findDeliveredBefore(Instant deliveredBefore) { - List orders = orderJpaRepository.findByStatusAndUpdatedAtBeforeAndIsDeletedFalse(OrderStatus.DELIVERED, deliveredBefore); + public List findDeliveredBefore(Instant deliveredBefore, int limit) { + List orders = orderJpaRepository.findByStatusAndUpdatedAtBeforeAndIsDeletedFalse( + OrderStatus.DELIVERED, deliveredBefore, PageRequest.of(0, limit)); return toDomainsWithLines(orders); } diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java index f7c60b0c..b100c019 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java @@ -2,6 +2,7 @@ import kr.magicbox.order.adapter.out.persistence.entity.OrderEntity; import kr.magicbox.order.domain.enums.OrderStatus; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -22,5 +23,5 @@ public interface OrderJpaRepository extends JpaRepository { List findBySellerIdAndIsDeletedFalse(@Param("sellerId") Long sellerId); @Query("SELECT o FROM OrderEntity o WHERE o.status = :status AND o.updatedAt < :before AND o.isDeleted = false") - List findByStatusAndUpdatedAtBeforeAndIsDeletedFalse(@Param("status") OrderStatus status, @Param("before") Instant before); + List findByStatusAndUpdatedAtBeforeAndIsDeletedFalse(@Param("status") OrderStatus status, @Param("before") Instant before, Pageable pageable); } diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java index 43ca951e..127e3c1f 100644 --- a/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java +++ b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java @@ -13,5 +13,5 @@ public interface OrderRepositoryPort { Order findByOrderLineId(Long orderLineId); List findByCustomerId(Long customerId); List findBySellerId(Long sellerId); - List findDeliveredBefore(Instant deliveredBefore); + List findDeliveredBefore(Instant deliveredBefore, int limit); } diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java new file mode 100644 index 00000000..bb889905 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java @@ -0,0 +1,27 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderAutoConfirmedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoConfirmOrderChunkService { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + public void confirmOne(Order order) { + order.confirmPurchase(); + orderRepositoryPort.update(order); + OrderAutoConfirmedEvent.fromShippedLines(order).forEach(orderOutboxPort::save); + log.info("[AutoConfirm] 자동 구매 확정 처리. orderId={}", order.getId().value()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java index 2b9a02dd..f26d1ed4 100644 --- a/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java +++ b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java @@ -1,14 +1,12 @@ package kr.magicbox.order.application.service; +import kr.magicbox.order.adapter.in.scheduler.properties.AutoConfirmProperties; import kr.magicbox.order.application.port.in.AutoConfirmOrderUseCase; -import kr.magicbox.order.application.port.out.OrderOutboxPort; import kr.magicbox.order.application.port.out.OrderRepositoryPort; import kr.magicbox.order.domain.aggregate.Order; -import kr.magicbox.order.domain.event.OrderAutoConfirmedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -19,22 +17,19 @@ @RequiredArgsConstructor public class AutoConfirmOrderService implements AutoConfirmOrderUseCase { - private static final int AUTO_CONFIRM_DAYS = 7; - private final OrderRepositoryPort orderRepositoryPort; - private final OrderOutboxPort orderOutboxPort; + private final AutoConfirmOrderChunkService autoConfirmOrderChunkService; + private final AutoConfirmProperties autoConfirmProperties; - @Transactional @Override public void autoConfirmDeliveredOrders() { - Instant threshold = Instant.now().minus(AUTO_CONFIRM_DAYS, ChronoUnit.DAYS); - List orders = orderRepositoryPort.findDeliveredBefore(threshold); + Instant threshold = Instant.now().minus(autoConfirmProperties.getDays(), ChronoUnit.DAYS); + int chunkSize = autoConfirmProperties.getChunkSize(); - for (Order order : orders) { - order.confirmPurchase(); - orderRepositoryPort.update(order); - OrderAutoConfirmedEvent.fromShippedLines(order).forEach(orderOutboxPort::save); - log.info("[AutoConfirm] 자동 구매 확정 처리. orderId={}", order.getId().value()); - } + List chunk; + do { + chunk = orderRepositoryPort.findDeliveredBefore(threshold, chunkSize); + chunk.forEach(autoConfirmOrderChunkService::confirmOne); + } while (chunk.size() == chunkSize); } } diff --git a/services/order/src/main/resources/application-dev.yml b/services/order/src/main/resources/application-dev.yml index 8a992e86..15d84330 100644 --- a/services/order/src/main/resources/application-dev.yml +++ b/services/order/src/main/resources/application-dev.yml @@ -29,6 +29,12 @@ spring: backoff: delay: 1s max-delay: 3s + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + timeout: 2000ms datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -60,3 +66,8 @@ resilience4j: inbox: max-event-age-minutes: 5 + +order: + auto-confirm: + days: 7 + chunk-size: 100 diff --git a/services/order/src/main/resources/application-local.yml b/services/order/src/main/resources/application-local.yml index 7e944600..e5c2a0a2 100644 --- a/services/order/src/main/resources/application-local.yml +++ b/services/order/src/main/resources/application-local.yml @@ -26,6 +26,12 @@ spring: backoff: delay: 1s max-delay: 3s + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + timeout: 2000ms datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -68,3 +74,8 @@ resilience4j: inbox: max-event-age-minutes: 5 + +order: + auto-confirm: + days: 7 + chunk-size: 100 diff --git a/services/order/src/main/resources/application-prod.yml b/services/order/src/main/resources/application-prod.yml index cc95fcd6..0da7b143 100644 --- a/services/order/src/main/resources/application-prod.yml +++ b/services/order/src/main/resources/application-prod.yml @@ -29,6 +29,12 @@ spring: backoff: delay: 1s max-delay: 10s + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + timeout: 2000ms datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} @@ -60,3 +66,8 @@ resilience4j: inbox: max-event-age-minutes: 60 + +order: + auto-confirm: + days: 7 + chunk-size: 100 From 413f8aad9a21f19950957c4b66e53e95d6f9cd0e Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 22:39:41 +0900 Subject: [PATCH 097/116] =?UTF-8?q?refactor(order):=20AutoConfirm=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B2=AD=ED=81=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20+=20=EB=B6=84=EC=82=B0=20=EB=9D=BD=20+=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=99=B8=EB=B6=80=ED=99=94=20(=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../scheduler/AutoConfirmOrderScheduler.java | 24 +++++++++++++ .../in/scheduler/SchedulerConfiguration.java | 23 ++++++++++++ .../properties/AutoConfirmProperties.java | 13 +++++++ .../service/AutoConfirmOrderChunkService.java | 27 ++++++++++++++ .../service/AutoConfirmOrderService.java | 35 +++++++++++++++++++ 5 files changed, 122 insertions(+) create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java new file mode 100644 index 00000000..62811f4f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.adapter.in.scheduler; + +import kr.magicbox.order.application.port.in.AutoConfirmOrderUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AutoConfirmOrderScheduler { + + private final AutoConfirmOrderUseCase autoConfirmOrderUseCase; + + @Scheduled(cron = "0 0 2 * * *") + @SchedulerLock(name = "autoConfirmDeliveredOrders", lockAtMostFor = "PT1H", lockAtLeastFor = "PT10M") + public void autoConfirmDeliveredOrders() { + log.info("[Scheduler] 자동 구매 확정 스케줄러 시작"); + autoConfirmOrderUseCase.autoConfirmDeliveredOrders(); + log.info("[Scheduler] 자동 구매 확정 스케줄러 완료"); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java new file mode 100644 index 00000000..7c06f32e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.scheduler; + +import kr.magicbox.order.adapter.in.scheduler.properties.AutoConfirmProperties; +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.redisson.RedissonLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.redisson.api.RedissonClient; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@EnableSchedulerLock(defaultLockAtMostFor = "PT10M") +@EnableConfigurationProperties(AutoConfirmProperties.class) +@Configuration +public class SchedulerConfiguration { + + @Bean + public LockProvider lockProvider(RedissonClient redissonClient) { + return new RedissonLockProvider(redissonClient); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java new file mode 100644 index 00000000..78ff720c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java @@ -0,0 +1,13 @@ +package kr.magicbox.order.adapter.in.scheduler.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "order.auto-confirm") +public class AutoConfirmProperties { + private final int days; + private final int chunkSize; +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java new file mode 100644 index 00000000..bb889905 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java @@ -0,0 +1,27 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderAutoConfirmedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoConfirmOrderChunkService { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + public void confirmOne(Order order) { + order.confirmPurchase(); + orderRepositoryPort.update(order); + OrderAutoConfirmedEvent.fromShippedLines(order).forEach(orderOutboxPort::save); + log.info("[AutoConfirm] 자동 구매 확정 처리. orderId={}", order.getId().value()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java new file mode 100644 index 00000000..f26d1ed4 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java @@ -0,0 +1,35 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.adapter.in.scheduler.properties.AutoConfirmProperties; +import kr.magicbox.order.application.port.in.AutoConfirmOrderUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoConfirmOrderService implements AutoConfirmOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final AutoConfirmOrderChunkService autoConfirmOrderChunkService; + private final AutoConfirmProperties autoConfirmProperties; + + @Override + public void autoConfirmDeliveredOrders() { + Instant threshold = Instant.now().minus(autoConfirmProperties.getDays(), ChronoUnit.DAYS); + int chunkSize = autoConfirmProperties.getChunkSize(); + + List chunk; + do { + chunk = orderRepositoryPort.findDeliveredBefore(threshold, chunkSize); + chunk.forEach(autoConfirmOrderChunkService::confirmOne); + } while (chunk.size() == chunkSize); + } +} From 1edac13848b69ed59f3b263b32251997367ece37 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 22:41:04 +0900 Subject: [PATCH 098/116] =?UTF-8?q?refactor(order):=20AutoConfirm=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B2=AD=ED=81=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20+=20=EB=B6=84=EC=82=B0=20=EB=9D=BD=20+=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=99=B8=EB=B6=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AUTO_CONFIRM_DAYS 하드코딩 제거 → order.auto-confirm.days/chunk-size 설정 외부화 - findDeliveredBefore에 limit(Pageable) 추가로 전체 로드 방지 - 단일 트랜잭션 전체 배치 → AutoConfirmOrderChunkService.confirmOne()으로 분리 self-invocation 문제 해결, 한 건 실패해도 다른 건 롤백 없이 계속 처리 - Redisson 분산 락 적용(@SchedulerLock): 멀티 인스턴스 중복 실행 방지 Co-Authored-By: Claude Sonnet 4.6 --- .../in/kafka/DeliveryEventKafkaListener.java | 40 +++ .../adapter/in/kafka/KafkaConfiguration.java | 22 ++ .../in/kafka/OrderStateKafkaListener.java | 27 ++ .../in/kafka/PaymentEventKafkaListener.java | 60 ++++ .../in/kafka/StockEventKafkaListener.java | 38 +++ .../in/kafka/annotation/Idempotent.java | 11 + .../in/kafka/aop/IdempotentAspect.java | 89 ++++++ .../kafka/event/DeliveryCompletedEvent.java | 23 ++ .../in/kafka/event/DeliveryStartedEvent.java | 18 ++ .../adapter/in/kafka/event/InboxEvent.java | 8 + .../in/kafka/event/OrderPrepareEventDto.java | 23 ++ .../kafka/event/PaymentCancelFailedEvent.java | 15 + .../event/PaymentCancelSucceededEvent.java | 23 ++ .../in/kafka/event/PaymentFailedEvent.java | 15 + .../in/kafka/event/PaymentSucceededEvent.java | 12 + .../kafka/event/StockReserveFailedEvent.java | 21 ++ .../event/StockReserveSucceededEvent.java | 12 + .../in/kafka/properties/InboxProperties.java | 12 + .../scheduler/AutoConfirmOrderScheduler.java | 24 ++ .../in/scheduler/SchedulerConfiguration.java | 23 ++ .../properties/AutoConfirmProperties.java | 13 + .../configuration/SecurityConfiguration.java | 46 +++ .../filter/UserInfoExtractFilter.java | 52 ++++ .../properties/TrustedIpProperties.java | 14 + .../in/web/OrderCommandController.java | 106 +++++++ .../adapter/in/web/OrderQueryController.java | 48 ++++ .../web/dto/request/CancelOrderRequest.java | 17 ++ .../web/dto/request/CreateOrderRequest.java | 36 +++ .../request/CreateReleaseOrderRequest.java | 28 ++ .../in/web/dto/request/OrderLineRequest.java | 14 + .../dto/request/ShippingAddressRequest.java | 16 ++ .../web/dto/response/OrderLineResponse.java | 23 ++ .../in/web/dto/response/OrderResponse.java | 38 +++ .../dto/response/ShippingAddressResponse.java | 23 ++ .../web/exception/handler/ErrorResponse.java | 14 + .../handler/GlobalExceptionHandler.java | 87 ++++++ .../out/communication/ServiceHost.java | 12 + .../communication/grpc/GrpcConfiguration.java | 17 ++ .../grpc/WaitingGrpcAdapter.java | 43 +++ .../WaitingServiceUnavailableException.java | 12 + .../out/persistence/OrderJpaAdapter.java | 112 ++++++++ .../out/persistence/OrderOutboxAdapter.java | 26 ++ .../configuration/JpaConfiguration.java | 9 + .../out/persistence/entity/BaseEntity.java | 30 ++ .../out/persistence/entity/OrderEntity.java | 71 +++++ .../persistence/entity/OrderInboxEntity.java | 53 ++++ .../persistence/entity/OrderInboxStatus.java | 7 + .../persistence/entity/OrderLineEntity.java | 56 ++++ .../persistence/entity/OrderOutboxEntity.java | 28 ++ .../out/persistence/mapper/OrderMapper.java | 79 ++++++ .../repository/OrderInboxJpaRepository.java | 12 + .../repository/OrderJpaRepository.java | 27 ++ .../repository/OrderLineJpaRepository.java | 21 ++ .../repository/OrderOutboxJpaRepository.java | 7 + .../dto/command/CancelOrderCommand.java | 6 + .../dto/command/ConfirmOrderCommand.java | 6 + .../dto/command/CreateOrderCommand.java | 24 ++ .../command/CreateReleaseOrderCommand.java | 15 + .../command/PurchaseConfirmOrderCommand.java | 6 + .../dto/query/GetOrderListQuery.java | 6 + .../application/dto/query/GetOrderQuery.java | 6 + .../dto/result/OrderLineResult.java | 12 + .../application/dto/result/OrderResult.java | 19 ++ .../dto/result/ShippingAddressResult.java | 23 ++ .../port/in/AutoConfirmOrderUseCase.java | 5 + .../port/in/CancelOrderUseCase.java | 7 + .../port/in/ComplainOrderLineUseCase.java | 5 + .../port/in/ConfirmOrderLineUseCase.java | 5 + .../port/in/ConfirmOrderUseCase.java | 7 + .../port/in/CreateOrderUseCase.java | 7 + .../port/in/CreateReleaseOrderUseCase.java | 7 + .../port/in/GetOrderListUseCase.java | 10 + .../application/port/in/GetOrderUseCase.java | 8 + .../in/HandleDeliveryCompletedUseCase.java | 5 + .../port/in/HandleDeliveryStartedUseCase.java | 5 + .../port/in/HandleOrderPrepareUseCase.java | 5 + .../in/HandlePaymentCancelFailedUseCase.java | 5 + .../HandlePaymentCancelSucceededUseCase.java | 5 + .../port/in/HandlePaymentFailedUseCase.java | 5 + .../in/HandlePaymentSucceededUseCase.java | 5 + .../in/HandleStockReserveFailedUseCase.java | 5 + .../HandleStockReserveSucceededUseCase.java | 5 + .../port/in/PurchaseConfirmOrderUseCase.java | 7 + .../application/port/out/OrderOutboxPort.java | 7 + .../port/out/OrderRepositoryPort.java | 17 ++ .../port/out/PurchaseTokenValidationPort.java | 6 + .../service/AutoConfirmOrderChunkService.java | 27 ++ .../service/AutoConfirmOrderService.java | 35 +++ .../service/CancelOrderService.java | 40 +++ .../service/ComplainOrderLineService.java | 37 +++ .../service/ConfirmOrderLineService.java | 39 +++ .../service/ConfirmOrderService.java | 35 +++ .../service/CreateOrderService.java | 47 ++++ .../service/CreateReleaseOrderService.java | 60 ++++ .../service/GetOrderListService.java | 32 +++ .../application/service/GetOrderService.java | 32 +++ .../HandleDeliveryCompletedService.java | 31 +++ .../service/HandleDeliveryStartedService.java | 24 ++ .../service/HandleOrderPrepareService.java | 24 ++ .../HandlePaymentCancelFailedService.java | 24 ++ .../HandlePaymentCancelSucceededService.java | 24 ++ .../service/HandlePaymentFailedService.java | 24 ++ .../HandlePaymentSucceededService.java | 24 ++ .../HandleStockReserveFailedService.java | 24 ++ .../HandleStockReserveSucceededService.java | 24 ++ .../service/OrderResultMapper.java | 36 +++ .../service/PurchaseConfirmOrderService.java | 35 +++ .../order/domain/aggregate/Order.java | 261 ++++++++++++++++++ .../order/domain/aggregate/OrderLine.java | 127 +++++++++ .../domain/enums/OrderLineDeliveryStatus.java | 12 + .../order/domain/enums/OrderStatus.java | 18 ++ .../domain/event/OrderAutoConfirmedEvent.java | 48 ++++ .../order/domain/event/OrderCancelEvent.java | 38 +++ .../domain/event/OrderConfirmedEvent.java | 38 +++ .../domain/event/OrderDeliveredEvent.java | 36 +++ .../order/domain/event/OrderDomainEvent.java | 6 + .../domain/event/OrderDomainEventType.java | 18 ++ .../order/domain/event/OrderPrepareEvent.java | 78 ++++++ .../event/OrderPurchaseConfirmedEvent.java | 48 ++++ .../ReleaseSoldQuantityIncreaseEvent.java | 32 +++ .../exception/InvalidFieldException.java | 11 + .../InvalidPurchaseTokenException.java | 11 + .../OrderLineComplainNotAllowedException.java | 11 + .../exception/OrderLineNotFoundException.java | 11 + .../exception/OrderNotFoundException.java | 11 + .../OrderStatusConflictException.java | 11 + .../exception/OrderUnauthorizedException.java | 11 + .../kr/magicbox/order/domain/vo/OrderId.java | 16 ++ .../magicbox/order/domain/vo/OrderLineId.java | 16 ++ .../order/domain/vo/ShippingAddress.java | 31 +++ .../kr/magicbox/order/domain/vo/UserId.java | 15 + .../order/global/exception/BaseException.java | 20 ++ .../global/exception/BusinessException.java | 17 ++ .../order/global/exception/SystemError.java | 14 + 134 files changed, 3623 insertions(+) create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/KafkaConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/annotation/Idempotent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/InboxEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/properties/InboxProperties.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/security/configuration/SecurityConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/security/filter/UserInfoExtractFilter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/security/properties/TrustedIpProperties.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderCommandController.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderQueryController.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CancelOrderRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/ShippingAddressRequest.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderResponse.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/ShippingAddressResponse.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/ErrorResponse.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/GlobalExceptionHandler.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/WaitingServiceUnavailableException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderOutboxAdapter.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/configuration/JpaConfiguration.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/BaseEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxStatus.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderOutboxEntity.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderInboxJpaRepository.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderLineJpaRepository.java create mode 100644 services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderOutboxJpaRepository.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/CancelOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/ConfirmOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/command/PurchaseConfirmOrderCommand.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderListQuery.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderQuery.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderResult.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/dto/result/ShippingAddressResult.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/AutoConfirmOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/CancelOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/ComplainOrderLineUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderLineUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/CreateOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/CreateReleaseOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderListUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryCompletedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryStartedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleOrderPrepareUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelFailedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelSucceededUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentFailedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentSucceededUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveFailedUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveSucceededUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/in/PurchaseConfirmOrderUseCase.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/out/OrderOutboxPort.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/port/out/PurchaseTokenValidationPort.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/CancelOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/ComplainOrderLineService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/GetOrderListService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/GetOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryCompletedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryStartedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleOrderPrepareService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelFailedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelSucceededService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentFailedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentSucceededService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveFailedService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveSucceededService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java create mode 100644 services/order/src/main/java/kr/magicbox/order/application/service/PurchaseConfirmOrderService.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/enums/OrderLineDeliveryStatus.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/enums/OrderStatus.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderAutoConfirmedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderCancelEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderConfirmedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderDeliveredEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/OrderPurchaseConfirmedEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/event/ReleaseSoldQuantityIncreaseEvent.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidFieldException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidPurchaseTokenException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineComplainNotAllowedException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineNotFoundException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderNotFoundException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderStatusConflictException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/exception/OrderUnauthorizedException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/vo/OrderId.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/vo/OrderLineId.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/vo/ShippingAddress.java create mode 100644 services/order/src/main/java/kr/magicbox/order/domain/vo/UserId.java create mode 100644 services/order/src/main/java/kr/magicbox/order/global/exception/BaseException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/global/exception/BusinessException.java create mode 100644 services/order/src/main/java/kr/magicbox/order/global/exception/SystemError.java diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java new file mode 100644 index 00000000..43658baf --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java @@ -0,0 +1,40 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent; +import kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent; +import kr.magicbox.order.application.port.in.HandleDeliveryCompletedUseCase; +import kr.magicbox.order.application.port.in.HandleDeliveryStartedUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeliveryEventKafkaListener { + + private final HandleDeliveryStartedUseCase handleDeliveryStartedUseCase; + private final HandleDeliveryCompletedUseCase handleDeliveryCompletedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.delivery-started", groupId = "order-service") + public void handleDeliveryStarted(ConsumerRecord consumerRecord) { + log.info("[Inbox] delivery.started 이벤트 수신. eventId={}", consumerRecord.key()); + DeliveryStartedEvent event = consumerRecord.value(); + handleDeliveryStartedUseCase.handleDeliveryStarted(event.orderId(), event.orderLineId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.delivery-completed", groupId = "order-service") + public void handleDeliveryCompleted(ConsumerRecord consumerRecord) { + log.info("[Inbox] delivery.completed 이벤트 수신. eventId={}", consumerRecord.key()); + DeliveryCompletedEvent event = consumerRecord.value(); + handleDeliveryCompletedUseCase.handleDeliveryCompleted(event.orderId(), event.orderLineId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/KafkaConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/KafkaConfiguration.java new file mode 100644 index 00000000..85e3d609 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/KafkaConfiguration.java @@ -0,0 +1,22 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.properties.InboxProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafkaRetryTopic; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@EnableKafkaRetryTopic +@Configuration +@EnableConfigurationProperties(InboxProperties.class) +public class KafkaConfiguration { + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("kafka-retry-"); + return scheduler; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java new file mode 100644 index 00000000..873c2d5c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java @@ -0,0 +1,27 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto; +import kr.magicbox.order.application.port.in.HandleOrderPrepareUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderStateKafkaListener { + + private final HandleOrderPrepareUseCase handleOrderPrepareUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.order-prepare", groupId = "order-service") + public void handleOrderPrepare(ConsumerRecord consumerRecord) { + log.info("[Inbox] order.prepare 이벤트 수신. eventId={}", consumerRecord.key()); + handleOrderPrepareUseCase.handleOrderPrepare(consumerRecord.value().orderId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java new file mode 100644 index 00000000..03600c45 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java @@ -0,0 +1,60 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent; +import kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent; +import kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent; +import kr.magicbox.order.adapter.in.kafka.event.PaymentSucceededEvent; +import kr.magicbox.order.application.port.in.HandlePaymentCancelFailedUseCase; +import kr.magicbox.order.application.port.in.HandlePaymentCancelSucceededUseCase; +import kr.magicbox.order.application.port.in.HandlePaymentFailedUseCase; +import kr.magicbox.order.application.port.in.HandlePaymentSucceededUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventKafkaListener { + + private final HandlePaymentSucceededUseCase handlePaymentSucceededUseCase; + private final HandlePaymentFailedUseCase handlePaymentFailedUseCase; + private final HandlePaymentCancelSucceededUseCase handlePaymentCancelSucceededUseCase; + private final HandlePaymentCancelFailedUseCase handlePaymentCancelFailedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-succeeded", groupId = "order-service") + public void handlePaymentSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentSucceededUseCase.handlePaymentSucceeded(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-failed", groupId = "order-service") + public void handlePaymentFailed(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.failed 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentFailedUseCase.handlePaymentFailed(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-cancel-succeeded", groupId = "order-service") + public void handlePaymentCancelSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.cancel.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentCancelSucceededUseCase.handlePaymentCancelSucceeded(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.payment-cancel-failed", groupId = "order-service") + public void handlePaymentCancelFailed(ConsumerRecord consumerRecord) { + log.info("[Inbox] payment.cancel.failed 이벤트 수신. eventId={}", consumerRecord.key()); + handlePaymentCancelFailedUseCase.handlePaymentCancelFailed(consumerRecord.value().orderId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java new file mode 100644 index 00000000..4e4dfe8a --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java @@ -0,0 +1,38 @@ +package kr.magicbox.order.adapter.in.kafka; + +import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent; +import kr.magicbox.order.adapter.in.kafka.event.StockReserveSucceededEvent; +import kr.magicbox.order.application.port.in.HandleStockReserveFailedUseCase; +import kr.magicbox.order.application.port.in.HandleStockReserveSucceededUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StockEventKafkaListener { + + private final HandleStockReserveSucceededUseCase handleStockReserveSucceededUseCase; + private final HandleStockReserveFailedUseCase handleStockReserveFailedUseCase; + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.stock-reserve-succeeded", groupId = "order-service") + public void handleStockReserveSucceeded(ConsumerRecord consumerRecord) { + log.info("[Inbox] stock.reserve.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); + handleStockReserveSucceededUseCase.handleStockReserveSucceeded(consumerRecord.value().orderId()); + } + + @Idempotent + @RetryableTopic + @KafkaListener(topics = "outbox.event.stock-reserve-failed", groupId = "order-service") + public void handleStockReserveFailed(ConsumerRecord consumerRecord) { + log.info("[Inbox] stock.reserve.failed 이벤트 수신. eventId={}", consumerRecord.key()); + handleStockReserveFailedUseCase.handleStockReserveFailed(consumerRecord.value().orderId()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/annotation/Idempotent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/annotation/Idempotent.java new file mode 100644 index 00000000..906de2fa --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/annotation/Idempotent.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.adapter.in.kafka.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Idempotent { +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java new file mode 100644 index 00000000..8461e3c5 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java @@ -0,0 +1,89 @@ +package kr.magicbox.order.adapter.in.kafka.aop; + +import kr.magicbox.order.adapter.in.kafka.event.InboxEvent; +import kr.magicbox.order.adapter.in.kafka.properties.InboxProperties; +import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxEntity; +import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxStatus; +import kr.magicbox.order.adapter.out.persistence.repository.OrderInboxJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class IdempotentAspect { + + private final OrderInboxJpaRepository orderInboxJpaRepository; + private final TransactionTemplate transactionTemplate; + private final InboxProperties inboxProperties; + + @Around("@annotation(kr.magicbox.order.adapter.in.kafka.annotation.Idempotent)") + public Object around(ProceedingJoinPoint pjp) { + ConsumerRecord consumerRecord = extractRecord(pjp); + InboxEvent event = (InboxEvent) consumerRecord.value(); + Long eventId = event.eventId(); + Instant occurredAt = event.occurredAt(); + + if (isTooOld(occurredAt)) { + log.warn("[Inbox] 만료된 메시지 DEAD_LETTERED 처리. eventId={}, occurredAt={}", eventId, occurredAt); + transactionTemplate.executeWithoutResult(status -> + orderInboxJpaRepository.save(OrderInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(OrderInboxStatus.DEAD_LETTERED) + .occurredAt(occurredAt) + .build()) + ); + return null; + } + + return transactionTemplate.execute(status -> { + if (orderInboxJpaRepository.existsByEventId(eventId)) { + log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId); + return null; + } + OrderInboxEntity inbox = orderInboxJpaRepository.save(OrderInboxEntity.builder() + .eventId(eventId) + .topic(consumerRecord.topic()) + .partition(consumerRecord.partition()) + .offset(consumerRecord.offset()) + .status(OrderInboxStatus.PENDING) + .occurredAt(occurredAt) + .build()); + try { + pjp.proceed(); + } catch (Throwable e) { + status.setRollbackOnly(); + throw new RuntimeException(e); + } + inbox.markProcessed(); + return null; + }); + } + + private boolean isTooOld(Instant occurredAt) { + return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES)); + } + + @SuppressWarnings("unchecked") + private ConsumerRecord extractRecord(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(ConsumerRecord.class::isInstance) + .map(arg -> (ConsumerRecord) arg) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다.")); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java new file mode 100644 index 00000000..9d046067 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryCompletedEvent.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record DeliveryCompletedEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("delivery_id") Long deliveryId, + @JsonProperty("tracking_number") String trackingNumber, + @JsonProperty("delivered_at") Instant deliveredAt, + @JsonProperty("items") List items, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent { + public record ItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java new file mode 100644 index 00000000..6e25582f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/DeliveryStartedEvent.java @@ -0,0 +1,18 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record DeliveryStartedEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("delivery_id") Long deliveryId, + @JsonProperty("carrier") String carrier, + @JsonProperty("carrier_code") String carrierCode, + @JsonProperty("tracking_number") String trackingNumber, + @JsonProperty("dispatched_at") Instant dispatchedAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/InboxEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/InboxEvent.java new file mode 100644 index 00000000..d3c4c8c6 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/InboxEvent.java @@ -0,0 +1,8 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import java.time.Instant; + +public interface InboxEvent { + Long eventId(); + Instant occurredAt(); +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java new file mode 100644 index 00000000..09abb8d6 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/OrderPrepareEventDto.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record OrderPrepareEventDto( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("items") List items, + @JsonProperty("total_amount") long totalAmount, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent { + public record ItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity, + @JsonProperty("unit_price") long unitPrice, + @JsonProperty("product_name") String productName + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java new file mode 100644 index 00000000..78564ff9 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelFailedEvent.java @@ -0,0 +1,15 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record PaymentCancelFailedEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("pg_code") String pgCode, + @JsonProperty("pg_message") String pgMessage, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java new file mode 100644 index 00000000..81fa1a67 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentCancelSucceededEvent.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record PaymentCancelSucceededEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("pg_transaction_id") String pgTransactionId, + @JsonProperty("refund_amount") long refundAmount, + @JsonProperty("currency") String currency, + @JsonProperty("refunded_at") Instant refundedAt, + @JsonProperty("items") List items, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent { + public record ItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java new file mode 100644 index 00000000..47e46b82 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentFailedEvent.java @@ -0,0 +1,15 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record PaymentFailedEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("pg_code") String pgCode, + @JsonProperty("pg_message") String pgMessage, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java new file mode 100644 index 00000000..495a5190 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/PaymentSucceededEvent.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record PaymentSucceededEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java new file mode 100644 index 00000000..61cbc068 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveFailedEvent.java @@ -0,0 +1,21 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record StockReserveFailedEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("failed_items") List failedItems, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent { + public record FailedItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("requested") int requested, + @JsonProperty("available") int available + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java new file mode 100644 index 00000000..4222cb26 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/event/StockReserveSucceededEvent.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +public record StockReserveSucceededEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/properties/InboxProperties.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/properties/InboxProperties.java new file mode 100644 index 00000000..ec5d6ade --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/properties/InboxProperties.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.in.kafka.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "inbox") +public class InboxProperties { + private final long maxEventAgeMinutes; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java new file mode 100644 index 00000000..62811f4f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/AutoConfirmOrderScheduler.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.adapter.in.scheduler; + +import kr.magicbox.order.application.port.in.AutoConfirmOrderUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AutoConfirmOrderScheduler { + + private final AutoConfirmOrderUseCase autoConfirmOrderUseCase; + + @Scheduled(cron = "0 0 2 * * *") + @SchedulerLock(name = "autoConfirmDeliveredOrders", lockAtMostFor = "PT1H", lockAtLeastFor = "PT10M") + public void autoConfirmDeliveredOrders() { + log.info("[Scheduler] 자동 구매 확정 스케줄러 시작"); + autoConfirmOrderUseCase.autoConfirmDeliveredOrders(); + log.info("[Scheduler] 자동 구매 확정 스케줄러 완료"); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java new file mode 100644 index 00000000..7c06f32e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.scheduler; + +import kr.magicbox.order.adapter.in.scheduler.properties.AutoConfirmProperties; +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.redisson.RedissonLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.redisson.api.RedissonClient; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@EnableSchedulerLock(defaultLockAtMostFor = "PT10M") +@EnableConfigurationProperties(AutoConfirmProperties.class) +@Configuration +public class SchedulerConfiguration { + + @Bean + public LockProvider lockProvider(RedissonClient redissonClient) { + return new RedissonLockProvider(redissonClient); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java new file mode 100644 index 00000000..78ff720c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/properties/AutoConfirmProperties.java @@ -0,0 +1,13 @@ +package kr.magicbox.order.adapter.in.scheduler.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "order.auto-confirm") +public class AutoConfirmProperties { + private final int days; + private final int chunkSize; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/security/configuration/SecurityConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/configuration/SecurityConfiguration.java new file mode 100644 index 00000000..97a93333 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/configuration/SecurityConfiguration.java @@ -0,0 +1,46 @@ +package kr.magicbox.order.adapter.in.security.configuration; + +import kr.magicbox.order.adapter.in.security.filter.UserInfoExtractFilter; +import kr.magicbox.order.adapter.in.security.properties.TrustedIpProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.filter.ForwardedHeaderFilter; + +@Configuration +@EnableWebSecurity +@EnableConfigurationProperties(TrustedIpProperties.class) +@RequiredArgsConstructor +public class SecurityConfiguration { + + private final TrustedIpProperties trustedIpProperties; + + @Bean + public ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + @Bean + public UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(new UserInfoExtractFilter(trustedIpProperties), UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/security/filter/UserInfoExtractFilter.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/filter/UserInfoExtractFilter.java new file mode 100644 index 00000000..5f57742d --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/filter/UserInfoExtractFilter.java @@ -0,0 +1,52 @@ +package kr.magicbox.order.adapter.in.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.magicbox.order.adapter.in.security.properties.TrustedIpProperties; +import kr.magicbox.order.domain.vo.UserId; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class UserInfoExtractFilter extends OncePerRequestFilter { + + private final TrustedIpProperties trustedIpProperties; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + String clientIp = request.getRemoteAddr(); + + if (!trustedIpProperties.getIps().contains(clientIp)) { + filterChain.doFilter(request, response); + return; + } + + String userIdHeader = request.getHeader("X-User-Id"); + + if (!isValidUserId(userIdHeader)) { + filterChain.doFilter(request, response); + return; + } + + UserId userId = UserId.of(Long.valueOf(userIdHeader)); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userId, null); + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } + + private boolean isValidUserId(String userIdHeader) { + try { + return Long.parseLong(userIdHeader) > 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/security/properties/TrustedIpProperties.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/properties/TrustedIpProperties.java new file mode 100644 index 00000000..ed0c5c25 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/security/properties/TrustedIpProperties.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.adapter.in.security.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "security.trusted") +public class TrustedIpProperties { + private final List ips; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderCommandController.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderCommandController.java new file mode 100644 index 00000000..00f16d8c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderCommandController.java @@ -0,0 +1,106 @@ +package kr.magicbox.order.adapter.in.web; + +import jakarta.validation.Valid; +import kr.magicbox.order.adapter.in.web.dto.request.CancelOrderRequest; +import kr.magicbox.order.adapter.in.web.dto.request.CreateOrderRequest; +import kr.magicbox.order.adapter.in.web.dto.request.CreateReleaseOrderRequest; +import kr.magicbox.order.application.dto.command.ConfirmOrderCommand; +import kr.magicbox.order.application.dto.command.PurchaseConfirmOrderCommand; +import kr.magicbox.order.application.port.in.CancelOrderUseCase; +import kr.magicbox.order.application.port.in.ComplainOrderLineUseCase; +import kr.magicbox.order.application.port.in.ConfirmOrderUseCase; +import kr.magicbox.order.application.port.in.CreateOrderUseCase; +import kr.magicbox.order.application.port.in.CreateReleaseOrderUseCase; +import kr.magicbox.order.application.port.in.PurchaseConfirmOrderUseCase; +import kr.magicbox.order.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/order") +@RequiredArgsConstructor +@Validated +public class OrderCommandController { + + private final CreateOrderUseCase createOrderUseCase; + private final CreateReleaseOrderUseCase createReleaseOrderUseCase; + private final ConfirmOrderUseCase confirmOrderUseCase; + private final CancelOrderUseCase cancelOrderUseCase; + private final PurchaseConfirmOrderUseCase purchaseConfirmOrderUseCase; + private final ComplainOrderLineUseCase complainOrderLineUseCase; + + @PostMapping + public ResponseEntity createOrder( + @AuthenticationPrincipal UserId userId, + @Valid @RequestBody CreateOrderRequest request + ) { + createOrderUseCase.createOrder(request.toCommand(userId.value())); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/release") + public ResponseEntity createReleaseOrder( + @AuthenticationPrincipal UserId userId, + @Valid @RequestBody CreateReleaseOrderRequest request + ) { + createReleaseOrderUseCase.createReleaseOrder(request.toCommand(userId.value())); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PostMapping("/{orderId}/confirm") + public ResponseEntity confirmOrder( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId + ) { + confirmOrderUseCase.confirmOrder(toConfirmCommand(orderId, userId.value())); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{orderId}/lines/{orderLineId}/cancel") + public ResponseEntity cancelOrder( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId, + @PathVariable Long orderLineId, + @Valid @RequestBody CancelOrderRequest request + ) { + cancelOrderUseCase.cancelOrder(request.toCommand(orderId, orderLineId, userId.value())); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{orderId}/purchase-confirm") + public ResponseEntity purchaseConfirmOrder( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId + ) { + purchaseConfirmOrderUseCase.purchaseConfirmOrder(toPurchaseConfirmCommand(orderId, userId.value())); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{orderId}/lines/{orderLineId}/complain") + public ResponseEntity complainOrderLine( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId, + @PathVariable Long orderLineId + ) { + complainOrderLineUseCase.complainOrderLine(orderId, orderLineId, userId.value()); + return ResponseEntity.noContent().build(); + } + + private ConfirmOrderCommand toConfirmCommand(Long orderId, Long sellerId) { + return ConfirmOrderCommand.builder() + .orderId(orderId) + .sellerId(sellerId) + .build(); + } + + private PurchaseConfirmOrderCommand toPurchaseConfirmCommand(Long orderId, Long customerId) { + return PurchaseConfirmOrderCommand.builder() + .orderId(orderId) + .customerId(customerId) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderQueryController.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderQueryController.java new file mode 100644 index 00000000..9be4c93e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/OrderQueryController.java @@ -0,0 +1,48 @@ +package kr.magicbox.order.adapter.in.web; + +import kr.magicbox.order.adapter.in.web.dto.response.OrderResponse; +import kr.magicbox.order.application.dto.query.GetOrderListQuery; +import kr.magicbox.order.application.dto.query.GetOrderQuery; +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.application.port.in.GetOrderListUseCase; +import kr.magicbox.order.application.port.in.GetOrderUseCase; +import kr.magicbox.order.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/order") +@RequiredArgsConstructor +public class OrderQueryController { + + private final GetOrderUseCase getOrderUseCase; + private final GetOrderListUseCase getOrderListUseCase; + + @GetMapping("/{orderId}") + public ResponseEntity getOrder( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderId + ) { + OrderResult result = getOrderUseCase.getOrder(GetOrderQuery.builder() + .orderId(orderId) + .requesterId(userId.value()) + .build()); + return ResponseEntity.ok(OrderResponse.from(result)); + } + + @GetMapping + public ResponseEntity> getOrderList( + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) Long sellerId + ) { + List results = getOrderListUseCase.getOrderList(GetOrderListQuery.builder() + .customerId(customerId) + .sellerId(sellerId) + .build()); + return ResponseEntity.ok(results.stream().map(OrderResponse::from).toList()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CancelOrderRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CancelOrderRequest.java new file mode 100644 index 00000000..8a48b006 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CancelOrderRequest.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.constraints.NotBlank; +import kr.magicbox.order.application.dto.command.CancelOrderCommand; + +public record CancelOrderRequest( + @NotBlank(message = "취소 사유는 필수입니다.") String reason +) { + public CancelOrderCommand toCommand(Long orderId, Long orderLineId, Long customerId) { + return CancelOrderCommand.builder() + .orderId(orderId) + .orderLineId(orderLineId) + .customerId(customerId) + .reason(reason) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java new file mode 100644 index 00000000..132b5cbf --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java @@ -0,0 +1,36 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import kr.magicbox.order.application.dto.command.CreateOrderCommand; + +import java.util.List; + +public record CreateOrderRequest( + @NotNull(message = "판매자 ID는 필수입니다.") @Positive(message = "판매자 ID는 양수여야 합니다.") Long sellerId, + @NotNull(message = "총 금액은 필수입니다.") @Positive(message = "총 금액은 양수여야 합니다.") Long totalAmount, + @Valid @NotNull(message = "배송지 정보는 필수입니다.") ShippingAddressRequest shippingAddress, + @NotEmpty(message = "주문 항목은 하나 이상이어야 합니다.") List<@Valid OrderLineRequest> orderLines +) { + public CreateOrderCommand toCommand(Long customerId) { + List lineCommands = orderLines.stream() + .map(line -> CreateOrderCommand.OrderLineCommand.builder() + .productId(line.productId()) + .sellerId(line.sellerId()) + .productName(line.productName()) + .quantity(line.quantity()) + .unitPrice(line.unitPrice()) + .build()) + .toList(); + + return CreateOrderCommand.builder() + .customerId(customerId) + .sellerId(sellerId) + .totalAmount(totalAmount) + .shippingAddress(shippingAddress.toDomain()) + .orderLines(lineCommands) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java new file mode 100644 index 00000000..f5a3798a --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java @@ -0,0 +1,28 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import kr.magicbox.order.application.dto.command.CreateReleaseOrderCommand; + +public record CreateReleaseOrderRequest( + @NotNull(message = "판매자 ID는 필수입니다.") @Positive(message = "판매자 ID는 양수여야 합니다.") Long sellerId, + @NotNull(message = "릴리즈 ID는 필수입니다.") @Positive(message = "릴리즈 ID는 양수여야 합니다.") Long releaseId, + @NotBlank(message = "구매 토큰은 필수입니다.") String purchaseToken, + @NotBlank(message = "상품명은 필수입니다.") String productName, + @NotNull(message = "단가는 필수입니다.") @Positive(message = "단가는 양수여야 합니다.") Long unitPrice, + @Valid @NotNull(message = "배송지 정보는 필수입니다.") ShippingAddressRequest shippingAddress +) { + public CreateReleaseOrderCommand toCommand(Long customerId) { + return CreateReleaseOrderCommand.builder() + .customerId(customerId) + .sellerId(sellerId) + .releaseId(releaseId) + .purchaseToken(purchaseToken) + .productName(productName) + .unitPrice(unitPrice) + .shippingAddress(shippingAddress.toDomain()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java new file mode 100644 index 00000000..3f329b79 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record OrderLineRequest( + @NotNull(message = "상품 ID는 필수입니다.") @Positive(message = "상품 ID는 양수여야 합니다.") Long productId, + @NotNull(message = "판매자 ID는 필수입니다.") @Positive(message = "판매자 ID는 양수여야 합니다.") Long sellerId, + @NotBlank(message = "상품명은 필수입니다.") String productName, + @NotNull(message = "수량은 필수입니다.") @Min(value = 1, message = "수량은 1 이상이어야 합니다.") Integer quantity, + @NotNull(message = "단가는 필수입니다.") @Min(value = 0, message = "단가는 0 이상이어야 합니다.") Long unitPrice +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/ShippingAddressRequest.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/ShippingAddressRequest.java new file mode 100644 index 00000000..7b144686 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/ShippingAddressRequest.java @@ -0,0 +1,16 @@ +package kr.magicbox.order.adapter.in.web.dto.request; + +import jakarta.validation.constraints.NotBlank; +import kr.magicbox.order.domain.vo.ShippingAddress; + +public record ShippingAddressRequest( + @NotBlank(message = "수령인은 필수입니다.") String recipient, + @NotBlank(message = "전화번호는 필수입니다.") String phone, + @NotBlank(message = "우편번호는 필수입니다.") String zipCode, + @NotBlank(message = "도로명 주소는 필수입니다.") String address1, + String address2 +) { + public ShippingAddress toDomain() { + return ShippingAddress.of(recipient, phone, zipCode, address1, address2); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java new file mode 100644 index 00000000..f5552a45 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.web.dto.response; + +import kr.magicbox.order.application.dto.result.OrderLineResult; +import lombok.Builder; + +@Builder +public record OrderLineResponse( + Long orderLineId, + Long productId, + String productName, + int quantity, + long unitPrice +) { + public static OrderLineResponse from(OrderLineResult result) { + return OrderLineResponse.builder() + .orderLineId(result.orderLineId()) + .productId(result.productId()) + .productName(result.productName()) + .quantity(result.quantity()) + .unitPrice(result.unitPrice()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderResponse.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderResponse.java new file mode 100644 index 00000000..4e95270a --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderResponse.java @@ -0,0 +1,38 @@ +package kr.magicbox.order.adapter.in.web.dto.response; + +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.domain.enums.OrderStatus; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderResponse( + Long orderId, + Long customerId, + Long sellerId, + OrderStatus status, + long totalAmount, + ShippingAddressResponse shippingAddress, + List orderLines, + Instant createdAt +) { + public static OrderResponse from(OrderResult result) { + List lineResponses = result.orderLines().stream() + .map(OrderLineResponse::from) + .toList(); + + return OrderResponse.builder() + .orderId(result.orderId()) + .customerId(result.customerId()) + .sellerId(result.sellerId()) + .status(result.status()) + .totalAmount(result.totalAmount()) + .shippingAddress(ShippingAddressResponse.from(result.shippingAddress())) + .orderLines(lineResponses) + .createdAt(result.createdAt()) + .build(); + } +} + diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/ShippingAddressResponse.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/ShippingAddressResponse.java new file mode 100644 index 00000000..19f3e46b --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/ShippingAddressResponse.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.adapter.in.web.dto.response; + +import kr.magicbox.order.application.dto.result.ShippingAddressResult; +import lombok.Builder; + +@Builder +public record ShippingAddressResponse( + String recipient, + String phone, + String zipCode, + String address1, + String address2 +) { + public static ShippingAddressResponse from(ShippingAddressResult result) { + return ShippingAddressResponse.builder() + .recipient(result.recipient()) + .phone(result.phone()) + .zipCode(result.zipCode()) + .address1(result.address1()) + .address2(result.address2()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/ErrorResponse.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/ErrorResponse.java new file mode 100644 index 00000000..00171491 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/ErrorResponse.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.adapter.in.web.exception.handler; + +import lombok.Builder; +import org.springframework.http.HttpStatus; + +@Builder +public record ErrorResponse(int status, String message) { + public static ErrorResponse of(HttpStatus status, String message) { + return ErrorResponse.builder() + .status(status.value()) + .message(message) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/GlobalExceptionHandler.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..a51ba7e5 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,87 @@ +package kr.magicbox.order.adapter.in.web.exception.handler; + +import jakarta.validation.ConstraintViolationException; +import kr.magicbox.order.global.exception.BaseException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException() { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ErrorResponse.of(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다.")); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("요청 본문을 읽을 수 없습니다: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, "요청 본문을 읽을 수 없습니다.")); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String errorMessage = e.getBindingResult().getFieldError() != null ? + e.getBindingResult().getFieldError().getDefaultMessage() : "인자값이 유효하지 않습니다."; + log.error("요청 데이터 유효성 검증 실패: {}", errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, errorMessage)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { + String errorMessage = e.getConstraintViolations().isEmpty() ? + "유효성 검증에 실패했습니다." : + e.getConstraintViolations().iterator().next().getMessage(); + log.error("유효성 검증 실패: {}", errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, errorMessage)); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.error("지원되지 않는 HTTP 메서드: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED, "지원되지 않는 HTTP 메서드입니다.")); + } + + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + public ResponseEntity handleOptimisticLockingFailureException(ObjectOptimisticLockingFailureException e) { + log.warn("동시 수정 충돌 발생: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ErrorResponse.of(HttpStatus.CONFLICT, "다른 요청과 충돌이 발생했습니다. 다시 시도해주세요.")); + } + + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(BaseException e) { + HttpStatus status = e.getStatus(); + return ResponseEntity + .status(status) + .body(ErrorResponse.of(status, e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("예상하지 못한 오류 발생: {}", e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 오류가 발생했습니다.")); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java new file mode 100644 index 00000000..5c424ce6 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/ServiceHost.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.out.communication; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ServiceHost { + WAITING("waiting-service"); + + private final String hostName; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java new file mode 100644 index 00000000..e9009385 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/GrpcConfiguration.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.adapter.out.communication.grpc; + +import io.grpc.ManagedChannel; +import kr.magicbox.order.adapter.out.communication.ServiceHost; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.GrpcChannelFactory; + +@Configuration +public class GrpcConfiguration { + + @Bean + public ManagedChannel waitingManagedChannel(GrpcChannelFactory grpcChannelFactory) { + return grpcChannelFactory.createChannel(ServiceHost.WAITING.getHostName()); + } + +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java new file mode 100644 index 00000000..941ad756 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/WaitingGrpcAdapter.java @@ -0,0 +1,43 @@ +package kr.magicbox.order.adapter.out.communication.grpc; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.grpc.ManagedChannel; +import kr.magicbox.order.adapter.out.communication.grpc.exception.WaitingServiceUnavailableException; +import kr.magicbox.order.application.port.out.PurchaseTokenValidationPort; +import kr.magicbox.order.grpc.waiting.ValidatePurchaseTokenRequest; +import kr.magicbox.order.grpc.waiting.ValidatePurchaseTokenResponse; +import kr.magicbox.order.grpc.waiting.WaitingServiceGrpc; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WaitingGrpcAdapter implements PurchaseTokenValidationPort { + + private final ManagedChannel waitingManagedChannel; + + @Override + @CircuitBreaker(name = "waitingService", fallbackMethod = "validateFallback") + public boolean validate(Long releaseId, Long userId, String purchaseToken) { + ValidatePurchaseTokenRequest request = ValidatePurchaseTokenRequest.newBuilder() + .setReleaseId(releaseId) + .setUserId(userId) + .setPurchaseToken(purchaseToken) + .build(); + + WaitingServiceGrpc.WaitingServiceBlockingStub stub = WaitingServiceGrpc.newBlockingStub(waitingManagedChannel) + .withDeadlineAfter(2, TimeUnit.SECONDS); + ValidatePurchaseTokenResponse response = stub.validatePurchaseToken(request); + + return response.getValid(); + } + + @SuppressWarnings("unused") + private boolean validateFallback(Long releaseId, Long userId, String purchaseToken, Throwable throwable) { + log.warn("대기열 서비스 연결 실패"); + throw new WaitingServiceUnavailableException(throwable); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/WaitingServiceUnavailableException.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/WaitingServiceUnavailableException.java new file mode 100644 index 00000000..1e24c850 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/communication/grpc/exception/WaitingServiceUnavailableException.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.out.communication.grpc.exception; + +import kr.magicbox.order.global.exception.SystemError; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class WaitingServiceUnavailableException extends SystemError { + + public WaitingServiceUnavailableException(Throwable cause) { + super("대기열 서비스에 연결할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE, cause); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java new file mode 100644 index 00000000..a53ba462 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderJpaAdapter.java @@ -0,0 +1,112 @@ +package kr.magicbox.order.adapter.out.persistence; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderEntity; +import kr.magicbox.order.adapter.out.persistence.entity.OrderLineEntity; +import kr.magicbox.order.adapter.out.persistence.mapper.OrderMapper; +import kr.magicbox.order.adapter.out.persistence.repository.OrderJpaRepository; +import kr.magicbox.order.adapter.out.persistence.repository.OrderLineJpaRepository; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import kr.magicbox.order.domain.enums.OrderStatus; +import kr.magicbox.order.domain.exception.OrderNotFoundException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class OrderJpaAdapter implements OrderRepositoryPort { + + private final OrderJpaRepository orderJpaRepository; + private final OrderLineJpaRepository orderLineJpaRepository; + private final OrderMapper orderMapper; + + @Override + public Long save(Order order) { + OrderEntity orderEntity = orderJpaRepository.save(orderMapper.toEntity(order)); + List orderLines = order.getOrderLines(); + orderLines.forEach(line -> + orderLineJpaRepository.save(orderMapper.toLineEntity(orderEntity.getId(), line)) + ); + return orderEntity.getId(); + } + + @Override + public void update(Order order) { + OrderEntity entity = orderJpaRepository.findByIdAndIsDeletedFalse(order.getId().value()) + .orElseThrow(OrderNotFoundException::new); + entity.update(order.getStatus()); + orderJpaRepository.save(entity); + + List lineEntities = orderLineJpaRepository.findByOrderId(entity.getId()); + Map lineEntityById = lineEntities.stream() + .collect(Collectors.toMap(OrderLineEntity::getId, l -> l)); + order.getOrderLines().forEach(line -> { + if (line.getId() != null) { + OrderLineEntity lineEntity = lineEntityById.get(line.getId().value()); + if (lineEntity != null) { + lineEntity.updateDeliveryStatus(line.getDeliveryStatus()); + orderLineJpaRepository.save(lineEntity); + } + } + }); + } + + @Override + public Order findById(OrderId id) { + OrderEntity entity = orderJpaRepository.findByIdAndIsDeletedFalse(id.value()) + .orElseThrow(OrderNotFoundException::new); + List lineEntities = orderLineJpaRepository.findByOrderId(entity.getId()); + return orderMapper.toDomain(entity, lineEntities); + } + + @Override + public Order findByOrderLineId(Long orderLineId) { + OrderLineEntity lineEntity = orderLineJpaRepository.findByOrderLineId(orderLineId) + .orElseThrow(OrderNotFoundException::new); + OrderEntity orderEntity = orderJpaRepository.findByIdAndIsDeletedFalse(lineEntity.getOrderId()) + .orElseThrow(OrderNotFoundException::new); + List allLineEntities = orderLineJpaRepository.findByOrderId(orderEntity.getId()); + return orderMapper.toDomain(orderEntity, allLineEntities); + } + + @Override + public List findByCustomerId(Long customerId) { + List orders = orderJpaRepository.findByCustomerIdAndIsDeletedFalse(customerId); + return toDomainsWithLines(orders); + } + + @Override + public List findBySellerId(Long sellerId) { + List orders = orderJpaRepository.findBySellerIdAndIsDeletedFalse(sellerId); + return toDomainsWithLines(orders); + } + + @Override + public List findDeliveredBefore(Instant deliveredBefore, int limit) { + List orders = orderJpaRepository.findByStatusAndUpdatedAtBeforeAndIsDeletedFalse( + OrderStatus.DELIVERED, deliveredBefore, PageRequest.of(0, limit)); + return toDomainsWithLines(orders); + } + + private List toDomainsWithLines(List orders) { + if (orders.isEmpty()) { + return List.of(); + } + List orderIds = orders.stream().map(OrderEntity::getId).toList(); + Map> linesByOrderId = orderLineJpaRepository.findByOrderIdIn(orderIds) + .stream() + .collect(Collectors.groupingBy(OrderLineEntity::getOrderId)); + + return orders.stream() + .map(entity -> orderMapper.toDomain(entity, linesByOrderId.getOrDefault(entity.getId(), List.of()))) + .toList(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderOutboxAdapter.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderOutboxAdapter.java new file mode 100644 index 00000000..481b27fa --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/OrderOutboxAdapter.java @@ -0,0 +1,26 @@ +package kr.magicbox.order.adapter.out.persistence; + +import tools.jackson.databind.ObjectMapper; +import kr.magicbox.order.adapter.out.persistence.entity.OrderOutboxEntity; +import kr.magicbox.order.adapter.out.persistence.repository.OrderOutboxJpaRepository; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.domain.event.OrderDomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrderOutboxAdapter implements OrderOutboxPort { + + private final OrderOutboxJpaRepository orderOutboxJpaRepository; + private final ObjectMapper objectMapper; + + @Override + public void save(OrderDomainEvent event) { + String payload = objectMapper.writeValueAsString(event); + orderOutboxJpaRepository.save(OrderOutboxEntity.builder() + .eventType(event.eventType().getValue()) + .payload(payload) + .build()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/configuration/JpaConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/configuration/JpaConfiguration.java new file mode 100644 index 00000000..91a6cb99 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/configuration/JpaConfiguration.java @@ -0,0 +1,9 @@ +package kr.magicbox.order.adapter.out.persistence.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfiguration { +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/BaseEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/BaseEntity.java new file mode 100644 index 00000000..a39bc548 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/BaseEntity.java @@ -0,0 +1,30 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import com.github.lian2945.sonyflake.annotation.SonyflakeId; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + @Id + @SonyflakeId + private Long id; + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderEntity.java new file mode 100644 index 00000000..d1542f70 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderEntity.java @@ -0,0 +1,71 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import kr.magicbox.order.domain.enums.OrderStatus; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "orders") +public class OrderEntity extends BaseEntity { + + @Column(name = "customer_id", nullable = false) + private Long customerId; + + @Column(name = "seller_id", nullable = false) + private Long sellerId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "recipient", nullable = false) + private String recipient; + + @Column(name = "phone", nullable = false) + private String phone; + + @Column(name = "zip_code", nullable = false) + private String zipCode; + + @Column(name = "address1", nullable = false) + private String address1; + + @Column(name = "address2") + private String address2; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Version + private Integer version; + + @Builder + public OrderEntity(Long customerId, Long sellerId, OrderStatus status, Long totalAmount, + String recipient, String phone, String zipCode, String address1, String address2) { + this.customerId = customerId; + this.sellerId = sellerId; + this.status = status; + this.totalAmount = totalAmount; + this.recipient = recipient; + this.phone = phone; + this.zipCode = zipCode; + this.address1 = address1; + this.address2 = address2; + this.isDeleted = false; + } + + public void update(OrderStatus status) { + this.status = status; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxEntity.java new file mode 100644 index 00000000..b4051104 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "order_inbox") +public class OrderInboxEntity extends BaseEntity { + + @Column(nullable = false, unique = true) + private Long eventId; + + @Column(nullable = false) + private String topic; + + @Column(name = "kafka_partition", nullable = false) + private Integer partition; + + @Column(name = "kafka_offset", nullable = false) + private Long offset; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderInboxStatus status; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Builder + public OrderInboxEntity(Long eventId, String topic, Integer partition, Long offset, OrderInboxStatus status, Instant occurredAt) { + this.eventId = eventId; + this.topic = topic; + this.partition = partition; + this.offset = offset; + this.status = status; + this.occurredAt = occurredAt; + } + + public void markProcessed() { + this.status = OrderInboxStatus.PROCESSED; + } + + public void markDeadLettered() { + this.status = OrderInboxStatus.DEAD_LETTERED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxStatus.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxStatus.java new file mode 100644 index 00000000..517693a3 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderInboxStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +public enum OrderInboxStatus { + PENDING, + PROCESSED, + DEAD_LETTERED +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java new file mode 100644 index 00000000..9e5853b0 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java @@ -0,0 +1,56 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import kr.magicbox.order.domain.enums.OrderLineDeliveryStatus; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "order_line") +public class OrderLineEntity extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "seller_id", nullable = false) + private Long sellerId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Column(name = "unit_price", nullable = false) + private Long unitPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "delivery_status", nullable = false) + private OrderLineDeliveryStatus deliveryStatus; + + @Builder + public OrderLineEntity(Long orderId, Long productId, Long sellerId, String productName, Integer quantity, Long unitPrice, OrderLineDeliveryStatus deliveryStatus) { + this.orderId = orderId; + this.productId = productId; + this.sellerId = sellerId; + this.productName = productName; + this.quantity = quantity; + this.unitPrice = unitPrice; + this.deliveryStatus = deliveryStatus != null ? deliveryStatus : OrderLineDeliveryStatus.PENDING; + } + + public void updateDeliveryStatus(OrderLineDeliveryStatus deliveryStatus) { + this.deliveryStatus = deliveryStatus; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderOutboxEntity.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderOutboxEntity.java new file mode 100644 index 00000000..75b34b42 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderOutboxEntity.java @@ -0,0 +1,28 @@ +package kr.magicbox.order.adapter.out.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "order_outbox") +public class OrderOutboxEntity extends BaseEntity { + + @Column(nullable = false) + private String eventType; + + @Column(nullable = false, columnDefinition = "JSON") + private String payload; + + @Builder + public OrderOutboxEntity(String eventType, String payload) { + this.eventType = eventType; + this.payload = payload; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java new file mode 100644 index 00000000..683ce512 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java @@ -0,0 +1,79 @@ +package kr.magicbox.order.adapter.out.persistence.mapper; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderEntity; +import kr.magicbox.order.adapter.out.persistence.entity.OrderLineEntity; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import kr.magicbox.order.domain.vo.OrderId; +import kr.magicbox.order.domain.vo.OrderLineId; +import kr.magicbox.order.domain.vo.ShippingAddress; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class OrderMapper { + + public OrderEntity toEntity(Order domain) { + ShippingAddress addr = domain.getShippingAddress(); + return OrderEntity.builder() + .customerId(domain.getCustomerId()) + .sellerId(domain.getSellerId()) + .status(domain.getStatus()) + .totalAmount(domain.getTotalAmount()) + .recipient(addr.recipient()) + .phone(addr.phone()) + .zipCode(addr.zipCode()) + .address1(addr.address1()) + .address2(addr.address2()) + .build(); + } + + public OrderLineEntity toLineEntity(Long orderId, OrderLine domain) { + return OrderLineEntity.builder() + .orderId(orderId) + .productId(domain.getProductId()) + .sellerId(domain.getSellerId()) + .productName(domain.getProductName()) + .quantity(domain.getQuantity()) + .unitPrice(domain.getUnitPrice()) + .deliveryStatus(domain.getDeliveryStatus()) + .build(); + } + + public Order toDomain(OrderEntity entity, List lineEntities) { + List orderLines = lineEntities.stream() + .map(this::toLineDomain) + .toList(); + + return Order.reconstructBuilder() + .id(OrderId.of(entity.getId())) + .customerId(entity.getCustomerId()) + .sellerId(entity.getSellerId()) + .status(entity.getStatus()) + .totalAmount(entity.getTotalAmount()) + .shippingAddress(ShippingAddress.of( + entity.getRecipient(), + entity.getPhone(), + entity.getZipCode(), + entity.getAddress1(), + entity.getAddress2() + )) + .orderLines(orderLines) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + private OrderLine toLineDomain(OrderLineEntity entity) { + return OrderLine.reconstructBuilder() + .id(OrderLineId.of(entity.getId())) + .productId(entity.getProductId()) + .sellerId(entity.getSellerId()) + .productName(entity.getProductName()) + .quantity(entity.getQuantity()) + .unitPrice(entity.getUnitPrice()) + .deliveryStatus(entity.getDeliveryStatus()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderInboxJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderInboxJpaRepository.java new file mode 100644 index 00000000..f8921902 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderInboxJpaRepository.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.adapter.out.persistence.repository; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface OrderInboxJpaRepository extends JpaRepository { + + @Query("SELECT CASE WHEN EXISTS (SELECT i FROM OrderInboxEntity i WHERE i.eventId = :eventId) THEN true ELSE false END") + boolean existsByEventId(@Param("eventId") Long eventId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java new file mode 100644 index 00000000..b100c019 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderJpaRepository.java @@ -0,0 +1,27 @@ +package kr.magicbox.order.adapter.out.persistence.repository; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderEntity; +import kr.magicbox.order.domain.enums.OrderStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OrderEntity o WHERE o.id = :id AND o.isDeleted = false") + Optional findByIdAndIsDeletedFalse(@Param("id") Long id); + + @Query("SELECT o FROM OrderEntity o WHERE o.customerId = :customerId AND o.isDeleted = false") + List findByCustomerIdAndIsDeletedFalse(@Param("customerId") Long customerId); + + @Query("SELECT o FROM OrderEntity o WHERE o.sellerId = :sellerId AND o.isDeleted = false") + List findBySellerIdAndIsDeletedFalse(@Param("sellerId") Long sellerId); + + @Query("SELECT o FROM OrderEntity o WHERE o.status = :status AND o.updatedAt < :before AND o.isDeleted = false") + List findByStatusAndUpdatedAtBeforeAndIsDeletedFalse(@Param("status") OrderStatus status, @Param("before") Instant before, Pageable pageable); +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderLineJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderLineJpaRepository.java new file mode 100644 index 00000000..50683227 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderLineJpaRepository.java @@ -0,0 +1,21 @@ +package kr.magicbox.order.adapter.out.persistence.repository; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderLineEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface OrderLineJpaRepository extends JpaRepository { + + @Query("SELECT ol FROM OrderLineEntity ol WHERE ol.orderId = :orderId") + List findByOrderId(@Param("orderId") Long orderId); + + @Query("SELECT ol FROM OrderLineEntity ol WHERE ol.orderId IN :orderIds") + List findByOrderIdIn(@Param("orderIds") List orderIds); + + @Query("SELECT ol FROM OrderLineEntity ol WHERE ol.id = :orderLineId") + Optional findByOrderLineId(@Param("orderLineId") Long orderLineId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderOutboxJpaRepository.java b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderOutboxJpaRepository.java new file mode 100644 index 00000000..a6aec1fc --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/repository/OrderOutboxJpaRepository.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.adapter.out.persistence.repository; + +import kr.magicbox.order.adapter.out.persistence.entity.OrderOutboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderOutboxJpaRepository extends JpaRepository { +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/CancelOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CancelOrderCommand.java new file mode 100644 index 00000000..2d515e97 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CancelOrderCommand.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.command; + +import lombok.Builder; + +@Builder +public record CancelOrderCommand(Long orderId, Long orderLineId, Long customerId, String reason) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/ConfirmOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/ConfirmOrderCommand.java new file mode 100644 index 00000000..5a3b9cab --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/ConfirmOrderCommand.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.command; + +import lombok.Builder; + +@Builder +public record ConfirmOrderCommand(Long orderId, Long sellerId) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java new file mode 100644 index 00000000..97e9c887 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.dto.command; + +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +import java.util.List; + +@Builder +public record CreateOrderCommand( + Long customerId, + Long sellerId, + Long totalAmount, + ShippingAddress shippingAddress, + List orderLines +) { + @Builder + public record OrderLineCommand( + Long productId, + Long sellerId, + String productName, + Integer quantity, + Long unitPrice + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java new file mode 100644 index 00000000..50181c10 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java @@ -0,0 +1,15 @@ +package kr.magicbox.order.application.dto.command; + +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +@Builder +public record CreateReleaseOrderCommand( + Long customerId, + Long sellerId, + Long releaseId, + String purchaseToken, + String productName, + Long unitPrice, + ShippingAddress shippingAddress +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/command/PurchaseConfirmOrderCommand.java b/services/order/src/main/java/kr/magicbox/order/application/dto/command/PurchaseConfirmOrderCommand.java new file mode 100644 index 00000000..0abae999 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/PurchaseConfirmOrderCommand.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.command; + +import lombok.Builder; + +@Builder +public record PurchaseConfirmOrderCommand(Long orderId, Long customerId) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderListQuery.java b/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderListQuery.java new file mode 100644 index 00000000..7bf7a40c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderListQuery.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.query; + +import lombok.Builder; + +@Builder +public record GetOrderListQuery(Long customerId, Long sellerId) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderQuery.java b/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderQuery.java new file mode 100644 index 00000000..8a23daa3 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/query/GetOrderQuery.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.dto.query; + +import lombok.Builder; + +@Builder +public record GetOrderQuery(Long orderId, Long requesterId) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java new file mode 100644 index 00000000..619f8acf --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.application.dto.result; + +import lombok.Builder; + +@Builder +public record OrderLineResult( + Long orderLineId, + Long productId, + String productName, + int quantity, + long unitPrice +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderResult.java b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderResult.java new file mode 100644 index 00000000..2baf2608 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderResult.java @@ -0,0 +1,19 @@ +package kr.magicbox.order.application.dto.result; + +import kr.magicbox.order.domain.enums.OrderStatus; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderResult( + Long orderId, + Long customerId, + Long sellerId, + OrderStatus status, + long totalAmount, + ShippingAddressResult shippingAddress, + List orderLines, + Instant createdAt +) {} diff --git a/services/order/src/main/java/kr/magicbox/order/application/dto/result/ShippingAddressResult.java b/services/order/src/main/java/kr/magicbox/order/application/dto/result/ShippingAddressResult.java new file mode 100644 index 00000000..9a21b092 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/result/ShippingAddressResult.java @@ -0,0 +1,23 @@ +package kr.magicbox.order.application.dto.result; + +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +@Builder +public record ShippingAddressResult( + String recipient, + String phone, + String zipCode, + String address1, + String address2 +) { + public static ShippingAddressResult from(ShippingAddress domain) { + return ShippingAddressResult.builder() + .recipient(domain.recipient()) + .phone(domain.phone()) + .zipCode(domain.zipCode()) + .address1(domain.address1()) + .address2(domain.address2()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/AutoConfirmOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/AutoConfirmOrderUseCase.java new file mode 100644 index 00000000..8e114a98 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/AutoConfirmOrderUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface AutoConfirmOrderUseCase { + void autoConfirmDeliveredOrders(); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/CancelOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/CancelOrderUseCase.java new file mode 100644 index 00000000..73431f36 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/CancelOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.CancelOrderCommand; + +public interface CancelOrderUseCase { + void cancelOrder(CancelOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/ComplainOrderLineUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/ComplainOrderLineUseCase.java new file mode 100644 index 00000000..11242400 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/ComplainOrderLineUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface ComplainOrderLineUseCase { + void complainOrderLine(Long orderId, Long orderLineId, Long customerId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderLineUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderLineUseCase.java new file mode 100644 index 00000000..f0c04b25 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderLineUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface ConfirmOrderLineUseCase { + void confirmOrderLine(Long orderId, Long orderLineId, Long sellerId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderUseCase.java new file mode 100644 index 00000000..5f7a15d5 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/ConfirmOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.ConfirmOrderCommand; + +public interface ConfirmOrderUseCase { + void confirmOrder(ConfirmOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateOrderUseCase.java new file mode 100644 index 00000000..afc50982 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.CreateOrderCommand; + +public interface CreateOrderUseCase { + void createOrder(CreateOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateReleaseOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateReleaseOrderUseCase.java new file mode 100644 index 00000000..60f3f656 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/CreateReleaseOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.CreateReleaseOrderCommand; + +public interface CreateReleaseOrderUseCase { + void createReleaseOrder(CreateReleaseOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderListUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderListUseCase.java new file mode 100644 index 00000000..271f2a0e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderListUseCase.java @@ -0,0 +1,10 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.query.GetOrderListQuery; +import kr.magicbox.order.application.dto.result.OrderResult; + +import java.util.List; + +public interface GetOrderListUseCase { + List getOrderList(GetOrderListQuery query); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderUseCase.java new file mode 100644 index 00000000..ade1ddef --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/GetOrderUseCase.java @@ -0,0 +1,8 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.query.GetOrderQuery; +import kr.magicbox.order.application.dto.result.OrderResult; + +public interface GetOrderUseCase { + OrderResult getOrder(GetOrderQuery query); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryCompletedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryCompletedUseCase.java new file mode 100644 index 00000000..b7fb8ee1 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryCompletedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleDeliveryCompletedUseCase { + void handleDeliveryCompleted(Long orderId, Long orderLineId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryStartedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryStartedUseCase.java new file mode 100644 index 00000000..dea41246 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleDeliveryStartedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleDeliveryStartedUseCase { + void handleDeliveryStarted(Long orderId, Long orderLineId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleOrderPrepareUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleOrderPrepareUseCase.java new file mode 100644 index 00000000..6ad65180 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleOrderPrepareUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleOrderPrepareUseCase { + void handleOrderPrepare(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelFailedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelFailedUseCase.java new file mode 100644 index 00000000..44cf7982 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelFailedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandlePaymentCancelFailedUseCase { + void handlePaymentCancelFailed(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelSucceededUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelSucceededUseCase.java new file mode 100644 index 00000000..e57a7e24 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentCancelSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandlePaymentCancelSucceededUseCase { + void handlePaymentCancelSucceeded(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentFailedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentFailedUseCase.java new file mode 100644 index 00000000..722ed873 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentFailedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandlePaymentFailedUseCase { + void handlePaymentFailed(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentSucceededUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentSucceededUseCase.java new file mode 100644 index 00000000..8c72f6be --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandlePaymentSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandlePaymentSucceededUseCase { + void handlePaymentSucceeded(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveFailedUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveFailedUseCase.java new file mode 100644 index 00000000..f8ad3209 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveFailedUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleStockReserveFailedUseCase { + void handleStockReserveFailed(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveSucceededUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveSucceededUseCase.java new file mode 100644 index 00000000..55148d54 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/HandleStockReserveSucceededUseCase.java @@ -0,0 +1,5 @@ +package kr.magicbox.order.application.port.in; + +public interface HandleStockReserveSucceededUseCase { + void handleStockReserveSucceeded(Long orderId); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/in/PurchaseConfirmOrderUseCase.java b/services/order/src/main/java/kr/magicbox/order/application/port/in/PurchaseConfirmOrderUseCase.java new file mode 100644 index 00000000..397ed075 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/in/PurchaseConfirmOrderUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.in; + +import kr.magicbox.order.application.dto.command.PurchaseConfirmOrderCommand; + +public interface PurchaseConfirmOrderUseCase { + void purchaseConfirmOrder(PurchaseConfirmOrderCommand command); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderOutboxPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderOutboxPort.java new file mode 100644 index 00000000..c166b966 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderOutboxPort.java @@ -0,0 +1,7 @@ +package kr.magicbox.order.application.port.out; + +import kr.magicbox.order.domain.event.OrderDomainEvent; + +public interface OrderOutboxPort { + void save(OrderDomainEvent event); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java new file mode 100644 index 00000000..127e3c1f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/out/OrderRepositoryPort.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.application.port.out; + +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; + +import java.time.Instant; +import java.util.List; + +public interface OrderRepositoryPort { + Long save(Order order); + void update(Order order); + Order findById(OrderId id); + Order findByOrderLineId(Long orderLineId); + List findByCustomerId(Long customerId); + List findBySellerId(Long sellerId); + List findDeliveredBefore(Instant deliveredBefore, int limit); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/port/out/PurchaseTokenValidationPort.java b/services/order/src/main/java/kr/magicbox/order/application/port/out/PurchaseTokenValidationPort.java new file mode 100644 index 00000000..f44e3d73 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/port/out/PurchaseTokenValidationPort.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.application.port.out; + +public interface PurchaseTokenValidationPort { + /** purchase_token 검증 및 소비 (1회용). 유효하지 않으면 false */ + boolean validate(Long releaseId, Long userId, String purchaseToken); +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java new file mode 100644 index 00000000..bb889905 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderChunkService.java @@ -0,0 +1,27 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderAutoConfirmedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoConfirmOrderChunkService { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + public void confirmOne(Order order) { + order.confirmPurchase(); + orderRepositoryPort.update(order); + OrderAutoConfirmedEvent.fromShippedLines(order).forEach(orderOutboxPort::save); + log.info("[AutoConfirm] 자동 구매 확정 처리. orderId={}", order.getId().value()); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java new file mode 100644 index 00000000..f26d1ed4 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/AutoConfirmOrderService.java @@ -0,0 +1,35 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.adapter.in.scheduler.properties.AutoConfirmProperties; +import kr.magicbox.order.application.port.in.AutoConfirmOrderUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoConfirmOrderService implements AutoConfirmOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final AutoConfirmOrderChunkService autoConfirmOrderChunkService; + private final AutoConfirmProperties autoConfirmProperties; + + @Override + public void autoConfirmDeliveredOrders() { + Instant threshold = Instant.now().minus(autoConfirmProperties.getDays(), ChronoUnit.DAYS); + int chunkSize = autoConfirmProperties.getChunkSize(); + + List chunk; + do { + chunk = orderRepositoryPort.findDeliveredBefore(threshold, chunkSize); + chunk.forEach(autoConfirmOrderChunkService::confirmOne); + } while (chunk.size() == chunkSize); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/CancelOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/CancelOrderService.java new file mode 100644 index 00000000..f3548d12 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CancelOrderService.java @@ -0,0 +1,40 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.CancelOrderCommand; +import kr.magicbox.order.application.port.in.CancelOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.enums.OrderStatus; +import kr.magicbox.order.domain.event.OrderCancelEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CancelOrderService implements CancelOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void cancelOrder(CancelOrderCommand command) { + Order order = orderRepositoryPort.findById(OrderId.of(command.orderId())); + + if (!order.getCustomerId().equals(command.customerId())) { + throw new OrderUnauthorizedException(); + } + + order.cancelOrderLine(command.orderLineId()); + orderRepositoryPort.update(order); + + // 모든 라인이 취소 요청 완료(Order CANCELLING)된 시점에 order-level 이벤트 1회 발행 + if (order.getStatus() == OrderStatus.CANCELLING) { + orderOutboxPort.save(OrderCancelEvent.from(order, command.reason())); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/ComplainOrderLineService.java b/services/order/src/main/java/kr/magicbox/order/application/service/ComplainOrderLineService.java new file mode 100644 index 00000000..83e6b453 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/ComplainOrderLineService.java @@ -0,0 +1,37 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.ComplainOrderLineUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderDeliveredEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ComplainOrderLineService implements ComplainOrderLineUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void complainOrderLine(Long orderId, Long orderLineId, Long customerId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + + if (!order.getCustomerId().equals(customerId)) { + throw new OrderUnauthorizedException(); + } + + order.complainOrderLine(orderLineId); + orderRepositoryPort.update(order); + + if (order.isAllDelivered()) { + orderOutboxPort.save(OrderDeliveredEvent.from(order)); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java new file mode 100644 index 00000000..698ea2ed --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java @@ -0,0 +1,39 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.ConfirmOrderLineUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.enums.OrderStatus; +import kr.magicbox.order.domain.event.OrderConfirmedEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ConfirmOrderLineService implements ConfirmOrderLineUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void confirmOrderLine(Long orderId, Long orderLineId, Long sellerId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + + if (!order.getSellerId().equals(sellerId)) { + throw new OrderUnauthorizedException(); + } + + order.confirmOrderLine(orderLineId); + orderRepositoryPort.update(order); + + // 모든 라인 CONFIRMED → Order CONFIRMED 전환 시 이벤트 발행 + if (order.getStatus() == OrderStatus.CONFIRMED) { + orderOutboxPort.save(OrderConfirmedEvent.from(order)); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderService.java new file mode 100644 index 00000000..63463325 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderService.java @@ -0,0 +1,35 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.ConfirmOrderCommand; +import kr.magicbox.order.application.port.in.ConfirmOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderConfirmedEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ConfirmOrderService implements ConfirmOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void confirmOrder(ConfirmOrderCommand command) { + Order order = orderRepositoryPort.findById(OrderId.of(command.orderId())); + + if (!order.getSellerId().equals(command.sellerId())) { + throw new OrderUnauthorizedException(); + } + + order.confirm(); + orderRepositoryPort.update(order); + orderOutboxPort.save(OrderConfirmedEvent.from(order)); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java new file mode 100644 index 00000000..b6157dda --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java @@ -0,0 +1,47 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.CreateOrderCommand; +import kr.magicbox.order.application.port.in.CreateOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import kr.magicbox.order.domain.event.OrderPrepareEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CreateOrderService implements CreateOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void createOrder(CreateOrderCommand command) { + List orderLines = command.orderLines().stream() + .map(line -> OrderLine.createBuilder() + .productId(line.productId()) + .sellerId(line.sellerId()) + .productName(line.productName()) + .quantity(line.quantity()) + .unitPrice(line.unitPrice()) + .build()) + .toList(); + + Order order = Order.createBuilder() + .customerId(command.customerId()) + .sellerId(command.sellerId()) + .totalAmount(command.totalAmount()) + .shippingAddress(command.shippingAddress()) + .orderLines(orderLines) + .build(); + + Long savedOrderId = orderRepositoryPort.save(order); + orderOutboxPort.save(OrderPrepareEvent.from(savedOrderId, order)); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java new file mode 100644 index 00000000..9042422b --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java @@ -0,0 +1,60 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.CreateReleaseOrderCommand; +import kr.magicbox.order.application.port.in.CreateReleaseOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.application.port.out.PurchaseTokenValidationPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import kr.magicbox.order.domain.event.OrderPrepareEvent; +import kr.magicbox.order.domain.event.ReleaseSoldQuantityIncreaseEvent; +import kr.magicbox.order.domain.exception.InvalidPurchaseTokenException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CreateReleaseOrderService implements CreateReleaseOrderUseCase { + + private final PurchaseTokenValidationPort purchaseTokenValidationPort; + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Override + public void createReleaseOrder(CreateReleaseOrderCommand command) { + boolean valid = purchaseTokenValidationPort.validate( + command.releaseId(), command.customerId(), command.purchaseToken()); + if (!valid) { + throw new InvalidPurchaseTokenException(); + } + + saveOrderWithOutbox(command); + } + + @Transactional + protected void saveOrderWithOutbox(CreateReleaseOrderCommand command) { + OrderLine orderLine = OrderLine.createBuilder() + .productId(command.releaseId()) + .sellerId(command.sellerId()) + .productName(command.productName()) + .quantity(1) + .unitPrice(command.unitPrice()) + .build(); + + Order order = Order.createBuilder() + .customerId(command.customerId()) + .sellerId(command.sellerId()) + .totalAmount(command.unitPrice()) + .shippingAddress(command.shippingAddress()) + .orderLines(List.of(orderLine)) + .build(); + + Long savedOrderId = orderRepositoryPort.save(order); + orderOutboxPort.save(OrderPrepareEvent.from(savedOrderId, order)); + orderOutboxPort.save(ReleaseSoldQuantityIncreaseEvent.of(command.releaseId())); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderListService.java b/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderListService.java new file mode 100644 index 00000000..50cb0b53 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderListService.java @@ -0,0 +1,32 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.query.GetOrderListQuery; +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.application.port.in.GetOrderListUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class GetOrderListService implements GetOrderListUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderResultMapper orderResultMapper; + + @Transactional(readOnly = true) + @Override + public List getOrderList(GetOrderListQuery query) { + List orders; + if (query.customerId() != null) { + orders = orderRepositoryPort.findByCustomerId(query.customerId()); + } else { + orders = orderRepositoryPort.findBySellerId(query.sellerId()); + } + return orders.stream().map(orderResultMapper::toResult).toList(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderService.java new file mode 100644 index 00000000..26ddf320 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/GetOrderService.java @@ -0,0 +1,32 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.query.GetOrderQuery; +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.application.port.in.GetOrderUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GetOrderService implements GetOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderResultMapper orderResultMapper; + + @Transactional(readOnly = true) + @Override + public OrderResult getOrder(GetOrderQuery query) { + Order order = orderRepositoryPort.findById(OrderId.of(query.orderId())); + + if (!order.getCustomerId().equals(query.requesterId()) && !order.getSellerId().equals(query.requesterId())) { + throw new OrderUnauthorizedException(); + } + + return orderResultMapper.toResult(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryCompletedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryCompletedService.java new file mode 100644 index 00000000..8819d9af --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryCompletedService.java @@ -0,0 +1,31 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleDeliveryCompletedUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderDeliveredEvent; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleDeliveryCompletedService implements HandleDeliveryCompletedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void handleDeliveryCompleted(Long orderId, Long orderLineId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.completeDelivery(orderLineId); + orderRepositoryPort.update(order); + + if (order.isAllDelivered()) { + orderOutboxPort.save(OrderDeliveredEvent.from(order)); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryStartedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryStartedService.java new file mode 100644 index 00000000..b4ab6fca --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleDeliveryStartedService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleDeliveryStartedUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleDeliveryStartedService implements HandleDeliveryStartedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handleDeliveryStarted(Long orderId, Long orderLineId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.startDelivery(orderLineId); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleOrderPrepareService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleOrderPrepareService.java new file mode 100644 index 00000000..067459ea --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleOrderPrepareService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleOrderPrepareUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleOrderPrepareService implements HandleOrderPrepareUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handleOrderPrepare(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.prepare(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelFailedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelFailedService.java new file mode 100644 index 00000000..18c1c321 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelFailedService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandlePaymentCancelFailedUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandlePaymentCancelFailedService implements HandlePaymentCancelFailedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handlePaymentCancelFailed(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.failCancellation(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelSucceededService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelSucceededService.java new file mode 100644 index 00000000..98463bb1 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentCancelSucceededService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandlePaymentCancelSucceededUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandlePaymentCancelSucceededService implements HandlePaymentCancelSucceededUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handlePaymentCancelSucceeded(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.completeCancellation(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentFailedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentFailedService.java new file mode 100644 index 00000000..1ee44bb3 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentFailedService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandlePaymentFailedUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandlePaymentFailedService implements HandlePaymentFailedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handlePaymentFailed(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.failPayment(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentSucceededService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentSucceededService.java new file mode 100644 index 00000000..bde1f717 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandlePaymentSucceededService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandlePaymentSucceededUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandlePaymentSucceededService implements HandlePaymentSucceededUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handlePaymentSucceeded(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.completePayment(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveFailedService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveFailedService.java new file mode 100644 index 00000000..415af1e9 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveFailedService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleStockReserveFailedUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleStockReserveFailedService implements HandleStockReserveFailedUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handleStockReserveFailed(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.failStock(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveSucceededService.java b/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveSucceededService.java new file mode 100644 index 00000000..d50c8678 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/HandleStockReserveSucceededService.java @@ -0,0 +1,24 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.port.in.HandleStockReserveSucceededUseCase; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HandleStockReserveSucceededService implements HandleStockReserveSucceededUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + + @Transactional + @Override + public void handleStockReserveSucceeded(Long orderId) { + Order order = orderRepositoryPort.findById(OrderId.of(orderId)); + order.reserveStock(); + orderRepositoryPort.update(order); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java b/services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java new file mode 100644 index 00000000..f1e518b2 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java @@ -0,0 +1,36 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.result.OrderLineResult; +import kr.magicbox.order.application.dto.result.OrderResult; +import kr.magicbox.order.application.dto.result.ShippingAddressResult; +import kr.magicbox.order.domain.aggregate.Order; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class OrderResultMapper { + + public OrderResult toResult(Order order) { + List lineResults = order.getOrderLines().stream() + .map(line -> OrderLineResult.builder() + .orderLineId(line.getId() != null ? line.getId().value() : null) + .productId(line.getProductId()) + .productName(line.getProductName()) + .quantity(line.getQuantity()) + .unitPrice(line.getUnitPrice()) + .build()) + .toList(); + + return OrderResult.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .sellerId(order.getSellerId()) + .status(order.getStatus()) + .totalAmount(order.getTotalAmount()) + .shippingAddress(ShippingAddressResult.from(order.getShippingAddress())) + .orderLines(lineResults) + .createdAt(order.getCreatedAt()) + .build(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/PurchaseConfirmOrderService.java b/services/order/src/main/java/kr/magicbox/order/application/service/PurchaseConfirmOrderService.java new file mode 100644 index 00000000..f4ae361c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/PurchaseConfirmOrderService.java @@ -0,0 +1,35 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.PurchaseConfirmOrderCommand; +import kr.magicbox.order.application.port.in.PurchaseConfirmOrderUseCase; +import kr.magicbox.order.application.port.out.OrderOutboxPort; +import kr.magicbox.order.application.port.out.OrderRepositoryPort; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.event.OrderPurchaseConfirmedEvent; +import kr.magicbox.order.domain.exception.OrderUnauthorizedException; +import kr.magicbox.order.domain.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PurchaseConfirmOrderService implements PurchaseConfirmOrderUseCase { + + private final OrderRepositoryPort orderRepositoryPort; + private final OrderOutboxPort orderOutboxPort; + + @Transactional + @Override + public void purchaseConfirmOrder(PurchaseConfirmOrderCommand command) { + Order order = orderRepositoryPort.findById(OrderId.of(command.orderId())); + + if (!order.getCustomerId().equals(command.customerId())) { + throw new OrderUnauthorizedException(); + } + + order.confirmPurchase(); + orderRepositoryPort.update(order); + OrderPurchaseConfirmedEvent.fromShippedLines(order).forEach(orderOutboxPort::save); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java new file mode 100644 index 00000000..17eb9548 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java @@ -0,0 +1,261 @@ +package kr.magicbox.order.domain.aggregate; + +import kr.magicbox.order.domain.enums.OrderLineDeliveryStatus; +import kr.magicbox.order.domain.enums.OrderStatus; +import kr.magicbox.order.domain.exception.InvalidFieldException; +import kr.magicbox.order.domain.exception.OrderLineComplainNotAllowedException; +import kr.magicbox.order.domain.exception.OrderStatusConflictException; +import kr.magicbox.order.domain.exception.OrderLineNotFoundException; +import kr.magicbox.order.domain.vo.OrderId; +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Getter +public class Order { + + private final OrderId id; + private final Long customerId; + private final Long sellerId; + private OrderStatus status; + private final Long totalAmount; + private final ShippingAddress shippingAddress; + private final List orderLines; + private final Instant createdAt; + private Instant updatedAt; + + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public Order(Long customerId, Long sellerId, Long totalAmount, + ShippingAddress shippingAddress, List orderLines) { + validateCreate(customerId, sellerId, totalAmount, shippingAddress); + this.id = null; + this.customerId = customerId; + this.sellerId = sellerId; + this.status = OrderStatus.PENDING; + this.totalAmount = totalAmount; + this.shippingAddress = shippingAddress; + this.orderLines = orderLines != null ? new ArrayList<>(orderLines) : new ArrayList<>(); + this.createdAt = Instant.now(); + this.updatedAt = Instant.now(); + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public Order(OrderId id, Long customerId, Long sellerId, OrderStatus status, + Long totalAmount, ShippingAddress shippingAddress, + List orderLines, Instant createdAt, Instant updatedAt) { + validateReconstruct(id, customerId, sellerId, status, totalAmount, shippingAddress, createdAt, updatedAt); + this.id = id; + this.customerId = customerId; + this.sellerId = sellerId; + this.status = status; + this.totalAmount = totalAmount; + this.shippingAddress = shippingAddress; + this.orderLines = orderLines != null ? new ArrayList<>(orderLines) : new ArrayList<>(); + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + private void validateCreate(Long customerId, Long sellerId, Long totalAmount, ShippingAddress shippingAddress) { + if (customerId == null || customerId <= 0) throw new InvalidFieldException("구매자 ID는 양수여야 합니다."); + if (sellerId == null || sellerId <= 0) throw new InvalidFieldException("판매자 ID는 양수여야 합니다."); + if (totalAmount == null || totalAmount < 0) throw new InvalidFieldException("총 금액은 0 이상이어야 합니다."); + if (shippingAddress == null) throw new InvalidFieldException("배송지 정보는 필수 값입니다."); + } + + private void validateReconstruct(OrderId id, Long customerId, Long sellerId, OrderStatus status, + Long totalAmount, ShippingAddress shippingAddress, + Instant createdAt, Instant updatedAt) { + if (id == null) throw new InvalidFieldException("주문 ID는 필수입니다."); + if (customerId == null || customerId <= 0) throw new InvalidFieldException("구매자 ID는 양수여야 합니다."); + if (sellerId == null || sellerId <= 0) throw new InvalidFieldException("판매자 ID는 양수여야 합니다."); + if (status == null) throw new InvalidFieldException("주문 상태는 필수입니다."); + if (totalAmount == null || totalAmount < 0) throw new InvalidFieldException("총 금액은 0 이상이어야 합니다."); + if (shippingAddress == null) throw new InvalidFieldException("배송지 정보는 필수입니다."); + if (createdAt == null) throw new InvalidFieldException("생성 시각은 필수입니다."); + if (updatedAt == null) throw new InvalidFieldException("수정 시각은 필수입니다."); + } + + public void reserveStock() { + validateStatus(OrderStatus.PENDING); + this.status = OrderStatus.STOCK_RESERVED; + this.updatedAt = Instant.now(); + } + + public void completePayment() { + validateStatus(OrderStatus.STOCK_RESERVED); + this.status = OrderStatus.PAYMENT_COMPLETED; + this.updatedAt = Instant.now(); + } + + /** + * 모든 OrderLine을 PREPARING 상태로 전환한다. + * Order도 PREPARING으로 전환한다. + */ + public void prepare() { + validateStatus(OrderStatus.PAYMENT_COMPLETED); + orderLines.forEach(OrderLine::prepare); + this.status = OrderStatus.PREPARING; + this.updatedAt = Instant.now(); + } + + /** + * 특정 OrderLine을 CONFIRMED 상태로 전환한다. + * 모든 라인이 CONFIRMED 이상이면 Order도 CONFIRMED로 전환한다. + */ + public void confirmOrderLine(Long orderLineId) { + if (this.status != OrderStatus.PREPARING && this.status != OrderStatus.CONFIRMED) { + throw new OrderStatusConflictException("현재 상태에서 확정 처리할 수 없습니다. 현재: " + this.status); + } + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.confirm(); + this.updatedAt = Instant.now(); + + if (isAllLinesAtLeast()) { + this.status = OrderStatus.CONFIRMED; + } + } + + /** + * 모든 OrderLine을 CONFIRMED 상태로 전환한다 (판매자 전체 확정). + * Order도 CONFIRMED로 전환한다. + */ + public void confirm() { + if (this.status != OrderStatus.PREPARING) { + throw new OrderStatusConflictException("현재 상태에서 확정 처리할 수 없습니다. 현재: " + this.status); + } + orderLines.forEach(OrderLine::confirm); + this.status = OrderStatus.CONFIRMED; + this.updatedAt = Instant.now(); + } + + /** + * 특정 OrderLine의 배송을 시작한다. + * 첫 번째 라인 배송 시작 시 Order를 DELIVERING으로 전환한다. + */ + public void startDelivery(Long orderLineId) { + if (this.status != OrderStatus.CONFIRMED && this.status != OrderStatus.DELIVERING) { + throw new OrderStatusConflictException("현재 상태에서 배송을 시작할 수 없습니다. 현재: " + this.status); + } + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.startDelivery(); + this.status = OrderStatus.DELIVERING; + this.updatedAt = Instant.now(); + } + + /** + * 특정 OrderLine의 배송을 완료한다. + * 모든 라인 SHIPPED → DELIVERED, 하나라도 COMPLAINING 포함 → COMPLAINING + */ + public void completeDelivery(Long orderLineId) { + validateStatus(OrderStatus.DELIVERING); + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.completeDelivery(); + this.updatedAt = Instant.now(); + updateDeliveryStatus(); + } + + /** + * 배달 중(SHIPPING) 상태의 OrderLine에 대해 미수령 신고를 처리한다. + * 모든 라인 완료 시 하나라도 COMPLAINING이면 Order → COMPLAINING, 전부 SHIPPED면 → DELIVERED + */ + public void complainOrderLine(Long orderLineId) { + if (this.status != OrderStatus.DELIVERING) { + throw new OrderLineComplainNotAllowedException(); + } + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.complain(); + this.updatedAt = Instant.now(); + updateDeliveryStatus(); + } + + public boolean isAllDelivered() { + return this.status == OrderStatus.DELIVERED || this.status == OrderStatus.COMPLAINING; + } + + private void updateDeliveryStatus() { + boolean allDone = orderLines.stream() + .allMatch(line -> line.getDeliveryStatus() == OrderLineDeliveryStatus.SHIPPED + || line.getDeliveryStatus() == OrderLineDeliveryStatus.COMPLAINING); + if (!allDone) return; + + boolean hasComplain = orderLines.stream() + .anyMatch(line -> line.getDeliveryStatus() == OrderLineDeliveryStatus.COMPLAINING); + this.status = hasComplain ? OrderStatus.COMPLAINING : OrderStatus.DELIVERED; + } + + public List shippedLines() { + return orderLines.stream() + .filter(line -> line.getDeliveryStatus() == OrderLineDeliveryStatus.SHIPPED) + .toList(); + } + + public void confirmPurchase() { + validateStatus(OrderStatus.DELIVERED); + this.status = OrderStatus.PURCHASE_CONFIRMED; + this.updatedAt = Instant.now(); + } + + /** + * 특정 OrderLine에 대해 취소를 요청한다. + * 모든 라인이 CANCEL_REQUESTED 상태가 되면 Order를 CANCELLING으로 전환한다. + */ + public void cancelOrderLine(Long orderLineId) { + if (this.status == OrderStatus.CANCELLED || this.status == OrderStatus.PURCHASE_CONFIRMED) { + throw new OrderStatusConflictException("취소할 수 없는 주문 상태입니다: " + this.status); + } + OrderLine orderLine = findOrderLine(orderLineId); + orderLine.requestCancel(); + this.updatedAt = Instant.now(); + + if (orderLines.stream().allMatch(OrderLine::isCancelRequested)) { + this.status = OrderStatus.CANCELLING; + } + } + + public void completeCancellation() { + validateStatus(OrderStatus.CANCELLING); + orderLines.forEach(OrderLine::completeCancellation); + this.status = OrderStatus.CANCELLED; + this.updatedAt = Instant.now(); + } + + public void failCancellation() { + validateStatus(OrderStatus.CANCELLING); + this.status = OrderStatus.CANCELLATION_FAILED; + this.updatedAt = Instant.now(); + } + + public void failStock() { + validateStatus(OrderStatus.PENDING); + this.status = OrderStatus.STOCK_FAILED; + this.updatedAt = Instant.now(); + } + + public void failPayment() { + validateStatus(OrderStatus.STOCK_RESERVED); + this.status = OrderStatus.PAYMENT_FAILED; + this.updatedAt = Instant.now(); + } + + private boolean isAllLinesAtLeast() { + return orderLines.stream().allMatch(line -> line.isAtLeast(OrderLineDeliveryStatus.CONFIRMED)); + } + + private OrderLine findOrderLine(Long orderLineId) { + return orderLines.stream() + .filter(line -> line.getId() != null && line.getId().value().equals(orderLineId)) + .findFirst() + .orElseThrow(OrderLineNotFoundException::new); + } + + private void validateStatus(OrderStatus expected) { + if (this.status != expected) { + throw new OrderStatusConflictException( + "현재 상태에서 해당 작업을 수행할 수 없습니다. 현재: " + this.status + ", 기대: " + expected); + } + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java new file mode 100644 index 00000000..38f3051c --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java @@ -0,0 +1,127 @@ +package kr.magicbox.order.domain.aggregate; + +import kr.magicbox.order.domain.enums.OrderLineDeliveryStatus; +import kr.magicbox.order.domain.exception.InvalidFieldException; +import kr.magicbox.order.domain.exception.OrderLineComplainNotAllowedException; +import kr.magicbox.order.domain.exception.OrderStatusConflictException; +import kr.magicbox.order.domain.vo.OrderLineId; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class OrderLine { + + private final OrderLineId id; + private final Long productId; + private final Long sellerId; + private final String productName; + private final Integer quantity; + private final Long unitPrice; + private OrderLineDeliveryStatus deliveryStatus; + + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public OrderLine(Long productId, Long sellerId, String productName, Integer quantity, Long unitPrice) { + validateCreate(productId, quantity, unitPrice); + this.id = null; + this.productId = productId; + this.sellerId = sellerId; + this.productName = productName; + this.quantity = quantity; + this.unitPrice = unitPrice; + this.deliveryStatus = OrderLineDeliveryStatus.PENDING; + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public OrderLine(OrderLineId id, Long productId, Long sellerId, String productName, + Integer quantity, Long unitPrice, OrderLineDeliveryStatus deliveryStatus) { + validateReconstruct(id, productId, sellerId, productName, quantity, unitPrice, deliveryStatus); + this.id = id; + this.productId = productId; + this.sellerId = sellerId; + this.productName = productName; + this.quantity = quantity; + this.unitPrice = unitPrice; + this.deliveryStatus = deliveryStatus; + } + + private void validateCreate(Long productId, Integer quantity, Long unitPrice) { + if (productId == null || productId <= 0) throw new InvalidFieldException("상품 ID는 양수여야 합니다."); + if (quantity == null || quantity <= 0) throw new InvalidFieldException("주문 수량은 1 이상이어야 합니다."); + if (unitPrice == null || unitPrice < 0) throw new InvalidFieldException("단가는 0 이상이어야 합니다."); + } + + private void validateReconstruct(OrderLineId id, Long productId, Long sellerId, String productName, + Integer quantity, Long unitPrice, OrderLineDeliveryStatus deliveryStatus) { + if (id == null) throw new InvalidFieldException("주문 라인 ID는 필수입니다."); + if (productId == null || productId <= 0) throw new InvalidFieldException("상품 ID는 양수여야 합니다."); + if (sellerId == null || sellerId <= 0) throw new InvalidFieldException("판매자 ID는 양수여야 합니다."); + if (productName == null || productName.isBlank()) throw new InvalidFieldException("상품명은 필수입니다."); + if (quantity == null || quantity <= 0) throw new InvalidFieldException("주문 수량은 1 이상이어야 합니다."); + if (unitPrice == null || unitPrice < 0) throw new InvalidFieldException("단가는 0 이상이어야 합니다."); + if (deliveryStatus == null) throw new InvalidFieldException("배송 상태는 필수입니다."); + } + + public long lineTotal() { + return unitPrice * quantity; + } + + public void prepare() { + if (this.deliveryStatus != OrderLineDeliveryStatus.PENDING) { + throw new OrderStatusConflictException("준비 처리할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.PREPARING; + } + + public void confirm() { + if (this.deliveryStatus != OrderLineDeliveryStatus.PREPARING) { + throw new OrderStatusConflictException("확정 처리할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.CONFIRMED; + } + + public void startDelivery() { + if (this.deliveryStatus != OrderLineDeliveryStatus.CONFIRMED) { + throw new OrderStatusConflictException("배송 시작할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.SHIPPING; + } + + public void completeDelivery() { + if (this.deliveryStatus != OrderLineDeliveryStatus.SHIPPING) { + throw new OrderStatusConflictException("배송 완료 처리할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.SHIPPED; + } + + public void complain() { + if (this.deliveryStatus != OrderLineDeliveryStatus.SHIPPING) { + throw new OrderLineComplainNotAllowedException(); + } + this.deliveryStatus = OrderLineDeliveryStatus.COMPLAINING; + } + + public void requestCancel() { + if (this.deliveryStatus == OrderLineDeliveryStatus.SHIPPED + || this.deliveryStatus == OrderLineDeliveryStatus.COMPLAINING + || this.deliveryStatus == OrderLineDeliveryStatus.CANCEL_REQUESTED + || this.deliveryStatus == OrderLineDeliveryStatus.CANCELLED) { + throw new OrderStatusConflictException("취소 요청할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.CANCEL_REQUESTED; + } + + public void completeCancellation() { + if (this.deliveryStatus != OrderLineDeliveryStatus.CANCEL_REQUESTED) { + throw new OrderStatusConflictException("취소 완료 처리할 수 없는 상태입니다: " + this.deliveryStatus); + } + this.deliveryStatus = OrderLineDeliveryStatus.CANCELLED; + } + + public boolean isCancelRequested() { + return this.deliveryStatus == OrderLineDeliveryStatus.CANCEL_REQUESTED; + } + + public boolean isAtLeast(OrderLineDeliveryStatus target) { + return this.deliveryStatus.ordinal() >= target.ordinal(); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderLineDeliveryStatus.java b/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderLineDeliveryStatus.java new file mode 100644 index 00000000..6adf23d7 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderLineDeliveryStatus.java @@ -0,0 +1,12 @@ +package kr.magicbox.order.domain.enums; + +public enum OrderLineDeliveryStatus { + PENDING, + PREPARING, + CONFIRMED, + SHIPPING, + SHIPPED, + COMPLAINING, + CANCEL_REQUESTED, + CANCELLED +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderStatus.java b/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderStatus.java new file mode 100644 index 00000000..fadb8915 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/enums/OrderStatus.java @@ -0,0 +1,18 @@ +package kr.magicbox.order.domain.enums; + +public enum OrderStatus { + PENDING, + STOCK_RESERVED, + PAYMENT_COMPLETED, + PREPARING, + CONFIRMED, + DELIVERING, + DELIVERED, + COMPLAINING, + PURCHASE_CONFIRMED, + CANCELLING, + CANCELLED, + CANCELLATION_FAILED, + STOCK_FAILED, + PAYMENT_FAILED +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderAutoConfirmedEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderAutoConfirmedEvent.java new file mode 100644 index 00000000..33db3434 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderAutoConfirmedEvent.java @@ -0,0 +1,48 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderAutoConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("gross_amount") long grossAmount, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderAutoConfirmedEvent from(Order order, OrderLine line, Instant now) { + return OrderAutoConfirmedEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .orderLineId(line.getId().value()) + .sellerId(line.getSellerId()) + .grossAmount(line.lineTotal()) + .occurredAt(now) + .build(); + } + + public static List fromShippedLines(Order order) { + Instant now = Instant.now(); + return order.shippedLines().stream() + .map(line -> from(order, line, now)) + .toList(); + } + + @Override + public String key() { + return orderLineId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_AUTO_CONFIRMED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderCancelEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderCancelEvent.java new file mode 100644 index 00000000..1e5f06ae --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderCancelEvent.java @@ -0,0 +1,38 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderCancelEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("requested_at") Instant requestedAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderCancelEvent from(Order order, String reason) { + Instant now = Instant.now(); + return OrderCancelEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .reason(reason) + .requestedAt(now) + .occurredAt(now) + .build(); + } + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_CANCEL; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderConfirmedEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderConfirmedEvent.java new file mode 100644 index 00000000..465c5ce4 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderConfirmedEvent.java @@ -0,0 +1,38 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("confirmed_at") Instant confirmedAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderConfirmedEvent from(Order order) { + Instant now = Instant.now(); + return OrderConfirmedEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .sellerId(order.getSellerId()) + .confirmedAt(now) + .occurredAt(now) + .build(); + } + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_CONFIRMED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDeliveredEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDeliveredEvent.java new file mode 100644 index 00000000..e1ce458d --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDeliveredEvent.java @@ -0,0 +1,36 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record OrderDeliveredEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("delivered_at") Instant deliveredAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderDeliveredEvent from(Order order) { + Instant now = Instant.now(); + return OrderDeliveredEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .deliveredAt(now) + .occurredAt(now) + .build(); + } + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_DELIVERED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEvent.java new file mode 100644 index 00000000..9cc89dd2 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEvent.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.domain.event; + +public interface OrderDomainEvent { + String key(); + OrderDomainEventType eventType(); +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java new file mode 100644 index 00000000..2fe5578f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderDomainEventType.java @@ -0,0 +1,18 @@ +package kr.magicbox.order.domain.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OrderDomainEventType { + ORDER_PREPARE("order-prepare"), + ORDER_CONFIRMED("order-confirmed"), + ORDER_CANCEL("order-cancel"), + ORDER_PURCHASE_CONFIRMED("order-purchase-confirmed"), + ORDER_AUTO_CONFIRMED("order-auto-confirmed"), + ORDER_DELIVERED("order-delivered"), + RELEASE_SOLD_QUANTITY_INCREASE("release-sold-quantity-increase"); + + private final String value; +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java new file mode 100644 index 00000000..1b6810fb --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java @@ -0,0 +1,78 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderPrepareEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("items") List items, + @JsonProperty("total_amount") long totalAmount, + @JsonProperty("shipping_address") ShippingAddressPayload shippingAddress, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderPrepareEvent from(Long savedOrderId, Order order) { + List items = order.getOrderLines().stream() + .map(line -> OrderItemPayload.builder() + .productId(line.getProductId()) + .quantity(line.getQuantity()) + .unitPrice(line.getUnitPrice()) + .productName(line.getProductName()) + .build()) + .toList(); + + ShippingAddress addr = order.getShippingAddress(); + ShippingAddressPayload shippingAddress = ShippingAddressPayload.builder() + .recipient(addr.recipient()) + .phone(addr.phone()) + .zipCode(addr.zipCode()) + .address1(addr.address1()) + .address2(addr.address2()) + .build(); + + return OrderPrepareEvent.builder() + .orderId(savedOrderId) + .customerId(order.getCustomerId()) + .sellerId(order.getSellerId()) + .items(items) + .totalAmount(order.getTotalAmount()) + .shippingAddress(shippingAddress) + .occurredAt(Instant.now()) + .build(); + } + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_PREPARE; + } + + @Builder + public record OrderItemPayload( + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity, + @JsonProperty("unit_price") long unitPrice, + @JsonProperty("product_name") String productName + ) {} + + @Builder + public record ShippingAddressPayload( + @JsonProperty("recipient") String recipient, + @JsonProperty("phone") String phone, + @JsonProperty("zip_code") String zipCode, + @JsonProperty("address1") String address1, + @JsonProperty("address2") String address2 + ) {} +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPurchaseConfirmedEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPurchaseConfirmedEvent.java new file mode 100644 index 00000000..f1d0ce4f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPurchaseConfirmedEvent.java @@ -0,0 +1,48 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.aggregate.OrderLine; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderPurchaseConfirmedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("gross_amount") long grossAmount, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderPurchaseConfirmedEvent from(Order order, OrderLine line, Instant now) { + return OrderPurchaseConfirmedEvent.builder() + .orderId(order.getId().value()) + .customerId(order.getCustomerId()) + .orderLineId(line.getId().value()) + .sellerId(line.getSellerId()) + .grossAmount(line.lineTotal()) + .occurredAt(now) + .build(); + } + + public static List fromShippedLines(Order order) { + Instant now = Instant.now(); + return order.shippedLines().stream() + .map(line -> from(order, line, now)) + .toList(); + } + + @Override + public String key() { + return orderLineId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.ORDER_PURCHASE_CONFIRMED; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/event/ReleaseSoldQuantityIncreaseEvent.java b/services/order/src/main/java/kr/magicbox/order/domain/event/ReleaseSoldQuantityIncreaseEvent.java new file mode 100644 index 00000000..2735b707 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/ReleaseSoldQuantityIncreaseEvent.java @@ -0,0 +1,32 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record ReleaseSoldQuantityIncreaseEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("release_id") Long releaseId, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static ReleaseSoldQuantityIncreaseEvent of(Long releaseId) { + return ReleaseSoldQuantityIncreaseEvent.builder() + .eventId(releaseId) + .releaseId(releaseId) + .occurredAt(Instant.now()) + .build(); + } + + @Override + public String key() { + return releaseId.toString(); + } + + @Override + public OrderDomainEventType eventType() { + return OrderDomainEventType.RELEASE_SOLD_QUANTITY_INCREASE; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidFieldException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidFieldException.java new file mode 100644 index 00000000..90e33a17 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidFieldException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class InvalidFieldException extends BusinessException { + + public InvalidFieldException(String message) { + super(message, HttpStatus.BAD_REQUEST); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidPurchaseTokenException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidPurchaseTokenException.java new file mode 100644 index 00000000..2bf9c616 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/InvalidPurchaseTokenException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class InvalidPurchaseTokenException extends BusinessException { + public InvalidPurchaseTokenException() { + super("유효하지 않은 구매 토큰입니다.", HttpStatus.UNAUTHORIZED); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineComplainNotAllowedException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineComplainNotAllowedException.java new file mode 100644 index 00000000..6e1eaf69 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineComplainNotAllowedException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class OrderLineComplainNotAllowedException extends BusinessException { + public OrderLineComplainNotAllowedException() { + super("배달 중(SHIPPING) 상태의 주문 라인에서만 미수령 신고가 가능합니다.", HttpStatus.CONFLICT); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineNotFoundException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineNotFoundException.java new file mode 100644 index 00000000..9edaf4fe --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderLineNotFoundException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class OrderLineNotFoundException extends BusinessException { + + public OrderLineNotFoundException() { + super("주문 라인을 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderNotFoundException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderNotFoundException.java new file mode 100644 index 00000000..d9127d05 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderNotFoundException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class OrderNotFoundException extends BusinessException { + public OrderNotFoundException() { + super("주문을 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderStatusConflictException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderStatusConflictException.java new file mode 100644 index 00000000..8fd842b0 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderStatusConflictException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class OrderStatusConflictException extends BusinessException { + public OrderStatusConflictException(String message) { + super(message, HttpStatus.CONFLICT); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderUnauthorizedException.java b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderUnauthorizedException.java new file mode 100644 index 00000000..9c3f8725 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/exception/OrderUnauthorizedException.java @@ -0,0 +1,11 @@ +package kr.magicbox.order.domain.exception; + +import kr.magicbox.order.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("java:S110") +public class OrderUnauthorizedException extends BusinessException { + public OrderUnauthorizedException() { + super("해당 주문에 접근할 권한이 없습니다.", HttpStatus.FORBIDDEN); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderId.java b/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderId.java new file mode 100644 index 00000000..38f63c92 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderId.java @@ -0,0 +1,16 @@ +package kr.magicbox.order.domain.vo; + +import kr.magicbox.order.domain.exception.InvalidFieldException; + +public record OrderId(Long value) { + + public OrderId { + if (value == null || value <= 0) { + throw new InvalidFieldException("주문 ID는 양수여야 합니다."); + } + } + + public static OrderId of(Long value) { + return new OrderId(value); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderLineId.java b/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderLineId.java new file mode 100644 index 00000000..a0f36cf1 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/vo/OrderLineId.java @@ -0,0 +1,16 @@ +package kr.magicbox.order.domain.vo; + +import kr.magicbox.order.domain.exception.InvalidFieldException; + +public record OrderLineId(Long value) { + + public OrderLineId { + if (value == null || value <= 0) { + throw new InvalidFieldException("주문 라인 ID는 양수여야 합니다."); + } + } + + public static OrderLineId of(Long value) { + return new OrderLineId(value); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/vo/ShippingAddress.java b/services/order/src/main/java/kr/magicbox/order/domain/vo/ShippingAddress.java new file mode 100644 index 00000000..7fd26407 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/vo/ShippingAddress.java @@ -0,0 +1,31 @@ +package kr.magicbox.order.domain.vo; + +import kr.magicbox.order.domain.exception.InvalidFieldException; + +public record ShippingAddress( + String recipient, + String phone, + String zipCode, + String address1, + String address2 +) { + + public ShippingAddress { + if (recipient == null || recipient.isBlank()) { + throw new InvalidFieldException("수령인은 필수 값입니다."); + } + if (phone == null || phone.isBlank()) { + throw new InvalidFieldException("전화번호는 필수 값입니다."); + } + if (zipCode == null || zipCode.isBlank()) { + throw new InvalidFieldException("우편번호는 필수 값입니다."); + } + if (address1 == null || address1.isBlank()) { + throw new InvalidFieldException("도로명 주소는 필수 값입니다."); + } + } + + public static ShippingAddress of(String recipient, String phone, String zipCode, String address1, String address2) { + return new ShippingAddress(recipient, phone, zipCode, address1, address2); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/domain/vo/UserId.java b/services/order/src/main/java/kr/magicbox/order/domain/vo/UserId.java new file mode 100644 index 00000000..c46dd36f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/vo/UserId.java @@ -0,0 +1,15 @@ +package kr.magicbox.order.domain.vo; + +import kr.magicbox.order.domain.exception.InvalidFieldException; + +public record UserId(Long value) { + public UserId { + if (value == null || value <= 0) { + throw new InvalidFieldException("사용자 ID는 양수여야 합니다."); + } + } + + public static UserId of(Long value) { + return new UserId(value); + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/global/exception/BaseException.java b/services/order/src/main/java/kr/magicbox/order/global/exception/BaseException.java new file mode 100644 index 00000000..0b4cf2f0 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/global/exception/BaseException.java @@ -0,0 +1,20 @@ +package kr.magicbox.order.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class BaseException extends RuntimeException { + + private final HttpStatus status; + + public BaseException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public BaseException(String message, HttpStatus status, Throwable cause) { + super(message, cause); + this.status = status; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/global/exception/BusinessException.java b/services/order/src/main/java/kr/magicbox/order/global/exception/BusinessException.java new file mode 100644 index 00000000..a709771d --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/global/exception/BusinessException.java @@ -0,0 +1,17 @@ +package kr.magicbox.order.global.exception; + +import org.springframework.http.HttpStatus; + +public class BusinessException extends BaseException { + + public BusinessException(String message, HttpStatus status) { + super(message, validateStatus(status)); + } + + private static HttpStatus validateStatus(HttpStatus status) { + if (!status.is4xxClientError()) { + throw new SystemError("클라이언트 에러가 아닙니다.", HttpStatus.INTERNAL_SERVER_ERROR); + } + return status; + } +} diff --git a/services/order/src/main/java/kr/magicbox/order/global/exception/SystemError.java b/services/order/src/main/java/kr/magicbox/order/global/exception/SystemError.java new file mode 100644 index 00000000..aec86b39 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/global/exception/SystemError.java @@ -0,0 +1,14 @@ +package kr.magicbox.order.global.exception; + +import org.springframework.http.HttpStatus; + +public class SystemError extends BaseException { + + public SystemError(String message, HttpStatus status) { + super(message, status); + } + + public SystemError(String message, HttpStatus status, Throwable cause) { + super(message, status, cause); + } +} From 7c6f82a1c094d9f337c53ce1df7da47049a66d93 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 11:58:09 +0900 Subject: [PATCH 099/116] =?UTF-8?q?fix(kafka):=20IdempotentAspect=20catch(?= =?UTF-8?q?Throwable)=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20@RetryableTopic?= =?UTF-8?q?=20exclude=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IdempotentAspect: catch(Throwable→RuntimeException) wrap으로 인한 retry 정책 무력화 수정 - Error는 즉시 rethrow - RuntimeException은 타입 보존하여 rethrow - checked Exception만 IllegalStateException으로 wrap - 전 서비스 KafkaListener: @RetryableTopic(exclude = {BusinessException.class}) 추가 - BusinessException(4xx)은 재시도 불필요하므로 즉시 DLT로 전송 Co-Authored-By: Claude Sonnet 4.6 --- .../auth/adapter/in/kafka/UserEventKafkaListener.java | 4 ++++ .../auth/adapter/in/kafka/aop/IdempotentAspect.java | 9 +++++++-- .../creator/adapter/in/kafka/UserEventKafkaListener.java | 5 +++-- .../creator/adapter/in/kafka/aop/IdempotentAspect.java | 9 +++++++-- .../adapter/in/kafka/CreatorEventKafkaListener.java | 3 +++ .../adapter/in/kafka/aop/IdempotentAspect.java | 9 +++++++-- .../adapter/in/kafka/DeliveryEventKafkaListener.java | 5 +++-- .../order/adapter/in/kafka/OrderStateKafkaListener.java | 3 ++- .../adapter/in/kafka/PaymentEventKafkaListener.java | 9 +++++---- .../order/adapter/in/kafka/StockEventKafkaListener.java | 5 +++-- .../order/adapter/in/kafka/aop/IdempotentAspect.java | 9 +++++++-- .../adapter/in/kafka/CreatorEventKafkaListener.java | 3 +++ .../adapter/in/kafka/UserEventKafkaListener.java | 4 ++++ .../subscribe/adapter/in/kafka/aop/IdempotentAspect.java | 9 +++++++-- .../user/adapter/in/kafka/AuthEventKafkaListener.java | 5 +++-- .../user/adapter/in/kafka/SseConnectedKafkaListener.java | 3 ++- .../adapter/in/kafka/SseDisconnectedKafkaListener.java | 3 ++- .../user/adapter/in/kafka/aop/IdempotentAspect.java | 9 +++++++-- 18 files changed, 79 insertions(+), 27 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java index 32ab822a..19cef214 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java @@ -12,6 +12,8 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import kr.magicbox.auth.global.exception.BusinessException; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Slf4j @@ -24,6 +26,7 @@ public class UserEventKafkaListener { private final AuthInboxRepository authInboxRepository; @Idempotent + @RetryableTopic(exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "auth-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { UserWithdrawnEvent event = consumerRecord.value(); @@ -31,6 +34,7 @@ public void handleUserWithdrawnEvent(ConsumerRecord } @Idempotent + @RetryableTopic(exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.user-banned", groupId = "auth-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { UserBannedEvent event = consumerRecord.value(); diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 07fe8fc7..6ee6862b 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -65,9 +65,14 @@ public Object around(ProceedingJoinPoint pjp) { .build()); try { pjp.proceed(); - } catch (Throwable e) { + } catch (Error e) { + throw e; + } catch (RuntimeException e) { status.setRollbackOnly(); - throw new RuntimeException(e); + throw e; + } catch (Exception e) { + status.setRollbackOnly(); + throw new IllegalStateException(e); } inbox.markProcessed(); return null; diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java index fc9e2a06..f6570f46 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java @@ -13,6 +13,7 @@ import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +import kr.magicbox.creator.global.exception.BusinessException; @Slf4j @Component @@ -24,7 +25,7 @@ public class UserEventKafkaListener { private final CreatorInboxRepository creatorInboxRepository; @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.creator.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "creator-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { log.info("[Inbox] user-withdrawn 이벤트 수신. eventId={}", consumerRecord.key()); @@ -32,7 +33,7 @@ public void handleUserWithdrawnEvent(ConsumerRecord } @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.creator.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.user-banned", groupId = "creator-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { log.info("[Inbox] user-banned 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index f87a2204..db88a258 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -65,9 +65,14 @@ public Object around(ProceedingJoinPoint pjp) { .build()); try { pjp.proceed(); - } catch (Throwable e) { + } catch (Error e) { + throw e; + } catch (RuntimeException e) { status.setRollbackOnly(); - throw new RuntimeException(e); + throw e; + } catch (Exception e) { + status.setRollbackOnly(); + throw new IllegalStateException(e); } inbox.markProcessed(); return null; diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java index dcb3ef6e..b48ac932 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java @@ -4,11 +4,13 @@ import kr.magicbox.generalgoods.application.dto.command.HandleCreatorRevokedCommand; import kr.magicbox.generalgoods.adapter.out.persistence.repository.GeneralGoodsInboxRepository; import kr.magicbox.generalgoods.application.port.in.HandleCreatorRevokedUseCase; +import kr.magicbox.generalgoods.global.exception.BusinessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Slf4j @@ -18,6 +20,7 @@ public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; private final GeneralGoodsInboxRepository generalGoodsInboxRepository; + @RetryableTopic(exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "general-goods-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java index 2742a8e0..a10d8223 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -65,9 +65,14 @@ public Object around(ProceedingJoinPoint pjp) { .build()); try { pjp.proceed(); - } catch (Throwable e) { + } catch (Error e) { + throw e; + } catch (RuntimeException e) { status.setRollbackOnly(); - throw new RuntimeException(e); + throw e; + } catch (Exception e) { + status.setRollbackOnly(); + throw new IllegalStateException(e); } inbox.markProcessed(); return null; diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java index 43658baf..137e8dc5 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java @@ -11,6 +11,7 @@ import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +import kr.magicbox.order.global.exception.BusinessException; @Slf4j @Component @@ -21,7 +22,7 @@ public class DeliveryEventKafkaListener { private final HandleDeliveryCompletedUseCase handleDeliveryCompletedUseCase; @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.delivery-started", groupId = "order-service") public void handleDeliveryStarted(ConsumerRecord consumerRecord) { log.info("[Inbox] delivery.started 이벤트 수신. eventId={}", consumerRecord.key()); @@ -30,7 +31,7 @@ public void handleDeliveryStarted(ConsumerRecord c } @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.delivery-completed", groupId = "order-service") public void handleDeliveryCompleted(ConsumerRecord consumerRecord) { log.info("[Inbox] delivery.completed 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java index 873c2d5c..4c1a1454 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java @@ -9,6 +9,7 @@ import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +import kr.magicbox.order.global.exception.BusinessException; @Slf4j @Component @@ -18,7 +19,7 @@ public class OrderStateKafkaListener { private final HandleOrderPrepareUseCase handleOrderPrepareUseCase; @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.order-prepare", groupId = "order-service") public void handleOrderPrepare(ConsumerRecord consumerRecord) { log.info("[Inbox] order.prepare 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java index 03600c45..1ef644e7 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java @@ -15,6 +15,7 @@ import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +import kr.magicbox.order.global.exception.BusinessException; @Slf4j @Component @@ -27,7 +28,7 @@ public class PaymentEventKafkaListener { private final HandlePaymentCancelFailedUseCase handlePaymentCancelFailedUseCase; @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.payment-succeeded", groupId = "order-service") public void handlePaymentSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] payment.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); @@ -35,7 +36,7 @@ public void handlePaymentSucceeded(ConsumerRecord } @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.payment-failed", groupId = "order-service") public void handlePaymentFailed(ConsumerRecord consumerRecord) { log.info("[Inbox] payment.failed 이벤트 수신. eventId={}", consumerRecord.key()); @@ -43,7 +44,7 @@ public void handlePaymentFailed(ConsumerRecord consu } @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.payment-cancel-succeeded", groupId = "order-service") public void handlePaymentCancelSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] payment.cancel.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); @@ -51,7 +52,7 @@ public void handlePaymentCancelSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] payment.cancel.failed 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java index 4e4dfe8a..752871e7 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java @@ -11,6 +11,7 @@ import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +import kr.magicbox.order.global.exception.BusinessException; @Slf4j @Component @@ -21,7 +22,7 @@ public class StockEventKafkaListener { private final HandleStockReserveFailedUseCase handleStockReserveFailedUseCase; @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.stock-reserve-succeeded", groupId = "order-service") public void handleStockReserveSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] stock.reserve.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); @@ -29,7 +30,7 @@ public void handleStockReserveSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] stock.reserve.failed 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java index 8461e3c5..f7b4d6e7 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java @@ -65,9 +65,14 @@ public Object around(ProceedingJoinPoint pjp) { .build()); try { pjp.proceed(); - } catch (Throwable e) { + } catch (Error e) { + throw e; + } catch (RuntimeException e) { status.setRollbackOnly(); - throw new RuntimeException(e); + throw e; + } catch (Exception e) { + status.setRollbackOnly(); + throw new IllegalStateException(e); } inbox.markProcessed(); return null; diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java index bc1b7506..d52a176c 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java @@ -4,11 +4,13 @@ import kr.magicbox.subscribe.application.dto.command.HandleCreatorRevokedCommand; import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; import kr.magicbox.subscribe.application.port.in.HandleCreatorRevokedUseCase; +import kr.magicbox.subscribe.global.exception.BusinessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Slf4j @@ -18,6 +20,7 @@ public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; private final SubscribeInboxRepository subscribeInboxRepository; + @RetryableTopic(exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "subscribe-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java index d6dc8abc..8a266ae4 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java @@ -5,12 +5,14 @@ import kr.magicbox.subscribe.application.dto.command.HandleUserRevokedCommand; import kr.magicbox.subscribe.adapter.out.persistence.repository.SubscribeInboxRepository; import kr.magicbox.subscribe.application.port.in.HandleUserRevokedUseCase; +import kr.magicbox.subscribe.global.exception.BusinessException; import kr.magicbox.subscribe.domain.vo.SubscriberId; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; @Slf4j @@ -20,6 +22,7 @@ public class UserEventKafkaListener { private final HandleUserRevokedUseCase handleUserRevokedUseCase; private final SubscribeInboxRepository subscribeInboxRepository; + @RetryableTopic(exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "subscribe-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { UserWithdrawnEvent event = consumerRecord.value(); @@ -28,6 +31,7 @@ public void handleUserWithdrawnEvent(ConsumerRecord ); } + @RetryableTopic(exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.user-banned", groupId = "subscribe-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { UserBannedEvent event = consumerRecord.value(); diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java index 756bb451..23d0c6c8 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -65,9 +65,14 @@ public Object around(ProceedingJoinPoint pjp) { .build()); try { pjp.proceed(); - } catch (Throwable e) { + } catch (Error e) { + throw e; + } catch (RuntimeException e) { status.setRollbackOnly(); - throw new RuntimeException(e); + throw e; + } catch (Exception e) { + status.setRollbackOnly(); + throw new IllegalStateException(e); } inbox.markProcessed(); return null; diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java index 72e29f30..9afdf744 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java @@ -14,6 +14,7 @@ import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; import org.springframework.stereotype.Component; +import kr.magicbox.user.global.exception.BusinessException; @Slf4j @Component @@ -24,7 +25,7 @@ public class AuthEventKafkaListener { private final UserInboxRepository userInboxRepository; @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.user.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.user-logged-in", groupId = "user-service") public void handleLoginEvent(ConsumerRecord record) { LoginEvent event = record.value(); @@ -32,7 +33,7 @@ public void handleLoginEvent(ConsumerRecord record) { } @Idempotent - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.user.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.user-logged-out", groupId = "user-service") public void handleLogoutEvent(ConsumerRecord record) { LogoutEvent event = record.value(); diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java index a4a222c0..812fc70a 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import java.time.Instant; +import kr.magicbox.user.global.exception.BusinessException; @Slf4j @Component @@ -19,7 +20,7 @@ public class SseConnectedKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.user.global.exception.BusinessException.class}) @KafkaListener(topics = "sse.connected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") public void handleConnected(ConsumerRecord record) { Long userId = Long.parseLong(record.key()); diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java index 1b8d334f..366d6f66 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import java.time.Instant; +import kr.magicbox.user.global.exception.BusinessException; @Slf4j @Component @@ -19,7 +20,7 @@ public class SseDisconnectedKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; - @RetryableTopic + @RetryableTopic(exclude = {kr.magicbox.user.global.exception.BusinessException.class}) @KafkaListener(topics = "sse.disconnected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") public void handleDisconnected(ConsumerRecord record) { Long userId = Long.parseLong(record.key()); diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index 8cd8c481..ee3bd5ee 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -65,9 +65,14 @@ public Object around(ProceedingJoinPoint pjp) { .build()); try { pjp.proceed(); - } catch (Throwable e) { + } catch (Error e) { + throw e; + } catch (RuntimeException e) { status.setRollbackOnly(); - throw new RuntimeException(e); + throw e; + } catch (Exception e) { + status.setRollbackOnly(); + throw new IllegalStateException(e); } inbox.markProcessed(); return null; From 5bb879c4dbfd60778655918bdeabfa9703056a53 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:03:21 +0900 Subject: [PATCH 100/116] =?UTF-8?q?fix(order):=20ConfirmOrderLineService?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EB=B0=A9=EC=A7=80=20=EA=B0=80=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order.confirmOrderLine()은 CONFIRMED 상태에서도 호출 가능(partial confirm 이후 재호출)하므로 이미 CONFIRMED인 상태에서 라인 확정 시 OrderConfirmedEvent가 중복 발행되는 버그 수정. PREPARING → CONFIRMED 전이 시에만 이벤트를 발행하도록 previousStatus 가드 추가. Co-Authored-By: Claude Sonnet 4.6 --- .../order/application/service/ConfirmOrderLineService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java index 698ea2ed..9dc26390 100644 --- a/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java +++ b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java @@ -28,11 +28,12 @@ public void confirmOrderLine(Long orderId, Long orderLineId, Long sellerId) { throw new OrderUnauthorizedException(); } + OrderStatus previousStatus = order.getStatus(); order.confirmOrderLine(orderLineId); orderRepositoryPort.update(order); - // 모든 라인 CONFIRMED → Order CONFIRMED 전환 시 이벤트 발행 - if (order.getStatus() == OrderStatus.CONFIRMED) { + // PREPARING → CONFIRMED 전이 시에만 이벤트 1회 발행 (이미 CONFIRMED였던 경우 중복 발행 방지) + if (previousStatus != OrderStatus.CONFIRMED && order.getStatus() == OrderStatus.CONFIRMED) { orderOutboxPort.save(OrderConfirmedEvent.from(order)); } } From 0d6638252260bc1dee0cd7c7d2d8d9bfb6ab87cf Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:08:45 +0900 Subject: [PATCH 101/116] =?UTF-8?q?fix(order):=20confirmOrderLine=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=97=90=EC=84=9C=20PREPARING=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A7=8C=20=ED=97=88=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONFIRMED 상태에서 재확정을 도메인 레벨에서 차단하여 이벤트 중복 발행 방지. 서비스 레이어의 previousStatus 가드는 불필요해져 제거. Co-Authored-By: Claude Sonnet 4.6 --- .../order/application/service/ConfirmOrderLineService.java | 5 ++--- .../main/java/kr/magicbox/order/domain/aggregate/Order.java | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java index 9dc26390..698ea2ed 100644 --- a/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java +++ b/services/order/src/main/java/kr/magicbox/order/application/service/ConfirmOrderLineService.java @@ -28,12 +28,11 @@ public void confirmOrderLine(Long orderId, Long orderLineId, Long sellerId) { throw new OrderUnauthorizedException(); } - OrderStatus previousStatus = order.getStatus(); order.confirmOrderLine(orderLineId); orderRepositoryPort.update(order); - // PREPARING → CONFIRMED 전이 시에만 이벤트 1회 발행 (이미 CONFIRMED였던 경우 중복 발행 방지) - if (previousStatus != OrderStatus.CONFIRMED && order.getStatus() == OrderStatus.CONFIRMED) { + // 모든 라인 CONFIRMED → Order CONFIRMED 전환 시 이벤트 발행 + if (order.getStatus() == OrderStatus.CONFIRMED) { orderOutboxPort.save(OrderConfirmedEvent.from(order)); } } diff --git a/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java index 17eb9548..2e22429d 100644 --- a/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java +++ b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/Order.java @@ -107,9 +107,7 @@ public void prepare() { * 모든 라인이 CONFIRMED 이상이면 Order도 CONFIRMED로 전환한다. */ public void confirmOrderLine(Long orderLineId) { - if (this.status != OrderStatus.PREPARING && this.status != OrderStatus.CONFIRMED) { - throw new OrderStatusConflictException("현재 상태에서 확정 처리할 수 없습니다. 현재: " + this.status); - } + validateStatus(OrderStatus.PREPARING); OrderLine orderLine = findOrderLine(orderLineId); orderLine.confirm(); this.updatedAt = Instant.now(); From a72677bfe8eb100eac4a83829208de5a557df5be Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:13:06 +0900 Subject: [PATCH 102/116] =?UTF-8?q?fix(kafka):=20@RetryableTopic=EC=97=90?= =?UTF-8?q?=20dltStrategy,=20dltTopicSuffix=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본값과 동일하나 운영 시 DLT 전략을 명확히 하기 위해 명시적으로 추가. - dltStrategy = FAIL_ON_ERROR: DLT 전송 실패 시 컨슈머 중단(메시지 유실 방지) - dltTopicSuffix = "-dlt": DLT 토픽명 명시 Co-Authored-By: Claude Sonnet 4.6 --- .../auth/adapter/in/kafka/UserEventKafkaListener.java | 5 +++-- .../creator/adapter/in/kafka/UserEventKafkaListener.java | 5 +++-- .../adapter/in/kafka/CreatorEventKafkaListener.java | 3 ++- .../adapter/in/kafka/DeliveryEventKafkaListener.java | 5 +++-- .../order/adapter/in/kafka/OrderStateKafkaListener.java | 3 ++- .../adapter/in/kafka/PaymentEventKafkaListener.java | 9 +++++---- .../order/adapter/in/kafka/StockEventKafkaListener.java | 5 +++-- .../adapter/in/kafka/CreatorEventKafkaListener.java | 3 ++- .../adapter/in/kafka/UserEventKafkaListener.java | 5 +++-- .../user/adapter/in/kafka/AuthEventKafkaListener.java | 5 +++-- .../user/adapter/in/kafka/SseConnectedKafkaListener.java | 3 ++- .../adapter/in/kafka/SseDisconnectedKafkaListener.java | 3 ++- 12 files changed, 33 insertions(+), 21 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java index 19cef214..73d3746e 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java @@ -14,6 +14,7 @@ import org.springframework.kafka.annotation.KafkaListener; import kr.magicbox.auth.global.exception.BusinessException; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; @Slf4j @@ -26,7 +27,7 @@ public class UserEventKafkaListener { private final AuthInboxRepository authInboxRepository; @Idempotent - @RetryableTopic(exclude = {BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "auth-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { UserWithdrawnEvent event = consumerRecord.value(); @@ -34,7 +35,7 @@ public void handleUserWithdrawnEvent(ConsumerRecord } @Idempotent - @RetryableTopic(exclude = {BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.user-banned", groupId = "auth-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { UserBannedEvent event = consumerRecord.value(); diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java index f6570f46..4484a679 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java @@ -12,6 +12,7 @@ import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; import kr.magicbox.creator.global.exception.BusinessException; @@ -25,7 +26,7 @@ public class UserEventKafkaListener { private final CreatorInboxRepository creatorInboxRepository; @Idempotent - @RetryableTopic(exclude = {kr.magicbox.creator.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.creator.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "creator-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { log.info("[Inbox] user-withdrawn 이벤트 수신. eventId={}", consumerRecord.key()); @@ -33,7 +34,7 @@ public void handleUserWithdrawnEvent(ConsumerRecord } @Idempotent - @RetryableTopic(exclude = {kr.magicbox.creator.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.creator.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.user-banned", groupId = "creator-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { log.info("[Inbox] user-banned 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java index b48ac932..3c1970e1 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java @@ -11,6 +11,7 @@ import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; @Slf4j @@ -20,7 +21,7 @@ public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; private final GeneralGoodsInboxRepository generalGoodsInboxRepository; - @RetryableTopic(exclude = {BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "general-goods-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java index 137e8dc5..75aeb4dc 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/DeliveryEventKafkaListener.java @@ -10,6 +10,7 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; import kr.magicbox.order.global.exception.BusinessException; @@ -22,7 +23,7 @@ public class DeliveryEventKafkaListener { private final HandleDeliveryCompletedUseCase handleDeliveryCompletedUseCase; @Idempotent - @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.delivery-started", groupId = "order-service") public void handleDeliveryStarted(ConsumerRecord consumerRecord) { log.info("[Inbox] delivery.started 이벤트 수신. eventId={}", consumerRecord.key()); @@ -31,7 +32,7 @@ public void handleDeliveryStarted(ConsumerRecord c } @Idempotent - @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.delivery-completed", groupId = "order-service") public void handleDeliveryCompleted(ConsumerRecord consumerRecord) { log.info("[Inbox] delivery.completed 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java index 4c1a1454..c6e03651 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/OrderStateKafkaListener.java @@ -8,6 +8,7 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; import kr.magicbox.order.global.exception.BusinessException; @@ -19,7 +20,7 @@ public class OrderStateKafkaListener { private final HandleOrderPrepareUseCase handleOrderPrepareUseCase; @Idempotent - @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.order-prepare", groupId = "order-service") public void handleOrderPrepare(ConsumerRecord consumerRecord) { log.info("[Inbox] order.prepare 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java index 1ef644e7..1a4ac467 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/PaymentEventKafkaListener.java @@ -14,6 +14,7 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; import kr.magicbox.order.global.exception.BusinessException; @@ -28,7 +29,7 @@ public class PaymentEventKafkaListener { private final HandlePaymentCancelFailedUseCase handlePaymentCancelFailedUseCase; @Idempotent - @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.payment-succeeded", groupId = "order-service") public void handlePaymentSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] payment.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); @@ -36,7 +37,7 @@ public void handlePaymentSucceeded(ConsumerRecord } @Idempotent - @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.payment-failed", groupId = "order-service") public void handlePaymentFailed(ConsumerRecord consumerRecord) { log.info("[Inbox] payment.failed 이벤트 수신. eventId={}", consumerRecord.key()); @@ -44,7 +45,7 @@ public void handlePaymentFailed(ConsumerRecord consu } @Idempotent - @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.payment-cancel-succeeded", groupId = "order-service") public void handlePaymentCancelSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] payment.cancel.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); @@ -52,7 +53,7 @@ public void handlePaymentCancelSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] payment.cancel.failed 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java index 752871e7..2a975850 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/StockEventKafkaListener.java @@ -10,6 +10,7 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; import kr.magicbox.order.global.exception.BusinessException; @@ -22,7 +23,7 @@ public class StockEventKafkaListener { private final HandleStockReserveFailedUseCase handleStockReserveFailedUseCase; @Idempotent - @RetryableTopic(exclude = {kr.magicbox.order.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.order.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.stock-reserve-succeeded", groupId = "order-service") public void handleStockReserveSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] stock.reserve.succeeded 이벤트 수신. eventId={}", consumerRecord.key()); @@ -30,7 +31,7 @@ public void handleStockReserveSucceeded(ConsumerRecord consumerRecord) { log.info("[Inbox] stock.reserve.failed 이벤트 수신. eventId={}", consumerRecord.key()); diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java index d52a176c..de8366e2 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java @@ -11,6 +11,7 @@ import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; @Slf4j @@ -20,7 +21,7 @@ public class CreatorEventKafkaListener { private final HandleCreatorRevokedUseCase handleCreatorRevokedUseCase; private final SubscribeInboxRepository subscribeInboxRepository; - @RetryableTopic(exclude = {BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.creator-revoked", groupId = "subscribe-service") public void handleCreatorRevokedEvent(ConsumerRecord consumerRecord) { CreatorRevokedEvent event = consumerRecord.value(); diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java index 8a266ae4..c6329b28 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java @@ -13,6 +13,7 @@ import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; @Slf4j @@ -22,7 +23,7 @@ public class UserEventKafkaListener { private final HandleUserRevokedUseCase handleUserRevokedUseCase; private final SubscribeInboxRepository subscribeInboxRepository; - @RetryableTopic(exclude = {BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.user-withdrawn", groupId = "subscribe-service") public void handleUserWithdrawnEvent(ConsumerRecord consumerRecord) { UserWithdrawnEvent event = consumerRecord.value(); @@ -31,7 +32,7 @@ public void handleUserWithdrawnEvent(ConsumerRecord ); } - @RetryableTopic(exclude = {BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {BusinessException.class}) @KafkaListener(topics = "outbox.event.user-banned", groupId = "subscribe-service") public void handleUserBannedEvent(ConsumerRecord consumerRecord) { UserBannedEvent event = consumerRecord.value(); diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java index 9afdf744..2e07d3e2 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java @@ -13,6 +13,7 @@ import org.springframework.kafka.annotation.DltHandler; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; import kr.magicbox.user.global.exception.BusinessException; @@ -25,7 +26,7 @@ public class AuthEventKafkaListener { private final UserInboxRepository userInboxRepository; @Idempotent - @RetryableTopic(exclude = {kr.magicbox.user.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.user.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.user-logged-in", groupId = "user-service") public void handleLoginEvent(ConsumerRecord record) { LoginEvent event = record.value(); @@ -33,7 +34,7 @@ public void handleLoginEvent(ConsumerRecord record) { } @Idempotent - @RetryableTopic(exclude = {kr.magicbox.user.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.user.global.exception.BusinessException.class}) @KafkaListener(topics = "outbox.event.user-logged-out", groupId = "user-service") public void handleLogoutEvent(ConsumerRecord record) { LogoutEvent event = record.value(); diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java index 812fc70a..0a9ffc05 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java @@ -8,6 +8,7 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; import java.time.Instant; @@ -20,7 +21,7 @@ public class SseConnectedKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; - @RetryableTopic(exclude = {kr.magicbox.user.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.user.global.exception.BusinessException.class}) @KafkaListener(topics = "sse.connected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") public void handleConnected(ConsumerRecord record) { Long userId = Long.parseLong(record.key()); diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java index 366d6f66..3725b914 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java @@ -8,6 +8,7 @@ import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; import org.springframework.stereotype.Component; import java.time.Instant; @@ -20,7 +21,7 @@ public class SseDisconnectedKafkaListener { private final ManageUserSessionUseCase manageUserSessionUseCase; - @RetryableTopic(exclude = {kr.magicbox.user.global.exception.BusinessException.class}) + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {kr.magicbox.user.global.exception.BusinessException.class}) @KafkaListener(topics = "sse.disconnected", groupId = "user-service", containerFactory = "stringKafkaListenerContainerFactory") public void handleDisconnected(ConsumerRecord record) { Long userId = Long.parseLong(record.key()); From e7bf5c83bbeca62af3ecf147529dec759b876619 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:18:55 +0900 Subject: [PATCH 103/116] =?UTF-8?q?fix(kafka):=20IdempotentAspect=20catch(?= =?UTF-8?q?Exception)=20=E2=86=92=20catch(Throwable)=20=EC=BB=B4=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pjp.proceed()가 throws Throwable로 선언되어 있어 catch(Exception)으로는 컴파일러가 Throwable 처리를 인정하지 않아 컴파일 에러 발생. 마지막 catch 블록을 Throwable로 변경하여 수정. Co-Authored-By: Claude Sonnet 4.6 --- .../kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java | 2 +- .../magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java | 2 +- .../generalgoods/adapter/in/kafka/aop/IdempotentAspect.java | 2 +- .../magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java | 2 +- .../subscribe/adapter/in/kafka/aop/IdempotentAspect.java | 2 +- .../kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java index 6ee6862b..b8f50843 100644 --- a/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/aop/IdempotentAspect.java @@ -70,7 +70,7 @@ public Object around(ProceedingJoinPoint pjp) { } catch (RuntimeException e) { status.setRollbackOnly(); throw e; - } catch (Exception e) { + } catch (Throwable e) { status.setRollbackOnly(); throw new IllegalStateException(e); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java index db88a258..4f9d79fe 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/aop/IdempotentAspect.java @@ -70,7 +70,7 @@ public Object around(ProceedingJoinPoint pjp) { } catch (RuntimeException e) { status.setRollbackOnly(); throw e; - } catch (Exception e) { + } catch (Throwable e) { status.setRollbackOnly(); throw new IllegalStateException(e); } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java index a10d8223..198e38d5 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java @@ -70,7 +70,7 @@ public Object around(ProceedingJoinPoint pjp) { } catch (RuntimeException e) { status.setRollbackOnly(); throw e; - } catch (Exception e) { + } catch (Throwable e) { status.setRollbackOnly(); throw new IllegalStateException(e); } diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java index f7b4d6e7..bbc6d9a1 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/kafka/aop/IdempotentAspect.java @@ -70,7 +70,7 @@ public Object around(ProceedingJoinPoint pjp) { } catch (RuntimeException e) { status.setRollbackOnly(); throw e; - } catch (Exception e) { + } catch (Throwable e) { status.setRollbackOnly(); throw new IllegalStateException(e); } diff --git a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java index 23d0c6c8..c13c1bca 100644 --- a/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java @@ -70,7 +70,7 @@ public Object around(ProceedingJoinPoint pjp) { } catch (RuntimeException e) { status.setRollbackOnly(); throw e; - } catch (Exception e) { + } catch (Throwable e) { status.setRollbackOnly(); throw new IllegalStateException(e); } diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java index ee3bd5ee..bbeece88 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/aop/IdempotentAspect.java @@ -70,7 +70,7 @@ public Object around(ProceedingJoinPoint pjp) { } catch (RuntimeException e) { status.setRollbackOnly(); throw e; - } catch (Exception e) { + } catch (Throwable e) { status.setRollbackOnly(); throw new IllegalStateException(e); } From a3eccf79a320501ee9ee692001b8b0b95addb13c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:21:59 +0900 Subject: [PATCH 104/116] =?UTF-8?q?fix:=20=EC=B6=A9=EB=8F=8C=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/auth/src/main/resources/application-dev.yml | 4 ---- services/creator/src/main/resources/application-dev.yml | 9 --------- .../general-goods/src/main/resources/application-dev.yml | 7 ------- .../subscribe/src/main/resources/application-dev.yml | 3 --- .../out/communication/grpc/ReviewQueryGrpcAdapter.java | 5 ----- 5 files changed, 28 deletions(-) diff --git a/services/auth/src/main/resources/application-dev.yml b/services/auth/src/main/resources/application-dev.yml index ec804c3a..87c3f183 100644 --- a/services/auth/src/main/resources/application-dev.yml +++ b/services/auth/src/main/resources/application-dev.yml @@ -108,9 +108,5 @@ logging: level: org.springframework.web: INFO org.springframework.data.redis: INFO -<<<<<<< HEAD - -======= ->>>>>>> feat/116 inbox: max-event-age-minutes: 5 diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index dbe29cbf..79a70604 100644 --- a/services/creator/src/main/resources/application-dev.yml +++ b/services/creator/src/main/resources/application-dev.yml @@ -61,20 +61,11 @@ spring: jpa: hibernate: ddl-auto: update -<<<<<<< HEAD - open-in-view: false -security: - trusted: - ips: - - 127.0.0.1 - - 0:0:0:0:0:0:0:1 -======= security: trusted: ips: - ${TRUSTED_IP_GATEWAY} ->>>>>>> feat/116 inbox: max-event-age-minutes: 5 diff --git a/services/general-goods/src/main/resources/application-dev.yml b/services/general-goods/src/main/resources/application-dev.yml index 5b967fdf..97cea099 100644 --- a/services/general-goods/src/main/resources/application-dev.yml +++ b/services/general-goods/src/main/resources/application-dev.yml @@ -1,9 +1,3 @@ -<<<<<<< HEAD - - -inbox: - max-event-age-minutes: 5 -======= spring: application: name: general-goods-dev @@ -70,4 +64,3 @@ resilience4j: instances: creatorService: timeout-duration: 2s ->>>>>>> feat/116 diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index a1e9f45d..3d40a309 100644 --- a/services/subscribe/src/main/resources/application-dev.yml +++ b/services/subscribe/src/main/resources/application-dev.yml @@ -50,8 +50,6 @@ security: inbox: max-event-age-minutes: 5 -<<<<<<< HEAD -======= resilience4j: circuitbreaker: @@ -68,4 +66,3 @@ resilience4j: instances: creatorService: timeout-duration: 2s ->>>>>>> feat/116 diff --git a/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java b/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java index fb10e064..7725fe68 100644 --- a/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java +++ b/services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java @@ -33,12 +33,7 @@ public List getAllReviewsByUserId(Long userId) { .build(); ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.REVIEW.getHostName()); -<<<<<<< HEAD - ReviewServiceGrpc.ReviewServiceBlockingStub reviewStub = ReviewServiceGrpc - .newBlockingStub(channel) -======= ReviewServiceGrpc.ReviewServiceBlockingStub reviewStub = ReviewServiceGrpc.newBlockingStub(channel) ->>>>>>> feat/116 .withDeadlineAfter(2, TimeUnit.SECONDS); GetAllReviewsByUserIdResponse response = reviewStub.getAllReviewsByUserId(request); From ad5ba18a4cb37e3babe9c26d615d915f42edb11b Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:29:08 +0900 Subject: [PATCH 105/116] =?UTF-8?q?fix:=20settings.gradle=20shopping-cart?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/settings.gradle b/settings.gradle index 9255a500..41f9a0a6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,3 +9,4 @@ include 'services:release' include 'services:waiting' include 'services:order' include 'services:search' +include 'services:delivery' From bdbff9692bbd8ac3ab54c7b99d9bae378d909613 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:34:19 +0900 Subject: [PATCH 106/116] =?UTF-8?q?fix:=20settings.gradle=20delivery=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(feat/120=EC=97=90=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=97=86=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/settings.gradle b/settings.gradle index e54a62fa..d58c0345 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,5 +9,4 @@ include 'services:release' include 'services:waiting' include 'services:order' include 'services:search' -include 'services:delivery' include 'services:orchestrator' From 14a67c30eab78916c6b7ab1cbecda25e6b2d5a66 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:47:33 +0900 Subject: [PATCH 107/116] =?UTF-8?q?fix(grpc):=20proto=20Release=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=88=84=EB=9D=BD=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20created=5Fat=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release.proto: ReleaseStatus enum 추가, Release 메시지에 scheduled_at/status/limited_quantity/sold_quantity 필드 추가 - ReleaseGrpcService: toProtoRelease()에서 createdAt을 scheduledAt으로 잘못 매핑하던 버그 수정, 누락 필드 매핑 추가 - creator/release.proto: release 서비스 proto와 동기화 (ReleaseStatus enum, 누락 필드 추가) - ReleaseResult: soldQuantity/status/scheduledAt/createdAt 필드 추가 - ReleaseStatus: 신규 enum 추가 (SCHEDULED/ON_SALE/SOLD_OUT/ENDED) - ReleaseQueryGrpcAdapter: 신규 필드 매핑 추가, import 정렬 Co-Authored-By: Claude Sonnet 4.6 --- .../grpc/ReleaseQueryGrpcAdapter.java | 6 +++++ .../application/dto/result/ReleaseResult.java | 8 ++++++- .../application/dto/result/ReleaseStatus.java | 8 +++++++ services/creator/src/main/proto/release.proto | 14 ++++++++++-- .../adapter/in/grpc/ReleaseGrpcService.java | 22 +++++++++++++++---- services/release/src/main/proto/release.proto | 11 ++++++++++ 6 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 services/creator/src/main/java/kr/magicbox/creator/application/dto/result/ReleaseStatus.java diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java index fdeb06c2..05a58d76 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java @@ -7,6 +7,7 @@ import kr.magicbox.creator.application.dto.result.ReleaseId; import kr.magicbox.creator.application.dto.result.ReleaseLevel; import kr.magicbox.creator.application.dto.result.ReleaseResult; +import kr.magicbox.creator.application.dto.result.ReleaseStatus; import kr.magicbox.creator.application.port.out.ReleaseQueryPort; import kr.magicbox.creator.grpc.release.GetReleaseCountRequest; import kr.magicbox.creator.grpc.release.GetReleaseCountResponse; @@ -18,6 +19,7 @@ import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.stereotype.Component; +import java.time.Instant; import java.util.List; import java.util.concurrent.TimeUnit; @@ -65,6 +67,10 @@ public List getReleases(Long creatorId) { .creatorNickname(release.getCreatorNickname()) .price(release.getPrice()) .limitedQuantity(release.getLimitedQuantity()) + .soldQuantity(release.getSoldQuantity()) + .status(ReleaseStatus.valueOf(release.getStatus().name())) + .scheduledAt(Instant.ofEpochSecond(release.getScheduledAt().getSeconds(), release.getScheduledAt().getNanos())) + .createdAt(Instant.ofEpochSecond(release.getCreatedAt().getSeconds(), release.getCreatedAt().getNanos())) .build()) .toList(); } diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/dto/result/ReleaseResult.java b/services/creator/src/main/java/kr/magicbox/creator/application/dto/result/ReleaseResult.java index 359b98cc..fcd6bc56 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/application/dto/result/ReleaseResult.java +++ b/services/creator/src/main/java/kr/magicbox/creator/application/dto/result/ReleaseResult.java @@ -2,6 +2,8 @@ import lombok.Builder; +import java.time.Instant; + @Builder public record ReleaseResult( ReleaseId releaseId, @@ -10,6 +12,10 @@ public record ReleaseResult( ReleaseLevel level, String creatorNickname, long price, - int limitedQuantity + int limitedQuantity, + int soldQuantity, + ReleaseStatus status, + Instant scheduledAt, + Instant createdAt ) { } \ No newline at end of file diff --git a/services/creator/src/main/java/kr/magicbox/creator/application/dto/result/ReleaseStatus.java b/services/creator/src/main/java/kr/magicbox/creator/application/dto/result/ReleaseStatus.java new file mode 100644 index 00000000..8e9e5965 --- /dev/null +++ b/services/creator/src/main/java/kr/magicbox/creator/application/dto/result/ReleaseStatus.java @@ -0,0 +1,8 @@ +package kr.magicbox.creator.application.dto.result; + +public enum ReleaseStatus { + SCHEDULED, + ON_SALE, + SOLD_OUT, + ENDED +} diff --git a/services/creator/src/main/proto/release.proto b/services/creator/src/main/proto/release.proto index 58fb3775..3724d059 100644 --- a/services/creator/src/main/proto/release.proto +++ b/services/creator/src/main/proto/release.proto @@ -35,6 +35,13 @@ enum ReleaseLevel { ADVANCED = 2; } +enum ReleaseStatus { + SCHEDULED = 0; + ON_SALE = 1; + SOLD_OUT = 2; + ENDED = 3; +} + message Release { int64 release_id = 1; string title = 2; @@ -42,6 +49,9 @@ message Release { ReleaseLevel level = 4; string creator_nickname = 5; int64 price = 6; - int32 limited_quantity = 7; - google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp scheduled_at = 8; + ReleaseStatus status = 9; + int32 limited_quantity = 10; + int32 sold_quantity = 11; } \ No newline at end of file diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java index ff340355..fd6e55ad 100644 --- a/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java @@ -27,7 +27,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.grpc.server.service.GrpcService; -import java.time.Instant; import java.util.List; @GrpcService @@ -98,7 +97,6 @@ public void increaseSoldQuantity(IncreaseSoldQuantityRequest request, } private Release toProtoRelease(ReleaseResult result) { - Instant scheduledAt = result.scheduledAt(); return Release.newBuilder() .setReleaseId(result.releaseId()) .setTitle(result.title()) @@ -106,9 +104,16 @@ private Release toProtoRelease(ReleaseResult result) { .setLevel(toProtoLevel(result.level())) .setPrice(result.price()) .setCreatedAt(Timestamp.newBuilder() - .setSeconds(scheduledAt.getEpochSecond()) - .setNanos(scheduledAt.getNano()) + .setSeconds(result.createdAt().getEpochSecond()) + .setNanos(result.createdAt().getNano()) .build()) + .setScheduledAt(Timestamp.newBuilder() + .setSeconds(result.scheduledAt().getEpochSecond()) + .setNanos(result.scheduledAt().getNano()) + .build()) + .setStatus(toProtoStatus(result.status())) + .setLimitedQuantity(result.limitedQuantity()) + .setSoldQuantity(result.soldQuantity()) .build(); } @@ -119,4 +124,13 @@ private ReleaseLevel toProtoLevel(kr.magicbox.release.domain.enums.ReleaseLevel case ADVANCED -> ReleaseLevel.ADVANCED; }; } + + private kr.magicbox.release.grpc.release.ReleaseStatus toProtoStatus(kr.magicbox.release.domain.enums.ReleaseStatus status) { + return switch (status) { + case SCHEDULED -> kr.magicbox.release.grpc.release.ReleaseStatus.SCHEDULED; + case ON_SALE -> kr.magicbox.release.grpc.release.ReleaseStatus.ON_SALE; + case SOLD_OUT -> kr.magicbox.release.grpc.release.ReleaseStatus.SOLD_OUT; + case ENDED -> kr.magicbox.release.grpc.release.ReleaseStatus.ENDED; + }; + } } diff --git a/services/release/src/main/proto/release.proto b/services/release/src/main/proto/release.proto index faed45ac..68ce5398 100644 --- a/services/release/src/main/proto/release.proto +++ b/services/release/src/main/proto/release.proto @@ -62,6 +62,13 @@ message IncreaseSoldQuantityResponse { bool sold_out = 1; } +enum ReleaseStatus { + SCHEDULED = 0; + ON_SALE = 1; + SOLD_OUT = 2; + ENDED = 3; +} + message Release { int64 release_id = 1; string title = 2; @@ -70,4 +77,8 @@ message Release { string creator_nickname = 5; int64 price = 6; google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp scheduled_at = 8; + ReleaseStatus status = 9; + int32 limited_quantity = 10; + int32 sold_quantity = 11; } From 418df4de654425d85b29e30f321a95638cc87e46 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:54:43 +0900 Subject: [PATCH 108/116] =?UTF-8?q?fix(scheduler):=20Redisson=20=EB=B6=84?= =?UTF-8?q?=EC=82=B0=20=EB=9D=BD=20+=20=EC=B2=AD=ED=81=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20AutoStartSale=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StartSaleScheduler: fixedDelay=60000 → cron="0 */10 * * * *" (10분 주기, ScheduledAtMultipleOfTenMinutes와 정렬) - StartSaleScheduler: RedissonClient.tryLock()으로 분산 락 적용 — 락 획득 실패 시 즉시 반환 - AutoStartSaleService: 단일 트랜잭션 전체 처리 → 청크 루프(100건 단위) + AutoStartSaleChunkService에서 건별 트랜잭션 - ReleaseRepositoryPort/ReleaseJpaAdapter/ReleaseJpaRepository: findScheduledBefore에 limit 파라미터 추가 (Pageable 기반) - build.gradle: redisson-spring-boot-starter 의존성 추가 - application-*.yml: Redis 설정 추가 Co-Authored-By: Claude Sonnet 4.6 --- services/release/build.gradle | 1 + .../in/scheduler/StartSaleScheduler.java | 23 +++++++++++++++---- .../out/persistence/ReleaseJpaAdapter.java | 5 ++-- .../repository/ReleaseJpaRepository.java | 3 ++- .../port/out/ReleaseRepositoryPort.java | 2 +- .../service/AutoStartSaleChunkService.java | 23 +++++++++++++++++++ .../service/AutoStartSaleService.java | 15 ++++++------ .../src/main/resources/application-dev.yml | 6 +++++ .../src/main/resources/application-local.yml | 6 +++++ .../src/main/resources/application-prod.yml | 6 +++++ 10 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleChunkService.java diff --git a/services/release/build.gradle b/services/release/build.gradle index 45a1ceb7..6c689332 100644 --- a/services/release/build.gradle +++ b/services/release/build.gradle @@ -19,6 +19,7 @@ dependencies { implementation 'org.springframework.grpc:spring-grpc-server-spring-boot-starter' implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter' implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + implementation 'org.redisson:redisson-spring-boot-starter:3.45.1' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/StartSaleScheduler.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/StartSaleScheduler.java index 474a079d..8e9390de 100644 --- a/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/StartSaleScheduler.java +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/scheduler/StartSaleScheduler.java @@ -3,20 +3,35 @@ import kr.magicbox.release.application.port.in.AutoStartSaleUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + @Slf4j @Component @RequiredArgsConstructor public class StartSaleScheduler { + private static final String LOCK_KEY = "lock:autoStartScheduledReleases"; + private final AutoStartSaleUseCase autoStartSaleUseCase; + private final RedissonClient redissonClient; - @Scheduled(fixedDelay = 60000) + @Scheduled(cron = "0 */10 * * * *") public void autoStartScheduledReleases() { - log.info("[Scheduler] 판매 예정 릴리즈 자동 오픈 시작"); - autoStartSaleUseCase.autoStartScheduledReleases(); - log.info("[Scheduler] 판매 예정 릴리즈 자동 오픈 완료"); + RLock lock = redissonClient.getLock(LOCK_KEY); + if (!lock.tryLock()) { + return; + } + try { + log.info("[Scheduler] 판매 예정 릴리즈 자동 오픈 시작"); + autoStartSaleUseCase.autoStartScheduledReleases(); + log.info("[Scheduler] 판매 예정 릴리즈 자동 오픈 완료"); + } finally { + lock.unlock(); + } } } diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/ReleaseJpaAdapter.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/ReleaseJpaAdapter.java index 23993608..aa6d002f 100644 --- a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/ReleaseJpaAdapter.java +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/ReleaseJpaAdapter.java @@ -10,6 +10,7 @@ import kr.magicbox.release.domain.vo.CreatorId; import kr.magicbox.release.domain.vo.ReleaseId; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; import java.time.Instant; @@ -57,8 +58,8 @@ public long countByCreatorId(CreatorId creatorId) { } @Override - public List findScheduledBefore(Instant scheduledAt) { - return releaseJpaRepository.findByStatusAndScheduledAtBefore(ReleaseStatus.SCHEDULED, scheduledAt) + public List findScheduledBefore(Instant scheduledAt, int limit) { + return releaseJpaRepository.findByStatusAndScheduledAtBefore(ReleaseStatus.SCHEDULED, scheduledAt, PageRequest.of(0, limit)) .stream() .map(releaseMapper::toDomain) .toList(); diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseJpaRepository.java b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseJpaRepository.java index aadf74da..483df133 100644 --- a/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseJpaRepository.java +++ b/services/release/src/main/java/kr/magicbox/release/adapter/out/persistence/repository/ReleaseJpaRepository.java @@ -2,6 +2,7 @@ import kr.magicbox.release.adapter.out.persistence.entity.ReleaseEntity; import kr.magicbox.release.domain.enums.ReleaseStatus; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.time.Instant; @@ -16,5 +17,5 @@ public interface ReleaseJpaRepository extends JpaRepository long countByCreatorId(Long creatorId); - List findByStatusAndScheduledAtBefore(ReleaseStatus status, Instant scheduledAt); + List findByStatusAndScheduledAtBefore(ReleaseStatus status, Instant scheduledAt, Pageable pageable); } diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/out/ReleaseRepositoryPort.java b/services/release/src/main/java/kr/magicbox/release/application/port/out/ReleaseRepositoryPort.java index f340d839..5e867a56 100644 --- a/services/release/src/main/java/kr/magicbox/release/application/port/out/ReleaseRepositoryPort.java +++ b/services/release/src/main/java/kr/magicbox/release/application/port/out/ReleaseRepositoryPort.java @@ -19,5 +19,5 @@ public interface ReleaseRepositoryPort { long countByCreatorId(CreatorId creatorId); - List findScheduledBefore(Instant scheduledAt); + List findScheduledBefore(Instant scheduledAt, int limit); } diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleChunkService.java b/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleChunkService.java new file mode 100644 index 00000000..328881a1 --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleChunkService.java @@ -0,0 +1,23 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoStartSaleChunkService { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Transactional + public void startOne(Release release) { + release.startSale(); + releaseRepositoryPort.update(release); + log.info("[AutoStartSale] 판매 시작 처리. releaseId={}", release.getId().value()); + } +} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleService.java b/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleService.java index 56ca85d4..0e53bddb 100644 --- a/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleService.java +++ b/services/release/src/main/java/kr/magicbox/release/application/service/AutoStartSaleService.java @@ -5,7 +5,6 @@ import kr.magicbox.release.domain.aggregate.Release; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; @@ -14,15 +13,17 @@ @RequiredArgsConstructor public class AutoStartSaleService implements AutoStartSaleUseCase { + private static final int CHUNK_SIZE = 100; + private final ReleaseRepositoryPort releaseRepositoryPort; + private final AutoStartSaleChunkService autoStartSaleChunkService; @Override - @Transactional public void autoStartScheduledReleases() { - List releases = releaseRepositoryPort.findScheduledBefore(Instant.now()); - releases.forEach(release -> { - release.startSale(); - releaseRepositoryPort.update(release); - }); + List chunk; + do { + chunk = releaseRepositoryPort.findScheduledBefore(Instant.now(), CHUNK_SIZE); + chunk.forEach(autoStartSaleChunkService::startOne); + } while (chunk.size() == CHUNK_SIZE); } } diff --git a/services/release/src/main/resources/application-dev.yml b/services/release/src/main/resources/application-dev.yml index 0b919cf2..61798003 100644 --- a/services/release/src/main/resources/application-dev.yml +++ b/services/release/src/main/resources/application-dev.yml @@ -33,6 +33,12 @@ spring: backoff: delay: 1s max-delay: 3s + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + timeout: 2000ms datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} diff --git a/services/release/src/main/resources/application-local.yml b/services/release/src/main/resources/application-local.yml index eb3995ca..6d138563 100644 --- a/services/release/src/main/resources/application-local.yml +++ b/services/release/src/main/resources/application-local.yml @@ -36,6 +36,12 @@ spring: backoff: delay: 1s max-delay: 3s + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + timeout: 2000ms datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} diff --git a/services/release/src/main/resources/application-prod.yml b/services/release/src/main/resources/application-prod.yml index 36e41baf..3fc8b973 100644 --- a/services/release/src/main/resources/application-prod.yml +++ b/services/release/src/main/resources/application-prod.yml @@ -33,6 +33,12 @@ spring: backoff: delay: 1s max-delay: 10s + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + timeout: 2000ms datasource: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} From 4b9b8fadd4bd0b8e69ff43285f03f5f2796041ab Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 16:36:07 +0900 Subject: [PATCH 109/116] =?UTF-8?q?chore(deps):=20redisson-spring-boot-sta?= =?UTF-8?q?rter=203.45.1=20=E2=86=92=203.50.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/release/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/release/build.gradle b/services/release/build.gradle index 6c689332..2bc7447b 100644 --- a/services/release/build.gradle +++ b/services/release/build.gradle @@ -19,7 +19,7 @@ dependencies { implementation 'org.springframework.grpc:spring-grpc-server-spring-boot-starter' implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter' implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' - implementation 'org.redisson:redisson-spring-boot-starter:3.45.1' + implementation 'org.redisson:redisson-spring-boot-starter:3.50.0' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' From 6cdb350287d978c303beb6b9b17cec86bc53b0b6 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 16:43:21 +0900 Subject: [PATCH 110/116] =?UTF-8?q?refactor(grpc):=20IncreaseSoldQuantity?= =?UTF-8?q?=20gRPC=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=E2=80=94=20Kafka=20Inbox=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sold_quantity 증가는 Outbox→Inbox(Kafka) 경로만 사용하므로 gRPC 중복 경로 제거. - release.proto: rpc IncreaseSoldQuantity 및 관련 메시지 제거 - ReleaseGrpcService: increaseSoldQuantity 메서드 및 IncreaseSoldQuantityUseCase 의존성 제거 - IncreaseSoldQuantityUseCase, IncreaseSoldQuantityService 삭제 Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/in/grpc/ReleaseGrpcService.java | 12 ---------- .../port/in/IncreaseSoldQuantityUseCase.java | 7 ------ .../service/IncreaseSoldQuantityService.java | 24 ------------------- services/release/src/main/proto/release.proto | 8 ------- 4 files changed, 51 deletions(-) delete mode 100644 services/release/src/main/java/kr/magicbox/release/application/port/in/IncreaseSoldQuantityUseCase.java delete mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/IncreaseSoldQuantityService.java diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java index fd6e55ad..9f5fb5d6 100644 --- a/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java @@ -7,7 +7,6 @@ import kr.magicbox.release.application.port.in.GetReleaseCountByCreatorUseCase; import kr.magicbox.release.application.port.in.GetReleaseListByCreatorUseCase; import kr.magicbox.release.application.port.in.GetReleaseUseCase; -import kr.magicbox.release.application.port.in.IncreaseSoldQuantityUseCase; import kr.magicbox.release.domain.enums.ReleaseStatus; import kr.magicbox.release.domain.vo.CreatorId; import kr.magicbox.release.domain.vo.ReleaseId; @@ -17,8 +16,6 @@ import kr.magicbox.release.grpc.release.GetReleasesByCreatorIdResponse; import kr.magicbox.release.grpc.release.GetRemainingQuantityRequest; import kr.magicbox.release.grpc.release.GetRemainingQuantityResponse; -import kr.magicbox.release.grpc.release.IncreaseSoldQuantityRequest; -import kr.magicbox.release.grpc.release.IncreaseSoldQuantityResponse; import kr.magicbox.release.grpc.release.IsReleaseOnSaleRequest; import kr.magicbox.release.grpc.release.IsReleaseOnSaleResponse; import kr.magicbox.release.grpc.release.Release; @@ -36,7 +33,6 @@ public class ReleaseGrpcService extends ReleaseServiceGrpc.ReleaseServiceImplBas private final GetReleaseCountByCreatorUseCase getReleaseCountByCreatorUseCase; private final GetReleaseListByCreatorUseCase getReleaseListByCreatorUseCase; private final GetReleaseUseCase getReleaseUseCase; - private final IncreaseSoldQuantityUseCase increaseSoldQuantityUseCase; @Override public void getReleaseCount(GetReleaseCountRequest request, @@ -88,14 +84,6 @@ public void getRemainingQuantity(GetRemainingQuantityRequest request, responseObserver.onCompleted(); } - @Override - public void increaseSoldQuantity(IncreaseSoldQuantityRequest request, - StreamObserver responseObserver) { - increaseSoldQuantityUseCase.increaseSoldQuantity(ReleaseId.of(request.getReleaseId())); - responseObserver.onNext(IncreaseSoldQuantityResponse.newBuilder().build()); - responseObserver.onCompleted(); - } - private Release toProtoRelease(ReleaseResult result) { return Release.newBuilder() .setReleaseId(result.releaseId()) diff --git a/services/release/src/main/java/kr/magicbox/release/application/port/in/IncreaseSoldQuantityUseCase.java b/services/release/src/main/java/kr/magicbox/release/application/port/in/IncreaseSoldQuantityUseCase.java deleted file mode 100644 index 375d4333..00000000 --- a/services/release/src/main/java/kr/magicbox/release/application/port/in/IncreaseSoldQuantityUseCase.java +++ /dev/null @@ -1,7 +0,0 @@ -package kr.magicbox.release.application.port.in; - -import kr.magicbox.release.domain.vo.ReleaseId; - -public interface IncreaseSoldQuantityUseCase { - void increaseSoldQuantity(ReleaseId releaseId); -} diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/IncreaseSoldQuantityService.java b/services/release/src/main/java/kr/magicbox/release/application/service/IncreaseSoldQuantityService.java deleted file mode 100644 index 49516a43..00000000 --- a/services/release/src/main/java/kr/magicbox/release/application/service/IncreaseSoldQuantityService.java +++ /dev/null @@ -1,24 +0,0 @@ -package kr.magicbox.release.application.service; - -import kr.magicbox.release.application.port.in.IncreaseSoldQuantityUseCase; -import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; -import kr.magicbox.release.domain.aggregate.Release; -import kr.magicbox.release.domain.vo.ReleaseId; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class IncreaseSoldQuantityService implements IncreaseSoldQuantityUseCase { - - private final ReleaseRepositoryPort releaseRepositoryPort; - - @Override - @Transactional - public void increaseSoldQuantity(ReleaseId releaseId) { - Release release = releaseRepositoryPort.findById(releaseId); - release.increaseSoldQuantity(); - releaseRepositoryPort.update(release); - } -} diff --git a/services/release/src/main/proto/release.proto b/services/release/src/main/proto/release.proto index 68ce5398..8b057e35 100644 --- a/services/release/src/main/proto/release.proto +++ b/services/release/src/main/proto/release.proto @@ -13,7 +13,6 @@ service ReleaseService { rpc GetReleasesByCreatorId(GetReleasesByCreatorIdRequest) returns (GetReleasesByCreatorIdResponse); rpc IsReleaseOnSale(IsReleaseOnSaleRequest) returns (IsReleaseOnSaleResponse); rpc GetRemainingQuantity(GetRemainingQuantityRequest) returns (GetRemainingQuantityResponse); - rpc IncreaseSoldQuantity(IncreaseSoldQuantityRequest) returns (IncreaseSoldQuantityResponse); } message GetReleaseCountRequest { @@ -54,13 +53,6 @@ message GetRemainingQuantityResponse { int32 remaining_quantity = 1; } -message IncreaseSoldQuantityRequest { - int64 release_id = 1; -} - -message IncreaseSoldQuantityResponse { - bool sold_out = 1; -} enum ReleaseStatus { SCHEDULED = 0; From 53f1aff3e1b5afdd1d23fc54a589c5b0065c4f34 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 17:15:15 +0900 Subject: [PATCH 111/116] =?UTF-8?q?refactor(release):=20gRPC=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EB=B0=96=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit creatorIdQueryPort.getCreatorId() (gRPC + CircuitBreaker)가 @Transactional 경계 안에 포함되어 DB 커넥션을 불필요하게 점유하는 문제 수정. - RegisterReleaseService: @Transactional 제거, gRPC 조회 후 RegisterReleaseTxService에 위임 - RegisterReleaseTxService: DB 저장만 담당하는 별도 서비스로 @Transactional 격리 (self-invocation으로 AOP 프록시가 무시되는 문제를 피하기 위해 별도 클래스로 분리) Co-Authored-By: Claude Sonnet 4.6 --- .../service/RegisterReleaseService.java | 19 ++---------- .../service/RegisterReleaseTxService.java | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseTxService.java diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseService.java b/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseService.java index 844dbecd..cdde8296 100644 --- a/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseService.java +++ b/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseService.java @@ -3,35 +3,20 @@ import kr.magicbox.release.application.dto.command.RegisterReleaseCommand; import kr.magicbox.release.application.port.in.RegisterReleaseUseCase; import kr.magicbox.release.application.port.out.CreatorIdQueryPort; -import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; -import kr.magicbox.release.domain.aggregate.Release; import kr.magicbox.release.domain.vo.CreatorId; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class RegisterReleaseService implements RegisterReleaseUseCase { - private final ReleaseRepositoryPort releaseRepositoryPort; private final CreatorIdQueryPort creatorIdQueryPort; + private final RegisterReleaseTxService registerReleaseTxService; @Override - @Transactional public Long registerRelease(RegisterReleaseCommand command) { CreatorId creatorId = creatorIdQueryPort.getCreatorId(command.userId()); - - Release release = Release.createBuilder() - .creatorId(creatorId) - .title(command.title()) - .description(command.description()) - .thumbnailUrl(command.thumbnailUrl()) - .level(command.level()) - .price(command.price()) - .limitedQuantity(command.limitedQuantity()) - .scheduledAt(command.scheduledAt()) - .build(); - return releaseRepositoryPort.save(release); + return registerReleaseTxService.save(creatorId, command); } } diff --git a/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseTxService.java b/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseTxService.java new file mode 100644 index 00000000..3eaa965a --- /dev/null +++ b/services/release/src/main/java/kr/magicbox/release/application/service/RegisterReleaseTxService.java @@ -0,0 +1,31 @@ +package kr.magicbox.release.application.service; + +import kr.magicbox.release.application.dto.command.RegisterReleaseCommand; +import kr.magicbox.release.application.port.out.ReleaseRepositoryPort; +import kr.magicbox.release.domain.aggregate.Release; +import kr.magicbox.release.domain.vo.CreatorId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RegisterReleaseTxService { + + private final ReleaseRepositoryPort releaseRepositoryPort; + + @Transactional + public Long save(CreatorId creatorId, RegisterReleaseCommand command) { + Release release = Release.createBuilder() + .creatorId(creatorId) + .title(command.title()) + .description(command.description()) + .thumbnailUrl(command.thumbnailUrl()) + .level(command.level()) + .price(command.price()) + .limitedQuantity(command.limitedQuantity()) + .scheduledAt(command.scheduledAt()) + .build(); + return releaseRepositoryPort.save(release); + } +} From 9a1f7d480a6304187b721743b605ea2d6a2acb41 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 17:25:22 +0900 Subject: [PATCH 112/116] =?UTF-8?q?fix(release):=20PR=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EC=BD=94=EB=A9=98=ED=8A=B8=20=EB=B0=98=EC=98=81=20=E2=80=94?= =?UTF-8?q?=20isOnSale=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9C=84=EC=9E=84,?= =?UTF-8?q?=20Validator=20=EA=B0=9C=EC=84=A0,=20gRPC=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=20=EC=9E=AC=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReleaseResult: isOnSale() 파생 메서드 추가 — 판매 중 판단 로직을 DTO에 집중 - ReleaseGrpcService: isReleaseOnSale에서 enum 직접 비교 → result.isOnSale() 위임, 불필요한 ReleaseStatus import 제거 - ScheduledAtMultipleOfTenMinutesValidator: atZone() 중복 호출 제거(ZonedDateTime 한 번만), 현재 시각+10분 이후만 허용하는 최소 예약 시간 검증 추가 - ReleaseQueryGrpcAdapter: 매 호출마다 새 채널 생성 → GrpcConfiguration의 releaseManagedChannel Bean 주입으로 채널 재사용 Co-Authored-By: Claude Sonnet 4.6 --- .../grpc/ReleaseQueryGrpcAdapter.java | 17 +++++++++-------- .../adapter/in/grpc/ReleaseGrpcService.java | 3 +-- ...cheduledAtMultipleOfTenMinutesValidator.java | 13 +++++++++---- .../application/dto/result/ReleaseResult.java | 4 ++++ 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java index 05a58d76..03b0f511 100644 --- a/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java +++ b/services/creator/src/main/java/kr/magicbox/creator/adapter/out/communication/grpc/ReleaseQueryGrpcAdapter.java @@ -2,7 +2,6 @@ import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import io.grpc.ManagedChannel; -import kr.magicbox.creator.adapter.out.communication.ServiceHost; import kr.magicbox.creator.adapter.out.communication.grpc.exception.ReleaseServiceUnavailableException; import kr.magicbox.creator.application.dto.result.ReleaseId; import kr.magicbox.creator.application.dto.result.ReleaseLevel; @@ -16,7 +15,7 @@ import kr.magicbox.creator.grpc.release.ReleaseServiceGrpc; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import java.time.Instant; @@ -24,10 +23,14 @@ import java.util.concurrent.TimeUnit; @Component -@RequiredArgsConstructor @Slf4j public class ReleaseQueryGrpcAdapter implements ReleaseQueryPort { - private final GrpcChannelFactory grpcChannelFactory; + + private final ManagedChannel releaseManagedChannel; + + public ReleaseQueryGrpcAdapter(@Qualifier("releaseManagedChannel") ManagedChannel releaseManagedChannel) { + this.releaseManagedChannel = releaseManagedChannel; + } @Override @CircuitBreaker(name = "releaseService", fallbackMethod = "getReleaseCountFallback") @@ -36,9 +39,8 @@ public long getReleaseCount(Long creatorId) { .setCreatorId(creatorId) .build(); - ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc - .newBlockingStub(channel) + .newBlockingStub(releaseManagedChannel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleaseCountResponse response = stub.getReleaseCount(request); @@ -52,9 +54,8 @@ public List getReleases(Long creatorId) { .setCreatorId(creatorId) .build(); - ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName()); ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc - .newBlockingStub(channel) + .newBlockingStub(releaseManagedChannel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetReleasesByCreatorIdResponse response = stub.getReleasesByCreatorId(request); diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java index 9f5fb5d6..1732b805 100644 --- a/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/grpc/ReleaseGrpcService.java @@ -7,7 +7,6 @@ import kr.magicbox.release.application.port.in.GetReleaseCountByCreatorUseCase; import kr.magicbox.release.application.port.in.GetReleaseListByCreatorUseCase; import kr.magicbox.release.application.port.in.GetReleaseUseCase; -import kr.magicbox.release.domain.enums.ReleaseStatus; import kr.magicbox.release.domain.vo.CreatorId; import kr.magicbox.release.domain.vo.ReleaseId; import kr.magicbox.release.grpc.release.GetReleaseCountRequest; @@ -65,7 +64,7 @@ public void isReleaseOnSale(IsReleaseOnSaleRequest request, StreamObserver responseObserver) { ReleaseResult result = getReleaseUseCase.getRelease( GetReleaseQuery.builder().releaseId(request.getReleaseId()).build()); - boolean onSale = result.status() == ReleaseStatus.ON_SALE; + boolean onSale = result.isOnSale(); responseObserver.onNext(IsReleaseOnSaleResponse.newBuilder() .setOnSale(onSale) .build()); diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java index 89e38254..238ebdae 100644 --- a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java @@ -5,16 +5,21 @@ import java.time.Instant; import java.time.ZoneOffset; +import java.time.ZonedDateTime; public class ScheduledAtMultipleOfTenMinutesValidator implements ConstraintValidator { + private static final int MIN_MINUTES_FROM_NOW = 10; + @Override public boolean isValid(Instant value, ConstraintValidatorContext context) { if (value == null) return true; // null 체크는 @NotNull이 담당 - int minute = value.atZone(ZoneOffset.UTC).getMinute(); - int second = value.atZone(ZoneOffset.UTC).getSecond(); - int nano = value.getNano(); - return minute % 10 == 0 && second == 0 && nano == 0; + ZonedDateTime zonedDateTime = value.atZone(ZoneOffset.UTC); // KST(+09:00)는 분 오프셋 없음 — UTC 기준과 동일 + boolean isMultipleOfTen = zonedDateTime.getMinute() % 10 == 0 + && zonedDateTime.getSecond() == 0 + && zonedDateTime.getNano() == 0; + boolean isFarEnough = value.isAfter(Instant.now().plusSeconds(MIN_MINUTES_FROM_NOW * 60L)); + return isMultipleOfTen && isFarEnough; } } diff --git a/services/release/src/main/java/kr/magicbox/release/application/dto/result/ReleaseResult.java b/services/release/src/main/java/kr/magicbox/release/application/dto/result/ReleaseResult.java index 68486365..b630a0a4 100644 --- a/services/release/src/main/java/kr/magicbox/release/application/dto/result/ReleaseResult.java +++ b/services/release/src/main/java/kr/magicbox/release/application/dto/result/ReleaseResult.java @@ -23,6 +23,10 @@ public record ReleaseResult( Instant createdAt, Instant updatedAt ) { + public boolean isOnSale() { + return status == ReleaseStatus.ON_SALE; + } + public static ReleaseResult from(Release release) { return ReleaseResult.builder() .releaseId(release.getId().value()) From 3bf465498376ca0c48a32a08a382013486b99199 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 17:37:30 +0900 Subject: [PATCH 113/116] =?UTF-8?q?refactor(validation):=20ZonedDateTime?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20epochSecond=20%=20600=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../ScheduledAtMultipleOfTenMinutesValidator.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java index 238ebdae..ae84ac81 100644 --- a/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java +++ b/services/release/src/main/java/kr/magicbox/release/adapter/in/web/validation/ScheduledAtMultipleOfTenMinutesValidator.java @@ -4,22 +4,17 @@ import jakarta.validation.ConstraintValidatorContext; import java.time.Instant; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; public class ScheduledAtMultipleOfTenMinutesValidator implements ConstraintValidator { - private static final int MIN_MINUTES_FROM_NOW = 10; + private static final long TEN_MINUTES_SECONDS = 600L; @Override public boolean isValid(Instant value, ConstraintValidatorContext context) { if (value == null) return true; // null 체크는 @NotNull이 담당 - ZonedDateTime zonedDateTime = value.atZone(ZoneOffset.UTC); // KST(+09:00)는 분 오프셋 없음 — UTC 기준과 동일 - boolean isMultipleOfTen = zonedDateTime.getMinute() % 10 == 0 - && zonedDateTime.getSecond() == 0 - && zonedDateTime.getNano() == 0; - boolean isFarEnough = value.isAfter(Instant.now().plusSeconds(MIN_MINUTES_FROM_NOW * 60L)); + boolean isMultipleOfTen = value.getEpochSecond() % TEN_MINUTES_SECONDS == 0; + boolean isFarEnough = value.isAfter(Instant.now().plusSeconds(TEN_MINUTES_SECONDS)); return isMultipleOfTen && isFarEnough; } } From 4017fd359cd444bf612a4792c00184efe59761d0 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Sat, 23 May 2026 15:27:21 +0900 Subject: [PATCH 114/116] =?UTF-8?q?fix(general-goods):=20CreatorGrpcAdapte?= =?UTF-8?q?r=20=E2=80=94=20grpcChannelFactory=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=A0=9C=EA=B1=B0,=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EB=90=9C=20creatorManagedChannel=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../adapter/out/communication/grpc/CreatorGrpcAdapter.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java index cd38c4f2..53dc9d31 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java @@ -15,7 +15,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit; @@ -32,8 +31,7 @@ public CreatorId getCreatorId(UserId userId) { .setUserId(userId.value()) .build(); - ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.CREATOR.getHostName()); - GeneralGoodsServiceGrpc.GeneralGoodsServiceBlockingStub stub = GeneralGoodsServiceGrpc.newBlockingStub(channel) + CreatorServiceGrpc.CreatorServiceBlockingStub stub = CreatorServiceGrpc.newBlockingStub(creatorManagedChannel) .withDeadlineAfter(2, TimeUnit.SECONDS); GetCreatorIdByUserIdResponse response = stub.getCreatorIdByUserId(request); @@ -49,4 +47,4 @@ private CreatorId getCreatorIdFallback(UserId userId, Throwable throwable) { log.warn("크리에이터 서비스 연결 실패"); throw new CreatorServiceUnavailableException(throwable); } -} \ No newline at end of file +} From 0722dca2b093291c42fb49da533e77b85af48eed Mon Sep 17 00:00:00 2001 From: Lian08 Date: Sat, 23 May 2026 15:35:41 +0900 Subject: [PATCH 115/116] =?UTF-8?q?fix(order):=20shedlock-provider-redisso?= =?UTF-8?q?n=20=E2=86=92=20shedlock-provider-redis-spring=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20(Maven=20Central=20=EB=AF=B8=EC=A1=B4=EC=9E=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/order/build.gradle | 4 ++-- .../adapter/in/scheduler/SchedulerConfiguration.java | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/order/build.gradle b/services/order/build.gradle index 125cd81e..22bd44bf 100644 --- a/services/order/build.gradle +++ b/services/order/build.gradle @@ -18,8 +18,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter' implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' - implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2' - implementation 'net.javacrumbs.shedlock:shedlock-provider-redisson:6.9.2' + implementation 'net.javacrumbs.shedlock:shedlock-spring:7.7.0' + implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:7.7.0' implementation 'org.redisson:redisson-spring-boot-starter:3.45.1' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java index 7c06f32e..f9b3c2ce 100644 --- a/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/scheduler/SchedulerConfiguration.java @@ -2,12 +2,12 @@ import kr.magicbox.order.adapter.in.scheduler.properties.AutoConfirmProperties; import net.javacrumbs.shedlock.core.LockProvider; -import net.javacrumbs.shedlock.provider.redisson.RedissonLockProvider; +import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider; import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; -import org.redisson.api.RedissonClient; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling @@ -17,7 +17,7 @@ public class SchedulerConfiguration { @Bean - public LockProvider lockProvider(RedissonClient redissonClient) { - return new RedissonLockProvider(redissonClient); + public LockProvider lockProvider(RedisConnectionFactory redisConnectionFactory) { + return new RedisLockProvider(redisConnectionFactory); } } From 82f2719c47d8a6db193457e641e15ebacbbafb47 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Sat, 23 May 2026 15:38:14 +0900 Subject: [PATCH 116/116] =?UTF-8?q?fix(docker):=20COPY=20--chown=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20chown=20RUN=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=E2=80=94=20I/O=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- services/auth/Dockerfile | 3 +-- services/creator/Dockerfile | 3 +-- services/general-goods/Dockerfile | 3 +-- services/order/Dockerfile | 3 +-- services/release/Dockerfile | 3 +-- services/search/Dockerfile | 3 +-- services/subscribe/Dockerfile | 3 +-- services/user/Dockerfile | 3 +-- services/waiting/Dockerfile | 3 +-- 9 files changed, 9 insertions(+), 18 deletions(-) diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/creator/Dockerfile b/services/creator/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/creator/Dockerfile +++ b/services/creator/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/general-goods/Dockerfile b/services/general-goods/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/general-goods/Dockerfile +++ b/services/general-goods/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/order/Dockerfile b/services/order/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/order/Dockerfile +++ b/services/order/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/release/Dockerfile b/services/release/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/release/Dockerfile +++ b/services/release/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/search/Dockerfile b/services/search/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/search/Dockerfile +++ b/services/search/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/subscribe/Dockerfile b/services/subscribe/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/subscribe/Dockerfile +++ b/services/subscribe/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/user/Dockerfile b/services/user/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/user/Dockerfile +++ b/services/user/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/waiting/Dockerfile b/services/waiting/Dockerfile index c7049a93..cc9dbc2f 100644 --- a/services/waiting/Dockerfile +++ b/services/waiting/Dockerfile @@ -2,8 +2,7 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=build/libs/*.jar RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app -COPY ${JAR_FILE} app.jar -RUN chown -R appuser:appuser /app +COPY --chown=appuser:appuser ${JAR_FILE} app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]