Study/SpringBoot

[e-commerce 프로젝트] 7. 상품 주문 - 1(GET 메소드들, DB스케줄링)

delay100 2024. 7. 30. 00:30
728x90
반응형

안녕하세요! delay100입니다.

오늘은 본격적으로 상품 주문 API를 다루기 전에 결제 이전 및 이후에 동작하는 GET 메소드들과, 날짜에 따른 상품 상태를 변경하는 메소드들을 다루겠습니다.

실제로 상품 주문 요청을 다루는 것 외에 부가적으로 만들었던 기능들에 대해 먼저 살펴볼 예정입니다.

이 내용은 1번을 먼저 보고, 8번 포스팅 이후에 3번과 4번을 보는 것을 추천드립니다!

 


서론 

 

상품 주문의 주요 API별로 구성했습니다. 글 내용을 1, 3, 4를 먼저 작성했는데 생각보다 길어져서 주문의 핵심 내용인 2번은 다음 포스팅으로 미루게 되었습니다..! 

[결제 이전]

1. 배송지 입력 및 주문 폼 접근

2. 상품 주문 -> 다음 포스팅에서 다룸

 

 

[결제 이후]

3. 상품 취소, 반품

4. 주문 내역 리스트, 상세


1. 배송지 입력 및 주문 폼 접근


1-1. 동작 방식

배송지 입력 및 주문 폼에 접근하는 API입니다.  다시봐도 API 네이밍 센스가 좋지 않네요..  아래의 사진과 같이 결제창 이전에 정보 확인하는 창에 접근하는 GET요청에 대한 API입니다.

배송지 입력 및 주문 폼 접근 - 1
배송지 입력 및 주문 폼 접근 - 2
배송지 입력 및 주문 폼 접근 - 3

 

 

GET요청 시 기본으로 반환되는 값

  • 상품 및 상품 옵션 정보: 상품 옵션, 상품 정보
  • 배송지 정보: 현재 회원(member)에 저장되어있는 기본 배송지(주소, 우편번호 등)
  • 배송비를 제외한 금액: 배송비 제외 상품, 상품 옵션 추가금 비용 리스트
  • 배송비: 주문 금액이 3만원이 넘는 경우 0원, 넘지 못하는 경우 3000원
  • 전체 상품 가격: 상품, 상품 옵션, 배송비 등이 적용된 총 가격 

 


1-2. 배송지 입력 및 주문 폼 접근 구현

Github 주소

// OrderController
/**
 * GET
 * 배송지 입력 및 결제확인
 * @param userDetails security의 회원 정보
 * @param orderProductAllInfoRequestDto 주문 상품 요청 객체
 * @return 회원의 상품 주문 정보 객체 DTO
 */
@GetMapping(BASE_ORDER + "/info")
public ApiResponse<OrderProductAllInfoResponseDto> getOrderProductAllInfo(
        @AuthenticationPrincipal UserDetailsImpl userDetails,
        @RequestBody OrderProductAllInfoRequestDto orderProductAllInfoRequestDto
    ) {
    return ApiResponse.createSuccess(orderService.getOrderProductAllInfo(userDetails.getMember(), orderProductAllInfoRequestDto));
}
// OrderService
@Transactional(readOnly = true)
public OrderProductAllInfoResponseDto getOrderProductAllInfo(Member member, OrderProductAllInfoRequestDto orderProductAllInfoRequestDto) {
    member = memberRepository.findByMemberId(member.getMemberId())
            .orElseThrow(() -> new IllegalArgumentException("사용자 정보가 없습니다."));
    List<OrderProductResponseDto> orderProducts = orderProductAllInfoRequestDto.getOrderProducts().stream().map(orderProduct -> {
        Product product = productRepository.findById(orderProduct.getProductId())
                .orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));

        int productPrice = product.getProductPrice();

        ProductOption productOption = productOptionRepository.findByProductOptionId(orderProduct.getProductOptionId())
                    .orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
        int productOptionPrice = productOption.getProductOptionPrice();


        int productTotalPrice = (productPrice + productOptionPrice) * orderProduct.getQuantity();

        return OrderProductResponseDto.from(product, orderProduct.getQuantity(), productOption, productPrice, productTotalPrice);
    }).collect(Collectors.toList());

    // 총 결제 금액 계산
    int productTotalPrice = orderProducts.stream().mapToInt(OrderProductResponseDto::getProductTotalPrice).sum();
    int orderShippingFee = productTotalPrice >= 30000 ? 0 : 3000;
    int orderPrice = productTotalPrice + orderShippingFee;

    return OrderProductAllInfoResponseDto.from(member ,aes256Encoder, orderProducts, productTotalPrice, orderShippingFee, orderPrice);
}
// OrderProductAllInfoResponseDto
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderProductAllInfoResponseDto {
    // 주문할 상품
    private List<OrderProductResponseDto> orderProducts;

    // 기본으로 사용할 주소 정보 - user에서 빼오기
    private String orderMemberName;
    private int orderZipCode;
    private String orderAddress;
    private String orderPhone;

    // 결제 금액
    private int productTotalPrice;
    private int orderShippingFee;
    private int orderPrice; // 총금액

    public static OrderProductAllInfoResponseDto from(
            Member member,
            AES256Encoder aes256Encoder,
            List<OrderProductResponseDto> orderProducts,
            int productTotalPrice,
            int orderShippingFee,
            int orderPrice
            ) {
        return OrderProductAllInfoResponseDto.builder()
                .orderMemberName(aes256Encoder.decodeString(member.getMemberName()))
                .orderZipCode(member.getZipCode())
                .orderAddress(aes256Encoder.decodeString(member.getAddress()))
                .orderPhone(aes256Encoder.decodeString(member.getPhone()))
                .orderProducts(orderProducts)
                .productTotalPrice(productTotalPrice)
                .orderShippingFee(orderShippingFee)
                .orderPrice(orderPrice)
                .build();
    }
}
// OrderProductResponseDto
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderProductResponseDto {
    private Long productId;
    private String productTitle;
    private int quantity;
    private Long productOptionId;
    private String productOptionTitle;
    private int productPrice; // 각 제품 가격
    private int productOptionPrice; // 각 제품의 옵션 가격
    private int productTotalPrice; // 각 제품의 총 결제 금액(수량*가격)

    public static OrderProductResponseDto from(
            Product product,
            int quantity,
            ProductOption productOption,
            int productPrice,
            int productTotalPrice
    ) {
        return OrderProductResponseDto.builder()
                .productId(product.getProductId())
                .productTitle(product.getProductTitle())
                .quantity(quantity)
                .productOptionId(productOption.getProductOptionId())
                .productOptionTitle(productOption.getProductOptionTitle())
                .productPrice(productPrice)
                .productOptionPrice(productOption.getProductOptionPrice())
                .productTotalPrice(productTotalPrice)
                .build();
    }
}

2. 상품 취소, 반품


2-1. 동작 방식

OrderStatusUpdateService클래스와 OrderService클래스 내의 2개의 메소드를 이용합니다.

상품 취소, 반품에 쓰이는 클래스 1개 및 메소드 2개

2-1-1. 주문 상태 스케쥴링(OrderStatusUpdateService) 클래스

- 만든 이유: 실제로 택배를 보낼 수 없기 때문에 배송중, 배송완료 여부를 알 수 없음

- 하는 일: 자정(00시)마다 스케쥴링 진행하면서 아래의 조건 확인

  1. 주문 이후 1일 후에 주문 상태를 "배송중"으로 변경
  2. 주문 이후 2일 후에 "배송완료"로 변경
  3. 상태가 "반품요청"인 건에 대해 3일 후에 "반품 완료"로 변경  

 

2-1-2. 상품 취소(updateOrderStatusCancel) 메소드

사용자가 상품 취소 요청을 보낸 경우에 대한 메소드입니다.

 

1. 취소가능 상태(주문 상태가 "결제완료" 또는 "배송준비중")인지 확인

2. 상품 옵션 확인 후 상품 재고 추가(Redis에는 재고 추가를 하지 않고 있음)

=> 1, 2를 모두 충족하면 상품 취소 가능


여기서 고민해봐야할 점은 Redis에는 재고 추가를 어느 시점에 할 것인가?

2-1. 상품 수량이 추가된 즉시

2-2. 캐시로 올라갈 때

 -> 상품이 현재 Redis에 캐시 되어있으면 Redis에 즉시 반영, 없으면 DB에만 반영

(블로그 글을 쓰다가 방법이 갑자기 떠올랐습니다!?!? 다음에 기능들 추가할 때 이 방법으로 하면 될 것 같아요~!) 


 

 

2-1-3. 상품 반품(updateOrderStatusReturn) 메소드

사용자가 상품 반품 요청을 보낸 경우에 대한 메소드입니다.

 

1. 반품가능 상태(주문 상태가 "배송 완료")인지 확인

2. 배송 완료 후 1일 이내인지 확인

=> 1, 2를 모두 충족하면 상품 반품 가능


2-2. 상품 취소, 반품 구현

Github 주소

// OrderStatusUpdateService
@Service
@RequiredArgsConstructor
public class OrderStatusUpdateService {

    private final OrderRepository orderRepository;
    private final OrderProductRepository orderProductRepository;
    private final ProductOptionRepository productOptionRepository;

    @Scheduled(cron = "0 0 0 * * ?") // 자정(00시)마다 스케줄 실행
    @Transactional
    public void updateOrderStatuses() {
        List<Order> orders = orderRepository.findAll();
        LocalDateTime now = LocalDateTime.now();

        for (Order order : orders) {
            // 배송 상태 업데이트
            if (order.getOrderDate().plusDays(1).isBefore(now) && order.getOrderStatus() == OrderStatusEnum.PAYMENT_COMPLETED) {
                order.setOrderStatus(OrderStatusEnum.SHIPPING);
            } else if (order.getOrderDate().plusDays(2).isBefore(now) && order.getOrderStatus() == OrderStatusEnum.SHIPPING) {
                order.setOrderStatus(OrderStatusEnum.DELIVERY_COMPLETED);
            }

            // 반품 처리
            // 반품한 상품은 반품 신청 후 D+1에 재고에 반영 됨. 재고에 반영된후 상태는 반품완료로 변경됨
            if (order.getOrderStatus() == OrderStatusEnum.RETURN_REQUESTED && order.getOrderDate().plusDays(3).isBefore(now)) {
                List<OrderProduct> orderProducts = orderProductRepository.findByOrderOrderId(order.getOrderId());
                for (OrderProduct orderProduct : orderProducts) {
                    ProductOption productOption = productOptionRepository.findById(orderProduct.getOrderProductOptionId())
                            .orElseThrow(() -> new RuntimeException("상품 옵션을 찾지 못했습니다."));
                    productOption.setProductOptionStock(productOption.getProductOptionStock() + orderProduct.getOrderProductQuantity());
                    productOptionRepository.save(productOption);
                }
                order.setOrderStatus(OrderStatusEnum.RETURN_COMPLETED);
            }
        }

        orderRepository.saveAll(orders);
    }
}
// OrderController
/**
 * GET
 * 한 주문 내 상품 전체 취소
 * @param userDetails security의 회원 정보
 * @param orderId 주문 아이디
 * @return 취소된 주문 내역에 대한 정보 DTO
 */
@PatchMapping(BASE_MYPAGE + "/order/cancel")
public ApiResponse<OrderCancelResponseDto> updateOrderStatusCancel(
        @AuthenticationPrincipal UserDetailsImpl userDetails,
        @RequestParam Long orderId
    ) {
    return ApiResponse.createSuccess(orderService.updateOrderStatusCancel(userDetails.getMember(), orderId));
}

/**
 * 한 주문 내 상품 전체 반품
 * @param userDetails security의 회원 정보
 * @param orderId 주문 아이디
 * @return 반품된 주문 내역에 대한 정보 DTO
 */
@PatchMapping(BASE_MYPAGE + "/order/return")
public ApiResponse<OrderReturnResponseDto> updateOrderStatusReturn(
        @AuthenticationPrincipal UserDetailsImpl userDetails,
        @RequestParam Long orderId
    ) {
    return ApiResponse.createSuccess(orderService.updateOrderStatusReturn(userDetails.getMember(), orderId));
}
// OrderService
@Transactional
public OrderCancelResponseDto updateOrderStatusCancel(Member member, Long orderId) {
    Order order = orderRepository.findByMemberMemberIdAndOrderId(member.getMemberId(), orderId)
            .orElseThrow(() -> new IllegalArgumentException("해당 주문이 없습니다."));

    // 취소 가능 상태인지 확인
    if (!order.getOrderStatus().isCancellable()) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "취소 가능 상태가 아닙니다.");
    }

    order.setOrderStatus(OrderStatusEnum.ORDER_CANCELLED);

    List<OrderProduct> orderProducts = orderProductRepository.findByOrderOrderId(orderId);
    for (OrderProduct orderProduct : orderProducts) {
        ProductOption productOption = productOptionRepository.findById(orderProduct.getOrderProductOptionId())
                .orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
        productOption.setProductOptionStock(productOption.getProductOptionStock() + orderProduct.getOrderProductQuantity());
        productOptionRepository.save(productOption);
    }

    orderRepository.save(order);

    return OrderCancelResponseDto.from(order);
}

@Transactional
public OrderReturnResponseDto updateOrderStatusReturn(Member member, Long orderId) {
    Order order = orderRepository.findByMemberMemberIdAndOrderId(member.getMemberId(), orderId)
            .orElseThrow(() -> new IllegalArgumentException("해당 주문이 없습니다."));

    // 반품 가능 상태인지 확인
    if (!order.getOrderStatus().isReturnable()) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "반품 가능 상태가 아닙니다.");
    }

    // 반품 가능 기간인지 확인 (배송 완료 후 1일 이내)
    if (order.getUpdatedAt().plusDays(1).isBefore(LocalDateTime.now())) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "반품 가능 기간이 아닙니다. (배송 완료 후 1일 이내)");
    }

    order.setOrderStatus(OrderStatusEnum.RETURN_REQUESTED);
    orderRepository.save(order);

    return OrderReturnResponseDto.from(order);
}

3. 주문 내역 리스트, 상세


3-1. 동작 방식

위에서 주문한 정보에 대해 상세 주문 정보와 리스트 정보를 보여줍니다. 간단하게 GET 메소드 2개로 구현했습니다.

 

3-1-1. 주문 내역 리스트

주문 내역 리스트를 반환할 때 상세 리스트 전체를 가져올 필요가 없다고 판단하여, 각 주문의 제목을 최상단의 상품 1개만 (Limit 1) 가져오도록 했습니다. 대신, 해당 주문에 포함된 총 상품 개수도 함께 반환하여 프론트엔드에서 "반팔 외 3건"과 같은 형식으로 처리할 수 있게 했습니다.

// 묶음 상품에 대해서 대표 상품에 대한 정보만 출력함
Product product = productRepository.findById(orderProducts.getFirst().getProduct().getProductId())
        .orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
String productTitle = product.getProductTitle();
int orderProductCount = orderProducts.size(); // 총 orderProduct 수

 

3-1-2. 주문 내역 상세

현재 주문에 대한 모든 주문 상품(OrderProduct)을 가져와서 List형식으로 보여줍니다.

주문 내역 상세 응답데이터(OrderDetailResponseDto)

예시 응답데이터는 위와 같습니다. 응답데이터는 주문 정보와 주문 상품 정보가 모두 포함되어 있습니다.


3-2. 주문 내역 리스트, 상세 구현

Github 주소

// OrderController
/**
 * GET
 * 주문 내역 리스트
 * @param userDetails security의 회원 정보
 * @param page 페이지 번호
 * @param size 한 페이지에 띄울 수
 * @return 주문 정보 리스트 DTO
 */
@GetMapping(BASE_MYPAGE + "/orderlist")
public ApiResponse<Page<OrderListResponseDto>> getOrderList(
        @AuthenticationPrincipal UserDetailsImpl userDetails,
        @RequestParam int page,
        @RequestParam int size
    ) {
    return ApiResponse.createSuccess(orderService.getOrderList(userDetails.getMember(), page, size));
}

/**
 * GET
 * 주문내역 상세
 * @param userDetails security의 회원 정보
 * @param orderId 주문 아이디
 * @return 하나의 주문 내역에 대한 정보 DTO
 */
@GetMapping(BASE_MYPAGE + "/order")
public ApiResponse<OrderDetailResponseDto> getOrderDetail(
        @AuthenticationPrincipal UserDetailsImpl userDetails,
        @RequestParam Long orderId
    ) {
    return ApiResponse.createSuccess(orderService.getOrderDetail(userDetails.getMember(), orderId));
}
// OrderService
@Transactional(readOnly = true)
    public Page<OrderListResponseDto> getOrderList(Member member, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        Page<Order> orders = orderRepository.findByMemberMemberId(member.getMemberId(), pageable);

        return orders.map(order -> {
            List<OrderProduct> orderProducts = orderProductRepository.findByOrderOrderId(order.getOrderId());

            if (orderProducts.isEmpty()) {
                throw new IllegalArgumentException("해당 주문에 대한 상품이 없습니다.");
            }

            // 묶음 상품에 대해서 대표 상품에 대한 정보만 출력함
            Product product = productRepository.findById(orderProducts.getFirst().getProduct().getProductId())
                    .orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
            String productTitle = product.getProductTitle();
            int orderProductCount = orderProducts.size(); // 총 orderProduct 수

            return OrderListResponseDto.from(order, productTitle, orderProductCount);
        });
    }

    @Transactional(readOnly = true)
    public OrderDetailResponseDto getOrderDetail(Member member, Long orderId) {
        Order order = orderRepository.findByMemberMemberIdAndOrderId(member.getMemberId(), orderId)
                .orElseThrow(() -> new IllegalArgumentException("해당 주문이 없습니다."));

        List<OrderProduct> orderProducts = orderProductRepository.findByOrderOrderId(orderId);
        if (orderProducts.isEmpty()) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "잘못된 주문이 존재:" + orderId);
        }
        List<OrderProductDetailResponseDto> orderProductDetailResponseDto = orderProducts.stream()
                .map(orderProduct -> {
                    Product product = productRepository.findById(orderProduct.getProduct().getProductId())
                            .orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));

                    String productOptionTitle = productOptionRepository.findProductOptionTitleById(orderProduct.getOrderProductOptionId());

                    return OrderProductDetailResponseDto.from(orderProduct, product.getProductTitle(), productOptionTitle);
                }).collect(Collectors.toList());

        return OrderDetailResponseDto.from(order, orderProductDetailResponseDto, aes256Encoder);
    }
// OrderDetailResponseDto
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderDetailResponseDto {
    private Long orderId;
    private LocalDateTime orderDate;
    private OrderStatusEnum orderStatus;
    private int orderShippingFee;
    private OrderCardCompanyEnum orderCardCompany;
    private int orderPrice;

    private String orderMemberName;
    private int orderZipCode;
    private String orderAddress;
    private String orderPhone;
    private String orderReq;


    private List<OrderProductDetailResponseDto> orderProductDetailResponseDto;

    public static OrderDetailResponseDto from(Order order, List<OrderProductDetailResponseDto> orderProductDetailResponseDto, AES256Encoder aes256Encoder) {
        return OrderDetailResponseDto.builder()
                .orderId(order.getOrderId())
                .orderDate(order.getOrderDate())
                .orderStatus(order.getOrderStatus())
                .orderShippingFee(order.getOrderShippingFee())
                .orderPrice(order.getOrderPrice())
                .orderCardCompany(order.getOrderCardCompany())
                .orderMemberName(aes256Encoder.decodeString(order.getOrderMemberName()))
                .orderZipCode(order.getOrderZipCode())
                .orderAddress(aes256Encoder.decodeString(order.getOrderAddress()))
                .orderPhone(aes256Encoder.decodeString(order.getOrderPhone()))
                .orderReq(aes256Encoder.decodeString(order.getOrderReq()))
                .orderProductDetailResponseDto(orderProductDetailResponseDto)
                .build();
    }

}

4. 결과 및 예제(API)

아래의 링크에서 더 자세히 보실 수 있습니다.

API(POSTMAN) : https://documenter.getpostman.com/view/23481846/2sA3kSo3ZJ

POSTMAN
결제 이전 - 배송지 입력 및 주문 폼 접근
결제 이후 - 상품 취소
결제 이후 - 상품 반품
결제 이후 - 주문 내역 상세
결제이후 - 주문 내역 리스트

 


5. 이후 추가하고 싶은 기능

 

5-1. 상품 취소, 반품 (+ 재고 추가)시 Redis에 반영하는 시점 반영


1. 취소가능 상태(주문 상태가 "결제완료" 또는 "배송준비중")인지 확인

2. 상품 옵션 확인 후 상품 재고 추가(Redis에는 재고 추가를 하지 않고 있음)

여기서 앞으로 고민해봐야할 점은 Redis에 재고 추가를 어느 시점에 할 것인가?

2-1. 상품 수량이 추가된 즉시: 상품이 현재 Redis에 

2-2. 캐시로 올라갈 때

 -> 상품이 현재 Redis에 캐시 되어있으면 Redis에 즉시 반영, 없으면 DB에만 반영


3-1-2에서 위와 같은 결론을 냈었고, 해당 부분을 반하려 합니다.  


긴 글 읽어주셔서 감사합니다! 코드나 동작 방식에 대해 궁금한 점이 있으시면 언제든지 질문해 주세요. 피드백도 환영합니다.

728x90
반응형