From 4167de5839b40a4bc0789194c8ea1e1e0ad8817d Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Sun, 13 Sep 2026 00:41:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(item):=20=EC=83=81=ED=92=88=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EA=B5=AC=EB=A7=A4=20=EC=88=98=EB=9F=89=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=20=20=20=20-=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=20API=EC=97=90=20=EC=88=98=EB=9F=89=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=EC=99=80=20=EC=B5=9C=EC=86=8C?= =?UTF-8?q?=EA=B0=92=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20=20=20=20?= =?UTF-8?q?=20-=20=EA=B5=AC=EB=A7=A4=20=EC=88=98=EB=9F=89=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EC=A3=BC=EB=AC=B8=20=EA=B8=88=EC=95=A1?= =?UTF-8?q?=EA=B3=BC=20=EC=A7=80=EA=B8=89=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=EA=B3=84=EC=82=B0=20=20=20=20=20-=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EA=B5=AC=EB=A7=A4=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C=20=EB=B3=B4=EC=9C=A0=20=ED=95=9C=EB=8F=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=20=20=20=20-=20=EB=8B=A4=EC=A4=91=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=20=EB=B0=8F=20=EA=B5=AC=EB=A7=A4=20=ED=95=9C=EB=8F=84?= =?UTF-8?q?=20=EC=B4=88=EA=B3=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20=20=20=20-=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/product/service/ShopService.java | 2 +- .../product/service/ShopServiceImpl.java | 14 +- .../item/infra/controller/ShopController.java | 6 +- .../{ => admin}/AdminRouletteController.java | 2 +- .../product/service/ShopServiceImplTest.java | 123 ++++++++++++++++-- .../AdminRouletteControllerTest.java | 1 + 6 files changed, 127 insertions(+), 21 deletions(-) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/AdminRouletteController.java (97%) diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java index 6667d93..3e64ba3 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java @@ -10,7 +10,7 @@ public interface ShopService { List getActiveProducts(Long memberId, Boolean isBundle); - void requestPurchase(Long memberId, Long productId); + void requestPurchase(Long memberId, Long productId, int quantity); PurchasePendingStatusResponse getMyPurchaseRequestStatus(Long memberId); diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java index ddceaad..8b97468 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java @@ -67,7 +67,7 @@ public List getActiveProducts(Long memberId, Boolean isBundle) @Override @DistributedLock(key = "order:pending", identifier = "#memberId") - public void requestPurchase(Long memberId, Long productId) { + public void requestPurchase(Long memberId, Long productId, int quantity) { Product product = productRepository.findById(productId) .orElseThrow(() -> new BusinessException(ItemErrorCode.PRODUCT_NOT_FOUND)); @@ -81,7 +81,7 @@ public void requestPurchase(Long memberId, Long productId) { throw new BusinessException(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS); } - validatePurchaseLimit(memberId, product, now); + validatePurchaseLimit(memberId, product, now, quantity); validatePurchaseCountLimit(memberId, product, now); OrdererInfoDto ordererInfo = userOrderClient.getOrdererInfo(memberId); @@ -95,8 +95,8 @@ public void requestPurchase(Long memberId, Long productId) { .requestedItemName(product.getName()) .requesterRealName(realName) .requesterUsername(username) - .requestedPrice(product.getPrice()) - .expectedPrice(product.getPrice()) + .requestedPrice(product.getPrice() * quantity) + .expectedPrice(product.getPrice() * quantity) .requestedAt(now) .expiresAt(now.plusMinutes(paymentOrderProperties.expireMinutes())) .build(); @@ -104,7 +104,7 @@ public void requestPurchase(Long memberId, Long productId) { product.getRewards().forEach(reward -> order.addOrderItem( OrderItem.builder() .itemType(reward.getItemType()) - .quantity(reward.getQuantity()) + .quantity(reward.getQuantity() * quantity) .build() )); @@ -200,11 +200,11 @@ public PurchaseLimitResponse getMyPurchaseLimits(Long memberId) { ); } - private void validatePurchaseLimit(Long memberId, Product product, LocalDateTime now) { + private void validatePurchaseLimit(Long memberId, Product product, LocalDateTime now, int quantity) { Map requestedQuantityByType = getRequestedQuantityByType(product); for (ItemType itemType : LIMITED_ITEM_TYPES) { - int requestedQuantity = requestedQuantityByType.getOrDefault(itemType, 0); + int requestedQuantity = requestedQuantityByType.getOrDefault(itemType, 0) * quantity; if (requestedQuantity == 0) { continue; } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java b/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java index 63bfa22..e717d9d 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java @@ -2,6 +2,7 @@ import java.util.List; +import jakarta.validation.constraints.Min; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -54,9 +55,10 @@ public ResponseEntity> getMyPurchaseLimits( @PostMapping("/purchase/{productId}") public ResponseEntity> requestPurchase( @CurrentMember MemberInfo memberInfo, - @PathVariable Long productId + @PathVariable Long productId, + @RequestParam(defaultValue = "1") @Min(1) int quantity ) { - shopService.requestPurchase(memberInfo.memberId(), productId); + shopService.requestPurchase(memberInfo.memberId(), productId, quantity); return ResponseEntity.ok(ApiResponse.ok()); } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminRouletteController.java similarity index 97% rename from item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminRouletteController.java index c39d907..6245bb0 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminRouletteController.java @@ -1,4 +1,4 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; import java.util.List; diff --git a/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java index 40a8920..6f44cbe 100644 --- a/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java @@ -67,6 +67,8 @@ class ShopServiceImplTest { @Mock private PaymentOrderProperties paymentOrderProperties; + private static final int DEFAULT_QUANTITY = 1; + @Test @DisplayName("상품 ID 기반 요청이면 상품 가격/구성품으로 주문을 생성한다") void shouldCreateOrderFromProductSnapshot() { @@ -84,7 +86,7 @@ void shouldCreateOrderFromProductSnapshot() { // when LocalDateTime before = LocalDateTime.now(); - shopService.requestPurchase(100L, 3L); + shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY); LocalDateTime after = LocalDateTime.now(); // then @@ -156,7 +158,7 @@ void shouldThrowWhenProductNotFound() { given(productRepository.findById(999L)).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 999L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 999L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(ItemErrorCode.PRODUCT_NOT_FOUND); @@ -170,7 +172,7 @@ void shouldThrowWhenProductIsNotActive() { given(productRepository.findById(3L)).willReturn(Optional.of(product)); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(ItemErrorCode.PRODUCT_NOT_AVAILABLE); @@ -185,7 +187,7 @@ void shouldBlockWhenPendingRequestExists() { given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(true); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS); @@ -206,7 +208,7 @@ void shouldThrowWhenPurchaseLimitExceeded() { .willReturn(0L); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.PURCHASE_LIMIT_EXCEEDED); @@ -228,7 +230,7 @@ void shouldThrowWhenProductPurchaseCountLimitExceeded() { .willReturn(0L); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.PRODUCT_PURCHASE_LIMIT_EXCEEDED); @@ -248,7 +250,7 @@ void shouldThrowWhenFirstPurchaseOnlyProductHasExistingPurchase() { given(orderRepository.existsApprovedOrActivePendingOrder(eq(100L), any())).willReturn(true); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.FIRST_PURCHASE_ONLY); @@ -274,7 +276,7 @@ void shouldCreateFirstPurchaseOnlyOrderWhenNoExistingPurchase() { given(paymentOrderProperties.expireMinutes()).willReturn(43200L); // when - shopService.requestPurchase(100L, 3L); + shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY); // then then(orderRepository).should().save(any(Order.class)); @@ -350,7 +352,7 @@ void shouldThrowWhenRealNameMissing() { given(userOrderClient.getOrdererInfo(100L)).willReturn(new OrdererInfoDto(100L, null, "길동이")); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.REAL_NAME_REQUIRED); @@ -366,7 +368,7 @@ void shouldThrowWhenUsernameMissing() { given(userOrderClient.getOrdererInfo(100L)).willReturn(new OrdererInfoDto(100L, "홍길동", null)); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.USERNAME_REQUIRED); @@ -445,4 +447,105 @@ private Product product(String name, int price, boolean isActive, boolean isBund .isBundle(isBundle) .build(); } + + @Test + @DisplayName("여러 개 구매 시 구매 수량만큼 가격과 지급 아이템 수량을 증가시켜 주문을 생성한다") + void shouldCreateOrderWithMultipleQuantity() { + // given + int quantity = 3; + + Product product = product("매칭권 10개 (+옵션권 5개)", 5000, true); + ReflectionTestUtils.setField(product, "id", 3L); + + product.addReward( + ProductReward.builder() + .itemType(ItemType.MATCHING_TICKET) + .quantity(10) + .build() + ); + + product.addReward( + ProductReward.builder() + .itemType(ItemType.OPTION_TICKET) + .quantity(10) + .build() + ); + + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(false); + + given(userOrderClient.getOrdererInfo(100L)) + .willReturn(new OrdererInfoDto(100L, "홍길동", "길동이")); + + given(paymentOrderProperties.expireMinutes()).willReturn(43200L); + + // when + shopService.requestPurchase(100L, 3L, quantity); + + // then + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(Order.class); + then(orderRepository).should().save(orderCaptor.capture()); + + Order savedOrder = orderCaptor.getValue(); + + assertThat(savedOrder.getRequestedPrice()).isEqualTo(15000); + assertThat(savedOrder.getExpectedPrice()).isEqualTo(15000); + + assertThat(savedOrder.getOrderItems()).hasSize(2); + + assertThat(savedOrder.getOrderItems()).anyMatch( + item -> + item.getItemType() == ItemType.MATCHING_TICKET + && item.getQuantity() == 30 + ); + + assertThat(savedOrder.getOrderItems()).anyMatch( + item -> + item.getItemType() == ItemType.OPTION_TICKET + && item.getQuantity() == 30 + ); + + then(orderOutboxService).should().enqueueOrderCreated(savedOrder); + } + + @Test + @DisplayName("여러 개 구매 시 전체 구매 수량 기준으로 아이템 보유 한도를 검증한다") + void shouldThrowWhenMultipleQuantityExceedsPurchaseLimit() { + // given + int quantity = 99; + + Product product = product("매칭권 패키지", 1000, true); + + product.addReward( + ProductReward.builder() + .itemType(ItemType.MATCHING_TICKET) + .quantity(2) + .build() + ); + + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(false); + + given(itemRepository.sumUsableQuantityByMemberIdAndItemType( + 100L, + ItemType.MATCHING_TICKET + )).willReturn(25L); + + given(orderRepository.sumActivePendingQuantityByMemberIdAndItemType( + eq(100L), + eq(ItemType.MATCHING_TICKET), + any() + )).willReturn(0L); + + // when & then + assertThatThrownBy(() -> + shopService.requestPurchase(100L, 3L, quantity) + ) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(PaymentErrorCode.PURCHASE_LIMIT_EXCEEDED); + + then(userOrderClient).should(never()).getOrdererInfo(any()); + then(orderRepository).should(never()).save(any()); + } } diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java index 58d6212..d6ae489 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.List; +import com.comatching.item.infra.controller.admin.AdminRouletteController; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test;