diff --git a/kafka.sh b/kafka.sh index 8d273ced..cb838066 100755 --- a/kafka.sh +++ b/kafka.sh @@ -15,16 +15,11 @@ set -e # 에러 발생 시 즉시 중단 echo "=== 선택적 컨테이너 종료 (Order, Payment, Temporal) ===" -docker compose stop spot-order spot-payment temporal temporal-ui -docker compose rm -f spot-order spot-payment temporal temporal-ui +docker compose stop spot-order spot-payment +docker compose rm -f spot-order spot-payment echo "=== 핵심 서비스 빌드 (Order, Payment) ===" (cd spot-order && ./gradlew clean bootJar -x test) (cd spot-payment && ./gradlew clean bootJar -x test) -echo "=== 인프라 및 핵심 서비스 시작 ===" -docker compose up -d db temporal temporal-ui -echo ">> Temporal 안정화 대기 (5초)..." -sleep 5 - docker compose up --build -d spot-order spot-payment \ No newline at end of file diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/dto/MenuOptionResponse.java b/spot-order/src/main/java/com/example/Spot/global/feign/dto/MenuOptionResponse.java index bd9b4d57..0a4aa69c 100644 --- a/spot-order/src/main/java/com/example/Spot/global/feign/dto/MenuOptionResponse.java +++ b/spot-order/src/main/java/com/example/Spot/global/feign/dto/MenuOptionResponse.java @@ -2,6 +2,9 @@ import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -11,6 +14,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) public class MenuOptionResponse { private UUID id; @@ -18,5 +22,6 @@ public class MenuOptionResponse { private String name; private String detail; private Integer price; + @JsonProperty("isDeleted") private boolean isDeleted; } diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/dto/MenuResponse.java b/spot-order/src/main/java/com/example/Spot/global/feign/dto/MenuResponse.java index 93fbd366..fd6fa6f7 100644 --- a/spot-order/src/main/java/com/example/Spot/global/feign/dto/MenuResponse.java +++ b/spot-order/src/main/java/com/example/Spot/global/feign/dto/MenuResponse.java @@ -2,6 +2,9 @@ import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -11,6 +14,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) public class MenuResponse { private UUID id; @@ -19,6 +23,8 @@ public class MenuResponse { private String description; private Integer price; private String imageUrl; + @JsonProperty("isHidden") private boolean isHidden; + @JsonProperty("isDeleted") private boolean isDeleted; } diff --git a/spot-order/src/main/java/com/example/Spot/global/feign/dto/StoreResponse.java b/spot-order/src/main/java/com/example/Spot/global/feign/dto/StoreResponse.java index 3810000f..42baa38c 100644 --- a/spot-order/src/main/java/com/example/Spot/global/feign/dto/StoreResponse.java +++ b/spot-order/src/main/java/com/example/Spot/global/feign/dto/StoreResponse.java @@ -2,6 +2,9 @@ import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -11,6 +14,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) public class StoreResponse { private UUID id; @@ -20,5 +24,6 @@ public class StoreResponse { private String phoneNumber; private String status; private Integer ownerId; + @JsonProperty("isDeleted") private boolean isDeleted; } diff --git a/spot-order/src/main/java/com/example/Spot/order/application/service/OrderServiceImpl.java b/spot-order/src/main/java/com/example/Spot/order/application/service/OrderServiceImpl.java index 124b0e34..89e9c2e7 100644 --- a/spot-order/src/main/java/com/example/Spot/order/application/service/OrderServiceImpl.java +++ b/spot-order/src/main/java/com/example/Spot/order/application/service/OrderServiceImpl.java @@ -2,10 +2,9 @@ import java.math.BigDecimal; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -14,15 +13,12 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; +import com.example.Spot.global.feign.MenuClient; import com.example.Spot.global.feign.PaymentClient; import com.example.Spot.global.feign.StoreClient; import com.example.Spot.global.feign.dto.MenuOptionResponse; import com.example.Spot.global.feign.dto.MenuResponse; -import com.example.Spot.global.feign.dto.PaymentCancelRequest; -import com.example.Spot.global.feign.dto.PaymentResponse; import com.example.Spot.global.feign.dto.StoreResponse; import com.example.Spot.order.domain.entity.OrderEntity; import com.example.Spot.order.domain.entity.OrderItemEntity; @@ -33,24 +29,24 @@ import com.example.Spot.order.domain.exception.InvalidOrderStatusTransitionException; import com.example.Spot.order.domain.repository.OrderItemOptionRepository; import com.example.Spot.order.domain.repository.OrderRepository; -import com.example.Spot.order.infrastructure.aop.OrderStatusChange; import com.example.Spot.order.infrastructure.aop.OrderValidationContext; import com.example.Spot.order.infrastructure.aop.StoreOwnershipRequired; import com.example.Spot.order.infrastructure.aop.ValidateStoreAndMenu; import com.example.Spot.order.infrastructure.producer.OrderEventProducer; import com.example.Spot.order.infrastructure.temporal.config.OrderConstants; +import com.example.Spot.order.infrastructure.temporal.dto.OrderStatusUpdate; import com.example.Spot.order.infrastructure.temporal.workflow.OrderWorkflow; import com.example.Spot.order.presentation.dto.request.OrderCreateRequestDto; import com.example.Spot.order.presentation.dto.request.OrderItemOptionRequestDto; import com.example.Spot.order.presentation.dto.request.OrderItemRequestDto; +import com.example.Spot.order.presentation.dto.response.OrderContextDto; import com.example.Spot.order.presentation.dto.response.OrderResponseDto; import com.example.Spot.order.presentation.dto.response.OrderStatsResponseDto; -import io.github.resilience4j.bulkhead.annotation.Bulkhead; -import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; -import io.github.resilience4j.retry.annotation.Retry; import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowNotFoundException; import io.temporal.client.WorkflowOptions; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -66,6 +62,7 @@ public class OrderServiceImpl implements OrderService { private final StoreClient storeClient; private final OrderEventProducer orderEventProducer; private final WorkflowClient workflowClient; + private final MenuClient menuClient; // ******* // // 주문 조회 // @@ -211,267 +208,110 @@ private void fetchOrderItemOptions(List orders) { @Transactional @ValidateStoreAndMenu public OrderResponseDto createOrder(OrderCreateRequestDto requestDto, Integer userId) { - - StoreResponse store = OrderValidationContext.getStoreResponse(); - - checkDuplicateOrder(userId, store.getId(), requestDto); - - String orderNumber = generateOrderNumber(); - OrderEntity order = OrderEntity.builder() - .storeId(store.getId()) - .userId(userId) - .orderNumber(orderNumber) - .pickupTime(requestDto.getPickupTime()) - .needDisposables(requestDto.getNeedDisposables()) - .request(requestDto.getRequest()) - .build(); - - for (OrderItemRequestDto itemDto : requestDto.getOrderItems()) { - MenuResponse menu = OrderValidationContext.getMenuResponse(itemDto.getMenuId()); - - OrderItemEntity orderItem = OrderItemEntity.builder() - .menuId(menu.getId()) - .menuName(menu.getName()) - .menuPrice(BigDecimal.valueOf(menu.getPrice())) - .quantity(itemDto.getQuantity()) - .build(); - - for (OrderItemOptionRequestDto optionDto : itemDto.getOptions()) { - MenuOptionResponse menuOption = OrderValidationContext.getMenuOptionResponse(optionDto.getMenuOptionId()); - - OrderItemOptionEntity orderItemOption = OrderItemOptionEntity.builder() - .menuOptionId(menuOption.getId()) - .optionName(menuOption.getName()) - .optionDetail(menuOption.getDetail()) - .optionPrice(BigDecimal.valueOf(menuOption.getPrice())) - .build(); - - orderItem.addOrderItemOption(orderItemOption); - } - - order.addOrderItem(orderItem); - } - - OrderEntity savedOrder = orderRepository.save(order); - OrderResponseDto responseDto = OrderResponseDto.from(savedOrder); - - orderEventProducer.reserveOrderCreated( - savedOrder.getId(), - userId, - responseDto.getTotalAmount().longValue() - ); - + + OrderContextDto contextDto = fetchOrderContext(requestDto); + checkDuplicateOrder(userId, contextDto.getStore().getId(), requestDto); + UUID orderId = UUID.randomUUID(); + BigDecimal totalAmount = contextDto.calculateTotalAmount(requestDto); + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, WorkflowOptions.newBuilder() - .setWorkflowId(savedOrder.getId().toString()) + .setWorkflowId(orderId.toString()) .setTaskQueue(OrderConstants.ORDER_TASK_QUEUE) .build()); - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - WorkflowClient.start(workflow::processOrder, savedOrder.getId()); - } - }); - return responseDto; + WorkflowClient.start(workflow::processOrder, orderId, userId, requestDto, contextDto); + + return OrderResponseDto.of(orderId, userId, requestDto, contextDto, totalAmount); } - // *********** // // 주문 상태 변경 // // *********** // @Override - @Transactional - @OrderStatusChange("ACCEPT") public OrderResponseDto acceptOrder(UUID orderId, Integer estimatedTime) { - OrderEntity order = OrderValidationContext.getCurrentOrder(); - order.acceptOrder(estimatedTime); - - // 주문 수락 이벤트 발행 - orderEventProducer.reserveOrderAccepted(order.getUserId(), order.getId(), estimatedTime); - sendSignalToWorkflow(orderId, OrderStatus.ACCEPTED); - - return OrderResponseDto.from(order); + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalStatusChanged(new OrderStatusUpdate(OrderStatus.ACCEPTED, estimatedTime, null, null)); + return OrderResponseDto.fromId(orderId, OrderStatus.ACCEPTED); } @Override - @Transactional - @OrderStatusChange("REJECT") + @Transactional(readOnly = true) public OrderResponseDto rejectOrder(UUID orderId, String reason) { - - OrderEntity order = OrderValidationContext.getCurrentOrder(); + OrderEntity order = orderRepository.findById(orderId) + .orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다.")); if (order.getOrderStatus() != OrderStatus.PENDING) { - throw new InvalidOrderStatusTransitionException(order.getOrderStatus(), OrderStatus.CANCEL_PENDING); + throw new InvalidOrderStatusTransitionException(order.getOrderStatus(), OrderStatus.REJECT_PENDING); } - order.initiateCancel(reason, null); - orderEventProducer.reserveOrderCancelled(order.getId(), reason); - sendSignalToWorkflow(orderId, OrderStatus.CANCEL_PENDING); - log.info("주문 거절 처리 시작 (환불 대기): orderId={}, reason={}", orderId, reason); - - return OrderResponseDto.from(order); + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalStatusChanged(new OrderStatusUpdate(OrderStatus.REJECT_PENDING, null, reason, null)); + return OrderResponseDto.fromId(orderId, OrderStatus.REJECT_PENDING); } @Override - @Transactional - @OrderStatusChange("COOKING") public OrderResponseDto startCooking(UUID orderId) { - - OrderEntity order = OrderValidationContext.getCurrentOrder(); - order.startCooking(); - sendSignalToWorkflow(orderId, OrderStatus.COOKING); - - return OrderResponseDto.from(order); + sendSignal(orderId, OrderStatus.COOKING); + return OrderResponseDto.fromId(orderId, OrderStatus.COOKING); } @Override - @Transactional - @OrderStatusChange("READY") public OrderResponseDto readyForPickup(UUID orderId) { - - OrderEntity order = OrderValidationContext.getCurrentOrder(); - order.readyForPickup(); - sendSignalToWorkflow(orderId, OrderStatus.READY); - - return OrderResponseDto.from(order); + sendSignal(orderId, OrderStatus.READY); + return OrderResponseDto.fromId(orderId, OrderStatus.READY); } @Override - @Transactional - @OrderStatusChange("COMPLETE") public OrderResponseDto completeOrder(UUID orderId) { - - OrderEntity order = OrderValidationContext.getCurrentOrder(); - order.completeOrder(); - sendSignalToWorkflow(orderId, OrderStatus.COMPLETED); - - return OrderResponseDto.from(order); + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalStatusChanged(new OrderStatusUpdate(OrderStatus.COMPLETED, null, null, null)); + return OrderResponseDto.fromId(orderId, OrderStatus.COMPLETED); } // ******* // // 주문 취소 // // ******* // @Override - @Transactional - public void completeOrderCancellation(UUID orderId) { - OrderEntity order = orderRepository.findByIdWithLock(orderId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문입니다.")); - - if (order.getOrderStatus() == OrderStatus.CANCEL_PENDING) { - order.finalizeCancel(); - log.info("[보상 트랜잭션 완료] 주문 ID {} 가 최종 확정되었습니다.", orderId); - sendSignalToWorkflow(orderId, order.getOrderStatus()); - } else { - log.warn("[무시됨] 주문 ID {} 는 현재 취소 대기 상태가 아닙니다. (현재 상태: {})", - orderId, order.getOrderStatus()); - } - } - - @Override - @Transactional public OrderResponseDto customerCancelOrder(UUID orderId, String reason) { - OrderEntity order = orderRepository.findByIdWithLock(orderId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문입니다.")); - - order.initiateCancel(reason, CancelledBy.CUSTOMER); - // 주문 취소(거절) 이벤트 발행 - orderEventProducer.reserveOrderCancelled(order.getId(), reason); - sendSignalToWorkflow(orderId, OrderStatus.CANCEL_PENDING); - log.info("고객에 의한 취소 처리 시작 (환불 대기): orderId={}, reason={}", orderId, reason); - - // 결제 취소 처리 (Payment 서비스 호출) - 비동기 전환으로 인한 주석처리: 추후 삭제 afterDelete - // cancelPaymentIfExists(orderId, "고객 주문 취소: " + reason); - - return OrderResponseDto.from(order); + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalStatusChanged(new OrderStatusUpdate(OrderStatus.CANCEL_PENDING, null, reason, CancelledBy.CUSTOMER)); + log.info("고객 취소 시그널 전송 완료: orderId={}, reason={}", orderId, reason); + return OrderResponseDto.fromId(orderId, OrderStatus.CANCEL_PENDING); } @Override - @Transactional public OrderResponseDto storeCancelOrder(UUID orderId, String reason) { - OrderEntity order = orderRepository.findByIdWithLock(orderId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문입니다.")); - - order.initiateCancel(reason, CancelledBy.STORE); - // 주문 취소(거절) 이벤트 발행 - orderEventProducer.reserveOrderCancelled(order.getId(), reason); - sendSignalToWorkflow(orderId, OrderStatus.CANCEL_PENDING); - log.info("가게에 의한 취소 처리 시작 (환불 대기): orderId={}, reason={}", orderId, reason); - - return OrderResponseDto.from(order); + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalStatusChanged(new OrderStatusUpdate(OrderStatus.CANCEL_PENDING, null, reason, CancelledBy.STORE)); + log.info("가게 취소 시그널 전송 완료: orderId={}, reason={}", orderId, reason); + return OrderResponseDto.fromId(orderId, OrderStatus.CANCEL_PENDING); } - - @CircuitBreaker(name = "payment_ready_create") - @Bulkhead(name = "payment_ready_create", type = Bulkhead.Type.SEMAPHORE) - @Retry(name = "payment_ready_create") - private void cancelPaymentIfExists(UUID orderId, String cancelReason) { - try { - // Payment 서비스에서 결제 정보 조회 - if (!paymentClient.existsActivePaymentByOrderId(orderId)) { - log.info("주문 ID {}에 대한 결제 정보가 없습니다. 결제 취소를 건너뜁니다.", orderId); - return; - } - - PaymentResponse payment = paymentClient.getPaymentByOrderId(orderId); - - log.info("결제 취소 요청 - 결제 ID: {}, 주문 ID: {}, 사유: {}", payment.getId(), orderId, cancelReason); - - // Payment 서비스에 결제 취소 요청 - PaymentCancelRequest cancelRequest = PaymentCancelRequest.builder() - .cancelReason(cancelReason) - .build(); - paymentClient.cancelPayment(payment.getId(), cancelRequest); - - log.info("결제 취소 완료 - 결제 ID: {}", payment.getId()); - - } catch (Exception e) { - log.error("결제 취소 실패 - 주문 ID: {}, 오류: {}", orderId, e.getMessage(), e); - throw new RuntimeException("결제 취소 중 오류가 발생했습니다: " + e.getMessage(), e); - } + + @Override + public void completeOrderCancellation(UUID orderId) { + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalRefundCompleted(); + log.info("환불 완료 시그널 전송 성공: orderId={}", orderId); } @Override - @Transactional - @OrderStatusChange("COMPLETE_PAYMENT") public OrderResponseDto completePayment(UUID orderId) { - OrderEntity order = orderRepository.findByIdWithLock(orderId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문입니다.")); - - // 멱등성 보장 결제 처리 로직 - if (order.getOrderStatus() == OrderStatus.PENDING || - order.getOrderStatus().isFinalStatus()) { - if (order.getOrderStatus() == OrderStatus.CANCELLED) { - log.warn("[주문서비스] 이미 취소된 주문에 결제 성공 이벤트 수신. 환불을 예약합니다. orderId={}", orderId); - orderEventProducer.reserveOrderCancelled(orderId, "타임아웃 이후 결제 성공 발생"); - } else { - log.info("[멱등성처리] 이미 처리된 주문입니다. 스킵: orderId={}, status={}", orderId, order.getOrderStatus()); - } - return OrderResponseDto.from(order); + try { + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalStatusChanged(new OrderStatusUpdate(OrderStatus.PENDING, null, null, null)); + log.info("결제 완료 시그널 전송: orderId={}", orderId); + } catch (WorkflowNotFoundException e) { + log.warn("이미 종료된 워크플로우입니다. orderId={}", orderId); } - - // 정상 결제 처리 로직 - log.info("결제 성공 이벤트 수신 - 주문 확정 처리 시작: orderId={}", orderId); - order.completePayment(); - orderEventProducer.reserveOrderPending(order.getStoreId(), order.getId()); - log.info("결제 처리 및 사장님 알림 이벤트 발행 완료: orderId={}", orderId); - sendSignalToWorkflow(orderId, OrderStatus.PENDING); - - return OrderResponseDto.from(order); + return OrderResponseDto.fromId(orderId, OrderStatus.PENDING); } @Override - @Transactional public OrderResponseDto failPayment(UUID orderId) { - OrderEntity order = orderRepository.findByIdWithLock(orderId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문입니다.")); - - if (order.getOrderStatus() == OrderStatus.PAYMENT_FAILED || order.getOrderStatus().isFinalStatus()) { - log.info("[멱등성 처리] 이미 실패 처리되었거나 최종 상태인 주문입니다. 스킵: orderId={}", orderId); - return OrderResponseDto.from(order); - } - - order.failPayment(); - return OrderResponseDto.from(order); + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalStatusChanged(new OrderStatusUpdate(OrderStatus.PAYMENT_FAILED, null, "결제 승인 거절", null)); + return OrderResponseDto.fromId(orderId, OrderStatus.PAYMENT_FAILED); } private void checkDuplicateOrder(Integer userId, UUID storeId, OrderCreateRequestDto requestDto) { @@ -498,23 +338,7 @@ private void checkDuplicateOrder(Integer userId, UUID storeId, OrderCreateReques } } } - - private String generateOrderNumber() { - String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - String datePattern = "ORDER-" + date + "-%"; - - Optional lastOrderNumber = orderRepository.findTopOrderNumberByDatePattern(datePattern); - - int sequence = 1; - if (lastOrderNumber.isPresent()) { - String lastNumber = lastOrderNumber.get(); - String lastSeq = lastNumber.substring(lastNumber.lastIndexOf('-') + 1); - sequence = Integer.parseInt(lastSeq) + 1; - } - - return String.format("ORDER-%s-%04d", date, sequence); - } - + private LocalDateTime[] getDateRange(LocalDateTime date) { LocalDateTime startOfDay = date.toLocalDate().atStartOfDay(); LocalDateTime endOfDay = date.toLocalDate().atTime(23, 59, 59); @@ -552,21 +376,40 @@ public OrderStatsResponseDto getOrderStats() { .build(); } - private void sendSignalToWorkflow(UUID orderId, OrderStatus status) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - try { - OrderWorkflow workflow = workflowClient.newWorkflowStub( - OrderWorkflow.class, - orderId.toString() - ); - workflow.signalStatusChanged(status); - log.info("워크플로우 시그널 전송 성공: orderId={}, status={}", orderId, status); - } catch (Exception e) { - log.error("워크플로우 시그널 전송 실패: orderId={}, status={}", orderId, status, e); + private void sendSignal(UUID orderId, OrderStatus status) { + OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, orderId.toString()); + workflow.signalStatusChanged(new OrderStatusUpdate(status, null, null, null)); + log.info("시그널 전송 완료: orderId={}, status={}", orderId, status); + } + + private OrderContextDto fetchOrderContext(OrderCreateRequestDto requestDto) { + StoreResponse store = storeClient.getStoreById(requestDto.getStoreId()); + if (store == null) { + throw new IllegalArgumentException("존재하지 않는 가게입니다."); + } + + Map menuMap = new HashMap<>(); + Map optionMap = new HashMap<>(); + + for (OrderItemRequestDto itemDto : requestDto.getOrderItems()) { + MenuResponse menu = menuClient.getMenuById(itemDto.getMenuId()); + if (menu == null || menu.isHidden() || menu.isDeleted()) { + throw new IllegalArgumentException("판매 불가 메뉴입니다: " + itemDto.getMenuId()); + } + menuMap.put(itemDto.getMenuId(), menu); + + for (OrderItemOptionRequestDto optionDto : itemDto.getOptions()) { + MenuOptionResponse option = menuClient.getMenuOptionById(optionDto.getMenuOptionId()); + if (option == null || option.isDeleted()) { + throw new IllegalArgumentException("판매 불가 옵션입니다."); } + optionMap.put(optionDto.getMenuOptionId(), option); } - }); + } + return OrderContextDto.builder() + .store(store) + .menuMap(menuMap) + .optionMap(optionMap) + .build(); } } diff --git a/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderEntity.java b/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderEntity.java index 5cadc29b..5f810899 100644 --- a/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderEntity.java +++ b/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderEntity.java @@ -1,12 +1,11 @@ package com.example.Spot.order.domain.entity; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.UUID; -import org.hibernate.annotations.UuidGenerator; - import com.example.Spot.global.common.BaseEntity; import com.example.Spot.order.domain.enums.CancelledBy; import com.example.Spot.order.domain.enums.OrderStatus; @@ -17,7 +16,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; @@ -38,8 +36,6 @@ public class OrderEntity extends BaseEntity { // orderItems는 unique key에 포함할 수 없음. @Id - @GeneratedValue - @UuidGenerator @Column(columnDefinition = "UUID") private UUID id; @@ -103,8 +99,9 @@ public class OrderEntity extends BaseEntity { private List orderItems = new ArrayList<>(); @Builder - public OrderEntity(UUID storeId, Integer userId, String orderNumber, - String request, boolean needDisposables, LocalDateTime pickupTime) { + public OrderEntity(UUID id, UUID storeId, Integer userId, String orderNumber, + String request, boolean needDisposables, LocalDateTime pickupTime, + OrderStatus orderStatus) { if (storeId == null) { throw new IllegalArgumentException("가게 ID는 필수입니다."); } @@ -117,15 +114,15 @@ public OrderEntity(UUID storeId, Integer userId, String orderNumber, if (pickupTime == null) { throw new IllegalArgumentException("픽업 시간은 필수입니다."); } - + + this.id = id; this.storeId = storeId; this.userId = userId; this.orderNumber = orderNumber; this.request = request; this.needDisposables = needDisposables; this.pickupTime = pickupTime; - - this.orderStatus = OrderStatus.PAYMENT_PENDING; + this.orderStatus = orderStatus != null ? orderStatus : OrderStatus.PAYMENT_PENDING; } public void addOrderItem(OrderItemEntity orderItem) { @@ -186,6 +183,12 @@ public void cancelOrder(String reason, CancelledBy cancelledBy) { this.cancelledBy = cancelledBy; } + public BigDecimal getTotalAmount() { + return orderItems.stream() + .map(OrderItemEntity::getTotalPrice) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + public void startCooking() { validateStatusTransition(OrderStatus.COOKING); this.orderStatus = OrderStatus.COOKING; @@ -203,6 +206,13 @@ public void completeOrder() { this.orderStatus = OrderStatus.COMPLETED; this.pickedUpAt = LocalDateTime.now(); } + + public void initiateReject(String reason) { + validateStatusTransition(OrderStatus.REJECT_PENDING); + this.orderStatus = OrderStatus.REJECT_PENDING; + this.reason = reason; + this.cancelledBy = null; + } public void initiateCancel(String reason, CancelledBy cancelledBy) { validateStatusTransition(OrderStatus.CANCEL_PENDING); @@ -211,26 +221,22 @@ public void initiateCancel(String reason, CancelledBy cancelledBy) { this.cancelledBy = cancelledBy; } + public void finalizeReject() { + validateStatusTransition(OrderStatus.REJECTED); + this.orderStatus = OrderStatus.REJECTED; + this.rejectedAt = LocalDateTime.now(); + } + public void finalizeCancel() { - OrderStatus finalStatus = (this.cancelledBy == null) - ? OrderStatus.REJECTED - : OrderStatus.CANCELLED; - - validateStatusTransition(finalStatus); - - this.orderStatus = finalStatus; - if (finalStatus == OrderStatus.REJECTED) { - this.rejectedAt = LocalDateTime.now(); - } else { - this.cancelledAt = LocalDateTime.now(); - } + validateStatusTransition(OrderStatus.CANCELLED); + this.orderStatus = OrderStatus.CANCELLED; + this.cancelledAt = LocalDateTime.now(); } public void markAsRefundError() { - if (this.orderStatus != OrderStatus.CANCEL_PENDING) { + if (this.orderStatus != OrderStatus.CANCEL_PENDING && this.orderStatus != OrderStatus.REJECT_PENDING) { throw new IllegalStateException("환불 대기 상태가 아닌 주문은 에러 처리를 할 수 없습니다."); } - this.orderStatus = OrderStatus.REFUND_ERROR; } diff --git a/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderItemEntity.java b/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderItemEntity.java index 6e0d0070..9a023c9e 100644 --- a/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderItemEntity.java +++ b/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderItemEntity.java @@ -88,4 +88,12 @@ public void addOrderItemOption(OrderItemOptionEntity orderItemOption) { protected void setOrder(OrderEntity order) { this.order = order; } + + public BigDecimal getTotalPrice() { + BigDecimal optionsSum = orderItemOptions.stream() + .map(OrderItemOptionEntity::getSubtotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + return this.menuPrice.multiply(BigDecimal.valueOf(this.quantity)).add(optionsSum); + } } diff --git a/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderItemOptionEntity.java b/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderItemOptionEntity.java index 79de9fd2..959b7126 100644 --- a/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderItemOptionEntity.java +++ b/spot-order/src/main/java/com/example/Spot/order/domain/entity/OrderItemOptionEntity.java @@ -69,5 +69,9 @@ public OrderItemOptionEntity(UUID menuOptionId, String optionName, String option protected void setOrderItem(OrderItemEntity orderItem) { this.orderItem = orderItem; } + + public BigDecimal getSubtotal() { + return this.optionPrice; // 옵션 하나의 가격 + } } diff --git a/spot-order/src/main/java/com/example/Spot/order/domain/enums/OrderStatus.java b/spot-order/src/main/java/com/example/Spot/order/domain/enums/OrderStatus.java index 78ef8059..27035326 100644 --- a/spot-order/src/main/java/com/example/Spot/order/domain/enums/OrderStatus.java +++ b/spot-order/src/main/java/com/example/Spot/order/domain/enums/OrderStatus.java @@ -5,6 +5,7 @@ public enum OrderStatus { PAYMENT_FAILED("결제 실패"), // 결제 실패 PENDING("주문 수락 대기"), // 결제 완료 후 점주 수락 대기 ACCEPTED("주문 수락"), + REJECT_PENDING("거절 처리 중"), REJECTED("주문 거절"), COOKING("조리중"), READY("픽업 대기"), @@ -26,22 +27,20 @@ public String getDescription() { public boolean isFinalStatus() { return this == COMPLETED || this == CANCELLED || this == REJECTED || this == PAYMENT_FAILED || this == REFUND_ERROR; } - - public boolean isPaid() { - return this == PENDING || this == ACCEPTED || this == COOKING || - this == READY || this == COMPLETED; - } // 상태 전환 가능 여부 검증 public boolean canTransitionTo(OrderStatus newStatus) { return switch (this) { case PAYMENT_PENDING -> newStatus == PENDING || newStatus == PAYMENT_FAILED || newStatus == CANCELLED; case PAYMENT_FAILED -> newStatus == PAYMENT_PENDING || newStatus == CANCELLED; // 재결제 시도 가능 - case PENDING -> newStatus == ACCEPTED || newStatus == CANCEL_PENDING; + + case PENDING -> newStatus == ACCEPTED || newStatus == REJECT_PENDING || newStatus == CANCEL_PENDING; case ACCEPTED -> newStatus == COOKING || newStatus == CANCEL_PENDING; case COOKING -> newStatus == READY || newStatus == CANCEL_PENDING; - case CANCEL_PENDING -> newStatus == CANCELLED || newStatus == REJECTED || newStatus == REFUND_ERROR; case READY -> newStatus == COMPLETED; + + case REJECT_PENDING -> newStatus == REJECTED || newStatus == REFUND_ERROR; + case CANCEL_PENDING -> newStatus == CANCELLED || newStatus == REFUND_ERROR; default -> false; }; } diff --git a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/activity/OrderActivity.java b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/activity/OrderActivity.java index 0782116c..b6b0184d 100644 --- a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/activity/OrderActivity.java +++ b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/activity/OrderActivity.java @@ -2,7 +2,10 @@ import java.util.UUID; +import com.example.Spot.order.domain.enums.CancelledBy; import com.example.Spot.order.domain.enums.OrderStatus; +import com.example.Spot.order.presentation.dto.request.OrderCreateRequestDto; +import com.example.Spot.order.presentation.dto.response.OrderContextDto; import io.temporal.activity.ActivityInterface; import io.temporal.activity.ActivityMethod; @@ -10,6 +13,12 @@ @ActivityInterface public interface OrderActivity { + @ActivityMethod + void createOrderInDb(UUID orderId, Integer userId, OrderCreateRequestDto requestDto, OrderContextDto contextDto); + + @ActivityMethod + void updateOrderStatusInDb(UUID orderId, OrderStatus nextStatus, Integer estimatedTime, String reason, CancelledBy actor); + @ActivityMethod OrderStatus getOrderStatus(UUID orderId); diff --git a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/activity/OrderActivityImpl.java b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/activity/OrderActivityImpl.java index aff7fd79..d607131e 100644 --- a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/activity/OrderActivityImpl.java +++ b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/activity/OrderActivityImpl.java @@ -1,16 +1,29 @@ package com.example.Spot.order.infrastructure.temporal.activity; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.example.Spot.global.feign.dto.MenuOptionResponse; +import com.example.Spot.global.feign.dto.MenuResponse; import com.example.Spot.order.domain.entity.OrderEntity; +import com.example.Spot.order.domain.entity.OrderItemEntity; +import com.example.Spot.order.domain.entity.OrderItemOptionEntity; import com.example.Spot.order.domain.enums.CancelledBy; import com.example.Spot.order.domain.enums.OrderStatus; import com.example.Spot.order.domain.repository.OrderRepository; import com.example.Spot.order.infrastructure.producer.OrderEventProducer; import com.example.Spot.order.infrastructure.temporal.config.OrderConstants; +import com.example.Spot.order.presentation.dto.request.OrderCreateRequestDto; +import com.example.Spot.order.presentation.dto.request.OrderItemOptionRequestDto; +import com.example.Spot.order.presentation.dto.request.OrderItemRequestDto; +import com.example.Spot.order.presentation.dto.response.OrderContextDto; import io.temporal.spring.boot.ActivityImpl; import lombok.RequiredArgsConstructor; @@ -25,6 +38,107 @@ public class OrderActivityImpl implements OrderActivity { private final OrderRepository orderRepository; private final OrderEventProducer orderEventProducer; + @Override + public void createOrderInDb(UUID orderId, Integer userId, OrderCreateRequestDto requestDto, OrderContextDto contextDto) { + if (orderRepository.existsById(orderId)) { + return; + } + + String orderNumber = generateOrderNumber(); + BigDecimal totalAmount = BigDecimal.ZERO; + + OrderEntity order = OrderEntity.builder() + .id(orderId) + .storeId(contextDto.getStore().getId()) + .userId(userId) + .orderNumber(orderNumber) + .pickupTime(requestDto.getPickupTime()) + .needDisposables(requestDto.getNeedDisposables()) + .request(requestDto.getRequest()) + .orderStatus(OrderStatus.PAYMENT_PENDING) + .build(); + + for (OrderItemRequestDto itemDto : requestDto.getOrderItems()) { + MenuResponse menu = contextDto.getMenuMap().get(itemDto.getMenuId()); + BigDecimal itemPrice = BigDecimal.valueOf(menu.getPrice()); + + // 총액 합산 로직 + totalAmount = totalAmount.add(itemPrice.multiply(BigDecimal.valueOf(itemDto.getQuantity()))); + + OrderItemEntity orderItem = OrderItemEntity.builder() + .menuId(menu.getId()) + .menuName(menu.getName()) + .menuPrice(itemPrice) + .quantity(itemDto.getQuantity()) + .build(); + + for (OrderItemOptionRequestDto optionDto : itemDto.getOptions()) { + MenuOptionResponse menuOption = contextDto.getOptionMap().get(optionDto.getMenuOptionId()); + BigDecimal optionPrice = BigDecimal.valueOf(menuOption.getPrice()); + + // 옵션 총액 합산 + totalAmount = totalAmount.add(optionPrice); + + OrderItemOptionEntity orderItemOption = OrderItemOptionEntity.builder() + .menuOptionId(menuOption.getId()) + .optionName(menuOption.getName()) + .optionDetail(menuOption.getDetail()) + .optionPrice(optionPrice) + .build(); + + orderItem.addOrderItemOption(orderItemOption); + } + order.addOrderItem(orderItem); + } + + orderRepository.save(order); + + orderEventProducer.reserveOrderCreated( + orderId, + userId, + totalAmount.longValue() + ); + + log.info("주문 생성이 완료되었습니다. OrderID: {}, OrderNumber: {}", orderId, orderNumber); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) // 독립적인 트랜잭션 보장 + public void updateOrderStatusInDb(UUID orderId, OrderStatus nextStatus, Integer estimatedTime, String reason, CancelledBy actor) { + OrderEntity order = orderRepository.findByIdWithLock(orderId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문입니다: " + orderId)); + + if (!order.getOrderStatus().canTransitionTo(nextStatus)) { + log.warn("Activity: 유효하지 않은 상태 전환 시도 - current={}, next={}", order.getOrderStatus(), nextStatus); + return; + } + + switch (nextStatus) { + case PENDING -> { + order.completePayment(); + orderEventProducer.reserveOrderPending(order.getStoreId(), order.getId()); + } + case ACCEPTED -> { + order.acceptOrder(estimatedTime); + orderEventProducer.reserveOrderAccepted(order.getUserId(), order.getId(), estimatedTime); + } + case COOKING -> order.startCooking(); + case READY -> order.readyForPickup(); + case COMPLETED -> order.completeOrder(); + case REJECT_PENDING -> { + order.initiateReject(reason); + orderEventProducer.reserveOrderCancelled(order.getId(), reason); // 환불 프로세스 시작 + } + case CANCEL_PENDING -> { + order.initiateCancel(reason, actor); + orderEventProducer.reserveOrderCancelled(order.getId(), reason); // 환불 프로세스 시작 + } + default -> log.info("상태 변경: {}", nextStatus); + } + + log.info("Activity: 주문 상태 변경 완료 - orderId={}, changedStatus={}", orderId, order.getOrderStatus()); + } + @Override @Transactional public OrderStatus getOrderStatus(UUID orderId) { @@ -60,7 +174,13 @@ public void finalizeOrder(UUID orderId) { OrderEntity order = orderRepository.findByIdWithLock(orderId) .orElseThrow(() -> new IllegalArgumentException("주문 없음: " + orderId)); - order.finalizeCancel(); + if (order.getOrderStatus() == OrderStatus.REJECT_PENDING) { + order.finalizeReject(); + log.info("주문 거절 확정 완료: {}", orderId); + } else if (order.getOrderStatus() == OrderStatus.CANCEL_PENDING) { + order.finalizeCancel(); + log.info("주문 취소 확정 완료: {}", orderId); + } } @Override @@ -74,5 +194,20 @@ public void handleRefundTimeout(UUID orderId) { orderId, order.getOrderStatus()); orderRepository.save(order); } - + + private String generateOrderNumber() { + String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String datePattern = "ORDER-" + date + "-%"; + + Optional lastOrderNumber = orderRepository.findTopOrderNumberByDatePattern(datePattern); + + int sequence = 1; + if (lastOrderNumber.isPresent()) { + String lastNumber = lastOrderNumber.get(); + String lastSeq = lastNumber.substring(lastNumber.lastIndexOf('-') + 1); + sequence = Integer.parseInt(lastSeq) + 1; + } + + return String.format("ORDER-%s-%04d", date, sequence); + } } diff --git a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/dto/OrderStatusUpdate.java b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/dto/OrderStatusUpdate.java new file mode 100644 index 00000000..89dd14e5 --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/dto/OrderStatusUpdate.java @@ -0,0 +1,18 @@ +package com.example.Spot.order.infrastructure.temporal.dto; + +import com.example.Spot.order.domain.enums.CancelledBy; +import com.example.Spot.order.domain.enums.OrderStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class OrderStatusUpdate { + private OrderStatus status; + private Integer estimatedTime; + private String reason; + private CancelledBy cancelledBy; +} diff --git a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/workflow/OrderWorkflow.java b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/workflow/OrderWorkflow.java index 7b1e3475..982a3657 100644 --- a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/workflow/OrderWorkflow.java +++ b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/workflow/OrderWorkflow.java @@ -2,7 +2,9 @@ import java.util.UUID; -import com.example.Spot.order.domain.enums.OrderStatus; +import com.example.Spot.order.infrastructure.temporal.dto.OrderStatusUpdate; +import com.example.Spot.order.presentation.dto.request.OrderCreateRequestDto; +import com.example.Spot.order.presentation.dto.response.OrderContextDto; import io.temporal.workflow.SignalMethod; import io.temporal.workflow.WorkflowInterface; @@ -12,11 +14,11 @@ public interface OrderWorkflow { @WorkflowMethod - void processOrder(UUID orderId); + void processOrder(UUID orderId, Integer userId, OrderCreateRequestDto requestDto, OrderContextDto contextDto); @SignalMethod - void signalStatusChanged(OrderStatus nextStatus); - + void signalStatusChanged(OrderStatusUpdate update); + @SignalMethod void signalRefundCompleted(); } diff --git a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/workflow/OrderWorkflowImpl.java b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/workflow/OrderWorkflowImpl.java index 7f3758c7..9d75fed6 100644 --- a/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/workflow/OrderWorkflowImpl.java +++ b/spot-order/src/main/java/com/example/Spot/order/infrastructure/temporal/workflow/OrderWorkflowImpl.java @@ -5,66 +5,112 @@ import org.springframework.stereotype.Component; +import com.example.Spot.order.domain.enums.CancelledBy; import com.example.Spot.order.domain.enums.OrderStatus; import com.example.Spot.order.infrastructure.temporal.activity.OrderActivity; import com.example.Spot.order.infrastructure.temporal.config.OrderConstants; +import com.example.Spot.order.infrastructure.temporal.dto.OrderStatusUpdate; +import com.example.Spot.order.presentation.dto.request.OrderCreateRequestDto; +import com.example.Spot.order.presentation.dto.response.OrderContextDto; import io.temporal.activity.ActivityOptions; import io.temporal.common.RetryOptions; import io.temporal.spring.boot.WorkflowImpl; +import io.temporal.workflow.ChildWorkflowOptions; +import io.temporal.workflow.ChildWorkflowStub; import io.temporal.workflow.Workflow; @Component @WorkflowImpl(taskQueues = OrderConstants.ORDER_TASK_QUEUE) public class OrderWorkflowImpl implements OrderWorkflow { + private OrderStatus currentStatus = OrderStatus.PAYMENT_PENDING; + private Integer estimatedTime; + private String reason; + private CancelledBy actor; + private boolean isRefundCompleted = false; + private static final ActivityOptions ACTIVITY_OPTIONS = ActivityOptions.newBuilder() .setStartToCloseTimeout(Duration.ofSeconds(10)) .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(5).build()) .build(); - - private OrderStatus currentStatus = OrderStatus.PAYMENT_PENDING; - private boolean isRefundCompleted = false; - + @Override - public void processOrder(UUID orderId) { + public void processOrder(UUID orderId, Integer userId, OrderCreateRequestDto requestDto, OrderContextDto contextDto) { OrderActivity activities = Workflow.newActivityStub(OrderActivity.class, ACTIVITY_OPTIONS); + activities.createOrderInDb(orderId, userId, requestDto, contextDto); + + ChildWorkflowStub paymentStub = Workflow.newUntypedChildWorkflowStub("PaymentApproveWorkflow", + ChildWorkflowOptions.newBuilder() + .setWorkflowId("payment-wf-" + orderId) // Payment 서비스가 사용할 ID와 일치시켜야 함 + .setTaskQueue("PAYMENT_TASK_QUEUE") // PaymentConstants.PAYMENT_TASK_QUEUE 값과 일치해야 함 + .build()); + try { + paymentStub.execute(Void.class, orderId); + } catch (Exception e) { + // 결제 워크플로우 실패 시 예외가 이쪽으로 전파됩니다. + } - boolean paidWithinTime = Workflow.await(Duration.ofMinutes(5), - () -> currentStatus == OrderStatus.PENDING || currentStatus.isFinalStatus()); - - if (!paidWithinTime && currentStatus == OrderStatus.PAYMENT_PENDING) { - activities.cancelOrder(orderId, "결제 시간 초과로 인한 자동 취소"); - if (currentStatus == OrderStatus.CANCEL_PENDING) { - waitForRefundAndFinalize(orderId, activities); - return; - } + Workflow.await(Duration.ofMinutes(5), + () -> currentStatus == OrderStatus.PENDING || currentStatus.isFinalStatus() || currentStatus == OrderStatus.CANCEL_PENDING); + if (currentStatus == OrderStatus.PENDING) { + activities.updateOrderStatusInDb(orderId, OrderStatus.PENDING, null, null, null); + } else { + handleCancelOrRejectIfNecessary(orderId, activities, "결제 단계 취소/타임아웃"); return; } - Workflow.await(Duration.ofMinutes(10), () -> currentStatus == OrderStatus.ACCEPTED || isTrulyFinalStatus(currentStatus)); - if (handleCancelIfNecessary(orderId, activities)) { + boolean isAccepted = Workflow.await(Duration.ofMinutes(10), + () -> currentStatus == OrderStatus.ACCEPTED || currentStatus == OrderStatus.CANCEL_PENDING || currentStatus == OrderStatus.REJECT_PENDING || currentStatus.isFinalStatus()); + + if (isAccepted && currentStatus == OrderStatus.ACCEPTED) { + activities.updateOrderStatusInDb(orderId, OrderStatus.ACCEPTED, this.estimatedTime, null, null); + } else { + if (!isAccepted) { + this.currentStatus = OrderStatus.CANCEL_PENDING; + this.reason = "타임아웃으로 인한 자동취소"; + this.actor = CancelledBy.SYSTEM; + } + handleCancelOrRejectIfNecessary(orderId, activities, "점주 미수락/거절"); return; } - - Workflow.await(() -> currentStatus == OrderStatus.COOKING || isTrulyFinalStatus(currentStatus)); - if (handleCancelIfNecessary(orderId, activities)) { + + // 4. 조리 단계 (COOKING) + if (waitForStatusAndUpdate(orderId, OrderStatus.COOKING, activities)) { return; } - - Workflow.await(() -> currentStatus == OrderStatus.READY || isTrulyFinalStatus(currentStatus)); - if (handleCancelIfNecessary(orderId, activities)) { + if (waitForStatusAndUpdate(orderId, OrderStatus.READY, activities)) { return; } - - Workflow.await(() -> currentStatus == OrderStatus.COMPLETED || isTrulyFinalStatus(currentStatus)); - if (handleCancelIfNecessary(orderId, activities)) { + if (waitForStatusAndUpdate(orderId, OrderStatus.COMPLETED, activities)) { return; } } - - private boolean handleCancelIfNecessary(UUID orderId, OrderActivity activities) { - if (currentStatus == OrderStatus.CANCEL_PENDING) { + + private boolean handleCancelOrRejectIfNecessary(UUID orderId, OrderActivity activities, String defaultReason) { + if (currentStatus == OrderStatus.CANCEL_PENDING || currentStatus == OrderStatus.REJECT_PENDING) { + String finalReason = (this.reason != null) ? this.reason : defaultReason; + CancelledBy finalActor = this.actor; + if (currentStatus == OrderStatus.CANCEL_PENDING && finalActor == null) { + finalActor = CancelledBy.SYSTEM; + } + + activities.updateOrderStatusInDb(orderId, currentStatus, null, finalReason, finalActor); + + ChildWorkflowStub cancelStub = Workflow.newUntypedChildWorkflowStub("PaymentCancelWorkflow", + ChildWorkflowOptions.newBuilder() + .setWorkflowId("cancel-wf-" + orderId) + .setTaskQueue("PAYMENT_TASK_QUEUE") // 반드시 결제 큐 지정 + .build()); + try { + // 리스너가 던지는 (orderId, reason) 파라미터와 형식을 맞춥니다. + cancelStub.execute(Void.class, orderId, finalReason); + // Workflow.getWorkflowExecution(cancelStub); // 만약 비동기로 넘기고 싶다면 이 방식 사용 + } catch (Exception e) { + // 이미 리스너가 해당 ID로 실행을 완료했거나 진행 중일 때 발생하는 에러는 + // 부모-자식 관계가 맺어졌다면 무시해도 무방합니다. + } + waitForRefundAndFinalize(orderId, activities); return true; } @@ -80,17 +126,35 @@ private void waitForRefundAndFinalize(UUID orderId, OrderActivity activities) { } } + private boolean waitForStatusAndUpdate(UUID orderId, OrderStatus targetStatus, OrderActivity activities) { + Workflow.await(() -> currentStatus == targetStatus || currentStatus == OrderStatus.CANCEL_PENDING + || currentStatus.isFinalStatus()); + if (currentStatus == targetStatus) { + activities.updateOrderStatusInDb(orderId, targetStatus, null, null, null); + return false; + } + handleCancelOrRejectIfNecessary(orderId, activities, "진행 중 취소/거절"); + return true; + } + @Override - public void signalStatusChanged(OrderStatus nextStatus) { - this.currentStatus = nextStatus; + public void signalStatusChanged(OrderStatusUpdate update) { + if (this.currentStatus.isFinalStatus()) { + return; + } + if (update.getStatus() == OrderStatus.REJECT_PENDING) { + if (this.currentStatus != OrderStatus.PENDING) { + return; + } + } + this.currentStatus = update.getStatus(); + this.estimatedTime = update.getEstimatedTime(); + this.reason = update.getReason(); + this.actor = update.getCancelledBy(); } @Override public void signalRefundCompleted() { this.isRefundCompleted = true; } - - private boolean isTrulyFinalStatus(OrderStatus status) { - return status == OrderStatus.COMPLETED || status == OrderStatus.CANCELLED || status == OrderStatus.REJECTED; - } } diff --git a/spot-order/src/main/java/com/example/Spot/order/presentation/dto/response/OrderContextDto.java b/spot-order/src/main/java/com/example/Spot/order/presentation/dto/response/OrderContextDto.java new file mode 100644 index 00000000..67c914f9 --- /dev/null +++ b/spot-order/src/main/java/com/example/Spot/order/presentation/dto/response/OrderContextDto.java @@ -0,0 +1,58 @@ +package com.example.Spot.order.presentation.dto.response; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Map; +import java.util.UUID; + +import com.example.Spot.global.feign.dto.MenuOptionResponse; +import com.example.Spot.global.feign.dto.MenuResponse; +import com.example.Spot.global.feign.dto.StoreResponse; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderContextDto implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private StoreResponse store; + private Map menuMap; + private Map optionMap; + + public java.math.BigDecimal calculateTotalAmount(com.example.Spot.order.presentation.dto.request.OrderCreateRequestDto request) { + java.math.BigDecimal total = java.math.BigDecimal.ZERO; + + if (request.getOrderItems() == null) { + return total; + } + + for (var item : request.getOrderItems()) { + // 1. 메뉴 가격 계산 + MenuResponse menu = this.menuMap.get(item.getMenuId()); + if (menu != null) { + java.math.BigDecimal itemSubtotal = java.math.BigDecimal.valueOf(menu.getPrice()) + .multiply(java.math.BigDecimal.valueOf(item.getQuantity())); + total = total.add(itemSubtotal); + } + + // 2. 옵션 가격 합산 + if (item.getOptions() != null) { + for (var opt : item.getOptions()) { + MenuOptionResponse option = this.optionMap.get(opt.getMenuOptionId()); + if (option != null) { + total = total.add(java.math.BigDecimal.valueOf(option.getPrice())); + } + } + } + } + return total; + } +} diff --git a/spot-order/src/main/java/com/example/Spot/order/presentation/dto/response/OrderResponseDto.java b/spot-order/src/main/java/com/example/Spot/order/presentation/dto/response/OrderResponseDto.java index a50a390a..567e86d3 100644 --- a/spot-order/src/main/java/com/example/Spot/order/presentation/dto/response/OrderResponseDto.java +++ b/spot-order/src/main/java/com/example/Spot/order/presentation/dto/response/OrderResponseDto.java @@ -9,6 +9,7 @@ import com.example.Spot.order.domain.entity.OrderEntity; import com.example.Spot.order.domain.enums.CancelledBy; import com.example.Spot.order.domain.enums.OrderStatus; +import com.example.Spot.order.presentation.dto.request.OrderCreateRequestDto; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -113,5 +114,26 @@ public static OrderResponseDto from(OrderEntity entity, String storeName) { .totalAmount(dto.getTotalAmount()) .build(); } + public static OrderResponseDto of(UUID orderId, Integer userId, OrderCreateRequestDto request, OrderContextDto context, BigDecimal totalAmount) { + return OrderResponseDto.builder() + .id(orderId) + .userId(userId) + .storeId(request.getStoreId()) + .storeName(context.getStore().getName()) + .orderStatus(OrderStatus.PAYMENT_PENDING) // 시작 상태 + .pickupTime(request.getPickupTime()) + .needDisposables(request.getNeedDisposables()) + .request(request.getRequest()) + .totalAmount(totalAmount) + .createdAt(LocalDateTime.now()) + .build(); + } + + public static OrderResponseDto fromId(UUID orderId, OrderStatus status) { + return OrderResponseDto.builder() + .id(orderId) + .orderStatus(status) + .build(); + } } diff --git a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/listener/PaymentListener.java b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/listener/PaymentListener.java index c7c3a162..df072bd9 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/listener/PaymentListener.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/listener/PaymentListener.java @@ -55,12 +55,11 @@ public void handleOrderCreated(String message, Acknowledgment ack) { WorkflowOptions options = WorkflowOptions.newBuilder() .setWorkflowId("payment-wf-" + event.getOrderId()) .setTaskQueue(PaymentConstants.PAYMENT_TASK_QUEUE) - .setWorkflowIdReusePolicy(WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE) + .setWorkflowIdReusePolicy(WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY) // 2. 정책 완화 추천 .build(); .build(); - try { PaymentApproveWorkflow workflow = workflowClient.newWorkflowStub(PaymentApproveWorkflow.class, options); - WorkflowClient.start(workflow::processApprove, paymentId); + WorkflowClient.start(workflow::processApprove, event.getOrderId()); log.info("[결제] 새 워크플로우 시작: orderId={}, paymentId={}", event.getOrderId(), paymentId); } catch (WorkflowExecutionAlreadyStarted e) { log.info("[결제] 이미 진행 중인 워크플로우입니다. 스킵: orderId={}", event.getOrderId()); @@ -84,7 +83,7 @@ public void handleOrderCancelled(String message, Acknowledgment ack) { WorkflowOptions options = WorkflowOptions.newBuilder() .setWorkflowId("cancel-wf-" + event.getOrderId()) .setTaskQueue(PaymentConstants.PAYMENT_TASK_QUEUE) - .setWorkflowIdReusePolicy(WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE) + .setWorkflowIdReusePolicy(WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY) .build(); try { diff --git a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentApproveWorkflow.java b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentApproveWorkflow.java index 89067176..4be5e884 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentApproveWorkflow.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentApproveWorkflow.java @@ -8,5 +8,5 @@ @WorkflowInterface public interface PaymentApproveWorkflow { @WorkflowMethod - void processApprove(UUID paymentId); + void processApprove(UUID orderId); } diff --git a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentApproveWorkflowImpl.java b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentApproveWorkflowImpl.java index 819e09d5..ccef40f8 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentApproveWorkflowImpl.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentApproveWorkflowImpl.java @@ -19,7 +19,6 @@ public class PaymentApproveWorkflowImpl implements PaymentApproveWorkflow { private static final String[] DO_NOT_RETRY_EXCEPTIONS = { "com.example.Spot.global.presentation.advice.BillingKeyNotFoundException", - "com.example.Spot.global.presentation.advice.ResourceNotFoundException", "java.lang.IllegalArgumentException" }; @@ -37,8 +36,10 @@ public class PaymentApproveWorkflowImpl implements PaymentApproveWorkflow { .build()); @Override - public void processApprove(UUID paymentId) { + public void processApprove(UUID orderId) { Saga saga = new Saga(new Saga.Options.Builder().setContinueWithError(false).build()); + + UUID paymentId = activities.findActivePaymentIdByOrderId(orderId); try { activities.recordStatus(paymentId, "IN_PROGRESS"); diff --git a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentCancelWorkflowImpl.java b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentCancelWorkflowImpl.java index 5448e0c2..bad4b766 100644 --- a/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentCancelWorkflowImpl.java +++ b/spot-payment/src/main/java/com/example/Spot/payments/infrastructure/temporal/workflow/PaymentCancelWorkflowImpl.java @@ -19,7 +19,6 @@ public class PaymentCancelWorkflowImpl implements PaymentCancelWorkflow { private static final String[] DO_NOT_RETRY_EXCEPTIONS = { "com.example.Spot.global.presentation.advice.BillingKeyNotFoundException", - "com.example.Spot.global.presentation.advice.ResourceNotFoundException", "java.lang.IllegalArgumentException" };