From 2f54bd972007dea460744c16493fd72883e99b99 Mon Sep 17 00:00:00 2001 From: popeye Date: Sun, 26 Apr 2026 22:12:30 +0900 Subject: [PATCH] fix(payment): paginate pending requests --- .../item/domain/order/entity/Order.java | 3 + .../order/repository/OrderRepository.java | 4 +- .../product/service/AdminPaymentService.java | 5 +- .../service/AdminPaymentServiceImpl.java | 31 ++++++++-- .../controller/AdminPaymentController.java | 15 +++-- .../service/AdminPaymentServiceImplTest.java | 61 +++++++++++++++++++ 6 files changed, 105 insertions(+), 14 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/order/entity/Order.java b/item-service/src/main/java/com/comatching/item/domain/order/entity/Order.java index b13335d..4cfd3dc 100644 --- a/item-service/src/main/java/com/comatching/item/domain/order/entity/Order.java +++ b/item-service/src/main/java/com/comatching/item/domain/order/entity/Order.java @@ -4,6 +4,8 @@ import java.util.ArrayList; import java.util.List; +import org.hibernate.annotations.BatchSize; + import com.comatching.item.domain.order.enums.OrderStatus; import jakarta.persistence.CascadeType; @@ -68,6 +70,7 @@ public class Order { private LocalDateTime decidedAt; + @BatchSize(size = 100) @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List orderItems = new ArrayList<>(); 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..559121e 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 @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -25,7 +27,7 @@ SELECT CASE WHEN COUNT(o) > 0 THEN true ELSE false END List findAllByStatusOrderByRequestedAtDesc(OrderStatus status); - List findAllByStatusAndExpiresAtAfterOrderByRequestedAtDesc(OrderStatus status, LocalDateTime now); + Page findAllByStatusAndExpiresAtAfter(OrderStatus status, LocalDateTime now, Pageable pageable); List findTop100ByStatusAndExpiresAtBeforeOrderByExpiresAtAsc(OrderStatus status, LocalDateTime now); diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentService.java b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentService.java index 75e80a5..ae3562d 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentService.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentService.java @@ -1,12 +1,13 @@ package com.comatching.item.domain.product.service; -import java.util.List; +import org.springframework.data.domain.Pageable; +import com.comatching.common.dto.response.PagingResponse; import com.comatching.item.domain.product.dto.PurchaseRequestDto; public interface AdminPaymentService { - List getPendingRequests(); + PagingResponse getPendingRequests(Pageable pageable); void approvePurchase(Long requestId, Long adminId); diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentServiceImpl.java index f016925..c8a4434 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/AdminPaymentServiceImpl.java @@ -1,13 +1,16 @@ package com.comatching.item.domain.product.service; import java.time.LocalDateTime; -import java.util.List; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.comatching.common.domain.enums.ItemRoute; import com.comatching.common.dto.item.AddItemRequest; +import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; import com.comatching.item.domain.item.service.ItemService; import com.comatching.item.domain.order.entity.Order; @@ -29,6 +32,8 @@ public class AdminPaymentServiceImpl implements AdminPaymentService { private static final String EXPIRE_REASON = "AUTO_EXPIRED_BEFORE_ADMIN_DECISION"; + private static final int MAX_PENDING_REQUEST_PAGE_SIZE = 100; + private static final Sort DEFAULT_PENDING_REQUEST_SORT = Sort.by(Sort.Direction.DESC, "requestedAt"); private final OrderRepository orderRepository; private final OrderGrantLedgerRepository orderGrantLedgerRepository; @@ -40,11 +45,25 @@ public class AdminPaymentServiceImpl implements AdminPaymentService { private static final int PURCHASED_ITEM_EXPIRE_DAYS = 36500; @Override - public List getPendingRequests() { - return orderRepository.findAllByStatusAndExpiresAtAfterOrderByRequestedAtDesc(OrderStatus.PENDING, LocalDateTime.now()) - .stream() - .map(PurchaseRequestDto::from) - .toList(); + public PagingResponse getPendingRequests(Pageable pageable) { + Pageable boundedPageable = toBoundedPageable(pageable); + return PagingResponse.from( + orderRepository.findAllByStatusAndExpiresAtAfter( + OrderStatus.PENDING, + LocalDateTime.now(), + boundedPageable + ).map(PurchaseRequestDto::from) + ); + } + + private Pageable toBoundedPageable(Pageable pageable) { + if (pageable == null || pageable.isUnpaged()) { + return PageRequest.of(0, MAX_PENDING_REQUEST_PAGE_SIZE, DEFAULT_PENDING_REQUEST_SORT); + } + + int pageSize = Math.min(pageable.getPageSize(), MAX_PENDING_REQUEST_PAGE_SIZE); + Sort sort = pageable.getSort().isSorted() ? pageable.getSort() : DEFAULT_PENDING_REQUEST_SORT; + return PageRequest.of(pageable.getPageNumber(), pageSize, sort); } @Override diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java index 7513c32..24fbb09 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java @@ -1,7 +1,8 @@ package com.comatching.item.infra.controller; -import java.util.List; - +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -14,6 +15,7 @@ import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; +import com.comatching.common.dto.response.PagingResponse; import com.comatching.item.domain.product.dto.PurchaseRequestDto; import com.comatching.item.domain.product.service.AdminPaymentService; @@ -30,10 +32,13 @@ public class AdminPaymentController { private final AdminPaymentService adminPaymentService; @RequireRole(MemberRole.ROLE_ADMIN) - @Operation(summary = "승인 대기 목록 조회", description = "아직 처리되지 않은(PENDING) 구매 요청 목록을 최신순으로 조회합니다.") + @Operation(summary = "승인 대기 목록 조회", description = "아직 처리되지 않은(PENDING) 구매 요청 목록을 페이지 단위로 최신순 조회합니다.") @GetMapping("/requests") - public ResponseEntity>> getPendingRequests(@CurrentMember MemberInfo memberInfo) { - return ResponseEntity.ok(ApiResponse.ok(adminPaymentService.getPendingRequests())); + public ResponseEntity>> getPendingRequests( + @CurrentMember MemberInfo memberInfo, + @PageableDefault(size = 20, sort = "requestedAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + return ResponseEntity.ok(ApiResponse.ok(adminPaymentService.getPendingRequests(pageable))); } @RequireRole(MemberRole.ROLE_ADMIN) diff --git a/item-service/src/test/java/com/comatching/item/domain/product/service/AdminPaymentServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/product/service/AdminPaymentServiceImplTest.java index c687ffe..cb1cc51 100644 --- a/item-service/src/test/java/com/comatching/item/domain/product/service/AdminPaymentServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/product/service/AdminPaymentServiceImplTest.java @@ -1,5 +1,6 @@ package com.comatching.item.domain.product.service; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -16,11 +17,16 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; import com.comatching.common.domain.enums.ItemRoute; import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.item.AddItemRequest; +import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; import com.comatching.item.domain.item.service.ItemService; import com.comatching.item.domain.order.entity.Order; @@ -31,6 +37,7 @@ import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.order.service.OrderDecisionLogService; import com.comatching.item.domain.order.service.OrderOutboxService; +import com.comatching.item.domain.product.dto.PurchaseRequestDto; import com.comatching.item.global.exception.PaymentErrorCode; @ExtendWith(MockitoExtension.class) @@ -55,6 +62,60 @@ class AdminPaymentServiceImplTest { @Mock private ItemService itemService; + @Test + @DisplayName("승인 대기 목록을 페이지 단위로 조회한다") + void shouldGetPendingRequestsWithPagination() { + // given + Pageable pageable = PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "requestedAt")); + Order firstOrder = Order.builder() + .memberId(11L) + .requestedItemName("매칭 패키지") + .requesterRealName("홍길동") + .requesterUsername("길동") + .requestedPrice(9900) + .expectedPrice(9900) + .requestedAt(java.time.LocalDateTime.now()) + .expiresAt(java.time.LocalDateTime.now().plusMinutes(10)) + .build(); + ReflectionTestUtils.setField(firstOrder, "id", 7L); + firstOrder.addOrderItem(OrderItem.builder().itemType(ItemType.MATCHING_TICKET).quantity(2).build()); + + given(orderRepository.findAllByStatusAndExpiresAtAfter(eq(OrderStatus.PENDING), any(), eq(pageable))) + .willReturn(new PageImpl<>(java.util.List.of(firstOrder), pageable, 5)); + + // when + PagingResponse response = adminPaymentService.getPendingRequests(pageable); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).requestId()).isEqualTo(7L); + assertThat(response.content().get(0).matchingTicketQty()).isEqualTo(2); + assertThat(response.currentPage()).isEqualTo(1); + assertThat(response.size()).isEqualTo(2); + assertThat(response.totalElements()).isEqualTo(5); + assertThat(response.totalPages()).isEqualTo(3); + then(orderRepository).should() + .findAllByStatusAndExpiresAtAfter(eq(OrderStatus.PENDING), any(), eq(pageable)); + } + + @Test + @DisplayName("승인 대기 목록 페이지 크기는 최대 100개로 제한한다") + void shouldCapPendingRequestPageSize() { + // given + Pageable pageable = PageRequest.of(0, 500, Sort.by(Sort.Direction.DESC, "requestedAt")); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + given(orderRepository.findAllByStatusAndExpiresAtAfter(eq(OrderStatus.PENDING), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(java.util.List.of(), PageRequest.of(0, 100), 0)); + + // when + adminPaymentService.getPendingRequests(pageable); + + // then + then(orderRepository).should() + .findAllByStatusAndExpiresAtAfter(eq(OrderStatus.PENDING), any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(100); + } + @Test @DisplayName("관리자 승인 시 주문 상태를 승인으로 바꾸고 구성품을 지급한다") void shouldApprovePendingOrderAndGrantRewards() {