Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<Order> findAllByStatusOrderByRequestedAtDesc(OrderStatus status);

List<Order> findAllByStatusAndExpiresAtAfterOrderByRequestedAtDesc(OrderStatus status, LocalDateTime now);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ItemPurchaseLimitResponse> 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
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -12,4 +13,6 @@ public interface ShopService {
void requestPurchase(Long memberId, Long productId);

PurchasePendingStatusResponse getMyPurchaseRequestStatus(Long memberId);

PurchaseLimitResponse getMyPurchaseLimits(Long memberId);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -30,9 +36,18 @@
public class ShopServiceImpl implements ShopService {

private static final int ORDER_EXPIRE_MINUTES = 10;
private static final Map<ItemType, Integer> PURCHASE_LIMITS = Map.of(
ItemType.MATCHING_TICKET, 30,
ItemType.OPTION_TICKET, 90
);
private static final List<ItemType> LIMITED_ITEM_TYPES = List.of(
ItemType.MATCHING_TICKET,
ItemType.OPTION_TICKET
);
Comment on lines +39 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

구매 한도 정책(PURCHASE_LIMITS, LIMITED_ITEM_TYPES)이 서비스 클래스 내부에 하드코딩되어 있습니다. Repository Style Guide 3.1절에 따라 이러한 설정값은 @ConfigurationProperties를 사용하여 외부 설정(yml)으로 관리하는 것을 권장합니다. 또한 LIMITED_ITEM_TYPESPURCHASE_LIMITS.keySet()으로 대체하여 중복 관리를 제거할 수 있습니다.

References
  1. 설정 값은 @value 남용 대신 @ConfigurationProperties 사용을 권장합니다. (link)


private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final ItemRepository itemRepository;
private final UserOrderClient userOrderClient;
private final OrderOutboxService orderOutboxService;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()
Comment on lines +120 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

아이템 타입별로 루프를 돌며 DB 조회를 반복하고 있습니다(N+1 조회). 현재는 대상 타입이 2개뿐이라 영향이 적지만, 확장성을 고려하여 IN 절을 사용하는 단일 쿼리로 모든 타입의 수량을 한 번에 조회하도록 개선하는 것이 좋습니다.

);
}

private void validatePurchaseLimit(Long memberId, Product product, LocalDateTime now) {
Map<ItemType, Integer> 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);
}
}
Comment on lines +134 to +146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

validatePurchaseLimit 메서드 내의 반복문에서도 개별 DB 조회가 발생하고 있습니다. requestPurchase는 분산 락이 걸리는 임계 영역(Critical Section)이므로, 루프 외부에서 데이터를 일괄 조회하여 락 점유 시간을 최소화하는 것이 성능상 유리합니다.

}

private Map<ItemType, Integer> getRequestedQuantityByType(Product product) {
Map<ItemType, Integer> requestedQuantityByType = new EnumMap<>(ItemType.class);
for (ProductReward reward : product.getRewards()) {
requestedQuantityByType.merge(reward.getItemType(), reward.getQuantity(), Integer::sum);
}
Comment on lines +151 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 getRequestedQuantityByTypeproduct.getRewards()만 집계하고 있습니다. 만약 보너스 리워드(bonusRewards)도 실제 사용자에게 지급되는 아이템이라면, 구매 한도 검증 시 보너스 수량도 합산되어야 정책상 일관성을 유지할 수 있습니다.

return requestedQuantityByType;
}

private String normalizeRequiredText(String value, PaymentErrorCode errorCode) {
if (!StringUtils.hasText(value)) {
throw new BusinessException(errorCode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -57,4 +58,12 @@ public ResponseEntity<ApiResponse<PurchasePendingStatusResponse>> getMyPurchaseR
) {
return ResponseEntity.ok(ApiResponse.ok(shopService.getMyPurchaseRequestStatus(memberInfo.memberId())));
}

@Operation(summary = "내 아이템 구매 한도 조회", description = "현재 사용자의 매칭권/옵션권 보유 수량, 진행 중인 구매 요청 수량, 최대 구매 한도와 남은 구매 가능 수량을 조회합니다.")
@GetMapping("/purchase/limits")
public ResponseEntity<ApiResponse<PurchaseLimitResponse>> getMyPurchaseLimits(
@CurrentMember MemberInfo memberInfo
) {
return ResponseEntity.ok(ApiResponse.ok(shopService.getMyPurchaseLimits(memberInfo.memberId())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,6 +52,9 @@ class ShopServiceImplTest {
@Mock
private OrderRepository orderRepository;

@Mock
private ItemRepository itemRepository;

@Mock
private UserOrderClient userOrderClient;

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
Expand Down