From 4c62dbe64980a307997f0371142a51bce311f44f Mon Sep 17 00:00:00 2001 From: HeuJung Date: Tue, 4 Aug 2026 09:25:00 +0900 Subject: [PATCH] =?UTF-8?q?perf(ecommerce):=20=EC=A7=84=EC=97=B4=C2=B7?= =?UTF-8?q?=EC=A7=91=EA=B3=84=C2=B7=EA=B2=80=EC=83=89=EC=9D=98=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EC=8B=A4=ED=96=89=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=81=ED=95=9C=20=EA=B3=84=EC=95=BD=20=EC=86=8C=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 집계 — 판매량 정렬과 인기 상품이 ORDER BY 안의 상관 서브쿼리라 LIMIT 이 평가 범위를 막지 못했다. 전 상품을 평가한 뒤 정렬하던 것을 파생 테이블 조인으로 바꿨다. 리뷰 개수·평점도 상품마다 리뷰 표를 두 번씩 훑던 것을 한 번의 그룹 집계로 합쳤다. 대표 이미지는 이미 적재된 관계를 쓰지 않고 매번 관계 빌더로 조회해 상품당 2회씩 쿼리를 더 내고 있었다. 루프 안 쿼리 — 체크아웃의 항목별 옵션 조회, 게스트 장바구니 병합의 항목별 2쿼리, 주문 옵션의 항목별 find 를 일괄 조회로 옮겼다. 상품 대량 가격·재고 수정은 행마다 저장을 반복했는데, 두 번째 인자를 넘기던 오용 때문에 실제로는 바인딩이 어긋나 실행 자체가 실패하고 있었다. 이 두 메서드를 호출하는 테스트가 0건이라 전 스위트 green 상태로 통과하고 있었다. 카테고리 트리는 노드마다 집계 쿼리를 재귀 발행하던 것을 평면 1쿼리 + 조립으로 바꾸고 캐시했다. 거의 모든 쇼핑 화면이 읽는 값이라 분류·상품 변경 시 무효화한다. 검색 — 조건에 맞는 ID 전량을 PHP 로 적재한 뒤 무제한 whereIn 으로 다시 조회하던 경로를 없애고 키워드 술어를 페이지 쿼리에 직접 붙였다. 구체 엔진 직접 호출도 해석기 경유로 옮겨 플러그인 검색엔진이 상품·쿠폰·브랜드 검색에 적용된다. 쇼핑 첫 화면과 분류 화면은 각각 5요청·3요청을 한 번으로 합쳤다. 분류 화면이 함께 받던 분류 트리는 그 화면의 어떤 표현식도 읽지 않는 순수 낭비였다. 없는 분류 주소는 빈 화면 대신 404 로 처리해 검색엔진이 정상 페이지로 수집하지 않게 했다. 색인 — 전시 조건으로 좁힌 뒤 등록일·판매가로 정렬하는 목록과 주문 옵션 판매량 집계에 필요한 색인을 더했다. 신규 설치는 마이그레이션이, 기존 사이트는 업그레이드 스텝이 적용한다. 관리자 화면 — 총 건수가 잘린 목록에서 페이저가 접히던 자리와, 쿠폰 사용조건의 상품 검색이 추가 로드되지 않던 조건을 고쳤다. 같은 파일의 사전 결함(반복 렌더 안의 정적 id, Icon 박스 크기, 다크 모드 대비, 반복 변수 선언 오탈자)도 함께 처리했다. --- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 25 +- ...storefront_indexes_to_ecommerce_tables.php | 80 +++ .../sirsoft-ecommerce/docs/api/README.md | 1 + .../sirsoft-ecommerce/docs/api/storefront.md | 138 ++++ modules/_bundled/sirsoft-ecommerce/module.php | 2 + .../admin_ecommerce_product_review_index.json | 5 +- .../_transactions_table.json | 3 +- .../_partial_activity_log.json | 5 +- .../_partial_order_datagrid.json | 3 +- .../_partial_activity_log.json | 11 +- .../_partial_product_datagrid.json | 3 +- .../_partial_usage_conditions.json | 8 +- .../_modal_issue_history.json | 3 +- .../_partial_coupon_datagrid.json | 3 +- .../_tab_identity_policies.json | 8 +- .../_tab_notification_definitions.json | 3 +- .../_partial_datagrid.json | 3 +- .../Public/StorefrontController.php | 181 +++++ .../Public/PublicStorefrontRequest.php | 85 +++ .../src/Http/Resources/ProductCollection.php | 56 +- .../Http/Resources/ProductListResource.php | 23 +- .../Listeners/CategoryTreeCacheListener.php | 78 +++ .../src/Listeners/SearchProductsListener.php | 113 +++- .../sirsoft-ecommerce/src/Models/Category.php | 211 +++++- .../src/Repositories/BrandRepository.php | 4 +- .../src/Repositories/CartRepository.php | 46 ++ .../src/Repositories/CategoryRepository.php | 38 +- .../Contracts/CartRepositoryInterface.php | 23 + .../Contracts/CategoryRepositoryInterface.php | 17 +- .../Contracts/OrderRepositoryInterface.php | 19 + ...ditionalOptionValueRepositoryInterface.php | 11 + .../ProductOptionRepositoryInterface.php | 9 + .../Contracts/ProductRepositoryInterface.php | 27 +- .../Repositories/CouponIssueRepository.php | 2 + .../src/Repositories/CouponRepository.php | 48 +- .../MileageTransactionRepository.php | 9 + .../Repositories/OrderOptionRepository.php | 6 +- .../src/Repositories/OrderRepository.php | 137 +++- ...ProductAdditionalOptionValueRepository.php | 21 +- .../Repositories/ProductInquiryRepository.php | 5 + .../Repositories/ProductOptionRepository.php | 25 + .../src/Repositories/ProductRepository.php | 636 +++++++++++++----- .../Repositories/ProductReviewRepository.php | 3 + .../AdditionalOptionSelectionService.php | 86 ++- .../src/Services/CartService.php | 46 +- .../src/Services/CategoryService.php | 25 +- .../src/Services/ProductService.php | 57 +- .../src/Services/TempOrderService.php | 49 +- .../sirsoft-ecommerce/src/routes/api.php | 8 + .../Feature/EcommerceUserAbilitiesTest.php | 6 +- .../Feature/EcommerceUserPermissionTest.php | 6 +- .../Public/PublicProductControllerTest.php | 5 +- .../Feature/ListCacheInvalidationTest.php | 189 ++++++ .../ListQueryCountRegressionTest.php | 205 ++++++ .../StorefrontIndexCoverageTest.php | 269 ++++++++ .../StorefrontCategoryContractTest.php | 107 +++ .../Listeners/SearchProductsListenerTest.php | 136 +++- .../ProductBulkAdjustmentTest.php | 220 ++++++ .../tests/Unit/Services/CartServiceTest.php | 82 +-- .../Unit/Services/CategoryServiceTest.php | 14 +- .../upgrades/Upgrade_1_0_5.php | 4 + .../migrations/06_AddStorefrontIndexes.php | 77 +++ 62 files changed, 3283 insertions(+), 445 deletions(-) create mode 100644 modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php create mode 100644 modules/_bundled/sirsoft-ecommerce/docs/api/storefront.md create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/StorefrontController.php create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/PublicStorefrontRequest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Listeners/CategoryTreeCacheListener.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/ListCacheInvalidationTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/ListQueryCountRegressionTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/StorefrontIndexCoverageTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/StorefrontCategoryContractTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductBulkAdjustmentTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/06_AddStorefrontIndexes.php diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 256c59b5..76bf76db 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -32,6 +32,25 @@ - 관리자 주문 목록이 주문마다 배송 정보 전체를 함께 내려주지 않습니다. 목록은 대표 배송 1건(배송유형·배송방법·택배사·송장번호)만 표시하므로 그 1건만 읽으며, 택배사 이름도 함께 읽어 행마다 다시 조회하지 않습니다. 분할 배송이 많은 쇼핑몰일수록 목록이 가벼워집니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) - 배송정책 목록이 정책마다 국가별 배송 설정을 통째로 내려주지 않습니다. 목록에는 화면이 실제로 표시하는 값(국가·배송방법·부과정책·배송비·무료배송 기준액·도서산간 사용 여부·사용 여부)만 실리고, 구간별 배송비 표·도서산간 지역 설정·배송비 계산 API 설정은 빠집니다. 정책 하나에 국가 설정이 수백 건까지 달릴 수 있어 목록을 여는 것만으로 그만큼의 상세 설정이 전송됐습니다. 배송정책 관리 목록과 상품 등록/수정 화면의 표시 내용은 종전과 동일하며, 전체 설정이 필요한 외부 연동은 주소 뒤에 `?with_country_settings=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) - 마이페이지 주문내역 조회가 기본으로 주문마다 주문상품 전체를 내려주지 않습니다. 기본 응답에는 대표 상품 1건과 전체 개수만 실리며, 주문상품을 전부 나열하는 마이페이지 화면은 전량을 함께 요청하므로 보이는 내용은 종전과 동일합니다. 주문내역 조회를 직접 사용하는 외부 연동에서 상품 전체가 필요하면 주소 뒤에 `?with_items=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 통합 검색의 상품 결과를 최신순·가격순으로 볼 때 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 결과가 많아도 페이지를 넘길수록 느려지지 않습니다. +- 분류 단건 조회가 화면에서 쓰지 않는 상품 수·하위 분류 수 집계를 기본으로 계산하지 않도록 바꿨습니다. 분류 순서 변경처럼 그 값을 읽지 않는 작업이 빨라집니다. +- 쇼핑 첫 화면이 분류·상품목록·최근 본·인기·신상품을 각각 따로 요청하던 것을 한 번에 받도록 바꿨습니다. 화면에 보이는 내용은 동일하며 첫 화면이 뜨는 속도가 빨라집니다. +- 상품 분류 화면도 같은 방식으로 바꿨습니다. 예전에는 분류 정보·분류 목록·상품 목록을 각각 요청했고 그중 분류 목록은 화면에서 쓰지 않았습니다. 이제 한 번의 요청으로 필요한 것만 받습니다. 없는 분류 주소로 들어오면 빈 화면 대신 없는 페이지로 처리됩니다. +- 상품 목록의 판매량 정렬과 인기 상품 조회 방식을 개선했습니다. 예전에는 순서를 정하기 위해 전체 상품의 판매량을 매번 계산했지만, 이제 미리 묶어 둔 집계를 사용합니다. 상품이 많을수록 차이가 큽니다. +- 상품 목록의 리뷰 개수·평점도 같은 방식으로 한 번에 집계합니다. 예전에는 상품 한 건마다 리뷰 표를 두 번씩 훑었습니다. +- 주문·마일리지·쿠폰 발급내역·리뷰·1:1 문의 목록도 총 건수가 아주 많으면 "N건 이상" 으로 표시합니다. 페이지 이동은 끝까지 열려 있습니다. +- 상품·쿠폰 검색이 조건에 맞는 항목 전체를 먼저 메모리에 올린 뒤 다시 조회하던 방식을 없앴습니다. 검색 결과가 많아도 메모리 사용량이 늘지 않습니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.) +- 플러그인으로 다른 검색엔진을 연결하면 상품·쿠폰·브랜드 검색에도 그 엔진이 적용됩니다. 이전에는 연결한 엔진과 무관하게 기본 방식으로만 검색됐습니다. +- 상품 검색 결과의 총 건수가 아주 많으면 "N건 이상" 으로 표시하고, 다음 페이지 이동은 끝까지 열어 둡니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다. +- 기간으로 걸러 보는 목록(주문·쿠폰)의 날짜 조건을 색인을 활용하는 방식으로 바꿨습니다. +- 주문 관리 화면 상단의 통계를 짧게 저장해 두고 재사용합니다. 주문이 바뀌면 즉시 새로 계산됩니다. +- 상품 대량 수정(가격·재고)이 상품 수만큼 저장을 반복하던 것을 한 번에 처리하도록 바꿨습니다. +- 결제 진행과 비회원 장바구니 병합에서 항목마다 반복하던 조회를 한 번에 처리하도록 바꿨습니다. 장바구니에 담긴 상품이 많을수록 차이가 큽니다. +- 카테고리 트리를 만들 때 하위 분류마다 조회를 반복하던 것을 한 번에 읽어 조립하도록 바꿨습니다. 거의 모든 쇼핑 화면이 읽는 값이라 결과를 짧게 저장해 두고 재사용하며, 분류나 상품이 바뀌면 즉시 새로 계산됩니다. +- 상품 관리 화면 상단의 통계를 짧게 저장해 두고 재사용합니다. 상품·재고가 바뀌면 즉시 새로 계산됩니다. +- 상품 검색 탭의 배지 숫자도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. +- 상품 목록의 분류 경로 표시가 같은 조상 분류를 반복 조회하던 것을 한 번으로 줄였습니다. +- 상품 목록·주문 옵션 집계에 필요한 색인을 추가했습니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 데이터가 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 해당 데이터의 저장이 잠시 대기할 수 있습니다. - 실제로 사용되지 않던 설정 항목(상품·주문 목록의 페이지당 개수, 결제 자동취소 기한 중복 항목)을 정리했습니다. 미입금 자동취소 기한은 관리자 환경설정의 값 하나만 사용합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. - 상품 리뷰·마일리지 내역·쿠폰 발급 내역 목록도 같은 방식으로 뒤쪽 페이지 조회를 개선했습니다. 상품 리뷰는 작성일 정렬용 인덱스를 추가하고 기간 검색 조건을 인덱스가 활용되는 형태로 바꿔, 리뷰가 많은 쇼핑몰에서 목록과 기간 검색이 함께 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) @@ -57,6 +76,9 @@ - 리뷰 작성 기한 판정이 화면 표시와 실제 저장 가능 여부에서 서로 다른 기준을 쓸 수 있던 문제를 수정했습니다. 이제 두 곳이 같은 설정값을 사용하므로 "작성 가능"으로 보이는데 저장이 거부되는 일이 없습니다. - 관리자 환경설정에서 미입금 자동취소 기한을 한 번이라도 저장하면, 이후 모든 무통장입금·가상계좌 주문이 결제 처리 오류로 실패하던 문제를 수정했습니다. 저장된 기한 값의 형태 때문에 입금기한을 계산하지 못한 것으로, 이제 저장 형태와 무관하게 주문이 정상 생성됩니다. 기한이 비어 있거나 허용 범위(1~30일)를 벗어난 값이면 기본값 3일로 처리합니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.) - 설정 화면에서 숫자 항목(기한·수량·금액 등)을 저장할 때 값이 숫자가 아닌 형태로 보관되던 문제를 수정했습니다. 화면 표시는 같았지만 저장된 형태가 달라, 그 값을 사용하는 기능에서 오류가 날 수 있었습니다. +- 관리자 상품 목록의 일괄 가격 변경(정액·정률 증액/감액)과 일괄 재고 증감이 실행되지 않던 문제를 수정했습니다. 지정한 금액이나 수량이 반영되지 않고 오류가 발생했습니다. 값을 그대로 지정하는 방식(가격/재고를 특정 값으로 설정)은 영향이 없었습니다. +- 상품 리뷰·상품 문의 목록에서 총 건수를 끝까지 세지 못했을 때 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. +- 마이페이지의 주문 내역·마일리지 내역·위시리스트에서 총 건수를 끝까지 세지 못했을 때 페이지 이동 막대가 통째로 사라지던 문제를 수정했습니다. - 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 로그인하지 않은 상태로 장바구니에 담아 둔 상품이 로그인 후 사라지던 문제를 수정했습니다. 로그인 시 장바구니를 회원 계정으로 옮기는 처리가 실행되지 않아, 담아 둔 상품이 아무 안내 없이 없어졌습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 설정 화면이 허용하는 값과 저장 규칙이 서로 달라 저장이 거부되거나 그 반대가 되던 항목들을 맞췄습니다. 리뷰 이미지 최대 개수(최대 20개), 리뷰 이미지 최대 크기(정수 MB, 최대 50MB), 마일리지 기본 적립률(최대 100%), 미입금 자동취소 기한(최소 1일 — 0일은 주문 즉시 만료라 사용할 수 없는 값)이 대상입니다. 정수만 받는 항목은 입력칸의 증감 단위도 1로 맞춰, 화살표로 값을 올리다 소수가 되어 저장이 거부되는 일이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) @@ -110,7 +132,8 @@ - 쿠폰의 다른 항목만 고치는 경우에도 "1인당 사용 제한"을 함께 보내지 않으면 저장이 거절되던 문제를 수정했습니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) - 배송지를 수정할 때 주소를 통째로 비운 채 저장할 수 있던 문제를 수정했습니다. 국내 배송지는 우편번호와 주소가, 해외 배송지는 주소·도시·우편번호가 갖춰져야 저장됩니다. 기본 배송지 지정처럼 주소를 건드리지 않는 수정은 이전과 동일하게 저장됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) - 해외 배송지를 수정하면 도시·주·우편번호가 안내 없이 비워지던 문제를 수정했습니다. 이번 수정에서 다시 입력하지 않은 항목은 저장된 값이 그대로 유지됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) - +- 관리자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. 주문·상품·마일리지·쿠폰·쿠폰 발급 이력·상품 리뷰·배송 정책·활동 이력 목록이 해당하며, 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다. +- 쿠폰 사용조건의 상품 검색에서 결과가 많을 때 목록 끝까지 스크롤해도 다음 묶음이 더 불러와지지 않던 문제를 수정했습니다. ## [1.0.4] - 2026-07-16 ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php new file mode 100644 index 00000000..9a5a8267 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php @@ -0,0 +1,80 @@ + [테이블, 컬럼]] + * + * @var array}> + */ + private const TARGETS = [ + 'idx_products_display_created_id' => ['ecommerce_products', ['display_status', 'created_at', 'id']], + 'idx_products_display_price_id' => ['ecommerce_products', ['display_status', 'selling_price', 'id']], + 'idx_order_options_product_created_qty' => ['ecommerce_order_options', ['product_id', 'created_at', 'quantity']], + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + foreach (self::TARGETS as $indexName => [$table, $columns]) { + if (! Schema::hasTable($table)) { + continue; + } + + $existing = array_column(Schema::getIndexes($table), 'name'); + + if (in_array($indexName, $existing, true)) { + continue; + } + + Schema::table($table, function (Blueprint $blueprint) use ($columns, $indexName) { + $blueprint->index($columns, $indexName); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (self::TARGETS as $indexName => [$table, $columns]) { + if (! Schema::hasTable($table)) { + continue; + } + + $existing = array_column(Schema::getIndexes($table), 'name'); + + if (! in_array($indexName, $existing, true)) { + continue; + } + + Schema::table($table, function (Blueprint $blueprint) use ($indexName) { + $blueprint->dropIndex($indexName); + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/README.md b/modules/_bundled/sirsoft-ecommerce/docs/api/README.md index ea170343..73bc5334 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/README.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/README.md @@ -39,6 +39,7 @@ | [shipping-carriers.md](shipping-carriers.md) | `shipping-carriers` | 7 | | [shipping-country.md](shipping-country.md) | `shipping-country` | 2 | | [shipping-policies.md](shipping-policies.md) | `shipping-policies` | 11 | +| [storefront.md](storefront.md) | `storefront` | 1 | | [users.md](users.md) | `users` | 2 | | [wishlist.md](wishlist.md) | `wishlist` | 3 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/storefront.md b/modules/_bundled/sirsoft-ecommerce/docs/api/storefront.md new file mode 100644 index 00000000..d71f64ee --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/storefront.md @@ -0,0 +1,138 @@ +# Storefront API 레퍼런스 + +> **소유**: module `sirsoft-ecommerce` · 쇼핑 첫 화면이 필요한 데이터를 한 번에 돌려주는 통합 엔드포인트입니다. + +--- + +## TL;DR (5초 요약) + +```text +1. GET /api/modules/sirsoft-ecommerce/storefront — 분류·상품목록·최근본·인기·신상품 한 번에 (`category_slug` 지정 시 그 분류의 상세까지) +2. 각 묶음의 응답 형태는 개별 엔드포인트와 완전히 동일 (조립 위치만 서버로 이동) +3. 개별 엔드포인트도 그대로 남아 다른 화면에서 계속 사용 +4. products 묶음은 총 건수 정확도 메타(total_relation / total_is_exact / result_cap) 포함 +5. 진열 묶음 개수를 0 으로 주면 그 묶음은 조회하지 않음 +``` + +--- + +## 왜 통합 엔드포인트인가 + +쇼핑 첫 화면은 분류·상품 목록·최근 본·인기·신상품 다섯 묶음을 한 화면에 함께 그립니다. +이를 각각의 엔드포인트로 부르면 화면 하나에 요청이 다섯 번 나가고, 그 다섯 번이 전부 같은 +부팅(라우팅·미들웨어·권한 판정·설정 적재)을 되풀이합니다. + +각 묶음의 응답 형태(리소스 계약)는 개별 엔드포인트와 같습니다 — 옮긴 것은 조립 위치뿐이므로, +개별 엔드포인트를 쓰는 다른 화면은 영향을 받지 않습니다. + +분류 화면도 같은 이유로 이 엔드포인트를 씁니다. 종전에는 분류 상세·분류 트리·상품 목록으로 +요청이 세 번 나갔고, 그중 분류 트리는 그 화면의 어떤 표현식도 읽지 않는 낭비였습니다. +`category_slug` 를 주면 `category` 묶음에 그 분류의 상세가 함께 담겨 요청 한 번으로 끝납니다. + +--- + +### GET /api/modules/sirsoft-ecommerce/storefront + +- **라우트명**: `api.modules.sirsoft-ecommerce.storefront.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\StorefrontController@index` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| category_id | query | integer | 아니오 | — | 상품 목록 묶음의 카테고리 필터 (category 식별자) | +| category_slug | query | string | 아니오 | max 100 | 상품 목록 묶음의 카테고리 필터 (URL 친화 식별자). **지정하면 `category` 묶음에 그 분류의 상세가 함께 담깁니다** — 없는 분류면 404 | +| brand_id | query | integer | 아니오 | — | 상품 목록 묶음의 브랜드 필터 | +| search | query | string | 아니오 | max 200 | 상품 목록 묶음의 검색어 | +| sort | query | string | 아니오 | `latest`, `sales`, `price_asc`, `price_desc` | 상품 목록 묶음의 정렬 기준 | +| min_price | query | integer | 아니오 | min 0 | 판매가 하한 | +| max_price | query | integer | 아니오 | min 0 | 판매가 상한 | +| per_page | query | integer | 아니오 | min 1, max 100 | 상품 목록 묶음의 페이지당 항목 수 | +| page | query | integer | 아니오 | min 1 | 상품 목록 묶음의 페이지 번호 | +| popular_limit | query | integer | 아니오 | min 0, max 50 | 인기 상품 개수 (0 이면 조회하지 않음, 미지정 시 8) | +| new_limit | query | integer | 아니오 | min 0, max 50 | 신상품 개수 (0 이면 조회하지 않음, 미지정 시 8) | +| recent_ids | query | string | 아니오 | max 500 | 최근 본 상품 ID 목록 (쉼표 구분, 비면 조회하지 않음) | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.public_storefront_validation_rules`, `sirsoft-ecommerce.product.public_storefront_validation_messages`). + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/storefront?page=1&per_page=12&sort=latest&popular_limit=8&new_limit=8&recent_ids=12%2C34 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| category | object\|null | 지금 보고 있는 분류의 상세. `GET /categories/{slug}` 의 `data` 와 동일한 형태. `category_slug` 를 주지 않으면 `null` | +| categories | array | 공개 카테고리 트리. `GET /categories` 의 `data` 와 동일한 형태 | +| products | object | 상품 목록. `GET /products` 의 `data` 와 동일한 형태 (`data[]` + `pagination`) | +| recent_products | array | 최근 본 상품. `GET /products/recent` 의 `data` 와 동일한 형태 (`recent_ids` 가 비면 `[]`) | +| popular_products | array | 인기 상품. `GET /products/popular` 의 `data` 와 동일한 형태 (`popular_limit=0` 이면 `[]`) | +| new_products | array | 신상품. `GET /products/new` 의 `data` 와 동일한 형태 (`new_limit=0` 이면 `[]`) | + +`products.pagination` 은 표준 페이지네이션 메타에 총 건수 정확도가 더해진 형태입니다. + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| current_page | integer | 현재 페이지 번호 | +| per_page | integer | 페이지당 항목 수 | +| from / to | integer\|null | 현재 페이지의 첫/마지막 행 번호 | +| has_more_pages | boolean | 다음 페이지 존재 여부 (총 건수를 몰라도 정확) | +| total | integer | 총 건수 (상한 초과 시 상한값 = 하한 보증) | +| last_page | integer\|null | 마지막 페이지 번호. **총 건수가 부정확하면 `null`** | +| total_relation | string | `exact` 또는 `at_least` | +| total_is_exact | boolean | 총 건수가 정확한지 여부 | +| result_cap | integer\|null | 총 건수 집계에 적용된 상한 (무제한이면 `null`) | + +> 총 건수 상한과 페이지 이동의 관계는 [pagination.md](../../../../../docs/backend/pagination.md) 참조. + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 목록을 조회했습니다.", + "data": { + "category": null, + "categories": [], + "products": { + "data": [], + "pagination": { + "current_page": 1, + "per_page": 12, + "from": null, + "to": null, + "has_more_pages": false, + "total": 0, + "last_page": 1, + "total_relation": "exact", + "total_is_exact": true, + "result_cap": 10000 + } + }, + "recent_products": [], + "popular_products": [], + "new_products": [] + } +} +``` + +**오류 응답** + +| 상태 | 상황 | +| --- | --- | +| 404 | `category_slug` 가 가리키는 분류가 없거나 비활성 (200 + 빈 목록으로 내보내면 검색엔진이 없는 주소를 정상 페이지로 수집합니다) | +| 401 | 비회원에게 `sirsoft-ecommerce.user-products.read` 권한이 없음 | +| 403 | 인증 사용자에게 해당 권한이 없음 | +| 422 | 파라미터 검증 실패 (범위를 벗어난 `popular_limit` 등) | +| 500 | 조회 중 예외 — 응답 형태는 개별 상품 엔드포인트와 동일 | diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php index 37a4f155..97604235 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.php +++ b/modules/_bundled/sirsoft-ecommerce/module.php @@ -18,6 +18,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\ActivityLogDescriptionResolver; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultCurrencyOnRegisterListener; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultShippingCountryOnRegisterListener; use Modules\Sirsoft\Ecommerce\Listeners\CategoryActivityLogListener; +use Modules\Sirsoft\Ecommerce\Listeners\CategoryTreeCacheListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponRestoreListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener; @@ -2087,6 +2088,7 @@ class Module extends AbstractModule CouponUseListener::class, SeoProductCacheListener::class, SeoCategoryCacheListener::class, + CategoryTreeCacheListener::class, SeoSettingsCacheListener::class, MileageTransactionListener::class, UserMileageInfoListener::class, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json index 3c326c9b..fd68d536 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json @@ -2902,10 +2902,11 @@ { "type": "composite", "name": "Pagination", - "if": "{{(reviews.data?.meta?.last_page ?? 1) > 1}}", + "if": "{{((reviews.data?.meta?.last_page ?? 0) > 1) || (reviews.data?.meta?.has_more_pages === true) || ((reviews.data?.meta?.current_page ?? 1) > 1)}}", "props": { "currentPage": "{{reviews.data?.meta?.current_page ?? 1}}", - "totalPages": "{{reviews.data?.meta?.last_page ?? 1}}", + "totalPages": "{{reviews.data?.meta?.last_page ?? null}}", + "hasMorePages": "{{reviews.data?.meta?.has_more_pages ?? false}}", "className": "flex justify-center mt-4" }, "actions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_mileage_transaction_index/_transactions_table.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_mileage_transaction_index/_transactions_table.json index 0f85fa98..c1d797fd 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_mileage_transaction_index/_transactions_table.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_mileage_transaction_index/_transactions_table.json @@ -31,7 +31,8 @@ "pagination": true, "serverSidePagination": true, "serverCurrentPage": "{{transactions?.data?.pagination?.current_page ?? 1}}", - "serverTotalPages": "{{transactions?.data?.pagination?.last_page ?? 1}}", + "serverTotalPages": "{{transactions?.data?.pagination?.last_page ?? null}}", + "serverHasMorePages": "{{transactions?.data?.pagination?.has_more_pages ?? false}}", "pageSize": "{{query.per_page || 20}}", "alwaysShowPagination": true, "columns": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_activity_log.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_activity_log.json index e3b65495..1f3bc051 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_activity_log.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_activity_log.json @@ -216,7 +216,7 @@ "text": "$t:sirsoft-ecommerce.admin.order.detail.activity_log.empty" }, { - "id": "log_rows", + "id": "log_rows_{{logIdx}}", "type": "basic", "name": "Div", "iteration": { @@ -889,7 +889,8 @@ "name": "Pagination", "props": { "currentPage": "{{order_logs.data?.meta?.current_page ?? 1}}", - "totalPages": "{{order_logs.data?.meta?.last_page ?? 1}}", + "totalPages": "{{order_logs.data?.meta?.last_page ?? null}}", + "hasMorePages": "{{order_logs.data?.meta?.has_more_pages ?? false}}", "showFirstLast": true }, "actions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_list/_partial_order_datagrid.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_list/_partial_order_datagrid.json index cbd6948e..b6b07b15 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_list/_partial_order_datagrid.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_list/_partial_order_datagrid.json @@ -740,7 +740,8 @@ "pagination": true, "serverSidePagination": true, "serverCurrentPage": "{{orders?.data?.pagination?.current_page ?? 1}}", - "serverTotalPages": "{{orders?.data?.pagination?.last_page ?? 1}}", + "serverTotalPages": "{{orders?.data?.pagination?.last_page ?? null}}", + "serverHasMorePages": "{{orders?.data?.pagination?.has_more_pages ?? false}}", "pageSize": 20, "alwaysShowPagination": true, "rowActions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_partial_activity_log.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_partial_activity_log.json index cd534b55..a16a7d8c 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_partial_activity_log.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_form/_partial_activity_log.json @@ -330,7 +330,7 @@ }, "children": [ { - "id": "activity_log_row", + "id": "activity_log_row_{{logIdx}}", "type": "basic", "name": "Tr", "iteration": { @@ -671,7 +671,7 @@ "name": "Icon", "props": { "name": "clipboard-list", - "className": "w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" + "className": "text-3xl mx-auto mb-2 text-gray-300 dark:text-gray-600" } }, { @@ -703,7 +703,7 @@ }, "children": [ { - "id": "activity_log_card_item", + "id": "activity_log_card_item_{{logIdx}}", "type": "basic", "name": "Div", "iteration": { @@ -1039,7 +1039,7 @@ "name": "Icon", "props": { "name": "clipboard-list", - "className": "w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" + "className": "text-3xl mx-auto mb-2 text-gray-300 dark:text-gray-600" } }, { @@ -1064,7 +1064,8 @@ "name": "Pagination", "props": { "currentPage": "{{activity_logs.data?.meta?.current_page ?? 1}}", - "totalPages": "{{activity_logs.data?.meta?.last_page ?? 1}}", + "totalPages": "{{activity_logs.data?.meta?.last_page ?? null}}", + "hasMorePages": "{{activity_logs.data?.meta?.has_more_pages ?? false}}", "showFirstLast": true }, "actions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_list/_partial_product_datagrid.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_list/_partial_product_datagrid.json index f7430ad6..0e692e63 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_list/_partial_product_datagrid.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_list/_partial_product_datagrid.json @@ -557,7 +557,8 @@ "pagination": true, "serverSidePagination": true, "serverCurrentPage": "{{products?.data?.pagination?.current_page ?? 1}}", - "serverTotalPages": "{{products?.data?.pagination?.last_page ?? 1}}", + "serverTotalPages": "{{products?.data?.pagination?.last_page ?? null}}", + "serverHasMorePages": "{{products?.data?.pagination?.has_more_pages ?? false}}", "pageSize": 20, "alwaysShowPagination": true, "showColumnSelector": false, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json index afcb70f5..87e58a8a 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json @@ -282,7 +282,8 @@ "target": "_local", "productSearchResults": "{{response?.data?.data ?? response?.data ?? []}}", "productSearchPage": "{{response?.data?.pagination?.current_page ?? 1}}", - "productSearchLastPage": "{{response?.data?.pagination?.last_page ?? 1}}", + "productSearchLastPage": "{{response?.data?.pagination?.last_page ?? null}}", + "productSearchHasMore": "{{response?.data?.pagination?.has_more_pages ?? false}}", "productSearchTotal": "{{response?.data?.pagination?.total ?? 0}}" } } @@ -402,7 +403,7 @@ { "handler": "switch", "params": { - "value": "{{$event.target.scrollHeight - $event.target.scrollTop <= $event.target.clientHeight + 100 && (_local.productSearchPage ?? 1) < (_local.productSearchLastPage ?? 1) && !_local.productSearchIsLoadingMore}}" + "value": "{{$event.target.scrollHeight - $event.target.scrollTop <= $event.target.clientHeight + 100 && ((_local.productSearchHasMore === true) || ((_local.productSearchPage ?? 1) < (_local.productSearchLastPage ?? 0))) && !_local.productSearchIsLoadingMore}}" }, "cases": { "true": { @@ -446,7 +447,8 @@ "target": "_local", "productSearchResults": "{{[...(_local.productSearchResults ?? []), ...(response?.data?.data ?? response?.data ?? [])]}}", "productSearchPage": "{{response?.data?.pagination?.current_page ?? 1}}", - "productSearchLastPage": "{{response?.data?.pagination?.last_page ?? 1}}", + "productSearchLastPage": "{{response?.data?.pagination?.last_page ?? null}}", + "productSearchHasMore": "{{response?.data?.pagination?.has_more_pages ?? false}}", "productSearchTotal": "{{response?.data?.pagination?.total ?? 0}}", "productSearchIsLoadingMore": false } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_issue_history.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_issue_history.json index f2415111..52da3941 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_issue_history.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_modal_issue_history.json @@ -385,7 +385,8 @@ "name": "Pagination", "props": { "currentPage": "{{couponIssues?.data?.pagination?.current_page || 1}}", - "totalPages": "{{couponIssues?.data?.pagination?.last_page || 1}}", + "totalPages": "{{couponIssues?.data?.pagination?.last_page ?? null}}", + "hasMorePages": "{{couponIssues?.data?.pagination?.has_more_pages ?? false}}", "totalItems": "{{couponIssues?.data?.pagination?.total || 0}}", "perPage": "{{couponIssues?.data?.pagination?.per_page || 10}}" }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json index 768dd69e..44296856 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json @@ -232,7 +232,8 @@ "pagination": true, "serverSidePagination": true, "serverCurrentPage": "{{coupons?.data?.pagination?.current_page ?? 1}}", - "serverTotalPages": "{{coupons?.data?.pagination?.last_page ?? 1}}", + "serverTotalPages": "{{coupons?.data?.pagination?.last_page ?? null}}", + "serverHasMorePages": "{{coupons?.data?.pagination?.has_more_pages ?? false}}", "pageSize": "{{+(query.per_page || 10)}}", "alwaysShowPagination": true, "selectable": true, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json index d62033f9..f82bfecc 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json @@ -1047,7 +1047,7 @@ "props": { "className": "text-tertiary" }, - "text": "$t:admin.identity.policies.pagination_summary|total={{ecommerceIdentityPolicies?.data?.meta?.total ?? 0}}|page={{ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{ecommerceIdentityPolicies?.data?.meta?.last_page ?? 1}}" + "text": "$t:admin.identity.policies.pagination_summary|total={{ecommerceIdentityPolicies?.data?.meta?.total ?? 0}}|page={{ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{ecommerceIdentityPolicies?.data?.meta?.last_page ?? '-'}}" }, { "type": "basic", @@ -1100,14 +1100,14 @@ "props": { "className": "px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-200" }, - "text": "$t:admin.identity.policies.page_indicator|page={{ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{ecommerceIdentityPolicies?.data?.meta?.last_page ?? 1}}" + "text": "$t:admin.identity.policies.page_indicator|page={{ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{ecommerceIdentityPolicies?.data?.meta?.last_page ?? '-'}}" }, { "type": "basic", "name": "Button", "props": { "type": "button", - "disabled": "{{(ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1) >= (ecommerceIdentityPolicies?.data?.meta?.last_page ?? 1)}}", + "disabled": "{{ecommerceIdentityPolicies?.data?.meta?.last_page ? ((ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1) >= ecommerceIdentityPolicies?.data?.meta?.last_page) : (ecommerceIdentityPolicies?.data?.meta?.has_more_pages !== true)}}", "className": "px-3 py-1.5 text-xs font-medium bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed" }, "actions": [ @@ -1120,7 +1120,7 @@ "mergeQuery": true, "query": { "tab": "identity_policies", - "page": "{{Math.min(ecommerceIdentityPolicies?.data?.meta?.last_page ?? 1, (ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1) + 1)}}" + "page": "{{ecommerceIdentityPolicies?.data?.meta?.last_page ? Math.min(ecommerceIdentityPolicies?.data?.meta?.last_page, (ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1) + 1) : ((ecommerceIdentityPolicies?.data?.meta?.current_page ?? 1) + 1)}}" }, "transition_overlay_target": "policy_table_card", "scroll": { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json index 39dd4012..9bc531cf 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json @@ -713,7 +713,8 @@ "name": "Pagination", "props": { "currentPage": "{{ecommerceNotificationDefinitions?.data?.pagination?.current_page || 1}}", - "totalPages": "{{ecommerceNotificationDefinitions?.data?.pagination?.last_page || 1}}" + "totalPages": "{{ecommerceNotificationDefinitions?.data?.pagination?.last_page ?? null}}", + "hasMorePages": "{{ecommerceNotificationDefinitions?.data?.pagination?.has_more_pages ?? false}}" }, "actions": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json index bb33be43..e9ca5a27 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json @@ -17,7 +17,8 @@ "pagination": true, "serverSidePagination": true, "serverCurrentPage": "{{shipping_policies?.data?.pagination?.current_page ?? 1}}", - "serverTotalPages": "{{shipping_policies?.data?.pagination?.last_page ?? 1}}", + "serverTotalPages": "{{shipping_policies?.data?.pagination?.last_page ?? null}}", + "serverHasMorePages": "{{shipping_policies?.data?.pagination?.has_more_pages ?? false}}", "pageSize": "{{+(query.per_page || 20)}}", "alwaysShowPagination": true, "subRowClassName": "px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 whitespace-normal shadow-sm", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/StorefrontController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/StorefrontController.php new file mode 100644 index 00000000..15b5f321 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/StorefrontController.php @@ -0,0 +1,181 @@ +logApiUsage('storefront.index'); + + $validated = $request->validated(); + + $category = $this->resolveFocusedCategory($validated); + + if ($category === false) { + return ResponseHelper::notFound( + 'messages.categories.not_found', + [], + 'sirsoft-ecommerce' + ); + } + + return ResponseHelper::moduleSuccess( + 'sirsoft-ecommerce', + 'messages.products.fetch_success', + [ + 'category' => $category, + 'categories' => PublicCategoryResource::collection( + $this->categoryService->getPublicCategoryTree() + )->resolve(), + 'products' => $this->resolveProductList($request, $validated), + 'recent_products' => $this->resolveRecentProducts($validated), + 'popular_products' => $this->resolveDisplayGroup( + (int) ($validated['popular_limit'] ?? 8), + fn (int $limit) => $this->productService->getPopularProducts($limit) + ), + 'new_products' => $this->resolveDisplayGroup( + (int) ($validated['new_limit'] ?? 8), + fn (int $limit) => $this->productService->getNewProducts($limit) + ), + ] + ); + } catch (Exception $e) { + return ResponseHelper::moduleError( + 'sirsoft-ecommerce', + 'messages.products.fetch_failed', + 500 + ); + } + } + + /** + * 상품 목록 묶음을 조립합니다. + * + * 개별 상품 목록 엔드포인트와 같은 컬렉션을 쓰므로 페이지네이션 메타까지 형태가 같습니다. + * + * @param PublicStorefrontRequest $request 요청 객체 + * @param array $validated 검증된 입력 + * @return array 상품 목록 응답 데이터 + */ + private function resolveProductList(PublicStorefrontRequest $request, array $validated): array + { + $listKeys = ['category_id', 'category_slug', 'brand_id', 'search', 'sort', 'min_price', 'max_price']; + $filters = array_filter( + $validated, + fn ($value, $key) => in_array($key, $listKeys, true) && $value !== null && $value !== '', + ARRAY_FILTER_USE_BOTH + ); + + $perPage = (int) ($validated['per_page'] ?? 20); + $collection = new ProductCollection($this->productService->getPublicList($filters, $perPage)); + + return $collection->toArray($request); + } + + /** + * 특정 분류 화면이 필요로 하는 단건 분류를 조립합니다. + * + * 분류 화면은 "지금 보고 있는 분류" 하나와 전체 분류 트리, 그 분류의 상품 목록을 함께 + * 그린다. 단건 분류를 여기서 함께 돌려주면 그 화면도 요청 한 번으로 끝난다. + * `category_slug` 가 없으면(쇼핑 첫 화면) 조회하지 않는다. + * + * 응답 형태는 단건 분류 엔드포인트와 같은 리소스를 써서 완전히 일치시킨다 — + * 옮긴 것은 조립 위치뿐이므로 화면 바인딩이 그대로 동작해야 한다. + * + * 반환값이 false 면 "분류를 지정했는데 그런 분류가 없다" 는 뜻이다. 이 경우 호출자가 + * 404 로 끊는다 — 200 으로 빈 화면을 내보내면 검색엔진이 존재하지 않는 분류 주소를 + * 정상 페이지로 수집한다. + * + * @param array $validated 검증된 입력 + * @return array|null|false 분류 응답 데이터 / 미지정 시 null / 대상 없음 시 false + */ + private function resolveFocusedCategory(array $validated): array|null|false + { + $slug = trim((string) ($validated['category_slug'] ?? '')); + + if ($slug === '') { + return null; + } + + $category = $this->categoryService->getPublicCategoryBySlug($slug); + + return $category ? (new PublicCategoryDetailResource($category))->resolve() : false; + } + + /** + * 최근 본 상품 묶음을 조립합니다. + * + * 최근 본 상품은 클라이언트가 보관한 ID 목록으로만 정해지므로, 비어 있으면 조회하지 않습니다. + * + * @param array $validated 검증된 입력 + * @return array 상품 목록 (없으면 빈 배열) + */ + private function resolveRecentProducts(array $validated): array + { + $ids = array_values(array_filter(array_map( + 'intval', + explode(',', (string) ($validated['recent_ids'] ?? '')) + ))); + + if (empty($ids)) { + return []; + } + + return ProductListResource::collection( + $this->productService->getProductsByIds($ids) + )->resolve(); + } + + /** + * 진열 묶음(인기·신상품)을 조립합니다. + * + * 개수가 0 이면 그 묶음을 쓰지 않겠다는 뜻이므로 조회하지 않습니다. + * + * @param int $limit 조회 개수 + * @param callable $resolver 상품 조회 클로저 + * @return array 상품 목록 (없으면 빈 배열) + */ + private function resolveDisplayGroup(int $limit, callable $resolver): array + { + if ($limit <= 0) { + return []; + } + + return ProductListResource::collection($resolver($limit))->resolve(); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/PublicStorefrontRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/PublicStorefrontRequest.php new file mode 100644 index 00000000..4d51dbcf --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/PublicStorefrontRequest.php @@ -0,0 +1,85 @@ + ['nullable', 'integer'], + 'category_slug' => ['nullable', 'string', 'max:100'], + 'brand_id' => ['nullable', 'integer'], + 'search' => ['nullable', 'string', 'max:200'], + 'sort' => ['nullable', 'string', 'in:latest,sales,price_asc,price_desc'], + 'min_price' => ['nullable', 'integer', 'min:0'], + 'max_price' => ['nullable', 'integer', 'min:0'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + + // 진열 묶음 + 'popular_limit' => ['nullable', 'integer', 'min:0', 'max:50'], + 'new_limit' => ['nullable', 'integer', 'min:0', 'max:50'], + 'recent_ids' => ['nullable', 'string', 'max:500'], + ]; + + return HookManager::applyFilters('sirsoft-ecommerce.product.public_storefront_validation_rules', $rules, $this); + } + + /** + * 검증 에러 메시지 정의 + * + * @return array 검증 메시지 + */ + public function messages(): array + { + $messages = [ + 'category_id.integer' => __('sirsoft-ecommerce::validation.public_product.category_id.integer'), + 'category_slug.string' => __('sirsoft-ecommerce::validation.public_product.category_slug.string'), + 'category_slug.max' => __('sirsoft-ecommerce::validation.public_product.category_slug.max'), + 'brand_id.integer' => __('sirsoft-ecommerce::validation.public_product.brand_id.integer'), + 'search.string' => __('sirsoft-ecommerce::validation.public_product.search.string'), + 'search.max' => __('sirsoft-ecommerce::validation.public_product.search.max'), + 'sort.in' => __('sirsoft-ecommerce::validation.public_product.sort.in'), + 'min_price.integer' => __('sirsoft-ecommerce::validation.public_product.min_price.integer'), + 'min_price.min' => __('sirsoft-ecommerce::validation.public_product.min_price.min'), + 'max_price.integer' => __('sirsoft-ecommerce::validation.public_product.max_price.integer'), + 'max_price.min' => __('sirsoft-ecommerce::validation.public_product.max_price.min'), + 'per_page.integer' => __('sirsoft-ecommerce::validation.public_product.per_page.integer'), + 'per_page.min' => __('sirsoft-ecommerce::validation.public_product.per_page.min'), + 'per_page.max' => __('sirsoft-ecommerce::validation.public_product.per_page.max'), + 'popular_limit.integer' => __('sirsoft-ecommerce::validation.public_product.limit.integer'), + 'popular_limit.max' => __('sirsoft-ecommerce::validation.public_product.limit.max'), + 'new_limit.integer' => __('sirsoft-ecommerce::validation.public_product.limit.integer'), + 'new_limit.max' => __('sirsoft-ecommerce::validation.public_product.limit.max'), + 'recent_ids.string' => __('sirsoft-ecommerce::validation.public_product.ids.string'), + 'recent_ids.max' => __('sirsoft-ecommerce::validation.public_product.ids.max'), + ]; + + return HookManager::applyFilters('sirsoft-ecommerce.product.public_storefront_validation_messages', $messages, $this); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductCollection.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductCollection.php index 9f1e7a03..ea3f2571 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductCollection.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductCollection.php @@ -5,8 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources; use App\Http\Resources\BaseApiCollection; use App\Http\Resources\Traits\HasAbilityCheck; use Illuminate\Http\Request; -use Illuminate\Pagination\LengthAwarePaginator; use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices; +use Modules\Sirsoft\Ecommerce\Models\Category; /** * 상품 컬렉션 리소스 @@ -40,26 +40,33 @@ class ProductCollection extends BaseApiCollection */ public function toArray(Request $request): array { - $result = [ + $this->prefetchCategoryAncestors(); + + return [ 'data' => $this->mapWithRowNumber(function ($product) { return (new ProductListResource($product))->resolve(request()); }), 'abilities' => $this->resolveAbilitiesFromMap($this->abilityMap(), $request->user()), + // 표준 메타를 쓴다. 상한형 페이지에서는 total_relation/result_cap 이 함께 실리고, + // 총 건수를 정확히 알 수 없으면 last_page 가 null 로 나간다. + ...$this->paginationMeta(), ]; + } - if ($this->resource instanceof LengthAwarePaginator) { - $result['pagination'] = [ - 'current_page' => $this->resource->currentPage(), - 'last_page' => $this->resource->lastPage(), - 'per_page' => $this->resource->perPage(), - 'total' => $this->resource->total(), - 'from' => $this->resource->firstItem(), - 'to' => $this->resource->lastItem(), - 'has_more_pages' => $this->resource->hasMorePages(), - ]; - } - - return $result; + /** + * 이번 응답이 그릴 분류 경로의 조상 카테고리를 한 번에 예열합니다. + * + * 상품마다 예열하면 예열 쿼리가 상품 수만큼 반복됩니다. 카테고리는 이미 적재돼 + * 있으므로 path 를 메모리에서 읽어 조상 조회를 응답당 1회로 고정합니다. + */ + private function prefetchCategoryAncestors(): void + { + Category::prefetchAncestorsFor( + $this->collection + ->filter(fn ($product) => $product->relationLoaded('categories')) + ->flatMap(fn ($product) => $product->categories) + ->unique('id') + ); } /** @@ -70,27 +77,16 @@ class ProductCollection extends BaseApiCollection */ public function withStatistics(array $statistics = []): array { - $result = [ + $this->prefetchCategoryAncestors(); + + return [ 'data' => $this->mapWithRowNumber(function ($product) { return (new ProductListResource($product))->resolve(request()); }), 'abilities' => $this->resolveAbilitiesFromMap($this->abilityMap(), request()->user()), 'statistics' => $statistics, + ...$this->paginationMeta(), ]; - - if ($this->resource instanceof LengthAwarePaginator) { - $result['pagination'] = [ - 'current_page' => $this->resource->currentPage(), - 'last_page' => $this->resource->lastPage(), - 'per_page' => $this->resource->perPage(), - 'total' => $this->resource->total(), - 'from' => $this->resource->firstItem(), - 'to' => $this->resource->lastItem(), - 'has_more_pages' => $this->resource->hasMorePages(), - ]; - } - - return $result; } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductListResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductListResource.php index 30883bc7..b09edbdc 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductListResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductListResource.php @@ -64,12 +64,23 @@ class ProductListResource extends BaseApiResource ])), 'primary_category' => $this->whenLoaded('categories', fn () => $this->categories->firstWhere('pivot.is_primary', true)?->getLocalizedName() ), - 'categories_with_path' => $this->whenLoaded('categories', fn () => $this->categories->map(fn ($cat) => [ - 'id' => $cat->id, - 'path' => $cat->getBreadcrumb(), - 'path_string' => collect($cat->getBreadcrumb())->pluck('name')->implode(' > '), - 'is_primary' => $cat->pivot->is_primary, - ])), + // 경로는 카테고리당 한 번만 만든다. 두 번 부르면 조상 조회도 두 번 나간다. + // + // 조상 예열은 여기가 아니라 ProductCollection 이 응답 단위로 한 번 수행한다. + // 상품마다 예열하면 예열 쿼리가 상품 수만큼 반복된다. 단건 사용처(컬렉션을 + // 거치지 않는 경로)에서는 getBreadcrumb() 이 스스로 필요한 조상만 읽는다. + 'categories_with_path' => $this->whenLoaded('categories', function () { + return $this->categories->map(function ($cat) { + $breadcrumb = $cat->getBreadcrumb(); + + return [ + 'id' => $cat->id, + 'path' => $breadcrumb, + 'path_string' => collect($breadcrumb)->pluck('name')->implode(' > '), + 'is_primary' => $cat->pivot->is_primary, + ]; + }); + }), // 브랜드 (다국어) 'brand_name' => $this->whenLoaded('brand', fn () => $this->brand?->getLocalizedName()), diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CategoryTreeCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CategoryTreeCacheListener.php new file mode 100644 index 00000000..d4467fc8 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CategoryTreeCacheListener.php @@ -0,0 +1,78 @@ + 'flush', 'priority' => 10]; + } + + // 상품 변경 — 트리에 실리는 상품 수 집계가 달라진다 + foreach ([ + 'sirsoft-ecommerce.product.after_create', + 'sirsoft-ecommerce.product.after_update', + 'sirsoft-ecommerce.product.after_delete', + 'sirsoft-ecommerce.product.after_bulk_update', + ] as $hook) { + $hooks[$hook] = ['method' => 'flush', 'priority' => 10]; + } + + return $hooks; + } + + /** + * 기본 훅 핸들러 (HookListenerInterface 필수 메서드) + * + * @param mixed ...$args 훅 인자 + */ + public function handle(...$args): void + { + $this->flush(); + } + + /** + * 카테고리 트리 캐시를 비웁니다. + * + * @param mixed ...$args 훅 인자 (사용하지 않음 — 어떤 변경이든 트리 전체가 대상) + */ + public function flush(...$args): void + { + try { + Category::flushTreeCache(); + } catch (\Throwable $e) { + // 캐시 무효화 실패가 본 작업(상품/카테고리 저장)을 되돌리게 두지 않는다. + // TTL 이 안전망으로 남아 있으므로 낡은 트리는 스스로 만료된다. + Log::warning('카테고리 트리 캐시 무효화 실패', ['message' => $e->getMessage()]); + } + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php index 4e81f347..0b636843 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SearchProductsListener.php @@ -3,6 +3,9 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\TotalRelation; +use App\Search\SearchCategoryPayload; +use App\Support\Query\BoundedCount; use App\Helpers\PermissionHelper; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices; @@ -53,7 +56,7 @@ class SearchProductsListener implements HookListenerInterface * 훅 이벤트를 처리합니다. * Filter Hook은 getSubscribedHooks에서 지정한 메서드를 직접 호출하므로 이 메서드는 사용되지 않습니다. * - * @param mixed ...$args 훅에서 전달된 인수들 + * @param mixed ...$args 훅에서 전달된 인수들 * @return void */ public function handle(...$args): void @@ -65,7 +68,7 @@ class SearchProductsListener implements HookListenerInterface /** * 검색 파라미터 validation rules 추가 * - * @param array $rules 기존 validation rules + * @param array $rules 기존 validation rules * @return array 이커머스 모듈 파라미터가 추가된 rules */ public function addValidationRules(array $rules): array @@ -80,8 +83,8 @@ class SearchProductsListener implements HookListenerInterface /** * 상품 검색을 수행하고 결과를 반환합니다. * - * @param array $results 기존 검색 결과 - * @param array $context 검색 컨텍스트 (q, type, sort, page, per_page, user, request) + * @param array $results 기존 검색 결과 + * @param array $context 검색 컨텍스트 (q, type, sort, page, per_page, user, request) * @return array 상품이 추가된 검색 결과 */ public function searchProducts(array $results, array $context): array @@ -107,27 +110,62 @@ class SearchProductsListener implements HookListenerInterface $perPage = $context['per_page'] ?? 10; try { - // 다른 탭인 경우 count만 반환 - if (!$isRelevantTab) { - $results['products'] = [ - 'total' => $this->productService->countByKeyword($q, $categoryId), - 'items' => [], - ]; + // 다른 탭인 경우 count만 반환. + // 배지에도 정확도를 함께 싣는다 — 상한에 걸려 잘린 값을 정확한 것처럼 + // 내보내면 화면에는 그냥 틀린 숫자로만 보인다. + if (! $isRelevantTab) { + // 배지를 그리지 않는 화면은 건수를 요청하지 않는다 — 그 경우 집계를 생략한다. + if (! ($context['include_inactive_counts'] ?? true)) { + $results['products'] = SearchCategoryPayload::fromCountOnly( + new BoundedCount(0, TotalRelation::Exact, null) + ); + + return $results; + } + + $results['products'] = SearchCategoryPayload::fromCountOnly( + $this->productService->countByKeyword($q, $categoryId) + ); return $results; } - $offset = ($page - 1) * $perPage; - $searchResult = $this->productService->searchByKeyword($q, $sort, $categoryId, $offset, $perPage); + // 전체 탭은 미리보기 몇 건만, 상품 탭은 실제 요청 페이지를 그대로 조회한다. + // 종전에는 전체 탭에서도 페이지 분량을 받아 PHP 에서 다시 잘랐다. + $isAllTab = ($type === 'all'); + $fetchPerPage = $isAllTab ? (int) ($context['all_tab_limit'] ?? 5) : (int) $perPage; + $offset = $isAllTab ? 0 : (((int) $page - 1) * (int) $perPage); - $items = $searchResult['items']->map( - fn ($product) => $this->formatProductResult($product, $q) - )->toArray(); + /** 조회 결과 항목을 화면 형태로 가공한다. */ + $format = fn (iterable $items): array => collect($items) + ->map(fn ($product) => $this->formatProductResult($product, $q)) + ->toArray(); - $results['products'] = [ - 'total' => $searchResult['total'], - 'items' => $items, - ]; + // 커서를 받았고 그 정렬을 커서로 처리할 수 있으면 키셋으로 응답한다. + // 전체 탭은 미리보기 몇 건뿐이라 깊은 페이지가 없어 대상이 아니다. + $cursorPage = $isAllTab + ? null + : $this->productService->searchByKeywordWithCursor( + $q, + $sort, + $categoryId, + $fetchPerPage, + $context['cursor'] ?? null + ); + + if ($cursorPage !== null) { + $results['products'] = SearchCategoryPayload::fromCursor( + $cursorPage, + $this->productService->countByKeyword($q, $categoryId), + $format($cursorPage->items()) + ); + + return $results; + } + + $searchPage = $this->productService->searchByKeyword($q, $sort, $categoryId, $offset, $fetchPerPage); + + $results['products'] = SearchCategoryPayload::fromBounded($searchPage, $format($searchPage->items())); } catch (\Exception $e) { Log::error('Search products error', [ 'message' => $e->getMessage(), @@ -141,14 +179,14 @@ class SearchProductsListener implements HookListenerInterface /** * 상품 검색 결과를 프론트엔드 응답 구조로 변환합니다. * - * @param array $response 기존 응답 구조 - * @param array $results 검색 결과 (core.search.results에서 반환된 데이터) - * @param array $context 검색 컨텍스트 + * @param array $response 기존 응답 구조 + * @param array $results 검색 결과 (core.search.results에서 반환된 데이터) + * @param array $context 검색 컨텍스트 * @return array 상품 응답이 추가된 구조 */ public function buildProductsResponse(array $response, array $results, array $context): array { - if (!isset($results['products'])) { + if (! isset($results['products'])) { return $response; } @@ -157,20 +195,27 @@ class SearchProductsListener implements HookListenerInterface $page = $context['page'] ?? 1; $perPage = $context['per_page'] ?? 10; + // 항목은 이미 요청한 분량만 조회돼 있다 — 여기서 다시 자르지 않는다. $items = $productsData['items'] ?? []; $total = $productsData['total'] ?? 0; if ($type === 'all') { - $allTabLimit = $context['all_tab_limit'] ?? 5; - $response['products'] = array_slice($items, 0, $allTabLimit); + $response['products'] = $items; } elseif ($type === 'products') { $response['products'] = $items; $response['current_page'] = $page; $response['per_page'] = $perPage; - $response['last_page'] = max(1, (int) ceil($total / $perPage)); + // 총 건수가 상한에 걸리면 마지막 페이지를 계산할 수 없다 — null 로 알린다. + $response['last_page'] = $productsData['last_page'] ?? null; + $response['has_more_pages'] = $productsData['has_more_pages'] ?? false; + // 커서 응답이면 다음/이전 커서를 함께 실어 깊은 페이지를 OFFSET 없이 넘긴다. + $response['next_cursor'] = $productsData['next_cursor'] ?? null; + $response['prev_cursor'] = $productsData['prev_cursor'] ?? null; } $response['products_count'] = $total; + $response['products_total_is_exact'] = $productsData['total_is_exact'] ?? true; + $response['products_total_relation'] = $productsData['total_relation'] ?? null; return $response; } @@ -180,8 +225,8 @@ class SearchProductsListener implements HookListenerInterface /** * 상품을 검색 결과 형식으로 변환합니다. * - * @param object $product 상품 모델 - * @param string $keyword 검색어 + * @param object $product 상품 모델 + * @param string $keyword 검색어 * @return array 변환된 상품 데이터 */ private function formatProductResult(object $product, string $keyword): array @@ -229,8 +274,8 @@ class SearchProductsListener implements HookListenerInterface /** * 텍스트에서 검색어를 하이라이트 처리합니다. * - * @param string|null $text 원본 텍스트 - * @param string $keyword 검색어 + * @param string|null $text 원본 텍스트 + * @param string $keyword 검색어 * @return string 하이라이트 처리된 텍스트 */ private function highlightKeyword(?string $text, string $keyword): string @@ -247,9 +292,9 @@ class SearchProductsListener implements HookListenerInterface /** * 본문에서 키워드 주변 텍스트를 추출합니다. * - * @param string|null $content 본문 내용 - * @param string $keyword 검색어 - * @param int $length 추출할 최대 길이 + * @param string|null $content 본문 내용 + * @param string $keyword 검색어 + * @param int $length 추출할 최대 길이 * @return string 추출된 미리보기 텍스트 */ private function extractContentPreview(?string $content, string $keyword, int $length = 150): string @@ -274,4 +319,4 @@ class SearchProductsListener implements HookListenerInterface return $preview.(mb_strlen($plainText) > $length ? '...' : ''); } -} \ No newline at end of file +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Category.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Category.php index ee1bdcce..a91e28e5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/Category.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Category.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Models; use App\Casts\AsUnicodeJson; +use App\Contracts\Extension\CacheInterface; use App\Extension\HookManager; use App\Search\Contracts\FulltextSearchable; use Illuminate\Database\Eloquent\Builder; @@ -176,6 +177,20 @@ class Category extends Model implements FulltextSearchable return $ids; } + /** + * 조상 카테고리 예열 캐시 (요청 스코프) + * + * @var array + */ + private static array $ancestorPrefetch = []; + + /** + * 트리 캐시 TTL (초) + * + * 정상 경로의 무효화는 훅이 담당하므로 이 값은 훅이 닿지 않는 변경에 대한 안전망이다. + */ + private const TREE_CACHE_TTL = 600; + /** * 조상 카테고리들 조회 * @@ -189,10 +204,95 @@ class Category extends Model implements FulltextSearchable return new Collection; } - $ancestors = self::whereIn('id', $ancestorIds)->get(); - $orderedIds = array_flip($ancestorIds); + // 한 응답에서 여러 카테고리의 조상을 그리는 경우(상품 목록의 분류 경로 등) + // 같은 조상을 반복해 조회하지 않도록, 미리 예열해 둔 맵을 먼저 본다. + $prefetched = self::$ancestorPrefetch; + $missing = array_values(array_filter($ancestorIds, fn ($id) => ! isset($prefetched[$id]))); - return $ancestors->sortBy(fn ($item) => $orderedIds[$item->id] ?? PHP_INT_MAX)->values(); + if (! empty($missing)) { + foreach (self::whereIn('id', $missing)->get() as $fetched) { + self::$ancestorPrefetch[$fetched->id] = $fetched; + } + } + + $ancestors = new Collection; + + foreach ($ancestorIds as $id) { + if (isset(self::$ancestorPrefetch[$id])) { + $ancestors->push(self::$ancestorPrefetch[$id]); + } + } + + return $ancestors->values(); + } + + /** + * 조상 카테고리를 미리 한 번에 적재합니다. + * + * 한 응답이 여러 카테고리의 경로를 그릴 때, 조상 조회가 카테고리 수만큼 반복되지 않도록 + * 호출자가 먼저 예열합니다. 예열하지 않아도 동작은 같고 쿼리 수만 달라집니다. + * + * @param array $categoryIds 경로를 그릴 카테고리 ID 목록 + */ + public static function prefetchAncestors(array $categoryIds): void + { + $categoryIds = array_values(array_unique(array_filter($categoryIds))); + + if (empty($categoryIds)) { + return; + } + + self::warmAncestorsFromPaths( + self::whereIn('id', $categoryIds)->get(['id', 'path']) + ); + } + + /** + * 이미 적재된 카테고리 모델들로 조상을 예열합니다. + * + * `path` 가 메모리에 이미 있으므로 그 값을 다시 읽는 쿼리를 내지 않습니다. + * 상품 목록처럼 응답 전체가 같은 조상 집합을 공유하는 자리에서는 컬렉션 단위로 + * 한 번만 부르면 되며, 그러면 조상 조회가 응답당 1회로 고정됩니다. + * + * @param iterable $categories path 가 적재된 카테고리들 + */ + public static function prefetchAncestorsFor(iterable $categories): void + { + self::warmAncestorsFromPaths(new Collection($categories instanceof Collection ? $categories->all() : $categories)); + } + + /** + * path 문자열에서 조상 ID 를 뽑아 한 번에 적재합니다. + * + * @param Collection $categories path 가 있는 카테고리들 + */ + private static function warmAncestorsFromPaths(Collection $categories): void + { + $ancestorIds = $categories + ->flatMap(fn ($category) => array_filter(array_map('intval', explode('/', (string) $category->path)))) + ->unique() + ->reject(fn ($id) => isset(self::$ancestorPrefetch[$id])) + ->values() + ->all(); + + if (empty($ancestorIds)) { + return; + } + + foreach (self::whereIn('id', $ancestorIds)->get() as $ancestor) { + self::$ancestorPrefetch[$ancestor->id] = $ancestor; + } + } + + /** + * 조상 예열 캐시를 비웁니다. + * + * 캐시 수명은 요청 스코프입니다. 같은 프로세스에서 카테고리를 바꾼 뒤 다시 경로를 + * 그려야 하는 경우(테스트·콘솔 작업)에 호출합니다. + */ + public static function flushAncestorPrefetch(): void + { + self::$ancestorPrefetch = []; } /** @@ -300,20 +400,91 @@ class Category extends Model implements FulltextSearchable } /** - * 트리 구조로 카테고리 조회 (재귀) + * 트리 구조로 카테고리 조회 + * + * 종전에는 노드마다 자식을 다시 조회해, 카테고리 수만큼 쿼리(각각 상품 수 집계 서브쿼리 + * 포함)가 발행됐다. 지금은 대상 전체를 한 번에 읽고 PHP 에서 부모–자식을 잇는다. + * + * 결과는 캐시한다 — 카테고리는 자주 바뀌지 않는데 거의 모든 쇼핑 화면이 이 트리를 읽는다. + * 무효화는 카테고리·상품 변경 지점이 담당한다({@see self::flushTreeCache()}). + * 트리에 상품 수 집계가 함께 실리므로 상품 변경도 무효화 대상이다. * * @param int|null $parentId 부모 ID (null이면 루트부터) * @param bool $onlyActive 활성 카테고리만 조회할지 여부 - * @return Collection + * @return Collection 트리 구조 카테고리 컬렉션 */ public static function getTree(?int $parentId = null, bool $onlyActive = false): Collection + { + $key = self::treeCacheKey($parentId, $onlyActive); + + $cached = app(CacheInterface::class)->get($key); + + if ($cached instanceof Collection) { + return $cached; + } + + $tree = self::buildTree($parentId, $onlyActive); + + // TTL 은 무효화 훅이 닿지 않는 경로(직접 SQL 수정 등)에 대한 안전망일 뿐, + // 정상 경로의 반영 지연은 flushTreeCache() 가 없앤다. + app(CacheInterface::class)->put($key, $tree, self::TREE_CACHE_TTL); + + return $tree; + } + + /** + * 카테고리 트리 캐시를 전부 비웁니다. + * + * 캐시 키는 (부모, 활성여부) 조합이라 변경 하나가 어느 조합에 영향을 주는지 + * 특정하기 어렵다 — 조합 수가 적으므로 전량을 비운다. + */ + public static function flushTreeCache(): void + { + $cache = app(CacheInterface::class); + + foreach ([true, false] as $onlyActive) { + $cache->forget(self::treeCacheKey(null, $onlyActive)); + } + + // 부모 지정 트리는 그 부모 ID 로 키가 갈린다. 태그를 지원하지 않는 드라이버가 + // 있으므로 존재하는 카테고리 ID 만 훑어 지운다 (카테고리 수는 수백 단위). + foreach (self::query()->pluck('id') as $id) { + foreach ([true, false] as $onlyActive) { + $cache->forget(self::treeCacheKey((int) $id, $onlyActive)); + } + } + } + + /** + * 트리 캐시 키를 만듭니다. + * + * @param int|null $parentId 부모 ID + * @param bool $onlyActive 활성만 여부 + * @return string 캐시 키 + */ + private static function treeCacheKey(?int $parentId, bool $onlyActive): string + { + return sprintf( + 'sirsoft-ecommerce.category.tree.%s.%s', + $parentId === null ? 'root' : $parentId, + $onlyActive ? 'active' : 'all' + ); + } + + /** + * 대상 전체를 한 번에 읽어 트리를 조립합니다. + * + * @param int|null $parentId 루트로 삼을 부모 ID + * @param bool $onlyActive 활성 카테고리만 포함할지 여부 + * @return Collection 트리 구조 카테고리 컬렉션 + */ + private static function buildTree(?int $parentId, bool $onlyActive): Collection { $query = self::with([ 'images', 'parent:id,name,slug', // parent에서 필요한 필드만 선택 ]) ->withCount('products') - ->where('parent_id', $parentId) ->orderBy('sort_order') ->orderBy('id'); @@ -321,13 +492,31 @@ class Category extends Model implements FulltextSearchable $query->where('is_active', true); } - $categories = $query->get(); + $all = $query->get(); + $byParent = $all->groupBy(fn (self $category) => $category->parent_id === null ? '' : (string) $category->parent_id); - foreach ($categories as $category) { - $category->setRelation('children', self::getTree($category->id, $onlyActive)); - } + // 방문 집합으로 유한 종료를 보장한다 — 오염된 데이터(순환 참조)에서도 멈춘다. + $visited = []; - return $categories; + $attach = function (?int $currentParentId) use (&$attach, $byParent, &$visited): Collection { + $children = $byParent->get($currentParentId === null ? '' : (string) $currentParentId) ?? new Collection; + + $resolved = new Collection; + + foreach ($children as $child) { + if (isset($visited[$child->id])) { + continue; + } + + $visited[$child->id] = true; + $child->setRelation('children', $attach($child->id)); + $resolved->push($child); + } + + return $resolved; + }; + + return $attach($parentId); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/BrandRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/BrandRepository.php index b484aa41..a046d562 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/BrandRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/BrandRepository.php @@ -3,7 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Helpers\PermissionHelper; -use App\Search\Engines\DatabaseFulltextEngine; +use App\Search\KeywordSearch; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Modules\Sirsoft\Ecommerce\Models\Brand; @@ -47,7 +47,7 @@ class BrandRepository implements BrandRepositoryInterface $keyword = $filters['search']; $query->where(function ($q) use ($keyword) { - DatabaseFulltextEngine::whereFulltext($q, 'name', $keyword, 'and'); + KeywordSearch::apply($q, 'name', $keyword, 'and'); $q->orWhere('slug', 'like', "%{$keyword}%") ->orWhere('website', 'like', "%{$keyword}%"); }); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CartRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CartRepository.php index c50c80d6..64fb4cbb 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CartRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CartRepository.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection as SupportCollection; use Modules\Sirsoft\Ecommerce\Models\Cart; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CartRepositoryInterface; @@ -128,6 +129,32 @@ class CartRepository implements CartRepositoryInterface ->get(); } + /** + * 회원 장바구니에서 여러 옵션의 라인을 한 번에 조회해 옵션별로 묶어 반환합니다. + * + * 비회원 장바구니 병합처럼 여러 항목을 한꺼번에 다루는 경로에서, 항목마다 조회하면 + * 로그인 한 번에 항목 수만큼 쿼리가 난다. + * + * @param int $userId 회원 ID + * @param array $productOptionIds 옵션 ID 목록 + * @return SupportCollection 옵션 ID ⇒ 장바구니 라인 컬렉션 + */ + public function findAllByUserAndOptions(int $userId, array $productOptionIds): SupportCollection + { + $productOptionIds = array_values(array_unique(array_filter($productOptionIds))); + + if (empty($productOptionIds)) { + return new SupportCollection; + } + + // groupBy 는 Support\Collection 을 돌려준다 (Eloquent\Collection 이 아니다). + return $this->model + ->where('user_id', $userId) + ->whereIn('product_option_id', $productOptionIds) + ->get() + ->groupBy('product_option_id'); + } + /** * {@inheritDoc} */ @@ -226,6 +253,25 @@ class CartRepository implements CartRepositoryInterface return (int) $query->sum('quantity'); } + /** + * {@inheritDoc} + */ + public function sumQuantityByProducts(array $productIds, int $userId): array + { + if (empty($productIds)) { + return []; + } + + return $this->model + ->whereIn('product_id', $productIds) + ->where('user_id', $userId) + ->groupBy('product_id') + ->selectRaw('product_id, SUM(quantity) as total_quantity') + ->pluck('total_quantity', 'product_id') + ->map(fn ($total) => (int) $total) + ->all(); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php index b0357fd8..374a0388 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php @@ -90,7 +90,7 @@ class CategoryRepository implements CategoryRepositoryInterface /** * {@inheritDoc} */ - public function findById(int $id, array $with = []): ?Category + public function findById(int $id, array $with = [], bool $withCounts = false): ?Category { $query = $this->model->newQuery(); @@ -98,12 +98,29 @@ class CategoryRepository implements CategoryRepositoryInterface $query->with($with); } - // 상품 수 및 자식 수 카운트 추가 - $query->withCount(['products', 'children']); + // 상품 수 및 자식 수 카운트는 화면에서만 쓴다. 재정렬 같은 쓰기 루프는 이 값을 + // 읽지 않으므로, 무조건 계산하면 항목마다 집계 서브쿼리가 두 개씩 따라붙는다. + if ($withCounts) { + $query->withCount(['products', 'children']); + } return $query->find($id); } + /** + * {@inheritDoc} + */ + public function findByIdsKeyed(array $ids): Collection + { + $ids = array_values(array_unique(array_filter($ids))); + + if (empty($ids)) { + return new Collection; + } + + return $this->model->newQuery()->whereIn('id', $ids)->get()->keyBy('id'); + } + /** * {@inheritDoc} */ @@ -117,7 +134,9 @@ class CategoryRepository implements CategoryRepositoryInterface */ public function update(int $id, array $data): Category { - $category = $this->findById($id); + // 재정렬은 항목 수만큼 이 메서드를 부른다. 집계를 켜 두면 항목마다 상품 수·자식 수 + // 서브쿼리가 두 개씩 붙지만 쓰기 경로는 그 값을 읽지 않는다. + $category = $this->findById($id, withCounts: false); $category->update($data); return $category->fresh(); @@ -128,7 +147,7 @@ class CategoryRepository implements CategoryRepositoryInterface */ public function delete(int $id): bool { - $category = $this->findById($id); + $category = $this->findById($id, withCounts: false); return $category->delete(); } @@ -146,7 +165,8 @@ class CategoryRepository implements CategoryRepositoryInterface */ public function getProductCount(int $id): int { - $category = $this->findById($id); + // 아래에서 products()->count() 를 직접 세므로 withCount 집계는 버려진다 + $category = $this->findById($id, withCounts: false); return $category->products()->count(); } @@ -180,7 +200,7 @@ class CategoryRepository implements CategoryRepositoryInterface /** * {@inheritDoc} */ - public function findBySlug(string $slug, array $with = []): ?Category + public function findBySlug(string $slug, array $with = [], bool $withCounts = false): ?Category { $query = $this->model->newQuery(); @@ -188,7 +208,9 @@ class CategoryRepository implements CategoryRepositoryInterface $query->with($with); } - $query->withCount(['products', 'children']); + if ($withCounts) { + $query->withCount(['products', 'children']); + } return $query->where('slug', $slug)->first(); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CartRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CartRepositoryInterface.php index 69abe685..b252ddb6 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CartRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CartRepositoryInterface.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as SupportCollection; use Modules\Sirsoft\Ecommerce\Models\Cart; /** @@ -89,6 +90,15 @@ interface CartRepositoryInterface */ public function findAllByUserAndOption(int $userId, int $productOptionId): Collection; + /** + * 회원 장바구니에서 여러 옵션의 라인을 한 번에 조회해 옵션별로 묶어 반환합니다. + * + * @param int $userId 회원 ID + * @param array $productOptionIds 옵션 ID 목록 + * @return SupportCollection 옵션 ID ⇒ 장바구니 라인 컬렉션 + */ + public function findAllByUserAndOptions(int $userId, array $productOptionIds): SupportCollection; + /** * cart_key와 옵션 ID로 동일 옵션 비회원 장바구니 아이템 전체 조회 * @@ -160,6 +170,19 @@ interface CartRepositoryInterface */ public function sumQuantityByProduct(int $productId, ?int $userId, ?string $cartKey, ?int $excludeCartId = null): int; + /** + * 회원 장바구니에서 여러 상품의 수량 합계를 한 번에 조회합니다. + * + * 여러 항목을 한꺼번에 다루는 경로(비회원 장바구니 병합 등)에서 항목마다 합계를 + * 조회하면 항목 수만큼 쿼리가 납니다. 시작 시점의 합계를 한 번 읽어 오고, 이후 + * 변동분은 호출자가 메모리에서 누적합니다. + * + * @param array $productIds 상품 ID 목록 + * @param int $userId 회원 ID + * @return array 상품 ID ⇒ 수량 합계 (없는 상품은 키 자체가 없음) + */ + public function sumQuantityByProducts(array $productIds, int $userId): array; + /** * 보관기간이 지난 장바구니 항목을 삭제합니다 (마지막 활동 updated_at 기준). * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php index 6762b532..ec246f24 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php @@ -24,9 +24,10 @@ interface CategoryRepositoryInterface * * @param int $id 카테고리 ID * @param array $with Eager loading 관계 + * @param bool $withCounts 상품 수·자식 수 집계 포함 여부 (쓰기 경로는 false) * @return Category|null 카테고리 모델 (없으면 null) */ - public function findById(int $id, array $with = []): ?Category; + public function findById(int $id, array $with = [], bool $withCounts = false): ?Category; /** * 카테고리 생성 @@ -91,9 +92,21 @@ interface CategoryRepositoryInterface * * @param string $slug 카테고리 slug * @param array $with Eager loading 관계 + * @param bool $withCounts 상품 수·자식 수 집계 포함 여부 (쓰기 경로는 false) * @return Category|null 카테고리 모델 (없으면 null) */ - public function findBySlug(string $slug, array $with = []): ?Category; + public function findBySlug(string $slug, array $with = [], bool $withCounts = false): ?Category; + + /** + * ID 목록으로 카테고리를 조회하고 ID 키 맵으로 반환합니다. + * + * 재정렬처럼 여러 항목을 한꺼번에 다루는 경로에서 항목마다 조회하지 않도록 합니다. + * 상품 수·자식 수 집계는 포함하지 않습니다 (쓰기 경로는 읽지 않음). + * + * @param array $ids 카테고리 ID 목록 + * @return Collection 카테고리 ID ⇒ 카테고리 + */ + public function findByIdsKeyed(array $ids): Collection; /** * 평면 리스트로 카테고리 목록 조회 (TagInput 등에 사용) diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php index 540f407e..8ab419d6 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php @@ -113,6 +113,14 @@ interface OrderRepositoryInterface */ public function getStatistics(): array; + /** + * 주문 통계 캐시를 무효화합니다. + * + * 주문이 바뀌면 통계도 곧바로 달라져야 하므로, 저장소 밖에서 주문을 변경하는 + * 경로도 이 메서드로 캐시를 비웁니다. + */ + public function forgetStatisticsCache(): void; + /** * 사용자별 주문상태 통계 조회 * @@ -164,6 +172,17 @@ interface OrderRepositoryInterface */ public function findByIdsKeyed(array $ids): Collection; + /** + * ID 목록으로 주문을 관계와 함께 조회하고 ID 키 맵으로 반환합니다. + * + * 주문 ID 를 하나씩 돌며 조회하면 대상 수만큼 쿼리가 납니다. + * + * @param array $ids 주문 ID 목록 + * @param array $with eager load 할 관계 + * @return Collection 주문 ID ⇒ 주문 + */ + public function findByIdsWithRelationsKeyed(array $ids, array $with = []): Collection; + /** * ID 목록으로 주문 스냅샷(배열)을 조회하고 ID 키 맵으로 반환합니다 (ChangeDetector용). * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductAdditionalOptionValueRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductAdditionalOptionValueRepositoryInterface.php index 15459498..dfbf3fb3 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductAdditionalOptionValueRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductAdditionalOptionValueRepositoryInterface.php @@ -30,6 +30,17 @@ interface ProductAdditionalOptionValueRepositoryInterface */ public function getActiveByProductKeyed(int $productId): Collection; + /** + * 여러 상품의 활성 선택지를 상품 ID → (value_id 키 맵) 형태로 한 번에 조회합니다. + * + * 장바구니·바로구매처럼 항목이 여러 개인 경로에서 상품마다 조회하지 않기 위한 + * 일괄 진입점입니다. + * + * @param array $productIds 상품 ID 목록 + * @return Collection> 상품 ID 키 맵 + */ + public function getActiveByProductIdsKeyed(array $productIds): Collection; + /** * 추가옵션 그룹 ID 목록에 속한 모든 선택지를 삭제합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductOptionRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductOptionRepositoryInterface.php index eead86d9..248224e4 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductOptionRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductOptionRepositoryInterface.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as SupportCollection; use Modules\Sirsoft\Ecommerce\Models\ProductOption; /** @@ -45,6 +46,14 @@ interface ProductOptionRepositoryInterface */ public function getByProductId(int $productId): Collection; + /** + * 여러 상품의 옵션을 한 번에 조회해 상품별로 묶어 반환합니다. + * + * @param array $productIds 상품 ID 목록 + * @return SupportCollection 상품 ID ⇒ 옵션 컬렉션 + */ + public function getByProductIds(array $productIds): SupportCollection; + /** * 옵션 판매가 일괄 변경 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php index 4a3e65b8..592717bd 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php @@ -2,6 +2,9 @@ namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts; +use App\Support\Query\BoundedCount; +use App\Support\Query\BoundedPage; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Modules\Sirsoft\Ecommerce\Models\Product; @@ -149,6 +152,13 @@ interface ProductRepositoryInterface */ public function getStatistics(): array; + /** + * 상품 통계 캐시를 무효화합니다. + * + * 상품이 바뀌면 통계도 곧바로 달라져야 합니다. + */ + public function forgetStatisticsCache(): void; + /** * 상품 코드로 상품 조회 * @@ -218,6 +228,17 @@ interface ProductRepositoryInterface */ public function findByIds(array $ids): Collection; + /** + * ID 목록으로 상품을 관계와 함께 조회해 ID 키 맵으로 반환합니다. + * + * 항목마다 `find()` 를 부르는 루프를 없애기 위한 일괄 진입점입니다. + * + * @param array $ids 상품 ID 배열 + * @param array $relations 함께 적재할 관계 + * @return Collection 상품 ID 키 맵 + */ + public function findByIdsWithRelationsKeyed(array $ids, array $relations = []): Collection; + /** * ID 목록으로 상품을 조회하고 ID 키 맵으로 반환합니다 (bulk activity log lookup). * @@ -246,16 +267,16 @@ interface ProductRepositoryInterface * @param int $limit 조회할 최대 항목 수 * @return array{total: int, items: Collection} */ - public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', ?int $categoryId = null, int $offset = 0, int $limit = 10): array; + public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', ?int $categoryId = null, int $offset = 0, int $limit = 10): BoundedPage; /** * 키워드와 일치하는 공개 상품 수를 조회합니다. * * @param string $keyword 검색 키워드 * @param int|null $categoryId 카테고리 필터 (null이면 전체) - * @return int 일치하는 상품 수 + * @return BoundedCount 일치하는 상품 수 (정확도 포함) */ - public function countByKeyword(string $keyword, ?int $categoryId = null): int; + public function countByKeyword(string $keyword, ?int $categoryId = null): BoundedCount; /** * 상품 재고를 옵션 재고 합계와 동기화 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php index 874dd4b5..734efe08 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Support\Query\PaginationLimits; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -179,6 +180,7 @@ class CouponIssueRepository implements CouponIssueRepositoryInterface // 제3자 확장이 쿠폰 원본 정의(쿠폰명·할인율·사용조건)를 읽고 있을 수 있어 현행 유지 // (PO 결정 2026-08-04). 번들 템플릿에는 아직 대응 마이페이지 화면이 없다. relations: ['coupon'], + resultCap: PaginationLimits::resultCap('ecommerce.coupon_issues'), ); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php index ca45dd6b..33745ee8 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php @@ -3,8 +3,12 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Helpers\PermissionHelper; +use App\Repositories\Concerns\FiltersByDateRange; use App\Repositories\Concerns\PaginatesWithDeferredJoin; use App\Repositories\Concerns\ResolvesSortSpec; +use App\Search\KeywordSearch; +use App\Support\Query\BoundedPaginator; +use App\Support\Query\PaginationLimits; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -20,6 +24,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponRepositoryInterface; */ class CouponRepository implements CouponRepositoryInterface { + use FiltersByDateRange; use PaginatesWithDeferredJoin; use ResolvesSortSpec; @@ -55,20 +60,19 @@ class CouponRepository implements CouponRepositoryInterface // all → FULLTEXT(name+description) + creator 보조필드 union (Scout queryCallback total // 재계산 시 orWhereHas('creator') 가 MATCH 절 없이 재적용되어 total=0 이 되는 결함 회피) if ($searchField === 'all') { - $ftIds = Coupon::search($keyword)->keys()->all(); + // FULLTEXT 매칭과 작성자 보조 매칭을 하나의 WHERE 그룹으로 묶어 페이지 쿼리에 + // 직접 넣는다. 종전에는 매칭 ID 전량을 PHP 로 끌어와 whereIn 으로 되먹여, + // 매칭이 많을수록 메모리와 IN(...) 목록이 함께 커졌다. + $query->where(function ($group) use ($keyword) { + // 정제는 코어 헬퍼가 단독 수행 — BOOLEAN MODE 연산자 입력이 500 이 되지 않는다. + // 쿠폰 테이블도 컬럼별 단일 FULLTEXT 인덱스다 — 복합 MATCH 는 쓸 수 없다. + KeywordSearch::applyAny($group, ['name', 'description'], $keyword); - $auxIds = $this->model->newQuery() - ->whereHas('creator', function ($creatorQuery) use ($keyword) { + $group->orWhereHas('creator', function ($creatorQuery) use ($keyword) { $creatorQuery->where('name', 'like', "%{$keyword}%") ->orWhere('email', 'like', "%{$keyword}%"); - }) - ->pluck('id') - ->all(); - - $matchedIds = array_values(array_unique([...$ftIds, ...$auxIds])); - - // 매칭 ID 로 조건 한정 (빈 매칭은 존재하지 않는 ID 로 빈 결과 → total=0 이 정상) - $query->whereIn('id', $matchedIds ?: [0]); + }); + }); } elseif ($searchField === 'name') { // 컬럼 한정 검색 — Scout(FULLTEXT)는 컬럼 한정 미지원이므로 단일컬럼 LIKE $query->where('name', 'like', "%{$keyword}%"); @@ -111,7 +115,14 @@ class CouponRepository implements CouponRepositoryInterface // audit:allow repository-paginate-column-pruning reason: 쿠폰 "정의" 목록 — // 행 수가 운영자가 만든 쿠폰 수에 묶인다(발급 이력은 별도 테이블). description 외에 넓은 컬럼이 없다 - return $query->paginate($perPage); + // + // 키워드 검색은 FULLTEXT + creator 보조 매칭이라 매칭 수가 데이터 증가에 비례한다. + // 총 건수는 상한까지만 세고, "다음" 이동은 per_page + 1 실측으로 끝까지 열어 둔다. + return BoundedPaginator::paginate( + $query, + perPage: $perPage, + resultCap: PaginationLimits::resultCap('ecommerce.coupons'), + ); } /** @@ -199,6 +210,7 @@ class CouponRepository implements CouponRepositoryInterface perPage: $perPage, // 관계 로드 (사용처 주문번호 표시를 위해 order 도 로드) relations: ['user:id,uuid,name', 'order:id,order_number'], + resultCap: PaginationLimits::resultCap('ecommerce.coupon_issues'), ); } @@ -331,26 +343,26 @@ class CouponRepository implements CouponRepositoryInterface // 등록일 필터 if (! empty($filters['created_start_date'])) { - $query->whereDate('created_at', '>=', $filters['created_start_date']); + $this->applyDateRangeFilter($query, 'created_at', $filters['created_start_date'], null); } if (! empty($filters['created_end_date'])) { - $query->whereDate('created_at', '<=', $filters['created_end_date']); + $this->applyDateRangeFilter($query, 'created_at', null, $filters['created_end_date']); } // 유효기간 필터 if (! empty($filters['valid_start_date'])) { - $query->whereDate('valid_from', '>=', $filters['valid_start_date']); + $this->applyDateRangeFilter($query, 'valid_from', $filters['valid_start_date'], null); } if (! empty($filters['valid_end_date'])) { - $query->whereDate('valid_to', '<=', $filters['valid_end_date']); + $this->applyDateRangeFilter($query, 'valid_to', null, $filters['valid_end_date']); } // 발급기간 필터 if (! empty($filters['issue_start_date'])) { - $query->whereDate('issue_from', '>=', $filters['issue_start_date']); + $this->applyDateRangeFilter($query, 'issue_from', $filters['issue_start_date'], null); } if (! empty($filters['issue_end_date'])) { - $query->whereDate('issue_to', '<=', $filters['issue_end_date']); + $this->applyDateRangeFilter($query, 'issue_to', null, $filters['issue_end_date']); } // 등록자 필터 (UUID → 정수 FK 변환) diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php index 76b8a6e9..d7dbd913 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Models\User; use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Support\Query\PaginationLimits; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -62,6 +63,9 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf public function getActiveLotsForUpdate(int $userId, string $currency): Collection { // 잠금 순서 고정 — 항상 트랜잭션 내에서 호출 (만료 임박 순) + // audit:allow query-unbounded-get reason: 차감 대상 lot 을 만료 임박 순으로 소진해야 + // 하므로 잔액이 있는 활성 lot 전량이 한 트랜잭션에 필요하다. 상한을 걸면 뒤쪽 lot 이 + // 차감에서 누락돼 잔액이 틀어진다. 모수는 한 사용자·한 통화의 미소진 lot 으로 한정된다 return MileageTransaction::query() ->forUserCurrency($userId, $currency) ->active() @@ -261,6 +265,7 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf outerUsing: fn (Builder $outer) => $outer ->addSelect('*') ->addSelect(['expired_amount' => $expiredAmount]), + resultCap: PaginationLimits::resultCap('admin.mileage_transactions'), ); } @@ -318,6 +323,7 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf columns: ['*'], sort: [['column' => 'id', 'direction' => 'desc']], perPage: $perPage, + resultCap: PaginationLimits::resultCap('admin.mileage_transactions'), ); } @@ -447,6 +453,9 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf */ public function getActiveLotsForUser(int $userId): Collection { + // audit:allow query-unbounded-get reason: 한 사용자의 잔액 산정용 활성 lot 전량 — + // 잔액은 미소진 lot 의 합이라 일부만 읽으면 값이 틀린다. 소진된 lot 은 active() + // 스코프에서 빠지므로 모수가 사용 이력에 비례해 늘지 않는다 return MileageTransaction::query() ->where('user_id', $userId) ->active() diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderOptionRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderOptionRepository.php index af17e316..ee80dc99 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderOptionRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderOptionRepository.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; +use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; @@ -220,6 +221,9 @@ class OrderOptionRepository implements OrderOptionRepositoryInterface return $this->model->newQuery() ->join($ordersTable, "{$ordersTable}.id", '=', "{$optionsTable}.order_id") ->whereNull("{$ordersTable}.deleted_at") - ->whereDate("{$ordersTable}.ordered_at", $date); + ->whereBetween("{$ordersTable}.ordered_at", [ + CarbonImmutable::parse($date)->startOfDay(), + CarbonImmutable::parse($date)->endOfDay(), + ]); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php index 0f367e31..9abf1c48 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php @@ -2,12 +2,15 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; +use App\Contracts\Extension\CacheInterface; use App\Helpers\PermissionHelper; use App\Models\ActivityLog; use App\Models\User; +use App\Repositories\Concerns\FiltersByDateRange; use App\Repositories\Concerns\PaginatesWithDeferredJoin; use App\Repositories\Concerns\ResolvesSortSpec; use App\Repositories\Concerns\SortsByRelatedColumn; +use App\Support\Query\PaginationLimits; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -26,6 +29,17 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; */ class OrderRepository implements OrderRepositoryInterface { + /** + * 주문 통계 캐시 키 + */ + private const STATISTICS_CACHE_KEY = 'ecommerce.orders.statistics'; + + /** + * 주문 통계 캐시 태그 + */ + private const STATISTICS_CACHE_TAG = 'ecommerce.orders'; + + use FiltersByDateRange; use PaginatesWithDeferredJoin; use ResolvesSortSpec; use SortsByRelatedColumn; @@ -262,12 +276,14 @@ class OrderRepository implements OrderRepositoryInterface if (! empty($filters['date_type']) && (! empty($filters['start_date']) || ! empty($filters['end_date']))) { $dateField = $filters['date_type']; // ordered_at, paid_at, etc. - if (! empty($filters['start_date'])) { - $query->whereDate($dateField, '>=', $filters['start_date']); - } - if (! empty($filters['end_date'])) { - $query->whereDate($dateField, '<=', $filters['end_date']); - } + // whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 같은 결과를 내는 + // 범위 조건으로 준다 (종료일은 그날 끝까지 포함). + $this->applyDateRangeFilter( + $query, + $dateField, + $filters['start_date'] ?? null, + $filters['end_date'] ?? null + ); } // 주문상태 필터 (다중 선택 가능) @@ -410,6 +426,7 @@ class OrderRepository implements OrderRepositoryInterface OrderStatusEnum::CANCELLED->value, ), ], + resultCap: PaginationLimits::resultCap('admin.orders'), ); } @@ -418,7 +435,10 @@ class OrderRepository implements OrderRepositoryInterface */ public function create(array $data): Order { - return $this->model->create($data); + $order = $this->model->create($data); + $this->forgetStatisticsCache(); + + return $order; } /** @@ -427,6 +447,7 @@ class OrderRepository implements OrderRepositoryInterface public function update(Order $order, array $data): Order { $order->update($data); + $this->forgetStatisticsCache(); return $order->fresh(); } @@ -436,7 +457,10 @@ class OrderRepository implements OrderRepositoryInterface */ public function delete(Order $order): bool { - return $order->delete(); + $deleted = $order->delete(); + $this->forgetStatisticsCache(); + + return $deleted; } /** @@ -444,12 +468,16 @@ class OrderRepository implements OrderRepositoryInterface */ public function bulkUpdateStatus(array $ids, string $status): int { - return $this->model + $affected = $this->model ->whereIn('id', $ids) ->update([ 'order_status' => $status, 'updated_at' => now(), ]); + + $this->forgetStatisticsCache(); + + return $affected; } /** @@ -521,6 +549,48 @@ class OrderRepository implements OrderRepositoryInterface * {@inheritDoc} */ public function getStatistics(): array + { + // 이 통계는 주문 목록 화면과 함께 매 페이지 조회된다. 값이 초 단위로 달라질 필요는 + // 없으므로 짧게 캐시해 같은 집계가 페이지를 넘길 때마다 다시 돌지 않게 한다. + // 주문이 바뀌면 즉시 무효화되므로 화면이 낡은 수치를 보여 주지 않는다. + if (! g7_core_settings('cache.stats_enabled', true)) { + return $this->computeStatistics(); + } + + $ttl = (int) g7_core_settings('cache.stats_ttl', 1800); + + if ($ttl <= 0) { + return $this->computeStatistics(); + } + + return app(CacheInterface::class)->remember( + self::STATISTICS_CACHE_KEY, + fn () => $this->computeStatistics(), + $ttl, + [self::STATISTICS_CACHE_TAG] + ); + } + + /** + * 주문 통계 캐시를 무효화합니다. + * + * 주문이 생성·수정·삭제되면 통계도 곧바로 달라져야 합니다. 쓰기 경로가 이 메서드를 + * 부르지 않으면 화면이 TTL 이 끝날 때까지 이전 수치를 보여 줍니다. + */ + public function forgetStatisticsCache(): void + { + app(CacheInterface::class)->forget(self::STATISTICS_CACHE_KEY); + } + + /** + * 주문 통계를 실제로 집계합니다. + * + * 날짜 조건은 전부 범위 조건으로 준다 — whereDate/whereYear/whereMonth 는 컬럼에 + * 함수를 씌워 ordered_at 인덱스를 쓸 수 없게 만든다. + * + * @return array 주문 통계 + */ + private function computeStatistics(): array { // 숨김 상태(PENDING_ORDER 등) 제외한 전체 통계 (OrderStatusEnum::listHiddenValues SSoT) $total = $this->model @@ -536,22 +606,21 @@ class OrderRepository implements OrderRepositoryInterface ->toArray(); // 오늘 주문 수 - $todayCount = $this->model - ->whereDate('ordered_at', today()) - ->count(); + $todayCountQuery = $this->model->newQuery(); + $this->applyDayFilter($todayCountQuery, 'ordered_at', today()); + $todayCount = $todayCountQuery->count(); // 오늘 매출액 - $todayRevenue = $this->model - ->whereDate('ordered_at', today()) - ->whereNotIn('order_status', [OrderStatusEnum::CANCELLED->value]) - ->sum('total_paid_amount'); + $todayRevenueQuery = $this->model->newQuery() + ->whereNotIn('order_status', [OrderStatusEnum::CANCELLED->value]); + $this->applyDayFilter($todayRevenueQuery, 'ordered_at', today()); + $todayRevenue = $todayRevenueQuery->sum('total_paid_amount'); // 이번 달 매출액 - $monthlyRevenue = $this->model - ->whereYear('ordered_at', now()->year) - ->whereMonth('ordered_at', now()->month) - ->whereNotIn('order_status', [OrderStatusEnum::CANCELLED->value]) - ->sum('total_paid_amount'); + $monthlyRevenueQuery = $this->model->newQuery() + ->whereNotIn('order_status', [OrderStatusEnum::CANCELLED->value]); + $this->applyMonthFilter($monthlyRevenueQuery, 'ordered_at', (int) now()->year, (int) now()->month); + $monthlyRevenue = $monthlyRevenueQuery->sum('total_paid_amount'); return [ 'total' => $total, @@ -666,12 +735,13 @@ class OrderRepository implements OrderRepositoryInterface if (! empty($filters['date_type']) && (! empty($filters['start_date']) || ! empty($filters['end_date']))) { $dateField = $filters['date_type']; - if (! empty($filters['start_date'])) { - $query->whereDate($dateField, '>=', $filters['start_date']); - } - if (! empty($filters['end_date'])) { - $query->whereDate($dateField, '<=', $filters['end_date']); - } + // whereDate 는 인덱스를 무력화한다 — 범위 조건으로 준다. + $this->applyDateRangeFilter( + $query, + $dateField, + $filters['start_date'] ?? null, + $filters['end_date'] ?? null + ); } // 주문상태 필터 @@ -740,6 +810,18 @@ class OrderRepository implements OrderRepositoryInterface return Order::whereIn('id', $ids)->get()->keyBy('id'); } + /** + * {@inheritDoc} + */ + public function findByIdsWithRelationsKeyed(array $ids, array $with = []): Collection + { + if (empty($ids)) { + return new Collection; + } + + return Order::with($with)->whereIn('id', $ids)->get()->keyBy('id'); + } + /** * {@inheritDoc} */ @@ -790,6 +872,7 @@ class OrderRepository implements OrderRepositoryInterface columns: ['*'], sort: [['column' => 'created_at', 'direction' => $sortOrder]], perPage: $perPage, + resultCap: PaginationLimits::resultCap('admin.activity_logs'), ); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductAdditionalOptionValueRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductAdditionalOptionValueRepository.php index a5b4ca8b..a6cd3d3a 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductAdditionalOptionValueRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductAdditionalOptionValueRepository.php @@ -38,12 +38,29 @@ class ProductAdditionalOptionValueRepository implements ProductAdditionalOptionV */ public function getActiveByProductKeyed(int $productId): Collection { + return $this->getActiveByProductIdsKeyed([$productId])->get($productId) ?? new Collection; + } + + /** + * {@inheritDoc} + */ + public function getActiveByProductIdsKeyed(array $productIds): Collection + { + $productIds = array_values(array_unique(array_filter(array_map('intval', $productIds)))); + + if (empty($productIds)) { + return new Collection; + } + + // 상품별로 한 번씩 조회하면 장바구니 항목 수만큼 쿼리가 난다. 한 번에 읽고 + // 상품 ID 로 묶어 돌려준다 — 호출자는 상품별 맵을 그대로 쓴다. return $this->model ->with('additionalOption') ->where('is_active', true) - ->whereHas('additionalOption', fn ($query) => $query->where('product_id', $productId)) + ->whereHas('additionalOption', fn ($query) => $query->whereIn('product_id', $productIds)) ->get() - ->keyBy('id'); + ->groupBy(fn ($value) => (int) $value->additionalOption?->product_id) + ->map(fn (Collection $values) => $values->keyBy('id')); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php index 7472d009..37bd4fd0 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Support\Query\PaginationLimits; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; @@ -33,6 +34,9 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface */ public function findByProductId(int $productId): Collection { + // audit:allow query-unbounded-get reason: 상품 삭제 시 딸린 문의를 함께 정리하기 위한 + // 내부 조회다. 상한을 걸면 남은 문의가 고아로 남는다. 화면용 목록은 같은 클래스의 + // paginateByProductId() 가 담당한다 return $this->model->newQuery() ->where('product_id', $productId) ->orderBy('created_at', 'desc') @@ -144,6 +148,7 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface sort: [['column' => 'created_at', 'direction' => 'desc']], perPage: $perPage, relations: ['product', 'user'], + resultCap: PaginationLimits::resultCap('admin.product_inquiries'), ); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductOptionRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductOptionRepository.php index 7d803785..3d26338b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductOptionRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductOptionRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as SupportCollection; use Modules\Sirsoft\Ecommerce\Models\ProductOption; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface; @@ -47,6 +48,30 @@ class ProductOptionRepository implements ProductOptionRepositoryInterface return $this->model->where('product_id', $productId)->get(); } + /** + * 여러 상품의 옵션을 한 번에 조회해 상품별로 묶어 반환합니다. + * + * 항목마다 getByProductId 를 부르면 항목 수만큼 쿼리가 난다. 체크아웃처럼 여러 + * 항목을 한꺼번에 다루는 경로는 이 메서드로 한 번에 읽는다. + * + * @param array $productIds 상품 ID 목록 + * @return SupportCollection 상품 ID ⇒ 옵션 컬렉션 + */ + public function getByProductIds(array $productIds): SupportCollection + { + $productIds = array_values(array_unique(array_filter($productIds))); + + if (empty($productIds)) { + return new SupportCollection; + } + + // groupBy 는 Support\Collection 을 돌려준다 (Eloquent\Collection 이 아니다). + return $this->model + ->whereIn('product_id', $productIds) + ->get() + ->groupBy('product_id'); + } + /** * 옵션 가격 조정액(price_adjustment) 일괄 변경 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php index 9e22da56..7939163f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php @@ -2,20 +2,30 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; +use App\Contracts\Extension\CacheInterface; use App\Helpers\PermissionHelper; use App\Models\ActivityLog; +use App\Repositories\Concerns\FiltersByDateRange; use App\Repositories\Concerns\PaginatesWithDeferredJoin; use App\Repositories\Concerns\ResolvesSortSpec; -use App\Search\Engines\DatabaseFulltextEngine; +use App\Search\KeywordSearch; +use App\Support\Query\BoundedCount; +use App\Support\Query\BoundedPage; +use App\Support\Query\BoundedPaginator; +use App\Support\Query\KeysetPaginator; +use App\Support\Query\PaginationLimits; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; +use Modules\Sirsoft\Ecommerce\Enums\ReviewStatus; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Models\OrderOption; use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Models\ProductOption; +use Modules\Sirsoft\Ecommerce\Models\ProductReview; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; /** @@ -23,12 +33,19 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; */ class ProductRepository implements ProductRepositoryInterface { + use FiltersByDateRange; use PaginatesWithDeferredJoin; use ResolvesSortSpec; /** 허용 정렬 컬럼 (ProductListRequest 와 동일 집합) */ private const ADMIN_SORTABLE_COLUMNS = ['created_at', 'updated_at', 'selling_price', 'stock_quantity', 'name']; + /** 상품 통계 캐시 키 */ + private const STATISTICS_CACHE_KEY = 'ecommerce.products.statistics'; + + /** 상품 통계 캐시 태그 */ + private const STATISTICS_CACHE_TAG = 'ecommerce.products'; + public function __construct( protected Product $model ) {} @@ -118,36 +135,15 @@ class ProductRepository implements ProductRepositoryInterface $keyword = $filters['search_keyword']; $field = $filters['search_field'] ?? 'all'; - // FULLTEXT 대상 필드(all/name/description)는 Scout 검색 ID 와 보조필드 LIKE 매칭 ID 의 - // 합집합(union)을 미리 산출한 뒤 plain Eloquent whereIn 으로 조회한다. - // (total 계산 경로와 결과 조회 경로가 동일한 검색 조건을 보장 — Scout queryCallback - // total 재계산 시 보조필드 OR 가 MATCH 절 없이 재적용되어 total=0 이 되는 결함 회피) + // FULLTEXT 대상 필드(all/name/description)는 FULLTEXT 매칭과 보조필드 LIKE 를 + // 하나의 WHERE 그룹으로 묶어 페이지 쿼리에 직접 넣는다. + // + // 종전에는 매칭 ID 전량을 PHP 로 끌어와 whereIn 으로 되먹였다. 그 방식은 매칭이 + // 많을수록 PHP 메모리와 IN(...) 목록이 함께 커지고, 조회 전에 전 매칭을 한 번 + // 훑는 왕복이 추가된다. 술어를 그대로 밀어넣으면 왕복도 목록 폭발도 없고, + // total 계산 경로와 결과 조회 경로가 같은 술어를 공유한다는 성질도 유지된다. if (in_array($field, ['all', 'name', 'description'])) { - // FULLTEXT 매칭 ID (Scout 엔진 순수 검색 — queryCallback 없음) - $ftIds = Product::search($keyword)->keys()->all(); - - // 보조필드(id/product_code/sku/barcode) LIKE 매칭 ID (all 일 때만) - $auxIds = []; - if ($field === 'all') { - $auxIds = $this->model->newQuery() - ->where(function ($q) use ($keyword) { - $q->where('product_code', 'like', "%{$keyword}%") - ->orWhere('sku', 'like', "%{$keyword}%") - ->orWhere('barcode', 'like', "%{$keyword}%"); - - // 숫자 키워드는 상품 ID 정확 매칭도 포함 (예: "99" → id=99) - if (ctype_digit($keyword)) { - $q->orWhere('id', (int) $keyword); - } - }) - ->pluck('id') - ->all(); - } - - $matchedIds = array_values(array_unique([...$ftIds, ...$auxIds])); - - // 매칭 ID 로 조건 한정 (빈 매칭은 존재하지 않는 ID 로 빈 결과 → total=0 이 정상) - $query->whereIn('id', $matchedIds ?: [0]); + $this->applyKeywordMatch($query, $keyword, includeAuxFields: $field === 'all'); // 필터 적용 $this->applyAdminFilters($query, $filters); @@ -160,6 +156,7 @@ class ProductRepository implements ProductRepositoryInterface relations: $listRelations, withCount: $listWithCount, outerUsing: $listOuterUsing, + resultCap: PaginationLimits::resultCap('admin.products'), ); } @@ -188,6 +185,7 @@ class ProductRepository implements ProductRepositoryInterface relations: $listRelations, withCount: $listWithCount, outerUsing: $listOuterUsing, + resultCap: PaginationLimits::resultCap('admin.products'), ); } @@ -243,7 +241,10 @@ class ProductRepository implements ProductRepositoryInterface */ public function create(array $data): Product { - return $this->model->create($data); + $product = $this->model->create($data); + $this->forgetStatisticsCache(); + + return $product; } /** @@ -252,6 +253,7 @@ class ProductRepository implements ProductRepositoryInterface public function update(Product $product, array $data): Product { $product->update($data); + $this->forgetStatisticsCache(); return $product->fresh(); } @@ -261,7 +263,10 @@ class ProductRepository implements ProductRepositoryInterface */ public function delete(Product $product): bool { - return $product->delete(); + $deleted = $product->delete(); + $this->forgetStatisticsCache(); + + return $deleted; } /** @@ -269,7 +274,10 @@ class ProductRepository implements ProductRepositoryInterface */ public function forceDelete(Product $product): bool { - return (bool) $product->forceDelete(); + $deleted = (bool) $product->forceDelete(); + $this->forgetStatisticsCache(); + + return $deleted; } /** @@ -289,13 +297,17 @@ class ProductRepository implements ProductRepositoryInterface */ public function bulkUpdateStatus(array $ids, string $field, string $value): int { - return $this->model + $affected = $this->model ->whereIn('id', $ids) ->update([ $field => $value, 'updated_by' => auth()->id(), 'updated_at' => now(), ]); + + $this->forgetStatisticsCache(); + + return $affected; } /** @@ -303,24 +315,59 @@ class ProductRepository implements ProductRepositoryInterface */ public function bulkUpdatePrice(array $ids, string $method, float $value, string $unit): int { - $updatedCount = 0; + if (empty($ids)) { + return 0; + } - DB::transaction(function () use ($ids, $method, $value, $unit, &$updatedCount) { - $products = $this->model->whereIn('id', $ids)->get(); - - foreach ($products as $product) { - $currentPrice = $product->selling_price; - $newPrice = $this->calculateNewPrice($currentPrice, $method, $value, $unit); - - $product->update([ - 'selling_price' => max(0, $newPrice), + // 'set' 은 새 값이 행마다 같으므로 단일 statement 로 끝난다. + if ($method === 'set') { + $affected = $this->model + ->whereIn('id', $ids) + ->update([ + 'selling_price' => max(0, $value), 'updated_by' => auth()->id(), + 'updated_at' => now(), ]); - $updatedCount++; - } - }); - return $updatedCount; + $this->forgetStatisticsCache(); + + return $affected; + } + + // 증감·비율은 현재 값에 의존하지만 그 계산도 SQL 이 할 수 있다. + // 행마다 update() 를 부르면 대상 수만큼 UPDATE 문이 나가므로, 표현식 하나로 합친다. + // (같은 파일의 bulkUpdateFields 가 쓰는 단일 statement 방식과 같은 결) + // + // 표현식 안의 값은 플레이스홀더로 두지 않는다 — update() 는 값 배열 하나만 받고 + // 표현식에 딸린 바인딩을 실을 통로가 없다. 남겨 두면 WHERE 바인딩이 SET 절로 + // 밀려 엉뚱한 컬럼에 기록되거나 파라미터 수 불일치로 실패한다. + $amount = $this->sqlDecimal($value); + + $expression = match ($method) { + 'increase' => $unit === 'percent' + ? 'selling_price * (1 + '.$amount.' / 100)' + : 'selling_price + '.$amount, + 'decrease' => $unit === 'percent' + ? 'selling_price * (1 - '.$amount.' / 100)' + : 'selling_price - '.$amount, + default => null, + }; + + if ($expression === null) { + return 0; + } + + $affected = $this->model + ->whereIn('id', $ids) + ->update([ + 'selling_price' => DB::raw('GREATEST(0, '.$expression.')'), + 'updated_by' => auth()->id(), + 'updated_at' => now(), + ]); + + $this->forgetStatisticsCache(); + + return $affected; } /** @@ -328,29 +375,51 @@ class ProductRepository implements ProductRepositoryInterface */ public function bulkUpdateStock(array $ids, string $method, int $value): int { - $updatedCount = 0; + if (empty($ids)) { + return 0; + } - DB::transaction(function () use ($ids, $method, $value, &$updatedCount) { - $products = $this->model->whereIn('id', $ids)->get(); + // 행마다 조회 후 update() 를 부르면 대상 수만큼 UPDATE 문이 나간다. + // 재고 증감은 SQL 표현식으로 표현되므로 단일 statement 로 합친다. + // 수량은 정수로 강제되므로 표현식에 그대로 넣는다 (바인딩 불가 사유는 bulkUpdatePrice 참조). + $quantity = (int) $value; - foreach ($products as $product) { - $currentStock = $product->stock_quantity; - $newStock = match ($method) { - 'increase' => $currentStock + $value, - 'decrease' => $currentStock - $value, - 'set' => $value, - default => $currentStock, - }; + $newStock = match ($method) { + 'increase' => DB::raw('GREATEST(0, stock_quantity + '.$quantity.')'), + 'decrease' => DB::raw('GREATEST(0, stock_quantity - '.$quantity.')'), + 'set' => max(0, $value), + default => null, + }; - $product->update([ - 'stock_quantity' => max(0, $newStock), - 'updated_by' => auth()->id(), - ]); - $updatedCount++; - } - }); + if ($newStock === null) { + return 0; + } - return $updatedCount; + $affected = $this->model + ->whereIn('id', $ids) + ->update([ + 'stock_quantity' => $newStock, + 'updated_by' => auth()->id(), + 'updated_at' => now(), + ]); + + $this->forgetStatisticsCache(); + + return $affected; + } + + /** + * 실수를 SQL 표현식에 넣을 십진 리터럴로 만듭니다. + * + * 인자가 float 로 강제되므로 주입 여지는 없고, 지수 표기(1.0E-5)로 변환되면 + * SQL 이 해석하지 못하므로 고정 소수점으로 고정합니다. + * + * @param float $value 변환할 값 + * @return string 십진 리터럴 문자열 + */ + private function sqlDecimal(float $value): string + { + return number_format($value, 4, '.', ''); } /** @@ -371,6 +440,45 @@ class ProductRepository implements ProductRepositoryInterface * {@inheritDoc} */ public function getStatistics(): array + { + // 상품 목록 화면과 함께 매 페이지 조회되는 집계 5종이다. 값이 초 단위로 달라질 + // 필요는 없으므로 짧게 캐시하고, 상품이 바뀌면 즉시 무효화한다 + // (주문 통계와 같은 규약 — cache.stats_enabled / cache.stats_ttl). + if (! g7_core_settings('cache.stats_enabled', true)) { + return $this->computeStatistics(); + } + + $ttl = (int) g7_core_settings('cache.stats_ttl', 1800); + + if ($ttl <= 0) { + return $this->computeStatistics(); + } + + return app(CacheInterface::class)->remember( + self::STATISTICS_CACHE_KEY, + fn () => $this->computeStatistics(), + $ttl, + [self::STATISTICS_CACHE_TAG] + ); + } + + /** + * 상품 통계 캐시를 무효화합니다. + * + * 상품이 생성·수정·삭제되면 통계도 곧바로 달라져야 합니다. 쓰기 경로가 이 메서드를 + * 부르지 않으면 화면이 TTL 이 끝날 때까지 이전 수치를 보여 줍니다. + */ + public function forgetStatisticsCache(): void + { + app(CacheInterface::class)->forget(self::STATISTICS_CACHE_KEY); + } + + /** + * 상품 통계를 실제로 집계합니다. + * + * @return array 상품 통계 + */ + private function computeStatistics(): array { $total = $this->model->count(); @@ -485,9 +593,13 @@ class ProductRepository implements ProductRepositoryInterface $fields['updated_at'] = now(); $fields['updated_by'] = auth()->id(); - return $this->model + $affected = $this->model ->whereIn('id', $ids) ->update($fields); + + $this->forgetStatisticsCache(); + + return $affected; } /** @@ -495,9 +607,9 @@ class ProductRepository implements ProductRepositoryInterface */ public function getPublicList(array $filters, int $perPage = 20): LengthAwarePaginator { + // 리뷰 건수·평점은 파생 테이블 조인으로 한 번에 얻는다. 목록 경로는 지연 조인을 + // 쓰므로 집계를 outer 에서만 붙인다 (아래 outerUsing). $query = $this->model->newQuery() - ->withCount('visibleReviews as review_count') - ->withAvg('visibleReviews as rating_avg', 'rating') ->where('display_status', 'visible'); // 지연 조인 outer 전용 관계 (inner 는 id 만 훑으므로 with() 를 걸지 않는다) @@ -528,7 +640,7 @@ class ProductRepository implements ProductRepositoryInterface // 키워드 검색 (FULLTEXT) if (! empty($filters['search'])) { $keyword = $filters['search']; - DatabaseFulltextEngine::whereFulltext($query, 'name', $keyword); + KeywordSearch::apply($query, 'name', $keyword); } // 가격 범위 필터 (소수 통화 대응 — float 비교) @@ -548,19 +660,32 @@ class ProductRepository implements ProductRepositoryInterface // 정렬 $sort = $filters['sort'] ?? 'latest'; - // 판매량 정렬은 집계 서브쿼리(total_sold) 를 select 에 올려 두고 그 별칭으로 정렬한다. - // 지연 조인의 inner 는 select 를 id 하나로 교체하므로 별칭이 사라져 성립하지 않는다. + // 판매량 정렬. + // + // 종전에는 상관 서브쿼리를 SELECT 에 올리고 그 별칭으로 정렬했다. 상관 서브쿼리가 + // ORDER BY 에 걸리면 LIMIT 이 평가 범위를 막지 못한다 — 정렬 순서를 정하려면 모든 + // 후보 행에 대해 서브쿼리를 한 번씩 돌려야 하므로, 한 페이지만 필요해도 전 상품에 + // 대해 집계가 실행된다. 미리 묶은 파생 테이블을 LEFT JOIN 하면 집계가 한 번만 + // 수행되고 조인 결과를 정렬한다. + // + // 파생 테이블은 product_id 로 그룹지어 상품당 한 행이므로 원 행이 부풀지 않는다. if ($sort === 'sales') { - $query - ->addSelect(['total_sold' => OrderOption::selectRaw('COALESCE(SUM(quantity), 0)') - ->whereColumn('product_id', 'ecommerce_products.id'), - ]) - ->orderByDesc('total_sold') + // 판매량 정렬은 지연 조인을 쓰지 않으므로 리뷰 집계도 여기서 함께 붙인다. + // 두 파생 테이블 모두 product_id 로 그룹지어 상품당 한 행이라 원 행이 부풀지 않는다. + $this->joinReviewAggregate($query); + + $this->joinSalesAggregate($query) + ->orderByDesc($this->salesAlias().'.total_sold') ->with($listRelations); - // audit:allow repository-paginate-column-pruning reason: 판매량 정렬은 집계 서브쿼리 - // 별칭 기준이라 지연 조인 inner(=id 단일 select)로 옮길 수 없다. 나머지 정렬은 지연 조인 적용 - return $query->paginate($perPage); + // audit:allow repository-paginate-column-pruning reason: 판매량 정렬은 조인한 + // 집계 별칭 기준이라 지연 조인 inner(=id 단일 select)로 옮길 수 없다. + // 나머지 정렬은 지연 조인 적용 + return BoundedPaginator::paginate( + $query, + perPage: $perPage, + resultCap: PaginationLimits::resultCap('shop.products'), + ); } $sortSpec = match ($sort) { @@ -575,6 +700,8 @@ class ProductRepository implements ProductRepositoryInterface sort: $sortSpec, perPage: $perPage, relations: $listRelations, + outerUsing: $this->reviewAggregateForOuter(), + resultCap: PaginationLimits::resultCap('shop.products'), ); } @@ -585,31 +712,175 @@ class ProductRepository implements ProductRepositoryInterface { $thirtyDaysAgo = now()->subDays(30); - return $this->model->newQuery() + // 판매량 정렬과 같은 이유로 파생 테이블 조인을 쓴다 — 상관 서브쿼리를 ORDER BY 에 + // 두면 LIMIT 이 있어도 전 상품에 대해 집계가 실행된다. + $query = $this->model->newQuery() ->with(['images', 'categories', 'activeLabelAssignments.label']) - ->withCount('visibleReviews as review_count') - ->withAvg('visibleReviews as rating_avg', 'rating') - ->where('display_status', 'visible') - ->addSelect(['recent_sold' => OrderOption::selectRaw('COALESCE(SUM(quantity), 0)') - ->whereColumn('product_id', 'ecommerce_products.id') - ->where('created_at', '>=', $thirtyDaysAgo), - ]) - ->orderByDesc('recent_sold') + ->where('display_status', 'visible'); + + $this->joinReviewAggregate($query); + + return $this->joinSalesAggregate($query, $thirtyDaysAgo) + ->orderByDesc($this->salesAlias().'.total_sold') ->limit($limit) ->get(); } + /** + * 상품별 판매 수량 집계를 파생 테이블로 조인합니다. + * + * 상관 서브쿼리 대신 미리 그룹지은 결과를 LEFT JOIN 합니다. 집계가 상품 수만큼 + * 반복 실행되지 않고, product_id 기준 그룹이라 원 행이 부풀지 않습니다. + * + * 판매 이력이 없는 상품은 조인 결과가 없으므로 정렬에서 자연스럽게 뒤로 밀립니다. + * + * @param Builder $query 대상 쿼리 + * @param \DateTimeInterface|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return Builder 조인이 적용된 쿼리 + */ + private function joinSalesAggregate($query, ?\DateTimeInterface $since = null) + { + $alias = $this->salesAlias(); + $productsTable = $this->model->getTable(); + + $aggregate = OrderOption::query() + ->select('product_id') + ->selectRaw('SUM(quantity) as total_sold') + ->when($since !== null, fn ($q) => $q->where('created_at', '>=', $since)) + ->groupBy('product_id'); + + return $this->ensureBaseSelect($query) + ->leftJoinSub( + $aggregate, + $alias, + fn ($join) => $join->on($alias.'.product_id', '=', $productsTable.'.id') + ) + ->addSelect($alias.'.total_sold'); + } + + /** + * {@inheritDoc} + */ + public function findByIdsWithRelationsKeyed(array $ids, array $relations = []): Collection + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + + if (empty($ids)) { + return $this->model->newCollection(); + } + + // 항목마다 find() 를 부르면 항목 수만큼 쿼리가 난다. 한 번에 읽고 ID 로 키를 잡는다. + return $this->model->newQuery() + ->when(! empty($relations), fn ($q) => $q->with($relations)) + ->whereIn('id', $ids) + ->get() + ->keyBy('id'); + } + + /** + * 조인 전에 상품 테이블 기준 SELECT 를 한 번만 고정합니다. + * + * 조인이 붙은 쿼리에서 `*` 는 조인 대상의 컬럼까지 끌어와 별칭이 충돌합니다. + * 그렇다고 집계 헬퍼마다 `select()` 를 부르면 나중에 부른 쪽이 앞서 쌓은 + * `addSelect()` 를 지웁니다 — 판매 집계와 리뷰 집계를 함께 붙이는 경로에서 + * 한쪽 별칭이 조용히 사라지므로, 이미 지정돼 있으면 건드리지 않습니다. + * + * @param Builder $query 대상 쿼리 + * @return Builder SELECT 가 고정된 쿼리 + */ + private function ensureBaseSelect($query) + { + if (empty($query->getQuery()->columns)) { + $query->select($this->model->getTable().'.*'); + } + + return $query; + } + + /** + * 판매 집계 파생 테이블의 별칭을 반환합니다. + * + * @return string 별칭 + */ + private function salesAlias(): string + { + return 'g7_product_sales'; + } + + /** + * 상품별 리뷰 건수·평점 집계를 파생 테이블로 조인합니다. + * + * `withCount()` + `withAvg()` 를 겹쳐 쓰면 같은 리뷰 테이블을 훑는 상관 서브쿼리가 + * 행마다 **두 번** 실행됩니다. 건수와 평균은 한 번의 그룹 집계로 함께 얻을 수 있으므로 + * 미리 그룹지은 파생 테이블을 LEFT JOIN 해 스캔을 1회로 줄입니다. + * + * product_id 기준 그룹이라 상품당 한 행이므로 원 행이 부풀지 않습니다. 리뷰가 없는 + * 상품은 조인 결과가 없어 건수 0 / 평점 null 이 되며, 이는 종전 동작과 같습니다. + * + * @param Builder $query 대상 쿼리 + * @return Builder 조인이 적용된 쿼리 + */ + private function joinReviewAggregate($query) + { + $alias = $this->reviewAlias(); + $productsTable = $this->model->getTable(); + + $aggregate = ProductReview::query() + ->select('product_id') + ->selectRaw('COUNT(*) as review_count') + ->selectRaw('AVG(rating) as rating_avg') + ->where('status', ReviewStatus::VISIBLE->value) + ->groupBy('product_id'); + + return $this->ensureBaseSelect($query) + ->leftJoinSub( + $aggregate, + $alias, + fn ($join) => $join->on($alias.'.product_id', '=', $productsTable.'.id') + ) + // 별칭은 빌더가 만들게 둔다 — grammar 가 테이블 프리픽스를 별칭에도 붙이므로 + // raw 문자열로 별칭을 조립하면 `g7_g7_...` 처럼 이중 프리픽스가 된다. + // 리뷰가 없는 상품은 여기서 null 이 오고, 리소스가 0 으로 표기한다. + ->addSelect([ + $alias.'.review_count', + $alias.'.rating_avg', + ]); + } + + /** + * 지연 조인 목록의 outer 에만 리뷰 집계를 붙이는 클로저를 반환합니다. + * + * inner 에 두면 OFFSET 이 건너뛰는 행 전체에 대해 집계가 실행되므로, + * 이번 페이지 행에만 적용되도록 outer 전용으로 넘깁니다. + * + * @return \Closure outer 적용 클로저 + */ + private function reviewAggregateForOuter(): \Closure + { + return fn ($outer) => $this->joinReviewAggregate($outer); + } + + /** + * 리뷰 집계 파생 테이블의 별칭을 반환합니다. + * + * @return string 별칭 + */ + private function reviewAlias(): string + { + return 'g7_product_reviews_agg'; + } + /** * {@inheritDoc} */ public function getNewProducts(int $limit = 10): Collection { - return $this->model->newQuery() + $query = $this->model->newQuery() ->with(['images', 'categories', 'activeLabelAssignments.label']) - ->withCount('visibleReviews as review_count') - ->withAvg('visibleReviews as rating_avg', 'rating') - ->where('display_status', 'visible') - ->orderBy('created_at', 'desc') + ->where('display_status', 'visible'); + + return $this->joinReviewAggregate($query) + ->orderBy($this->model->getTable().'.created_at', 'desc') ->limit($limit) ->get(); } @@ -638,77 +909,129 @@ class ProductRepository implements ProductRepositoryInterface /** * {@inheritDoc} */ - public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', ?int $categoryId = null, int $offset = 0, int $limit = 10): array + public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', ?int $categoryId = null, int $offset = 0, int $limit = 10): BoundedPage { - $page = (int) floor($offset / $limit) + 1; + $page = (int) floor($offset / max(1, $limit)) + 1; // FULLTEXT(name/description) 매칭 + 보조필드(product_code/id) 매칭의 합집합. // 통합검색도 관리자 검색과 동일하게 상품코드/ID 로 상품을 찾을 수 있어야 한다. - $matchedIds = $this->resolveKeywordMatchedProductIds($keyword); - - $paginator = $this->model->newQuery() - ->whereIn('id', $matchedIds ?: [0]) + $query = $this->model->newQuery() ->where('display_status', ProductDisplayStatus::VISIBLE->value) // audit:allow list-repository-eager-load-vs-resource reason: 통합검색 결과는 Resource 가 // 아니라 SearchProductsListener 가 직렬화한다 — 그 안에서 primaryCategory 를 행마다 // 읽으므로(`$product->primaryCategory->first()`) 여기서 미리 로드하지 않으면 N+1 이 된다 ->with(['images', 'primaryCategory', 'brand', 'activeLabelAssignments.label']) - ->withCount('visibleReviews as review_count') - ->withAvg('visibleReviews as rating_avg', 'rating') - ->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId))) - ->orderBy($orderBy, $direction) + ->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId))); + + // 리뷰 건수·평점은 파생 테이블 조인 1회로 얻는다 (상관 서브쿼리 2회 아님) + $this->joinReviewAggregate($query); + + $productsTable = $this->model->getTable(); + $query->orderBy($productsTable.'.'.$orderBy, $direction) // 전순서 보장 — 정렬 컬럼(등록일/판매량 등)이 비고유라 페이지 경계가 흔들릴 수 있다 - ->orderBy('id', $direction === 'asc' ? 'asc' : 'desc') - // audit:allow repository-paginate-column-pruning reason: 통합검색 — - // whereIn(id, $matchedIds) 로 FULLTEXT 매칭 ID 집합에 먼저 한정되므로 스캔 대상이 - // 상품 전체가 아니라 매칭 건수로 묶인다(OFFSET 이 훑는 범위도 그 안). 지연 조인의 - // inner 가 하는 일을 매칭 ID 산출이 이미 수행하는 구조다 - ->paginate($limit, ['*'], 'page', $page); + ->orderBy($productsTable.'.id', $direction === 'asc' ? 'asc' : 'desc'); - return ['total' => $paginator->total(), 'items' => $paginator->getCollection()]; - } + $this->applyKeywordMatch($query, $keyword, limitContext: 'search'); - /** - * 키워드로 매칭되는 상품 ID 집합을 반환합니다. - * - * FULLTEXT(name/description) Scout 매칭과 보조필드(product_code / 숫자 ID) 매칭의 - * 합집합. 통합검색의 결과 조회와 total 계산이 동일한 조건을 쓰도록 단일 경로로 산출한다. - * - * @param string $keyword 검색 키워드 - * @return array 매칭된 상품 ID 배열 - */ - private function resolveKeywordMatchedProductIds(string $keyword): array - { - $ftIds = Product::search($keyword)->keys()->all(); - - $auxIds = $this->model->newQuery() - ->where(function ($q) use ($keyword) { - $q->where('product_code', 'like', "%{$keyword}%"); - - // 숫자 키워드는 상품 ID 정확 매칭도 포함 - if (ctype_digit($keyword)) { - $q->orWhere('id', (int) $keyword); - } - }) - ->pluck('id') - ->all(); - - return array_values(array_unique([...$ftIds, ...$auxIds])); + // audit:allow repository-paginate-column-pruning reason: 통합검색 결과 카드가 + // 이미지·카테고리·브랜드·라벨 관계를 함께 그리므로 모델 전체가 필요하다. 한 페이지 + // 분량만 조회되고, 총 건수에는 상한이 걸려 있다 + return BoundedPaginator::paginate( + $query, + perPage: $limit, + page: $page, + resultCap: PaginationLimits::resultCap('search'), + ); } /** * {@inheritDoc} */ - public function countByKeyword(string $keyword, ?int $categoryId = null): int + public function searchByKeywordWithCursor( + string $keyword, + array $sortKeys, + ?int $categoryId = null, + int $perPage = 10, + ?string $cursor = null + ): CursorPaginator { + $query = $this->model->newQuery() + ->where('display_status', ProductDisplayStatus::VISIBLE->value) + ->with(['images', 'primaryCategory', 'brand', 'activeLabelAssignments.label']) + ->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId))); + + $this->joinReviewAggregate($query); + $this->applyKeywordMatch($query, $keyword, limitContext: 'search'); + + // 정렬 컬럼은 상품 테이블 소속임을 명시한다 — 파생 조인이 붙어 있어 모호해질 수 있다. + $productsTable = $this->model->getTable(); + $qualified = array_map( + static fn (array $spec): array => [$productsTable.'.'.$spec[0], $spec[1]], + $sortKeys + ); + + return KeysetPaginator::paginate( + query: $query, + perPage: $perPage, + sortKeys: $qualified, + uniqueKey: $productsTable.'.id', + cursor: $cursor, + ); + } + + /** + * 키워드 매칭 조건을 쿼리에 직접 적용합니다. + * + * FULLTEXT(name/description) 매칭과 보조필드(product_code / sku / barcode / 숫자 ID) + * 매칭의 합집합을 하나의 WHERE 그룹으로 만듭니다. 매칭 ID 를 PHP 로 실어 나르지 않으므로 + * 매칭 건수가 아무리 커도 메모리와 SQL 길이가 늘지 않습니다. + * + * 결과 조회와 total 계산이 이 메서드 하나를 공유하므로 두 경로의 조건이 갈라지지 않습니다. + * + * @param Builder $query 대상 쿼리 + * @param string $keyword 검색 키워드 + * @param bool $includeAuxFields 상품코드·SKU·바코드·ID 보조 매칭 포함 여부 + * @param string|null $limitContext 키 집합 상한 해석에 쓸 목록 컨텍스트 이름 + */ + private function applyKeywordMatch($query, string $keyword, bool $includeAuxFields = true, ?string $limitContext = null): void + { + $query->where(function ($group) use ($keyword, $includeAuxFields, $limitContext) { + // 정제는 코어 헬퍼가 단독 수행 — BOOLEAN MODE 연산자 입력이 500 이 되지 않는다. + // 상품 테이블은 name·description 에 각각 단일 FULLTEXT 인덱스를 갖는다. + // 복합 MATCH(name, description) 는 그 조합의 복합 인덱스를 요구해 오류가 난다. + // 상한 컨텍스트를 함께 넘긴다 — 키 집합을 만드는 엔진이 총 건수 상한과 같은 + // 기준으로 잘라 내야 "엔진이 돌려준 건수" 와 "화면이 보고하는 총 건수" 가 어긋나지 않는다. + KeywordSearch::applyAny($group, ['name', 'description'], $keyword, 'and', $limitContext); + + if (! $includeAuxFields) { + return; + } + + $group->orWhere('product_code', 'like', "%{$keyword}%") + ->orWhere('sku', 'like', "%{$keyword}%") + ->orWhere('barcode', 'like', "%{$keyword}%"); + + // 숫자 키워드는 상품 ID 정확 매칭도 포함 (예: "99" → id=99) + if (ctype_digit($keyword)) { + $group->orWhere('id', (int) $keyword); + } + }); + } + + /** + * {@inheritDoc} + */ + public function countByKeyword(string $keyword, ?int $categoryId = null): BoundedCount { // searchByKeyword 와 동일한 매칭 조건(FULLTEXT ∪ 보조필드)으로 total 산출. - $matchedIds = $this->resolveKeywordMatchedProductIds($keyword); - - return $this->model->newQuery() - ->whereIn('id', $matchedIds ?: [0]) + $query = $this->model->newQuery() ->where('display_status', ProductDisplayStatus::VISIBLE->value) - ->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId))) - ->count(); + ->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId))); + + $this->applyKeywordMatch($query, $keyword); + + // 배지용 건수 — 상한을 걸어 대량 매칭에서도 비용이 일정하다. + // 상한에 걸리면 값이 잘리므로 정확도를 함께 돌려준다. + return BoundedPaginator::count($query, PaginationLimits::resultCap('search')); } /** @@ -736,12 +1059,13 @@ class ProductRepository implements ProductRepositoryInterface if (! empty($filters['date_type']) && (! empty($filters['start_date']) || ! empty($filters['end_date']))) { $dateField = $filters['date_type']; - if (! empty($filters['start_date'])) { - $query->whereDate($dateField, '>=', $filters['start_date']); - } - if (! empty($filters['end_date'])) { - $query->whereDate($dateField, '<=', $filters['end_date']); - } + // whereDate 는 인덱스를 무력화한다 — 범위 조건으로 준다. + $this->applyDateRangeFilter( + $query, + $dateField, + $filters['start_date'] ?? null, + $filters['end_date'] ?? null + ); } // 판매상태 필터 (다중 선택 가능) @@ -872,7 +1196,12 @@ class ProductRepository implements ProductRepositoryInterface */ public function updateStockQuantity(int $productId, int $stock): int { - return $this->model->where('id', $productId)->update(['stock_quantity' => $stock]); + $affected = $this->model->where('id', $productId)->update(['stock_quantity' => $stock]); + + // 통계에 품절·재고부족 건수가 들어 있으므로 재고 변경도 무효화 대상이다 + $this->forgetStatisticsCache(); + + return $affected; } /** @@ -908,6 +1237,7 @@ class ProductRepository implements ProductRepositoryInterface columns: ['*'], sort: [['column' => 'created_at', 'direction' => $sortOrder]], perPage: $perPage, + resultCap: PaginationLimits::resultCap('admin.activity_logs'), ); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductReviewRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductReviewRepository.php index d7ad1c5f..80dbd285 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductReviewRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductReviewRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Support\Query\PaginationLimits; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; @@ -145,6 +146,7 @@ class ProductReviewRepository implements ProductReviewRepositoryInterface 'orderOption.order', 'replyAdmin', ], + resultCap: PaginationLimits::resultCap('admin.product_reviews'), ); } @@ -222,6 +224,7 @@ class ProductReviewRepository implements ProductReviewRepositoryInterface // 개수는 배열 길이 대신 DB 집계를 그대로 쓴다(Resource 가 집계를 우선한다). relations: ['user', 'images'], withCount: ['images as image_count'], + resultCap: PaginationLimits::resultCap('shop.product_reviews'), ); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/AdditionalOptionSelectionService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/AdditionalOptionSelectionService.php index b3209800..48b7278c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/AdditionalOptionSelectionService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/AdditionalOptionSelectionService.php @@ -2,7 +2,9 @@ namespace Modules\Sirsoft\Ecommerce\Services; +use Illuminate\Support\Collection; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; +use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; @@ -14,11 +16,56 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; */ class AdditionalOptionSelectionService { + /** + * 상품 ID → 활성 선택지 맵 (요청 스코프) + * + * @var array + */ + private array $activeValuesCache = []; + + /** + * 상품 ID → 필수 그룹 판정용 상품 모델 (요청 스코프) + * + * @var array + */ + private array $productCache = []; + public function __construct( protected ProductAdditionalOptionValueRepositoryInterface $additionalOptionValueRepository, protected ProductRepositoryInterface $productRepository ) {} + /** + * 여러 상품의 검증 자료를 한 번에 적재합니다. + * + * 장바구니 병합·바로구매처럼 항목이 여러 개인 경로는 검증 전에 이 메서드를 부릅니다. + * 부르지 않아도 동작은 같지만, 그 경우 상품마다 조회가 발생합니다. + * + * @param array $productIds 상품 ID 목록 + */ + public function prefetchForProducts(array $productIds): void + { + $productIds = array_values(array_unique(array_filter(array_map('intval', $productIds)))); + $missing = array_values(array_diff($productIds, array_keys($this->activeValuesCache))); + + if (empty($missing)) { + return; + } + + $valuesByProduct = $this->additionalOptionValueRepository->getActiveByProductIdsKeyed($missing); + + foreach ($missing as $productId) { + $this->activeValuesCache[$productId] = $valuesByProduct->get($productId) ?? collect(); + } + + // 필수 그룹 판정에 쓰는 상품 + 그룹도 한 번에 읽는다. + $products = $this->productRepository->findByIdsWithRelationsKeyed($missing, ['additionalOptions']); + + foreach ($missing as $productId) { + $this->productCache[$productId] = $products->get($productId); + } + } + /** * 추가옵션 선택을 검증하고 정규화합니다. * @@ -39,7 +86,8 @@ class AdditionalOptionSelectionService $selections = $selections ?? []; // 상품에 정의된 활성 선택지 lookup (value_id 키) - $activeValues = $this->additionalOptionValueRepository->getActiveByProductKeyed($productId); + // prefetchForProducts() 를 먼저 부른 경로는 여기서 쿼리가 나가지 않는다. + $activeValues = $this->resolveActiveValues($productId); // 그룹당 1개로 정규화 (라디오) — 값과 직접입력 텍스트를 함께 보관 $byGroup = []; @@ -77,9 +125,8 @@ class AdditionalOptionSelectionService } // 필수 그룹 미선택 + 필수 그룹의 직접입력 빈값 검증 (D9/D12/Q-E1) - $product = $this->productRepository->find($productId); + $product = $this->resolveProduct($productId); if ($product) { - $product->loadMissing('additionalOptions'); foreach ($product->additionalOptions as $group) { // 필수 그룹 미선택 차단 if ($group->is_required && ! isset($byGroup[$group->id])) { @@ -122,4 +169,37 @@ class AdditionalOptionSelectionService return $normalized; } + + /** + * 상품의 활성 선택지 맵을 반환합니다 (요청 스코프 캐시). + * + * @param int $productId 상품 ID + * @return Collection value_id 키 맵 + */ + private function resolveActiveValues(int $productId) + { + if (! array_key_exists($productId, $this->activeValuesCache)) { + $this->activeValuesCache[$productId] = $this->additionalOptionValueRepository + ->getActiveByProductKeyed($productId); + } + + return $this->activeValuesCache[$productId]; + } + + /** + * 필수 그룹 판정용 상품을 반환합니다 (요청 스코프 캐시, additionalOptions 적재). + * + * @param int $productId 상품 ID + * @return Product|null 상품 모델 + */ + private function resolveProduct(int $productId) + { + if (! array_key_exists($productId, $this->productCache)) { + $product = $this->productRepository->find($productId); + $product?->loadMissing('additionalOptions'); + $this->productCache[$productId] = $product; + } + + return $this->productCache[$productId]; + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php index 07b36981..255eca44 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php @@ -554,15 +554,33 @@ class CartService $guestItems = $this->cartRepository->findByCartKeyWithoutUser($cartKey); $count = 0; + // 병합할 항목이 없으면 사전 조회도 하지 않는다 (빈 cart_key·존재하지 않는 키) + if ($guestItems->isEmpty()) { + return $count; + } + + // 항목마다 옵션과 회원 장바구니 라인을 조회하면 로그인 한 번에 항목 수 × 2 만큼 + // 쿼리가 난다. 루프 밖에서 한 번씩만 읽어 맵으로 들고 간다. + $guestOptionIds = $guestItems->pluck('product_option_id')->all(); + $optionsById = $this->productOptionRepository->findByIds($guestOptionIds)->keyBy('id'); + $memberLinesByOption = $this->cartRepository->findAllByUserAndOptions($userId, $guestOptionIds); + + // 구매 수량 한도 판정에 쓰는 상품별 합계도 시작 시점 값을 한 번만 읽는다. + // 루프가 회원 장바구니를 바꾸므로 그 변동분은 아래에서 메모리로 누적한다 — + // 매 항목 합계를 다시 조회하면 항목 수만큼 쿼리가 더 난다. + $memberTotalByProduct = $this->cartRepository->sumQuantityByProducts( + $guestItems->pluck('product_id')->all(), + $userId + ); + foreach ($guestItems as $guestItem) { // 재고 확인 - $productOption = $this->productOptionRepository->findById($guestItem->product_option_id); + $productOption = $optionsById->get($guestItem->product_option_id); $availableStock = $productOption?->stock_quantity ?? 0; // 회원 장바구니에 동일 옵션 + 동일 추가옵션 조합이 있는지 확인 (D3) $guestHash = $guestItem->getAdditionalOptionSelectionHash(); - $existingItem = $this->cartRepository - ->findAllByUserAndOption($userId, $guestItem->product_option_id) + $existingItem = ($memberLinesByOption->get($guestItem->product_option_id) ?? collect()) ->first(fn ($candidate) => $candidate->getAdditionalOptionSelectionHash() === $guestHash); if ($existingItem) { @@ -573,12 +591,9 @@ class CartService // 로그인 시 자동 병합이라 예외를 던지면 로그인 자체가 실패한다 → // 상한까지 클램프하고 조정 내역을 반환값에 담아 화면이 안내하게 한다. // 같은 상품의 다른 라인 수량까지 합산해 판정한다 (옵션 분할 우회 차단). - $otherLinesTotal = $this->cartRepository->sumQuantityByProduct( - $guestItem->product_id, - $userId, - null, - $existingItem->id, - ); + // 지금 합치는 라인 자신은 빼야 하므로 그 수량만 제외한다. + $productTotal = $memberTotalByProduct[$guestItem->product_id] ?? 0; + $otherLinesTotal = max(0, $productTotal - (int) $existingItem->quantity); $clamped = $this->clampToPurchaseQuantityLimit( $guestItem->product_id, @@ -599,6 +614,9 @@ class CartService ]; } + // 이 라인의 수량이 바뀐 만큼 상품 합계도 옮겨 둔다 (다음 항목 판정용). + $memberTotalByProduct[$guestItem->product_id] = $otherLinesTotal + $clamped; + $this->cartRepository->update($existingItem, [ 'quantity' => $clamped, ]); @@ -607,11 +625,8 @@ class CartService // 동일 옵션 없음: 재고 초과 시 조정 후 user_id 업데이트 (cart_key 유지) $finalQuantity = $availableStock > 0 ? min($guestItem->quantity, $availableStock) : $guestItem->quantity; - $otherLinesTotal = $this->cartRepository->sumQuantityByProduct( - $guestItem->product_id, - $userId, - null, - ); + // 회원 장바구니에 같은 옵션이 없으므로 기존 합계 전부가 '다른 라인' 이다. + $otherLinesTotal = $memberTotalByProduct[$guestItem->product_id] ?? 0; $clamped = $this->clampToPurchaseQuantityLimit( $guestItem->product_id, @@ -637,6 +652,9 @@ class CartService 'user_id' => $userId, 'quantity' => $clamped, ]); + + // 이 라인이 회원 장바구니로 넘어왔으므로 상품 합계에 더한다. + $memberTotalByProduct[$guestItem->product_id] = $otherLinesTotal + $clamped; } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php index 0562ca8c..3fa89184 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php @@ -89,7 +89,7 @@ class CategoryService $query->withCount('products')->orderBy('sort_order'); }, 'images', - ]); + ], withCounts: true); if ($category && ! $category->is_active) { return null; @@ -118,7 +118,7 @@ class CategoryService 'images', 'children', 'parent:id,name,slug', // parent에서 필요한 필드만 선택 - ]); + ], withCounts: true); if ($category) { // 필터 훅 - 조회 결과 변형 @@ -186,7 +186,8 @@ class CategoryService */ public function updateCategory(int $id, array $data): Category { - $category = $this->repository->findById($id); + // 쓰기 경로는 상품 수·자식 수 집계를 읽지 않는다 — 계산하면 조회마다 서브쿼리 2개가 붙는다. + $category = $this->repository->findById($id, withCounts: false); if (! $category) { throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]); @@ -245,7 +246,7 @@ class CategoryService */ public function deleteCategory(int $id): array { - $category = $this->repository->findById($id); + $category = $this->repository->findById($id, withCounts: false); if (! $category) { throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]); @@ -299,7 +300,7 @@ class CategoryService return ['depth' => 0, 'path' => '']; } - $parent = $this->repository->findById($parentId); + $parent = $this->repository->findById($parentId, withCounts: false); if (! $parent) { return ['depth' => 0, 'path' => '']; @@ -317,7 +318,7 @@ class CategoryService private function updatePath(Category $category): void { if ($category->parent_id) { - $parent = $this->repository->findById($category->parent_id); + $parent = $this->repository->findById($category->parent_id, withCounts: false); if ($parent) { $newPath = $parent->path ? $parent->path.'/'.$category->id : (string) $category->id; $category->update(['path' => $newPath]); @@ -365,7 +366,7 @@ class CategoryService */ public function toggleStatus(int $id): Category { - $category = $this->repository->findById($id); + $category = $this->repository->findById($id, withCounts: false); if (! $category) { throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]); @@ -394,9 +395,15 @@ class CategoryService // Before 훅 HookManager::doAction('sirsoft-ecommerce.category.before_reorder', $orders); - DB::transaction(function () use ($orders) { + // 항목마다 조회하면 재정렬 한 번에 항목 수만큼 쿼리가 난다. 한 번에 읽어 맵으로 들고 간다. + // 집계(상품 수·자식 수)는 재정렬이 읽지 않으므로 계산하지 않는다. + $categoriesById = $this->repository->findByIdsKeyed( + array_map(fn ($order) => (int) $order['id'], $orders) + ); + + DB::transaction(function () use ($orders, $categoriesById) { foreach ($orders as $order) { - $category = $this->repository->findById($order['id']); + $category = $categoriesById->get((int) $order['id']); if (! $category) { continue; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php index bf208622..ba29276b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php @@ -3,6 +3,10 @@ namespace Modules\Sirsoft\Ecommerce\Services; use App\Extension\HookManager; +use App\Search\SearchPagePolicy; +use App\Support\Query\BoundedCount; +use App\Support\Query\BoundedPage; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Auth; @@ -24,6 +28,24 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; */ class ProductService { + /** + * 검색 정렬 이름 → [실제 컬럼, 방향] 선언 + * + * 코어({@see SearchPagePolicy})가 이 선언을 읽어 커서 적용 여부를 판정한다. + * 여기에 없는 정렬 이름(관련도순 등)은 커서로 처리하지 않고 offset 을 유지한다. + */ + public const SEARCH_SORT_MAP = [ + 'latest' => ['created_at', 'desc'], + 'oldest' => ['created_at', 'asc'], + 'price_asc' => ['selling_price', 'asc'], + 'price_desc' => ['selling_price', 'desc'], + ]; + + /** + * 커서(키셋) 경계로 쓸 수 있는 실제 컬럼 선언 + */ + public const SEARCH_CURSOR_COLUMNS = ['created_at', 'selling_price']; + /** * HTMLPurifier 인스턴스 (지연 생성) */ @@ -1829,21 +1851,50 @@ class ProductService * @param int $limit 조회할 최대 항목 수 * @return array{total: int, items: Collection} */ - public function searchByKeyword(string $keyword, string $sort = 'latest', ?int $categoryId = null, int $offset = 0, int $limit = 10): array + public function searchByKeyword(string $keyword, string $sort = 'latest', ?int $categoryId = null, int $offset = 0, int $limit = 10): BoundedPage { [$orderBy, $direction] = $this->resolveSortColumn($sort); return $this->repository->searchByKeyword($keyword, $orderBy, $direction, $categoryId, $offset, $limit); } + /** + * 키워드로 상품을 커서(키셋)로 검색합니다. + * + * 커서 적용 가능 여부는 코어({@see SearchPagePolicy})가 판정한다. 이 서비스는 + * 정렬 선언({@see self::SEARCH_SORT_MAP})만 제공하고 규칙을 다시 쓰지 않는다. + * + * @param string $keyword 검색 키워드 + * @param string $sort 정렬 옵션 + * @param int|null $categoryId 카테고리 필터 + * @param int $perPage 페이지당 항목 수 + * @param string|null $cursor 인코딩된 커서 (첫 페이지면 null) + * @return CursorPaginator|null 커서 페이지 결과 (커서 적용 불가 시 null) + */ + public function searchByKeywordWithCursor( + string $keyword, + string $sort = 'latest', + ?int $categoryId = null, + int $perPage = 10, + ?string $cursor = null + ): ?CursorPaginator { + $sortKeys = SearchPagePolicy::sortKeys($sort, self::SEARCH_SORT_MAP); + + if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS)) { + return null; + } + + return $this->repository->searchByKeywordWithCursor($keyword, $sortKeys, $categoryId, $perPage, $cursor); + } + /** * 키워드와 일치하는 공개 상품 수를 조회합니다. * * @param string $keyword 검색 키워드 * @param int|null $categoryId 카테고리 필터 - * @return int 일치하는 상품 수 + * @return BoundedCount 일치하는 상품 수 (정확도 포함) */ - public function countByKeyword(string $keyword, ?int $categoryId = null): int + public function countByKeyword(string $keyword, ?int $categoryId = null): BoundedCount { return $this->repository->countByKeyword($keyword, $categoryId); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php index d9279ea6..f3187f56 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php @@ -215,13 +215,18 @@ class TempOrderService { $cartItems = new Collection; - foreach ($items as $item) { + // 항목마다 옵션을 두 번 조회하면 체크아웃 한 번에 항목 수 × 2 만큼 쿼리가 난다. + // 상품별 옵션과 확정된 옵션 상세를 각각 한 번씩만 읽어 맵으로 들고 간다. + $optionsByProduct = $this->productOptionRepository->getByProductIds( + array_map(fn ($item) => (int) ($item['product_id'] ?? 0), $items) + ); + + $resolvedOptionIds = []; + + foreach ($items as $index => $item) { $productId = (int) ($item['product_id'] ?? 0); $requestedOptionId = $item['product_option_id'] ?? null; - $quantity = max(1, (int) ($item['quantity'] ?? 1)); - - // 상품 옵션 매칭 (옵션 ID 기준, 없으면 기본 옵션) - $productOptions = $this->productOptionRepository->getByProductId($productId); + $productOptions = $optionsByProduct->get($productId) ?? new Collection; if (! empty($requestedOptionId)) { $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId); @@ -230,18 +235,34 @@ class TempOrderService throw new CartOperationException('option_not_found'); } - $optionId = $matchedOption->id; - } else { - $defaultOption = $productOptions->first(); - if (! $defaultOption) { - throw new CartOperationException('option_not_found'); - } + $resolvedOptionIds[$index] = $matchedOption->id; - $optionId = $defaultOption->id; + continue; } - // product/images 관계를 포함해 옵션 재조회 (검증·계산에 필요) - $option = $this->productOptionRepository->findByIdsWithProduct([$optionId])->first(); + $defaultOption = $productOptions->first(); + if (! $defaultOption) { + throw new CartOperationException('option_not_found'); + } + + $resolvedOptionIds[$index] = $defaultOption->id; + } + + // product/images 관계를 포함해 확정 옵션을 한 번에 재조회 (검증·계산에 필요) + $optionsById = $this->productOptionRepository + ->findByIdsWithProduct(array_values($resolvedOptionIds)) + ->keyBy('id'); + + // 추가옵션 검증 자료도 항목 루프 전에 한 번에 적재한다. 이걸 빼면 아래 루프가 + // 항목마다 선택지·상품·그룹을 다시 읽는다(항목당 3쿼리). + $this->additionalOptionSelectionService->prefetchForProducts( + $optionsById->pluck('product_id')->all() + ); + + foreach ($items as $index => $item) { + $quantity = max(1, (int) ($item['quantity'] ?? 1)); + + $option = $optionsById->get($resolvedOptionIds[$index]); if (! $option || ! $option->product) { throw new CartOperationException('option_not_found'); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/routes/api.php b/modules/_bundled/sirsoft-ecommerce/src/routes/api.php index 327427c1..f9614af0 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/routes/api.php +++ b/modules/_bundled/sirsoft-ecommerce/src/routes/api.php @@ -33,6 +33,7 @@ use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductImageController; use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductInquiryController as PublicProductInquiryController; use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductReviewController as PublicProductReviewController; use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\PublicCouponController; +use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\StorefrontController; use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\WishlistController; use Modules\Sirsoft\Ecommerce\Http\Controllers\Shop\PaymentConfigController; use Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController as UserOrderController; @@ -73,6 +74,13 @@ Route::prefix('categories')->group(function () { ->name('categories.show'); }); +// 쇼핑 첫 화면 통합 조회 API +// GET /api/modules/sirsoft-ecommerce/storefront - 분류·상품목록·최근본·인기·신상품 한 번에 조회 +// (각 묶음의 응답 형태는 개별 엔드포인트와 동일 — 조립 위치만 서버로 옮긴 것) +Route::get('storefront', [StorefrontController::class, 'index']) + ->middleware(['optional.sanctum', 'permission:user,sirsoft-ecommerce.user-products.read']) + ->name('storefront.index'); + // 공개 상품 API // GET /api/modules/sirsoft-ecommerce/products - 공개 상품 목록 조회 // GET /api/modules/sirsoft-ecommerce/products/popular - 인기 상품 조회 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/EcommerceUserAbilitiesTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/EcommerceUserAbilitiesTest.php index 719f3fa3..58702a86 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/EcommerceUserAbilitiesTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/EcommerceUserAbilitiesTest.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature; use App\Enums\PermissionType; +use App\Http\Middleware\PermissionMiddleware; use App\Models\Permission; use App\Models\Role; use App\Models\User; @@ -86,10 +87,7 @@ class EcommerceUserAbilitiesTest extends ModuleTestCase // 블랙리스트에는 권한 할당 없음 // PermissionMiddleware의 guest role 정적 캐시 초기화 - $reflection = new \ReflectionClass(\App\Http\Middleware\PermissionMiddleware::class); - $prop = $reflection->getProperty('guestRoleCache'); - $prop->setAccessible(true); - $prop->setValue(null, null); + PermissionMiddleware::clearGuestRoleCache(); // 사용자 생성 $this->permittedUser = User::factory()->create(); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/EcommerceUserPermissionTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/EcommerceUserPermissionTest.php index 1cfa216f..a27c2bcc 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/EcommerceUserPermissionTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/EcommerceUserPermissionTest.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature; use App\Enums\PermissionType; +use App\Http\Middleware\PermissionMiddleware; use App\Models\Permission; use App\Models\Role; use App\Models\User; @@ -88,10 +89,7 @@ class EcommerceUserPermissionTest extends ModuleTestCase $guestRole->permissions()->syncWithoutDetaching([$productReadPerm->id]); // PermissionMiddleware의 guest role 정적 캐시 초기화 - $reflection = new \ReflectionClass(\App\Http\Middleware\PermissionMiddleware::class); - $prop = $reflection->getProperty('guestRoleCache'); - $prop->setAccessible(true); - $prop->setValue(null, null); + PermissionMiddleware::clearGuestRoleCache(); // 사용자 생성 $this->permittedUser = User::factory()->create(); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductControllerTest.php index f70bb53c..67e4f405 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductControllerTest.php @@ -33,10 +33,7 @@ class PublicProductControllerTest extends ModuleTestCase // PermissionMiddleware의 guest role 정적 캐시 초기화 // (TestingSeeder에서 guest 역할을 생성하지만, 정적 캐시가 null로 초기화되지 않을 수 있음) - $reflection = new \ReflectionClass(PermissionMiddleware::class); - $prop = $reflection->getProperty('guestRoleCache'); - $prop->setAccessible(true); - $prop->setValue(null, null); + PermissionMiddleware::clearGuestRoleCache(); } // ======================================== diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/ListCacheInvalidationTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/ListCacheInvalidationTest.php new file mode 100644 index 00000000..41818819 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/ListCacheInvalidationTest.php @@ -0,0 +1,189 @@ + ['ko' => $name], + 'slug' => $slug, + 'depth' => 0, + 'sort_order' => $sortOrder, + 'is_active' => true, + 'path' => '', + ]); + + $category->update(['path' => (string) $category->id]); + + return $category; + } + + /** + * 테스트용 상품을 만듭니다. + * + * @param string $code 상품 코드 + * @return Product 생성된 상품 + */ + private function makeProduct(string $code) + { + return app(ProductRepositoryInterface::class)->create([ + 'product_code' => $code, + 'name' => ['ko' => '캐시 테스트 상품 '.$code], + 'selling_price' => 1000, + 'stock_quantity' => 5, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + ]); + } + + /** + * 카테고리 트리가 두 번째 호출에서 캐시로 응답하는지 확인 + */ + public function test_category_tree_is_cached_between_calls(): void + { + $this->makeCategory('appliance', '가전', 0); + + $this->assertCount(1, Category::getTree()); + + // 무효화 훅을 거치지 않고 행을 늘렸는데도 결과가 그대로라면, 두 번째 호출이 + // 캐시였다는 뜻이다 (Model::create 는 Service 훅을 발화하지 않는다). + $this->makeCategory('clothing', '의류', 1); + + $this->assertCount(1, Category::getTree()); + } + + /** + * 트리 캐시를 비우면 다음 호출이 다시 계산하는지 확인 + */ + public function test_flush_makes_tree_recompute(): void + { + $this->makeCategory('appliance', '가전', 0); + Category::getTree(); + + $this->makeCategory('clothing', '의류', 1); + Category::flushTreeCache(); + + $this->assertCount(2, Category::getTree()); + } + + /** + * 상품 변경 훅에서도 트리 캐시가 비워지는지 확인 + * + * 트리에는 카테고리 구조뿐 아니라 상품 수 집계가 함께 실린다. 상품 훅을 빠뜨리면 + * 카테고리 배지의 상품 수가 오류 없이 낡은 값으로 남는다. + */ + public function test_product_hooks_also_invalidate_tree_cache(): void + { + $hooks = CategoryTreeCacheListener::getSubscribedHooks(); + + foreach ([ + 'sirsoft-ecommerce.product.after_create', + 'sirsoft-ecommerce.product.after_update', + 'sirsoft-ecommerce.product.after_delete', + 'sirsoft-ecommerce.product.after_bulk_update', + ] as $hook) { + $this->assertArrayHasKey($hook, $hooks, "상품 훅 {$hook} 가 트리 캐시 무효화에 연결돼 있지 않다"); + } + + $this->makeCategory('appliance', '가전', 0); + Category::getTree(); + + $this->makeCategory('clothing', '의류', 1); + (new CategoryTreeCacheListener)->flush(); + + $this->assertCount(2, Category::getTree()); + } + + /** + * 상품 통계가 쓰기 경로에서 즉시 무효화되는지 확인 + */ + public function test_product_statistics_cache_is_invalidated_on_write(): void + { + $repository = app(ProductRepositoryInterface::class); + + $before = $repository->getStatistics()['total']; + + $product = $this->makeProduct('TEST-CACHE-1'); + + $this->assertSame( + $before + 1, + $repository->getStatistics()['total'], + '상품을 만들었는데 통계가 그대로다 — 쓰기 경로에서 캐시가 비워지지 않았다' + ); + + $repository->delete($product); + + $this->assertSame($before, $repository->getStatistics()['total']); + } + + /** + * 재고 변경도 통계 무효화 대상인지 확인 + * + * 통계에 품절·재고부족 건수가 들어 있으므로 재고만 바뀌어도 값이 달라진다. + */ + public function test_stock_change_invalidates_statistics(): void + { + $repository = app(ProductRepositoryInterface::class); + + $product = $this->makeProduct('TEST-CACHE-2'); + $before = $repository->getStatistics()['out_of_stock_count']; + + $repository->updateStockQuantity($product->id, 0); + + $this->assertSame( + $before + 1, + $repository->getStatistics()['out_of_stock_count'], + '재고를 0 으로 바꿨는데 품절 건수가 그대로다 — 재고 경로에서 캐시가 비워지지 않았다' + ); + } + + /** + * 통계 캐시를 끄면 캐시를 거치지 않고 매번 집계하는지 확인 + */ + public function test_statistics_cache_can_be_disabled(): void + { + config(['g7_settings.core.cache.stats_enabled' => false]); + + $repository = app(ProductRepositoryInterface::class); + $before = $repository->getStatistics()['total']; + + // 캐시가 꺼져 있으면 무효화 호출 없이도 다음 조회에 곧바로 반영된다 + Category::query()->getConnection()->table('ecommerce_products')->insert([ + 'product_code' => 'TEST-CACHE-3', + 'name' => json_encode(['ko' => '직접 삽입'], JSON_UNESCAPED_UNICODE), + 'selling_price' => 1000, + 'stock_quantity' => 5, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertSame($before + 1, $repository->getStatistics()['total']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/ListQueryCountRegressionTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/ListQueryCountRegressionTest.php new file mode 100644 index 00000000..3b6c95cb --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/ListQueryCountRegressionTest.php @@ -0,0 +1,205 @@ + $prefix.'-'.$i, + 'name' => ['ko' => '상품 '.$prefix.$i], + 'selling_price' => 1000 + $i, + 'stock_quantity' => 10, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + ]); + } + } + + /** + * 관리자 상품 목록: 상품 수가 늘어도 쿼리 수가 늘지 않는지 확인 + * + * @effects product_list_query_count_stable + */ + public function test_admin_product_list_query_count_is_stable(): void + { + $repository = app(ProductRepositoryInterface::class); + $this->seedProducts(5, 'ADMIN-A'); + + $this->assertQueryCountStableAsDataGrows( + measure: fn () => $repository->getListWithFilters([], 50), + grow: fn () => $this->seedProducts(10, 'ADMIN-B'), + context: '관리자 상품 목록', + ); + } + + /** + * 공개 상품 목록: 상품 수가 늘어도 쿼리 수가 늘지 않는지 확인 + * + * @effects public_list_query_count_stable + */ + public function test_public_product_list_query_count_is_stable(): void + { + $repository = app(ProductRepositoryInterface::class); + $this->seedProducts(5, 'PUB-A'); + + $this->assertQueryCountStableAsDataGrows( + measure: fn () => $repository->getPublicList([], 50), + grow: fn () => $this->seedProducts(10, 'PUB-B'), + context: '공개 상품 목록', + ); + } + + /** + * 카테고리 트리: 카테고리 수가 늘어도 쿼리 수가 늘지 않는지 확인 + * + * 종전에는 노드마다 자식을 다시 조회해 카테고리 수만큼 쿼리가 나갔다. + * + * @effects category_tree_query_count_stable + */ + public function test_category_tree_query_count_is_stable(): void + { + $this->seedCategories(3, 'tree-a'); + + $this->assertQueryCountStableAsDataGrows( + measure: function () { + // 캐시가 있으면 두 번째 측정이 0 쿼리가 되어 판정이 무의미해진다. + // 여기서 재는 것은 "조립 쿼리 수가 노드 수에 비례하지 않는다" 이므로 매번 비운다. + Category::flushTreeCache(); + + return Category::getTree(); + }, + grow: fn () => $this->seedCategories(6, 'tree-b'), + context: '카테고리 트리', + ); + } + + /** + * 쇼핑 첫 화면(storefront): 상품 수가 늘어도 쿼리 수가 늘지 않는지 확인 + * + * 이 엔드포인트는 분류·상품·최근·인기·신상품 5묶음을 한 응답으로 합친다. 묶음마다 + * 관계가 달려 있어 어느 한 묶음이 관계 eager load 를 놓치면 그 자리에서 N+1 이 된다. + * 응답을 통째로 재야 그 사실이 드러난다. + * + * @effects storefront_query_count_stable + */ + public function test_storefront_query_count_is_stable(): void + { + $this->seedProducts(5, 'SHOP-A'); + + // storefront 는 `permission:user,sirsoft-ecommerce.user-products.read` 로 가드된다. + // 시드된 'user' 역할에 그 권한이 붙어 있는지는 앞선 테스트가 무엇을 했는지에 따라 + // 달라지므로(단독 실행은 통과, 연속 실행은 403), 전용 역할에 권한을 직접 부여한다. + $this->actingAs($this->userWithProductReadPermission(), 'sanctum'); + + $this->assertQueryCountStableAsDataGrows( + measure: function () { + // 카테고리 트리 캐시가 살아 있으면 두 번째 측정이 그만큼 적게 나와 + // 판정이 무의미해진다. 매 측정 조건을 같게 맞춘다. + Category::flushTreeCache(); + + $response = $this->getJson('/api/modules/sirsoft-ecommerce/storefront'); + $response->assertOk(); + }, + grow: fn () => $this->seedProducts(10, 'SHOP-B'), + context: '쇼핑 첫 화면(storefront)', + ); + } + + /** + * 공개 상품 조회 권한을 가진 사용자를 만듭니다. + * + * 전용 역할에 권한을 직접 붙여, 시드된 공용 역할의 상태에 의존하지 않게 한다. + * + * @return User 권한을 가진 사용자 + */ + private function userWithProductReadPermission(): User + { + $user = User::factory()->create(); + + $role = Role::create([ + 'identifier' => 'storefront-probe-'.$user->id, + 'name' => ['ko' => '진열 조회 테스트', 'en' => 'Storefront Probe'], + ]); + + $permission = Permission::firstOrCreate( + ['identifier' => 'sirsoft-ecommerce.user-products.read'], + [ + 'name' => ['ko' => '공개 상품 조회', 'en' => 'Read public products'], + 'type' => PermissionType::User, + ] + ); + + $role->permissions()->syncWithoutDetaching([$permission->id]); + $user->roles()->attach($role->id); + + return User::findOrFail($user->id); + } + + /** + * 카테고리를 계층으로 만듭니다 (루트 1 + 자식 N-1). + * + * @param int $count 생성할 수 + * @param string $prefix 슬러그 접두 + */ + private function seedCategories(int $count, string $prefix): void + { + $root = Category::create([ + 'name' => ['ko' => '루트 '.$prefix], + 'slug' => $prefix.'-root', + 'depth' => 0, + 'sort_order' => 0, + 'is_active' => true, + 'path' => '', + ]); + $root->update(['path' => (string) $root->id]); + + for ($i = 1; $i < $count; $i++) { + $child = Category::create([ + 'name' => ['ko' => '자식 '.$prefix.$i], + 'slug' => $prefix.'-child-'.$i, + 'parent_id' => $root->id, + 'depth' => 1, + 'sort_order' => $i, + 'is_active' => true, + 'path' => '', + ]); + $child->update(['path' => $root->id.'/'.$child->id]); + } + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/StorefrontIndexCoverageTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/StorefrontIndexCoverageTest.php new file mode 100644 index 00000000..79ed85c2 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/StorefrontIndexCoverageTest.php @@ -0,0 +1,269 @@ +getDriverName(), ['mysql', 'mariadb'], true)) { + $this->markTestSkipped('EXPLAIN 판정은 MySQL 계열에서만 수행합니다.'); + } + } + + /** + * 전시 조건 + 정렬을 덮는 색인이 존재하는지 확인 + * + * @effects display_sort_index_exists + */ + #[Test] + public function display_sort_indexes_exist(): void + { + $names = array_column(Schema::getIndexes('ecommerce_products'), 'name'); + + $this->assertContains('idx_products_display_created_id', $names, '등록일 정렬을 덮는 색인이 없다'); + $this->assertContains('idx_products_display_price_id', $names, '판매가 정렬을 덮는 색인이 없다'); + } + + /** + * 판매 집계용 커버링 색인이 존재하는지 확인 + * + * @effects sales_aggregate_index_exists + */ + #[Test] + public function sales_aggregate_index_exists(): void + { + $names = array_column(Schema::getIndexes('ecommerce_order_options'), 'name'); + + $this->assertContains( + 'idx_order_options_product_created_qty', + $names, + '판매 집계가 테이블 본문을 읽지 않도록 하는 색인이 없다' + ); + } + + /** + * 색인 컬럼 순서가 조건·정렬 순서와 일치하는지 확인 + * + * 컬럼 순서가 어긋나면 색인은 존재해도 정렬에 쓰이지 않는다. + * + * @effects product_list_uses_display_sort_index + */ + #[Test] + public function index_column_order_matches_query_shape(): void + { + $this->assertIndexColumns( + 'ecommerce_products', + 'idx_products_display_created_id', + ['display_status', 'created_at', 'id'] + ); + + $this->assertIndexColumns( + 'ecommerce_products', + 'idx_products_display_price_id', + ['display_status', 'selling_price', 'id'] + ); + + $this->assertIndexColumns( + 'ecommerce_order_options', + 'idx_order_options_product_created_qty', + ['product_id', 'created_at', 'quantity'] + ); + } + + /** + * 등록일 정렬 목록이 실제로 이 색인을 고르는지 EXPLAIN 으로 확인 + * + * @effects product_list_uses_display_sort_index + */ + #[Test] + public function query_plan_uses_the_display_sort_index(): void + { + $this->seedProductsForPlanning(); + + $query = DB::table('ecommerce_products') + ->where('display_status', 'visible') + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc') + ->limit(10); + + $this->assertQueryPlanUsesIndex($query, 'idx_products_display_created_id'); + } + + /** + * 판매가 정렬 목록이 실제로 이 색인을 고르는지 EXPLAIN 으로 확인 + * + * @effects product_list_uses_price_sort_index + */ + #[Test] + public function query_plan_uses_the_price_sort_index(): void + { + $this->seedProductsForPlanning(); + + $query = DB::table('ecommerce_products') + ->where('display_status', 'visible') + ->orderBy('selling_price', 'asc') + ->orderBy('id', 'asc') + ->limit(10); + + $this->assertQueryPlanUsesIndex($query, 'idx_products_display_price_id'); + } + + /** + * 판매 집계 파생 테이블이 실제로 이 색인을 고르는지 EXPLAIN 으로 확인 + * + * 이 색인은 `(product_id, created_at, quantity)` 커버링이라, 집계가 원본 행을 읽지 + * 않고 색인만으로 끝나야 한다. + * + * @effects sales_aggregate_uses_covering_index + */ + #[Test] + public function query_plan_uses_the_sales_aggregate_index(): void + { + $this->seedOrderOptionsForPlanning(); + + $query = DB::table('ecommerce_order_options') + ->select('product_id') + ->selectRaw('SUM(quantity) as total_sold') + ->where('created_at', '>=', now()->subDays(30)) + ->groupBy('product_id'); + + $this->assertQueryPlanUsesIndex($query, 'idx_order_options_product_created_qty'); + } + + /** + * 옵티마이저가 실제로 그 색인을 **선택**했는지 단언합니다. + * + * `possible_keys` 는 "쓸 수 있었다" 일 뿐이다. 후보에만 올라 있고 실제로는 전체 + * 스캔을 골라도 통과해 버리므로, 판정은 실행계획이 확정한 `key` 로만 한다. + * + * @param Builder $query 판정 대상 쿼리 + * @param string $indexName 선택되어야 하는 색인명 + */ + private function assertQueryPlanUsesIndex($query, string $indexName): void + { + $plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings()); + + $this->assertNotEmpty($plan, 'EXPLAIN 결과가 비어 있다'); + + $row = (array) $plan[0]; + $chosen = (string) ($row['key'] ?? ''); + + $this->assertStringContainsString( + $indexName, + $chosen, + "옵티마이저가 {$indexName} 을 고르지 않았다 (선택: ".($chosen !== '' ? $chosen : '없음(전체 스캔)'). + ', 후보: '.($row['possible_keys'] ?? '없음').') — 컬럼 순서나 조건 형태가 어긋났다' + ); + } + + /** + * 실행계획이 색인을 고를 만큼 상품 행을 채웁니다. + * + * 행이 몇 건뿐이면 전체 스캔이 정상 선택이라 `key` 단언이 성립하지 않는다. + */ + private function seedProductsForPlanning(): void + { + $rows = []; + + for ($i = 0; $i < 300; $i++) { + $rows[] = [ + 'product_code' => 'PLAN-'.$i, + 'name' => json_encode(['ko' => '계획 '.$i], JSON_UNESCAPED_UNICODE), + 'selling_price' => 1000 + $i, + 'stock_quantity' => 10, + 'sales_status' => 'on_sale', + 'display_status' => $i % 10 === 0 ? 'hidden' : 'visible', + 'created_at' => now()->subMinutes($i), + 'updated_at' => now(), + ]; + } + + DB::table('ecommerce_products')->insert($rows); + DB::statement('ANALYZE TABLE '.DB::getTablePrefix().'ecommerce_products'); + } + + /** + * 실행계획이 색인을 고를 만큼 주문 옵션 행을 채웁니다. + * + * order_id / product_id 에 외래키가 걸려 있어 실제 부모 행이 먼저 있어야 한다. + */ + private function seedOrderOptionsForPlanning(): void + { + // 컬럼이 많고 필수 항목이 대부분이라 팩토리로 한 건을 만들어 형태를 얻은 뒤, + // 색인 판정에 필요한 축(product_id / created_at / quantity)만 바꿔 복제한다. + $seed = OrderOption::factory()->create(); + $template = $seed->getAttributes(); + unset($template['id']); + + $productIds = DB::table('ecommerce_products')->limit(30)->pluck('id')->all(); + + if (empty($productIds)) { + $this->seedProductsForPlanning(); + $productIds = DB::table('ecommerce_products')->limit(30)->pluck('id')->all(); + } + + $rows = []; + + for ($i = 0; $i < 300; $i++) { + $rows[] = array_merge($template, [ + // 상품 FK 는 실제 존재하는 행이어야 한다 + 'product_id' => $productIds[$i % count($productIds)], + 'quantity' => ($i % 5) + 1, + 'created_at' => now()->subDays($i % 40), + 'updated_at' => now(), + ]); + } + + DB::table('ecommerce_order_options')->insert($rows); + DB::statement('ANALYZE TABLE '.DB::getTablePrefix().'ecommerce_order_options'); + } + + /** + * 색인의 컬럼 구성을 단언합니다. + * + * @param string $table 테이블명 (프리픽스 제외) + * @param string $indexName 색인명 + * @param array $expected 기대 컬럼 순서 + */ + private function assertIndexColumns(string $table, string $indexName, array $expected): void + { + $index = collect(Schema::getIndexes($table))->firstWhere('name', $indexName); + + $this->assertNotNull($index, "색인이 없다: {$indexName}"); + $this->assertSame( + $expected, + array_values($index['columns']), + "색인 컬럼 순서가 조건·정렬 순서와 다르다: {$indexName}" + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/StorefrontCategoryContractTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/StorefrontCategoryContractTest.php new file mode 100644 index 00000000..d3f8ab10 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/StorefrontCategoryContractTest.php @@ -0,0 +1,107 @@ +category = Category::create([ + 'name' => ['ko' => '통합조회 분류'], + 'slug' => 'storefront-contract', + 'depth' => 0, + 'sort_order' => 1, + 'is_active' => true, + 'path' => '', + ]); + + $this->category->update(['path' => (string) $this->category->id]); + } + + /** + * 통합 조회의 분류가 단건 엔드포인트와 같은 키 집합이어야 함 + * + * @effects storefront_category_matches_single_endpoint + */ + #[Test] + public function storefront_category_matches_single_endpoint(): void + { + $single = $this->getJson('/api/modules/sirsoft-ecommerce/categories/'.$this->category->slug); + $single->assertOk(); + + $combined = $this->getJson( + '/api/modules/sirsoft-ecommerce/storefront?category_slug='.$this->category->slug + ); + $combined->assertOk(); + + $expected = $single->json('data'); + $actual = $combined->json('data.category'); + + $this->assertIsArray($actual, '통합 조회 응답에 분류가 없다 — 분류 화면이 제목조차 못 그린다.'); + + $missing = array_diff(array_keys($expected), array_keys($actual)); + $this->assertSame( + [], + $missing, + '단건 엔드포인트에는 있고 통합 조회에는 없는 필드: '.implode(', ', $missing) + ); + + $this->assertSame($expected, $actual, '같은 분류인데 두 경로의 응답이 다르다.'); + } + + /** + * 분류를 지정하지 않으면 분류 키가 비어야 함 (쇼핑 첫 화면 경로) + * + * @effects storefront_without_slug_omits_category + */ + #[Test] + public function storefront_without_slug_omits_category(): void + { + $response = $this->getJson('/api/modules/sirsoft-ecommerce/storefront'); + + $response->assertOk(); + $this->assertNull($response->json('data.category')); + } + + /** + * 없는 분류를 가리키면 404 여야 함 + * + * 200 으로 빈 화면을 내보내면 검색엔진이 존재하지 않는 분류 주소를 정상 페이지로 수집한다. + * + * @effects storefront_unknown_slug_returns_not_found + */ + #[Test] + public function storefront_unknown_slug_returns_not_found(): void + { + $response = $this->getJson( + '/api/modules/sirsoft-ecommerce/storefront?category_slug=no-such-category' + ); + + $response->assertNotFound(); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php index a3ebee74..a264101e 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php @@ -2,7 +2,12 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners; +use App\Enums\TotalRelation; +use App\Models\Permission; +use App\Models\Role; use App\Models\User; +use App\Support\GuestRoleResolver; +use App\Support\Query\BoundedCount; use Illuminate\Support\Facades\Gate; use Modules\Sirsoft\Ecommerce\Listeners\SearchProductsListener; use Modules\Sirsoft\Ecommerce\Services\ProductService; @@ -17,20 +22,47 @@ class SearchProductsListenerTest extends TestCase private ProductService $productService; + /** + * 권한 허용 여부 (권한 거부 케이스에서 false 로 바꾼다) + * + * Gate::before 는 **먼저 등록된** 콜백이 non-null 을 돌려주면 그 값으로 확정된다. + * setUp 에서 곧바로 true 를 등록하면 개별 테스트가 뒤에 false 를 등록해도 절대 + * 이기지 못하므로, 판정을 이 플래그로 미룬다. + */ + private bool $permissionsAllowed = true; + protected function setUp(): void { parent::setUp(); $this->productService = $this->createMock(ProductService::class); $this->listener = new SearchProductsListener($this->productService); - // 기본적으로 권한 허용 (개별 테스트에서 오버라이드 가능) - Gate::before(fn () => true); + // 기본적으로 권한 허용 (개별 테스트에서 $this->permissionsAllowed 로 오버라이드) + Gate::before(fn () => $this->permissionsAllowed ? true : false); + } + + /** + * guest 역할에서 상품 조회 권한을 떼어냅니다. + * + * 비회원 판정은 Gate 가 아니라 guest 역할의 권한 행을 보므로, 거부 케이스를 만들려면 + * 권한 자체를 없애야 합니다. 요청 스코프 캐시도 함께 비웁니다. + */ + private function revokeGuestProductReadPermission(): void + { + $permission = Permission::where('identifier', 'sirsoft-ecommerce.user-products.read')->first(); + $guestRole = Role::where('identifier', 'guest')->first(); + + if ($permission && $guestRole) { + $guestRole->permissions()->detach($permission->id); + } + + GuestRoleResolver::flush(); } /** * getSubscribedHooks()가 올바른 훅 목록을 반환하는지 확인 */ - public function test_getSubscribedHooks_returns_correct_hooks(): void + public function test_get_subscribed_hooks_returns_correct_hooks(): void { $hooks = SearchProductsListener::getSubscribedHooks(); @@ -52,7 +84,7 @@ class SearchProductsListenerTest extends TestCase /** * addValidationRules()가 sort에 가격순 옵션을 추가하는지 확인 */ - public function test_addValidationRules_adds_price_sort_and_category_id(): void + public function test_add_validation_rules_adds_price_sort_and_category_id(): void { $rules = ['q' => ['nullable', 'string', 'max:200']]; @@ -74,11 +106,11 @@ class SearchProductsListenerTest extends TestCase /** * searchProducts()가 type이 products가 아닐 때 count만 반환하는지 확인 */ - public function test_searchProducts_returns_count_only_when_type_is_not_products_or_all(): void + public function test_search_products_returns_count_only_when_type_is_not_products_or_all(): void { $this->productService ->method('countByKeyword') - ->willReturn(3); + ->willReturn(new BoundedCount(3, TotalRelation::Exact, 10000)); $results = []; $context = ['type' => 'posts', 'q' => '테스트', 'user' => User::factory()->make()]; @@ -90,10 +122,34 @@ class SearchProductsListenerTest extends TestCase $this->assertEmpty($result['products']['items']); } + /** + * 비활성 탭 배지도 잘린 건수를 정확한 것처럼 보고하지 않는지 확인 (#519) + * + * 배지는 오류를 내지 않고 그냥 틀린 숫자로만 보이므로, 정확도가 함께 실리지 않으면 + * 상한에 걸린 10,000 이 "정확히 10,000 건" 으로 화면에 나간다. + */ + public function test_count_only_badge_carries_accuracy_when_truncated(): void + { + $this->productService + ->method('countByKeyword') + ->willReturn(new BoundedCount(10000, TotalRelation::AtLeast, 10000)); + + $result = $this->listener->searchProducts([], [ + 'type' => 'posts', + 'q' => '테스트', + 'user' => User::factory()->make(), + ]); + + $this->assertSame(10000, $result['products']['total']); + $this->assertSame('at_least', $result['products']['total_relation']); + $this->assertFalse($result['products']['total_is_exact']); + $this->assertSame(10000, $result['products']['result_cap']); + } + /** * searchProducts()가 빈 검색어일 때 스킵하는지 확인 */ - public function test_searchProducts_skips_when_keyword_is_empty(): void + public function test_search_products_skips_when_keyword_is_empty(): void { $results = []; $context = ['type' => 'all', 'q' => '']; @@ -106,10 +162,10 @@ class SearchProductsListenerTest extends TestCase /** * searchProducts()가 권한 없을 때 빈 결과를 반환하는지 확인 */ - public function test_searchProducts_returns_empty_when_no_permission(): void + public function test_search_products_returns_empty_when_no_permission(): void { // 모든 권한 거부 - Gate::before(fn () => false); + $this->permissionsAllowed = false; $results = []; $context = ['type' => 'all', 'q' => '테스트', 'user' => User::factory()->make()]; @@ -123,25 +179,25 @@ class SearchProductsListenerTest extends TestCase /** * searchProducts()가 비회원(user=null)이고 guest 권한 없을 때 빈 결과를 반환하는지 확인 */ - public function test_searchProducts_returns_empty_for_guest_without_permission(): void + public function test_search_products_returns_empty_for_guest_without_permission(): void { - // Gate::before 해제 — guest는 Gate를 거치지 않으므로 DB 기반 체크 - // guest role에 권한이 없으면 거부됨 - Gate::before(fn () => null); // null = Gate::before가 판단하지 않음 + // 비회원 판정은 Gate 가 아니라 guest 역할의 권한 행을 본다. + // 기본 설치의 guest 는 상품 조회 권한을 가지므로, 그 권한을 실제로 떼고 확인한다. + $this->revokeGuestProductReadPermission(); $results = []; $context = ['type' => 'all', 'q' => '테스트', 'user' => null]; $result = $this->listener->searchProducts($results, $context); - // guest 권한이 DB에 없으므로 빈 결과 + // guest 권한이 없으므로 빈 결과 $this->assertArrayNotHasKey('products', $result); } /** * buildProductsResponse()가 products 결과가 없을 때 스킵하는지 확인 */ - public function test_buildProductsResponse_skips_when_no_products_results(): void + public function test_build_products_response_skips_when_no_products_results(): void { $response = ['posts_count' => 5]; $results = []; // products 키 없음 @@ -155,12 +211,15 @@ class SearchProductsListenerTest extends TestCase } /** - * buildProductsResponse()가 all 탭에서 5개로 제한하는지 확인 + * buildProductsResponse()가 all 탭에서 조회된 항목을 그대로 싣는지 확인 + * + * 미리보기 개수만큼만 조회해 오므로 여기서 다시 자르지 않는다. 종전에는 페이지 분량을 + * 받아 PHP 에서 잘랐고, 그만큼 쓰이지 않을 행까지 읽었다. */ - public function test_buildProductsResponse_limits_items_for_all_tab(): void + public function test_build_products_response_passes_through_items_for_all_tab(): void { $items = []; - for ($i = 0; $i < 10; $i++) { + for ($i = 0; $i < 5; $i++) { $items[] = ['id' => $i + 1, 'name' => "상품 {$i}"]; } @@ -177,7 +236,7 @@ class SearchProductsListenerTest extends TestCase /** * buildProductsResponse()가 products 탭에서 페이지네이션 정보를 추가하는지 확인 */ - public function test_buildProductsResponse_adds_pagination_for_products_tab(): void + public function test_build_products_response_adds_pagination_for_products_tab(): void { $items = []; for ($i = 0; $i < 10; $i++) { @@ -185,7 +244,12 @@ class SearchProductsListenerTest extends TestCase } $response = []; - $results = ['products' => ['total' => 25, 'items' => $items]]; + $results = ['products' => [ + 'total' => 25, + 'items' => $items, + 'last_page' => 3, + 'has_more_pages' => true, + ]]; $context = ['type' => 'products', 'page' => 1, 'per_page' => 10]; $result = $this->listener->buildProductsResponse($response, $results, $context); @@ -195,14 +259,40 @@ class SearchProductsListenerTest extends TestCase $this->assertArrayHasKey('last_page', $result); $this->assertEquals(1, $result['current_page']); $this->assertEquals(10, $result['per_page']); - $this->assertEquals(3, $result['last_page']); // ceil(25/10) = 3 + $this->assertEquals(3, $result['last_page']); + $this->assertTrue($result['has_more_pages']); $this->assertCount(10, $result['products']); } + /** + * 총 건수가 상한에 걸리면 마지막 페이지가 null 로 전달되는지 확인 + */ + public function test_build_products_response_omits_last_page_when_total_is_bounded(): void + { + $response = []; + $results = ['products' => [ + 'total' => 10000, + 'total_is_exact' => false, + 'total_relation' => 'at_least', + 'result_cap' => 10000, + 'items' => [['id' => 1]], + 'last_page' => null, + 'has_more_pages' => true, + ]]; + $context = ['type' => 'products', 'page' => 1, 'per_page' => 10]; + + $result = $this->listener->buildProductsResponse($response, $results, $context); + + $this->assertNull($result['last_page'], '마지막 페이지는 계산 불가'); + $this->assertTrue($result['has_more_pages'], '"다음" 이동은 계속 열려 있다'); + $this->assertFalse($result['products_total_is_exact']); + $this->assertEquals('at_least', $result['products_total_relation']); + } + /** * buildProductsResponse()가 products_count를 설정하는지 확인 */ - public function test_buildProductsResponse_sets_products_count(): void + public function test_build_products_response_sets_products_count(): void { $response = []; $results = ['products' => ['total' => 7, 'items' => [['id' => 1]]]]; @@ -216,7 +306,7 @@ class SearchProductsListenerTest extends TestCase /** * buildProductsResponse()가 rating_avg, review_count 필드를 유지하는지 확인 */ - public function test_buildProductsResponse_preserves_rating_fields(): void + public function test_build_products_response_preserves_rating_fields(): void { $items = [ ['id' => 1, 'name' => '상품1', 'rating_avg' => 4.5, 'review_count' => 10], diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductBulkAdjustmentTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductBulkAdjustmentTest.php new file mode 100644 index 00000000..a2ca878f --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/ProductBulkAdjustmentTest.php @@ -0,0 +1,220 @@ +repository = app(ProductRepositoryInterface::class); + } + + /** + * 상품 하나를 만듭니다. + * + * @param string $code 상품 코드 + * @param float $price 판매가 + * @param int $stock 재고 수량 + * @return Product 생성된 상품 + */ + private function makeProduct(string $code, float $price, int $stock = 10): Product + { + return Product::create([ + 'product_code' => $code, + 'name' => ['ko' => '상품 '.$code], + 'selling_price' => $price, + 'stock_quantity' => $stock, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + ]); + } + + /** + * 정액 증액이 지정한 금액만큼 정확히 오르는지 확인 + * + * @effects bulk_price_increase_applies_exact_amount + */ + public function test_bulk_price_increase_applies_exact_amount(): void + { + // Given: 판매가가 서로 다른 상품 2건 + $a = $this->makeProduct('BP-A', 1000); + $b = $this->makeProduct('BP-B', 2500); + + // When: 정액 1000원 증액 + $affected = $this->repository->bulkUpdatePrice([$a->id, $b->id], 'increase', 1000, 'amount'); + + // Then: 각 행이 정확히 1000원씩 오른다 + $this->assertSame(2, $affected); + $this->assertEquals(2000.0, (float) $a->fresh()->selling_price); + $this->assertEquals(3500.0, (float) $b->fresh()->selling_price); + } + + /** + * 정액 감액이 지정한 금액만큼 정확히 내리는지 확인 + * + * @effects bulk_price_increase_applies_exact_amount + */ + public function test_bulk_price_decrease_applies_exact_amount(): void + { + // Given: 판매가 5000원 상품 + $product = $this->makeProduct('BP-C', 5000); + + // When: 정액 1500원 감액 + $this->repository->bulkUpdatePrice([$product->id], 'decrease', 1500, 'amount'); + + // Then: 3500원 + $this->assertEquals(3500.0, (float) $product->fresh()->selling_price); + } + + /** + * 정률 증감이 지정한 비율만큼 정확히 반영되는지 확인 + * + * @effects bulk_price_percent_applies_exact_ratio + */ + public function test_bulk_price_percent_applies_exact_ratio(): void + { + // Given: 판매가 1000원 상품 2건 + $up = $this->makeProduct('BP-D', 1000); + $down = $this->makeProduct('BP-E', 1000); + + // When: 각각 10% 증액 / 10% 감액 + $this->repository->bulkUpdatePrice([$up->id], 'increase', 10, 'percent'); + $this->repository->bulkUpdatePrice([$down->id], 'decrease', 10, 'percent'); + + // Then: 1100 / 900 + $this->assertEquals(1100.0, (float) $up->fresh()->selling_price); + $this->assertEquals(900.0, (float) $down->fresh()->selling_price); + } + + /** + * 감액 결과가 음수가 되지 않고 0 에서 멈추는지 확인 + * + * @effects bulk_price_floors_at_zero + */ + public function test_bulk_price_decrease_floors_at_zero(): void + { + // Given: 판매가 500원 상품 + $product = $this->makeProduct('BP-F', 500); + + // When: 판매가보다 큰 금액을 감액 + $this->repository->bulkUpdatePrice([$product->id], 'decrease', 900, 'amount'); + + // Then: 음수가 아니라 0 + $this->assertEquals(0.0, (float) $product->fresh()->selling_price); + } + + /** + * 재고 증감이 지정한 수량만큼 정확히 반영되는지 확인 + * + * @effects bulk_stock_increase_applies_exact_amount + */ + public function test_bulk_stock_increase_and_decrease_apply_exact_amount(): void + { + // Given: 재고가 서로 다른 상품 2건 + $a = $this->makeProduct('BS-A', 1000, 10); + $b = $this->makeProduct('BS-B', 1000, 40); + + // When: 두 건을 함께 5 증가 + $this->repository->bulkUpdateStock([$a->id, $b->id], 'increase', 5); + + // Then: 각각 정확히 5씩 증가 + $this->assertSame(15, (int) $a->fresh()->stock_quantity); + $this->assertSame(45, (int) $b->fresh()->stock_quantity); + + // When: 두 건을 함께 3 감소 + $this->repository->bulkUpdateStock([$a->id, $b->id], 'decrease', 3); + + // Then: 각각 정확히 3씩 감소 + $this->assertSame(12, (int) $a->fresh()->stock_quantity); + $this->assertSame(42, (int) $b->fresh()->stock_quantity); + } + + /** + * 재고 감소 결과가 음수가 되지 않고 0 에서 멈추는지 확인 + * + * @effects bulk_stock_floors_at_zero + */ + public function test_bulk_stock_decrease_floors_at_zero(): void + { + // Given: 재고 2개 상품 + $product = $this->makeProduct('BS-C', 1000, 2); + + // When: 재고보다 많은 수량을 감소 + $this->repository->bulkUpdateStock([$product->id], 'decrease', 10); + + // Then: 음수가 아니라 0 + $this->assertSame(0, (int) $product->fresh()->stock_quantity); + } + + /** + * 지정하지 않은 행이 영향받지 않는지 확인 + * + * WHERE 바인딩이 SET 절로 밀리면 대상 선택 자체가 어긋난다. + * + * @effects bulk_adjustment_touches_only_selected_rows + */ + public function test_bulk_adjustment_touches_only_selected_rows(): void + { + // Given: 대상 2건 + 비대상 1건 + $target1 = $this->makeProduct('BT-1', 1000, 10); + $target2 = $this->makeProduct('BT-2', 1000, 10); + $untouched = $this->makeProduct('BT-3', 1000, 10); + + // When: 대상 2건만 지정해 증감 + $this->repository->bulkUpdatePrice([$target1->id, $target2->id], 'increase', 500, 'amount'); + $this->repository->bulkUpdateStock([$target1->id, $target2->id], 'increase', 7); + + // Then: 대상만 바뀌고 비대상은 그대로 + $this->assertEquals(1500.0, (float) $target1->fresh()->selling_price); + $this->assertEquals(1500.0, (float) $target2->fresh()->selling_price); + $this->assertEquals(1000.0, (float) $untouched->fresh()->selling_price); + $this->assertSame(17, (int) $target1->fresh()->stock_quantity); + $this->assertSame(10, (int) $untouched->fresh()->stock_quantity); + } + + /** + * 'set' 경로가 종전대로 동작하는지 확인 (회귀 방지) + * + * @effects bulk_price_increase_applies_exact_amount + */ + public function test_bulk_set_replaces_value(): void + { + // Given: 판매가 1000원 / 재고 10 상품 + $product = $this->makeProduct('BSET-1', 1000, 10); + + // When: set 으로 지정 + $this->repository->bulkUpdatePrice([$product->id], 'set', 7777, 'amount'); + $this->repository->bulkUpdateStock([$product->id], 'set', 33); + + // Then: 지정값 그대로 + $this->assertEquals(7777.0, (float) $product->fresh()->selling_price); + $this->assertSame(33, (int) $product->fresh()->stock_quantity); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CartServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CartServiceTest.php index f06c89e9..6fe8d088 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CartServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CartServiceTest.php @@ -90,6 +90,12 @@ class CartServiceTest extends ModuleTestCase ->andReturn(0) ->byDefault(); + // 병합 경로는 상품별 합계를 시작 시점에 한 번만 읽는다 (항목마다 조회하지 않는다). + $this->mockCartRepository + ->shouldReceive('sumQuantityByProducts') + ->andReturn([]) + ->byDefault(); + $this->mockProductRepository ->shouldReceive('find') ->andReturnUsing(fn ($id) => Product::find($id)) @@ -729,17 +735,18 @@ class CartServiceTest extends ModuleTestCase ->once() ->andReturn($guestItems); - // Mock stock check in mergeGuestCartToUser + // 병합은 항목마다 조회하지 않고 루프 밖에서 한 번에 읽는다 + // (옵션 상세 1회 + 회원 장바구니 라인 1회) $this->mockProductOptionRepository - ->shouldReceive('findById') - ->with($option->id) - ->andReturn($option); + ->shouldReceive('findByIds') + ->once() + ->andReturn(new Collection([$option])); $this->mockCartRepository - ->shouldReceive('findAllByUserAndOption') - ->with($user->id, $option->id) + ->shouldReceive('findAllByUserAndOptions') + ->with($user->id, Mockery::any()) ->once() - ->andReturn(new Collection); + ->andReturn(collect()); $this->mockCartRepository ->shouldReceive('update') @@ -777,16 +784,18 @@ class CartServiceTest extends ModuleTestCase ->andReturn($guestItems); // Mock stock check in mergeGuestCartToUser + // 병합은 항목마다 조회하지 않고 루프 밖에서 한 번에 읽는다 + // (옵션 상세 1회 + 회원 장바구니 라인 1회) $this->mockProductOptionRepository - ->shouldReceive('findById') - ->with($option->id) - ->andReturn($option); + ->shouldReceive('findByIds') + ->once() + ->andReturn(new Collection([$option])); $this->mockCartRepository - ->shouldReceive('findAllByUserAndOption') - ->with($user->id, $option->id) + ->shouldReceive('findAllByUserAndOptions') + ->with($user->id, Mockery::any()) ->once() - ->andReturn(new Collection([$userCart])); + ->andReturn(collect([$option->id => new Collection([$userCart])])); $combinedCart = clone $userCart; $combinedCart->quantity = 5; @@ -1162,22 +1171,19 @@ class CartServiceTest extends ModuleTestCase ->once() ->andReturn($guestItems); - // Mock stock check in mergeGuestCartToUser + // 병합은 항목마다 조회하지 않고 루프 밖에서 한 번에 읽는다 + // (옵션 상세 1회 + 회원 장바구니 라인 1회) $this->mockProductOptionRepository - ->shouldReceive('findById') - ->with($optionA->id) - ->andReturn($optionA); - $this->mockProductOptionRepository - ->shouldReceive('findById') - ->with($optionB->id) - ->andReturn($optionB); - - // 옵션A: 회원 장바구니에 존재 → 합산 - $this->mockCartRepository - ->shouldReceive('findAllByUserAndOption') - ->with($user->id, $optionA->id) + ->shouldReceive('findByIds') ->once() - ->andReturn(new Collection([$userCartA])); + ->andReturn(new Collection([$optionA, $optionB])); + + // 옵션A 는 회원 장바구니에 존재(합산), 옵션B 는 없음(user_id 이관) + $this->mockCartRepository + ->shouldReceive('findAllByUserAndOptions') + ->with($user->id, Mockery::any()) + ->once() + ->andReturn(collect([$optionA->id => new Collection([$userCartA])])); $mergedCartA = clone $userCartA; $mergedCartA->quantity = 5; @@ -1195,13 +1201,6 @@ class CartServiceTest extends ModuleTestCase ->with($guestCartA) ->once(); - // 옵션B: 회원 장바구니에 없음 → user_id 업데이트 - $this->mockCartRepository - ->shouldReceive('findAllByUserAndOption') - ->with($user->id, $optionB->id) - ->once() - ->andReturn(new Collection); - $updatedGuestCartB = clone $guestCartB; $updatedGuestCartB->user_id = $user->id; $updatedGuestCartB->cart_key = null; @@ -1598,18 +1597,19 @@ class CartServiceTest extends ModuleTestCase ->once() ->andReturn(new Collection([$guestCart])); - // Mock stock check in mergeGuestCartToUser - 재고 5개 반환 + // 병합은 항목마다 조회하지 않고 루프 밖에서 한 번에 읽는다 + // (옵션 상세 1회 + 회원 장바구니 라인 1회) $this->mockProductOptionRepository - ->shouldReceive('findById') - ->with($option->id) - ->andReturn($option); + ->shouldReceive('findByIds') + ->once() + ->andReturn(new Collection([$option])); // 회원 장바구니에 같은 옵션 있음 $this->mockCartRepository - ->shouldReceive('findAllByUserAndOption') - ->with($user->id, $option->id) + ->shouldReceive('findAllByUserAndOptions') + ->with($user->id, Mockery::any()) ->once() - ->andReturn(new Collection([$userCart])); + ->andReturn(collect([$option->id => new Collection([$userCart])])); // 기대: 수량을 재고(5개)로 조정하여 업데이트 $updatedUserCart = clone $userCart; diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryServiceTest.php index 75e05833..3e551499 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryServiceTest.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services; +use Illuminate\Database\Eloquent\Relations\HasMany; use Mockery; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryImageRepositoryInterface; @@ -50,13 +51,14 @@ class CategoryServiceTest extends ModuleTestCase $category = Mockery::mock(Category::class)->makePartial(); $category->shouldReceive('getAttribute')->with('id')->andReturn(1); - $mockImages = Mockery::mock(\Illuminate\Database\Eloquent\Relations\HasMany::class); + $mockImages = Mockery::mock(HasMany::class); $mockImages->shouldReceive('delete')->once(); $category->shouldReceive('images')->once()->andReturn($mockImages); $this->mockRepository ->shouldReceive('findById') - ->with(1) + // 쓰기 경로는 상품 수·자식 수 집계를 읽지 않으므로 withCounts=false 로 조회한다 + ->with(1, [], false) ->once() ->andReturn($category); @@ -97,13 +99,14 @@ class CategoryServiceTest extends ModuleTestCase $category = Mockery::mock(Category::class)->makePartial(); $category->shouldReceive('getAttribute')->with('id')->andReturn(1); - $mockChildren = Mockery::mock(\Illuminate\Database\Eloquent\Relations\HasMany::class); + $mockChildren = Mockery::mock(HasMany::class); $mockChildren->shouldReceive('count')->andReturn(2); $category->shouldReceive('children')->andReturn($mockChildren); $this->mockRepository ->shouldReceive('findById') - ->with(1) + // 쓰기 경로는 상품 수·자식 수 집계를 읽지 않으므로 withCounts=false 로 조회한다 + ->with(1, [], false) ->once() ->andReturn($category); @@ -128,7 +131,8 @@ class CategoryServiceTest extends ModuleTestCase $this->mockRepository ->shouldReceive('findById') - ->with(1) + // 쓰기 경로는 상품 수·자식 수 집계를 읽지 않으므로 withCounts=false 로 조회한다 + ->with(1, [], false) ->once() ->andReturn($category); diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php index d1f96c3c..548c961c 100644 --- a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php @@ -19,6 +19,10 @@ use App\Extension\AbstractUpgradeStep; * 반영되므로 업그레이드 시점에 동일 인덱스를 적용한다. 데이터가 많은 쇼핑몰에서는 * ALTER TABLE 이 수 분 걸리며 그동안 해당 테이블 쓰기가 대기한다. * + * 06_AddStorefrontIndexes 는 쇼핑 진열·판매 집계 색인이다 — 전시 조건으로 좁힌 뒤 + * 등록일/판매가로 정렬하는 공개 상품 목록과, 주문 옵션을 상품별로 묶어 수량을 더하는 + * 판매량 집계가 쓴다. + * * 3. 주문에 보관된 배송정책 스냅샷을 리스트+메타 구조로 정규화한다. * 항목 목록과 배송지 메타를 한 배열에 섞어 담아 json_encode 가 리스트가 아닌 객체로 * 직렬화했고, 배열을 전제한 주문 상세 화면에서 배송정책 줄이 통째로 사라졌다. diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/06_AddStorefrontIndexes.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/06_AddStorefrontIndexes.php new file mode 100644 index 00000000..1c1d09bc --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/06_AddStorefrontIndexes.php @@ -0,0 +1,77 @@ + [테이블, 컬럼]] + * + * @var array}> + */ + private const TARGETS = [ + 'idx_products_display_created_id' => ['ecommerce_products', ['display_status', 'created_at', 'id']], + 'idx_products_display_price_id' => ['ecommerce_products', ['display_status', 'selling_price', 'id']], + 'idx_order_options_product_created_qty' => ['ecommerce_order_options', ['product_id', 'created_at', 'quantity']], + ]; + + /** + * 마이그레이션 식별자를 반환합니다. + * + * @return string 마이그레이션 이름 + */ + public function name(): string + { + return 'AddStorefrontIndexes'; + } + + /** + * 진열·집계 색인을 적용합니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + foreach (self::TARGETS as $indexName => [$table, $columns]) { + if (! Schema::hasTable($table)) { + $context->logger->info("[ecommerce:1.0.5] 테이블 부재 — 색인 적용 스킵: {$table}"); + + continue; + } + + if (in_array($indexName, array_column(Schema::getIndexes($table), 'name'), true)) { + $context->logger->info("[ecommerce:1.0.5] 이미 적용된 색인 — 스킵: {$indexName}"); + + continue; + } + + $context->logger->info("[ecommerce:1.0.5] 진열·집계 색인 적용 시작: {$table} (데이터가 많으면 수 분 걸릴 수 있고 그동안 해당 테이블 쓰기가 대기합니다)"); + + Schema::table($table, function ($blueprint) use ($columns, $indexName) { + $blueprint->index($columns, $indexName); + }); + + $context->logger->info("[ecommerce:1.0.5] 색인 적용 완료: {$indexName}"); + } + } +}