From 846d85ca12d0c0e58fa60dd4169f11c9b466a166 Mon Sep 17 00:00:00 2001 From: popeye Date: Thu, 14 May 2026 00:44:38 +0900 Subject: [PATCH] feat(item): enforce purchase limits --- .../order/repository/OrderRepository.java | 16 +++++ .../product/dto/PurchaseLimitResponse.java | 37 ++++++++++++ .../domain/product/service/ShopService.java | 3 + .../product/service/ShopServiceImpl.java | 59 +++++++++++++++++++ .../global/exception/PaymentErrorCode.java | 1 + .../item/infra/controller/ShopController.java | 9 +++ .../product/service/ShopServiceImplTest.java | 59 +++++++++++++++++++ 7 files changed, 184 insertions(+) create mode 100644 item-service/src/main/java/com/comatching/item/domain/product/dto/PurchaseLimitResponse.java 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 1f9156d..c444b0c 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 @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.comatching.common.domain.enums.ItemType; import com.comatching.item.domain.order.entity.Order; import com.comatching.item.domain.order.enums.OrderStatus; @@ -23,6 +24,21 @@ SELECT CASE WHEN COUNT(o) > 0 THEN true ELSE false END """) boolean existsActivePendingOrder(@Param("memberId") Long memberId, @Param("now") LocalDateTime now); + @Query(""" + SELECT COALESCE(SUM(oi.quantity), 0) + FROM Order o + JOIN o.orderItems oi + WHERE o.memberId = :memberId + AND o.status = com.comatching.item.domain.order.enums.OrderStatus.PENDING + AND o.expiresAt > :now + AND oi.itemType = :itemType + """) + long sumActivePendingQuantityByMemberIdAndItemType( + @Param("memberId") Long memberId, + @Param("itemType") ItemType itemType, + @Param("now") LocalDateTime now + ); + List findAllByStatusOrderByRequestedAtDesc(OrderStatus status); List findAllByStatusAndExpiresAtAfterOrderByRequestedAtDesc(OrderStatus status, LocalDateTime now); diff --git a/item-service/src/main/java/com/comatching/item/domain/product/dto/PurchaseLimitResponse.java b/item-service/src/main/java/com/comatching/item/domain/product/dto/PurchaseLimitResponse.java new file mode 100644 index 0000000..39ef738 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/product/dto/PurchaseLimitResponse.java @@ -0,0 +1,37 @@ +package com.comatching.item.domain.product.dto; + +import java.util.List; + +import com.comatching.common.domain.enums.ItemType; + +public record PurchaseLimitResponse( + List limits +) { + public record ItemPurchaseLimitResponse( + ItemType itemType, + String itemName, + long ownedQuantity, + long pendingQuantity, + int maxQuantity, + long remainingQuantity, + boolean purchasable + ) { + public static ItemPurchaseLimitResponse of( + ItemType itemType, + long ownedQuantity, + long pendingQuantity, + int maxQuantity + ) { + long remainingQuantity = Math.max(0, maxQuantity - ownedQuantity - pendingQuantity); + return new ItemPurchaseLimitResponse( + itemType, + itemType.getName(), + ownedQuantity, + pendingQuantity, + maxQuantity, + remainingQuantity, + remainingQuantity > 0 + ); + } + } +} 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 ef1651e..f21ccca 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 @@ -3,6 +3,7 @@ import java.util.List; import com.comatching.item.domain.product.dto.ProductResponse; +import com.comatching.item.domain.product.dto.PurchaseLimitResponse; import com.comatching.item.domain.product.dto.PurchasePendingStatusResponse; public interface ShopService { @@ -12,4 +13,6 @@ public interface ShopService { void requestPurchase(Long memberId, Long productId); PurchasePendingStatusResponse getMyPurchaseRequestStatus(Long memberId); + + PurchaseLimitResponse getMyPurchaseLimits(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 f0327dd..a964a76 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 @@ -1,22 +1,28 @@ package com.comatching.item.domain.product.service; import java.time.LocalDateTime; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import com.comatching.common.annotation.DistributedLock; +import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.OrdererInfoDto; import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.repository.ItemRepository; import com.comatching.item.domain.order.entity.Order; import com.comatching.item.domain.order.entity.OrderItem; import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.order.service.OrderOutboxService; import com.comatching.item.domain.product.dto.ProductResponse; +import com.comatching.item.domain.product.dto.PurchaseLimitResponse; import com.comatching.item.domain.product.dto.PurchasePendingStatusResponse; import com.comatching.item.domain.product.entity.Product; +import com.comatching.item.domain.product.entity.ProductReward; import com.comatching.item.domain.product.repository.ProductRepository; import com.comatching.item.global.exception.ItemErrorCode; import com.comatching.item.global.exception.PaymentErrorCode; @@ -30,9 +36,18 @@ public class ShopServiceImpl implements ShopService { private static final int ORDER_EXPIRE_MINUTES = 10; + private static final Map PURCHASE_LIMITS = Map.of( + ItemType.MATCHING_TICKET, 30, + ItemType.OPTION_TICKET, 90 + ); + private static final List LIMITED_ITEM_TYPES = List.of( + ItemType.MATCHING_TICKET, + ItemType.OPTION_TICKET + ); private final ProductRepository productRepository; private final OrderRepository orderRepository; + private final ItemRepository itemRepository; private final UserOrderClient userOrderClient; private final OrderOutboxService orderOutboxService; @@ -62,6 +77,8 @@ public void requestPurchase(Long memberId, Long productId) { throw new BusinessException(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS); } + validatePurchaseLimit(memberId, product, now); + OrdererInfoDto ordererInfo = userOrderClient.getOrdererInfo(memberId); String realName = normalizeRequiredText(ordererInfo.realName(), PaymentErrorCode.REAL_NAME_REQUIRED); String username = normalizeRequiredText(ordererInfo.nickname(), PaymentErrorCode.USERNAME_REQUIRED); @@ -95,6 +112,48 @@ public PurchasePendingStatusResponse getMyPurchaseRequestStatus(Long memberId) { return hasPendingRequest ? PurchasePendingStatusResponse.pending() : PurchasePendingStatusResponse.none(); } + @Override + @Transactional(readOnly = true) + public PurchaseLimitResponse getMyPurchaseLimits(Long memberId) { + LocalDateTime now = LocalDateTime.now(); + return new PurchaseLimitResponse( + LIMITED_ITEM_TYPES.stream() + .map(itemType -> PurchaseLimitResponse.ItemPurchaseLimitResponse.of( + itemType, + itemRepository.sumUsableQuantityByMemberIdAndItemType(memberId, itemType), + orderRepository.sumActivePendingQuantityByMemberIdAndItemType(memberId, itemType, now), + PURCHASE_LIMITS.get(itemType) + )) + .toList() + ); + } + + private void validatePurchaseLimit(Long memberId, Product product, LocalDateTime now) { + Map requestedQuantityByType = getRequestedQuantityByType(product); + + for (ItemType itemType : LIMITED_ITEM_TYPES) { + int requestedQuantity = requestedQuantityByType.getOrDefault(itemType, 0); + if (requestedQuantity == 0) { + continue; + } + + long ownedQuantity = itemRepository.sumUsableQuantityByMemberIdAndItemType(memberId, itemType); + long pendingQuantity = orderRepository.sumActivePendingQuantityByMemberIdAndItemType(memberId, itemType, now); + int maxQuantity = PURCHASE_LIMITS.get(itemType); + if (ownedQuantity + pendingQuantity + requestedQuantity > maxQuantity) { + throw new BusinessException(PaymentErrorCode.PURCHASE_LIMIT_EXCEEDED); + } + } + } + + private Map getRequestedQuantityByType(Product product) { + Map requestedQuantityByType = new EnumMap<>(ItemType.class); + for (ProductReward reward : product.getRewards()) { + requestedQuantityByType.merge(reward.getItemType(), reward.getQuantity(), Integer::sum); + } + return requestedQuantityByType; + } + private String normalizeRequiredText(String value, PaymentErrorCode errorCode) { if (!StringUtils.hasText(value)) { throw new BusinessException(errorCode); diff --git a/item-service/src/main/java/com/comatching/item/global/exception/PaymentErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/PaymentErrorCode.java index fee3849..87aad8d 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/PaymentErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/PaymentErrorCode.java @@ -19,6 +19,7 @@ public enum PaymentErrorCode implements ErrorCode { INVALID_REQUESTED_PRICE("PAY-008", HttpStatus.BAD_REQUEST, "요청 금액이 서버 계산 금액과 일치하지 않습니다."), ITEM_NAME_REQUIRED("PAY-009", HttpStatus.BAD_REQUEST, "상품명을 입력해주세요."), USERNAME_REQUIRED("PAY-010", HttpStatus.BAD_REQUEST, "사용자명을 입력해주세요."), + PURCHASE_LIMIT_EXCEEDED("PAY-011", HttpStatus.BAD_REQUEST, "아이템 구매 한도를 초과했습니다."), ; private final String code; 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 7b38aba..f8d8d8d 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 @@ -14,6 +14,7 @@ import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; import com.comatching.item.domain.product.dto.ProductResponse; +import com.comatching.item.domain.product.dto.PurchaseLimitResponse; import com.comatching.item.domain.product.dto.PurchasePendingStatusResponse; import com.comatching.item.domain.product.service.ShopService; @@ -57,4 +58,12 @@ public ResponseEntity> getMyPurchaseR ) { return ResponseEntity.ok(ApiResponse.ok(shopService.getMyPurchaseRequestStatus(memberInfo.memberId()))); } + + @Operation(summary = "내 아이템 구매 한도 조회", description = "현재 사용자의 매칭권/옵션권 보유 수량, 진행 중인 구매 요청 수량, 최대 구매 한도와 남은 구매 가능 수량을 조회합니다.") + @GetMapping("/purchase/limits") + public ResponseEntity> getMyPurchaseLimits( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(shopService.getMyPurchaseLimits(memberInfo.memberId()))); + } } 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 2edff61..2c835bb 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 @@ -23,10 +23,12 @@ import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.OrdererInfoDto; import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.repository.ItemRepository; import com.comatching.item.domain.order.entity.Order; import com.comatching.item.domain.order.enums.OrderStatus; import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.order.service.OrderOutboxService; +import com.comatching.item.domain.product.dto.PurchaseLimitResponse; import com.comatching.item.domain.product.dto.PurchasePendingStatusResponse; import com.comatching.item.domain.product.dto.ProductResponse; import com.comatching.item.domain.product.entity.Product; @@ -50,6 +52,9 @@ class ShopServiceImplTest { @Mock private OrderRepository orderRepository; + @Mock + private ItemRepository itemRepository; + @Mock private UserOrderClient userOrderClient; @@ -174,6 +179,28 @@ void shouldBlockWhenPendingRequestExists() { then(userOrderClient).should(never()).getOrdererInfo(any()); } + @Test + @DisplayName("상품 구매 후 아이템 보유 수량이 구매 한도를 초과하면 요청을 막는다") + void shouldThrowWhenPurchaseLimitExceeded() { + // given + Product product = product("매칭권 패키지", 9900, true); + product.addReward(ProductReward.builder().itemType(ItemType.MATCHING_TICKET).quantity(10).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)) + .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()); + } + @Test @DisplayName("회원 실명이 없으면 예외가 발생한다") void shouldThrowWhenRealNameMissing() { @@ -232,6 +259,38 @@ void shouldReturnNoneStatusWhenNoPendingRequest() { assertThat(response.status()).isEqualTo("NONE"); } + @Test + @DisplayName("내 구매 한도 조회 시 보유 수량, 대기 수량, 최대치와 잔여 수량을 반환한다") + void shouldReturnMyPurchaseLimits() { + // given + given(itemRepository.sumUsableQuantityByMemberIdAndItemType(100L, ItemType.MATCHING_TICKET)) + .willReturn(12L); + given(itemRepository.sumUsableQuantityByMemberIdAndItemType(100L, ItemType.OPTION_TICKET)) + .willReturn(80L); + given(orderRepository.sumActivePendingQuantityByMemberIdAndItemType(eq(100L), eq(ItemType.MATCHING_TICKET), any())) + .willReturn(5L); + given(orderRepository.sumActivePendingQuantityByMemberIdAndItemType(eq(100L), eq(ItemType.OPTION_TICKET), any())) + .willReturn(20L); + + // when + PurchaseLimitResponse response = shopService.getMyPurchaseLimits(100L); + + // then + assertThat(response.limits()).hasSize(2); + assertThat(response.limits().get(0).itemType()).isEqualTo(ItemType.MATCHING_TICKET); + assertThat(response.limits().get(0).ownedQuantity()).isEqualTo(12); + assertThat(response.limits().get(0).pendingQuantity()).isEqualTo(5); + assertThat(response.limits().get(0).maxQuantity()).isEqualTo(30); + assertThat(response.limits().get(0).remainingQuantity()).isEqualTo(13); + assertThat(response.limits().get(0).purchasable()).isTrue(); + assertThat(response.limits().get(1).itemType()).isEqualTo(ItemType.OPTION_TICKET); + assertThat(response.limits().get(1).ownedQuantity()).isEqualTo(80); + assertThat(response.limits().get(1).pendingQuantity()).isEqualTo(20); + assertThat(response.limits().get(1).maxQuantity()).isEqualTo(90); + assertThat(response.limits().get(1).remainingQuantity()).isZero(); + assertThat(response.limits().get(1).purchasable()).isFalse(); + } + private Product product(String name, int price, boolean isActive) { return product(name, price, isActive, false); }