diff --git a/docs/BACKEND_CODEBASE.md b/docs/BACKEND_CODEBASE.md index 0a235a08..e1eb083b 100644 --- a/docs/BACKEND_CODEBASE.md +++ b/docs/BACKEND_CODEBASE.md @@ -688,5 +688,36 @@ private String payload; | Debezium | 3.1 | | Resilience4j | Spring Cloud 번들 | | JWT (jjwt) | 0.13.0 | + +--- + +## 8. Istio VirtualService & 컨트롤러 @RequestMapping 규칙 + +Kubernetes 환경에서 Istio VirtualService가 서비스별 prefix(`/release`, `/payment`, `/media` 등)를 `/`로 rewrite하여 서비스에 전달한다. + +**규칙**: 클래스 레벨 `@RequestMapping`에 서비스 prefix를 포함하면 안 된다. + +```java +// ❌ 잘못된 예 — Istio가 /release를 strip하므로 서비스엔 /{id}로 도착 → 404 +@RestController +@RequestMapping("/release") +public class ReleaseCommandController { ... } + +// ✅ 올바른 예 — prefix 없이 메서드 레벨 경로만 사용 +@RestController +public class ReleaseCommandController { ... } +``` + +**Admin 경로**: `/admin/{service}` prefix도 동일하게 `/admin`까지만 사용한다. + +```java +// ❌ +@RequestMapping("/admin/release") + +// ✅ +@RequestMapping("/admin") +``` + +적용 서비스: `release`, `payment`, `media` (그 외 서비스 추가 시 동일 규칙 적용) | Lombok | Spring Boot 관리 | | SonarCloud | GitHub Actions 통합 | \ No newline at end of file diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/OrchestratorCommandKafkaListener.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/OrchestratorCommandKafkaListener.java new file mode 100644 index 00000000..0c75123a --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/OrchestratorCommandKafkaListener.java @@ -0,0 +1,29 @@ +package kr.magicbox.generalgoods.adapter.in.kafka; + +import kr.magicbox.generalgoods.adapter.in.kafka.annotation.Idempotent; +import kr.magicbox.generalgoods.adapter.in.kafka.event.StockReserveCommandEvent; +import kr.magicbox.generalgoods.application.port.in.HandleStockReserveUseCase; +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.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrchestratorCommandKafkaListener { + + private final HandleStockReserveUseCase handleStockReserveUseCase; + + @Idempotent + @RetryableTopic(dltStrategy = DltStrategy.FAIL_ON_ERROR, dltTopicSuffix = "-dlt", exclude = {BusinessException.class}) + @KafkaListener(topics = "outbox.event.stock-reserve.general-good", groupId = "general-goods-service") + public void handleStockReserve(ConsumerRecord consumerRecord) { + log.info("[Inbox] stock-reserve.general-good 커맨드 수신. key={}", consumerRecord.key()); + handleStockReserveUseCase.handleStockReserve(consumerRecord.value()); + } +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/StockReserveCommandEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/StockReserveCommandEvent.java new file mode 100644 index 00000000..4a638c2d --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/event/StockReserveCommandEvent.java @@ -0,0 +1,25 @@ +package kr.magicbox.generalgoods.adapter.in.kafka.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record StockReserveCommandEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("total_amount") Long totalAmount, + @JsonProperty("items") List items, + @JsonProperty("occurred_at") Instant occurredAt +) implements InboxEvent { + + @Builder + public record ItemPayload( + @JsonProperty("order_line_id") Long orderLineId, + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity, + @JsonProperty("unit_price") long unitPrice + ) {} +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/GeneralGoodsJpaAdapter.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/GeneralGoodsJpaAdapter.java index d7884bdd..54a42afb 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/GeneralGoodsJpaAdapter.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/GeneralGoodsJpaAdapter.java @@ -74,4 +74,9 @@ public List findAllByCursor(Long cursorId, int size) { .map(generalGoodsMapper::toDomain) .toList(); } + + @Override + public boolean decreaseStock(Long productId, long quantity) { + return generalGoodsJpaRepository.decreaseStock(productId, quantity) > 0; + } } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsJpaRepository.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsJpaRepository.java index 673b44b7..84541f0f 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsJpaRepository.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/persistence/repository/GeneralGoodsJpaRepository.java @@ -26,4 +26,8 @@ public interface GeneralGoodsJpaRepository extends JpaRepository= :quantity AND g.isDeleted = false") + int decreaseStock(@Param("id") Long id, @Param("quantity") long quantity); } \ No newline at end of file diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/port/in/HandleStockReserveUseCase.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/port/in/HandleStockReserveUseCase.java new file mode 100644 index 00000000..69e1ac45 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/port/in/HandleStockReserveUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.generalgoods.application.port.in; + +import kr.magicbox.generalgoods.adapter.in.kafka.event.StockReserveCommandEvent; + +public interface HandleStockReserveUseCase { + void handleStockReserve(StockReserveCommandEvent event); +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/port/out/GeneralGoodsRepositoryPort.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/port/out/GeneralGoodsRepositoryPort.java index 5296e142..fc8e381d 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/port/out/GeneralGoodsRepositoryPort.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/port/out/GeneralGoodsRepositoryPort.java @@ -16,4 +16,6 @@ public interface GeneralGoodsRepositoryPort { GeneralGoods findById(GeneralGoodsId id); List findAllByCursor(Long cursorId, int size); + + boolean decreaseStock(Long productId, long quantity); } \ No newline at end of file diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/service/HandleStockReserveService.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/service/HandleStockReserveService.java new file mode 100644 index 00000000..75b978cb --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/application/service/HandleStockReserveService.java @@ -0,0 +1,60 @@ +package kr.magicbox.generalgoods.application.service; + +import kr.magicbox.generalgoods.adapter.in.kafka.event.StockReserveCommandEvent; +import kr.magicbox.generalgoods.application.port.in.HandleStockReserveUseCase; +import kr.magicbox.generalgoods.application.port.out.GeneralGoodsOutboxPort; +import kr.magicbox.generalgoods.application.port.out.GeneralGoodsRepositoryPort; +import kr.magicbox.generalgoods.domain.event.StockReserveFailedEvent; +import kr.magicbox.generalgoods.domain.event.StockReserveSucceededEvent; +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 HandleStockReserveService implements HandleStockReserveUseCase { + + private final GeneralGoodsRepositoryPort generalGoodsRepositoryPort; + private final GeneralGoodsOutboxPort generalGoodsOutboxPort; + + @Transactional + @Override + public void handleStockReserve(StockReserveCommandEvent event) { + if (event.items() == null || event.items().isEmpty()) { + log.warn("[StockReserve] items 없음. orderId={}", event.orderId()); + generalGoodsOutboxPort.save(StockReserveFailedEvent.builder() + .orderId(event.orderId()) + .customerId(event.customerId()) + .reason("items 없음") + .occurredAt(Instant.now()) + .build()); + return; + } + + for (StockReserveCommandEvent.ItemPayload item : event.items()) { + boolean decreased = generalGoodsRepositoryPort.decreaseStock(item.productId(), item.quantity()); + if (!decreased) { + log.warn("[StockReserve] 재고 부족. orderId={}, productId={}", event.orderId(), item.productId()); + generalGoodsOutboxPort.save(StockReserveFailedEvent.builder() + .orderId(event.orderId()) + .customerId(event.customerId()) + .reason("재고 부족. productId=" + item.productId()) + .occurredAt(Instant.now()) + .build()); + return; + } + } + + log.info("[StockReserve] 재고 예약 성공. orderId={}", event.orderId()); + generalGoodsOutboxPort.save(StockReserveSucceededEvent.builder() + .orderId(event.orderId()) + .customerId(event.customerId()) + .totalAmount(event.totalAmount()) + .occurredAt(Instant.now()) + .build()); + } +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/GeneralGoodsDomainEventType.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/GeneralGoodsDomainEventType.java index bd19ba20..c3969698 100644 --- a/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/GeneralGoodsDomainEventType.java +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/GeneralGoodsDomainEventType.java @@ -9,7 +9,9 @@ public enum GeneralGoodsDomainEventType { GENERAL_GOODS_CREATED("general-goods-created"), GENERAL_GOODS_UPDATED("general-goods-updated"), - GENERAL_GOODS_DELETED("general-goods-deleted"); + GENERAL_GOODS_DELETED("general-goods-deleted"), + STOCK_RESERVE_SUCCEEDED("stock-reserve-general-goods-succeeded"), + STOCK_RESERVE_FAILED("stock-reserve-general-goods-failed"); private final String value; } diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/StockReserveFailedEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/StockReserveFailedEvent.java new file mode 100644 index 00000000..5ff2fa3e --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/StockReserveFailedEvent.java @@ -0,0 +1,25 @@ +package kr.magicbox.generalgoods.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record StockReserveFailedEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("reason") String reason, + @JsonProperty("occurred_at") Instant occurredAt +) implements GeneralGoodsDomainEvent { + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public GeneralGoodsDomainEventType eventType() { + return GeneralGoodsDomainEventType.STOCK_RESERVE_FAILED; + } +} diff --git a/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/StockReserveSucceededEvent.java b/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/StockReserveSucceededEvent.java new file mode 100644 index 00000000..907eeaf7 --- /dev/null +++ b/services/general-goods/src/main/java/kr/magicbox/generalgoods/domain/event/StockReserveSucceededEvent.java @@ -0,0 +1,25 @@ +package kr.magicbox.generalgoods.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.time.Instant; + +@Builder +public record StockReserveSucceededEvent( + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("total_amount") Long totalAmount, + @JsonProperty("occurred_at") Instant occurredAt +) implements GeneralGoodsDomainEvent { + + @Override + public String key() { + return orderId.toString(); + } + + @Override + public GeneralGoodsDomainEventType eventType() { + return GeneralGoodsDomainEventType.STOCK_RESERVE_SUCCEEDED; + } +} diff --git a/services/notification/src/main/java/kr/magicbox/notification/adapter/in/web/controller/NotificationCommandController.java b/services/notification/src/main/java/kr/magicbox/notification/adapter/in/web/controller/NotificationCommandController.java index c7809672..41384b97 100644 --- a/services/notification/src/main/java/kr/magicbox/notification/adapter/in/web/controller/NotificationCommandController.java +++ b/services/notification/src/main/java/kr/magicbox/notification/adapter/in/web/controller/NotificationCommandController.java @@ -1,12 +1,16 @@ package kr.magicbox.notification.adapter.in.web.controller; +import kr.magicbox.notification.adapter.in.web.dto.request.ReadNotificationsRequest; import kr.magicbox.notification.adapter.in.web.dto.request.RegisterFcmTokenRequest; +import kr.magicbox.notification.application.dto.command.ReadNotificationCommand; import kr.magicbox.notification.application.dto.command.RegisterFcmTokenCommand; +import kr.magicbox.notification.application.port.in.ReadNotificationUseCase; import kr.magicbox.notification.application.port.in.RegisterFcmTokenUseCase; import kr.magicbox.notification.domain.vo.UserId; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,6 +22,7 @@ public class NotificationCommandController { private final RegisterFcmTokenUseCase registerFcmTokenUseCase; + private final ReadNotificationUseCase readNotificationUseCase; @PostMapping("/fcm-token") public ResponseEntity registerFcmToken( @@ -26,4 +31,12 @@ public ResponseEntity registerFcmToken( registerFcmTokenUseCase.register(RegisterFcmTokenCommand.of(userId.value(), request.token())); return ResponseEntity.ok().build(); } + + @PatchMapping("/read") + public ResponseEntity readNotifications( + @AuthenticationPrincipal UserId userId, + @RequestBody ReadNotificationsRequest request) { + readNotificationUseCase.readAll(ReadNotificationCommand.of(request.notificationIds(), userId.value())); + return ResponseEntity.noContent().build(); + } } diff --git a/services/notification/src/main/java/kr/magicbox/notification/adapter/in/web/dto/request/ReadNotificationsRequest.java b/services/notification/src/main/java/kr/magicbox/notification/adapter/in/web/dto/request/ReadNotificationsRequest.java new file mode 100644 index 00000000..067fb9a2 --- /dev/null +++ b/services/notification/src/main/java/kr/magicbox/notification/adapter/in/web/dto/request/ReadNotificationsRequest.java @@ -0,0 +1,6 @@ +package kr.magicbox.notification.adapter.in.web.dto.request; + +import java.util.List; + +public record ReadNotificationsRequest(List notificationIds) { +} diff --git a/services/notification/src/main/java/kr/magicbox/notification/adapter/out/persistence/NotificationJpaAdapter.java b/services/notification/src/main/java/kr/magicbox/notification/adapter/out/persistence/NotificationJpaAdapter.java index f1382ff1..bb5cb0d0 100644 --- a/services/notification/src/main/java/kr/magicbox/notification/adapter/out/persistence/NotificationJpaAdapter.java +++ b/services/notification/src/main/java/kr/magicbox/notification/adapter/out/persistence/NotificationJpaAdapter.java @@ -4,10 +4,12 @@ import kr.magicbox.notification.adapter.out.persistence.repository.NotificationJpaRepository; import kr.magicbox.notification.application.port.out.NotificationRepositoryPort; import kr.magicbox.notification.domain.aggregate.Notification; +import kr.magicbox.notification.domain.enums.NotificationStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository @RequiredArgsConstructor @@ -20,6 +22,24 @@ public void save(Notification notification) { notificationJpaRepository.save(NotificationEntity.from(notification)); } + @Override + public void update(Notification notification) { + notificationJpaRepository.findByIdAndUserId( + notification.getId().value(), notification.getUserId().value()) + .ifPresent(NotificationEntity::markRead); + } + + @Override + public void updateAllByIdsAndUserId(List notificationIds, Long userId) { + notificationJpaRepository.updateStatusByIdsAndUserId(notificationIds, userId, NotificationStatus.READ); + } + + @Override + public Optional findByIdAndUserId(Long notificationId, Long userId) { + return notificationJpaRepository.findByIdAndUserId(notificationId, userId) + .map(NotificationEntity::toDomain); + } + @Override public List findAllByUserId(Long userId) { return notificationJpaRepository.findAllByUserIdOrderByCreatedAtDesc(userId) diff --git a/services/notification/src/main/java/kr/magicbox/notification/adapter/out/persistence/repository/NotificationJpaRepository.java b/services/notification/src/main/java/kr/magicbox/notification/adapter/out/persistence/repository/NotificationJpaRepository.java index 2a55e0bf..b0a91df5 100644 --- a/services/notification/src/main/java/kr/magicbox/notification/adapter/out/persistence/repository/NotificationJpaRepository.java +++ b/services/notification/src/main/java/kr/magicbox/notification/adapter/out/persistence/repository/NotificationJpaRepository.java @@ -1,10 +1,20 @@ package kr.magicbox.notification.adapter.out.persistence.repository; import kr.magicbox.notification.adapter.out.persistence.entity.NotificationEntity; +import kr.magicbox.notification.domain.enums.NotificationStatus; 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; +import java.util.Optional; public interface NotificationJpaRepository extends JpaRepository { List findAllByUserIdOrderByCreatedAtDesc(Long userId); + Optional findByIdAndUserId(Long id, Long userId); + + @Modifying + @Query("UPDATE NotificationEntity n SET n.status = :status WHERE n.id IN :ids AND n.userId = :userId") + void updateStatusByIdsAndUserId(@Param("ids") List ids, @Param("userId") Long userId, @Param("status") NotificationStatus status); } diff --git a/services/notification/src/main/java/kr/magicbox/notification/application/dto/command/ReadNotificationCommand.java b/services/notification/src/main/java/kr/magicbox/notification/application/dto/command/ReadNotificationCommand.java new file mode 100644 index 00000000..05feccf8 --- /dev/null +++ b/services/notification/src/main/java/kr/magicbox/notification/application/dto/command/ReadNotificationCommand.java @@ -0,0 +1,12 @@ +package kr.magicbox.notification.application.dto.command; + +import java.util.List; + +public record ReadNotificationCommand( + List notificationIds, + Long userId +) { + public static ReadNotificationCommand of(List notificationIds, Long userId) { + return new ReadNotificationCommand(notificationIds, userId); + } +} diff --git a/services/notification/src/main/java/kr/magicbox/notification/application/port/in/ReadNotificationUseCase.java b/services/notification/src/main/java/kr/magicbox/notification/application/port/in/ReadNotificationUseCase.java new file mode 100644 index 00000000..6afd0fce --- /dev/null +++ b/services/notification/src/main/java/kr/magicbox/notification/application/port/in/ReadNotificationUseCase.java @@ -0,0 +1,7 @@ +package kr.magicbox.notification.application.port.in; + +import kr.magicbox.notification.application.dto.command.ReadNotificationCommand; + +public interface ReadNotificationUseCase { + void readAll(ReadNotificationCommand command); +} diff --git a/services/notification/src/main/java/kr/magicbox/notification/application/port/out/NotificationRepositoryPort.java b/services/notification/src/main/java/kr/magicbox/notification/application/port/out/NotificationRepositoryPort.java index 122eb316..e99c7af1 100644 --- a/services/notification/src/main/java/kr/magicbox/notification/application/port/out/NotificationRepositoryPort.java +++ b/services/notification/src/main/java/kr/magicbox/notification/application/port/out/NotificationRepositoryPort.java @@ -3,8 +3,12 @@ import kr.magicbox.notification.domain.aggregate.Notification; import java.util.List; +import java.util.Optional; public interface NotificationRepositoryPort { void save(Notification notification); + void update(Notification notification); + void updateAllByIdsAndUserId(List notificationIds, Long userId); + Optional findByIdAndUserId(Long notificationId, Long userId); List findAllByUserId(Long userId); } diff --git a/services/notification/src/main/java/kr/magicbox/notification/application/service/ReadNotificationService.java b/services/notification/src/main/java/kr/magicbox/notification/application/service/ReadNotificationService.java new file mode 100644 index 00000000..01bd57d5 --- /dev/null +++ b/services/notification/src/main/java/kr/magicbox/notification/application/service/ReadNotificationService.java @@ -0,0 +1,21 @@ +package kr.magicbox.notification.application.service; + +import kr.magicbox.notification.application.dto.command.ReadNotificationCommand; +import kr.magicbox.notification.application.port.in.ReadNotificationUseCase; +import kr.magicbox.notification.application.port.out.NotificationRepositoryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReadNotificationService implements ReadNotificationUseCase { + + private final NotificationRepositoryPort notificationRepositoryPort; + + @Transactional + @Override + public void readAll(ReadNotificationCommand command) { + notificationRepositoryPort.updateAllByIdsAndUserId(command.notificationIds(), command.userId()); + } +} 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..0c3c330b --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateOrderRequest.java @@ -0,0 +1,38 @@ +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()) + .productType(line.productType()) + .thumbnailUrl(line.thumbnailUrl()) + .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..2b5f3c01 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/CreateReleaseOrderRequest.java @@ -0,0 +1,30 @@ +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, + String thumbnailUrl, + @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) + .thumbnailUrl(thumbnailUrl) + .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..9aad8123 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/request/OrderLineRequest.java @@ -0,0 +1,17 @@ +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; +import kr.magicbox.order.domain.enums.ProductType; + +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, + @NotNull(message = "상품 타입은 필수입니다.") ProductType productType, + String thumbnailUrl +) {} 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..0230deb9 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/in/web/dto/response/OrderLineResponse.java @@ -0,0 +1,25 @@ +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, + String thumbnailUrl +) { + public static OrderLineResponse from(OrderLineResult result) { + return OrderLineResponse.builder() + .orderLineId(result.orderLineId()) + .productId(result.productId()) + .productName(result.productName()) + .quantity(result.quantity()) + .unitPrice(result.unitPrice()) + .thumbnailUrl(result.thumbnailUrl()) + .build(); + } +} 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..3e6824c9 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/entity/OrderLineEntity.java @@ -0,0 +1,66 @@ +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 kr.magicbox.order.domain.enums.ProductType; +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; + + @Enumerated(EnumType.STRING) + @Column(name = "product_type", nullable = false) + private ProductType productType; + + @Column(name = "thumbnail_url") + private String thumbnailUrl; + + @Builder + public OrderLineEntity(Long orderId, Long productId, Long sellerId, String productName, Integer quantity, Long unitPrice, OrderLineDeliveryStatus deliveryStatus, ProductType productType, String thumbnailUrl) { + 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; + this.productType = productType; + this.thumbnailUrl = thumbnailUrl; + } + + public void updateDeliveryStatus(OrderLineDeliveryStatus deliveryStatus) { + this.deliveryStatus = deliveryStatus; + } +} 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..9644a79e --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/adapter/out/persistence/mapper/OrderMapper.java @@ -0,0 +1,83 @@ +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()) + .productType(domain.getProductType()) + .thumbnailUrl(domain.getThumbnailUrl()) + .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()) + .productType(entity.getProductType()) + .thumbnailUrl(entity.getThumbnailUrl()) + .build(); + } +} 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..aaba97bd --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateOrderCommand.java @@ -0,0 +1,27 @@ +package kr.magicbox.order.application.dto.command; + +import kr.magicbox.order.domain.enums.ProductType; +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, + ProductType productType, + String thumbnailUrl + ) {} +} 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..2a99d2a9 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/command/CreateReleaseOrderCommand.java @@ -0,0 +1,16 @@ +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, + String thumbnailUrl, + ShippingAddress shippingAddress +) {} 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..e53f27f0 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/dto/result/OrderLineResult.java @@ -0,0 +1,13 @@ +package kr.magicbox.order.application.dto.result; + +import lombok.Builder; + +@Builder +public record OrderLineResult( + Long orderLineId, + Long productId, + String productName, + int quantity, + long unitPrice, + String thumbnailUrl +) {} 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..207ef954 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CreateOrderService.java @@ -0,0 +1,70 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.CreateOrderCommand; +import kr.magicbox.order.application.dto.result.CreateOrderResult; +import kr.magicbox.order.application.dto.result.OrderLineResult; +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 CreateOrderResult 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()) + .productType(line.productType()) + .thumbnailUrl(line.thumbnailUrl()) + .build()) + .toList(); + + Order order = Order.createBuilder() + .customerId(command.customerId()) + .sellerId(command.sellerId()) + .totalAmount(command.totalAmount()) + .shippingAddress(command.shippingAddress()) + .orderLines(orderLines) + .build(); + + Order savedOrder = orderRepositoryPort.save(order); + Long savedOrderId = savedOrder.getId().value(); + orderOutboxPort.save(OrderPrepareEvent.from(savedOrder)); + + List orderLineResults = savedOrder.getOrderLines().stream() + .map(line -> OrderLineResult.builder() + .orderLineId(line.getId().value()) + .productId(line.getProductId()) + .productName(line.getProductName()) + .quantity(line.getQuantity()) + .unitPrice(line.getUnitPrice()) + .thumbnailUrl(line.getThumbnailUrl()) + .build()) + .toList(); + + return CreateOrderResult.builder() + .orderId(savedOrderId) + .sellerId(command.sellerId()) + .totalAmount(command.totalAmount()) + .orderLines(orderLineResults) + .build(); + } +} 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..698708d6 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/CreateReleaseOrderService.java @@ -0,0 +1,85 @@ +package kr.magicbox.order.application.service; + +import kr.magicbox.order.application.dto.command.CreateReleaseOrderCommand; +import kr.magicbox.order.application.dto.result.CreateOrderResult; +import kr.magicbox.order.application.dto.result.OrderLineResult; +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.enums.ProductType; +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 CreateOrderResult createReleaseOrder(CreateReleaseOrderCommand command) { + boolean valid = purchaseTokenValidationPort.validate( + command.releaseId(), command.customerId(), command.purchaseToken()); + if (!valid) { + throw new InvalidPurchaseTokenException(); + } + + return saveOrderWithOutbox(command); + } + + @Transactional + protected CreateOrderResult saveOrderWithOutbox(CreateReleaseOrderCommand command) { + OrderLine orderLine = OrderLine.createBuilder() + .productId(command.releaseId()) + .sellerId(command.sellerId()) + .productName(command.productName()) + .quantity(1) + .unitPrice(command.unitPrice()) + .productType(ProductType.RELEASE) + .thumbnailUrl(command.thumbnailUrl()) + .build(); + + Order order = Order.createBuilder() + .customerId(command.customerId()) + .sellerId(command.sellerId()) + .totalAmount(command.unitPrice()) + .shippingAddress(command.shippingAddress()) + .orderLines(List.of(orderLine)) + .build(); + + Order savedOrder = orderRepositoryPort.save(order); + Long savedOrderId = savedOrder.getId().value(); + orderOutboxPort.save(OrderPrepareEvent.from(savedOrder)); + orderOutboxPort.save(ReleaseSoldQuantityIncreaseEvent.of(command.releaseId())); + + OrderLineResult orderLineResult = savedOrder.getOrderLines().stream() + .findFirst() + .map(line -> OrderLineResult.builder() + .orderLineId(line.getId().value()) + .productId(line.getProductId()) + .productName(line.getProductName()) + .quantity(line.getQuantity()) + .unitPrice(line.getUnitPrice()) + .thumbnailUrl(line.getThumbnailUrl()) + .build()) + .orElseThrow(); + + return CreateOrderResult.builder() + .orderId(savedOrderId) + .sellerId(command.sellerId()) + .totalAmount(command.unitPrice()) + .orderLines(List.of(orderLineResult)) + .build(); + } +} 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..1ac2712b --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/application/service/OrderResultMapper.java @@ -0,0 +1,37 @@ +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()) + .thumbnailUrl(line.getThumbnailUrl()) + .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/domain/aggregate/OrderLine.java b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java new file mode 100644 index 00000000..d1083cd2 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/aggregate/OrderLine.java @@ -0,0 +1,134 @@ +package kr.magicbox.order.domain.aggregate; + +import kr.magicbox.order.domain.enums.OrderLineDeliveryStatus; +import kr.magicbox.order.domain.enums.ProductType; +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 final ProductType productType; + private final String thumbnailUrl; + private OrderLineDeliveryStatus deliveryStatus; + + @Builder(builderMethodName = "createBuilder", builderClassName = "CreateBuilder") + public OrderLine(Long productId, Long sellerId, String productName, Integer quantity, Long unitPrice, ProductType productType, String thumbnailUrl) { + validateCreate(productId, quantity, unitPrice); + this.id = null; + this.productId = productId; + this.sellerId = sellerId; + this.productName = productName; + this.quantity = quantity; + this.unitPrice = unitPrice; + this.productType = productType; + this.thumbnailUrl = thumbnailUrl; + this.deliveryStatus = OrderLineDeliveryStatus.PENDING; + } + + @Builder(builderMethodName = "reconstructBuilder", builderClassName = "ReconstructBuilder") + public OrderLine(OrderLineId id, Long productId, Long sellerId, String productName, + Integer quantity, Long unitPrice, ProductType productType, String thumbnailUrl, 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.productType = productType; + this.thumbnailUrl = thumbnailUrl; + 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/ProductType.java b/services/order/src/main/java/kr/magicbox/order/domain/enums/ProductType.java new file mode 100644 index 00000000..6c2b4a93 --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/enums/ProductType.java @@ -0,0 +1,6 @@ +package kr.magicbox.order.domain.enums; + +public enum ProductType { + RELEASE, + GENERAL_GOODS +} 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..a269fe3f --- /dev/null +++ b/services/order/src/main/java/kr/magicbox/order/domain/event/OrderPrepareEvent.java @@ -0,0 +1,86 @@ +package kr.magicbox.order.domain.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import kr.magicbox.order.domain.aggregate.Order; +import kr.magicbox.order.domain.enums.ProductType; +import kr.magicbox.order.domain.vo.ShippingAddress; +import lombok.Builder; + +import java.time.Instant; +import java.util.List; + +@Builder +public record OrderPrepareEvent( + @JsonProperty("event_id") Long eventId, + @JsonProperty("order_id") Long orderId, + @JsonProperty("customer_id") Long customerId, + @JsonProperty("seller_id") Long sellerId, + @JsonProperty("items") List items, + @JsonProperty("total_amount") long totalAmount, + @JsonProperty("shipping_address") ShippingAddressPayload shippingAddress, + @JsonProperty("occurred_at") Instant occurredAt +) implements OrderDomainEvent { + + public static OrderPrepareEvent from(Order order) { + Long orderId = order.getId().value(); + List items = order.getOrderLines().stream() + .map(line -> OrderItemPayload.builder() + .orderLineId(line.getId().value()) + .productId(line.getProductId()) + .quantity(line.getQuantity()) + .unitPrice(line.getUnitPrice()) + .productName(line.getProductName()) + .productType(line.getProductType()) + .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() + .eventId(orderId) + .orderId(orderId) + .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("order_line_id") Long orderLineId, + @JsonProperty("product_id") Long productId, + @JsonProperty("quantity") int quantity, + @JsonProperty("unit_price") long unitPrice, + @JsonProperty("product_name") String productName, + @JsonProperty("product_type") ProductType productType + ) {} + + @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 + ) {} +}