From f41dfbf76e21089fea7c460398e64ce3e69aa83d Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Tue, 15 Sep 2026 15:51:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=A0=EC=9D=B8=20=EB=A7=A4=EC=B9=AD?= =?UTF-8?q?=EA=B6=8C=20=EB=B0=8F=20=ED=8A=B9=EA=B0=80=20=EB=B2=88=EB=93=A4?= =?UTF-8?q?=20=EA=B5=AC=EB=A7=A4=20=EC=A0=95=EC=B1=85=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 800원 매칭권을 인당 누적 최대 3개까지 구매 가능하도록 변경 - 승인 및 유효 대기 수량을 구매 한도에 반영 - 3000원 번들의 첫 결제 조건을 제거하고 인당 1회 제한 유지 - 구매 한도 관련 테스트 보강 --- .../order/repository/OrderRepository.java | 32 ++++++++ .../product/service/ShopServiceImpl.java | 42 ++++++++--- .../item/global/init/ShopDataInitializer.java | 2 +- .../product/service/ShopServiceImplTest.java | 74 +++++++++++++------ .../global/init/ShopDataInitializerTest.java | 2 +- .../infra/controller/ShopControllerTest.java | 24 +++--- 6 files changed, 131 insertions(+), 45 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java b/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java index a2bc200..46054ea 100644 --- a/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java @@ -66,6 +66,38 @@ long countActivePendingByMemberIdAndProductCode( @Param("now") LocalDateTime now ); + @Query(""" + SELECT COALESCE(SUM(oi.quantity), 0) + FROM Order o + JOIN o.orderItems oi + WHERE o.memberId = :memberId + AND o.productCode = :productCode + AND o.status = com.comatching.item.domain.order.enums.OrderStatus.APPROVED + AND oi.itemType = :itemType + """) + long sumApprovedQuantityByMemberIdAndProductCodeAndItemType( + @Param("memberId") Long memberId, + @Param("productCode") String productCode, + @Param("itemType") ItemType itemType + ); + + @Query(""" + SELECT COALESCE(SUM(oi.quantity), 0) + FROM Order o + JOIN o.orderItems oi + WHERE o.memberId = :memberId + AND o.productCode = :productCode + AND o.status = com.comatching.item.domain.order.enums.OrderStatus.PENDING + AND o.expiresAt > :now + AND oi.itemType = :itemType + """) + long sumActivePendingQuantityByMemberIdAndProductCodeAndItemType( + @Param("memberId") Long memberId, + @Param("productCode") String productCode, + @Param("itemType") ItemType itemType, + @Param("now") LocalDateTime now + ); + @Query(""" SELECT COALESCE(SUM(oi.quantity), 0) FROM Order o 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 dfd5f26..17ea586 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 @@ -78,10 +78,6 @@ public void requestPurchase(Long memberId, Long productId, int quantity) { throw new BusinessException(ItemErrorCode.PRODUCT_NOT_AVAILABLE); } - if (DISCOUNT_MATCHING_TICKET_CODE.equals(resolveProductCode(product)) && quantity != 1) { - throw new BusinessException(PaymentErrorCode.INVALID_ORDER_QUANTITY); - } - LocalDateTime now = LocalDateTime.now(); boolean hasPendingRequest = orderRepository.existsActivePendingOrder(memberId, now); if (hasPendingRequest) { @@ -89,7 +85,7 @@ public void requestPurchase(Long memberId, Long productId, int quantity) { } validatePurchaseLimit(memberId, product, now, quantity); - validatePurchaseCountLimit(memberId, product, now); + validatePurchaseCountLimit(memberId, product, now, quantity); OrdererInfoDto ordererInfo = userOrderClient.getOrdererInfo(memberId); String realName = normalizeRequiredText(ordererInfo.realName(), PaymentErrorCode.REAL_NAME_REQUIRED); @@ -121,8 +117,8 @@ public void requestPurchase(Long memberId, Long productId, int quantity) { private ProductResponse toMemberProductResponse(Long memberId, Product product, LocalDateTime now) { String productCode = resolveProductCode(product); - long usedPurchaseCount = orderRepository.countApprovedByMemberIdAndProductCode(memberId, productCode); - long activePendingOrderCount = orderRepository.countActivePendingByMemberIdAndProductCode(memberId, productCode, now); + long usedPurchaseCount = usedPurchaseCount(memberId, productCode); + long activePendingOrderCount = activePendingPurchaseCount(memberId, productCode, now); Long remainingPurchaseCount = remainingCount(product.getPurchaseLimitPerMember(), usedPurchaseCount, activePendingOrderCount); PurchaseBlockReason blockReason = purchaseBlockReason( @@ -141,20 +137,44 @@ private ProductResponse toMemberProductResponse(Long memberId, Product product, ); } - private void validatePurchaseCountLimit(Long memberId, Product product, LocalDateTime now) { + private void validatePurchaseCountLimit(Long memberId, Product product, LocalDateTime now, int quantity) { if (product.isFirstPurchaseOnly() && orderRepository.existsApprovedOrActivePendingOrder(memberId, now)) { throw new BusinessException(PaymentErrorCode.FIRST_PURCHASE_ONLY); } String productCode = resolveProductCode(product); - long usedPurchaseCount = orderRepository.countApprovedByMemberIdAndProductCode(memberId, productCode); - long activePendingOrderCount = orderRepository.countActivePendingByMemberIdAndProductCode(memberId, productCode, now); + long usedPurchaseCount = usedPurchaseCount(memberId, productCode); + long activePendingOrderCount = activePendingPurchaseCount(memberId, productCode, now); Long remainingPurchaseCount = remainingCount(product.getPurchaseLimitPerMember(), usedPurchaseCount, activePendingOrderCount); - if (remainingPurchaseCount != null && remainingPurchaseCount <= 0) { + int requestedPurchaseCount = DISCOUNT_MATCHING_TICKET_CODE.equals(productCode) ? quantity : 1; + if (remainingPurchaseCount != null && remainingPurchaseCount < requestedPurchaseCount) { throw new BusinessException(PaymentErrorCode.PRODUCT_PURCHASE_LIMIT_EXCEEDED); } } + private long usedPurchaseCount(Long memberId, String productCode) { + if (DISCOUNT_MATCHING_TICKET_CODE.equals(productCode)) { + return orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType( + memberId, + productCode, + ItemType.MATCHING_TICKET + ); + } + return orderRepository.countApprovedByMemberIdAndProductCode(memberId, productCode); + } + + private long activePendingPurchaseCount(Long memberId, String productCode, LocalDateTime now) { + if (DISCOUNT_MATCHING_TICKET_CODE.equals(productCode)) { + return orderRepository.sumActivePendingQuantityByMemberIdAndProductCodeAndItemType( + memberId, + productCode, + ItemType.MATCHING_TICKET, + now + ); + } + return orderRepository.countActivePendingByMemberIdAndProductCode(memberId, productCode, now); + } + private PurchaseBlockReason purchaseBlockReason( Long memberId, Product product, diff --git a/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java b/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java index 1cc82a0..51dd295 100644 --- a/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java +++ b/item-service/src/main/java/com/comatching/item/global/init/ShopDataInitializer.java @@ -39,7 +39,7 @@ public void run(String... args) throws Exception { 1, true, 1, - true + false ); addReward(firstPurchaseBundle, ItemType.MATCHING_TICKET, 3); addReward(firstPurchaseBundle, ItemType.OPTION_TICKET, 6); 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 ddc02c4..8fe72f3 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 @@ -73,27 +73,25 @@ class ShopServiceImplTest { private static final int DEFAULT_QUANTITY = 1; @ParameterizedTest - @ValueSource(longs = {0, 1, 2}) - @DisplayName("할인 뽑기권은 승인 3회 미만이면 800원에 1개 주문을 생성한다") - void shouldAllowDiscountTicketBeforeThreeApprovedPurchases(long approvedCount) { + @ValueSource(ints = {1, 2, 3}) + @DisplayName("할인 뽑기권은 누적 한도 안에서 요청 수량만큼 주문을 생성한다") + void shouldAllowDiscountTicketUpToThreePerMember(int quantity) { Product product = discountMatchingTicket(); given(productRepository.findById(3L)).willReturn(Optional.of(product)); - given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())) - .willReturn(approvedCount); given(userOrderClient.getOrdererInfo(100L)).willReturn(new OrdererInfoDto(100L, "홍길동", "길동이")); given(paymentOrderProperties.expireMinutes()).willReturn(43200L); - shopService.requestPurchase(100L, 3L, 1); + shopService.requestPurchase(100L, 3L, quantity); ArgumentCaptor captor = ArgumentCaptor.forClass(Order.class); then(orderRepository).should().save(captor.capture()); Order order = captor.getValue(); assertThat(order.getProductCode()).isEqualTo("DISCOUNT_MATCHING_TICKET_1"); - assertThat(order.getExpectedPrice()).isEqualTo(800); - assertThat(order.getRequestedPrice()).isEqualTo(800); + assertThat(order.getExpectedPrice()).isEqualTo(800 * quantity); + assertThat(order.getRequestedPrice()).isEqualTo(800 * quantity); assertThat(order.getOrderItems()).singleElement().satisfies(item -> { assertThat(item.getItemType()).isEqualTo(ItemType.MATCHING_TICKET); - assertThat(item.getQuantity()).isEqualTo(1); + assertThat(item.getQuantity()).isEqualTo(quantity); }); then(orderOutboxService).should().enqueueOrderCreated(order); } @@ -103,7 +101,8 @@ void shouldAllowDiscountTicketBeforeThreeApprovedPurchases(long approvedCount) { void shouldRejectDiscountTicketAfterThreeApprovedPurchases() { Product product = discountMatchingTicket(); given(productRepository.findById(3L)).willReturn(Optional.of(product)); - given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(3L); + given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType( + 100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(3L); assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, 1)) .isInstanceOf(BusinessException.class) @@ -119,9 +118,11 @@ void shouldRejectDiscountTicketAfterThreeApprovedPurchases() { void shouldReturnDiscountTicketRemainingCount(long approvedCount, long pendingCount, long remainingCount) { Product product = discountMatchingTicket(); given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product)); - given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())) + given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType( + 100L, product.getCode(), ItemType.MATCHING_TICKET)) .willReturn(approvedCount); - given(orderRepository.countActivePendingByMemberIdAndProductCode(eq(100L), eq(product.getCode()), any())) + given(orderRepository.sumActivePendingQuantityByMemberIdAndProductCodeAndItemType( + eq(100L), eq(product.getCode()), eq(ItemType.MATCHING_TICKET), any())) .willReturn(pendingCount); assertThat(shopService.getActiveProducts(100L, true)).singleElement().satisfies(response -> { @@ -137,8 +138,10 @@ void shouldReturnDiscountTicketRemainingCount(long approvedCount, long pendingCo void shouldRejectDiscountTicketWithTwoApprovedAndOnePendingPurchase() { Product product = discountMatchingTicket(); given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product)); - given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(2L); - given(orderRepository.countActivePendingByMemberIdAndProductCode(eq(100L), eq(product.getCode()), any())) + given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType( + 100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(2L); + given(orderRepository.sumActivePendingQuantityByMemberIdAndProductCodeAndItemType( + eq(100L), eq(product.getCode()), eq(ItemType.MATCHING_TICKET), any())) .willReturn(1L); given(productRepository.findById(3L)).willReturn(Optional.of(product)); given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(true); @@ -155,17 +158,44 @@ void shouldRejectDiscountTicketWithTwoApprovedAndOnePendingPurchase() { then(orderOutboxService).shouldHaveNoInteractions(); } - @ParameterizedTest - @ValueSource(ints = {2, 3}) - @DisplayName("할인 뽑기권 quantity 2 또는 3 직접 요청은 서버에서 거부한다") - void shouldRejectMultipleDiscountTicketsInOneRequest(int quantity) { - given(productRepository.findById(3L)).willReturn(Optional.of(discountMatchingTicket())); + @Test + @DisplayName("할인 뽑기권 승인 0개와 유효 대기 3개가 있으면 잔여 0개이며 추가 요청을 거부한다") + void shouldRejectDiscountTicketWithNoApprovedAndThreePendingTickets() { + Product product = discountMatchingTicket(); + given(productRepository.findActiveProductsWithRewards(true)).willReturn(List.of(product)); + given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType( + 100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(0L); + given(orderRepository.sumActivePendingQuantityByMemberIdAndProductCodeAndItemType( + eq(100L), eq(product.getCode()), eq(ItemType.MATCHING_TICKET), any())) + .willReturn(3L); + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(true); - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, quantity)) + assertThat(shopService.getActiveProducts(100L, true)).singleElement().satisfies(response -> { + assertThat(response.remainingPurchaseCount()).isZero(); + assertThat(response.purchaseCountPurchasable()).isFalse(); + }); + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, 1)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) - .isEqualTo(PaymentErrorCode.INVALID_ORDER_QUANTITY); - then(orderRepository).shouldHaveNoInteractions(); + .isEqualTo(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS); + then(orderRepository).should(never()).save(any()); + then(orderOutboxService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("할인 뽑기권은 기존 구매 수량과 요청 수량의 합이 3개를 넘으면 거부한다") + void shouldRejectDiscountTicketsExceedingThreePerMember() { + Product product = discountMatchingTicket(); + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType( + 100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(1L); + + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, 3)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(PaymentErrorCode.PRODUCT_PURCHASE_LIMIT_EXCEEDED); + then(orderRepository).should(never()).save(any()); then(userOrderClient).shouldHaveNoInteractions(); then(orderOutboxService).shouldHaveNoInteractions(); } diff --git a/item-service/src/test/java/com/comatching/item/global/init/ShopDataInitializerTest.java b/item-service/src/test/java/com/comatching/item/global/init/ShopDataInitializerTest.java index 0e782eb..74ca92c 100644 --- a/item-service/src/test/java/com/comatching/item/global/init/ShopDataInitializerTest.java +++ b/item-service/src/test/java/com/comatching/item/global/init/ShopDataInitializerTest.java @@ -69,7 +69,7 @@ void shouldCreateConfirmedDefaultProducts() throws Exception { assertThat(firstPurchaseBundle.getName()).isEqualTo("첫 구매 특가 번들"); assertThat(firstPurchaseBundle.getPrice()).isEqualTo(3000); assertThat(firstPurchaseBundle.getPurchaseLimitPerMember()).isEqualTo(1); - assertThat(firstPurchaseBundle.isFirstPurchaseOnly()).isTrue(); + assertThat(firstPurchaseBundle.isFirstPurchaseOnly()).isFalse(); assertThat(rewardQuantity(firstPurchaseBundle, ItemType.MATCHING_TICKET)).isEqualTo(3); assertThat(rewardQuantity(firstPurchaseBundle, ItemType.OPTION_TICKET)).isEqualTo(6); diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/ShopControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/ShopControllerTest.java index 60a2f4e..2656ec3 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/ShopControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/ShopControllerTest.java @@ -88,17 +88,20 @@ void shouldReturnDiscountMatchingTicket() throws Exception { } @Test - @DisplayName("POST /api/v1/shop/purchase/{id}는 할인 상품 quantity=2를 거부한다") - void shouldRejectMultipleDiscountTickets() throws Exception { - given(productRepository.findById(3L)).willReturn(Optional.of(discountMatchingTicket())); + @DisplayName("POST /api/v1/shop/purchase/{id}는 할인 상품 quantity=3을 허용한다") + void shouldAllowThreeDiscountTickets() throws Exception { + Product product = discountMatchingTicket(); + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(userOrderClient.getOrdererInfo(100L)).willReturn(new com.comatching.common.dto.member.OrdererInfoDto( + 100L, "홍길동", "길동이")); + given(paymentOrderProperties.expireMinutes()).willReturn(43200L); mockMvc.perform(post("/api/v1/shop/purchase/3") - .param("quantity", "2") + .param("quantity", "3") .header("X-Member-Id", "100")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("PAY-007")); + .andExpect(status().isOk()); - then(orderRepository).shouldHaveNoInteractions(); + then(orderRepository).should().save(any()); } @Test @@ -107,7 +110,8 @@ void shouldRejectFourthDiscountTicketPurchase() throws Exception { Product product = discountMatchingTicket(); given(productRepository.findById(3L)).willReturn(Optional.of(product)); given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(false); - given(orderRepository.countApprovedByMemberIdAndProductCode(100L, product.getCode())).willReturn(3L); + given(orderRepository.sumApprovedQuantityByMemberIdAndProductCodeAndItemType( + 100L, product.getCode(), ItemType.MATCHING_TICKET)).willReturn(3L); mockMvc.perform(post("/api/v1/shop/purchase/3") .param("quantity", "1") @@ -115,8 +119,8 @@ void shouldRejectFourthDiscountTicketPurchase() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("PAY-012")); - then(orderRepository).should().countActivePendingByMemberIdAndProductCode( - eq(100L), eq(product.getCode()), any()); + then(orderRepository).should().sumActivePendingQuantityByMemberIdAndProductCodeAndItemType( + eq(100L), eq(product.getCode()), eq(ItemType.MATCHING_TICKET), any()); then(orderRepository).should(never()).save(any()); }