diff --git a/src/main/java/com/example/Centralthon/domain/order/entity/Order.java b/src/main/java/com/example/Centralthon/domain/order/entity/Order.java new file mode 100644 index 0000000..7b12690 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/entity/Order.java @@ -0,0 +1,36 @@ +package com.example.Centralthon.domain.order.entity; + +import com.example.Centralthon.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="order_id") + private Long id; + + @Column(name = "pick_up_code", nullable = false, unique = true, length = 6) + private String pickUpCode; + + @Column(nullable = false) + private int price; + + @Column(name = "is_picked_up", nullable = false) + private boolean pickedUp; + + public static Order toEntity(String pickUpCode, int price){ + return Order.builder() + .pickUpCode(pickUpCode) + .price(price) + .pickedUp(false) + .build(); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/order/entity/OrderItem.java b/src/main/java/com/example/Centralthon/domain/order/entity/OrderItem.java new file mode 100644 index 0000000..17eca78 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/entity/OrderItem.java @@ -0,0 +1,40 @@ +package com.example.Centralthon.domain.order.entity; + +import com.example.Centralthon.domain.menu.entity.Menu; +import com.example.Centralthon.domain.store.entity.Store; +import com.example.Centralthon.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "order_items") +public class OrderItem extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="order_items_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "menu_id", nullable = false) + private Menu menu; + + @Column(nullable = false) + private int quantity; + + public static OrderItem toEntity(Order order, Menu menu, int quantity) { + return OrderItem.builder() + .order(order) + .menu(menu) + .quantity(quantity) + .build(); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/order/exception/CodeNotCreatedException.java b/src/main/java/com/example/Centralthon/domain/order/exception/CodeNotCreatedException.java new file mode 100644 index 0000000..3d83bec --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/exception/CodeNotCreatedException.java @@ -0,0 +1,9 @@ +package com.example.Centralthon.domain.order.exception; + +import com.example.Centralthon.global.exception.BaseException; + +public class CodeNotCreatedException extends BaseException { + public CodeNotCreatedException() { + super(OrderErrorCode.CODE_NOT_CREATED); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/order/exception/OrderErrorCode.java b/src/main/java/com/example/Centralthon/domain/order/exception/OrderErrorCode.java new file mode 100644 index 0000000..bf14a67 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/exception/OrderErrorCode.java @@ -0,0 +1,15 @@ +package com.example.Centralthon.domain.order.exception; + +import com.example.Centralthon.global.response.code.BaseResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum OrderErrorCode implements BaseResponseCode { + CODE_NOT_CREATED("ORDER_500_1",500,"서버에서 유일한 픽업 코드를 생성하지 못했습니다."); + + private final String code; + private final int httpStatus; + private final String message; +} diff --git a/src/main/java/com/example/Centralthon/domain/order/repository/OrderItemRepository.java b/src/main/java/com/example/Centralthon/domain/order/repository/OrderItemRepository.java new file mode 100644 index 0000000..1a553a9 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/repository/OrderItemRepository.java @@ -0,0 +1,7 @@ +package com.example.Centralthon.domain.order.repository; + +import com.example.Centralthon.domain.order.entity.OrderItem; +import org.springframework.data.repository.CrudRepository; + +public interface OrderItemRepository extends CrudRepository { +} diff --git a/src/main/java/com/example/Centralthon/domain/order/repository/OrderRepository.java b/src/main/java/com/example/Centralthon/domain/order/repository/OrderRepository.java new file mode 100644 index 0000000..f3d3bde --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/repository/OrderRepository.java @@ -0,0 +1,10 @@ +package com.example.Centralthon.domain.order.repository; + +import com.example.Centralthon.domain.order.entity.Order; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface OrderRepository extends CrudRepository { + Optional findByPickUpCode(String pickUpCode); +} diff --git a/src/main/java/com/example/Centralthon/domain/order/service/OrderService.java b/src/main/java/com/example/Centralthon/domain/order/service/OrderService.java new file mode 100644 index 0000000..fb43f7e --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/service/OrderService.java @@ -0,0 +1,10 @@ +package com.example.Centralthon.domain.order.service; + +import com.example.Centralthon.domain.order.web.dto.OrderReq; +import com.example.Centralthon.domain.order.web.dto.OrderRes; + +import java.util.List; + +public interface OrderService { + OrderRes orderMenus(OrderReq orderReq); +} diff --git a/src/main/java/com/example/Centralthon/domain/order/service/OrderServiceImpl.java b/src/main/java/com/example/Centralthon/domain/order/service/OrderServiceImpl.java new file mode 100644 index 0000000..decaa3c --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/service/OrderServiceImpl.java @@ -0,0 +1,118 @@ +package com.example.Centralthon.domain.order.service; + +import com.example.Centralthon.domain.menu.entity.Menu; +import com.example.Centralthon.domain.menu.exception.MenuNotFoundException; +import com.example.Centralthon.domain.menu.repository.MenuRepository; +import com.example.Centralthon.domain.order.entity.Order; +import com.example.Centralthon.domain.order.entity.OrderItem; +import com.example.Centralthon.domain.order.exception.CodeNotCreatedException; +import com.example.Centralthon.domain.order.repository.OrderItemRepository; +import com.example.Centralthon.domain.order.repository.OrderRepository; +import com.example.Centralthon.domain.order.web.dto.OrderItemListReq; +import com.example.Centralthon.domain.order.web.dto.OrderReq; +import com.example.Centralthon.domain.order.web.dto.OrderRes; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +@Service +@RequiredArgsConstructor +public class OrderServiceImpl implements OrderService { + @Lazy @Autowired + private OrderServiceImpl selfProxy; + + private final MenuRepository menuRepository; + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Override + @Transactional + public OrderRes orderMenus(OrderReq orderReq) { + Map orderList = new HashMap<>(); + + for (OrderItemListReq orderItemListReq : orderReq.getItems()) { + orderList.merge(orderItemListReq.getMenuId(), orderItemListReq.getCount(), Integer::sum); + } + + // 메뉴 id가 없을 경우 -> MenuNotFoundException + List menuList = menuRepository.findAllById(orderList.keySet()); + if(menuList.size() != orderList.keySet().size()) { + throw new MenuNotFoundException(); + } + + // 최종 결제 금액 계산 + int totalPrice = 0; + for (Menu menu : menuList) { + // 오버플로우 방지를 위한 addExact, multiplyExact 사용 + totalPrice = Math.addExact(totalPrice, Math.multiplyExact(menu.getSalePrice(), orderList.get(menu.getId()))); + } + + Order order = createOrderWithUniqueCode(totalPrice); + + List orderItemList = new ArrayList<>(); + for(Menu menu : menuList) { + OrderItem orderItem = OrderItem.toEntity(order, menu, orderList.get(menu.getId())); + orderItemList.add(orderItem); + } + orderItemRepository.saveAll(orderItemList); + + return OrderRes.from(order); + } + + private Order createOrderWithUniqueCode(int totalPrice) { + for(int i = 0; i < 10; i++) { + String code = generatePickUpCode(); + try{ + // 프록시를 적용해주지 않으면, this.saveOrder가 실행되어 트랙잭션이 실행되지 않음 + return selfProxy.saveOrder(code, totalPrice); + } catch (DataIntegrityViolationException e) { + // 다음 실행 진행 + } + } + throw new CodeNotCreatedException(); + } + + /** + * 같은 트랙잭션에서 실패하면 rollback-only 상태로 인해 정상 커밋이 불가능하므로, + * 새 트랜잭션을 열어 새로운 픽업 코드를 저장하도록 시도 + * -> 새 트랜잭션을 열기 위해 @Transactional(propagation = Propagation.REQUIRES_NEW) 사용 + * -> @Transactional(propagation = Propagation.REQUIRES_NEW)는 기존 트랜잭션을 중지시키고, 새 트랜잭션 시작 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Order saveOrder(String code, int totalPrice){ + Order order = Order.toEntity(code, totalPrice); + return orderRepository.save(order); + } + + /** + * 픽업 코드(영문 두자리 + 숫자 네자리) 생성 메서드 + */ + private String generatePickUpCode() { + ThreadLocalRandom random = ThreadLocalRandom.current(); + char[] codeArray = new char[6]; + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + String number = "0123456789"; + + for (int i = 0; i < 2; i++) { + char alpha = alphabet.charAt(random.nextInt(alphabet.length())); + codeArray[i] = alpha; + } + + for (int i = 0; i < 4; i++) { + char num = number.charAt(random.nextInt(number.length())); + codeArray[i + 2] = num; + } + + return new String(codeArray); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/order/web/controller/OrderController.java b/src/main/java/com/example/Centralthon/domain/order/web/controller/OrderController.java new file mode 100644 index 0000000..da1dcfa --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/web/controller/OrderController.java @@ -0,0 +1,25 @@ +package com.example.Centralthon.domain.order.web.controller; + +import com.example.Centralthon.domain.order.service.OrderService; +import com.example.Centralthon.domain.order.web.dto.OrderReq; +import com.example.Centralthon.domain.order.web.dto.OrderRes; +import com.example.Centralthon.global.response.SuccessResponse; +import com.example.Centralthon.global.response.code.SuccessResponseCode; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/orders") +public class OrderController { + private final OrderService orderService; + + @PostMapping + public ResponseEntity> createOrder(@RequestBody @Valid OrderReq orderReq) { + OrderRes orderRes = orderService.orderMenus(orderReq); + return ResponseEntity.status(HttpStatus.CREATED).body(SuccessResponse.of(orderRes, SuccessResponseCode.SUCCESS_CREATED)); + } +} diff --git a/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderItemListReq.java b/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderItemListReq.java new file mode 100644 index 0000000..97cec39 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderItemListReq.java @@ -0,0 +1,17 @@ +package com.example.Centralthon.domain.order.web.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class OrderItemListReq { + @NotNull(message = "메뉴 기본 키는 필수 값입니다.") + private Long menuId; + + @NotNull(message = "수량은 필수 값입니다.") + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") + private Integer count; +} diff --git a/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderReq.java b/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderReq.java new file mode 100644 index 0000000..e36408a --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderReq.java @@ -0,0 +1,15 @@ +package com.example.Centralthon.domain.order.web.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class OrderReq { + @NotEmpty(message = "주문 항목은 빈 배열일 수 없습니다.") + List<@Valid OrderItemListReq> items; +} diff --git a/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderRes.java b/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderRes.java new file mode 100644 index 0000000..0c351a2 --- /dev/null +++ b/src/main/java/com/example/Centralthon/domain/order/web/dto/OrderRes.java @@ -0,0 +1,11 @@ +package com.example.Centralthon.domain.order.web.dto; + +import com.example.Centralthon.domain.order.entity.Order; + +public record OrderRes( + String code +) { + public static OrderRes from(Order order) { + return new OrderRes(order.getPickUpCode()); + } +}