From 35db47e064669bedd90eeef536350f62ae562e3c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:07:12 +0900 Subject: [PATCH 001/107] =?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/107] =?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/107] =?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/107] =?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/107] =?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/107] =?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/107] =?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/107] =?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 867c9cec79bf83971e7f961a5baf42e3b28f3b82 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:09:01 +0900 Subject: [PATCH 009/107] =?UTF-8?q?feat/119=20::=20Delivery=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=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) --- .../delivery/domain/aggregate/Delivery.java | 89 +++++++++++++++++++ .../domain/aggregate/TrackingHistory.java | 38 ++++++++ .../delivery/domain/enums/DeliveryStatus.java | 7 ++ .../domain/enums/TrackingHistoryStatus.java | 7 ++ .../domain/event/DeliveryCompletedEvent.java | 27 ++++++ .../domain/event/DeliveryDomainEvent.java | 7 ++ .../domain/event/DeliveryDomainEventType.java | 13 +++ .../domain/event/DeliveryStartedEvent.java | 29 ++++++ .../DeliveryAlreadyStartedException.java | 11 +++ .../exception/DeliveryNotFoundException.java | 11 +++ .../exception/InvalidFieldException.java | 11 +++ .../delivery/domain/vo/DeliveryId.java | 16 ++++ .../delivery/domain/vo/TrackingHistoryId.java | 16 ++++ .../delivery/domain/vo/TrackingInfo.java | 22 +++++ .../magicbox/delivery/domain/vo/UserId.java | 16 ++++ .../FeignClientConfiguration.java | 9 ++ .../global/exception/BaseException.java | 20 +++++ .../global/exception/BusinessException.java | 17 ++++ .../global/exception/SystemError.java | 14 +++ 19 files changed, 380 insertions(+) create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/aggregate/Delivery.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/aggregate/TrackingHistory.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/enums/DeliveryStatus.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/enums/TrackingHistoryStatus.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryCompletedEvent.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryDomainEvent.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryDomainEventType.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryStartedEvent.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/DeliveryAlreadyStartedException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/DeliveryNotFoundException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/InvalidFieldException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/DeliveryId.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/TrackingHistoryId.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/TrackingInfo.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/UserId.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/global/configuration/FeignClientConfiguration.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/global/exception/BaseException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/global/exception/BusinessException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/global/exception/SystemError.java diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/aggregate/Delivery.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/aggregate/Delivery.java new file mode 100644 index 00000000..4b435c1e --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/aggregate/Delivery.java @@ -0,0 +1,89 @@ +package kr.magicbox.delivery.domain.aggregate; + +import kr.magicbox.delivery.domain.enums.DeliveryStatus; +import kr.magicbox.delivery.domain.exception.InvalidFieldException; +import kr.magicbox.delivery.domain.vo.DeliveryId; +import kr.magicbox.delivery.domain.vo.TrackingInfo; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Getter +public class Delivery { + + private final DeliveryId id; + private final Long orderLineId; + private final Long orderId; + private DeliveryStatus status; + private final TrackingInfo trackingInfo; + private final List trackingHistories; + private final Instant createdAt; + private Instant updatedAt; + + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public Delivery(Long orderLineId, Long orderId, TrackingInfo trackingInfo) { + if (orderLineId == null || orderLineId <= 0) throw new InvalidFieldException("주문 라인 ID는 양수여야 합니다."); + if (orderId == null || orderId <= 0) throw new InvalidFieldException("주문 ID는 양수여야 합니다."); + if (trackingInfo == null) throw new InvalidFieldException("운송 정보는 필수입니다."); + this.id = null; + this.orderLineId = orderLineId; + this.orderId = orderId; + this.status = DeliveryStatus.SHIPPED; + this.trackingInfo = trackingInfo; + this.trackingHistories = new ArrayList<>(); + this.createdAt = Instant.now(); + this.updatedAt = Instant.now(); + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public Delivery(DeliveryId id, Long orderLineId, Long orderId, DeliveryStatus status, TrackingInfo trackingInfo, + List trackingHistories, Instant createdAt, Instant updatedAt) { + if (id == null) throw new InvalidFieldException("배송 ID는 필수입니다."); + if (orderLineId == null || orderLineId <= 0) throw new InvalidFieldException("주문 라인 ID는 양수여야 합니다."); + if (orderId == null || orderId <= 0) throw new InvalidFieldException("주문 ID는 양수여야 합니다."); + if (status == null) throw new InvalidFieldException("배송 상태는 필수입니다."); + if (trackingInfo == null) throw new InvalidFieldException("운송 정보는 필수입니다."); + if (createdAt == null) throw new InvalidFieldException("생성 시각은 필수입니다."); + if (updatedAt == null) throw new InvalidFieldException("수정 시각은 필수입니다."); + this.id = id; + this.orderLineId = orderLineId; + this.orderId = orderId; + this.status = status; + this.trackingInfo = trackingInfo; + this.trackingHistories = trackingHistories != null ? new ArrayList<>(trackingHistories) : new ArrayList<>(); + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public void transit(TrackingHistory history) { + validateStatus(DeliveryStatus.SHIPPED); + this.status = DeliveryStatus.IN_TRANSIT; + addHistory(history); + this.updatedAt = Instant.now(); + } + + public void complete(TrackingHistory history) { + if (this.status != DeliveryStatus.SHIPPED && this.status != DeliveryStatus.IN_TRANSIT) { + throw new InvalidFieldException("현재 상태에서 배송 완료 처리를 할 수 없습니다: " + this.status); + } + this.status = DeliveryStatus.DELIVERED; + addHistory(history); + this.updatedAt = Instant.now(); + } + + public void addHistory(TrackingHistory history) { + if (history != null) { + this.trackingHistories.add(history); + } + } + + private void validateStatus(DeliveryStatus expected) { + if (this.status != expected) { + throw new InvalidFieldException( + "현재 상태에서 해당 작업을 수행할 수 없습니다. 현재: " + this.status + ", 기대: " + expected); + } + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/aggregate/TrackingHistory.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/aggregate/TrackingHistory.java new file mode 100644 index 00000000..944c61c3 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/aggregate/TrackingHistory.java @@ -0,0 +1,38 @@ +package kr.magicbox.delivery.domain.aggregate; + +import kr.magicbox.delivery.domain.enums.TrackingHistoryStatus; +import kr.magicbox.delivery.domain.exception.InvalidFieldException; +import kr.magicbox.delivery.domain.vo.TrackingHistoryId; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; + +@Getter +public class TrackingHistory { + + private final TrackingHistoryId id; + private final TrackingHistoryStatus status; + private final String description; + private final Instant trackedAt; + + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public TrackingHistory(TrackingHistoryStatus status, String description, Instant trackedAt) { + if (status == null) throw new InvalidFieldException("추적 상태는 필수 값입니다."); + this.id = null; + this.status = status; + this.description = description; + this.trackedAt = trackedAt != null ? trackedAt : Instant.now(); + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public TrackingHistory(TrackingHistoryId id, TrackingHistoryStatus status, String description, Instant trackedAt) { + if (id == null) throw new InvalidFieldException("추적 이력 ID는 필수입니다."); + if (status == null) throw new InvalidFieldException("추적 상태는 필수 값입니다."); + if (trackedAt == null) throw new InvalidFieldException("추적 시각은 필수입니다."); + this.id = id; + this.status = status; + this.description = description; + this.trackedAt = trackedAt; + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/enums/DeliveryStatus.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/enums/DeliveryStatus.java new file mode 100644 index 00000000..093237ff --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/enums/DeliveryStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.delivery.domain.enums; + +public enum DeliveryStatus { + SHIPPED, + IN_TRANSIT, + DELIVERED +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/enums/TrackingHistoryStatus.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/enums/TrackingHistoryStatus.java new file mode 100644 index 00000000..14a16f47 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/enums/TrackingHistoryStatus.java @@ -0,0 +1,7 @@ +package kr.magicbox.delivery.domain.enums; + +public enum TrackingHistoryStatus { + SHIPPED, + IN_TRANSIT, + DELIVERED +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryCompletedEvent.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryCompletedEvent.java new file mode 100644 index 00000000..fccec1f6 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryCompletedEvent.java @@ -0,0 +1,27 @@ +package kr.magicbox.delivery.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record DeliveryCompletedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("delivery_id") Long deliveryId, + @JsonProperty("tracking_number") String trackingNumber, + @JsonProperty("delivered_at") Instant deliveredAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements DeliveryDomainEvent { + + @Override + public String key() { + return orderLineId.toString(); + } + + @Override + public DeliveryDomainEventType eventType() { + return DeliveryDomainEventType.DELIVERY_COMPLETED; + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryDomainEvent.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryDomainEvent.java new file mode 100644 index 00000000..43ffdb4b --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryDomainEvent.java @@ -0,0 +1,7 @@ +package kr.magicbox.delivery.domain.event; + +public interface DeliveryDomainEvent { + String key(); + + DeliveryDomainEventType eventType(); +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryDomainEventType.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryDomainEventType.java new file mode 100644 index 00000000..d4c7258c --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryDomainEventType.java @@ -0,0 +1,13 @@ +package kr.magicbox.delivery.domain.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DeliveryDomainEventType { + DELIVERY_STARTED("delivery-started"), + DELIVERY_COMPLETED("delivery-completed"); + + private final String value; +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryStartedEvent.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryStartedEvent.java new file mode 100644 index 00000000..c9505750 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/event/DeliveryStartedEvent.java @@ -0,0 +1,29 @@ +package kr.magicbox.delivery.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +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_code") String carrierCode, + @JsonProperty("tracking_number") String trackingNumber, + @JsonProperty("dispatched_at") Instant dispatchedAt, + @JsonProperty("occurred_at") Instant occurredAt +) implements DeliveryDomainEvent { + + @Override + public String key() { + return orderLineId.toString(); + } + + @Override + public DeliveryDomainEventType eventType() { + return DeliveryDomainEventType.DELIVERY_STARTED; + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/DeliveryAlreadyStartedException.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/DeliveryAlreadyStartedException.java new file mode 100644 index 00000000..392687b8 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/DeliveryAlreadyStartedException.java @@ -0,0 +1,11 @@ +package kr.magicbox.delivery.domain.exception; + +import kr.magicbox.delivery.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class DeliveryAlreadyStartedException extends BusinessException { + + public DeliveryAlreadyStartedException() { + super("이미 배송이 시작된 주문입니다.", HttpStatus.CONFLICT); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/DeliveryNotFoundException.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/DeliveryNotFoundException.java new file mode 100644 index 00000000..62a4a5a1 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/DeliveryNotFoundException.java @@ -0,0 +1,11 @@ +package kr.magicbox.delivery.domain.exception; + +import kr.magicbox.delivery.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class DeliveryNotFoundException extends BusinessException { + + public DeliveryNotFoundException() { + super("배송 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/InvalidFieldException.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/InvalidFieldException.java new file mode 100644 index 00000000..bac604ea --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/exception/InvalidFieldException.java @@ -0,0 +1,11 @@ +package kr.magicbox.delivery.domain.exception; + +import kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/domain/vo/DeliveryId.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/DeliveryId.java new file mode 100644 index 00000000..b2a7525c --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/DeliveryId.java @@ -0,0 +1,16 @@ +package kr.magicbox.delivery.domain.vo; + +import kr.magicbox.delivery.domain.exception.InvalidFieldException; + +public record DeliveryId(Long value) { + + public DeliveryId { + if (value == null || value <= 0) { + throw new InvalidFieldException("배송 ID는 양수여야 합니다."); + } + } + + public static DeliveryId of(Long value) { + return new DeliveryId(value); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/TrackingHistoryId.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/TrackingHistoryId.java new file mode 100644 index 00000000..d7a0e818 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/TrackingHistoryId.java @@ -0,0 +1,16 @@ +package kr.magicbox.delivery.domain.vo; + +import kr.magicbox.delivery.domain.exception.InvalidFieldException; + +public record TrackingHistoryId(Long value) { + + public TrackingHistoryId { + if (value == null || value <= 0) { + throw new InvalidFieldException("추적 이력 ID는 양수여야 합니다."); + } + } + + public static TrackingHistoryId of(Long value) { + return new TrackingHistoryId(value); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/TrackingInfo.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/TrackingInfo.java new file mode 100644 index 00000000..dbeb6314 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/TrackingInfo.java @@ -0,0 +1,22 @@ +package kr.magicbox.delivery.domain.vo; + +import kr.magicbox.delivery.domain.exception.InvalidFieldException; + +public record TrackingInfo( + String carrierCode, + String trackingNumber +) { + + public TrackingInfo { + if (carrierCode == null || carrierCode.isBlank()) { + throw new InvalidFieldException("택배사 코드는 필수 값입니다."); + } + if (trackingNumber == null || trackingNumber.isBlank()) { + throw new InvalidFieldException("운송장 번호는 필수 값입니다."); + } + } + + public static TrackingInfo of(String carrierCode, String trackingNumber) { + return new TrackingInfo(carrierCode, trackingNumber); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/UserId.java b/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/UserId.java new file mode 100644 index 00000000..bb487bf2 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/domain/vo/UserId.java @@ -0,0 +1,16 @@ +package kr.magicbox.delivery.domain.vo; + +import kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/global/configuration/FeignClientConfiguration.java b/services/delivery/src/main/java/kr/magicbox/delivery/global/configuration/FeignClientConfiguration.java new file mode 100644 index 00000000..b1dd3de9 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/global/configuration/FeignClientConfiguration.java @@ -0,0 +1,9 @@ +package kr.magicbox.delivery.global.configuration; + +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableFeignClients(basePackages = "kr.magicbox.delivery.adapter.out.communication") +public class FeignClientConfiguration { +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/global/exception/BaseException.java b/services/delivery/src/main/java/kr/magicbox/delivery/global/exception/BaseException.java new file mode 100644 index 00000000..e9e40320 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/global/exception/BaseException.java @@ -0,0 +1,20 @@ +package kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/global/exception/BusinessException.java b/services/delivery/src/main/java/kr/magicbox/delivery/global/exception/BusinessException.java new file mode 100644 index 00000000..13450b8d --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/global/exception/BusinessException.java @@ -0,0 +1,17 @@ +package kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/global/exception/SystemError.java b/services/delivery/src/main/java/kr/magicbox/delivery/global/exception/SystemError.java new file mode 100644 index 00000000..a065aae5 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/global/exception/SystemError.java @@ -0,0 +1,14 @@ +package kr.magicbox.delivery.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 0e3abbc50f617cd175a26c6f95091c59e594825a Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:09:01 +0900 Subject: [PATCH 010/107] =?UTF-8?q?feat/119=20::=20Delivery=20UseCase,=20P?= =?UTF-8?q?ort,=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/StartDeliveryCommand.java | 14 +++ .../dto/query/GetDeliveryQuery.java | 9 ++ .../dto/result/DeliveryResult.java | 30 +++++ .../dto/result/SweetTrackerTrackingInfo.java | 16 +++ .../port/in/GetDeliveryUseCase.java | 8 ++ .../port/in/StartDeliveryUseCase.java | 8 ++ .../port/out/DeliveryOutboxPort.java | 7 ++ .../port/out/DeliveryRepositoryPort.java | 20 ++++ .../port/out/SweetTrackerPort.java | 7 ++ .../service/DeliveryResultMapper.java | 30 +++++ .../service/GetDeliveryService.java | 27 +++++ .../service/StartDeliveryService.java | 68 +++++++++++ .../service/SyncDeliveryTrackingService.java | 111 ++++++++++++++++++ 13 files changed, 355 insertions(+) create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/dto/command/StartDeliveryCommand.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/dto/query/GetDeliveryQuery.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/dto/result/DeliveryResult.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/dto/result/SweetTrackerTrackingInfo.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/port/in/GetDeliveryUseCase.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/port/in/StartDeliveryUseCase.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/DeliveryOutboxPort.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/DeliveryRepositoryPort.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/SweetTrackerPort.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/service/DeliveryResultMapper.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/service/GetDeliveryService.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/service/StartDeliveryService.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/application/service/SyncDeliveryTrackingService.java diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/command/StartDeliveryCommand.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/command/StartDeliveryCommand.java new file mode 100644 index 00000000..fe2e1959 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/command/StartDeliveryCommand.java @@ -0,0 +1,14 @@ +package kr.magicbox.delivery.application.dto.command; + +import lombok.Builder; + +@Builder +public record StartDeliveryCommand( + Long orderLineId, + Long orderId, + Long sellerId, + Long customerId, + String carrierCode, + String trackingNumber +) { +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/query/GetDeliveryQuery.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/query/GetDeliveryQuery.java new file mode 100644 index 00000000..fde28ef3 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/query/GetDeliveryQuery.java @@ -0,0 +1,9 @@ +package kr.magicbox.delivery.application.dto.query; + +import lombok.Builder; + +@Builder +public record GetDeliveryQuery( + Long orderLineId +) { +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/result/DeliveryResult.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/result/DeliveryResult.java new file mode 100644 index 00000000..6ffcfa3e --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/result/DeliveryResult.java @@ -0,0 +1,30 @@ +package kr.magicbox.delivery.application.dto.result; + +import kr.magicbox.delivery.domain.enums.DeliveryStatus; +import kr.magicbox.delivery.domain.enums.TrackingHistoryStatus; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record DeliveryResult( + Long deliveryId, + Long orderLineId, + Long orderId, + DeliveryStatus status, + String carrierCode, + String trackingNumber, + Instant createdAt, + Instant updatedAt, + List trackingHistories +) { + @Builder + public record TrackingHistoryResult( + Long trackingHistoryId, + TrackingHistoryStatus status, + String description, + Instant trackedAt + ) { + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/result/SweetTrackerTrackingInfo.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/result/SweetTrackerTrackingInfo.java new file mode 100644 index 00000000..cf8cf684 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/dto/result/SweetTrackerTrackingInfo.java @@ -0,0 +1,16 @@ +package kr.magicbox.delivery.application.dto.result; + +import java.util.List; + +public record SweetTrackerTrackingInfo( + boolean complete, + Integer level, + List details +) { + public record TrackingDetail( + String time, + String where, + String kind, + Integer level + ) {} +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/port/in/GetDeliveryUseCase.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/in/GetDeliveryUseCase.java new file mode 100644 index 00000000..41e5d4a9 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/in/GetDeliveryUseCase.java @@ -0,0 +1,8 @@ +package kr.magicbox.delivery.application.port.in; + +import kr.magicbox.delivery.application.dto.query.GetDeliveryQuery; +import kr.magicbox.delivery.application.dto.result.DeliveryResult; + +public interface GetDeliveryUseCase { + DeliveryResult getDelivery(GetDeliveryQuery query); +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/port/in/StartDeliveryUseCase.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/in/StartDeliveryUseCase.java new file mode 100644 index 00000000..7977dd6e --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/in/StartDeliveryUseCase.java @@ -0,0 +1,8 @@ +package kr.magicbox.delivery.application.port.in; + +import kr.magicbox.delivery.application.dto.command.StartDeliveryCommand; +import kr.magicbox.delivery.application.dto.result.DeliveryResult; + +public interface StartDeliveryUseCase { + DeliveryResult startDelivery(StartDeliveryCommand command); +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/DeliveryOutboxPort.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/DeliveryOutboxPort.java new file mode 100644 index 00000000..55d98bc6 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/DeliveryOutboxPort.java @@ -0,0 +1,7 @@ +package kr.magicbox.delivery.application.port.out; + +import kr.magicbox.delivery.domain.event.DeliveryDomainEvent; + +public interface DeliveryOutboxPort { + void save(DeliveryDomainEvent event); +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/DeliveryRepositoryPort.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/DeliveryRepositoryPort.java new file mode 100644 index 00000000..5320ae11 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/DeliveryRepositoryPort.java @@ -0,0 +1,20 @@ +package kr.magicbox.delivery.application.port.out; + +import kr.magicbox.delivery.domain.aggregate.Delivery; + +import java.util.List; +import java.util.Optional; + +public interface DeliveryRepositoryPort { + Delivery save(Delivery delivery); + + void update(Delivery delivery); + + boolean existsByOrderLineId(Long orderLineId); + + Optional findByOrderLineId(Long orderLineId); + + Optional findById(Long deliveryId); + + List findAllInProgress(); +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/SweetTrackerPort.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/SweetTrackerPort.java new file mode 100644 index 00000000..ef3b806e --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/port/out/SweetTrackerPort.java @@ -0,0 +1,7 @@ +package kr.magicbox.delivery.application.port.out; + +import kr.magicbox.delivery.application.dto.result.SweetTrackerTrackingInfo; + +public interface SweetTrackerPort { + SweetTrackerTrackingInfo getTrackingInfo(String carrierCode, String trackingNumber); +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/service/DeliveryResultMapper.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/service/DeliveryResultMapper.java new file mode 100644 index 00000000..70f2fb41 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/service/DeliveryResultMapper.java @@ -0,0 +1,30 @@ +package kr.magicbox.delivery.application.service; + +import kr.magicbox.delivery.application.dto.result.DeliveryResult; +import kr.magicbox.delivery.domain.aggregate.Delivery; +import org.springframework.stereotype.Component; + +@Component +public class DeliveryResultMapper { + + public DeliveryResult toResult(Delivery delivery) { + return DeliveryResult.builder() + .deliveryId(delivery.getId().value()) + .orderLineId(delivery.getOrderLineId()) + .orderId(delivery.getOrderId()) + .status(delivery.getStatus()) + .carrierCode(delivery.getTrackingInfo().carrierCode()) + .trackingNumber(delivery.getTrackingInfo().trackingNumber()) + .createdAt(delivery.getCreatedAt()) + .updatedAt(delivery.getUpdatedAt()) + .trackingHistories(delivery.getTrackingHistories().stream() + .map(history -> DeliveryResult.TrackingHistoryResult.builder() + .trackingHistoryId(history.getId() != null ? history.getId().value() : null) + .status(history.getStatus()) + .description(history.getDescription()) + .trackedAt(history.getTrackedAt()) + .build()) + .toList()) + .build(); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/service/GetDeliveryService.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/service/GetDeliveryService.java new file mode 100644 index 00000000..2a5f39a3 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/service/GetDeliveryService.java @@ -0,0 +1,27 @@ +package kr.magicbox.delivery.application.service; + +import kr.magicbox.delivery.application.dto.query.GetDeliveryQuery; +import kr.magicbox.delivery.application.dto.result.DeliveryResult; +import kr.magicbox.delivery.application.port.in.GetDeliveryUseCase; +import kr.magicbox.delivery.application.port.out.DeliveryRepositoryPort; +import kr.magicbox.delivery.domain.aggregate.Delivery; +import kr.magicbox.delivery.domain.exception.DeliveryNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GetDeliveryService implements GetDeliveryUseCase { + + private final DeliveryRepositoryPort deliveryRepositoryPort; + private final DeliveryResultMapper deliveryResultMapper; + + @Override + @Transactional(readOnly = true) + public DeliveryResult getDelivery(GetDeliveryQuery query) { + Delivery delivery = deliveryRepositoryPort.findByOrderLineId(query.orderLineId()) + .orElseThrow(DeliveryNotFoundException::new); + return deliveryResultMapper.toResult(delivery); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/service/StartDeliveryService.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/service/StartDeliveryService.java new file mode 100644 index 00000000..0c758fb3 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/service/StartDeliveryService.java @@ -0,0 +1,68 @@ +package kr.magicbox.delivery.application.service; + +import kr.magicbox.delivery.application.dto.command.StartDeliveryCommand; +import kr.magicbox.delivery.application.dto.result.DeliveryResult; +import kr.magicbox.delivery.application.port.in.StartDeliveryUseCase; +import kr.magicbox.delivery.application.port.out.DeliveryOutboxPort; +import kr.magicbox.delivery.application.port.out.DeliveryRepositoryPort; +import kr.magicbox.delivery.domain.aggregate.Delivery; +import kr.magicbox.delivery.domain.aggregate.TrackingHistory; +import kr.magicbox.delivery.domain.enums.TrackingHistoryStatus; +import kr.magicbox.delivery.domain.event.DeliveryStartedEvent; +import kr.magicbox.delivery.domain.exception.DeliveryAlreadyStartedException; +import kr.magicbox.delivery.domain.vo.TrackingInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StartDeliveryService implements StartDeliveryUseCase { + + private final DeliveryRepositoryPort deliveryRepositoryPort; + private final DeliveryOutboxPort deliveryOutboxPort; + private final DeliveryResultMapper deliveryResultMapper; + + @Override + @Transactional + public DeliveryResult startDelivery(StartDeliveryCommand command) { + if (deliveryRepositoryPort.existsByOrderLineId(command.orderLineId())) { + throw new DeliveryAlreadyStartedException(); + } + + Delivery delivery = Delivery.createBuilder() + .orderLineId(command.orderLineId()) + .orderId(command.orderId()) + .trackingInfo(TrackingInfo.of(command.carrierCode(), command.trackingNumber())) + .build(); + + delivery.addHistory(TrackingHistory.createBuilder() + .status(TrackingHistoryStatus.SHIPPED) + .description("배송이 시작되었습니다.") + .trackedAt(Instant.now()) + .build()); + + Delivery persistedDelivery = deliveryRepositoryPort.save(delivery); + + Instant now = Instant.now(); + deliveryOutboxPort.save(DeliveryStartedEvent.builder() + .orderId(persistedDelivery.getOrderId()) + .orderLineId(persistedDelivery.getOrderLineId()) + .customerId(command.customerId()) + .deliveryId(persistedDelivery.getId().value()) + .carrierCode(command.carrierCode()) + .trackingNumber(command.trackingNumber()) + .dispatchedAt(now) + .occurredAt(now) + .build()); + + log.info("[Delivery] 배송 시작 처리 완료. orderLineId={}, orderId={}, deliveryId={}", + persistedDelivery.getOrderLineId(), persistedDelivery.getOrderId(), persistedDelivery.getId().value()); + + return deliveryResultMapper.toResult(persistedDelivery); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/application/service/SyncDeliveryTrackingService.java b/services/delivery/src/main/java/kr/magicbox/delivery/application/service/SyncDeliveryTrackingService.java new file mode 100644 index 00000000..f9663cb4 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/application/service/SyncDeliveryTrackingService.java @@ -0,0 +1,111 @@ +package kr.magicbox.delivery.application.service; + +import kr.magicbox.delivery.adapter.out.communication.sweettracker.properties.SweetTrackerProperties; +import kr.magicbox.delivery.application.dto.result.SweetTrackerTrackingInfo; +import kr.magicbox.delivery.application.port.out.DeliveryOutboxPort; +import kr.magicbox.delivery.application.port.out.DeliveryRepositoryPort; +import kr.magicbox.delivery.application.port.out.SweetTrackerPort; +import kr.magicbox.delivery.domain.aggregate.Delivery; +import kr.magicbox.delivery.domain.aggregate.TrackingHistory; +import kr.magicbox.delivery.domain.enums.DeliveryStatus; +import kr.magicbox.delivery.domain.enums.TrackingHistoryStatus; +import kr.magicbox.delivery.domain.event.DeliveryCompletedEvent; +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.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SyncDeliveryTrackingService { + + private static final DateTimeFormatter SWEET_TRACKER_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final DeliveryRepositoryPort deliveryRepositoryPort; + private final DeliveryOutboxPort deliveryOutboxPort; + private final SweetTrackerPort sweetTrackerPort; + private final SweetTrackerProperties sweetTrackerProperties; + + @Transactional + public void syncTracking(Delivery delivery) { + SweetTrackerTrackingInfo info = sweetTrackerPort.getTrackingInfo( + delivery.getTrackingInfo().carrierCode(), + delivery.getTrackingInfo().trackingNumber() + ); + + if (info.details() == null || info.details().isEmpty()) { + return; + } + + List histories = info.details().stream() + .map(this::toTrackingHistory) + .toList(); + + if (info.complete()) { + histories.subList(0, histories.size() - 1).forEach(delivery::addHistory); + delivery.complete(histories.getLast()); + + Instant now = Instant.now(); + deliveryOutboxPort.save(DeliveryCompletedEvent.builder() + .orderId(delivery.getOrderId()) + .orderLineId(delivery.getOrderLineId()) + .deliveryId(delivery.getId().value()) + .trackingNumber(delivery.getTrackingInfo().trackingNumber()) + .deliveredAt(now) + .occurredAt(now) + .build()); + + log.info("[DeliverySync] 배송 완료. deliveryId={}, orderLineId={}", delivery.getId().value(), delivery.getOrderLineId()); + } + else if (delivery.getStatus() == DeliveryStatus.SHIPPED) { + delivery.transit(histories.getFirst()); + histories.subList(1, histories.size()).forEach(delivery::addHistory); + } + else { + histories.forEach(delivery::addHistory); + } + + deliveryRepositoryPort.update(delivery); + } + + private TrackingHistory toTrackingHistory(SweetTrackerTrackingInfo.TrackingDetail detail) { + return TrackingHistory.createBuilder() + .status(resolveStatus(detail.level())) + .description(buildDescription(detail)) + .trackedAt(parseTime(detail.time())) + .build(); + } + + private TrackingHistoryStatus resolveStatus(Integer level) { + if (level == null) return TrackingHistoryStatus.IN_TRANSIT; + if (level == sweetTrackerProperties.getDeliveryCompleteLevel()) return TrackingHistoryStatus.DELIVERED; + if (level == 1) return TrackingHistoryStatus.SHIPPED; + return TrackingHistoryStatus.IN_TRANSIT; + } + + private String buildDescription(SweetTrackerTrackingInfo.TrackingDetail detail) { + return Stream.of(detail.where(), detail.kind()) + .filter(s -> s != null && !s.isBlank()) + .collect(Collectors.joining(" - ", "", "")) + .transform(s -> s.isBlank() ? "배송 정보가 업데이트되었습니다." : s); + } + + private Instant parseTime(String timeString) { + return Optional.ofNullable(timeString) + .filter(s -> !s.isBlank()) + .map(s -> LocalDateTime.parse(s, SWEET_TRACKER_FORMAT) + .atZone(ZoneId.of("Asia/Seoul")) + .toInstant()) + .orElse(null); + } +} From d2efdab01965775b1824a8c6b080523aeec0a3a5 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:09:01 +0900 Subject: [PATCH 011/107] =?UTF-8?q?feat/119=20::=20Delivery=20=EC=96=B4?= =?UTF-8?q?=EB=8C=91=ED=84=B0=20(Web/Persistence/SweetTracker/Scheduler/Se?= =?UTF-8?q?curity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../delivery/DeliveryApplication.java | 15 ++++ .../scheduler/DeliveryTrackingScheduler.java | 28 ++++++ .../configuration/SecurityConfiguration.java | 46 ++++++++++ .../filter/UserInfoExtractFilter.java | 54 ++++++++++++ .../properties/TrustedIpProperties.java | 14 +++ .../in/web/DeliveryCommandController.java | 30 +++++++ .../in/web/DeliveryQueryController.java | 23 +++++ .../web/dto/request/StartDeliveryRequest.java | 33 +++++++ .../in/web/dto/response/DeliveryResponse.java | 55 ++++++++++++ .../web/exception/handler/ErrorResponse.java | 17 ++++ .../handler/GlobalExceptionHandler.java | 87 +++++++++++++++++++ .../SweetTrackerClientAdapter.java | 54 ++++++++++++ .../client/SweetTrackerFeignClient.java | 17 ++++ .../client/SweetTrackerFeignErrorDecoder.java | 34 ++++++++ .../dto/SweetTrackerTrackingInfoResponse.java | 20 +++++ .../SweetTrackerBadRequestException.java | 11 +++ ...eetTrackerServiceUnavailableException.java | 11 +++ .../SweetTrackerTimeoutException.java | 11 +++ .../SweetTrackerUnauthorizedException.java | 11 +++ .../properties/SweetTrackerProperties.java | 14 +++ .../out/persistence/DeliveryJpaAdapter.java | 72 +++++++++++++++ .../persistence/DeliveryOutboxAdapter.java | 26 ++++++ .../configuration/JpaConfiguration.java | 9 ++ .../out/persistence/entity/BaseEntity.java | 31 +++++++ .../persistence/entity/DeliveryEntity.java | 53 +++++++++++ .../entity/DeliveryOutboxEntity.java | 28 ++++++ .../entity/TrackingHistoryEntity.java | 42 +++++++++ .../persistence/mapper/DeliveryMapper.java | 57 ++++++++++++ .../repository/DeliveryJpaRepository.java | 24 +++++ .../DeliveryOutboxJpaRepository.java | 7 ++ .../TrackingHistoryJpaRepository.java | 19 ++++ 31 files changed, 953 insertions(+) create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/DeliveryApplication.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/scheduler/DeliveryTrackingScheduler.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/configuration/SecurityConfiguration.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/filter/UserInfoExtractFilter.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/properties/TrustedIpProperties.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/DeliveryCommandController.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/DeliveryQueryController.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/dto/request/StartDeliveryRequest.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/dto/response/DeliveryResponse.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/exception/handler/ErrorResponse.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/exception/handler/GlobalExceptionHandler.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/SweetTrackerClientAdapter.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/client/SweetTrackerFeignClient.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/client/SweetTrackerFeignErrorDecoder.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/dto/SweetTrackerTrackingInfoResponse.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerBadRequestException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerServiceUnavailableException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerTimeoutException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerUnauthorizedException.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/properties/SweetTrackerProperties.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/DeliveryJpaAdapter.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/DeliveryOutboxAdapter.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/configuration/JpaConfiguration.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/BaseEntity.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/DeliveryEntity.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/DeliveryOutboxEntity.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/TrackingHistoryEntity.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/mapper/DeliveryMapper.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/DeliveryJpaRepository.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/DeliveryOutboxJpaRepository.java create mode 100644 services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/TrackingHistoryJpaRepository.java diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/DeliveryApplication.java b/services/delivery/src/main/java/kr/magicbox/delivery/DeliveryApplication.java new file mode 100644 index 00000000..b76945aa --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/DeliveryApplication.java @@ -0,0 +1,15 @@ +package kr.magicbox.delivery; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@SpringBootApplication +public class DeliveryApplication { + + public static void main(String[] args) { + SpringApplication.run(DeliveryApplication.class, args); + } + +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/scheduler/DeliveryTrackingScheduler.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/scheduler/DeliveryTrackingScheduler.java new file mode 100644 index 00000000..b912310a --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/scheduler/DeliveryTrackingScheduler.java @@ -0,0 +1,28 @@ +package kr.magicbox.delivery.adapter.in.scheduler; + +import kr.magicbox.delivery.application.port.out.DeliveryRepositoryPort; +import kr.magicbox.delivery.application.service.SyncDeliveryTrackingService; +import kr.magicbox.delivery.domain.aggregate.Delivery; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeliveryTrackingScheduler { + + private final DeliveryRepositoryPort deliveryRepositoryPort; + private final SyncDeliveryTrackingService syncDeliveryTrackingService; + + @Scheduled(fixedDelay = 3 * 60 * 60 * 1000L) + public void syncDeliveryTracking() { + List inProgress = deliveryRepositoryPort.findAllInProgress(); + log.info("[DeliveryScheduler] 배송 추적 동기화 시작. 대상 건수={}", inProgress.size()); + inProgress.forEach(syncDeliveryTrackingService::syncTracking); + log.info("[DeliveryScheduler] 배송 추적 동기화 완료."); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/configuration/SecurityConfiguration.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/configuration/SecurityConfiguration.java new file mode 100644 index 00000000..2692ab2d --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/configuration/SecurityConfiguration.java @@ -0,0 +1,46 @@ +package kr.magicbox.delivery.adapter.in.security.configuration; + +import kr.magicbox.delivery.adapter.in.security.filter.UserInfoExtractFilter; +import kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/filter/UserInfoExtractFilter.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/filter/UserInfoExtractFilter.java new file mode 100644 index 00000000..e107e45b --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/filter/UserInfoExtractFilter.java @@ -0,0 +1,54 @@ +package kr.magicbox.delivery.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.delivery.adapter.in.security.properties.TrustedIpProperties; +import kr.magicbox.delivery.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.parseLong(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/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/properties/TrustedIpProperties.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/properties/TrustedIpProperties.java new file mode 100644 index 00000000..c4b981e2 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/security/properties/TrustedIpProperties.java @@ -0,0 +1,14 @@ +package kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/DeliveryCommandController.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/DeliveryCommandController.java new file mode 100644 index 00000000..66539384 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/DeliveryCommandController.java @@ -0,0 +1,30 @@ +package kr.magicbox.delivery.adapter.in.web; + +import jakarta.validation.Valid; +import kr.magicbox.delivery.adapter.in.web.dto.request.StartDeliveryRequest; +import kr.magicbox.delivery.adapter.in.web.dto.response.DeliveryResponse; +import kr.magicbox.delivery.application.dto.result.DeliveryResult; +import kr.magicbox.delivery.application.port.in.StartDeliveryUseCase; +import kr.magicbox.delivery.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/delivery") +@RequiredArgsConstructor +public class DeliveryCommandController { + + private final StartDeliveryUseCase startDeliveryUseCase; + + @PostMapping("/{orderLineId}/start") + public ResponseEntity startDelivery( + @AuthenticationPrincipal UserId userId, + @PathVariable Long orderLineId, + @Valid @RequestBody StartDeliveryRequest request + ) { + DeliveryResult result = startDeliveryUseCase.startDelivery(request.toCommand(orderLineId, userId.value())); + return ResponseEntity.ok(DeliveryResponse.from(result)); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/DeliveryQueryController.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/DeliveryQueryController.java new file mode 100644 index 00000000..b64f9124 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/DeliveryQueryController.java @@ -0,0 +1,23 @@ +package kr.magicbox.delivery.adapter.in.web; + +import kr.magicbox.delivery.adapter.in.web.dto.response.DeliveryResponse; +import kr.magicbox.delivery.application.dto.query.GetDeliveryQuery; +import kr.magicbox.delivery.application.port.in.GetDeliveryUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/delivery") +@RequiredArgsConstructor +public class DeliveryQueryController { + + private final GetDeliveryUseCase getDeliveryUseCase; + + @GetMapping("/{orderLineId}") + public ResponseEntity getDelivery(@PathVariable Long orderLineId) { + return ResponseEntity.ok(DeliveryResponse.from(getDeliveryUseCase.getDelivery(GetDeliveryQuery.builder() + .orderLineId(orderLineId) + .build()))); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/dto/request/StartDeliveryRequest.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/dto/request/StartDeliveryRequest.java new file mode 100644 index 00000000..3496f2eb --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/dto/request/StartDeliveryRequest.java @@ -0,0 +1,33 @@ +package kr.magicbox.delivery.adapter.in.web.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import kr.magicbox.delivery.application.dto.command.StartDeliveryCommand; + +public record StartDeliveryRequest( + @NotNull(message = "주문 ID는 필수입니다.") + @Positive(message = "주문 ID는 양수여야 합니다.") + Long orderId, + + @NotNull(message = "구매자 ID는 필수입니다.") + @Positive(message = "구매자 ID는 양수여야 합니다.") + Long customerId, + + @NotBlank(message = "택배사 코드는 필수입니다.") + String carrierCode, + + @NotBlank(message = "운송장 번호는 필수입니다.") + String trackingNumber +) { + public StartDeliveryCommand toCommand(Long orderLineId, Long sellerId) { + return StartDeliveryCommand.builder() + .orderLineId(orderLineId) + .orderId(orderId) + .sellerId(sellerId) + .customerId(customerId) + .carrierCode(carrierCode) + .trackingNumber(trackingNumber) + .build(); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/dto/response/DeliveryResponse.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/dto/response/DeliveryResponse.java new file mode 100644 index 00000000..86ad6465 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/dto/response/DeliveryResponse.java @@ -0,0 +1,55 @@ +package kr.magicbox.delivery.adapter.in.web.dto.response; + +import kr.magicbox.delivery.application.dto.result.DeliveryResult; +import kr.magicbox.delivery.domain.enums.DeliveryStatus; +import kr.magicbox.delivery.domain.enums.TrackingHistoryStatus; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record DeliveryResponse( + Long deliveryId, + Long orderLineId, + Long orderId, + DeliveryStatus status, + String carrierCode, + String trackingNumber, + Instant createdAt, + Instant updatedAt, + List trackingHistories +) { + public static DeliveryResponse from(DeliveryResult result) { + return DeliveryResponse.builder() + .deliveryId(result.deliveryId()) + .orderLineId(result.orderLineId()) + .orderId(result.orderId()) + .status(result.status()) + .carrierCode(result.carrierCode()) + .trackingNumber(result.trackingNumber()) + .createdAt(result.createdAt()) + .updatedAt(result.updatedAt()) + .trackingHistories(result.trackingHistories().stream() + .map(TrackingHistoryResponse::from) + .toList()) + .build(); + } + + @Builder + public record TrackingHistoryResponse( + Long trackingHistoryId, + TrackingHistoryStatus status, + String description, + Instant trackedAt + ) { + public static TrackingHistoryResponse from(DeliveryResult.TrackingHistoryResult result) { + return TrackingHistoryResponse.builder() + .trackingHistoryId(result.trackingHistoryId()) + .status(result.status()) + .description(result.description()) + .trackedAt(result.trackedAt()) + .build(); + } + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/exception/handler/ErrorResponse.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/exception/handler/ErrorResponse.java new file mode 100644 index 00000000..21da217e --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/exception/handler/ErrorResponse.java @@ -0,0 +1,17 @@ +package kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/exception/handler/GlobalExceptionHandler.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..c72e7981 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/in/web/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,87 @@ +package kr.magicbox.delivery.adapter.in.web.exception.handler; + +import jakarta.validation.ConstraintViolationException; +import kr.magicbox.delivery.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) { + return ResponseEntity + .status(e.getStatus()) + .body(ErrorResponse.of(e.getStatus(), 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/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/SweetTrackerClientAdapter.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/SweetTrackerClientAdapter.java new file mode 100644 index 00000000..6d1e50ea --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/SweetTrackerClientAdapter.java @@ -0,0 +1,54 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import kr.magicbox.delivery.adapter.out.communication.sweettracker.client.SweetTrackerFeignClient; +import kr.magicbox.delivery.adapter.out.communication.sweettracker.dto.SweetTrackerTrackingInfoResponse; +import kr.magicbox.delivery.adapter.out.communication.sweettracker.exception.SweetTrackerServiceUnavailableException; +import kr.magicbox.delivery.adapter.out.communication.sweettracker.properties.SweetTrackerProperties; +import kr.magicbox.delivery.application.dto.result.SweetTrackerTrackingInfo; +import kr.magicbox.delivery.application.port.out.SweetTrackerPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties(SweetTrackerProperties.class) +public class SweetTrackerClientAdapter implements SweetTrackerPort { + + private final SweetTrackerFeignClient sweetTrackerFeignClient; + private final SweetTrackerProperties sweetTrackerProperties; + + @Override + @CircuitBreaker(name = "sweetTrackerService", fallbackMethod = "getTrackingInfoFallback") + public SweetTrackerTrackingInfo getTrackingInfo(String carrierCode, String trackingNumber) { + SweetTrackerTrackingInfoResponse response = sweetTrackerFeignClient.getTrackingInfo( + sweetTrackerProperties.getApiKey(), + carrierCode, + trackingNumber + ); + + List details = response.trackingDetails() != null + ? response.trackingDetails().stream() + .map(d -> new SweetTrackerTrackingInfo.TrackingDetail(d.timeString(), d.where(), d.kind(), d.level())) + .toList() + : Collections.emptyList(); + + return new SweetTrackerTrackingInfo( + Boolean.TRUE.equals(response.complete()), + response.level(), + details + ); + } + + @SuppressWarnings("unused") + private SweetTrackerTrackingInfo getTrackingInfoFallback(String carrierCode, String trackingNumber, Throwable throwable) { + log.warn("[SweetTracker] 서비스 연결 실패: {}", throwable.getMessage()); + throw new SweetTrackerServiceUnavailableException(throwable); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/client/SweetTrackerFeignClient.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/client/SweetTrackerFeignClient.java new file mode 100644 index 00000000..da97574b --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/client/SweetTrackerFeignClient.java @@ -0,0 +1,17 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker.client; + +import kr.magicbox.delivery.adapter.out.communication.sweettracker.dto.SweetTrackerTrackingInfoResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "sweetTracker", url = "${sweet-tracker.base-url}", configuration = SweetTrackerFeignErrorDecoder.class) +public interface SweetTrackerFeignClient { + + @GetMapping("/api/v1/trackingInfo") + SweetTrackerTrackingInfoResponse getTrackingInfo( + @RequestParam("t_key") String tKey, + @RequestParam("t_code") String tCode, + @RequestParam("t_invoice") String tInvoice + ); +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/client/SweetTrackerFeignErrorDecoder.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/client/SweetTrackerFeignErrorDecoder.java new file mode 100644 index 00000000..57e32de1 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/client/SweetTrackerFeignErrorDecoder.java @@ -0,0 +1,34 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker.client; + +import feign.Response; +import feign.codec.ErrorDecoder; +import kr.magicbox.delivery.adapter.out.communication.sweettracker.exception.SweetTrackerBadRequestException; +import kr.magicbox.delivery.adapter.out.communication.sweettracker.exception.SweetTrackerServiceUnavailableException; +import kr.magicbox.delivery.adapter.out.communication.sweettracker.exception.SweetTrackerTimeoutException; +import kr.magicbox.delivery.adapter.out.communication.sweettracker.exception.SweetTrackerUnauthorizedException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +public class SweetTrackerFeignErrorDecoder implements ErrorDecoder { + + @Override + public Exception decode(String methodKey, Response response) { + int status = response.status(); + + if (status == HttpStatus.UNAUTHORIZED.value()) { + return new SweetTrackerUnauthorizedException(); + } + if (status == HttpStatus.REQUEST_TIMEOUT.value() || status == HttpStatus.GATEWAY_TIMEOUT.value()) { + return new SweetTrackerTimeoutException(); + } + if (isClientError(status)) { + return new SweetTrackerBadRequestException(); + } + return new SweetTrackerServiceUnavailableException(new RuntimeException("스윗트래커 서버 오류. status=" + status)); + } + + private boolean isClientError(int status) { + return status >= 400 && status < 500; + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/dto/SweetTrackerTrackingInfoResponse.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/dto/SweetTrackerTrackingInfoResponse.java new file mode 100644 index 00000000..ba82d9f9 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/dto/SweetTrackerTrackingInfoResponse.java @@ -0,0 +1,20 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record SweetTrackerTrackingInfoResponse( + Boolean complete, + String invoiceNo, + Integer level, + String result, + @JsonProperty("trackingDetails") List trackingDetails +) { + public record TrackingDetail( + String timeString, + String where, + String kind, + Integer level + ) {} +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerBadRequestException.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerBadRequestException.java new file mode 100644 index 00000000..c0b65d3b --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerBadRequestException.java @@ -0,0 +1,11 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker.exception; + +import kr.magicbox.delivery.global.exception.SystemError; +import org.springframework.http.HttpStatus; + +public class SweetTrackerBadRequestException extends SystemError { + + public SweetTrackerBadRequestException() { + super("스윗트래커 API 잘못된 요청입니다.", HttpStatus.BAD_REQUEST); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerServiceUnavailableException.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerServiceUnavailableException.java new file mode 100644 index 00000000..40728542 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerServiceUnavailableException.java @@ -0,0 +1,11 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker.exception; + +import kr.magicbox.delivery.global.exception.SystemError; +import org.springframework.http.HttpStatus; + +public class SweetTrackerServiceUnavailableException extends SystemError { + + public SweetTrackerServiceUnavailableException(Throwable cause) { + super("스윗트래커 서비스에 연결할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE, cause); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerTimeoutException.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerTimeoutException.java new file mode 100644 index 00000000..7554ddc4 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerTimeoutException.java @@ -0,0 +1,11 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker.exception; + +import kr.magicbox.delivery.global.exception.SystemError; +import org.springframework.http.HttpStatus; + +public class SweetTrackerTimeoutException extends SystemError { + + public SweetTrackerTimeoutException() { + super("스윗트래커 API 요청이 시간 초과되었습니다.", HttpStatus.GATEWAY_TIMEOUT); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerUnauthorizedException.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerUnauthorizedException.java new file mode 100644 index 00000000..76a3e091 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/exception/SweetTrackerUnauthorizedException.java @@ -0,0 +1,11 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker.exception; + +import kr.magicbox.delivery.global.exception.SystemError; +import org.springframework.http.HttpStatus; + +public class SweetTrackerUnauthorizedException extends SystemError { + + public SweetTrackerUnauthorizedException() { + super("스윗트래커 API 인증에 실패했습니다.", HttpStatus.UNAUTHORIZED); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/properties/SweetTrackerProperties.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/properties/SweetTrackerProperties.java new file mode 100644 index 00000000..5e244d54 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/communication/sweettracker/properties/SweetTrackerProperties.java @@ -0,0 +1,14 @@ +package kr.magicbox.delivery.adapter.out.communication.sweettracker.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "sweet-tracker") +public class SweetTrackerProperties { + private final String apiKey; + private final String baseUrl; + private final int deliveryCompleteLevel; +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/DeliveryJpaAdapter.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/DeliveryJpaAdapter.java new file mode 100644 index 00000000..6829c16e --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/DeliveryJpaAdapter.java @@ -0,0 +1,72 @@ +package kr.magicbox.delivery.adapter.out.persistence; + +import kr.magicbox.delivery.adapter.out.persistence.entity.DeliveryEntity; +import kr.magicbox.delivery.adapter.out.persistence.mapper.DeliveryMapper; +import kr.magicbox.delivery.adapter.out.persistence.repository.DeliveryJpaRepository; +import kr.magicbox.delivery.adapter.out.persistence.repository.TrackingHistoryJpaRepository; +import kr.magicbox.delivery.application.port.out.DeliveryRepositoryPort; +import kr.magicbox.delivery.domain.aggregate.Delivery; +import kr.magicbox.delivery.domain.exception.DeliveryNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class DeliveryJpaAdapter implements DeliveryRepositoryPort { + + private final DeliveryJpaRepository deliveryJpaRepository; + private final TrackingHistoryJpaRepository trackingHistoryJpaRepository; + private final DeliveryMapper deliveryMapper; + + @Override + public Delivery save(Delivery delivery) { + DeliveryEntity savedEntity = deliveryJpaRepository.save(deliveryMapper.toEntity(delivery)); + delivery.getTrackingHistories().forEach(history -> + trackingHistoryJpaRepository.save(deliveryMapper.toHistoryEntity(savedEntity.getId(), history))); + return findByOrderLineId(savedEntity.getOrderLineId()).orElseThrow(DeliveryNotFoundException::new); + } + + @Override + public void update(Delivery delivery) { + DeliveryEntity entity = deliveryJpaRepository.findByIdAndIsDeletedFalse(delivery.getId().value()) + .orElseThrow(DeliveryNotFoundException::new); + entity.update(delivery.getStatus(), delivery.getTrackingInfo().carrierCode(), delivery.getTrackingInfo().trackingNumber()); + deliveryJpaRepository.save(entity); + trackingHistoryJpaRepository.deleteByDeliveryId(entity.getId()); + delivery.getTrackingHistories().forEach(history -> + trackingHistoryJpaRepository.save(deliveryMapper.toHistoryEntity(entity.getId(), history))); + } + + @Override + public boolean existsByOrderLineId(Long orderLineId) { + return deliveryJpaRepository.existsByOrderLineIdAndIsDeletedFalse(orderLineId); + } + + @Override + public Optional findByOrderLineId(Long orderLineId) { + return deliveryJpaRepository.findByOrderLineIdAndIsDeletedFalse(orderLineId) + .map(entity -> deliveryMapper.toDomain( + entity, + trackingHistoryJpaRepository.findByDeliveryId(entity.getId())) + ); + } + + @Override + public Optional findById(Long deliveryId) { + return deliveryJpaRepository.findByIdAndIsDeletedFalse(deliveryId) + .map(entity -> deliveryMapper.toDomain( + entity, + trackingHistoryJpaRepository.findByDeliveryId(entity.getId())) + ); + } + + @Override + public List findAllInProgress() { + return deliveryJpaRepository.findAllInProgress().stream() + .map(entity -> deliveryMapper.toDomain(entity, trackingHistoryJpaRepository.findByDeliveryId(entity.getId()))) + .toList(); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/DeliveryOutboxAdapter.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/DeliveryOutboxAdapter.java new file mode 100644 index 00000000..2c243ac9 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/DeliveryOutboxAdapter.java @@ -0,0 +1,26 @@ +package kr.magicbox.delivery.adapter.out.persistence; + +import tools.jackson.databind.ObjectMapper; +import kr.magicbox.delivery.adapter.out.persistence.entity.DeliveryOutboxEntity; +import kr.magicbox.delivery.adapter.out.persistence.repository.DeliveryOutboxJpaRepository; +import kr.magicbox.delivery.application.port.out.DeliveryOutboxPort; +import kr.magicbox.delivery.domain.event.DeliveryDomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DeliveryOutboxAdapter implements DeliveryOutboxPort { + + private final DeliveryOutboxJpaRepository deliveryOutboxJpaRepository; + private final ObjectMapper objectMapper; + + @Override + public void save(DeliveryDomainEvent event) { + String payload = objectMapper.writeValueAsString(event); + deliveryOutboxJpaRepository.save(DeliveryOutboxEntity.builder() + .eventType(event.eventType().getValue()) + .payload(payload) + .build()); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/configuration/JpaConfiguration.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/configuration/JpaConfiguration.java new file mode 100644 index 00000000..0fdab4d2 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/configuration/JpaConfiguration.java @@ -0,0 +1,9 @@ +package kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/BaseEntity.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/BaseEntity.java new file mode 100644 index 00000000..619a7f3c --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/BaseEntity.java @@ -0,0 +1,31 @@ +package kr.magicbox.delivery.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/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/DeliveryEntity.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/DeliveryEntity.java new file mode 100644 index 00000000..18d01a86 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/DeliveryEntity.java @@ -0,0 +1,53 @@ +package kr.magicbox.delivery.adapter.out.persistence.entity; + +import jakarta.persistence.*; +import kr.magicbox.delivery.domain.enums.DeliveryStatus; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "delivery") +public class DeliveryEntity extends BaseEntity { + + @Column(name = "order_line_id", nullable = false, unique = true) + private Long orderLineId; + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private DeliveryStatus status; + + @Column(name = "carrier_code") + private String carrierCode; + + @Column(name = "tracking_number") + private String trackingNumber; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Version + private Integer version; + + @Builder + public DeliveryEntity(Long orderLineId, Long orderId, DeliveryStatus status, String carrierCode, String trackingNumber) { + this.orderLineId = orderLineId; + this.orderId = orderId; + this.status = status; + this.carrierCode = carrierCode; + this.trackingNumber = trackingNumber; + this.isDeleted = false; + } + + public void update(DeliveryStatus status, String carrierCode, String trackingNumber) { + this.status = status; + this.carrierCode = carrierCode; + this.trackingNumber = trackingNumber; + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/DeliveryOutboxEntity.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/DeliveryOutboxEntity.java new file mode 100644 index 00000000..f8be07c2 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/DeliveryOutboxEntity.java @@ -0,0 +1,28 @@ +package kr.magicbox.delivery.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 = "delivery_outbox") +public class DeliveryOutboxEntity extends BaseEntity { + + @Column(name = "event_type", nullable = false) + private String eventType; + + @Column(nullable = false, columnDefinition = "JSON") + private String payload; + + @Builder + public DeliveryOutboxEntity(String eventType, String payload) { + this.eventType = eventType; + this.payload = payload; + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/TrackingHistoryEntity.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/TrackingHistoryEntity.java new file mode 100644 index 00000000..a708357a --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/entity/TrackingHistoryEntity.java @@ -0,0 +1,42 @@ +package kr.magicbox.delivery.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.delivery.domain.enums.TrackingHistoryStatus; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "delivery_tracking_history") +public class TrackingHistoryEntity extends BaseEntity { + + @Column(name = "delivery_id", nullable = false) + private Long deliveryId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private TrackingHistoryStatus status; + + @Column(name = "description") + private String description; + + @Column(name = "tracked_at", nullable = false) + private Instant trackedAt; + + @Builder + public TrackingHistoryEntity(Long deliveryId, TrackingHistoryStatus status, String description, Instant trackedAt) { + this.deliveryId = deliveryId; + this.status = status; + this.description = description; + this.trackedAt = trackedAt; + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/mapper/DeliveryMapper.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/mapper/DeliveryMapper.java new file mode 100644 index 00000000..c406e313 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/mapper/DeliveryMapper.java @@ -0,0 +1,57 @@ +package kr.magicbox.delivery.adapter.out.persistence.mapper; + +import kr.magicbox.delivery.adapter.out.persistence.entity.DeliveryEntity; +import kr.magicbox.delivery.adapter.out.persistence.entity.TrackingHistoryEntity; +import kr.magicbox.delivery.domain.aggregate.Delivery; +import kr.magicbox.delivery.domain.aggregate.TrackingHistory; +import kr.magicbox.delivery.domain.vo.DeliveryId; +import kr.magicbox.delivery.domain.vo.TrackingHistoryId; +import kr.magicbox.delivery.domain.vo.TrackingInfo; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class DeliveryMapper { + + public DeliveryEntity toEntity(Delivery delivery) { + return DeliveryEntity.builder() + .orderLineId(delivery.getOrderLineId()) + .orderId(delivery.getOrderId()) + .status(delivery.getStatus()) + .carrierCode(delivery.getTrackingInfo().carrierCode()) + .trackingNumber(delivery.getTrackingInfo().trackingNumber()) + .build(); + } + + public TrackingHistoryEntity toHistoryEntity(Long deliveryId, TrackingHistory history) { + return TrackingHistoryEntity.builder() + .deliveryId(deliveryId) + .status(history.getStatus()) + .description(history.getDescription()) + .trackedAt(history.getTrackedAt()) + .build(); + } + + public Delivery toDomain(DeliveryEntity entity, List historyEntities) { + return Delivery.reconstructBuilder() + .id(DeliveryId.of(entity.getId())) + .orderLineId(entity.getOrderLineId()) + .orderId(entity.getOrderId()) + .status(entity.getStatus()) + .trackingInfo(TrackingInfo.of(entity.getCarrierCode(), entity.getTrackingNumber())) + .trackingHistories(historyEntities.stream().map(this::toHistoryDomain).toList()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + private TrackingHistory toHistoryDomain(TrackingHistoryEntity entity) { + return TrackingHistory.reconstructBuilder() + .id(TrackingHistoryId.of(entity.getId())) + .status(entity.getStatus()) + .description(entity.getDescription()) + .trackedAt(entity.getTrackedAt()) + .build(); + } +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/DeliveryJpaRepository.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/DeliveryJpaRepository.java new file mode 100644 index 00000000..16bd264a --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/DeliveryJpaRepository.java @@ -0,0 +1,24 @@ +package kr.magicbox.delivery.adapter.out.persistence.repository; + +import kr.magicbox.delivery.adapter.out.persistence.entity.DeliveryEntity; +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 DeliveryJpaRepository extends JpaRepository { + + @Query("SELECT CASE WHEN EXISTS (SELECT d FROM DeliveryEntity d WHERE d.orderLineId = :orderLineId AND d.isDeleted = false) THEN true ELSE false END") + boolean existsByOrderLineIdAndIsDeletedFalse(@Param("orderLineId") Long orderLineId); + + @Query("SELECT d FROM DeliveryEntity d WHERE d.orderLineId = :orderLineId AND d.isDeleted = false") + Optional findByOrderLineIdAndIsDeletedFalse(@Param("orderLineId") Long orderLineId); + + @Query("SELECT d FROM DeliveryEntity d WHERE d.id = :id AND d.isDeleted = false") + Optional findByIdAndIsDeletedFalse(@Param("id") Long id); + + @Query("SELECT d FROM DeliveryEntity d WHERE d.status IN (kr.magicbox.delivery.domain.enums.DeliveryStatus.SHIPPED, kr.magicbox.delivery.domain.enums.DeliveryStatus.IN_TRANSIT) AND d.isDeleted = false") + List findAllInProgress(); +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/DeliveryOutboxJpaRepository.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/DeliveryOutboxJpaRepository.java new file mode 100644 index 00000000..7c8ac483 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/DeliveryOutboxJpaRepository.java @@ -0,0 +1,7 @@ +package kr.magicbox.delivery.adapter.out.persistence.repository; + +import kr.magicbox.delivery.adapter.out.persistence.entity.DeliveryOutboxEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DeliveryOutboxJpaRepository extends JpaRepository { +} diff --git a/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/TrackingHistoryJpaRepository.java b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/TrackingHistoryJpaRepository.java new file mode 100644 index 00000000..5cae98f4 --- /dev/null +++ b/services/delivery/src/main/java/kr/magicbox/delivery/adapter/out/persistence/repository/TrackingHistoryJpaRepository.java @@ -0,0 +1,19 @@ +package kr.magicbox.delivery.adapter.out.persistence.repository; + +import kr.magicbox.delivery.adapter.out.persistence.entity.TrackingHistoryEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface TrackingHistoryJpaRepository extends JpaRepository { + + @Query("SELECT h FROM TrackingHistoryEntity h WHERE h.deliveryId = :deliveryId ORDER BY h.trackedAt ASC") + List findByDeliveryId(@Param("deliveryId") Long deliveryId); + + @Modifying + @Query("DELETE FROM TrackingHistoryEntity h WHERE h.deliveryId = :deliveryId") + void deleteByDeliveryId(@Param("deliveryId") Long deliveryId); +} From a55b85c92a2fcae1b2a741c4e27dbc7ee5c568e1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:09:01 +0900 Subject: [PATCH 012/107] feat/119 :: Delivery application-*.yml, Dockerfile, build.gradle, Gradle Wrapper Co-Authored-By: Claude Opus 4.6 (1M context) --- services/delivery/.gitattributes | 3 + services/delivery/.gitignore | 38 +++ services/delivery/Dockerfile | 6 + services/delivery/build.gradle | 21 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + services/delivery/gradlew | 248 ++++++++++++++++++ services/delivery/gradlew.bat | 93 +++++++ .../src/main/resources/application-dev.yml | 45 ++++ .../src/main/resources/application-local.yml | 51 ++++ .../src/main/resources/application-prod.yml | 32 +++ .../src/main/resources/application.yml | 3 + .../delivery/DeliveryApplicationTests.java | 13 + settings.gradle | 3 +- 14 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 services/delivery/.gitattributes create mode 100644 services/delivery/.gitignore create mode 100644 services/delivery/Dockerfile create mode 100644 services/delivery/build.gradle create mode 100644 services/delivery/gradle/wrapper/gradle-wrapper.jar create mode 100644 services/delivery/gradle/wrapper/gradle-wrapper.properties create mode 100755 services/delivery/gradlew create mode 100644 services/delivery/gradlew.bat create mode 100644 services/delivery/src/main/resources/application-dev.yml create mode 100644 services/delivery/src/main/resources/application-local.yml create mode 100644 services/delivery/src/main/resources/application-prod.yml create mode 100644 services/delivery/src/main/resources/application.yml create mode 100644 services/delivery/src/test/java/kr/magicbox/delivery/DeliveryApplicationTests.java diff --git a/services/delivery/.gitattributes b/services/delivery/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/services/delivery/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/services/delivery/.gitignore b/services/delivery/.gitignore new file mode 100644 index 00000000..b070be42 --- /dev/null +++ b/services/delivery/.gitignore @@ -0,0 +1,38 @@ +HELP.md +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/delivery/Dockerfile b/services/delivery/Dockerfile new file mode 100644 index 00000000..e9a509ca --- /dev/null +++ b/services/delivery/Dockerfile @@ -0,0 +1,6 @@ +FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu +ARG JAR_FILE=services/delivery/build/libs/*.jar +WORKDIR /app +COPY ${JAR_FILE} app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/delivery/build.gradle b/services/delivery/build.gradle new file mode 100644 index 00000000..fcb96e40 --- /dev/null +++ b/services/delivery/build.gradle @@ -0,0 +1,21 @@ +ext { + springCloudVersion = "2025.1.0" +} + +version = '0.0.1' +description = 'delivery' + +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-security' + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + runtimeOnly 'com.mysql:mysql-connector-j' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion" + } +} diff --git a/services/delivery/gradle/wrapper/gradle-wrapper.jar b/services/delivery/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 zcma&NW0WmQwk%w>ZQHhO+qUi6W!pA(xoVef+k2O7+pkXd9rt^$@9p#T8Y9=Q^(R-x zjL3*NQ$ZRS1O)&B0s;U4fbe_$e;)(@NB~(;6+v1_IWc+}NnuerWl>cXPyoQcezKvZ z?Yzc@<~LK@Yhh-7jwvSDadFw~t7KfJ%AUfU*p0wc+3m9#p=Zo4`H`aA_wBL6 z9Q`7!;Ok~8YhZ^Vt#N97bt5aZ#mQc8r~hs3;R?H6V4(!oxSADTK|DR2PL6SQ3v6jM<>eLMh9 zAsd(APyxHNFK|G4hA_zi+YV?J+3K_*DIrdla>calRjaE)4(?YnX+AMqEM!Y|ED{^2 zI5gZ%nG-1qAVtl==8o0&F1N+aPj`Oo99RfDNP#ZHw}}UKV)zw6yy%~8Se#sKr;3?g zJGOkV2luy~HgMlEJB+L<_$@9sUXM7@bI)>-K!}JQUCUwuMdq@68q*dV+{L#Vc?r<( z?Wf1HbqxnI6=(Aw!Vv*Z1H_SoPtQTiy^bDVD8L=rRZ`IoIh@}a`!hY>VN&316I#k} z1Sg~_3ApcIFaoZ+d}>rz0Z8DL*zGq%zU1vF1z1D^YDnQrG3^QourmO6;_SrGg3?qWd9R1GMnKV>0++L*NTt>aF2*kcZ;WaudfBhTaqikS(+iNzDggUqvhh?g ziJCF8kA+V@7zi30n=b(3>X0X^lcCCKT(CI)fz-wfOA1P()V)1OciPu4b_B5ORPq&l zchP6l3u9{2on%uTwo>b-v0sIrRwPOzG;Wcq8mstd&?Pgb9rRqF#Yol1d|Q6 z7O20!+zXL(B%tC}@3QOs&T8B=I*k{!Y74nv#{M<0_g4BCf1)-f)6~`;(P-= zPqqH2%j0LDX2k5|_)zavpD{L1BW?<+s$>F&1VNb3T+gu!Dgd{W+na9(yV`M7UaCBuJZg1Y)y6{U}0=LTvxBDApz@r>dGt(m^v|jy&aLA zdsOeJcquuj3G^NkH)g)z@gTzgpr!zpE$0>$aT^{((&VA>+(nQB!M(NnPvEP}ZRz+6 zE!=UW!r7sbX3>{1{XW1?hSDNsur6cNeYxE{$bFwZzZ597{pDqjr%ag85sIns_Xz%= zqY{h#z8J6GA~vfLQ2-jWWcloE5LA62jta=C*1KxAL}jugoPqj4el4R4g3zC4nE#2-NeS{c3#!2tIS|1h8*|kpw2VSH9OcIQZx0Yh!8~P&p}fI$4Bj9Z zr5Yv?i-PfO#<}clM>mO(D0wHniZZdv8pOuJFW z+-u}BH84PQCgT~VWBM88vtCly1y$uEGJ<7vnW%!2yV>l>dxA0X0q{cN6y3u$8R-*f z-4^OlZ1HmxCv`dFW%quP<7xzAbtiFxvY0M1&2ng&A}QXAVR=prc_5m(D+_?hv#$M^ zG#MQ#fHMc!+S%HgU^Qv7Z9eu6eNqpSr3e8(;No*YfovbJ;60LjCzv9O~^>gFKO>t zGZg9`a5;$hksp*fHp{7&RE@DM&Pa@a>Kwk%*F7UGO|}^Z0ho1U$THOgX9jtCW6N$v zLOm}xcMBtw)CC(;LLX!R9jp|UsBWGfs@HaMiosA3#hFee7(4vLY}IrhD++}>pY zo+=_h+uJ;j^CP*OGQ9$0q+%}UB`4`5c766d#)*Czs<91wxw)jI^IdvyjT%<8OqI=i zNn0OUqW#POg^4ma)e2b?*Xv;dri*N0SJ7_{&0>;S!)!YV1TQuiT1C3ZFDvThe}yTCmErx#6yyQ4X@OAbHhdEV!K2%;7J>tiUZF)>Z|eRVDwtDC~=J z*M8|WEgzsyNH@-5lJE+P6HrurgY!PqtWk z^69SOHZ*}xn|j2FDVg`qRT}ob*1XiGo=x8MDEX)duljcVO}oJjuAbB$Z+f&!{z3k< zO6+{@O#2^s4qT`6k}Nw?DKV1DU~}0jVA)(kNz$c-p`*FNG#Gb&o?ko70F||R^y*hD z6HD|hJzF)G&^K=vuN$@b2fIfHVFw@hC_-0hPnB!1{=Nn~ran4VeTMM(Xx2A3h95U} z&J#Kw4>*V(LHOA<3Dy{sbW-9k5M2<%yDw~ce0+aez8 z04skG8@QEESIL;m-@Mf_hY!)KkEUowHu(>)Inz(pM`@pkxz z1_K#Qs6$E^c$7w=JLy>nSY)>aY;x2z`LW-$$rnY0!suTZSG)^0ZMeT#$0_oER zfZ1Hf>#TP|;J^rzn3V^2)Dy!goj6roAho>c=?28yjzQ>N-yU)XduKq8Lb3+ZA|#-{ z?34)Ml8%)3F1}oF;q9XFxoM}Zn{~2>kr%X_=WMen%b>n))hx6kHWNoKUBAz?($h(m(l;U*Gq7;p5J{B;kfO^C%C9HhtW!=O3-h>$U zI2=uaEymeK^h#QuB8a?1Qr0Gn;ZZ@;otg2l>gf= z$_mO!iis+#(8-GZw`ZiCnt}>qKmghHCb)`6U!8qS*DhBANfGj|U2C->7>*Bqe5h<% zF+9uy>$;#cZB>?Wdz3mqi2Y>+6-#!Dd56@$WF{_^P2?6kNNfaw!r74>MZUNkFAt*H zvS@2hNmT%xnXp}_1gixv9!5#YI3ftgFXG20Vt1IQ(~+HmryrZI+r0(y2Scl+y=G^* zxt$Vvn&S=Vul-rgOlYNio7%ST_3!t`_`N@SCv$ppCqok(Q+i_?OL}2@TU$dr6B$c8 zQ$Z(lS6fp%7f}ymQwJAIdpkN~8$)O3|K7Z;{FD?hBSP-#pJgq0C_SFT;^sBc#da0M z;^UuXXq{!hEwQpp(o9+)jPM6ru1P$u0evVO(NJ;%0FgmMNlJ+BJ zf^`a|U*ab?uN*Ue>tHJ$Pl~chCwRnxi3%X06NxwlIAKa*KReLL^y1B^nuy|^SPj3} z5X|?1divh3@zci;648jb2qEOm!_8Tjh3gi;H%2`d`~Q(IL{Wcl1C18+&P>tU&0!nO z&+7mpvr2SsTj=@sX zxG=;T^f7Rg=c=V*u8X(fo)4;RYax^+=quviOJ{>r6{wgf)g){I&qe`=HL}6J>i6Ne zSZ*h9f&JG>Y`@Bg5Pb&>4&UqFp9I<8o`n4W_V=4AugM`RqUeS-!`OyNLyKMqa_Ct| zON-hyk#-}{lZZx>B1F@dF^8S>x|C*QAjKqn&Ej9H#z@Q#KA*ckBX@^;gIP&?aK15l z*EY@kG57oUcm(d{NyXg6$Kj#xR5XdZ1EBCT+Zy!gyXwN&b_zI&$$>7R#{ zh8U@H8NY-cA*CBfH$OCs^priPwtwrzFjDO}DBn#mgbI~hn}cp2U{yv@S)iy|jR9+E zgd(hF|1cyC#te0P;iFGqpNBqc(k<{p^1>wHE_c8Tr4|&NV4mzpzFe;Cr)C~qpVNjl z^u(^s5=kj{QBae)Y*#^A39jT4`!NuIUQzD#DOyfa!R=PrX6oS@x@kJV)Cn$!xTK9A&VI#F-Slt8I4|=$bcjaC5h=9E{51g8X5q1Qfg~~G>qAgy*7h4-WuqE zlIEx?Hu*%99?$6TheLAD4NIMO=Q@*;gaXDl6yLLXfFX0*1-9KQm42c%WX*AXFo$it z?FwnWn2tBHY&Qj6=PV?ergU$VKzu+`(5pCRqX}IoSFo?P!`sff%u1?N+(KsoL+K={ zi*JGl%_jiuB;&YW+n%1o^%5@!HB9}OlIdQZ*XzQ%vu!8p2gnKW+!X>@oC{gp3lNx^ z82|5Jdg9-B<1j|y(@3J;$D-lqdnf0Q6T~q7;#O}EMPV3k(bi$DpZwj9(UhU%_l&nN zR}8tN_NhDMhs)gtG*76~+W2yQ{!kDTE@X4gft2?W;S$BLp9X z;sh2jpm!mkfPX>Vuqxyt76<@f4fyY%&iuDfS1@#PHgzHqG;=X^`X}t2|Alr^lx^ja z1rhvG(PH(a0THitc?4hk=P*#IS;-`fjOKqJ4kgo@dAD@ob*))H)=)6s3cthp&4Q55 z4dQRdG0EveK*(ZUCFcCjILgS#$@%y=8leYxN-%zQaky@H?kjhyBrLYA!cv>kV5;i1 zZ^w&U7s&K8fNr4Pfy9GyTK2Tiay4Y_PsPWoWW5YA8nfUkoyjU)i@nKj@4rY13sxO6 z_NzYdG=Vr<@08Xi#8rnX&^d{Bl`oHXO6Y3!v2U~ZV>I*30X3X&4@zqqVO~RyF)6?a zD(<+33_9TqeHL)#Y?($m4_zZvaJXWXppZ4?wo?$wF)%M6rEVk2gM=l9k+=*Q+((fI zIUBH6)}M?ahSxD4lgmJ30ygk#4d!O@?%WNEONommx`ZK81ZV)mJpKB`PgQ}F>NGdV zkV|>^}oWQd6@Ay7$&)6!% zOu_p~TZ3A#G_UqiJ85&*$!(+!V*+*{&-JXb53gtc9n3>8)T$jUVXe+M6n$m633Mi? zlh5{_+6iZ<%gMWMrtHyDl(u-hMl^DViUDc50UD;0g_l$F`Hb(F=o+?94B0fjb;|?Q5c~TWX>t8i1RP@>Ccgm z?2=z0coeb?uvn44moKFb^+(#pAdHE7{EW(DxJE=@Z0^Am`dpm98e`*S+-~*zmhdQ7 zCNig0!yUu5U#>KKocrg-xMjQoNzQ`th0f{!0`ammp_KMFh?_zF4#YhF35bPE&Fq~_ z#VnniU6fso{!3Z^1C57q?0i!ok(a zL;-f$YlDk%qi%n637_$=Gw=bBY}8#meS~+#X}Oz~ZKd%q(UE>f%!qca?(u}) z!tLTuQadlAN;a#^A?!@V=T?oeJ1f7yRy)H1zn_+wARewYIYr`zD=^v+D|ObvH4rOB zT@duqF>$Dk6&i|pZh?%Wq-7_kyP4l)-nqBz#G0lqo3J2D%zmbU)>3)5e?sTZy8|~B zPC7!`eD+deR?L6$6 z-e{!ihef=f<4HPZ9rSt&yb=5Q)BFAXWPR^~a&Zru?8146wvlm;<)ugbd|!}O6aE0t z6`#KqcH#S#*yz-K90+!Fhv+ zKH+?!_0yl|gWXSaASLcB9a8g7i%qz*vbO)YW`Q@Nxpp*6TZ*OO8Z|5-UWihd@CUXF zY!aTAZ$c^?4hiaq34=s2il}#Pxu=#c2^=(PbHNAyUqy__kR+n?twKrQe^8l6rk=orf}Mk80viC1NZ^1q zeF~g*iGp0=jKncK%s@#jZcn6=EiR<8S#)yiEOuwbG;SV$4lB^R?7sxOf8)oq$sT)) zA&nBCFJxsnci+)owdCHV#cjP2|1j22xIRsxHrLLBk3GI|OppUv3%r>#;J|26!W>xC z9gq@NQWJ`|gH}F{-QG#R6xlT<;=43amaDT>VaG*;GfPZJ&W*rO8WAQQc^JGw-fz-| zzAe&RAnC(gAP#FoJtt~ynR3Z<)m_<9Oo)XW}CWd50^eI4!1p4}s(zLhBIDi5r zr{UH>YIz2!+&Cy(RI(;ja_>SUC2Q`ohWPlI+sK-6IU}*nIsT)vLnuVPFM%~gdel}S zUlY%>H$?-rQRGTdUM^p^FEkqnwC{^BGl|gM)h9zkXplL90;yOcgt(8&LJwOj!5Qgy zu$@^*k%9JoAzwj@iSB^SNu#YVl@&*g$uYxxsJBvIQ>bfuS97JccQcS7&a z)`1m2^@5c9pD`P$VqH*O*fxkvFRtH-@Pd0@3y2!jW>i=jabBCJ+bW@wwUkWjwx_WR zHH5*XR4hbQ1`D@4@unmyEX)!?^~_}~JQNvP4jO&F)CH9srkFhf8h*=P z;X1&vs_&v03#BGc`|#@!ZONxVj9Ssb#_d63jxA6dX_RBt(s;ig3#s(YU3P3klF;mc z%%@^IJUAlGE=cnsTH+(qb1SxN@HzfAjYcUCb(VU)JV^3ZC;#k!t?XjaC!|68eLE zU_hlvOSNj7Qlr{x)y$S$l^2DPCMA=pzapcSkjfk*r!iWU%T{?<3#Hw6s1ux1^Ao6o zR@5DIfo-|c9AaFw848Y!BVG-+vURe;I29F#hLu$9o}oSa9&2sgG#;lj@@)9|2Z3 zon?%NV&AYSVnd~eW~v0yoF$X^1FR@i2kin0mFLG8-aA>hYK;B%TJ~7%P4?_{Bu<0t zvmI)Uk-MRncVb)A890>OqnYf=wu-J5A~^%4jpK~*xp)=h0BZB4*5uWrP>iRV+|kMX zv+BEskY~(P-K)-!JSHR`$brY)HFI|L@YyrxheT3cgHu}KtF%s%k3B`X)E_lA=E>M4 z2VV3M{c0*)`qZAsJ==)F#D~2Ndzm@hKhSBL_Sf3{ctckh-rB`gkfC?Dp6FdM?p;vv z#UlQMp3H5*)8o#Ys@-aj7O#brUfgQ7BjG`7 ztoE7v-tH2%KVC$xKYf%uvZD!_uf3x>h?8r!zYHkcc7$Gdn(6cDmYL&p3pCfaSfY4$ zG|yuujr6!Wl0}V%* zQ;nY##kEdvo8YY=SVDb)M>^Ub9e#4c$O&urD$uaRtxm-UH=6_s0m^^5y^_+F^Q?;8 z+Fd?+De}er^2EmFNn&e8SyS*`*`e;KFIG&+x5iWCsrEyH*0SFBCMx?`m5~hl1BrT> zr8W3*3}Fwsx@%UOuxNoCSoL%AM{Uj|v@>l{pYYI&D$j`&**;?X`cuOOk~?;U{~xvDUjaiH^d`A+gQL#Z?*lm)x_n6R-S% zf6*=Q1m>mq5|Niefl8s=5F={ncn5S;6~&Ns2)yGZ@wt&u4c+)Sk?hdfI^b77@K-=y zM_k=j5hp&u`2nkJK+2Lw`uLypr4dO?Bm3BTZdtWnQa5unCoTKIiG81t4bG`epBU5| zG{toT`)LE}&j{P+AFj`YZrjF-^>k+`zCM`QcQz^Ba4BEte@S}j=Q_Opx14jq|DB}& zNB44BOJ`?GJM({v`gh9pzbg8-%Un=E@uLfJwGkagLEM^!`ct3s5@-xqq*xd+2C@eu z*1ge`retZK)=bPO<`>@62cLN?^S%v#EsiPQF`cg&I7{}l?)}O$!^wNJp4Zd;1yBbQ zv@_7x7d6aXJvGHkNNcOg?A};m_Nq7H=(+zqf9)e3&yP^EU63Ew!NW4CYj_!=OTVb* z-ijSrv0M)u=MF=@+`3ldT-hzOn$Ng><)WL0vqQ&jH>W7EmLLQY+c?%i9~f_x&{OYX z{?kyyNZ&gT*m$(%-OeDAJeC^c)X!k${D*c;c}9)0_7iWMbfu)!j3+{*!Dj|?C`sGz z2xWha)#`9@p*{-X2MN2a;%FM-WqB2h)GTqQH$ZsGD#Wi`;+$i?fk;23fLpYI^3TT3 z5+Zn3cu-_2Ck*@%3^L3}JpVN`5ZJ;gmKn>gm(Z)b%!v|RYf(qrmGL#0$WHQFw4mJqQ85w=$tn^7(z|eJ$3R0} z2k9^EU<^-$ygq!ZR+7wT0KViK8qkAO7xs*e@1dq{=M3haulHwA0~BYNytr7k2K*(W z755P9a^;Hdl2X;K{c}yWr|QH?PEuh6x)9n{^3m2QUfC_Q*BW&<9#^ZVwOolx@6y9- z-YF=S;mEypj68yxNxfJ56x%ES`z-5$M${V1HX(@#R>%$X`67*Ab8vC6UzvoDOY*P= zFbPXany0%>rqH1gi7d>e`=PWZTG>^=#PQf&iJjJ0&2dO(4b8) zCl%8xJg1mg4__!?t|y_roExn~%u@Eu|p9YFb`8_qP@v#KW#kFs4eVetJ+Q+s|Y0?#D z@?dt_BA7C4tGpjOB~*LFu0!5oU(_xj7xA$meN)Z;q4Z_Rb7jY1rJBzJPr0V=(y99F zh=V-NbK+64rd#ltw~7X-%kP$R896DxRuj)p7Zj@8&>IlP&}ME3s9eV2R>SpUnSxeg zmpm?HQJ^u1T;pvwvlc4F_)>3P~jlTch4+u6;o{@PtpnJcn~p0v_6Po%*KkTXV#2AGc) zv)jvvC?l#s$yvyy=>=7D3pkmV24xhd7<5}f_u5!8gmOU|4555dv`I=rLWW!W!Uxg| zFGXpH3~)9!C2|Y6oB~$gz(;$CTnw&R&psa+E!KNgrE1+WkLM6SOf$>sGW+Y{>u?Fw zTc!xG{pa3c#y@d$d0e7a9~e_xjGcaw5f6Fk>lg$Jm}cFd%BO_YT(9s+_Q;ft%1*k$ z_cXkf&QHkaQr9U?*Gr$r6|bCV>2S)Cedfk3rO?JbyabY zgqxm#BM7Sg6s-`5%(p@SxBJzR6w`O6`+Kuo36wwBzwf6K{0HENVz^^w|E$r zdZM%T0oy8OK|>>2vSzw5rqoqEroCZ%(^OmOSFN84B2-8Z?R1)Pn9|5Xkui(fQRl^zA35EH^(JbuQd@Uh z2FJ6C(5FDD(++_NLOG)1H<+X~pt68d@JiB8iUQSZ+?qc;Jr+aJ8bKF3z`K&zSl&C7 zEgl&!h?sc=}K7 ziEC(3IrY?h7|d= zVjh{@BGW^AaNcdRceoiKmQI+F$ITdcM$YigXtH)6<-7d@5DyyWw}s!`72j`A{QC~e ze-u0a6A;QSPT$vqf3f(kO1j^%GYap*vfWQ@X=n{lR9%HX^R~t+HoeaT5%L7XSTNn` zCzo})tF@DMZ$|t6$KTx+WQqu~PXPa9FL&shBGx3C>FlGz}7gjfv}(NKvjR#r5PL$a1>%asaylWA8^g!KJ=$}_UccHmi zAZd5c{I&Ywpi3a1#27C6TC~zm3y8D>_1an8XHGNgL?uT$p+a<5AdWLR6w9jdhUt9U zz?)93=1p$x;Qiq!CYbX&S}+IITWLkfu%T6X5(pk9-fs8lh9z8h?9+>GlFeFcs*Z>u zJSaL!2?L8LbOu_Ye!=4~ZKL?643lcsNn8>qUT|q&Rv+(z>Z9=tyG&5}zZK&Q?S!nG zR;Ui^<406=jLYA>zl!a-OXH#J-pP4A`=)r%9HV5m1qGZ1m*t^wi>3$JRcH)3Q(LQz z(3}~y3=QsUu!PN$$N~#yBP@=aJ+Bkp_hx8^x1Ou6+(Kk9l1CXr4p~IQvq@AUePuAj zcq5>YDr(JTmrAuLwn6sgohTR-vc^y^#I{grF7 zg}8?&5!^$|{X`C;YrZ7?rKH#`=n0zck(q37+5%U;Hmds2w+dLmm9|@`HqQ<5CUEz{I1eNIL?X~rd{f71y z>_<94#1G+j`d5|fKK@>QDK6|HRR|9UZvO6HdB1afJvuwUf8bw>_Fha)Ii8I}Gqw}p zdS~e^K4j{d%y+A#OBa1C4i0)sM=}tjd8fZ9#uY}{#G7rJp{t6?*5*A^KKhim06i{}OJ%eA@M~zIfA`h_gJ_o%w;FaFQMnVkBT|_ z(`m9r+11~EPh9f7>S=$F7|ibj=4Pt>WVzk6NfGRvI_aG66RHig-(S%WKRLP%_h0He``xT))N^RI@6!ADl=*vsqVb|7 zr~Lwl6qn|u!%is<{YA`Mde2Z${@EAHC^t>4`X;F9za=RC{{$4OcGmw%9+{$i@!cCn z;7w~r8HY->M@3OzYh+L7Z2Lc8AcP*FZbl6VVN*_sp}K zQP|=g@aFthq}*?|+Gm4@wbs_?Fx-HD2%)_UDJ);X88~7ch~d0cJ!<7;mv>iv!RS$a z;(-cYTW=K=|F0gIg3EW0%u2CSr(Kx}yLoki|KSIt$#P(O!=UjBGRzb3L3-?NGr7!! z^VC7_Q(GhT;C*(bLivfhlRDVdz7=h%ABuLA2g$qy)A}U@Kj_L-Jd|--fy#-*ESRo| zgu?*?jGEgs9y>1`t}|^Ucd1I=1N=mOo{8Ph zwZS(F%G?nfI{#%sGayNItK9J5P)Qk+^4$ZoXZJ0G1}hwcckJ0g-QJ<)3%`bF8}(ahYIjKFYMtg3X;e7J18ZvDkV@N=nxvDl zo?}lXoT3pZY;4$QKI`~GFuQKv;G6b<8;o89Hd2yu+|%sU(9C=h8ibwZ zARqZ#lk@kp4*#URe-YmpRc&=-b&QP>5b{9{(tH*)(@ZPKfOslBgwCPx6d*{XMX|Q{y0F!5a^ScCE;h8bQmTJR3*}A>aGcDF0?tU)Tnml z#DgruwAva-fiU3s*POY_ZHiJyW%v+733X`&ocwHz$uqJCOhrM;#u*V2eK$D5HiN(` zII{BEg(PV6#_Nv3rZBUyd+TI!>L72KW_Oml6L=pNv#aOl( zgpYxAH^@2aJQu3urlrCeanwSpHHD_Cxb+=cm49{ZU5Z@;{^{okEJ6&fpDD31w~$`% zcz@_REsC~Vq>3YF7yJ41ZEPBW&%|OwlnfG|QNpiX;fGR0f^3?PEf|-33P&LFGe`8^ zaX3M+*h+?6;s|=$j*d|S-r6PSHnmLqm9oshPNpGzlxV21cFrxcQLidd2%h>n%Mc4{ z|JWBvtbb;(-nhWpPO95hR>(e(H$n%*pCh0k4xE#I%xu=#B)zXSaH+azwCI;0@bY<*-10-Qyaq%5NxSlq_@YJUUwy z*d;qPjW^cuKxdXiOWwP}5FN6SZW~NqB%4?|WifPNZr&XNVkzF0n#Y)pbaEodqNO4F z2Bq#^Gr^Ji3!T9`_!D;a1lW$?!LQ-iYV_A{FQ~^C-Jp`_5uOC)6+mzBr4Nl3fHly% zcXeU3x-?#J`=p$6c~$T~V^!C0Bk_3#WYrtoFCx9_5quCQ*4*?XG0n_9%l_!n`M85^ z7}~Clj~ocls6)V&sWGs?B<`{Ob>vnbXZwdda%ipwbzOJ(V`W>KBF5zdCTE8;mc&xU z^clCzd0(T#8*(})tSYSNP1N{FnNVAU^M1S_pq4VEQ*#5nv`CoYSALMEB zf6egyuRMzK2?r^M0hCD*sU;On6c0^Vh|#tRG*n1p5R)QyVw%Va37nMSV%9&uq^hp| zCHeu}y{m=NsA=naDy;q`fd9t)I$Qd-A1Il$#0KyDc>X)hKJViqNB{HnQyf5D(ZJ*J z{-oGB-%Q|QZ%Pqu34>fCy)Asi}IY7luNR9ebgH4DAjCVvSWfa%PE16 zkC7EIuEK}?IR!jgP%eX%dcxk4%N!zIjW4wYMfIq@s%GetDs^g!^p}DH46EP`Nh_wD z4Rwc4ezh1U$Mc)Fe6ii6eD^*iB2MFp-B-HhGTR0tC2?bq$#^J!v1r+Z0y+& znVub*k=*^0yP(c#mEvX}@Abx%&}!W(1olcWEHAVgskbBrzx(f2v&}4~WkVN?af#yi z4IE-(_^)?4e3(d{F@0<~NV5|e0eaB!?(g%l&Hq$UqzC_Enuest?CL+IrSD`tv8|{C z=79vnL=P6ne+}6X1&cd$kam=jCcv`~^y#R{doTh?6D?H)^M7-P+=D@?H;bt$*V+)K z?+?Ex3Z@8JE3c4eHDYItB^tSot;@2p_fuZ8mW^i^a(L;Xn6K+1GuG0n$v(38;+<78 zC?eMzbQCW2%&;U>j}b>YEH5>RkP44$QlG6k(KwXtq{e#13wnx5Jh=uH?lQIl8%Qxr zq%pDC)mYYKa?N>%aF%YwA}CzV@IOV9&a81d9eiU-6F&lGvz68~%{&4LuwV_5{#km3(tf`fejjs%`{Y`|0p!6|-U z8XQA9Sl=*kM|(2KA!LWOCY3Qq4sZ7r&}__rR*Sj(9W8R1_RxI&4TI+_7RSJF&-363 zJvczH?1(`Jb+RDJL9$Whnj8qJRI+Mz9=Qjvubb=Lz8nWVXG{Te;$%s9-D#$)-!{~w zIM(vkr#OM>2F7W$$Lq%fEYl%e|Tsc>9rB9c8 zQoi4nXomx3&sBI9AwaHkoOp%SMDf2@T#73Bi?|!r!Q?wc(^b_u4ranezYx~=aRV-a zD|_WPK^iJh&=)~h{t<>_$VMXsee;{r-|`#H|1?DZgWvuc*!&C2*(yv(4G5s{8ZRzt zZMC~5gjiU@6fPGMN%X~pL};Q`|IfPfs0m9;RV}xSxjb)*gmvGO1`CQb~W1M1{KwXBLyPz0JQG=JkVX zlPq&zNZS59gf-?*5Z0IFitTX4T$1Oo#_~V%4q2vI?Y@UkSHh}H9xZ1va}^oBrCY{+ z3wwj*FHCsS2}GdSG7W(|k+MWu9h1Qs6cft~RH)n*!;)5HmPX1DqrJ3-Cs%i4q^{$N zC&skM7#8f{&S!9Eq-WqyY$u?uTgrSDt#NU%{3bQZtUSkUof4`Z1P8aLOKJ+^dKh%n zfEfQ zO|P*J>;{=`9@D)qpnt`#NH>}sir*&oFC+W!HR)ecHcPwjF-|)}8+tR#@A+~CLl+Ab zCqp+=Cuc(&VGC1ZYg4CxIXYL>33p^wjIWJSh6R=oq)jD52q3~KVGt=w_z(arS!gx^ zSd|?!rzDu1$>0o0Y0+!iZU=ew^Hr+cq(I(C>9}^sBc++0+S#I;js@_NLD9>MH(tN3 zE5F+J_bYdPfYm5%7-e=lm?!-xlvX~nDkBqu!Zf0ra65JD&@tYDW+c@P3W-YyWe4^6 zhW?FUJ;c{^?b`N)03>!@#JI)r2&!6An27q?*^wyUx3T4uyeIl4*(4CV5OTK#RSnYt zq<+RKCdrYIJtdmNC-NtfH)K&pytbM^Mi6JWjkzJo0TdX>HOjJaIQmQ?Q;l2)8oN@d zVyT=%y@TihQaJX7#B2wY#_ufuaF55-sWO{OwUx$2zRyW$YM(CFBs4Y;YmBk(4u&u- zEf@rIR~4#}IMeq$?T%z3s3RAR7m%M?8No;a=1HXKP?ia#uwy!`4v0GFSjZiMii@ib z#xRmA-v~CSVl8z9cEWVEk;9_BKPS6Y2|bk#PAb|}gPxHs-dt*k`5tU#FZL)FLodY8 zmb!m`DagEJ#q1VKwO~%zmw7;LESf5u!KJNm829pbY_w$P2}16`Bb?0uoL3~V71;_U z`B~wKOB7Bp!Vn!M@o?RHydmah!dHPaT`&idV83kQPxA>E=~YgJC<)rdM1#B$JIgnq z0V{p|Cm3eeMaO58Wrv^9-kAOJ+*HR!;;A9z&>78VsYmF9$U^*ZE=K%d7=MZ~G?~Hz zSHlKWK!Us^%?uE6`E|_XI+nC354jkbUPvedHbh(DkKGkquYf}=-EEB1g>RC{O9ORL371y8V*CR5EW z@lmFq%MWEBdeHR7%(Rpf!Yg52vX%D7#@*^M`fy7Srb z^Ta9wcwf$89uL61@qeg2vc&TAGKSLV>YKI3#5lfs#q5Zm`~Ogef!!CoWWyiA=J;js z%X_n!njeF2MZgaVoMh@S@8%lR)AsYyzmqkj+C8ghxI4G6O7ovK$udULO!2$(|__`2~6JjuoERet}kenJ%I0pU_O@tU*Fsd4gm&hV?p%Y{!;r}{S^Fv z_4EJbVjFv7>+dE9{rBS@8&_vbx9>4!8&g4JV^e2mSwlNR^Z&ujriy)b3jzqfYb35o z!;J+c>%LY+?P!IticwSrP;x2|k>j3Sxg2X%E2%57

`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 zCZTgG2Pr3n8|ec9kIu&uC|d?k4-cQ4#}Z`qDX5Y2mhC(jR1Ms;UG4Ho$DE|+SeJ@{ zJQQhAXj|<)*t3KiOWTuh{Wd^mS{u{&ERV)OpZwiQ%#1->r9p zSK_^*U~=?ywH~4IUxb}{0J!SmL!z2Tzq_PpetoC^_az1JFg0=gMcQADuOP%3=H1hH zH_=dG(PD;d*037Ov5G1924U#Zns?~fs+eh1%-bWqa%ssm3=nio1r3J<4G0IBETtr? zycs~0JIOn;MecYG=~OQsYHIrf?~A5>_ob%8+uOrVA+VCJw}{lygrBBdY1k<8B^wf6 zl|<%N$7)fOZX$%y>4ueco_Gb1H@B%XrKVwrn6hUOecnc^PU0rFuCB5=*2;|u-`o(@ zL*tr4bnQzXYLc4XqFbv5sK0}A)`}`8iM8ehtj#Oc5DrE;0VxbPmL@BUa_BQwa$EW~sU#-LP0?sGmqfUGhGWcciGZ*4(}u3z=@b>Ow9DQe7lcO3K}BG3j(t& zH10>sK!&4Q5-=gN@Nxj6{|*nuyqw7KZJ1?p)NUJ?U0bOigGdsOk}Iz&9PmN_5=W*Z9M zy^pA`&dX0oo6?CSuhE~(pYbLuTPp1a1Fa@e3Lu&mmgd$;D}&g-i=D-{sv?J9kIr9r zrX&Z)aFGK^kNY{LxrotP0}k*;uN12i_2a_JJhKwh zBt{D-JRxC$8U+-`u1xD>gJ^H4lbW;7spI-=H506i=ncdK;xq*L6f7jVz$XGMg5aQk zHRJY&$@g}i_SP##iC?lR?ltnWUTT-UDlq(*BTQaYNkg zNG#sNoo{WmP+Vl}U~?+T?g25b$E-7iwhu=VVgw3JdFXm~ba+LC4p>CP3~rNTiNBl7 zL{RfLLepNPEtZj}yL_#R{(^MqIlG)c0Va}>U|9Pl&B_3tV;Ps{r)WqBznD7FcTlP4 z`JQe2DvGhmeeHGGX39zGyOOxZ3tq~Dft(BQ;mDXwwJi?sBtxo$Gf1SS2w*eQ0p&RVMNVi@d zY8v4J0(n}%6*Rw(g~l@sUuxpiJ*Y}7TzBQyU+>-qWm*InUeGt@)T9g^0J#z4){Lw* zT;69if~U9DXBR9fgVPlYy7aDhJU)gDC?_GHQtwa6QXNaah7-CzA|Fx-lH7d@N9>38 zX(F&fd3w7AkZ+ha8-gKfX%@_~<#HDs?kBg5zW>V3%Xw5jwPs6uni{7r zd`EfPYrA*SU;xDtm@E>5TrJKlg5o=h;NSXk)pt4K)GbpP0xkUg>2o|oG=`UnX7^Un zb&@8d6Fj1cBWW^c(K#Csc8xEBa4KfHY>8Lp^77-lhzgWr9kR9_p+g|-9r?VSv?qA%^1O;cqgke)%AqHlR$B{!Y1Mq zj|)Ecg?{_!>kGDAwGa7%cwSUb{BcayJihkv$}ql+yu=O}jVvAFdC{Hjh$4}u+$mx% z5V$sUiGCX%D3A>bKwY8HR)Gv*lisI4q^3vJ*nDwj|mtr!0r!~+Qoe2cw^jPCXkT7tI*01|w@ z&gPC`?O1w7hQ%=&bcHi7(fqhY3${~JepA7y@^aLwHpew^Yk$;R4v{ASHjXjXtaTc_ zuz5*nXB&PrcyWx#gQ%?HyxawmS+Wu(7ssvB1UMh!1$to&o(mv_f=9~!9@VsJCGxpu z`>g5Sp=xDhpsiCy^y>=fI0DON$&pb7o7^d{@@&hj3!6PUd=vA;G;#7&8ChamsE{`^ zY8pDra8Jntp62Ivi)Y`*XbpM60s06v@Rz^-g)TW_F@B!~y7!4AJ>37mAuz!(!C+xQ zSR61?u!{N|qHWOeR%$RXRL~vpN0SGri7-klNHEJuivbi=0qSbdV4&ghf4i|7?$>z( zI{qH?i}`~a7GyB6|8pZRq982+P*r1+m-t&(%U5#ZWFQd-(CXKLHeN@y(c z;wqq1hzE@q1b$GG0VQ_)`{MeylBlVfy%UHR=;Z98>T3M&;{0i?+0T-Bck?I)AUQrz zeF**_iGu$JlCpLnFv`D9?q6R51jKPM{Rd6!0FF#KP=O|b3iQX*TqXSjO?gXaXAmLr zU#g&%@+XpjVArlGkfaPKk^PUSnMLsjlK<9nH*zxl^V2-jGC$4+HGE%?F3%4|y9>HN z|FJgz*HW$VwU8$RNtuBf(2vdZhW3x;R6%eoJM(|2zvKebxCh$s5J-*fhZ75B_yeUs zFTrToFiB^SNH?gV2>l?G&h!UD>UP%uKh1L;Er59!q&NoZRe$VEf?5Ar^&iUad&2gQ z&WE`E%lTg=_3XQT@gJOjkAi-Hbbqrl{(pA<>_GH4O8+xI^=IAhS#v+$vmgOK=>C!~_xFg-pLM>6kUfy=zL|u~KkNJ< z$L?p*?;%(Ze6w%%M(zjE|4dH&5$)_}mG3z{KUQ6s!Y@_+kInPH;kAC&{T^5HKmqz@ z@+!aA{YNIy&r;uKTz=r6e6v>d-%9<%_4R!+-iN^8H#0N(rQbiu-u&}-|2`q@k1agM zdHkW_1&%VDD_|I;NpK*OZfAjAb z`Ttl8km0{|{F`kWKWltH$^Ech;G2y`{7&N^%H;d0$cGv7Z^oJNOSiwAFaP<=em}wX z<8AA6<}bbeZc_7S=ii6PALi)3nOXL)o&Uj%-OnQ52M&L%(%ZaWiu^(R{b!Bu2WJl< h$Zw`p^gE5e2}ml*LW4$nU|{5+pXG<~Ugg7I{||-5t(pJ; literal 0 HcmV?d00001 diff --git a/services/delivery/gradle/wrapper/gradle-wrapper.properties b/services/delivery/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c61a118f --- /dev/null +++ b/services/delivery/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/delivery/gradlew b/services/delivery/gradlew new file mode 100755 index 00000000..739907df --- /dev/null +++ b/services/delivery/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/delivery/gradlew.bat b/services/delivery/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/services/delivery/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/delivery/src/main/resources/application-dev.yml b/services/delivery/src/main/resources/application-dev.yml new file mode 100644 index 00000000..a330a654 --- /dev/null +++ b/services/delivery/src/main/resources/application-dev.yml @@ -0,0 +1,45 @@ +spring: + application: + name: delivery-dev + jackson: + property-naming-strategy: SNAKE_CASE + 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 + cloud: + openfeign: + client: + config: + sweetTracker: + connect-timeout: 3000 + read-timeout: 5000 + +security: + trusted: + ips: + - 127.0.0.1 + - "::1" + - 0:0:0:0:0:0:0:1 + +sweet-tracker: + api-key: ${SWEET_TRACKER_API_KEY} + base-url: ${SWEET_TRACKER_BASE_URL} + delivery-complete-level: 6 + +resilience4j: + circuitbreaker: + instances: + sweetTrackerService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + minimum-number-of-calls: 5 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 diff --git a/services/delivery/src/main/resources/application-local.yml b/services/delivery/src/main/resources/application-local.yml new file mode 100644 index 00000000..db79a7c0 --- /dev/null +++ b/services/delivery/src/main/resources/application-local.yml @@ -0,0 +1,51 @@ +spring: + application: + name: delivery-local + jackson: + property-naming-strategy: SNAKE_CASE + config: + import: + - file:services/delivery/env/local.env[.properties] + datasource: + url: ${MYSQL_URL:jdbc:mysql://localhost:3306/delivery} + username: ${MYSQL_USERNAME:root} + password: ${MYSQL_PASSWORD:password} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + open-in-view: false + cloud: + openfeign: + client: + config: + sweetTracker: + connect-timeout: 3000 + read-timeout: 5000 + +server: + port: ${SERVER_PORT:8097} + +security: + trusted: + ips: + - 127.0.0.1 + - "::1" + - 0:0:0:0:0:0:0:1 + +sweet-tracker: + api-key: ${SWEET_TRACKER_API_KEY:dummy-key} + base-url: ${SWEET_TRACKER_BASE_URL:https://info.sweettracker.co.kr} + delivery-complete-level: 6 + +resilience4j: + circuitbreaker: + instances: + sweetTrackerService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + minimum-number-of-calls: 5 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 diff --git a/services/delivery/src/main/resources/application-prod.yml b/services/delivery/src/main/resources/application-prod.yml new file mode 100644 index 00000000..36e869a9 --- /dev/null +++ b/services/delivery/src/main/resources/application-prod.yml @@ -0,0 +1,32 @@ +spring: + application: + name: delivery-prod + jackson: + property-naming-strategy: SNAKE_CASE + 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} + +sweet-tracker: + api-key: ${SWEET_TRACKER_API_KEY:dummy-key} + base-url: ${SWEET_TRACKER_BASE_URL:https://info.sweettracker.co.kr} + cache-ttl-hours: 6 + +spring: + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} diff --git a/services/delivery/src/main/resources/application.yml b/services/delivery/src/main/resources/application.yml new file mode 100644 index 00000000..8a8513aa --- /dev/null +++ b/services/delivery/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: ${ENVIRONMENT:local} diff --git a/services/delivery/src/test/java/kr/magicbox/delivery/DeliveryApplicationTests.java b/services/delivery/src/test/java/kr/magicbox/delivery/DeliveryApplicationTests.java new file mode 100644 index 00000000..ea08028f --- /dev/null +++ b/services/delivery/src/test/java/kr/magicbox/delivery/DeliveryApplicationTests.java @@ -0,0 +1,13 @@ +package kr.magicbox.delivery; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DeliveryApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/settings.gradle b/settings.gradle index 91bea0e3..447254ac 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:delivery' From 6072bdf83f42a9092268ad462e21c3a3c33346c8 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:43:20 +0900 Subject: [PATCH 013/107] =?UTF-8?q?feat/119=20::=20DeliveryApplicationTest?= =?UTF-8?q?s=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) --- .../test/java/kr/magicbox/delivery/DeliveryApplicationTests.java | 1 + 1 file changed, 1 insertion(+) diff --git a/services/delivery/src/test/java/kr/magicbox/delivery/DeliveryApplicationTests.java b/services/delivery/src/test/java/kr/magicbox/delivery/DeliveryApplicationTests.java index ea08028f..07dc3917 100644 --- a/services/delivery/src/test/java/kr/magicbox/delivery/DeliveryApplicationTests.java +++ b/services/delivery/src/test/java/kr/magicbox/delivery/DeliveryApplicationTests.java @@ -8,6 +8,7 @@ class DeliveryApplicationTests { @Test void contextLoads() { + // Spring Boot 컨텍스트 정상 로드 검증 } } From b36bad95db53a4f078e05b3d43b839c066da6f6c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Mon, 18 May 2026 17:46:49 +0900 Subject: [PATCH 014/107] =?UTF-8?q?feat/119=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/delivery/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/delivery/Dockerfile b/services/delivery/Dockerfile index e9a509ca..f049e24f 100644 --- a/services/delivery/Dockerfile +++ b/services/delivery/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu ARG JAR_FILE=services/delivery/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/107] =?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/107] =?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 79762a858310d7ff690c5275aa982b45badd0aa1 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:25:48 +0900 Subject: [PATCH 017/107] =?UTF-8?q?feat/119=20::=20delivery=20application-?= =?UTF-8?q?dev.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/delivery/src/main/resources/application-dev.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/delivery/src/main/resources/application-dev.yml b/services/delivery/src/main/resources/application-dev.yml index a330a654..ba4c51e3 100644 --- a/services/delivery/src/main/resources/application-dev.yml +++ b/services/delivery/src/main/resources/application-dev.yml @@ -23,9 +23,7 @@ spring: security: trusted: ips: - - 127.0.0.1 - - "::1" - - 0:0:0:0:0:0:0:1 + - ${TRUSTED_IP_GATEWAY} sweet-tracker: api-key: ${SWEET_TRACKER_API_KEY} From 6e3c69066025b814b324fd28682c5b7aa649ee65 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:26:50 +0900 Subject: [PATCH 018/107] =?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/107] =?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 0eca94377be47a5e03f2cfd6b96df609020e0721 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Tue, 19 May 2026 17:35:50 +0900 Subject: [PATCH 020/107] =?UTF-8?q?feat/119=20::=20delivery=20Dockerfile?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20=EC=A4=84=EB=B0=94=EA=BF=88?= =?UTF-8?q?=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/delivery/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/delivery/Dockerfile b/services/delivery/Dockerfile index f049e24f..c7049a93 100644 --- a/services/delivery/Dockerfile +++ b/services/delivery/Dockerfile @@ -1,5 +1,5 @@ FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu -ARG JAR_FILE=services/delivery/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/107] =?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/107] =?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 609bf3b16c25cd99daba5338b521a8800798c47b Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 08:52:07 +0900 Subject: [PATCH 023/107] =?UTF-8?q?feat/119=20::=20delivery=20application-?= =?UTF-8?q?prod.yml=20=EC=88=98=EC=A0=95=20(spring=20=EC=A4=91=EB=B3=B5/se?= =?UTF-8?q?rver.port/TRUSTED=5FIPS=20=EC=A0=9C=EA=B1=B0,=20openfeign/resil?= =?UTF-8?q?ience4j=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 | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/services/delivery/src/main/resources/application-prod.yml b/services/delivery/src/main/resources/application-prod.yml index 36e869a9..fca5bf9b 100644 --- a/services/delivery/src/main/resources/application-prod.yml +++ b/services/delivery/src/main/resources/application-prod.yml @@ -12,21 +12,32 @@ spring: hibernate: ddl-auto: validate open-in-view: false - -server: - port: ${SERVER_PORT} + cloud: + openfeign: + client: + config: + sweetTracker: + connect-timeout: 3000 + read-timeout: 5000 security: trusted: - ips: ${TRUSTED_IPS} + ips: + - ${TRUSTED_IP_GATEWAY} sweet-tracker: - api-key: ${SWEET_TRACKER_API_KEY:dummy-key} - base-url: ${SWEET_TRACKER_BASE_URL:https://info.sweettracker.co.kr} - cache-ttl-hours: 6 + api-key: ${SWEET_TRACKER_API_KEY} + base-url: ${SWEET_TRACKER_BASE_URL} + delivery-complete-level: 6 -spring: - data: - redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT:6379} +resilience4j: + circuitbreaker: + instances: + sweetTrackerService: + register-health-indicator: true + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + minimum-number-of-calls: 5 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 From 831584a774ae1f15be076841b5807bd7418f8005 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 08:53:17 +0900 Subject: [PATCH 024/107] =?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/107] =?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/107] =?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/107] =?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 8d7c97847a06a2f0a4470d4d6be8773e4b05b4c8 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 09:03:10 +0900 Subject: [PATCH 028/107] =?UTF-8?q?feat/119=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 613a3e6420ece71b1e49ed6b5a53006daf1255a5 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 11:33:42 +0900 Subject: [PATCH 029/107] =?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 030/107] =?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 f4a58390d84c08feec916a4dc34db15526823a79 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 11:33:43 +0900 Subject: [PATCH 031/107] =?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 032/107] =?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 0a144f4262687fd707bbb6b53268f2ebc3022cc2 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 13:03:43 +0900 Subject: [PATCH 033/107] =?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 034/107] =?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 fff651367716bea58de931e59d87d7d45cd2aaa8 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:09:01 +0900 Subject: [PATCH 035/107] =?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 83553536d11f0ebc74066d3bb6b6a80b456edfcc Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:09:01 +0900 Subject: [PATCH 036/107] =?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 037/107] =?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 12d17168a032a5e6a49cd1f771aaef94f38aa5d9 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:40:17 +0900 Subject: [PATCH 038/107] =?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 4123c6adfebec9324573d6b960937dd214e19088 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:40:17 +0900 Subject: [PATCH 039/107] =?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 040/107] =?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 a35f21178965d28df286de2870b9b54b4ec5f18a Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 16:48:47 +0900 Subject: [PATCH 041/107] =?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 00f55f39e34814b5614703ca48014196667ed8f0 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:28:31 +0900 Subject: [PATCH 042/107] =?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 | 3 + .../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(+) 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..5b425bf4 100644 --- a/services/auth/src/main/resources/application-dev.yml +++ b/services/auth/src/main/resources/application-dev.yml @@ -104,6 +104,9 @@ security: ips: - ${TRUSTED_IP_GATEWAY} +inbox: + max-event-age-minutes: 5 + logging: level: org.springframework.web: INFO 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 b5ba18e51c1f5def98dad612981d94f840deac57 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:30:03 +0900 Subject: [PATCH 043/107] =?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 044/107] =?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 2075ef3dab181b8fe7bba6add857a0181b930ad6 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:34:58 +0900 Subject: [PATCH 045/107] =?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 046/107] =?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 047/107] 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 048/107] ci: trigger build From 8151ce58f6e77ec69523f8195b7123b3207154db Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:39:14 +0900 Subject: [PATCH 049/107] 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 050/107] =?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 051/107] =?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 052/107] =?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 9bd6242c0244418022af9cec3a4b54633eba5297 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:45:20 +0900 Subject: [PATCH 053/107] =?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 4109f1fb6e9b2115640705b7b395f455a9cc9a1f Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:54:02 +0900 Subject: [PATCH 054/107] =?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 055/107] =?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 5b9eb32969702894bb6091bb50e94118fe8df431 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:54:08 +0900 Subject: [PATCH 056/107] =?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 057/107] =?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 058/107] =?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 e1b9b1392dbb2ca267d893542f9b6d45cbda4a25 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 17:55:27 +0900 Subject: [PATCH 059/107] =?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 060/107] =?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 061/107] =?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 184f793c79ecb1e73bca5f417b30e067f819da89 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:06:38 +0900 Subject: [PATCH 062/107] =?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 6fac0b19efa99c9c9972551beb3616bf46f08f51 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:10:02 +0900 Subject: [PATCH 063/107] =?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 구 형식(retry.interval-ms, max-attempts) → @RetryableTopic이 인식하는 spring.kafka.retry.topic.backoff.* 형식으로 dev/prod 모두 통일 Co-Authored-By: Claude Sonnet 4.6 --- services/creator/src/main/resources/application-dev.yml | 8 +++++--- services/creator/src/main/resources/application-prod.yml | 7 +++++-- services/subscribe/src/main/resources/application-dev.yml | 7 +++++-- .../subscribe/src/main/resources/application-prod.yml | 7 +++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/services/creator/src/main/resources/application-dev.yml b/services/creator/src/main/resources/application-dev.yml index f4ff366c..cf62dc0d 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} diff --git a/services/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index 5bea406a..ca4829a7 100644 --- a/services/creator/src/main/resources/application-prod.yml +++ b/services/creator/src/main/resources/application-prod.yml @@ -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} diff --git a/services/subscribe/src/main/resources/application-dev.yml b/services/subscribe/src/main/resources/application-dev.yml index a0262e19..8919d05b 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} diff --git a/services/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 87d79b19..1aeb997a 100644 --- a/services/subscribe/src/main/resources/application-prod.yml +++ b/services/subscribe/src/main/resources/application-prod.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} From 3e82824e270844a0def3d744c2ad89eb66e33215 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:11:28 +0900 Subject: [PATCH 064/107] =?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 065/107] =?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 066/107] =?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 067/107] =?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 333f2a70edbe4de0b502191357eabb858f5caa45 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:15:42 +0900 Subject: [PATCH 068/107] =?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 069/107] =?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 070/107] =?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 7ef7fc00b8604ce10475ed4cec6c76910df28c04 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 18:18:39 +0900 Subject: [PATCH 071/107] =?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 fb15bb126eda367a8c6e9fd7ee592c42842313dd Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 19:51:32 +0900 Subject: [PATCH 072/107] =?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 | 6 +++++- services/creator/Dockerfile | 6 +++++- services/general-goods/Dockerfile | 6 +++++- services/release/Dockerfile | 6 +++++- services/search/Dockerfile | 6 +++++- services/shopping-cart/Dockerfile | 6 +++++- services/subscribe/Dockerfile | 6 +++++- services/user/Dockerfile | 6 +++++- services/waiting/Dockerfile | 6 +++++- 9 files changed, 45 insertions(+), 9 deletions(-) diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index a8e8e580..0a8e0708 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -1,6 +1,10 @@ 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..0a8e0708 100644 --- a/services/creator/Dockerfile +++ b/services/creator/Dockerfile @@ -1,6 +1,10 @@ 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..0a8e0708 100644 --- a/services/general-goods/Dockerfile +++ b/services/general-goods/Dockerfile @@ -1,6 +1,10 @@ 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..0a8e0708 100644 --- a/services/release/Dockerfile +++ b/services/release/Dockerfile @@ -1,6 +1,10 @@ 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..0a8e0708 100644 --- a/services/search/Dockerfile +++ b/services/search/Dockerfile @@ -1,6 +1,10 @@ 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..0a8e0708 100644 --- a/services/shopping-cart/Dockerfile +++ b/services/shopping-cart/Dockerfile @@ -1,6 +1,10 @@ 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..0a8e0708 100644 --- a/services/subscribe/Dockerfile +++ b/services/subscribe/Dockerfile @@ -1,6 +1,10 @@ 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..0a8e0708 100644 --- a/services/user/Dockerfile +++ b/services/user/Dockerfile @@ -1,6 +1,10 @@ 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..0a8e0708 100644 --- a/services/waiting/Dockerfile +++ b/services/waiting/Dockerfile @@ -1,6 +1,10 @@ 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 1fea8c93df115567efd057b55237f6dc31ee4b3b Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 19:53:50 +0900 Subject: [PATCH 073/107] =?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 074/107] =?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 3764a7f923ecef3b2a8739566da330414b2a25e0 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:03:45 +0900 Subject: [PATCH 075/107] =?UTF-8?q?fix(docker):=20order=20Dockerfile=20app?= =?UTF-8?q?user=20=EB=B3=B4=EC=95=88=20=ED=8C=A8=ED=84=B4=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 --- services/order/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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"] From 525a95a6da1d6316372366471db0cd0fee29e2e2 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:09:45 +0900 Subject: [PATCH 076/107] =?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 077/107] =?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 5e743d6e93beda3f46dc16c6d2405230e8910e1b Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:09:50 +0900 Subject: [PATCH 078/107] =?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 | 14 ++-- .../src/main/resources/application-prod.yml | 66 +++++++++++++++++++ .../src/main/resources/application-prod.yml | 5 +- .../src/main/resources/application-prod.yml | 5 +- 5 files changed, 86 insertions(+), 9 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/creator/src/main/resources/application-prod.yml b/services/creator/src/main/resources/application-prod.yml index ca4829a7..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: @@ -66,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/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/subscribe/src/main/resources/application-prod.yml b/services/subscribe/src/main/resources/application-prod.yml index 1aeb997a..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: @@ -47,3 +47,6 @@ security: trusted: ips: - ${TRUSTED_IP_GATEWAY} + +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..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 95d0e351c35af7dfdde3e09fe8300a4c434b7e29 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:12:20 +0900 Subject: [PATCH 079/107] =?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 080/107] =?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 b3dc6037be6ac78f1004eccfb0e342dbaad5497c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:14:19 +0900 Subject: [PATCH 081/107] =?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 ef6480a53098dc2f3b322d230b95d327d41987e6 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Wed, 20 May 2026 20:24:21 +0900 Subject: [PATCH 082/107] =?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 083/107] =?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 084/107] =?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 c0a73f76442648b000bd4458fb6ecf82193f77c8 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 21:48:00 +0900 Subject: [PATCH 085/107] =?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 086/107] =?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 087/107] =?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 088/107] =?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 089/107] =?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 25d143d332fc49fee7555f5d5c089133c0abb435 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Thu, 21 May 2026 22:41:03 +0900 Subject: [PATCH 090/107] =?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 091/107] =?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 092/107] =?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 093/107] =?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 094/107] =?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 095/107] =?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 69d3f16e3ba5bbca828999064c28a644730d018d Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:21:59 +0900 Subject: [PATCH 096/107] =?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/creator/src/main/resources/application-dev.yml | 9 --------- .../subscribe/src/main/resources/application-dev.yml | 3 --- 2 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 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/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 From 3ac915e3ad56f49175c5e8e625f61ed60e02482c Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:22:16 +0900 Subject: [PATCH 097/107] =?UTF-8?q?fix:=20Dockerfile=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=20=EB=A7=88=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/Dockerfile | 4 ---- services/creator/Dockerfile | 4 ---- services/general-goods/Dockerfile | 4 ---- services/release/Dockerfile | 4 ---- services/search/Dockerfile | 4 ---- services/shopping-cart/Dockerfile | 4 ---- services/subscribe/Dockerfile | 4 ---- services/user/Dockerfile | 4 ---- services/waiting/Dockerfile | 4 ---- 9 files changed, 36 deletions(-) diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 diff --git a/services/creator/Dockerfile b/services/creator/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/creator/Dockerfile +++ b/services/creator/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 diff --git a/services/general-goods/Dockerfile b/services/general-goods/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/general-goods/Dockerfile +++ b/services/general-goods/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 diff --git a/services/release/Dockerfile b/services/release/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/release/Dockerfile +++ b/services/release/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 diff --git a/services/search/Dockerfile b/services/search/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/search/Dockerfile +++ b/services/search/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 diff --git a/services/shopping-cart/Dockerfile b/services/shopping-cart/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/shopping-cart/Dockerfile +++ b/services/shopping-cart/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 diff --git a/services/subscribe/Dockerfile b/services/subscribe/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/subscribe/Dockerfile +++ b/services/subscribe/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 diff --git a/services/user/Dockerfile b/services/user/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/user/Dockerfile +++ b/services/user/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 diff --git a/services/waiting/Dockerfile b/services/waiting/Dockerfile index d1f0696d..c7049a93 100644 --- a/services/waiting/Dockerfile +++ b/services/waiting/Dockerfile @@ -7,7 +7,3 @@ RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] -<<<<<<< HEAD - -======= ->>>>>>> feat/116 From 14a67c30eab78916c6b7ab1cbecda25e6b2d5a66 Mon Sep 17 00:00:00 2001 From: Lian08 Date: Fri, 22 May 2026 12:47:33 +0900 Subject: [PATCH 098/107] =?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 099/107] =?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 100/107] =?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 101/107] =?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 102/107] =?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 103/107] =?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 104/107] =?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 105/107] =?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 106/107] =?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 107/107] =?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"]