From be9befe023b6393890454da221d33fd5ab8f95db Mon Sep 17 00:00:00 2001 From: HeuJung Date: Thu, 30 Jul 2026 20:00:23 +0900 Subject: [PATCH] =?UTF-8?q?fix(core,board,ecommerce,page,gdpr,basic):=20?= =?UTF-8?q?=EA=B9=8A=EC=9D=80=20OFFSET=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20raw/=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EB=AA=85=20=EC=A0=84=EC=88=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록이 뒤쪽 페이지로 갈수록 느려지는 원인은 건너뛰는 행의 넓은 컬럼까지 읽는 것이었다. 지연 조인(inner 는 키 컬럼만, outer 는 이번 페이지 행만 전체 컬럼)과 정렬 컬럼 해석을 코어 공통 트레이트로 두고 코어 8 + 확장 6 저장소에 적용했다. 감사 지적 처리: - 그룹 쿼리의 총 건수 오산 — count 는 groupBy 를 벗기지 않아 첫 그룹의 행 수를 총 건수로 돌려준다. getCountForPagination 으로 교정. - audit 전수 스캔이 버퍼 한계로 한 번도 동작하지 않던 결함과, 확장 경로를 놓치던 정규식 5건을 함께 고쳤다. 그동안 번들 확장의 위반이 보이지 않았다. raw 20건을 빌더 표현으로 치환하고 하드코딩 테이블명 16건을 모델 유래로 바꿨다. 이 과정에서 마이페이지 「내가 댓글 단 글」 검색이 조인 별칭 충돌로 SQL 오류를 내던 결함이 해소됐다. 재발 차단용 룰 2종을 신설하고, 컬럼 프루닝 룰을 error 로 승격했다 (전역 위반 0건 — warning 으로 두면 "0 error" 판정을 통과하며 무음으로 재누적된다). 정렬 허용 목록은 FormRequest 게이트를 덮어야 한다. 좁으면 검증을 통과한 정렬이 422 도 로그도 없이 기본값으로 되돌아간다(스케줄 실행 이력의 종료시각·소요시간 정렬이 그랬다). 호출처를 스캔해 매핑 완전성까지 검사하는 계약 테스트로 전수 강제한다. 해외 배송지 결함도 함께 고쳤다 — 조회는 city/state/postal_code 로 내려주고 폼은 intl_* 로 제출하는 비대칭 때문에, 수정 시 입력칸이 비어 보이고 저장하면 기존 값이 유실됐다. 쿠폰 수정의 유효기간 미검증과 1인당 사용 제한 부분 수정 차단도 함께 수정. --- AGENTS.md | 16 +- CHANGELOG.md | 5 + .../Commands/BenchPaginationCommand.php | 556 ++++++++++++++ app/Repositories/ActivityLogRepository.php | 67 +- .../Concerns/PaginatesWithDeferredJoin.php | 258 +++++++ .../Concerns/ResolvesSortSpec.php | 105 +++ .../IdentityMessageDefinitionRepository.php | 21 +- app/Repositories/IdentityPolicyRepository.php | 1 + .../IdentityVerificationLogRepository.php | 19 +- app/Repositories/LanguagePackRepository.php | 21 +- app/Repositories/MenuRepository.php | 56 +- .../NotificationDefinitionRepository.php | 40 +- .../NotificationLogRepository.php | 41 +- app/Repositories/NotificationRepository.php | 30 +- app/Repositories/RoleRepository.php | 1 + .../ScheduleHistoryRepository.php | 61 +- app/Repositories/ScheduleRepository.php | 73 +- app/Repositories/UserRepository.php | 27 +- docs/backend/service-repository.md | 203 ++++++ docs/backend/validation.md | 63 ++ docs/cheatsheet.md | 11 + docs/database-guide.md | 19 + .../src/Repositories/MemoRepository.php | 5 +- modules/_bundled/sirsoft-board/CHANGELOG.md | 6 +- .../_bundled/sirsoft-board/docs/api/boards.md | 2 +- .../src/Http/Requests/UpdateBoardRequest.php | 15 +- .../src/Repositories/BoardRepository.php | 31 +- .../src/Repositories/CommentRepository.php | 31 +- .../src/Repositories/PostRepository.php | 171 +++-- .../src/Repositories/ReportRepository.php | 165 +++-- .../tests/Feature/BoardRequestTest.php | 120 ++- .../BoardRepositorySitemapTest.php | 8 +- .../CommentListDeferredJoinTest.php | 196 +++++ .../Repositories/PostListDeferredJoinTest.php | 196 +++++ .../UserCommentedActivityDeferredJoinTest.php | 167 +++++ .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 7 + ...xes_to_ecommerce_product_reviews_table.php | 72 ++ .../sirsoft-ecommerce/docs/api/addresses.md | 2 + .../sirsoft-ecommerce/docs/api/orders.md | 2 +- .../docs/api/promotion-coupons.md | 2 + .../Controllers/Admin/ProductController.php | 10 +- .../Requests/Admin/ProductLogsRequest.php | 16 +- ...uest.php => ProductShowForCopyRequest.php} | 16 +- .../Requests/Admin/UpdateCouponRequest.php | 17 +- .../Concerns/MapsAddressBookFields.php | 16 +- .../User/UpdateUserAddressRequest.php | 142 +++- .../Contracts/ProductRepositoryInterface.php | 2 +- .../Repositories/CouponIssueRepository.php | 15 +- .../src/Repositories/CouponRepository.php | 39 +- .../ExtraFeeTemplateRepository.php | 30 +- .../Repositories/MileageBalanceRepository.php | 16 +- .../MileageTransactionRepository.php | 41 +- .../src/Repositories/OrderRepository.php | 115 ++- .../ProductCommonInfoRepository.php | 4 + .../Repositories/ProductInquiryRepository.php | 28 +- .../ProductNoticeTemplateRepository.php | 2 + .../src/Repositories/ProductRepository.php | 113 ++- .../Repositories/ProductReviewRepository.php | 66 +- .../ProductWishlistRepository.php | 2 + .../Repositories/ShippingPolicyRepository.php | 19 +- .../CouponUpdateConditionalValidationTest.php | 172 +++++ .../Admin/OrderListDeferredJoinTest.php | 199 +++++ ...serAddressUpdatePresenceValidationTest.php | 187 +++++ .../02_AddProductReviewSortIndexes.php | 78 ++ modules/_bundled/sirsoft-page/CHANGELOG.md | 1 + .../src/Repositories/PageRepository.php | 41 +- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 5 +- plugins/_bundled/sirsoft-gdpr/plugin.json | 2 +- .../GdprPolicyVersionRepository.php | 14 +- .../GdprUserConsentHistoryRepository.php | 47 +- templates/_bundled/sirsoft-basic/CHANGELOG.md | 2 + .../partials/mypage/addresses/_list.json | 51 +- .../partials/shop/_modal_address_manage.json | 18 +- .../address-edit-intl-field-mapping.test.tsx | 112 +++ .../mypage-address-overseas-edit.spec.ts | 158 ++++ .../specs/pagination/deep-offset-list.spec.ts | 235 ++++++ .../DeferredJoinCallSiteContractTest.php | 161 ++++ .../PaginatesWithDeferredJoinTest.php | 690 ++++++++++++++++++ .../Concerns/ResolvesSortSpecTest.php | 143 ++++ .../SortableColumnsGateContractTest.php | 248 +++++++ tests/scenarios/deep-offset-pagination.yaml | 91 +++ 81 files changed, 5799 insertions(+), 457 deletions(-) create mode 100644 app/Console/Commands/BenchPaginationCommand.php create mode 100644 app/Repositories/Concerns/PaginatesWithDeferredJoin.php create mode 100644 app/Repositories/Concerns/ResolvesSortSpec.php create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Repositories/CommentListDeferredJoinTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostListDeferredJoinTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Repositories/UserCommentedActivityDeferredJoinTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php rename modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/{ShowProductForCopyRequest.php => ProductShowForCopyRequest.php} (71%) create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponUpdateConditionalValidationTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderListDeferredJoinTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserAddressUpdatePresenceValidationTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_AddProductReviewSortIndexes.php create mode 100644 templates/_bundled/sirsoft-basic/src/__tests__/layouts/address-edit-intl-field-mapping.test.tsx create mode 100644 templates/_bundled/sirsoft-basic/tests/Playwright/specs/mypage-address-overseas-edit.spec.ts create mode 100644 tests/Playwright/specs/pagination/deep-offset-list.spec.ts create mode 100644 tests/Unit/Repositories/Concerns/DeferredJoinCallSiteContractTest.php create mode 100644 tests/Unit/Repositories/Concerns/PaginatesWithDeferredJoinTest.php create mode 100644 tests/Unit/Repositories/Concerns/ResolvesSortSpecTest.php create mode 100644 tests/Unit/Repositories/Concerns/SortableColumnsGateContractTest.php create mode 100644 tests/scenarios/deep-offset-pagination.yaml diff --git a/AGENTS.md b/AGENTS.md index 9e9e4b5f..5da0d614 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,7 +192,7 @@ ### ① 코어 → 확장 동기화 (`requires.g7_version`) -- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정 +- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Repositories/Concerns/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정 - 조치: 영향 받는 번들 확장의 `g7_version` 상향 + 각 확장 CHANGELOG 에 변경 기재 ### ② 확장 → 확장 동기화 (`dependencies.{modules|plugins}`) @@ -355,6 +355,20 @@ Icon 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 > 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md) +### 목록 조회 컬럼 프루닝과 지연 조인 + +| 금지 | 올바른 사용 | +|------|------------| +| `->paginate($perPage)` / `->paginate($perPage, ['*'])` (컬럼 목록 미지정) | 목록이 실제로 쓰는 컬럼만 명시. 깊은 OFFSET 이 가능한 목록은 `PaginatesWithDeferredJoin` | +| 요청 값에서 온 정렬 컬럼을 그대로 `orderBy` 에 전달 | `ResolvesSortSpec` 으로 닫힌 집합 해석 (방향만 검사하는 `in_array` 는 보호가 아니다) | +| 지연 조인의 `$query` 에 미리 `orderBy`/`with`/`select` 적용 | 필터/where 만 적용해 넘기고, 정렬·관계·컬럼은 trait 인자로 전달 | +| 목록 SELECT 에 `SUBSTRING(content, 1, N)` 을 두고 프루닝했다고 간주 | 오버플로 페이지 읽기가 그대로 발생 — 잘라내기는 outer(`$columns`)에서만 | +| 그룹 쿼리(`groupBy`)의 총 건수를 `count()` 로 계산 | `getCountForPagination()` (서브쿼리로 감싸 그룹 수를 센다) | +| raw SQL 안에 테이블명·별칭을 문자열로 조립 | 테이블명은 `(new Model)->getTable()`, 프리픽스는 `DB::getTablePrefix()`, 별칭은 빌더(`join($table.' as uc', …)`)가 만들게 | +| `whereRaw('1 = 0')` / `where($c, DB::raw("({$sub->toSql()})"))` + `mergeBindings` | `whereIn($key, [])` / `where($c, '=', $sub)` (빌더가 바인딩까지 처리) | + +> 상세: [service-repository.md "목록 조회 컬럼 프루닝과 지연 조인" / "정렬 컬럼 화이트리스트" / "허용되는 Raw 쿼리"](docs/backend/service-repository.md) + ### Listener 데이터 접근 | 금지 | 올바른 사용 | diff --git a/CHANGELOG.md b/CHANGELOG.md index ac8ac557..8284b4a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,13 @@ - 사이트에서 쓰기로 한 언어의 언어팩을 한 번에 채워 넣는 기능을 추가했습니다. 한국어·영어 외 언어(일본어 등)는 설치되어 있어야 화면에 표시되는데, 설치가 빠져 있으면 오류 없이 한국어로 대체되어 운영자가 눈치채기 어려웠습니다. 이제 언어팩 관리 목록과 명령으로 "설치가 필요한 언어"를 한 번에 채울 수 있고, 여러 번 실행해도 안전합니다. - 언어팩 관리 목록이 "설치되어 있다고 표시되지만 실제 파일이 없어 다른 언어로 대체되는" 상태를 눈에 띄게 보여주고, 그 자리에서 다시 설치할 수 있는 버튼을 제공합니다. 예전에는 목록에 정상으로 보이는데도 실제로는 번역이 적용되지 않는 경우를 발견하기 어려웠습니다. 파일이 사라진 언어팩은 화면의 다시 설치 버튼으로도, 언어팩을 채우는 명령으로도 복구할 수 있습니다. +#### 목록 성능 + +- 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) + ### Changed +- 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) - 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다. - 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다. - 같은 규칙에서 벗어나 있던 필터 이름 6개(플러그인 설정 저장, 비밀번호 재설정 토큰 확인, 비밀번호 확인, 변경사항 조회, 통합 검색, 프로필 사진 업로드)를 추가로 통일했습니다. 이번에는 예전 이름도 함께 계속 발행하므로 기존 확장은 수정 없이 그대로 동작하며, 예전 이름을 사용 중인 확장이 있으면 서버 기록에 한 번 안내가 남습니다. 새로 만드는 확장은 새 이름을 사용하세요. diff --git a/app/Console/Commands/BenchPaginationCommand.php b/app/Console/Commands/BenchPaginationCommand.php new file mode 100644 index 00000000..ad72b8cd --- /dev/null +++ b/app/Console/Commands/BenchPaginationCommand.php @@ -0,0 +1,556 @@ +, order: array, soft_delete: bool}> + */ + private function profiles(): array + { + return [ + 'board_posts' => [ + 'table' => 'board_posts', + 'columns' => ['id', 'board_id', 'title', 'author_name', 'view_count', 'is_notice', 'created_at'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + // 실제 목록은 게시판 하나를 조회한다. 필터 없이 재면 인덱스 선택이 달라져 + // 화면에서 일어나는 일과 다른 것을 재게 된다. + 'filters' => ['board_id' => 1], + 'seed_overrides' => ['board_id' => 1, 'is_notice' => 0], + ], + 'orders' => [ + 'table' => 'ecommerce_orders', + 'columns' => ['id', 'user_id', 'order_number', 'order_status', 'order_device', 'is_first_order', 'currency', 'currency_snapshot', 'total_amount', 'total_shipping_amount', 'total_paid_amount', 'total_cancelled_amount', 'total_refunded_amount', 'total_points_used_amount', 'total_earned_points_amount', 'mc_total_amount', 'mc_total_shipping_amount', 'ordered_at', 'created_at', 'updated_at', 'deleted_at'], + 'order' => [['ordered_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + // 아래 두 목록은 응답 계약상 넓은 컬럼(changes/properties, body)을 그대로 노출하므로 + // 컬럼 프루닝이 불가하다. 지연 조인만 적용했고, 계측 축도 select * vs select id 다. + 'activity_logs' => [ + 'table' => 'activity_logs', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + 'notification_logs' => [ + 'table' => 'notification_logs', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + 'users' => [ + 'table' => 'users', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + 'reports' => [ + 'table' => 'boards_reports', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + 'coupon_issues' => [ + 'table' => 'ecommerce_promotion_coupon_issues', + 'columns' => ['*'], + 'order' => [['issued_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + 'products' => [ + 'table' => 'ecommerce_products', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + 'product_inquiries' => [ + 'table' => 'ecommerce_product_inquiries', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + 'product_reviews' => [ + 'table' => 'ecommerce_product_reviews', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + 'pages' => [ + 'table' => 'pages', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + 'extra_fee_templates' => [ + 'table' => 'ecommerce_shipping_policy_extra_fee_templates', + 'columns' => ['*'], + 'order' => [['zipcode', 'asc'], ['id', 'asc']], + 'soft_delete' => false, + ], + ]; + } + + /** + * 커맨드를 실행합니다. + * + * @return int 종료 코드 + */ + public function handle(): int + { + $profiles = $this->profiles(); + + if ($this->option('list-profiles')) { + foreach ($profiles as $key => $profile) { + $this->line(sprintf('%-20s %s', $key, $profile['table'])); + } + + return self::SUCCESS; + } + + $key = (string) $this->option('table'); + + if (! isset($profiles[$key])) { + $this->error("등록되지 않은 프로파일: {$key} (--list-profiles 로 목록 확인)"); + + return self::FAILURE; + } + + $profile = $profiles[$key]; + + // 계측은 대량 시딩을 동반하므로 개발 DB 대신 폐기 가능한 DB 를 지정할 수 있어야 한다. + // config 가 캐시된 환경에서는 .env / --env 로 연결을 바꿀 수 없어 런타임 치환이 유일한 수단이다. + $database = (string) $this->option('database'); + + if ($database !== '') { + $connection = config('database.default'); + + config([ + "database.connections.{$connection}.database" => $database, + "database.connections.{$connection}.write.database" => $database, + "database.connections.{$connection}.read.database" => $database, + ]); + + DB::purge($connection); + } + + if (! Schema::hasTable($profile['table'])) { + $this->error("테이블이 없습니다: {$profile['table']} (해당 확장이 설치되어 있는지 확인)"); + + return self::FAILURE; + } + + // 계측 결과가 환경에 좌우되므로 실행 환경을 먼저 드러낸다 + $this->line(sprintf( + '환경: APP_ENV=%s / DB=%s / config:cache=%s', + config('app.env'), + config('database.default'), + app()->configurationIsCached() ? 'on' : 'off' + )); + + if ($this->option('fresh')) { + if (app()->environment('production')) { + $this->error('운영 환경에서는 --fresh 를 사용할 수 없습니다.'); + + return self::FAILURE; + } + + // 다른 테이블이 FK 로 참조하면 TRUNCATE 가 거부되므로 제약을 잠시 내린다 + Schema::withoutForeignKeyConstraints(function () use ($profile) { + DB::table($profile['table'])->truncate(); + }); + + $this->warn("비움: {$profile['table']}"); + } + + $seed = (int) $this->option('seed'); + + if ($seed > 0) { + $this->seedRows($profile['table'], $seed, $profile['seed_overrides'] ?? []); + } + + $columns = $this->existingColumns($profile['table'], $profile['columns']); + $order = $this->existingOrder($profile['table'], $profile['order']); + $offsets = $this->parseOffsets(); + $runs = max(1, (int) $this->option('runs')); + + $rows = []; + + foreach ($offsets as $offset) { + // 세 축: 컬럼 인자를 생략했을 때(=select *) / 목록 컬럼만 / 키 컬럼만. + // 첫 축이 프루닝 이전 상태, 셋째 축이 지연 조인의 inner 에 해당한다. + $allMs = $this->measure($profile, ['*'], $order, $offset, $runs); + $listMs = $this->measure($profile, $columns, $order, $offset, $runs); + $idOnlyMs = $this->measure($profile, ['id'], $order, $offset, $runs); + + $rows[] = [ + 'offset' => $offset, + 'all_ms' => $allMs, + 'list_ms' => $listMs, + 'id_only_ms' => $idOnlyMs, + 'ratio' => $idOnlyMs > 0 ? round($allMs / $idOnlyMs, 1) : null, + 'explain' => $this->option('explain') ? $this->explain($profile, $columns, $order, $offset) : null, + // 지연 조인의 inner 가 실제로 어떤 계획을 타는지가 인덱스 설계의 근거다 + 'explain_id_only' => $this->option('explain') ? $this->explain($profile, ['id'], $order, $offset) : null, + ]; + } + + if ($this->option('json')) { + $this->line(json_encode([ + 'profile' => $key, + 'table' => $profile['table'], + 'columns' => $columns, + 'runs' => $runs, + 'results' => $rows, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } + + $this->table( + ['OFFSET', '전체 컬럼(ms)', '목록 컬럼(ms)', 'ID만(ms)', '전체÷ID'], + array_map(fn (array $row) => [ + number_format($row['offset']), + number_format($row['all_ms'], 1), + number_format($row['list_ms'], 1), + number_format($row['id_only_ms'], 1), + $row['ratio'] !== null ? $row['ratio'].'×' : '-', + ], $rows) + ); + + if ($this->option('explain')) { + foreach ($rows as $row) { + $this->newLine(); + $this->line("EXPLAIN @ OFFSET {$row['offset']} — 목록 컬럼"); + + foreach ($row['explain'] as $line) { + $this->line(' '.$line); + } + + $this->line("EXPLAIN @ OFFSET {$row['offset']} — ID 만 (지연 조인 inner)"); + + foreach ($row['explain_id_only'] as $line) { + $this->line(' '.$line); + } + } + } + + return self::SUCCESS; + } + + /** + * OFFSET 목록을 파싱합니다. + * + * @return array 오름차순 정렬된 OFFSET 목록 + */ + private function parseOffsets(): array + { + $offsets = array_map('intval', array_filter(explode(',', (string) $this->option('offsets')), 'strlen')); + sort($offsets); + + return $offsets === [] ? [0] : $offsets; + } + + /** + * 스키마에 실제로 존재하는 컬럼만 남깁니다. + * + * @param string $table 테이블명 + * @param array $columns 후보 컬럼 + * @return array 존재하는 컬럼 목록 + */ + private function existingColumns(string $table, array $columns): array + { + // ['*'] 는 "목록이 전 컬럼을 그대로 노출한다" 는 선언이다. 응답 계약상 넓은 컬럼을 + // 뺄 수 없는 목록(활동 로그의 changes, 알림 발송 이력의 body 등)이 여기 해당하며, + // 이 경우 계측의 비교 축은 "select * vs select id" 가 된다. + if ($columns === ['*']) { + return Schema::getColumnListing($table); + } + + $existing = array_values(array_filter($columns, fn (string $column) => Schema::hasColumn($table, $column))); + + return $existing === [] ? ['id'] : $existing; + } + + /** + * 스키마에 존재하는 정렬 컬럼만 남깁니다. + * + * @param string $table 테이블명 + * @param array $order 후보 정렬 + * @return array 적용 가능한 정렬 목록 + */ + private function existingOrder(string $table, array $order): array + { + $existing = array_values(array_filter($order, fn (array $spec) => Schema::hasColumn($table, $spec[0]))); + + return $existing === [] ? [['id', 'desc']] : $existing; + } + + /** + * 한 조합을 여러 번 실행해 중앙값(ms)을 돌려줍니다. + * + * DB::enableQueryLog() 는 오버헤드와 메모리 누적이 있어 쓰지 않고 직접 시간을 잰다. + * + * @param array $profile 프로파일 정의 + * @param array $columns select 컬럼 + * @param array $order 정렬 + * @param int $offset OFFSET + * @param int $runs 측정 횟수 + * @return float 중앙값 (밀리초) + */ + private function measure(array $profile, array $columns, array $order, int $offset, int $runs): float + { + // 첫 회는 캐시 워밍 성격이라 버린다 + $this->runQuery($profile, $columns, $order, $offset); + + $samples = []; + + for ($i = 0; $i < $runs; $i++) { + $start = microtime(true); + $this->runQuery($profile, $columns, $order, $offset); + $samples[] = (microtime(true) - $start) * 1000; + } + + sort($samples); + + return $samples[intdiv(count($samples), 2)]; + } + + /** + * 계측 대상 쿼리를 실행합니다. + * + * @param array $profile 프로파일 정의 + * @param array $columns select 컬럼 + * @param array $order 정렬 + * @param int $offset OFFSET + */ + private function runQuery(array $profile, array $columns, array $order, int $offset): void + { + $this->buildQuery($profile, $columns, $order, $offset)->get(); + } + + /** + * 계측 대상 쿼리를 조립합니다. + * + * @param array $profile 프로파일 정의 + * @param array $columns select 컬럼 + * @param array $order 정렬 + * @param int $offset OFFSET + * @return Builder 조립된 쿼리 + */ + private function buildQuery(array $profile, array $columns, array $order, int $offset) + { + $query = DB::table($profile['table'])->select($columns); + + if ($profile['soft_delete'] && Schema::hasColumn($profile['table'], 'deleted_at')) { + $query->whereNull('deleted_at'); + } + + foreach ($profile['filters'] ?? [] as $column => $value) { + if (Schema::hasColumn($profile['table'], $column)) { + $query->where($column, $value); + } + } + + foreach ($order as $spec) { + $query->orderBy($spec[0], $spec[1]); + } + + return $query->offset($offset)->limit(20); + } + + /** + * 실행 계획을 수집합니다. + * + * @param array $profile 프로파일 정의 + * @param array $columns select 컬럼 + * @param array $order 정렬 + * @param int $offset OFFSET + * @return array 실행 계획 요약 줄 목록 + */ + private function explain(array $profile, array $columns, array $order, int $offset): array + { + $query = $this->buildQuery($profile, $columns, $order, $offset); + + try { + $plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings()); + } catch (\Throwable $e) { + return ['실행 계획 수집 실패: '.$e->getMessage()]; + } + + return array_map(function ($row) { + $row = (array) $row; + + return sprintf( + 'type=%s key=%s rows=%s filtered=%s extra=%s', + $row['type'] ?? '-', + $row['key'] ?? '-', + $row['rows'] ?? '-', + $row['filtered'] ?? '-', + $row['Extra'] ?? '-' + ); + }, $plan); + } + + /** + * 계측용 합성 행을 시딩합니다. + * + * 스키마를 introspect 해 NOT NULL + 기본값 없는 컬럼만 타입에 맞춰 채운다. 프로파일마다 + * 별도 시더를 두면 대상이 늘 때마다 시더가 따라 늘어나므로 일반화한다. + * + * @param string $table 대상 테이블 + * @param int $count 시딩 건수 + * @param array $overrides 컬럼별 고정값 (실제 조회 조건과 맞추기 위한 지정) + */ + private function seedRows(string $table, int $count, array $overrides = []): void + { + $columns = Schema::getColumns($table); + $fillable = array_values(array_filter( + $columns, + fn (array $column) => ! ($column['auto_increment'] ?? false) + && ! $column['nullable'] + && ($column['default'] === null) + )); + + $this->info("시딩: {$table} {$count} 건"); + $bar = $this->output->createProgressBar($count); + + $chunk = []; + $chunkSize = 500; + + for ($i = 1; $i <= $count; $i++) { + $row = []; + + foreach ($fillable as $column) { + $row[$column['name']] = array_key_exists($column['name'], $overrides) + ? $overrides[$column['name']] + : $this->synthesizeValue($column, $i); + } + + foreach ($overrides as $name => $value) { + if (! array_key_exists($name, $row) && Schema::hasColumn($table, $name)) { + $row[$name] = $value; + } + } + + $chunk[] = $row; + + if (count($chunk) >= $chunkSize) { + $this->insertSynthetic($table, $chunk); + $bar->advance(count($chunk)); + $chunk = []; + } + } + + if ($chunk !== []) { + $this->insertSynthetic($table, $chunk); + $bar->advance(count($chunk)); + } + + $bar->finish(); + $this->newLine(2); + } + + /** + * 합성 행을 삽입합니다. (외래키 제약 일시 해제) + * + * 합성 값은 실제 부모 행을 가리키지 않으므로 FK 를 그대로 두면 삽입이 거부된다. + * 본 커맨드가 재는 것은 대상 테이블의 OFFSET 스캔 비용이고 조인은 측정 대상이 아니므로, + * 부모 무결성 없이 행 수와 행 폭만 재현하면 목적에 충분하다. 폐기 가능한 계측용 DB + * (`--database`)에서만 쓰는 것을 전제로 한다. + * + * @param string $table 대상 테이블 + * @param array> $chunk 삽입할 행 묶음 + */ + private function insertSynthetic(string $table, array $chunk): void + { + Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) { + DB::table($table)->insert($chunk); + }); + } + + /** + * 컬럼 타입에 맞는 합성 값을 만듭니다. + * + * @param array $column Schema::getColumns() 항목 + * @param int $index 행 인덱스 (고유값 생성용) + * @return mixed 합성 값 + */ + private function synthesizeValue(array $column, int $index): mixed + { + $type = strtolower((string) ($column['type_name'] ?? $column['type'] ?? 'varchar')); + + return match (true) { + str_contains($type, 'int') => $index, + str_contains($type, 'decimal'), str_contains($type, 'float'), str_contains($type, 'double') => 1000, + str_contains($type, 'bool'), str_contains($type, 'tinyint') => 0, + str_contains($type, 'json') => '{}', + // 시간 컬럼을 한 값으로 채우면 정렬이 전부 동률이 되어 filesort 가 지배해 버린다. + // 정렬 인덱스 효과를 재는 것이 목적이므로 행마다 값을 분산시킨다. + str_contains($type, 'date'), str_contains($type, 'time') => now()->subMinutes($index), + str_contains($type, 'text') => str_repeat('벤치마크 본문 ', 100), + default => $this->synthesizeString($column, $index), + }; + } + + /** + * 문자열 컬럼의 선언 길이에 맞는 합성 값을 만듭니다. + * + * 컬럼 길이를 무시하고 고정 길이 문자열을 넣으면 `varchar(10)` 류(신고 대상 타입, + * 우편번호, 로그 타입 등)에서 "Data too long" 으로 시딩 자체가 실패한다. + * + * @param array $column Schema::getColumns() 항목 + * @param int $index 행 인덱스 (고유값 생성용) + * @return string 합성 문자열 + */ + private function synthesizeString(array $column, int $index): string + { + $length = 40; + + // type 은 'varchar(10)' 형태로 선언 길이를 담고 있다 + if (preg_match('/\((\d+)\)/', (string) ($column['type'] ?? ''), $m) === 1) { + $length = max(1, (int) $m[1]); + } + + // 짧은 컬럼은 고유성 확보가 우선이라 인덱스 자체를 문자열로 쓴다 + if ($length <= 12) { + return substr((string) $index, -$length); + } + + return substr("bench-{$index}-".Str::random(8), 0, $length); + } +} diff --git a/app/Repositories/ActivityLogRepository.php b/app/Repositories/ActivityLogRepository.php index 513573b7..9eabb8c7 100644 --- a/app/Repositories/ActivityLogRepository.php +++ b/app/Repositories/ActivityLogRepository.php @@ -7,7 +7,10 @@ use App\Helpers\PermissionHelper; use App\Helpers\TimezoneHelper; use App\Models\ActivityLog; use App\Repositories\Concerns\HasMultipleSearchFilters; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -17,6 +20,25 @@ use Illuminate\Database\Eloquent\Model; class ActivityLogRepository implements ActivityLogRepositoryInterface { use HasMultipleSearchFilters; + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** + * 활동 로그 목록 정렬 허용 컬럼 + * + * 요청 값을 그대로 orderBy 에 넘기면 없는 컬럼으로 SQL 오류가 나거나 인덱스 없는 컬럼 + * 정렬을 강제할 수 있다. + * + * @var array + */ + private const SORTABLE_COLUMNS = [ + 'id', + 'created_at', + 'action', + 'log_type', + 'user_id', + ]; + /** * 특정 모델의 활동 로그를 페이지네이션하여 조회합니다. * @@ -26,8 +48,8 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface */ public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator { - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query = ActivityLog::forModel($model)->orderBy('created_at', $sortOrder); + $sortOrder = $this->normalizeSortDirection($filters['sort_order'] ?? null); + $query = ActivityLog::forModel($model); if (isset($filters['action'])) { $query->action($filters['action']); @@ -37,7 +59,13 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface $query->byUser($filters['user_id']); } - return $query->paginate($filters['per_page'] ?? 15); + // 컬럼을 좁히지 않는 이유는 getPaginated() 와 같다 — 리소스가 변경 내역을 그대로 노출한다 + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'created_at', 'direction' => $sortOrder]], + perPage: (int) ($filters['per_page'] ?? 15), + ); } /** @@ -48,7 +76,8 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface */ public function getPaginated(array $filters = []): LengthAwarePaginator { - $query = ActivityLog::query()->with('user:id,uuid,name,email'); + // 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다) + $query = ActivityLog::query(); // 스코프 기반 권한 필터링 (self: 본인 로그만, role: 소유역할 로그, null: 전체) PermissionHelper::applyPermissionScope($query, 'core.activities.read'); @@ -92,11 +121,18 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface $query->where('created_at', '<=', TimezoneHelper::fromSiteDateTime($filters['date_to'])); } - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at'); - return $query->paginate($filters['per_page'] ?? 15); + // 목록 컬럼을 좁히지 않는 이유: 활동 로그 리소스는 변경 내역(mediumText `changes`)과 + // 부가 정보(`properties`)까지 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다. + // 지연 조인만으로도 이 넓은 컬럼들을 읽는 행 수가 이번 페이지 분량으로 고정된다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: (int) ($filters['per_page'] ?? 15), + relations: ['user:id,uuid,name,email'], + ); } /** @@ -105,10 +141,9 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface * action/description 필드는 영어 키를 저장하므로, * 검색어가 번역된 라벨과 일치하는 원본 키도 함께 검색합니다. * - * @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더 - * @param string $search 검색어 - * @param string $searchType 검색 유형 (all, action, description, ip_address) - * @return void + * @param Builder $query 쿼리 빌더 + * @param string $search 검색어 + * @param string $searchType 검색 유형 (all, action, description, ip_address) */ private function applyBilingualSearch($query, string $search, string $searchType): void { @@ -146,7 +181,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface /** * 번역된 액션 라벨로 원본 action 값을 역추적합니다. * - * @param string $searchLower 소문자 검색어 + * @param string $searchLower 소문자 검색어 * @return array 일치하는 action 값 목록 */ private function findActionsByTranslatedLabel(string $searchLower): array @@ -179,7 +214,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface /** * 번역된 설명 텍스트로 원본 description_key를 역추적합니다. * - * @param string $searchLower 소문자 검색어 + * @param string $searchLower 소문자 검색어 * @return array 일치하는 description_key 목록 */ private function findDescriptionKeysByTranslatedText(string $searchLower): array @@ -200,7 +235,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface /** * 활동 로그를 삭제합니다. * - * @param int $id 삭제할 활동 로그 ID + * @param int $id 삭제할 활동 로그 ID * @return bool 삭제 성공 여부 */ public function delete(int $id): bool @@ -211,7 +246,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface /** * 여러 활동 로그를 일괄 삭제합니다. * - * @param array $ids 삭제할 활동 로그 ID 목록 + * @param array $ids 삭제할 활동 로그 ID 목록 * @return int 삭제된 건수 */ public function deleteMany(array $ids): int diff --git a/app/Repositories/Concerns/PaginatesWithDeferredJoin.php b/app/Repositories/Concerns/PaginatesWithDeferredJoin.php new file mode 100644 index 00000000..90195eac --- /dev/null +++ b/app/Repositories/Concerns/PaginatesWithDeferredJoin.php @@ -0,0 +1,258 @@ + $sort 정렬 스펙 + * @param int $perPage 페이지당 건수 + * @param int|null $page 현재 페이지 (null = 요청에서 해석) + * @param array $relations outer 전용 eager load 관계 + * @param array $withCount outer 전용 관계 카운트 + * @param string $keyName 키 컬럼명 (기본 id) + * @param int|null $total 전체 건수 (null = COUNT 수행, 값 지정 시 캐시 total 사용) + * @param bool $simple true = COUNT 없이 다음 페이지 유무만 판정 + * @param bool $preserveIdOrder true = inner 가 정한 ID 순서를 outer 에서 그대로 복원 + * @param string $pageName 페이지 쿼리 파라미터명 + * @param Closure|null $outerUsing outer 에만 적용할 조인/집계 (inner 에서는 실행되지 않는다) + * @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, 아니면 LengthAwarePaginator) + */ + protected function paginateWithDeferredJoin( + Builder $query, + array $columns, + array $sort, + int $perPage, + ?int $page = null, + array $relations = [], + array $withCount = [], + string $keyName = 'id', + ?int $total = null, + bool $simple = false, + bool $preserveIdOrder = false, + string $pageName = 'page', + ?Closure $outerUsing = null, + ): PaginatorContract { + $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = max(1, $page); + + $sort = $this->normalizeSortSpecForKey($sort, $keyName); + + // inner — 키 컬럼만 조회한다. with() 를 비우는 이유 두 가지: + // (a) 성능 — 관계 쿼리가 inner/outer 두 번 실행된다 + // (b) 정합성 — inner 는 키 컬럼만 select 하므로 관계 FK 가 없어 eager load 가 깨진다 + // whereHas 는 결과 집합을 결정하는 조건이라 setEagerLoads 로 지워지지 않는다 (의도된 구분). + $inner = (clone $query)->setEagerLoads([]); + + if (! $simple && $total === null) { + // Laravel 의 paginate() 와 같은 집계 경로를 쓴다. count() 는 groupBy/having 이 있는 + // 쿼리에서 `select count(*) ... group by ...` 를 그대로 실행해 **첫 그룹의 행 수**를 + // 총 건수로 돌려준다. getCountForPagination() 은 그룹 쿼리를 서브쿼리로 감싸므로 + // 그룹 수가 정확히 나온다. toBase() 는 글로벌 스코프를 적용한 뒤 기반 쿼리를 주므로 + // 소프트 삭제/필터 조건이 집계에서 누락되지 않는다. + $total = (clone $inner)->toBase()->getCountForPagination(); + } + + $this->applySortSpec($inner, $sort); + + // simple 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다 (Paginator 가 잘라낸다). + // forPage() 를 쓰면 offset 이 (page-1) * (perPage+1) 로 계산돼 페이지가 넘어갈수록 + // 건너뛰는 행이 어긋나므로, offset 은 perPage 기준으로 직접 지정한다. + $fetchCount = $simple ? $perPage + 1 : $perPage; + + $ids = $inner + ->select($inner->getModel()->qualifyColumn($keyName)) + ->offset(($page - 1) * $perPage) + ->limit($fetchCount) + ->pluck($keyName); + + $items = $ids->isEmpty() + ? new Collection + : $this->fetchDeferredJoinRows($query, $columns, $sort, $ids->all(), $relations, $withCount, $keyName, $preserveIdOrder, $outerUsing); + + $options = [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ]; + + if ($simple) { + return new Paginator($items, $perPage, $page, $options); + } + + return new LengthAwarePaginator($items, $total ?? $items->count(), $perPage, $page, $options); + } + + /** + * outer 쿼리를 실행해 실제 행을 조회합니다. + * + * @param Builder $query 필터가 적용된 원본 쿼리 (soft delete 스코프 보존을 위해 재사용) + * @param array $columns select 목록 + * @param array $sort 정렬 스펙 + * @param array $ids 이번 페이지의 키 값 목록 + * @param array $relations eager load 관계 + * @param array $withCount 관계 카운트 + * @param string $keyName 키 컬럼명 + * @param bool $preserveIdOrder ID 순서 그대로 복원 여부 + * @param Closure|null $outerUsing outer 전용 조인/집계 + * @return Collection 조회된 행 컬렉션 + */ + private function fetchDeferredJoinRows( + Builder $query, + array $columns, + array $sort, + array $ids, + array $relations, + array $withCount, + string $keyName, + bool $preserveIdOrder, + ?Closure $outerUsing = null, + ): Collection { + $outer = (clone $query)->setEagerLoads([]); + $qualifiedKey = $outer->getModel()->qualifyColumn($keyName); + + $outer->whereIn($qualifiedKey, $ids); + + // 결과 집합을 좁히지 않는 조인/집계(최근 댓글 서브쿼리, 상관 COUNT 등)는 여기서만 + // 붙인다. inner 에 두면 건너뛸 행 전체에 대해 실행되므로 OFFSET 이 깊어질수록 그 + // 비용이 그대로 누적된다. whereIn 뒤에 적용하므로 이번 페이지 건수에만 실행된다. + if ($outerUsing !== null) { + $outerUsing($outer); + } + + if (! empty($relations)) { + $outer->with($relations); + } + + if (! empty($withCount)) { + $outer->withCount($withCount); + } + + if ($preserveIdOrder) { + $this->applyIdOrder($outer, $qualifiedKey, $ids); + } else { + // 정렬 스펙에 키 컬럼이 포함돼 전순서가 보장되므로, 같은 스펙을 다시 적용하면 + // inner 가 정한 순서와 결과가 동일하다 (MySQL 전용 FIELD() 가 필요 없다). + $this->applySortSpec($outer, $sort); + } + + return $outer->get($columns); + } + + /** + * 정렬 스펙을 쿼리에 적용합니다. + * + * @param Builder $query 대상 쿼리 + * @param array $sort 정렬 스펙 + */ + private function applySortSpec(Builder $query, array $sort): void + { + foreach ($sort as $spec) { + $query->orderBy($spec['column'], $spec['direction']); + } + } + + /** + * 정렬 스펙 끝에 키 컬럼을 덧붙여 전순서를 만듭니다. + * + * 정렬 컬럼에 중복이 많으면(상태/조회수/평점 등) 동률 행의 순서가 실행마다 달라져 페이지 + * 경계에서 행이 중복되거나 누락된다. inner 와 outer 가 같은 순서를 갖도록 하는 전제이기도 하다. + * + * @param array $sort 정렬 스펙 + * @param string $keyName 키 컬럼명 + * @return array 키 컬럼이 보장된 정렬 스펙 + */ + private function normalizeSortSpecForKey(array $sort, string $keyName): array + { + $normalized = []; + + foreach ($sort as $spec) { + $column = $spec['column'] ?? null; + + if (! is_string($column) || $column === '') { + continue; + } + + $normalized[] = [ + 'column' => $column, + 'direction' => strtolower($spec['direction'] ?? 'desc') === 'asc' ? 'asc' : 'desc', + ]; + } + + foreach ($normalized as $spec) { + // 이미 키 컬럼으로 정렬한다면 그것으로 전순서가 성립한다 + if ($spec['column'] === $keyName || str_ends_with($spec['column'], '.'.$keyName)) { + return $normalized; + } + } + + $lastDirection = $normalized === [] ? 'desc' : end($normalized)['direction']; + + $normalized[] = ['column' => $keyName, 'direction' => $lastDirection]; + + return $normalized; + } + + /** + * inner 가 정한 ID 순서를 outer 에서 그대로 복원합니다. + * + * outer 에 존재하지 않는 표현식(집계/조인 서브쿼리 등)으로 정렬한 경우에만 사용한다. + * MySQL 전용 `FIELD()` 대신 표준 SQL `CASE WHEN` 을 쓴다 (DB 비의존 규정). + * + * @param Builder $query 대상 쿼리 + * @param string $qualifiedKey 테이블명이 붙은 키 컬럼 + * @param array $ids 복원할 순서의 키 값 목록 + */ + private function applyIdOrder(Builder $query, string $qualifiedKey, array $ids): void + { + $wrapped = $query->getQuery()->getGrammar()->wrap($qualifiedKey); + + $cases = []; + $bindings = []; + + foreach (array_values($ids) as $index => $id) { + $cases[] = "WHEN {$wrapped} = ? THEN {$index}"; + $bindings[] = $id; + } + + $query->orderByRaw('CASE '.implode(' ', $cases).' ELSE '.count($ids).' END', $bindings); + } +} diff --git a/app/Repositories/Concerns/ResolvesSortSpec.php b/app/Repositories/Concerns/ResolvesSortSpec.php new file mode 100644 index 00000000..91639093 --- /dev/null +++ b/app/Repositories/Concerns/ResolvesSortSpec.php @@ -0,0 +1,105 @@ + $allowedColumns 허용 정렬 컬럼 화이트리스트 + * @param string $defaultColumn 화이트리스트에 없거나 미전송일 때 사용할 컬럼 + * @param string $defaultDirection 기본 정렬 방향 (asc|desc) + * @param array $columnAliases 요청 값 → 실제 컬럼 매핑 (예: ['author' => 'author_name']) + * @param string $columnKey 정렬 컬럼이 담긴 필터 키 + * @param string $directionKey 정렬 방향이 담긴 필터 키 + * @return array 정렬 스펙 배열 + */ + protected function resolveSortSpec( + array $filters, + array $allowedColumns, + string $defaultColumn, + string $defaultDirection = 'desc', + array $columnAliases = [], + string $columnKey = 'sort_by', + string $directionKey = 'sort_order', + ): array { + $column = $this->normalizeSortValue($filters[$columnKey] ?? null); + $direction = $this->normalizeSortValue($filters[$directionKey] ?? null); + + if ($column !== null && isset($columnAliases[$column])) { + $column = $columnAliases[$column]; + } + + if ($column === null || ! in_array($column, $allowedColumns, true)) { + $column = $defaultColumn; + } + + return [[ + 'column' => $column, + 'direction' => $this->normalizeSortDirection($direction, $defaultDirection), + ]]; + } + + /** + * 정렬 방향 문자열을 asc/desc 로 정규화합니다. + * + * @param string|null $direction 요청 값 + * @param string $default 허용 값이 아닐 때 사용할 기본 방향 + * @return string asc 또는 desc + */ + protected function normalizeSortDirection(?string $direction, string $default = 'desc'): string + { + $normalized = strtolower((string) $direction); + + if (in_array($normalized, ['asc', 'desc'], true)) { + return $normalized; + } + + $fallback = strtolower($default); + + return in_array($fallback, ['asc', 'desc'], true) ? $fallback : 'desc'; + } + + /** + * 정렬 관련 요청 값을 문자열로 정규화합니다. (Enum/빈 문자열 흡수) + * + * @param mixed $value 요청 값 (문자열 또는 BackedEnum) + * @return string|null 정규화된 문자열 (값이 없으면 null) + */ + private function normalizeSortValue(mixed $value): ?string + { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (! is_string($value) && ! is_int($value)) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } +} diff --git a/app/Repositories/IdentityMessageDefinitionRepository.php b/app/Repositories/IdentityMessageDefinitionRepository.php index da177b78..7d2ae18a 100644 --- a/app/Repositories/IdentityMessageDefinitionRepository.php +++ b/app/Repositories/IdentityMessageDefinitionRepository.php @@ -4,11 +4,25 @@ namespace App\Repositories; use App\Contracts\Repositories\IdentityMessageDefinitionRepositoryInterface; use App\Models\IdentityMessageDefinition; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; class IdentityMessageDefinitionRepository implements IdentityMessageDefinitionRepositoryInterface { + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (AdminIdentityMessageDefinitionIndexRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = [ + 'id', + 'provider_id', + 'scope_type', + 'scope_value', + 'is_active', + 'created_at', + 'updated_at', + ]; + /** * ID로 메시지 정의 조회. * @@ -180,10 +194,11 @@ class IdentityMessageDefinitionRepository implements IdentityMessageDefinitionRe }); } - $sortBy = $filters['sort_by'] ?? 'id'; - $sortOrder = $filters['sort_order'] ?? 'asc'; - $query->orderBy($sortBy, $sortOrder); + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'id', 'asc') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); + } + // audit:allow repository-paginate-column-pruning reason: 본인인증 메시지 정의 테이블 — 정의 수가 고정이고 넓은 컬럼이 없다 return $query->paginate($perPage); } } diff --git a/app/Repositories/IdentityPolicyRepository.php b/app/Repositories/IdentityPolicyRepository.php index a6b7611b..410494d5 100644 --- a/app/Repositories/IdentityPolicyRepository.php +++ b/app/Repositories/IdentityPolicyRepository.php @@ -252,6 +252,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface }); } + // audit:allow repository-paginate-column-pruning reason: 본인인증 정책 정의 테이블 — 정책 수가 고정이고 넓은 컬럼이 없다 return $query->orderByDesc('created_at')->orderByDesc('id')->paginate($perPage); } diff --git a/app/Repositories/IdentityVerificationLogRepository.php b/app/Repositories/IdentityVerificationLogRepository.php index 325222c4..6e51bd4d 100644 --- a/app/Repositories/IdentityVerificationLogRepository.php +++ b/app/Repositories/IdentityVerificationLogRepository.php @@ -4,7 +4,10 @@ namespace App\Repositories; use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface; use App\Enums\IdentityVerificationStatus; +use App\Models\IdentityPolicy; use App\Models\IdentityVerificationLog; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; /** @@ -14,6 +17,8 @@ use Illuminate\Support\Carbon; */ class IdentityVerificationLogRepository implements IdentityVerificationLogRepositoryInterface { + use PaginatesWithDeferredJoin; + /** * 검증 로그를 생성합니다. * @@ -130,7 +135,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi * * @param array $filters 검색 필터 * @param int $perPage 페이지당 항목 수 - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 페이지네이터 + * @return LengthAwarePaginator 페이지네이터 */ public function search(array $filters, int $perPage = 20) { @@ -158,7 +163,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi if (! empty($filters['source_type'])) { $query->whereIn('origin_policy_key', function ($q) use ($filters) { $q->select('key') - ->from('identity_policies') + ->from((new IdentityPolicy)->getTable()) ->where('source_type', $filters['source_type']); if (! empty($filters['source_identifier'])) { $q->where('source_identifier', $filters['source_identifier']); @@ -202,9 +207,15 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi ? $filters['sort_by'] : 'created_at'; $sortOrder = ($filters['sort_order'] ?? 'desc') === 'asc' ? 'asc' : 'desc'; - $query->orderBy($sortBy, $sortOrder); - return $query->paginate($perPage); + // 본인인증 로그는 계속 쌓이는 이력이라 뒤쪽 페이지에서 OFFSET 비용이 커진다. + // 응답이 요청/응답 payload 를 그대로 노출하므로 컬럼은 좁히지 않고 지연 조인만 적용한다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => $sortBy, 'direction' => $sortOrder]], + perPage: $perPage, + ); } /** diff --git a/app/Repositories/LanguagePackRepository.php b/app/Repositories/LanguagePackRepository.php index 1ee83b3d..3e29ff0e 100644 --- a/app/Repositories/LanguagePackRepository.php +++ b/app/Repositories/LanguagePackRepository.php @@ -4,10 +4,13 @@ namespace App\Repositories; use App\Contracts\Repositories\LanguagePackRepositoryInterface; use App\Enums\LanguagePackScope; +use App\Enums\LanguagePackSourceType; use App\Enums\LanguagePackStatus; +use App\Enums\TextDirection; use App\Models\LanguagePack; use App\Repositories\Concerns\HasMultipleSearchFilters; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -127,6 +130,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface */ public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator { + // audit:allow repository-paginate-column-pruning reason: 언어팩 정의 테이블 — 설치된 팩 수만큼만 존재하고 넓은 컬럼이 없다 return $this->buildFilteredQuery($filters)->paginate($perPage); } @@ -148,9 +152,9 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface * (실제 미설치 번들은 Service 계층에서 합쳐집니다). * * @param array $filters 필터 조건 - * @return \Illuminate\Database\Eloquent\Builder 정렬까지 적용된 쿼리 빌더 + * @return Builder 정렬까지 적용된 쿼리 빌더 */ - private function buildFilteredQuery(array $filters): \Illuminate\Database\Eloquent\Builder + private function buildFilteredQuery(array $filters): Builder { $query = LanguagePack::query(); @@ -165,7 +169,8 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface } if (! empty($filters['status'])) { if ($filters['status'] === LanguagePackStatus::Uninstalled->value) { - $query->whereRaw('1 = 0'); + // 결과 없음 — 빈 whereIn 은 `0 = 1` 로 컴파일된다(raw 불필요) + $query->whereIn($query->getModel()->getKeyName(), []); } else { $query->where('status', $filters['status']); } @@ -282,10 +287,10 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface $pack->version = (string) ($manifest['version'] ?? '0.0.0'); $pack->license = $manifest['license'] ?? null; $pack->description = is_array($manifest['description'] ?? null) ? $manifest['description'] : null; - $pack->status = \App\Enums\LanguagePackStatus::Uninstalled->value; + $pack->status = LanguagePackStatus::Uninstalled->value; $pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (요구사항 #3) $pack->manifest = $manifest; - $pack->source_type = \App\Enums\LanguagePackSourceType::Bundled->value; + $pack->source_type = LanguagePackSourceType::Bundled->value; $pack->source_url = $bundledIdentifier; $pack->setAttribute('bundled_identifier', $bundledIdentifier); @@ -335,11 +340,11 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface $pack->locale = $locale; $pack->locale_name = strtoupper($locale); $pack->locale_native_name = $nativeName; - $pack->text_direction = \App\Enums\TextDirection::Ltr->value; + $pack->text_direction = TextDirection::Ltr->value; $pack->version = $version; - $pack->status = \App\Enums\LanguagePackStatus::Active->value; + $pack->status = LanguagePackStatus::Active->value; $pack->is_protected = true; - $pack->source_type = \App\Enums\LanguagePackSourceType::BuiltIn->value; + $pack->source_type = LanguagePackSourceType::BuiltIn->value; $pack->source_url = $langPathRelative; $pack->manifest = [ 'identifier' => $identifier, diff --git a/app/Repositories/MenuRepository.php b/app/Repositories/MenuRepository.php index 126ae382..f98854ed 100644 --- a/app/Repositories/MenuRepository.php +++ b/app/Repositories/MenuRepository.php @@ -3,15 +3,24 @@ namespace App\Repositories; use App\Contracts\Repositories\MenuRepositoryInterface; -use App\Helpers\PermissionHelper; use App\Enums\ExtensionOwnerType; +use App\Helpers\PermissionHelper; use App\Models\Menu; +use App\Models\Module; +use App\Models\Plugin; use App\Models\User; +use App\Repositories\Concerns\ResolvesSortSpec; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; class MenuRepository implements MenuRepositoryInterface { + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (MenuListRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = ['created_at', 'name', 'slug', 'order']; + /** * 모든 메뉴를 조회합니다. * @@ -37,6 +46,14 @@ class MenuRepository implements MenuRepositoryInterface ->get(); } + /** + * 관리용 최상위 메뉴 목록을 조회합니다. (비활성 확장 메뉴 제외) + * + * @param array $activeModuleIdentifiers 활성 모듈 식별자 목록 + * @param array $activePluginIdentifiers 활성 플러그인 식별자 목록 + * @param User|null $user 접근 권한 판정 대상 사용자 (null 이면 역할 필터 미적용) + * @return Collection 최상위 메뉴 컬렉션 (자식 메뉴 포함) + */ public function getTopLevelMenusForManagement(array $activeModuleIdentifiers = [], array $activePluginIdentifiers = [], ?User $user = null): Collection { $extensionFilter = function ($query) use ($activeModuleIdentifiers, $activePluginIdentifiers) { @@ -67,13 +84,13 @@ class MenuRepository implements MenuRepositoryInterface } return $query->with(['creator', 'roles', 'children' => function ($query) use ($extensionFilter, $user) { - $query->where($extensionFilter); - if ($user) { - $query->accessibleBy($user); - } - $query->with('roles') - ->orderBy('order'); - }]) + $query->where($extensionFilter); + if ($user) { + $query->accessibleBy($user); + } + $query->with('roles') + ->orderBy('order'); + }]) ->orderBy('order') ->get(); } @@ -260,7 +277,7 @@ class MenuRepository implements MenuRepositoryInterface $q->where('extension_type', ExtensionOwnerType::Module) ->whereExists(function ($subQuery) { $subQuery->select(DB::raw(1)) - ->from('modules') + ->from((new Module)->getTable()) ->whereColumn('modules.identifier', 'menus.extension_identifier') ->where('modules.status', 'active'); }); @@ -270,7 +287,7 @@ class MenuRepository implements MenuRepositoryInterface $q->where('extension_type', ExtensionOwnerType::Plugin) ->whereExists(function ($subQuery) { $subQuery->select(DB::raw(1)) - ->from('plugins') + ->from((new Plugin)->getTable()) ->whereColumn('plugins.identifier', 'menus.extension_identifier') ->where('plugins.status', 'active'); }); @@ -359,6 +376,15 @@ class MenuRepository implements MenuRepositoryInterface return (int) Menu::where('parent_id', $parentId)->max('order'); } + /** + * 필터 조건이 적용된 최상위 메뉴 목록을 조회합니다. + * + * @param array $filters 필터 조건 배열 (sort_by/sort_order/검색 필드) + * @param array $activeModuleIdentifiers 활성 모듈 식별자 목록 + * @param array $activePluginIdentifiers 활성 플러그인 식별자 목록 + * @param User|null $user 접근 권한 판정 대상 사용자 (null 이면 역할 필터 미적용) + * @return Collection 최상위 메뉴 컬렉션 (자식 메뉴 포함) + */ public function getFilteredTopLevelMenus(array $filters, array $activeModuleIdentifiers = [], array $activePluginIdentifiers = [], ?User $user = null): Collection { $extensionFilter = function ($query) use ($activeModuleIdentifiers, $activePluginIdentifiers) { @@ -418,10 +444,10 @@ class MenuRepository implements MenuRepositoryInterface } } - // 정렬 - $sortBy = $filters['sort_by'] ?? 'order'; - $sortOrder = $filters['sort_order'] ?? 'asc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 (허용 컬럼 화이트리스트로 해석) + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'order', 'asc') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); + } // 자식 메뉴도 동일한 조건 적용하여 로드 return $query->with(['creator', 'roles', 'children' => function ($childQuery) use ($extensionFilter, $filters, $user) { @@ -444,7 +470,7 @@ class MenuRepository implements MenuRepositoryInterface /** * 필터 연산자를 쿼리에 적용합니다. * - * @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더 + * @param Builder $query 쿼리 빌더 * @param string $field 필드명 * @param string $value 검색 값 * @param string $operator 연산자 (like, eq, starts_with, ends_with) diff --git a/app/Repositories/NotificationDefinitionRepository.php b/app/Repositories/NotificationDefinitionRepository.php index a652e898..e1da1c0e 100644 --- a/app/Repositories/NotificationDefinitionRepository.php +++ b/app/Repositories/NotificationDefinitionRepository.php @@ -4,15 +4,28 @@ namespace App\Repositories; use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface; use App\Models\NotificationDefinition; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; class NotificationDefinitionRepository implements NotificationDefinitionRepositoryInterface { + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (NotificationDefinitionIndexRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = [ + 'id', + 'type', + 'extension_type', + 'is_active', + 'created_at', + 'updated_at', + ]; + /** * ID로 알림 정의 조회. * - * @param int $id + * @param int $id * @return NotificationDefinition|null */ public function findById(int $id): ?NotificationDefinition @@ -23,7 +36,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 타입으로 알림 정의 조회. * - * @param string $type + * @param string $type * @return NotificationDefinition|null */ public function findByType(string $type): ?NotificationDefinition @@ -34,7 +47,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 활성 상태인 특정 타입 알림 정의 조회. * - * @param string $type + * @param string $type * @return NotificationDefinition|null */ public function getActiveByType(string $type): ?NotificationDefinition @@ -58,7 +71,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito * 알림 목록 응답에서 N+1 회피 목적으로 한 번 호출하여 [type => label] 맵을 생성합니다. * 라벨 fallback 우선순위: 사용자 로케일 → ko → en → type 식별자 * - * @param string|null $locale 사용자 로케일 (null이면 app()->getLocale()) + * @param string|null $locale 사용자 로케일 (null이면 app()->getLocale()) * @return array */ public function getLabelMap(?string $locale = null): array @@ -86,8 +99,8 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 특정 확장의 알림 정의 목록 조회. * - * @param string $extensionType - * @param string $extensionIdentifier + * @param string $extensionType + * @param string $extensionIdentifier * @return Collection */ public function getByExtension(string $extensionType, string $extensionIdentifier): Collection @@ -98,8 +111,8 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 알림 정의 수정. * - * @param NotificationDefinition $definition - * @param array $data + * @param NotificationDefinition $definition + * @param array $data * @return NotificationDefinition */ public function update(NotificationDefinition $definition, array $data): NotificationDefinition @@ -112,8 +125,8 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 페이지네이션 목록 조회. * - * @param array $filters - * @param int $perPage + * @param array $filters + * @param int $perPage * @return LengthAwarePaginator */ public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator @@ -148,10 +161,11 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito }); } - $sortBy = $filters['sort_by'] ?? 'id'; - $sortOrder = $filters['sort_order'] ?? 'asc'; - $query->orderBy($sortBy, $sortOrder); + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'id', 'asc') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); + } + // audit:allow repository-paginate-column-pruning reason: 알림 정의 테이블 — 정의 수가 고정이고 넓은 컬럼이 없다 return $query->paginate($perPage); } } diff --git a/app/Repositories/NotificationLogRepository.php b/app/Repositories/NotificationLogRepository.php index f92cf1a4..6dcb1edd 100644 --- a/app/Repositories/NotificationLogRepository.php +++ b/app/Repositories/NotificationLogRepository.php @@ -6,12 +6,35 @@ use App\Contracts\Repositories\NotificationLogRepositoryInterface; use App\Enums\NotificationLogStatus; use App\Models\NotificationLog; use App\Models\User; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; class NotificationLogRepository implements NotificationLogRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** + * 발송 이력 목록 정렬 허용 컬럼 + * + * 요청 값을 그대로 orderBy 에 넘기면 없는 컬럼으로 SQL 오류가 나거나 인덱스 없는 컬럼 + * 정렬을 강제할 수 있다. + * + * @var array + */ + private const SORTABLE_COLUMNS = [ + 'id', + 'sent_at', + 'created_at', + 'status', + 'channel', + 'notification_type', + 'recipient_name', + ]; + /** * 최근 발송된 알림 로그를 발송 시각 최신순으로 조회합니다 (대시보드 최근 알림). * @@ -81,7 +104,8 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface */ public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator { - $query = NotificationLog::with(['senderUser', 'recipientUser']); + // 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다) + $query = NotificationLog::query(); // notification-logs scope: 전달된 사용자의 권한 스코프 적용 if ($scopeUser) { @@ -125,11 +149,18 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface }); } - $sortBy = $filters['sort_by'] ?? 'sent_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'sent_at'); - return $query->paginate($perPage); + // 목록 컬럼을 좁히지 않는 이유: 이 목록의 리소스는 렌더링된 본문(longText `body`)까지 + // 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다. 지연 조인만으로도 본문을 읽는 + // 행 수가 OFFSET 과 무관하게 이번 페이지 분량으로 고정된다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + relations: ['senderUser', 'recipientUser'], + ); } /** diff --git a/app/Repositories/NotificationRepository.php b/app/Repositories/NotificationRepository.php index bc23df76..50998432 100644 --- a/app/Repositories/NotificationRepository.php +++ b/app/Repositories/NotificationRepository.php @@ -11,6 +11,11 @@ class NotificationRepository implements NotificationRepositoryInterface { /** * 사용자의 알림 목록을 페이지네이션으로 조회합니다. + * + * @param User $user 대상 사용자 + * @param array $filters 필터 조건 (read: unread|read) + * @param int $perPage 페이지당 항목 수 + * @return LengthAwarePaginator 페이지네이션된 알림 목록 */ public function getByUser(User $user, array $filters = [], int $perPage = 20): LengthAwarePaginator { @@ -22,11 +27,17 @@ class NotificationRepository implements NotificationRepositoryInterface $query->whereNotNull('read_at'); } + // audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 알림함 — + // $user->notifications() 로 이미 좁혀지고, 목록이 표시하는 본문 자체가 data(JSON) 라 + // 걷어낼 넓은 컬럼이 없다 return $query->orderBy('created_at', 'desc')->paginate($perPage); } /** * 사용자의 미읽음 알림 수를 반환합니다. + * + * @param User $user 대상 사용자 + * @return int 미읽음 알림 수 */ public function getUnreadCount(User $user): int { @@ -35,6 +46,10 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 특정 알림을 읽음 처리합니다. + * + * @param User $user 대상 사용자 + * @param string $notificationId 알림 ID + * @return DatabaseNotification|null 읽음 처리된 알림 (없으면 null) */ public function markAsRead(User $user, string $notificationId): ?DatabaseNotification { @@ -51,6 +66,10 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 지정된 알림들을 일괄 읽음 처리합니다. + * + * @param User $user 대상 사용자 + * @param array $ids 알림 ID 배열 + * @return int 읽음 처리된 알림 수 */ public function markBatchAsRead(User $user, array $ids): int { @@ -61,6 +80,9 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 사용자의 모든 미읽음 알림을 읽음 처리합니다. + * + * @param User $user 대상 사용자 + * @return int 읽음 처리된 알림 수 */ public function markAllAsRead(User $user): int { @@ -73,6 +95,10 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 알림을 삭제합니다. + * + * @param User $user 대상 사용자 + * @param string $notificationId 알림 ID + * @return bool 삭제 성공 여부 (알림이 없으면 false) */ public function delete(User $user, string $notificationId): bool { @@ -88,7 +114,7 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 사용자의 모든 알림을 삭제합니다. * - * @param User $user + * @param User $user * @return int 삭제된 알림 수 */ public function deleteAll(User $user): int @@ -99,6 +125,8 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 오래된 알림을 정리합니다. * + * @param int $readRetentionDays 읽은 알림 보관 일수 (0 이하면 정리하지 않음) + * @param int $unreadRetentionDays 미읽음 알림 보관 일수 (0 이하면 정리하지 않음) * @return array{deleted_read: int, deleted_unread: int} */ public function cleanup(int $readRetentionDays, int $unreadRetentionDays): array diff --git a/app/Repositories/RoleRepository.php b/app/Repositories/RoleRepository.php index 0b960311..782fa7dc 100644 --- a/app/Repositories/RoleRepository.php +++ b/app/Repositories/RoleRepository.php @@ -218,6 +218,7 @@ class RoleRepository implements RoleRepositoryInterface $query->orderByRaw('CASE WHEN extension_type IS NOT NULL THEN 0 ELSE 1 END') ->orderBy('created_at', 'desc'); + // audit:allow repository-paginate-column-pruning reason: 역할 정의 테이블 — 행 수가 운영상 고정(수십 건)이고 넓은 컬럼이 없다 return $query->paginate($perPage); } diff --git a/app/Repositories/ScheduleHistoryRepository.php b/app/Repositories/ScheduleHistoryRepository.php index 2f93cdc0..66379a82 100644 --- a/app/Repositories/ScheduleHistoryRepository.php +++ b/app/Repositories/ScheduleHistoryRepository.php @@ -4,15 +4,20 @@ namespace App\Repositories; use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface; use App\Models\ScheduleHistory; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + /** * ID로 실행 이력을 찾습니다. * - * @param int $id 이력 ID + * @param int $id 이력 ID * @return ScheduleHistory|null 찾은 이력 모델 또는 null */ public function findById(int $id): ?ScheduleHistory @@ -23,7 +28,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 새로운 실행 이력을 생성합니다. * - * @param array $data 이력 생성 데이터 + * @param array $data 이력 생성 데이터 * @return ScheduleHistory 생성된 이력 모델 */ public function create(array $data): ScheduleHistory @@ -34,8 +39,8 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 기존 실행 이력을 업데이트합니다. * - * @param ScheduleHistory $history 업데이트할 이력 모델 - * @param array $data 업데이트할 데이터 + * @param ScheduleHistory $history 업데이트할 이력 모델 + * @param array $data 업데이트할 데이터 * @return bool 업데이트 성공 여부 */ public function update(ScheduleHistory $history, array $data): bool @@ -46,7 +51,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 실행 이력을 삭제합니다. * - * @param ScheduleHistory $history 삭제할 이력 모델 + * @param ScheduleHistory $history 삭제할 이력 모델 * @return bool 삭제 성공 여부 */ public function delete(ScheduleHistory $history): bool @@ -57,8 +62,8 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 특정 스케줄의 실행 이력을 페이지네이션하여 조회합니다. * - * @param int $scheduleId 스케줄 ID - * @param array $filters 필터 조건 배열 + * @param int $scheduleId 스케줄 ID + * @param array $filters 필터 조건 배열 * @return LengthAwarePaginator 페이지네이션된 이력 목록 */ public function getPaginatedByScheduleId(int $scheduleId, array $filters = []): LengthAwarePaginator @@ -67,40 +72,50 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface ->where('schedule_id', $scheduleId); // 상태 필터 - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { $query->where('status', $filters['status']); } // 트리거 유형 필터 - if (!empty($filters['trigger_type'])) { + if (! empty($filters['trigger_type'])) { $query->where('trigger_type', $filters['trigger_type']); } // 날짜 필터 - if (!empty($filters['started_from'])) { + if (! empty($filters['started_from'])) { $query->whereDate('started_at', '>=', $filters['started_from']); } - if (!empty($filters['started_to'])) { + if (! empty($filters['started_to'])) { $query->whereDate('started_at', '<=', $filters['started_to']); } - // 정렬 적용 - $sortBy = $filters['sort_by'] ?? 'started_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 — 요청 값은 허용 목록으로만 해석한다. + // 허용 집합은 ScheduleHistoryListRequest 의 `in:` 규칙과 동일해야 한다. 저장소 목록이 + // 그보다 좁으면 게이트를 통과한 정렬이 조용히 기본값으로 되돌아간다 (종료시각/소요시간). + $sort = $this->resolveSortSpec( + $filters, + ['started_at', 'ended_at', 'duration', 'status'], + 'started_at' + ); - // 페이지네이션 적용 - $perPage = $filters['per_page'] ?? 15; + $perPage = (int) ($filters['per_page'] ?? 15); - return $query->paginate($perPage); + // 실행 이력은 계속 쌓이며 출력/에러 메시지를 그대로 노출하므로, 컬럼은 좁히지 않고 + // 지연 조인으로 넓은 컬럼을 읽는 행 수만 이번 페이지 분량으로 고정한다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + ); } /** * 특정 스케줄의 최근 실행 이력을 조회합니다. * - * @param int $scheduleId 스케줄 ID - * @param int $limit 조회 개수 + * @param int $scheduleId 스케줄 ID + * @param int $limit 조회 개수 * @return Collection 최근 이력 컬렉션 */ public function getRecentByScheduleId(int $scheduleId, int $limit = 10): Collection @@ -115,7 +130,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 여러 이력을 일괄 삭제합니다. * - * @param array $ids 이력 ID 배열 + * @param array $ids 이력 ID 배열 * @return int 삭제된 레코드 수 */ public function bulkDelete(array $ids): int @@ -126,7 +141,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 특정 스케줄의 모든 이력을 삭제합니다. * - * @param int $scheduleId 스케줄 ID + * @param int $scheduleId 스케줄 ID * @return int 삭제된 레코드 수 */ public function deleteByScheduleId(int $scheduleId): int @@ -137,7 +152,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 특정 기간 이전의 이력을 삭제합니다. * - * @param int $days 보관 기간 (일) + * @param int $days 보관 기간 (일) * @return int 삭제된 레코드 수 */ public function deleteOlderThan(int $days): int diff --git a/app/Repositories/ScheduleRepository.php b/app/Repositories/ScheduleRepository.php index dfe718b3..129ebfd8 100644 --- a/app/Repositories/ScheduleRepository.php +++ b/app/Repositories/ScheduleRepository.php @@ -3,12 +3,13 @@ namespace App\Repositories; use App\Contracts\Repositories\ScheduleRepositoryInterface; -use App\Helpers\PermissionHelper; use App\Enums\ScheduleFrequency; use App\Enums\ScheduleResultStatus; use App\Enums\ScheduleType; +use App\Helpers\PermissionHelper; use App\Models\Schedule; use App\Repositories\Concerns\HasMultipleSearchFilters; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -16,16 +17,29 @@ use Illuminate\Database\Eloquent\Collection; class ScheduleRepository implements ScheduleRepositoryInterface { use HasMultipleSearchFilters; + use ResolvesSortSpec; /** * 검색 가능한 필드 목록 */ private const SEARCHABLE_FIELDS = ['name', 'description', 'command']; + /** + * 허용 정렬 컬럼 (ScheduleListRequest 와 동일 집합) + */ + private const SORTABLE_COLUMNS = [ + 'created_at', + 'name', + 'next_run_at', + 'last_run_at', + 'is_active', + 'last_result', + ]; + /** * ID로 스케줄을 찾습니다. * - * @param int $id 스케줄 ID + * @param int $id 스케줄 ID * @return Schedule|null 찾은 스케줄 모델 또는 null */ public function findById(int $id): ?Schedule @@ -36,7 +50,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 새로운 스케줄을 생성합니다. * - * @param array $data 스케줄 생성 데이터 + * @param array $data 스케줄 생성 데이터 * @return Schedule 생성된 스케줄 모델 */ public function create(array $data): Schedule @@ -47,8 +61,8 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 기존 스케줄을 업데이트합니다. * - * @param Schedule $schedule 업데이트할 스케줄 모델 - * @param array $data 업데이트할 데이터 + * @param Schedule $schedule 업데이트할 스케줄 모델 + * @param array $data 업데이트할 데이터 * @return bool 업데이트 성공 여부 */ public function update(Schedule $schedule, array $data): bool @@ -59,7 +73,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 스케줄을 삭제합니다. * - * @param Schedule $schedule 삭제할 스케줄 모델 + * @param Schedule $schedule 삭제할 스케줄 모델 * @return bool 삭제 성공 여부 */ public function delete(Schedule $schedule): bool @@ -80,7 +94,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 필터링 및 페이지네이션이 적용된 스케줄 목록을 조회합니다. * - * @param array $filters 필터 조건 배열 + * @param array $filters 필터 조건 배열 * @return LengthAwarePaginator 페이지네이션된 스케줄 목록 */ public function getPaginatedSchedules(array $filters = []): LengthAwarePaginator @@ -93,33 +107,34 @@ class ScheduleRepository implements ScheduleRepositoryInterface // 필터 조건 적용 $this->applyFilters($query, $filters); - // 정렬 적용 - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 적용 (허용 컬럼 화이트리스트로 해석) + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); + } // 페이지네이션 적용 $perPage = $filters['per_page'] ?? 15; + // audit:allow repository-paginate-column-pruning reason: 스케줄 정의 테이블 — 등록된 스케줄 수만큼만 존재하고 넓은 컬럼이 없다 return $query->paginate($perPage); } /** * 쿼리에 필터 조건을 적용합니다. * - * @param Builder $query Eloquent 쿼리 빌더 - * @param array $filters 적용할 필터 조건 배열 + * @param Builder $query Eloquent 쿼리 빌더 + * @param array $filters 적용할 필터 조건 배열 * @return void */ private function applyFilters(Builder $query, array $filters): void { // 다중 검색 조건 적용 - if (!empty($filters['filters']) && is_array($filters['filters'])) { + if (! empty($filters['filters']) && is_array($filters['filters'])) { $this->applyMultipleSearchFilters($query, $filters['filters'], self::SEARCHABLE_FIELDS); } // 타입 필터 - if (!empty($filters['type'])) { + if (! empty($filters['type'])) { $type = ScheduleType::tryFrom($filters['type']); if ($type) { $query->where('type', $type); @@ -127,7 +142,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface } // 주기 필터 - if (!empty($filters['frequency'])) { + if (! empty($filters['frequency'])) { $frequency = ScheduleFrequency::tryFrom($filters['frequency']); if ($frequency) { $query->where('frequency', $frequency); @@ -135,7 +150,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface } // 상태 필터 - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { if ($filters['status'] === 'active') { $query->where('is_active', true); } elseif ($filters['status'] === 'inactive') { @@ -144,7 +159,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface } // 마지막 실행 결과 필터 - if (!empty($filters['last_result'])) { + if (! empty($filters['last_result'])) { $result = ScheduleResultStatus::tryFrom($filters['last_result']); if ($result) { $query->where('last_result', $result); @@ -162,10 +177,10 @@ class ScheduleRepository implements ScheduleRepositoryInterface } // 확장 타입 필터 - if (!empty($filters['extension_type'])) { + if (! empty($filters['extension_type'])) { $query->where('extension_type', $filters['extension_type']); - if (!empty($filters['extension_identifier'])) { + if (! empty($filters['extension_identifier'])) { $query->where('extension_identifier', $filters['extension_identifier']); } } @@ -177,17 +192,17 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 날짜 필터를 적용합니다. * - * @param Builder $query Eloquent 쿼리 빌더 - * @param array $filters 필터 조건 배열 + * @param Builder $query Eloquent 쿼리 빌더 + * @param array $filters 필터 조건 배열 * @return void */ private function applyDateFilters(Builder $query, array $filters): void { - if (!empty($filters['created_from'])) { + if (! empty($filters['created_from'])) { $query->whereDate('created_at', '>=', $filters['created_from']); } - if (!empty($filters['created_to'])) { + if (! empty($filters['created_to'])) { $query->whereDate('created_at', '<=', $filters['created_to']); } } @@ -233,8 +248,8 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 여러 스케줄의 상태를 일괄 업데이트합니다. * - * @param array $ids 스케줄 ID 배열 - * @param bool $isActive 활성화 여부 + * @param array $ids 스케줄 ID 배열 + * @param bool $isActive 활성화 여부 * @return int 업데이트된 레코드 수 */ public function bulkUpdateStatus(array $ids, bool $isActive): int @@ -245,7 +260,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 여러 스케줄을 일괄 삭제합니다. * - * @param array $ids 스케줄 ID 배열 + * @param array $ids 스케줄 ID 배열 * @return int 삭제된 레코드 수 */ public function bulkDelete(array $ids): int @@ -256,13 +271,13 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 스케줄을 복제합니다. * - * @param Schedule $schedule 복제할 스케줄 + * @param Schedule $schedule 복제할 스케줄 * @return Schedule 복제된 스케줄 */ public function duplicate(Schedule $schedule): Schedule { $newSchedule = $schedule->replicate(); - $newSchedule->name = $schedule->name . ' (' . __('schedule.copy') . ')'; + $newSchedule->name = $schedule->name.' ('.__('schedule.copy').')'; $newSchedule->is_active = false; $newSchedule->last_result = ScheduleResultStatus::Never; $newSchedule->last_run_at = null; diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php index c07b2f8b..3d031993 100644 --- a/app/Repositories/UserRepository.php +++ b/app/Repositories/UserRepository.php @@ -6,6 +6,8 @@ use App\Contracts\Repositories\UserRepositoryInterface; use App\Helpers\PermissionHelper; use App\Models\User; use App\Repositories\Concerns\HasMultipleSearchFilters; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -15,12 +17,19 @@ use Laravel\Sanctum\PersonalAccessToken; class UserRepository implements UserRepositoryInterface { use HasMultipleSearchFilters; + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; /** * 검색 가능한 필드 목록 */ private const SEARCHABLE_FIELDS = ['name', 'email']; + /** + * 허용 정렬 컬럼 (UserListRequest 와 동일 집합) + */ + private const SORTABLE_COLUMNS = ['created_at', 'name', 'email', 'last_login_at']; + /** * 이메일로 사용자를 찾습니다. * @@ -100,21 +109,23 @@ class UserRepository implements UserRepositoryInterface // 권한 스코프 필터링 PermissionHelper::applyPermissionScope($query, 'core.users.read'); - // roles 관계 eager loading - $query->with('roles'); - // 검색 조건 적용 $this->applyFilters($query, $filters); - // 정렬 적용 - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 적용 (허용 컬럼 화이트리스트로 해석) + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at'); // 페이지네이션 적용 $perPage = $filters['per_page'] ?? 15; - return $query->paginate($perPage); + // 지연 조인: inner 는 id 만 훑고 roles eager loading 은 해당 페이지에만 적용된다 + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + relations: ['roles'], + ); } /** diff --git a/docs/backend/service-repository.md b/docs/backend/service-repository.md index 8c862c64..571913a5 100644 --- a/docs/backend/service-repository.md +++ b/docs/backend/service-repository.md @@ -13,6 +13,7 @@ 4. 검증 로직은 FormRequest에서 (Service에 검증 금지) 5. 다중 검색은 HasMultipleSearchFilters Trait 사용 6. Service 에서 Model 직접 인스턴스화 금지 — Repository 의 build/factory 메서드 위임 (가상 모델 합성 포함) +7. 목록 조회는 컬럼 목록 명시 + 깊은 OFFSET 이 가능한 목록은 PaginatesWithDeferredJoin Trait 사용 ``` --- @@ -25,6 +26,7 @@ - [트랜잭션 및 관계 삭제 패턴](#트랜잭션-및-관계-삭제-패턴) - [상태 변경 자동 처리](#상태-변경-자동-처리) - [Repository 클래스](#repository-클래스) +- [목록 조회 컬럼 프루닝과 지연 조인](#목록-조회-컬럼-프루닝과-지연-조인) - [다중 검색 필터 Trait](#다중-검색-필터-trait-hasmultiplesearchfilters) - [모듈에서 Repository 인터페이스 바인딩](#모듈에서-repository-인터페이스-바인딩) - [중첩 리소스 스코프](#중첩-리소스-스코프) @@ -708,6 +710,49 @@ $query->selectRaw('sales_status, COUNT(*) as count'); $query->whereColumn('stock_quantity', '<=', 'safe_stock_quantity'); ``` +##### Raw 안에 테이블명·별칭을 쓰지 않는다 + +Raw 를 쓰더라도 **테이블명·별칭·프리픽스를 문자열로 조립하지 않는다.** 조립한 순간 프리픽스 처리를 사람이 떠안게 되고, 빌더가 만든 SQL 과 어긋나 조용히 깨진다. + +Laravel 은 별칭에도 프리픽스를 붙인다(`Grammar::wrapAliasedTable`). `join('board_comments as uc', ...)` 는 `g7_board_comments as g7_uc` 가 되므로 `where('uc.content', ...)` 도 `g7_uc.content` 로 맞아떨어진다. 별칭을 `DB::raw` 로 직접 만들면 그 별칭만 프리픽스가 빠져, 같은 별칭을 참조하는 빌더 조건이 전부 어긋난다. + +```php +// ❌ DON'T: 별칭을 raw 로 만들고 프리픽스를 손으로 처리 — where('uc.content') 가 g7_uc 를 찾아 실패한다 +$prefix = DB::getTablePrefix(); +$query->join(DB::raw("{$prefix}board_comments AS uc"), function ($join) { + $join->on(DB::raw("{$prefix}board_posts.id"), '=', DB::raw('uc.post_id')); +}); + +// ✅ DO: 빌더가 별칭과 프리픽스를 함께 처리하게 둔다 +$query->join((new Comment)->getTable().' as uc', function ($join) use ($postsTable) { + $join->on("{$postsTable}.id", '=', 'uc.post_id'); +}); +``` + +부득이 raw 안에 테이블명이 필요하면(예: `LEFT(...)`, `SUBSTRING(...)`) **모델에서 얻은 이름**과 **연결 설정에서 읽은 프리픽스**를 쓴다. + +```php +// ❌ DON'T +DB::raw("LEFT(`{$prefix}board_posts`.`content`, 300) as excerpt") + +// ✅ DO +$postsTable = (new Post)->getTable(); +DB::raw('LEFT(`'.DB::getTablePrefix().$postsTable.'`.`content`, 300) as excerpt') +``` + +##### 빌더로 대체 가능한 raw 관용구 + +| Raw | 빌더 대체 | 비고 | +|---|---|---| +| `whereRaw('1 = 0')` / `whereRaw('0 = 1')` | `whereIn($model->getKeyName(), [])` | 빈 `whereIn` 이 정확히 `0 = 1` 로 컴파일된다 | +| `where($col, DB::raw("({$sub->toSql()})"))` + `mergeBindings` | `where($col, '=', $sub)` | 빌더를 값으로 넘기면 바인딩이 자동 병합된다 | +| `addSelect(DB::raw("({$subSql}) as alias"))` | `addSelect(['alias' => $sub])` | 서브쿼리 select 는 빌더를 그대로 받는다 | +| `whereRaw("... CASE ... IN (?,?)")` | 갈래별 `orWhere(fn ($q) => $q->where(...)->whereIn(...))` | 대상 종류에 따라 갈리는 조건은 갈래별 빌더 조건으로 | +| 상관 서브쿼리 문자열 | `whereColumn()` 으로 외부 컬럼 참조 | 값 비교는 `where()` 로 바인딩 | +| 조인 + `groupBy` 로 존재 판정 | `whereHas()` | 행 증폭이 없어 inner 스캔이 가벼워지고 그룹 COUNT 도 불필요 | + +집계 함수(`COUNT`/`SUM`/`COALESCE`)와 DB 고유 함수(`MATCH ... AGAINST`, `JSON_CONTAINS`, `LEFT`, `SUBSTRING`)는 빌더에 대응 표현이 없으므로 raw 로 남긴다. 이때도 **값은 바인딩**하고 테이블명은 넣지 않는다. + ### 패턴 ```php @@ -788,6 +833,164 @@ public function getAll(): Collection --- +## 목록 조회 컬럼 프루닝과 지연 조인 + +### 규칙 + +- 목록 조회는 `paginate($perPage)` 처럼 컬럼 인자를 생략하지 않는다. 화면이 실제로 쓰는 컬럼만 명시한다 (인자를 생략하면 `select *` 로 폴백해 넓은 컬럼까지 전부 읽는다). +- 행이 계속 늘어나는 테이블(게시글·주문·활동 로그·알림 발송 이력 등)의 목록은 `PaginatesWithDeferredJoin` Trait 을 사용한다. + +### 배경 + +`OFFSET n` 은 건너뛸 n 건을 읽지 않고 넘기는 것이 아니라 **실제로 읽은 뒤 버린다.** 목록 SELECT 에 `text`/`longText`/JSON 컬럼이 포함돼 있으면 버릴 n 건의 넓은 컬럼까지 함께 읽힌다. 게다가 InnoDB 는 행에 다 담기지 않는 값을 오버플로 페이지에 따로 저장하므로, 앞부분만 필요해도 그 페이지를 읽는다. + +게시글 테이블 20만 행 기준 실측 (각 3회 중앙값, 첫 회 버림): + +```bash +php artisan g7:bench:pagination --table=board_posts --seed=200000 --offsets=0,20000,50000,199980 --runs=3 --explain +``` + +| OFFSET | 목록 컬럼 | ID 만 조회 | 배수 | +|---:|---:|---:|---:| +| 0 | 4.1ms | 3.9ms | 1.0× | +| 20,000 | 1,682.6ms | 139.8ms | 12.0× | +| 50,000 | 1,623.8ms | 90.3ms | 18.0× | +| 199,980 | 1,811.2ms | 93.9ms | 19.3× | + +같은 계측의 실행 계획을 보면 원인이 하나 더 드러난다. OFFSET 0 은 `(board_id, created_at)` 인덱스를 역방향으로 훑어 정렬 없이 끝나지만, OFFSET 이 커지면 옵티마이저가 소프트 삭제 단독 인덱스(`deleted_at`)로 갈아타면서 `filesort` 가 붙는다. 넓은 컬럼을 걷어내는 것과 정렬을 인덱스로 덮는 것은 함께 가야 한다. + +`SUBSTRING(content, 1, N)` 을 목록 컬럼에 두는 것도 프루닝이 아니다. 앞 N 바이트를 얻기 위해 오버플로 페이지를 그대로 읽는다. + +`SUBSTRING(content, 1, 200)` 을 목록 컬럼에 두는 것은 프루닝이 아니다. 앞 200바이트를 얻기 위해 오버플로 페이지를 그대로 읽기 때문이다. 잘라내기는 지연 조인의 outer 로 옮긴다. + +### 사용 + +```php +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; + +class OrderRepository implements OrderRepositoryInterface +{ + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + public function paginate(array $filters, int $perPage): LengthAwarePaginator + { + $query = $this->model->newQuery(); + $this->applyFilters($query, $filters); // where 만 적용 + + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['id', 'order_number', 'status', 'total_amount', 'ordered_at'], + sort: $this->resolveSortSpec($filters, ['id', 'ordered_at', 'total_amount'], 'ordered_at'), + perPage: $perPage, + relations: ['user', 'items'], + ); + } +} +``` + +### 계약 + +| 항목 | 규칙 | +| ------ | ------ | +| `$query` | 필터/where 만 적용한다. `orderBy`/`with`/`select` 는 Trait 이 담당한다 | +| `whereHas` | 결과 집합을 결정하는 조건이므로 그대로 둔다. Trait 은 `with` 만 inner 에서 제거한다 | +| 정렬 | 닫힌 집합이어야 한다. 인덱스 없는 임의 컬럼 정렬은 inner 도 전체 스캔하므로 개선 폭이 보장되지 않는다 | +| 키 컬럼 | 정렬 스펙 끝에 자동으로 덧붙는다. 동률 행의 순서가 흔들려 페이지 경계에서 행이 중복·누락되는 것을 막는다 | + +`paginate` / `simplePaginate` / 캐시 total 주입은 인자로 구분한다 — inner 쿼리는 동일하고 COUNT 수행 여부와 반환 클래스만 달라진다. + +| 호출자 상황 | `$simple` | `$total` | 반환 | +| ------ | ------ | ------ | ------ | +| 일반 목록 | `false` | `null` | `LengthAwarePaginator` (COUNT 1회) | +| COUNT 를 피하고 다음 페이지 유무만 필요 | `true` | — | `Paginator` | +| 총 건수를 이미 캐시해 둠 | `false` | 캐시값 | `LengthAwarePaginator` (COUNT 없음) | + +정렬 순서 복원은 `whereIn` + 같은 정렬 스펙 재적용으로 한다. 키 컬럼이 정렬에 포함돼 전순서가 성립하므로 결과가 inner 순서와 동일하다. MySQL 전용 `FIELD()` 는 쓰지 않는다. outer 에 존재하지 않는 표현식(집계·조인 서브쿼리)으로 정렬해 재현이 불가능한 경우에만 `$preserveIdOrder = true` 로 표준 SQL `CASE WHEN` 경로를 쓴다. + +### 정렬 컬럼 화이트리스트 + +요청 값을 그대로 `orderBy()` 에 넘기지 않는다. Laravel 이 컬럼명을 백틱으로 감싸므로 SQL 인젝션은 성립하지 않지만, (a) 없는 컬럼으로 인한 SQL 오류가 스키마 정보를 노출하고 (b) 인덱스 없는 넓은 컬럼 정렬을 강제해 DoS 표면이 된다. + +정렬 선택지가 4개 이하로 고정된 곳은 `match` 표현식으로 이미 닫혀 있으므로 그대로 두고, 요청 값이 동적 컬럼으로 흘러 들어가는 곳만 `ResolvesSortSpec` 을 쓴다. + +허용 컬럼 집합은 대응하는 FormRequest 의 `Rule::in(...)` 과 같은 값으로 맞추고, 그 사실을 상수 주석에 남긴다. 요청이 아닌 경로(콘솔 커맨드·내부 호출)로도 같은 Repository 메서드가 불릴 수 있으므로, 화이트리스트는 FormRequest 하나에만 두지 않고 쿼리를 만드는 자리에도 둔다. + +**게이트보다 좁게 두지 않는다.** 허용 값의 SSoT 는 요청을 실제로 거절하는 FormRequest 이고, Repository 상수는 그 아래에 깔리는 안전망이다. 상수가 게이트의 부분집합이면 **검증을 통과한 정렬이 조용히 기본값으로 되돌아간다** — 422 도 로그도 남지 않아 "정렬 버튼이 안 먹는다" 로만 관측된다. FormRequest 가 `HookManager::applyFilters` 로 확장에 열려 있는 목록은 확장이 정렬 컬럼을 늘릴 수 있으므로, 상수를 게이트보다 넓게 두는 편이 안전하다. 상수를 새로 만들거나 고칠 때는 대응 FormRequest 의 `in:` 값을 눈으로 대조하고, 컬럼명이 실재하는지 마이그레이션에서 확인한다. + +**방향 검증은 컬럼 검증이 아니다.** 다음은 정렬 방향만 닫아 두고 컬럼은 그대로 흘려보낸 형태이며, 사람이 읽어도 `in_array` 가 있어 안전해 보이지만 실제로는 무방비다. + +```php +// ❌ $sortOrder 만 검사 — $sortBy 는 그대로 orderBy 로 들어간다 +$sortBy = $filters['sort_by'] ?? 'created_at'; +$sortOrder = strtolower($filters['sort_order'] ?? 'desc'); + +if (! in_array($sortOrder, ['asc', 'desc'])) { + $sortOrder = 'desc'; +} + +$query->orderBy($sortBy, $sortOrder); +``` + +```php +// ✅ 컬럼·방향을 한 번에 해석 (방향 정규화는 Trait 이 담당) +foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); +} +``` + +`repository-sort-column-whitelist` 룰은 **정렬 컬럼 변수 자체를 인자로 받는** `in_array`/`match`/`array_key_exists` 또는 `resolveSortSpec` 만 화이트리스트로 인정한다. + +### 예외 + +설정/정의성 테이블처럼 행 수가 고정이고 넓은 컬럼이 없으면 컬럼 목록 없이 페이지네이션해도 된다. 다만 그 판단 근거를 코드 옆에 남긴다. + +```php +// audit:allow repository-paginate-column-pruning reason: 역할 정의 테이블 — 행 수 고정(<100), 넓은 컬럼 없음 +return $query->paginate($perPage); +``` + +면제 주석은 **위반 줄에 인접**해야 인식된다. 메서드 첫 줄에 두면 체인 중간의 `->paginate(...)` 줄과 사이에 코드가 끼어 인식되지 않는다. + +```php +// ✅ 체인 안에서는 ->paginate 바로 위에 둔다 +return $this->model->newQuery() + ->where('user_id', $userId) + ->orderByDesc('created_at') + // audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 목록 — OFFSET 이 깊어질 수 없다 + ->paginate($perPage); +``` + +면제 사유로 인정되는 판단 근거는 다음 세 가지다. 어느 것도 아니면 지연 조인으로 전환한다. + +| 근거 | 성립 조건 | 예 | +|---|---|---| +| 정의/템플릿 테이블 | 행 수가 운영자가 등록한 수에 묶이고 넓은 컬럼이 없다 | 게시판 정의, 배송정책, 상품 안내 템플릿 | +| 부모 1건에 종속 | `where(부모FK)` 로 좁혀져 OFFSET 이 깊어질 수 없다 | 상품 1건의 문의, 사용자 1명의 찜 | +| ID 집합으로 선한정 | `whereIn('id', $matchedIds)` 로 스캔 대상이 이미 묶였다 | FULLTEXT 매칭 후 조회하는 통합검색 | + +"부모 1건에 종속" 은 그 부모가 가질 수 있는 자식 수를 실제로 따져 본 뒤에만 쓴다. 대량 발급 쿠폰 1건의 발급 이력처럼 부모가 하나여도 수십만 행이 되는 경우는 이 근거가 성립하지 않는다. + +컨트롤러가 Service 에 목록 조회를 위임하는 형태(`$this->service->paginate(...)`)는 컬럼 선택 책임이 위임받은 Repository 에 있으므로 룰이 검사하지 않는다. + +### outer 의 `columns: ['*']` 는 위반이 아니다 + +지연 조인의 목적인 **OFFSET 구간의 넓은 컬럼 읽기**는 inner 가 키 컬럼만 읽는 것으로 이미 사라진다. outer 가 읽는 행 수는 페이지 크기로 고정되므로 `columns: ['*']` 여도 뒤쪽 페이지의 비용 증가는 없다. 넓은 컬럼을 가진 테이블에서 목록 컬럼을 좁히는 것은 그 위에 얹는 추가 최적화이고, 화면이 실제로 쓰는 컬럼이 분명할 때 적용한다. + +다만 `columns:` 자체를 생략하면 outer 가 무엇을 읽는지 호출처에서 드러나지 않으므로 항상 명시한다. `DeferredJoinCallSiteContractTest` 가 호출처를 스캔해 `columns:`/`sort:` 명시 여부를 전수 검사한다. + +### 정적 검사 대상 + +| 검출 대상 | 처리 | +|---|---| +| `->paginate($perPage)` / `->paginate($perPage, ['*'])` — 컬럼 목록 미지정 또는 전체 컬럼 | 차단 | +| 요청 값에서 온 정렬 컬럼을 닫힌 집합 검증 없이 `orderBy` 에 전달 | 차단 | + +검사 범위는 `app/Repositories/**`, `modules|plugins/_bundled/**/Repositories/**` 와 컨트롤러 3계열이다. 미수정 파일까지 확인하려면 변경분이 아니라 저장소 전체를 대상으로 검사한다 — 변경 파일만 보는 방식으로는 이미 저장소에 있던 위반이 드러나지 않는다. + +--- + ## 다중 검색 필터 Trait (HasMultipleSearchFilters) 목록 API에서 다중 검색 조건을 지원해야 할 때 `HasMultipleSearchFilters` Trait을 사용합니다. diff --git a/docs/backend/validation.md b/docs/backend/validation.md index ac160315..62fa7f2f 100644 --- a/docs/backend/validation.md +++ b/docs/backend/validation.md @@ -522,6 +522,7 @@ class TestMailRequest extends FormRequest | 기본값 제공 | `$this->input('mailer', 'smtp')` — 미전송 시 기본 드라이버 적용 | | Store/Update 정책 일치 | 같은 리소스의 Store 와 Update 는 동일 필드에 동일한 조건부 규칙을 적용한다. 한쪽에만 `exclude_if` 등이 있으면 다른 쪽에서만 저장이 막힌다 | | `sometimes` 는 부분 수정 지원 수단이 아니다 | 관리자 폼은 조회 응답 전체를 그대로 PUT 하므로 요청에는 항상 모든 키가 존재한다. 토글 OFF 상태의 종속 필드는 `sometimes` 가 아니라 `exclude_if` 로 검증에서 배제한다 | +| 상호 필수 조합은 결과 상태로 판정 | `required_with`/`required_without` 는 "상대 필드 부재" 자체가 발화 조건이라 부분 수정 요청과 상충한다. Update 에서는 페이로드 단독이 아니라 "기존 레코드 + 페이로드" 결과 상태를 기준으로 판정한다 | `exclude_if` 는 규칙 배열의 **첫 번째**에 둔다. 배제는 규칙 루프 도중 발동하며 이미 추가된 실패 메시지는 회수되지 않으므로, 앞선 규칙이 먼저 실패하면 배제되어도 오류가 남는다. @@ -533,6 +534,68 @@ class TestMailRequest extends FormRequest 'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'], ``` +조건부 규칙의 종류에 따라 Update 로 옮기는 방식이 달라진다. + +| Store 규칙 | Update 에 옮기는 방식 | +| ------ | ------ | +| `required_if:other,value` | 그대로 복사한다. 조건 필드가 요청에 없으면 발화하지 않아 부분 수정과 충돌하지 않는다 | +| `exclude_if` / `exclude_unless` | 그대로 복사한다. 조건 필드가 요청에 없으면 배제가 동작하지 않으므로(`ValidatesAttributes::validateExcludeIf`), 부분 수정 경로가 있으면 `prepareForValidation()` 에서 기존 레코드 값을 주입해 판정 기준을 세운다 | +| `required_with` / `required_without` | 그대로 복사하면 안 된다. 상대 필드가 요청에 없다는 사실만으로 발화하므로, 그 조합과 무관한 필드 하나만 바꾸는 요청이 전부 422 가 된다 | + +`required_with`/`required_without` 는 "요청에 무엇이 실려 왔는지"가 아니라 "저장 후 레코드가 유효한지"를 묻는 규칙이다. 따라서 Update 에서는 요청에 없는 필드를 저장된 값으로 메운 뒤 판정한다. 이때 폼 제출 키와 저장 컬럼명이 다르면(예: 해외 주소를 `intl_city` 로 제출하고 `city` 컬럼에 저장) 메우는 쪽에서 그 매핑을 반영해야 한다 — 매핑을 빠뜨리면 저장된 값이 있는데도 빈 값으로 보여 정상 수정 경로가 막힌다. + +```php +// ❌ DON'T: Store 규칙을 그대로 복사 — 기본 배송지 토글만 바꾸는 요청이 422 +'zipcode' => 'required_without:intl_postal_code|nullable|string|max:10', + +// ✅ DO: 결과 상태(기존 레코드 + 페이로드) 기준으로 withValidator() 에서 판정 +$validator->after(function (Validator $validator) { + $existing = app(UserAddressRepositoryInterface::class)->findByUserIdAndId($userId, $addressId); + $effective = array_key_exists('zipcode', $payload) ? $payload['zipcode'] : $existing?->zipcode; + // ... 국가별 필수 조합 판정 후 $validator->errors()->add(...) +}); +``` + +이 방식은 `formrequest-store-update-conditional-symmetry` 룰의 토큰 비교로는 "누락"으로 보이므로, 해당 필드에 `// audit:allow formrequest-store-update-conditional-symmetry reason: ...` 로 판정 위치를 남긴다. + +### `exclude_if` vs `exclude_unless` — 조건 필드가 nullable 일 때 + +`exclude_if:other,false` 의 값 비교는 strict 다. 조건 필드가 `nullable|boolean` 이라 `null` 이 올 수 있으면 `null !== false` 라 매칭되지 않아 배제가 일어나지 않는다. "true 가 아니면 제외" 를 뜻하는 `exclude_unless:other,true` 는 `null`/`false`/미전송을 모두 포괄한다. + +```php +// 조건 필드가 boolean 고정 — false 가 확실히 온다 +'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'], + +// 조건 필드가 nullable — null 이 올 수 있다 +'basic_defaults.allowed_extensions' => ['exclude_unless:basic_defaults.use_file_upload,true', 'sometimes', 'required', 'array', 'min:1'], +``` + +**중첩 배열의 조건 필드는 dot notation 으로 쓴다.** `exclude_unless:use_file_upload,true` 처럼 최상위 키만 적으면 요청 데이터에 그 키가 없어 조건이 성립하지 않고, 배제가 조용히 동작하지 않는다. 검증 대상 필드가 `basic_defaults.allowed_extensions` 라면 조건 필드도 `basic_defaults.use_file_upload` 여야 한다. + +### 조건 필드가 요청에 없을 때 — 저장값 주입 + +`exclude_if`/`exclude_unless` 는 **조건 필드가 데이터에 없으면 배제하지 않는다**(`ValidatesAttributes::validateExcludeIf`). 부분 수정 경로가 있는 Update 요청은 종속 필드만 전송되고 조건 토글은 빠질 수 있으므로, `prepareForValidation()` 에서 기존 레코드의 값을 주입해 판정 기준을 세운다. + +```php +protected function prepareForValidation(): void +{ + $data = $this->all(); + + // 종속 필드가 왔는데 조건 토글이 없으면 저장값으로 판정 기준을 세운다 + if (array_key_exists('allowed_extensions', $data) && ! array_key_exists('use_file_upload', $data)) { + $board = $this->route('board'); + + if ($board !== null && isset($board->use_file_upload)) { + $data['use_file_upload'] = (bool) $board->use_file_upload; + } + } + + $this->merge($data); +} +``` + +주입 조건에 **종속 필드가 실제로 전송되었는지**를 반드시 포함한다. 무조건 주입하면 사용자가 보내지도 않은 토글이 검증 대상으로 올라와, 그 필드와 무관한 부분 수정이 조건부 규칙의 영향을 받는다. + 자동 차단: 정적 검사 대상. store/update 사이의 조건부 검증 비대칭은 차단되고, `sometimes` + `array` + `min` 조합이 무효화되는 형태는 경고로 보고된다. --- diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index cf333c2e..fef00780 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -255,6 +255,17 @@ php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full) php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정 ``` +### 성능 계측 Artisan 커맨드 + +```bash +# 깊은 OFFSET 목록 조회 비용 계측 (목록 컬럼 vs 키 컬럼 두 축). 상세: docs/backend/service-repository.md +# 대량 합성 행을 시딩하므로 운영 데이터가 있는 환경에서 --seed/--fresh 를 쓰지 않는다. +php artisan --env=testing g7:bench:pagination --list-profiles +php artisan --env=testing g7:bench:pagination --table=board_posts --fresh --seed=200000 +php artisan --env=testing g7:bench:pagination --table=board_posts --offsets=0,20000,50000,199980 --runs=3 --explain +php artisan --env=testing g7:bench:pagination --table=orders --json # 기계 판독용 +``` + ### API 문서 Artisan 커맨드 ```bash diff --git a/docs/database-guide.md b/docs/database-guide.md index 22ccae0b..1025646c 100644 --- a/docs/database-guide.md +++ b/docs/database-guide.md @@ -221,6 +221,25 @@ public function up(): void } ``` +#### 정렬을 덮는 복합 인덱스의 선행 컬럼 + +인덱스가 `ORDER BY` 를 덮으려면 **선행 컬럼이 모두 등치로 고정**되고 그 다음 컬럼이 정렬 컬럼이어야 한다. 선택적 필터 컬럼을 선행에 두면, 그 필터를 걸지 않은 기본 진입에서 정렬을 못 덮어 filesort 가 그대로 남는다. + +```php +// ❌ status 는 선택적 필터 — 필터 없는 기본 목록에서 created_at 정렬을 못 덮는다 +$table->index(['status', 'created_at']); + +// ✅ SoftDeletes 가 항상 `deleted_at IS NULL` 을 붙이므로 선행 컬럼이 고정된다 +$table->index(['deleted_at', 'created_at']); +``` + +필터 조합이 다양한 목록은 단일 최적 인덱스가 없다. **항상 걸리는 조건**을 선행에 두고 하나만 추가한다 — 조합마다 인덱스를 추가하면 쓰기 비용만 늘어난다. + +카디널리티가 극히 낮은 컬럼(`deleted_at` 처럼 대부분 NULL)의 **단독** 인덱스는 옵티마이저가 좁은 range 로 오판해 index_merge 후보로 올리므로 거의 항상 해롭다. 다만 기존 단독 인덱스를 제거할 때는 다른 쿼리가 그것에 의존할 수 있으므로, 제거 전 해당 컬럼을 쓰는 쿼리를 전수 확인한다. + +> 후속 검토 항목: `g7_board_posts_deleted_at_index`(단독) — 지연 조인 전환으로 index_merge 는 사라졌으나 인덱스 자체는 남아 있다. 의존 쿼리 전수 확인 후 제거 여부를 별도로 판단한다. +> `board_posts` 에 정렬 전용 복합 인덱스를 **추가하지 않기로** 판정한 근거: 지연 조인 적용 후 재측정에서 index_merge 가 소멸했고 inner 가 기존 `idx_board_posts_list_count` 를 커버링으로 사용한다. 후보 인덱스 `(board_id, is_notice, parent_id, deleted_at, id)` 를 실제로 만들어 강제 비교했을 때의 이득이 1.6배에 그쳐 쓰기 비용 대비 근거가 부족하다. 계측 데이터가 단일 게시판·공지 없음·답글 없음의 균일 분포라 두 후보의 선택도가 같아지는 한계가 있어, 실데이터 분포에서 재확인이 필요하다. + #### `->change()` 패턴의 멱등성 `->change()` 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 fatal 위험은 없다. 단, MariaDB/MySQL 의 일부 ALTER 가 비-멱등인 경우 (예: enum 값 추가 후 재실행) 가 있으므로 마이그레이션 작성자가 의도 검증 필요. 정적 검사는 `->change()` 라인을 자동 제외하므로 false positive 미발생. diff --git a/modules/_bundled/gnuboard7-hello_module/src/Repositories/MemoRepository.php b/modules/_bundled/gnuboard7-hello_module/src/Repositories/MemoRepository.php index d90d9861..deaeb6ea 100644 --- a/modules/_bundled/gnuboard7-hello_module/src/Repositories/MemoRepository.php +++ b/modules/_bundled/gnuboard7-hello_module/src/Repositories/MemoRepository.php @@ -2,6 +2,7 @@ namespace Modules\Gnuboard7\HelloModule\Repositories; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Modules\Gnuboard7\HelloModule\Models\Memo; use Modules\Gnuboard7\HelloModule\Repositories\Contracts\MemoRepositoryInterface; @@ -21,6 +22,8 @@ class MemoRepository implements MemoRepositoryInterface { return Memo::query() ->orderByDesc('created_at') + // audit:allow repository-paginate-column-pruning reason: 학습용 샘플 확장의 메모 테이블 — + // 넓은 컬럼(text/JSON)이 없고, 지연 조인을 넣으면 샘플 코드의 학습 목적이 흐려진다 ->paginate($perPage); } @@ -41,7 +44,7 @@ class MemoRepository implements MemoRepositoryInterface * @param int $id 메모 ID * @return Memo 메모 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): Memo { diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 3c63e53f..f7932f3e 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -20,6 +20,9 @@ ### Changed - 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 게시글 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 게시글의 본문 앞부분까지 함께 읽어 느려졌지만, 이제 현재 페이지의 게시글만 본문 미리보기를 읽습니다. 공지 노출·답글 표시·다음 페이지 버튼은 이전과 동일하게 동작합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 신고 관리 목록도 같은 방식으로 개선했습니다. 신고 건수·대상 글 상태처럼 목록에 함께 표시되는 정보를 예전에는 건너뛰는 신고까지 모두 계산했지만, 이제 현재 페이지의 신고에 대해서만 계산합니다. 신고가 많이 쌓인 사이트에서 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 마이페이지의 「내가 쓴 댓글」·「내가 댓글 단 글」 목록도 같은 방식으로 개선했습니다. 각 글의 최근 댓글을 예전에는 건너뛰는 글까지 모두 찾아봤지만, 이제 현재 페이지의 글에 대해서만 찾습니다. 댓글 활동이 많은 회원의 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ### Fixed @@ -32,7 +35,8 @@ - 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). - 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) -- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.) +- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 마이페이지 「내가 댓글 단 글」에서 검색어를 입력하면 목록이 열리지 않고 오류가 나던 문제를 수정했습니다. 이제 댓글 내용으로 정상 검색됩니다. - 새 글 표시 기간을 "0시간"(새 글 표시 안 함)으로 저장할 수 없던 문제를 수정했습니다. 0은 원래 새 글 배지를 끄는 값으로 동작하도록 되어 있었으나 저장 단계에서 막혀 있었습니다. - 게시판 환경설정에서 제목·댓글 최소 길이를 0(제한 없음)으로 저장할 수 없던 문제를 수정했습니다. 내용 최소 길이는 0이 허용되어 같은 화면에서 항목마다 동작이 달랐습니다. - 댓글 계층 정리 시 서로가 서로의 부모로 얽혀 최상위 댓글에서 닿을 수 없는 댓글이 아무 기록 없이 넘어가던 문제를 수정했습니다. 이런 댓글은 내용과 위치를 그대로 보존하되, 정리되지 않았다는 사실이 업데이트 기록에 남아 확인할 수 있습니다. diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index 93504de1..ed68e4f6 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -1232,7 +1232,7 @@ HTTP/1.1 200 | use_file_upload | body | boolean | 예 | — | 파일 첨부 기능 사용 여부 (true일 때 allowed_extensions 최소 1개 필수) | | max_file_size | body | integer | 아니오 | min 1, max 200 | 첨부파일 1개당 최대 크기 (MB 단위) | | max_file_count | body | integer | 아니오 | min 1, max 20 | 게시글 1건당 첨부할 수 있는 최대 파일 개수 | -| allowed_extensions | body | array | 조건부 | min 1 | 업로드 허용 확장자 목록 (예: jpg, png, pdf). `use_file_upload=true` 일 때만 필수이며 최소 1개가 있어야 한다. `use_file_upload=false` 이면 검증에서 제외되어 빈 배열·`null` 도 허용된다 | +| allowed_extensions | body | array | 조건부 | min 1 | 업로드 허용 확장자 목록 (예: jpg, png, pdf). `use_file_upload=true` 일 때만 필수이며 최소 1개가 있어야 한다. `use_file_upload=false` 이면 검증에서 제외되어 빈 배열·`null` 도 허용된다. **`allowed_extensions` 만 보내고 `use_file_upload` 를 생략하면 저장된 게시판의 값을 기준으로 판정한다** — 첨부를 쓰지 않는 게시판은 빈 배열도 통과하고, 첨부를 쓰는 게시판은 그대로 차단된다 | | board_manager_ids | body | array | 예 | min 1 | board manager 식별자 배열 | | board_step_ids | body | array | 아니오 | — | board step 식별자 배열 | | permissions | body | array | 예 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 각 권한을 all 또는 특정 역할에 부여) | diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php index 8cd0c6e2..d2e37eb8 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php @@ -35,12 +35,25 @@ class UpdateBoardRequest extends FormRequest /** * 검증 전 데이터 전처리 * - * blocked_keywords가 문자열로 전송된 경우 배열로 변환합니다. + * blocked_keywords가 문자열로 전송된 경우 배열로 변환하고, 조건부 배제(`exclude_if`)의 + * 판정 기준이 되는 토글이 요청에 없으면 기존 게시판의 저장값을 주입합니다. */ protected function prepareForValidation(): void { $data = $this->all(); + // allowed_extensions 만 부분 전송되고 use_file_upload 가 없으면 exclude_if 가 동작하지 + // 않는다(ValidatesAttributes::validateExcludeIf 는 조건 필드가 데이터에 없으면 배제하지 + // 않는다). 그러면 첨부를 쓰지 않는 게시판인데도 확장자 필수 규칙이 발화한다. + // 기존 게시판의 저장값을 주입해 "저장된 상태 + 페이로드" 결과 상태로 판정한다. + if (array_key_exists('allowed_extensions', $data) && ! array_key_exists('use_file_upload', $data)) { + $board = $this->route('board'); + + if ($board !== null && isset($board->use_file_upload)) { + $data['use_file_upload'] = (bool) $board->use_file_upload; + } + } + // blocked_keywords가 문자열이면 배열로 변환 (validation 전) if (isset($data['blocked_keywords']) && is_string($data['blocked_keywords'])) { $keywords = array_filter( diff --git a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php index aa578fc3..a0eeea71 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php @@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Board\Repositories; use App\Enums\UserStatus; use App\Helpers\PermissionHelper; +use App\Models\Attachment; +use App\Models\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -147,6 +149,8 @@ class BoardRepository implements BoardRepositoryInterface // 권한 스코프 필터링 PermissionHelper::applyPermissionScope($query, 'sirsoft-board.boards.read'); + // audit:allow repository-paginate-column-pruning reason: 게시판 정의 테이블 — + // 행 수가 운영자가 만든 게시판 수에 묶이고(글이 아니라 게시판), 넓은 컬럼이 없다 return $query->paginate($perPage); } @@ -434,27 +438,32 @@ class BoardRepository implements BoardRepositoryInterface default => now()->subYear(), }; - $prefix = DB::getTablePrefix(); + // 테이블명은 모델에서 얻는다 (문자열 하드코딩 금지). LEFT() 는 빌더에 대응 표현이 + // 없는 표준 SQL 함수라 이 컬럼 하나만 raw 로 두고, 프리픽스는 연결 설정에서 읽는다. + $postsTable = (new Post)->getTable(); + $usersTable = (new User)->getTable(); + $attachmentsTable = (new Attachment)->getTable(); + $prefixedPostsTable = DB::getTablePrefix().$postsTable; $query = Post::query() ->with('board:id,slug,name') - ->leftJoin('users', 'board_posts.user_id', '=', 'users.id') - ->leftJoin('attachments', function ($join) { - $join->on('attachments.attachmentable_id', '=', 'users.id') - ->where('attachments.attachmentable_type', '=', 'App\\Models\\User') - ->where('attachments.collection', '=', 'avatar'); + ->leftJoin($usersTable, "{$postsTable}.user_id", '=', "{$usersTable}.id") + ->leftJoin($attachmentsTable, function ($join) use ($usersTable, $attachmentsTable) { + $join->on("{$attachmentsTable}.attachmentable_id", '=', "{$usersTable}.id") + ->where("{$attachmentsTable}.attachmentable_type", '=', User::class) + ->where("{$attachmentsTable}.collection", '=', 'avatar'); }) ->select([ 'board_posts.id', 'board_posts.board_id', 'board_posts.title', - DB::raw("LEFT(`{$prefix}board_posts`.`content`, 300) as content_raw"), + DB::raw("LEFT(`{$prefixedPostsTable}`.`content`, 300) as content_raw"), 'board_posts.user_id', 'board_posts.author_name as guest_author_name', - 'users.name as user_name', - 'users.email as user_email', - 'users.status as user_status', - 'attachments.hash as attachment_hash', + "{$usersTable}.name as user_name", + "{$usersTable}.email as user_email", + "{$usersTable}.status as user_status", + "{$attachmentsTable}.hash as attachment_hash", 'board_posts.view_count', 'board_posts.comments_count', 'board_posts.created_at', diff --git a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php index 95e980d4..0ce04015 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Board\Repositories; use App\Helpers\PermissionHelper; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -11,6 +12,7 @@ use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Board\Enums\TriggerType; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Comment; +use Modules\Sirsoft\Board\Models\Post; use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface; use Modules\Sirsoft\Board\Traits\FormatsBoardDate; @@ -22,6 +24,7 @@ use Modules\Sirsoft\Board\Traits\FormatsBoardDate; class CommentRepository implements CommentRepositoryInterface { use FormatsBoardDate; + use PaginatesWithDeferredJoin; /** * 특정 게시글의 댓글 목록을 조회합니다. @@ -546,18 +549,16 @@ class CommentRepository implements CommentRepositoryInterface // 비활성 게시판 제외 — JOIN 대신 whereNotIn으로 인덱스 활용 $inactiveBoardIds = Board::where('is_active', false)->pluck('id')->all(); + // 관계/정렬은 지연 조인이 담당한다 (inner 는 키 컬럼만 조회) $query = Comment::query() - ->select('board_comments.*') ->where('board_comments.user_id', $userId) // 삭제된 게시글 제외 — whereExists로 JOIN 대체 ->whereExists(function ($q) { $q->select(DB::raw(1)) - ->from('board_posts') + ->from((new Post)->getTable()) ->whereColumn('board_posts.id', 'board_comments.post_id') ->whereNull('board_posts.deleted_at'); - }) - ->with(['post.board']) - ->orderBy('board_comments.created_at', $orderDirection); + }); // 비활성 게시판 제외 if (! empty($inactiveBoardIds)) { @@ -578,7 +579,25 @@ class CommentRepository implements CommentRepositoryInterface $query->where('board_comments.content', 'like', $keyword); } - $paginator = $query->paginate($perPage, ['*'], 'page', null, $cachedTotal); + // 캐시된 total 이 있으면 COUNT 를 건너뛴다. 검색/게시판 필터가 걸린 조회는 캐시를 + // 쓰지 않으므로 이때는 trait 이 직접 COUNT 한다. + // + // 목록 컬럼은 아래 through() 가 실제로 읽는 것만 남긴다 (post_id 는 post 관계 FK). + // 넓은 컬럼인 content 는 화면에 표시하므로 유지하되, 지연 조인으로 읽는 행 수가 + // 페이지 크기에 고정된다. + $paginator = $this->paginateWithDeferredJoin( + query: $query, + columns: [ + 'board_comments.id', + 'board_comments.post_id', + 'board_comments.content', + 'board_comments.created_at', + ], + sort: [['column' => 'board_comments.created_at', 'direction' => $orderDirection]], + perPage: $perPage, + relations: ['post.board'], + total: $cachedTotal, + ); // paginate 후 현재 페이지 항목에만 PHP 가공 적용 $paginator->through(function (Comment $comment) { diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php index 6eb3a160..d360a09f 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Repositories; use App\Enums\PermissionType; use App\Helpers\PermissionHelper; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; use App\Search\Engines\DatabaseFulltextEngine; use Carbon\CarbonImmutable; use Illuminate\Contracts\Pagination\Paginator; @@ -31,6 +32,7 @@ class PostRepository implements PostRepositoryInterface { use ChecksBoardPermission; use FormatsBoardDate; + use PaginatesWithDeferredJoin; /** * PostRepository 생성자 @@ -692,12 +694,13 @@ class PostRepository implements PostRepositoryInterface // → 콜드 스타트(버퍼 풀 미적재)에서도 ~2ms (range scan 대비 100배 이상 빠름) $aggregateFunc = $strictOp === '<' ? 'MAX' : 'MIN'; $subQuery = $baseQuery() - ->select(DB::raw("{$aggregateFunc}(`{$orderBy}`)")) + ->select(DB::raw("{$aggregateFunc}({$orderBy})")) ->where($orderBy, $strictOp, $currentValue); + // 서브쿼리는 빌더 그대로 넘긴다 — toSql() 로 문자열을 붙이고 mergeBindings 로 + // 바인딩을 손수 옮기면 조건 추가 순서에 따라 바인딩이 어긋날 수 있다. return $baseQuery() - ->where($orderBy, DB::raw("({$subQuery->toSql()})")) - ->mergeBindings($subQuery->getQuery()) + ->where($orderBy, '=', $subQuery) ->orderBy('id', $idSort) ->first(); } @@ -827,22 +830,33 @@ class PostRepository implements PostRepositoryInterface $orderDirection = 'desc'; // 기본값으로 폴백 } - // 정렬 적용 (id를 2차 정렬로 추가 — 동일 값 내 순서를 결정론적으로 보장) + // 정렬 스펙 (id를 2차 정렬로 추가 — 동일 값 내 순서를 결정론적으로 보장) // created_at은 초 단위라 실질적 중복이 드물지만, view_count/title/author_name은 중복이 많음 - $parentQuery->orderBy($orderBy, $orderDirection)->orderBy('id', $orderDirection); - - if (! empty($relations)) { - $parentQuery->with($relations); - } + $sort = [['column' => $orderBy, 'direction' => $orderDirection]]; // 페이지네이션 여부에 따라 분기 if ($perPage !== null) { - // simplePaginate 사용 — COUNT(*) 쿼리 제거로 대량 데이터 성능 개선 - // total은 Service 캐시 카운트로 별도 제공 - $paginator = $parentQuery->simplePaginate($perPage, $columns, 'page', $currentPage); + // 지연 조인 — 이번 페이지의 원글 ID 를 먼저 구하고, 그 ID 에 대해서만 목록 컬럼과 + // 관계를 읽는다. OFFSET 이 건너뛰는 원글의 본문 앞부분(SUBSTRING)까지 읽던 비용이 + // 사라진다. COUNT 는 기존과 같이 수행하지 않는다(total 은 Service 캐시 카운트). + $paginator = $this->paginateWithDeferredJoin( + query: $parentQuery, + columns: $columns, + sort: $sort, + perPage: $perPage, + page: (int) $currentPage, + relations: $relations, + simple: true, + ); $parents = $paginator->getCollection(); } else { // 전체 조회 + $parentQuery->orderBy($orderBy, $orderDirection)->orderBy('id', $orderDirection); + + if (! empty($relations)) { + $parentQuery->with($relations); + } + $parents = $parentQuery->get($columns); $paginator = null; } @@ -1052,10 +1066,9 @@ class PostRepository implements PostRepositoryInterface $inactiveBoardIds = $this->getInactiveBoardIds(); $allExcludeIds = array_unique(array_merge($excludeBoardIds, $inactiveBoardIds)); + // 관계/정렬은 지연 조인이 담당한다 (inner 는 키 컬럼만 조회) $query = Post::query() - ->where('board_posts.user_id', $userId) - ->with('board') - ->orderBy($orderColumn, $orderDirection); + ->where('board_posts.user_id', $userId); if ($boardIdFilter) { $query->where('board_posts.board_id', $boardIdFilter); @@ -1078,7 +1091,26 @@ class PostRepository implements PostRepositoryInterface }); } - $paginator = $query->paginate($perPage, ['*'], 'page', null, $cachedTotal); + // 본문(content)은 목록 가공에 앞부분만 쓰이지만 태그 제거 후 길이가 줄어드는 것을 감안해 + // 넉넉히 잘라 읽는다. 잘라 읽기는 이번 페이지 분량(outer)에서만 일어난다. + $listColumns = [ + 'board_posts.id', 'board_posts.board_id', 'board_posts.user_id', + 'board_posts.title', 'board_posts.status', 'board_posts.is_secret', + 'board_posts.content_mode', 'board_posts.view_count', 'board_posts.comments_count', + 'board_posts.created_at', 'board_posts.updated_at', 'board_posts.deleted_at', + // DB::raw 는 테이블 프리픽스가 적용되지 않는다. 단일 테이블 조회라 컬럼만 적으면 충분하다. + DB::raw('SUBSTRING(content, 1, 1000) as content'), + ]; + + // 캐시된 total 을 그대로 통과시켜 COUNT 를 다시 수행하지 않는다 + $paginator = $this->paginateWithDeferredJoin( + query: $query, + columns: $listColumns, + sort: [['column' => $orderColumn, 'direction' => $orderDirection]], + perPage: $perPage, + relations: ['board'], + total: $cachedTotal, + ); // paginate 후 10건에만 PHP 가공 적용 (N+1 아님) $paginator->through(function ($post) { @@ -1125,59 +1157,88 @@ class PostRepository implements PostRepositoryInterface int $perPage, ?int $cachedTotal = null ): LengthAwarePaginator { - // DB::raw() / whereRaw() 내부 raw SQL은 prefix 자동 적용이 안 되므로 명시적으로 처리 - $prefix = DB::getTablePrefix(); - $commentsTable = $prefix.'board_comments'; - $postsTable = $prefix.'board_posts'; + // 테이블명은 모델에서 얻는다 — 문자열로 박으면 테이블명이 바뀔 때 조용히 깨진다 + $postsTable = (new Post)->getTable(); + $commentsTable = (new Comment)->getTable(); // 비활성 게시판 제외 — JOIN 없이 인덱스 활용 $inactiveBoardIds = $this->getInactiveBoardIds(); $allExcludeIds = array_unique(array_merge($excludeBoardIds, $inactiveBoardIds)); - $latestCommentSub = DB::table(DB::raw("{$commentsTable} as bc_outer")) - ->selectRaw('bc_outer.post_id, bc_outer.content, bc_outer.created_at') - ->whereRaw("bc_outer.id = ( - SELECT bc2.id FROM {$commentsTable} bc2 - WHERE bc2.post_id = bc_outer.post_id - AND bc2.user_id = ? - AND bc2.deleted_at IS NULL - ORDER BY bc2.created_at DESC - LIMIT 1 - ) AND bc_outer.user_id = ? AND bc_outer.deleted_at IS NULL", [$userId, $userId]); + /** + * 내가 이 글에 단 댓글 조건 (검색어까지 반영 — 활동 판정 · 건수 집계 공통). + * + * @param \Illuminate\Contracts\Database\Eloquent\Builder $q 댓글 하위 쿼리 + */ + $myComment = function ($q) use ($userId, $search) { + $q->where('user_id', $userId)->whereNull('deleted_at'); - $query = Post::query() - ->join(DB::raw("{$commentsTable} AS uc"), function ($join) use ($userId, $postsTable) { - $join->on(DB::raw("{$postsTable}.id"), '=', DB::raw('uc.post_id')) - ->whereRaw('uc.user_id = ?', [$userId]) - ->whereRaw('uc.deleted_at IS NULL'); - }) - ->leftJoinSub($latestCommentSub, 'lc', 'board_posts.id', '=', 'lc.post_id') - ->select([ - 'board_posts.*', - DB::raw('COUNT(DISTINCT uc.id) as activity_count'), - // board_posts.* 에 이미 comments_count 가 포함되므로 중복 지정 금지 - // (pagination count 쿼리가 subquery 로 감싸질 때 SQLSTATE[42S21] 유발) - 'lc.content as comment_content', - 'lc.created_at as comment_created_at', - ]) - ->with('board') - ->groupBy('board_posts.id', 'board_posts.board_id', 'board_posts.comments_count', 'lc.content', 'lc.created_at') - ->orderBy($orderColumn, $orderDirection); + if ($search) { + $q->where('content', 'like', '%'.$this->escapeLikeKeyword($search).'%'); + } + }; + + // "내가 댓글을 단 글" 집합은 EXISTS 로 정한다. 조인으로 행을 불린 뒤 groupBy 로 접는 + // 방식은 inner 가 읽는 행 수를 댓글 수만큼 늘리고, 그룹 쿼리라 총 건수도 서브쿼리로 + // 감싸야 한다. EXISTS 는 글 1건당 1행이라 둘 다 필요 없다. + $query = Post::query()->whereHas('comments', $myComment); if ($boardIdFilter) { - $query->where('board_posts.board_id', $boardIdFilter); + $query->where("{$postsTable}.board_id", $boardIdFilter); } if (! empty($allExcludeIds)) { - $query->whereNotIn('board_posts.board_id', $allExcludeIds); + $query->whereNotIn("{$postsTable}.board_id", $allExcludeIds); } - if ($search) { - $keyword = $this->escapeLikeKeyword($search); - $query->where('uc.content', 'like', "%{$keyword}%"); - } + // 목록 컬럼: 본문 전체 대신 앞부분만 (가공 후 표시 자수보다 넉넉히 잘라 읽는다). + // SUBSTRING 은 빌더에 대응 표현이 없는 표준 SQL 함수라 이 한 곳만 raw 로 남긴다. + // 테이블명·프리픽스는 문자열로 박지 않고 모델과 연결 설정에서 얻는다. + $listColumns = [ + "{$postsTable}.id", "{$postsTable}.board_id", "{$postsTable}.user_id", + "{$postsTable}.title", "{$postsTable}.status", "{$postsTable}.is_secret", + "{$postsTable}.content_mode", "{$postsTable}.view_count", "{$postsTable}.comments_count", + "{$postsTable}.created_at", "{$postsTable}.updated_at", "{$postsTable}.deleted_at", + DB::raw('SUBSTRING('.DB::getTablePrefix().$postsTable.'.content, 1, 1000) as content'), + 'lc.content as comment_content', + 'lc.created_at as comment_created_at', + ]; - $paginator = $query->paginate($perPage, ['*'], 'page', null, $cachedTotal); + // 정렬은 항상 board_posts 컬럼(작성일/조회수)이라 inner 에서 그대로 적용된다. + // 캐시된 total 이 있으면 COUNT 를 건너뛴다. 검색/게시판 필터가 걸린 조회는 캐시를 쓰지 + // 않아 total 이 null 로 들어오며, 이때 trait 이 그룹 쿼리를 서브쿼리로 감싸 그룹 수를 센다. + $paginator = $this->paginateWithDeferredJoin( + query: $query, + columns: $listColumns, + sort: [['column' => $orderColumn, 'direction' => $orderDirection]], + perPage: $perPage, + relations: ['board'], + keyName: 'id', + total: $cachedTotal, + // 최근 댓글 조회와 활동 건수 집계는 이번 페이지의 게시글에 대해서만 실행한다. + // inner 에 두면 건너뛸 행 전체에 대해 상관 서브쿼리가 돌아간다. + outerUsing: function ($outer) use ($userId, $myComment, $postsTable, $commentsTable) { + // 글마다 "내가 단 댓글 중 가장 최근 1건" — 원래 raw SQL 로 쓰던 상관 서브쿼리를 + // 빌더로 옮겼다. created_at 동률에서 순서가 흔들리지 않도록 id 를 덧붙인다. + $latest = DB::table("{$commentsTable} as bc_outer") + ->select('bc_outer.post_id', 'bc_outer.content', 'bc_outer.created_at') + ->where('bc_outer.user_id', $userId) + ->whereNull('bc_outer.deleted_at') + ->where('bc_outer.id', '=', function ($q) use ($userId, $commentsTable) { + $q->select('bc2.id') + ->from("{$commentsTable} as bc2") + ->whereColumn('bc2.post_id', 'bc_outer.post_id') + ->where('bc2.user_id', $userId) + ->whereNull('bc2.deleted_at') + ->orderByDesc('bc2.created_at') + ->orderByDesc('bc2.id') + ->limit(1); + }); + + $outer->leftJoinSub($latest, 'lc', "{$postsTable}.id", '=', 'lc.post_id') + ->withCount(['comments as activity_count' => $myComment]); + }, + ); // paginate 후 10건에만 PHP 가공 적용 $paginator->through(function ($post) { diff --git a/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php index 5223011f..ad3e05fb 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php @@ -3,9 +3,12 @@ namespace Modules\Sirsoft\Board\Repositories; use App\Helpers\PermissionHelper; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use App\Search\Engines\DatabaseFulltextEngine; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\QueryException; use Illuminate\Pagination\LengthAwarePaginator; @@ -13,6 +16,8 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Board\Enums\ReportStatus; use Modules\Sirsoft\Board\Exceptions\DuplicateReportException; +use Modules\Sirsoft\Board\Models\Comment; +use Modules\Sirsoft\Board\Models\Post; use Modules\Sirsoft\Board\Models\Report; use Modules\Sirsoft\Board\Models\ReportLog; use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface; @@ -24,16 +29,32 @@ use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface; */ class ReportRepository implements ReportRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** + * 신고 대상 종류(`target_type`) → 대상 모델 매핑. + * + * 신고는 게시글 또는 댓글을 가리키므로 대상 테이블이 갈린다. 갈래를 SQL CASE 문자열로 + * 잇는 대신 이 매핑을 돌며 갈래별 빌더 조건을 만든다. + * + * @var array> + */ + private const TARGET_MODELS = [ + 'post' => Post::class, + 'comment' => Comment::class, + ]; + /** * 신고 목록을 페이지네이션하여 조회합니다. * * @param array $filters 필터 조건 * @param int $perPage 페이지당 항목 수 + * @return LengthAwarePaginator 페이지네이션된 신고 목록 */ public function paginate(array $filters = [], int $perPage = 15): LengthAwarePaginator { - $query = Report::query() - ->with(['board', 'author', 'processor']); + $query = Report::query(); // 권한 스코프 필터링 PermissionHelper::applyPermissionScope($query, 'sirsoft-board.reports.view'); @@ -64,10 +85,14 @@ class ReportRepository implements ReportRepositoryInterface $query->where('created_at', '<=', $filters['reported_at_to'].' 23:59:59'); } - // 정렬 - $query->orderBy('created_at', 'desc'); - - return $query->paginate($perPage); + // 지연 조인: inner 는 id 만 훑고, 관계 로딩은 해당 페이지에만 적용된다 + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'created_at', 'direction' => 'desc']], + perPage: $perPage, + relations: ['board', 'author', 'processor'], + ); } /** @@ -225,18 +250,35 @@ class ReportRepository implements ReportRepositoryInterface * * @param array $filters 필터 조건 * @param int $perPage 페이지당 항목 수 + * @return LengthAwarePaginator 페이지네이션된 신고 케이스 목록 */ public function paginateGrouped(array $filters = [], int $perPage = 15): LengthAwarePaginator { - $tableName = (new Report)->getTable(); - $prefix = DB::getTablePrefix(); - $fullTableName = $prefix.$tableName; - $logsTable = $prefix.'boards_report_logs'; - $postsTable = $prefix.'board_posts'; - $commentsTable = $prefix.'board_comments'; + // 테이블명은 전부 모델에서 얻는다 (문자열 하드코딩 금지) + $reportsTable = (new Report)->getTable(); - $query = Report::query() - ->with(['board', 'author', 'processor', 'logs' => fn ($q) => $q->oldest()->limit(1)->with('reporter')]); + /** + * 대상(게시글/댓글) 컬럼을 신고 행에 대응시켜 읽는 상관 서브쿼리를 만듭니다. + * + * 신고는 target_type 에 따라 게시글 또는 댓글을 가리키므로 대상 테이블이 갈린다. + * 두 갈래를 CASE 로 잇는 대신, 갈래마다 빌더 서브쿼리를 만들고 그 중 조건에 맞는 + * 쪽만 값을 내도록 한다 — 결과는 같고 SQL 문자열 조립이 사라진다. + * + * @param class-string $model 대상 모델 + * @param string $targetType 이 갈래가 담당하는 target_type 값 + * @param string $column 읽어올 대상 컬럼 + * @return Builder 상관 서브쿼리 + */ + $targetColumn = function (string $model, string $targetType, string $column) use ($reportsTable) { + return $model::query() + ->select($column) + ->whereColumn((new $model)->getTable().'.board_id', "{$reportsTable}.board_id") + ->whereColumn((new $model)->getTable().'.id', "{$reportsTable}.target_id") + ->where("{$reportsTable}.target_type", $targetType) + ->limit(1); + }; + + $query = Report::query(); // 상태 필터 if (! empty($filters['status'])) { @@ -272,52 +314,64 @@ class ReportRepository implements ReportRepositoryInterface // 검색 $this->applySearchConditions($query, $filters, ['post_title']); - // 서브쿼리: 신고 건수, 대상 상태, trigger_type, post_id - $query->selectRaw("`{$fullTableName}`.*") - ->selectRaw("( - SELECT COUNT(*) - FROM `{$logsTable}` - WHERE `{$logsTable}`.report_id = `{$fullTableName}`.id - ) AS report_count") - ->selectRaw("( - CASE `{$fullTableName}`.target_type - WHEN 'post' THEN (SELECT bp.status FROM `{$postsTable}` bp WHERE bp.board_id = `{$fullTableName}`.board_id AND bp.id = `{$fullTableName}`.target_id LIMIT 1) - WHEN 'comment' THEN (SELECT bc.status FROM `{$commentsTable}` bc WHERE bc.board_id = `{$fullTableName}`.board_id AND bc.id = `{$fullTableName}`.target_id LIMIT 1) - END - ) AS target_status") - ->selectRaw("( - CASE `{$fullTableName}`.target_type - WHEN 'post' THEN (SELECT bp.trigger_type FROM `{$postsTable}` bp WHERE bp.board_id = `{$fullTableName}`.board_id AND bp.id = `{$fullTableName}`.target_id LIMIT 1) - WHEN 'comment' THEN (SELECT bc.trigger_type FROM `{$commentsTable}` bc WHERE bc.board_id = `{$fullTableName}`.board_id AND bc.id = `{$fullTableName}`.target_id LIMIT 1) - END - ) AS target_trigger_type") - ->selectRaw("( - CASE `{$fullTableName}`.target_type - WHEN 'post' THEN `{$fullTableName}`.target_id - WHEN 'comment' THEN (SELECT bc.post_id FROM `{$commentsTable}` bc WHERE bc.board_id = `{$fullTableName}`.board_id AND bc.id = `{$fullTableName}`.target_id LIMIT 1) - END - ) AS target_post_id"); - - // 대상 상태 필터 + // 대상 상태 필터 — 대상 종류에 따라 갈리는 조건을 CASE 문자열 대신 갈래별 조건으로 쓴다 if (! empty($filters['target_status'])) { $targetStatuses = is_array($filters['target_status']) ? $filters['target_status'] : [$filters['target_status']]; $targetStatuses = array_filter($targetStatuses, fn ($v) => $v !== 'all'); + if (! empty($targetStatuses)) { - $placeholders = implode(',', array_fill(0, count($targetStatuses), '?')); - $query->whereRaw("( - CASE `{$fullTableName}`.target_type - WHEN 'post' THEN (SELECT bp.status FROM `{$postsTable}` bp WHERE bp.board_id = `{$fullTableName}`.board_id AND bp.id = `{$fullTableName}`.target_id LIMIT 1) - WHEN 'comment' THEN (SELECT bc.status FROM `{$commentsTable}` bc WHERE bc.board_id = `{$fullTableName}`.board_id AND bc.id = `{$fullTableName}`.target_id LIMIT 1) - END - ) IN ({$placeholders})", $targetStatuses); + $query->where(function (Builder $outerWhere) use ($targetStatuses, $reportsTable) { + foreach (self::TARGET_MODELS as $targetType => $model) { + $outerWhere->orWhere(function (Builder $branch) use ($targetType, $model, $targetStatuses, $reportsTable) { + $branch->where("{$reportsTable}.target_type", $targetType) + ->whereIn("{$reportsTable}.target_id", $model::query() + ->select('id') + ->whereIn('status', $targetStatuses) + ->whereColumn((new $model)->getTable().'.board_id', "{$reportsTable}.board_id")); + }); + } + }); } } // 정렬: last_reported_at DESC (재신고 시 위로 올라옴) - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy('last_reported_at', $sortOrder); + $sortOrder = $this->normalizeSortDirection($filters['sort_order'] ?? null); - return $query->paginate($perPage); + $paginator = $this->paginateWithDeferredJoin( + query: $query, + columns: ["{$reportsTable}.*"], + sort: [['column' => 'last_reported_at', 'direction' => $sortOrder]], + perPage: $perPage, + relations: ['board', 'author', 'processor', 'logs' => fn ($q) => $q->oldest()->limit(1)->with('reporter')], + // 신고 건수와 대상(게시글/댓글) 정보는 스캔되는 행마다 계산되므로 + // outer(이번 페이지 $perPage 건)에서만 붙인다. + outerUsing: function (Builder $outer) use ($reportsTable, $targetColumn) { + $outer->select("{$reportsTable}.*")->withCount(['logs as report_count']); + + foreach (self::TARGET_MODELS as $targetType => $model) { + $outer->addSelect([ + "target_status_{$targetType}" => $targetColumn($model, $targetType, 'status'), + "target_trigger_type_{$targetType}" => $targetColumn($model, $targetType, 'trigger_type'), + ]); + } + + // 대상 글 ID — 게시글 신고는 target_id 자체, 댓글 신고는 그 댓글이 달린 글 + $outer->addSelect(['target_post_id_comment' => $targetColumn(Comment::class, 'comment', 'post_id')]); + }, + ); + + // 갈래별로 나눠 읽은 대상 정보를 화면이 쓰는 단일 속성으로 합친다. + // 이번 페이지 항목에만 적용되므로 추가 조회가 발생하지 않는다. + return $paginator->through(function (Report $report) { + $report->setAttribute('target_status', $report->target_status_post ?? $report->target_status_comment); + $report->setAttribute('target_trigger_type', $report->target_trigger_type_post ?? $report->target_trigger_type_comment); + $report->setAttribute( + 'target_post_id', + $report->target_type === 'post' ? $report->target_id : $report->target_post_id_comment + ); + + return $report; + }); } /** @@ -525,12 +579,15 @@ class ReportRepository implements ReportRepositoryInterface * @param int $reportId 신고 케이스 ID * @param int $perPage 페이지당 항목 수 * @param int $page 페이지 번호 + * @return LengthAwarePaginator 페이지네이션된 신고자 로그 */ public function paginateLogsByReport(int $reportId, int $perPage = 10, int $page = 1): LengthAwarePaginator { return ReportLog::where('report_id', $reportId) ->with('reporter') ->latest() + // audit:allow repository-paginate-column-pruning reason: 신고 케이스 1건에 종속된 신고자 로그 — + // where(report_id) 로 이미 좁혀져 OFFSET 이 깊어질 수 없다 ->paginate($perPage, ['*'], 'page', $page); } @@ -583,12 +640,12 @@ class ReportRepository implements ReportRepositoryInterface * ID 목록으로 조회하고 ID 키 맵으로 반환합니다 (bulk activity log lookup). * * @param array $ids ID 목록 - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ - public function findByIdsKeyed(array $ids): \Illuminate\Database\Eloquent\Collection + public function findByIdsKeyed(array $ids): Collection { if (empty($ids)) { - return new \Illuminate\Database\Eloquent\Collection(); + return new Collection; } return Report::whereIn('id', $ids)->get()->keyBy('id'); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/BoardRequestTest.php b/modules/_bundled/sirsoft-board/tests/Feature/BoardRequestTest.php index bc7da535..57224da8 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/BoardRequestTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/BoardRequestTest.php @@ -16,6 +16,7 @@ use Modules\Sirsoft\Board\Http\Requests\UpdateBoardRequest; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\BoardType; use Modules\Sirsoft\Board\Tests\ModuleTestCase; +use PHPUnit\Framework\Attributes\DataProvider; /** * 게시판 FormRequest 검증 통합 테스트 @@ -287,26 +288,30 @@ class BoardRequestTest extends ModuleTestCase /** * Mock Board 객체 생성 */ - private function getMockBoard(): object + private function getMockBoard(bool $useFileUpload = true): object { return (object) [ 'slug' => 'notice', 'categories' => ['일반', '중요'], + // prepareForValidation() 이 exclude_if 판정 기준으로 주입하는 저장값 + 'use_file_upload' => $useFileUpload, ]; } /** * UpdateBoardRequest 인스턴스 생성 (route 파라미터 포함) + * + * @param bool $boardUseFileUpload 기존 게시판에 저장된 첨부 사용 여부 */ - private function makeUpdateRequest(): UpdateBoardRequest + private function makeUpdateRequest(bool $boardUseFileUpload = true): UpdateBoardRequest { $request = new UpdateBoardRequest; - $request->setRouteResolver(function () { + $request->setRouteResolver(function () use ($boardUseFileUpload) { $route = \Mockery::mock(Route::class); $route->shouldReceive('parameter') ->with('board', null) - ->andReturn($this->getMockBoard()); + ->andReturn($this->getMockBoard($boardUseFileUpload)); return $route; }); @@ -314,6 +319,27 @@ class BoardRequestTest extends ModuleTestCase return $request; } + /** + * prepareForValidation() 을 거친 검증 입력을 만듭니다. + * + * `Validator::make($data, $request->rules())` 만으로는 전처리가 실행되지 않아 + * 조건 필드 주입(기존 게시판 값)을 검증할 수 없다. + * + * @param UpdateBoardRequest $request 대상 요청 + * @param array $data 전송 페이로드 + * @return array 전처리를 마친 입력 + */ + private function prepareUpdateInput(UpdateBoardRequest $request, array $data): array + { + $request->merge($data); + + $prepare = new \ReflectionMethod($request, 'prepareForValidation'); + $prepare->setAccessible(true); + $prepare->invoke($request); + + return $request->all(); + } + /** * 정상적인 수정 데이터 */ @@ -1010,21 +1036,89 @@ class BoardRequestTest extends ModuleTestCase } /** - * UpdateBoardRequest - allowed_extensions 빈 배열 전송 시 검증 실패해야 함 (회귀) + * UpdateBoardRequest - 첨부 사용 게시판에서 allowed_extensions 빈 배열은 실패 (케이스 2) * - * sometimes + array + min:1 로, 키가 전송되면 빈 배열은 차단된다. - * (nullable 제거로 빈 배열이 통과되던 버그 차단) + * 첨부를 쓰는 게시판에 확장자를 비워 저장하면 이후 모든 파일이 거부되므로 차단한다. + * 첨부 미사용 게시판은 exclude_if 로 배제되어 통과한다(케이스 3 참조) — 이 테스트는 + * 그 경계 중 "차단되어야 하는 쪽"만 고정한다. */ - public function test_update_request_empty_allowed_extensions_fails(): void + public function test_update_request_empty_allowed_extensions_fails_when_upload_enabled(): void { $request = $this->makeUpdateRequest(); - $validator = Validator::make(['allowed_extensions' => []], $request->rules()); + $validator = Validator::make( + ['use_file_upload' => true, 'allowed_extensions' => []], + $request->rules() + ); - $this->assertFalse($validator->passes(), 'allowed_extensions=[] 전송 시 실패해야 함'); + $this->assertFalse($validator->passes(), 'use_file_upload=true + [] 는 실패해야 함'); $this->assertArrayHasKey('allowed_extensions', $validator->errors()->toArray()); } + /** + * UpdateBoardRequest - 첨부 미사용 게시판의 빈 확장자는 통과 (케이스 3 · GH #78) + */ + public function test_update_request_empty_allowed_extensions_passes_when_upload_disabled(): void + { + $request = $this->makeUpdateRequest(); + + $validator = Validator::make( + ['use_file_upload' => false, 'allowed_extensions' => []], + $request->rules() + ); + + $this->assertTrue($validator->passes(), 'use_file_upload=false 이면 빈 확장자도 통과해야 함'); + } + + /** + * UpdateBoardRequest - 토글 미전송 + 확장자 전송 시 기존 게시판 값을 따른다 (케이스 5) + * + * exclude_if 는 조건 필드가 데이터에 없으면 배제하지 않는다. 부분 수정 요청이 + * use_file_upload 없이 allowed_extensions 만 보내면 첨부 미사용 게시판인데도 + * 확장자 필수 규칙이 발화하므로, prepareForValidation() 이 저장값을 주입해야 한다. + */ + public function test_update_request_omitted_toggle_follows_stored_board_value(): void + { + // 저장된 게시판이 첨부 미사용 → 빈 확장자 통과 + $disabled = $this->makeUpdateRequest(boardUseFileUpload: false); + $disabledInput = $this->prepareUpdateInput($disabled, ['allowed_extensions' => []]); + + $this->assertFalse( + $disabledInput['use_file_upload'], + '토글 미전송 시 기존 게시판의 use_file_upload 가 주입되어야 함' + ); + $this->assertTrue( + Validator::make($disabledInput, $disabled->rules())->passes(), + '첨부 미사용 게시판은 토글 미전송 + 빈 확장자도 통과해야 함' + ); + + // 저장된 게시판이 첨부 사용 → 빈 확장자 차단 + $enabled = $this->makeUpdateRequest(boardUseFileUpload: true); + $enabledInput = $this->prepareUpdateInput($enabled, ['allowed_extensions' => []]); + + $this->assertTrue($enabledInput['use_file_upload']); + $this->assertFalse( + Validator::make($enabledInput, $enabled->rules())->passes(), + '첨부 사용 게시판은 토글 미전송이어도 빈 확장자를 차단해야 함' + ); + } + + /** + * UpdateBoardRequest - 토글·확장자 모두 미전송이면 주입하지 않는다 (케이스 4 인접) + * + * 확장자를 건드리지 않는 부분 수정에 조건 필드를 주입하면, 보내지도 않은 토글이 + * 검증 대상으로 올라온다. + */ + public function test_update_request_does_not_inject_toggle_when_extensions_omitted(): void + { + $request = $this->makeUpdateRequest(boardUseFileUpload: false); + + $input = $this->prepareUpdateInput($request, ['per_page' => 30]); + + $this->assertArrayNotHasKey('use_file_upload', $input); + $this->assertTrue(Validator::make($input, $request->rules())->passes()); + } + /** * UpdateBoardRequest - allowed_extensions 미전송(부분 수정)은 통과해야 함 (회귀 방지) * @@ -1250,8 +1344,6 @@ class BoardRequestTest extends ModuleTestCase * * 0 = NEW 배지 표시 안 함 (Post::isNew() 가 0 을 정상 처리). * - * @dataProvider newDisplayHoursProvider - * * @scenario case=new_display_hours_zero * * @effects new_display_hours_zero_passes, new_display_hours_out_of_range_fails @@ -1259,6 +1351,7 @@ class BoardRequestTest extends ModuleTestCase * @param mixed $value new_display_hours 값 * @param bool $shouldPass 통과 기대 여부 */ + #[DataProvider('newDisplayHoursProvider')] public function test_update_matrix_new_display_hours_boundaries(mixed $value, bool $shouldPass): void { $request = $this->makeUpdateRequest(); @@ -1408,14 +1501,13 @@ class BoardRequestTest extends ModuleTestCase * config('sirsoft-board.limits') 는 min_title_length_min / min_comment_length_min 을 * 0 으로 선언하고 있으므로 하드코딩된 min:1 은 계약 위반이다. * - * @dataProvider settingsZeroLowerBoundProvider - * * @scenario case=zero_lower_bound_length_fields * * @effects zero_lower_bound_length_fields_pass * * @param string $field basic_defaults 하위 필드명 */ + #[DataProvider('settingsZeroLowerBoundProvider')] public function test_settings_matrix_zero_lower_bounds_pass(string $field): void { $request = new StoreBoardSettingsRequest; diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php index 5e31b53f..609ea855 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\LazyCollection; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; use Modules\Sirsoft\Board\Tests\BoardTestCase; +use PHPUnit\Framework\Attributes\Group; /** * BoardRepository::streamActiveForSitemap 단위 테스트 @@ -16,11 +17,10 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase; * - 순회자(LazyCollection) 를 반환한다 (전체 적재 금지) * - sitemap 에 필요한 컬럼만 적재한다 (전컬럼 적재 회귀 차단) * - 활성 게시판만 반환한다 - * - * @group board - * @group unit - * @group seo */ +#[Group('board')] +#[Group('unit')] +#[Group('seo')] class BoardRepositorySitemapTest extends BoardTestCase { private BoardRepositoryInterface $repository; diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/CommentListDeferredJoinTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/CommentListDeferredJoinTest.php new file mode 100644 index 00000000..1bbcc800 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/CommentListDeferredJoinTest.php @@ -0,0 +1,196 @@ +repository = app(CommentRepository::class); + } + + /** 쿼리 로그 수집을 시작합니다. */ + private function startCapture(): void + { + $this->queries = []; + + DB::listen(function ($query) { + $this->queries[] = $query->sql; + }); + } + + /** + * 수집된 쿼리 중 조건에 맞는 것만 돌려줍니다. + * + * @param string $needle 포함 문자열 + * @return array 일치 쿼리 목록 + */ + private function queriesContaining(string $needle): array + { + return array_values(array_filter($this->queries, fn (string $sql) => str_contains($sql, $needle))); + } + + /** + * 이 사용자의 댓글을 생성합니다. + * + * @param int $postId 대상 게시글 ID + * @param array $attributes 추가 속성 + * @return int 생성된 댓글 ID + */ + private function createUserComment(int $postId, array $attributes = []): int + { + return $this->createTestComment($postId, array_merge(['user_id' => $this->userId], $attributes)); + } + + public function test_soft_deleted_comments_are_excluded(): void + { + $postId = $this->createTestPost(); + $this->createUserComment($postId, ['content' => '살아있는 댓글']); + $this->createUserComment($postId, ['content' => '삭제된 댓글', 'deleted_at' => now()]); + + $result = $this->repository->getUserComments($this->userId, [], 10); + $contents = collect($result->items())->pluck('content')->all(); + + $this->assertContains('살아있는 댓글', $contents); + $this->assertNotContains('삭제된 댓글', $contents); + $this->assertSame(1, $result->total()); + } + + public function test_comments_of_soft_deleted_posts_are_excluded(): void + { + $livePostId = $this->createTestPost(['title' => '살아있는 글']); + $deletedPostId = $this->createTestPost(['title' => '삭제된 글', 'deleted_at' => now()]); + + $this->createUserComment($livePostId, ['content' => '보여야 하는 댓글']); + $this->createUserComment($deletedPostId, ['content' => '숨겨져야 하는 댓글']); + + $result = $this->repository->getUserComments($this->userId, [], 10); + $contents = collect($result->items())->pluck('content')->all(); + + $this->assertSame(['보여야 하는 댓글'], $contents); + $this->assertSame(1, $result->total(), '삭제된 글의 댓글은 총 건수에서도 빠져야 한다'); + } + + public function test_other_users_comments_are_not_included(): void + { + $postId = $this->createTestPost(); + $this->createUserComment($postId, ['content' => '내 댓글']); + $this->createTestComment($postId, ['user_id' => $this->userId + 1, 'content' => '남의 댓글']); + + $result = $this->repository->getUserComments($this->userId, [], 10); + + $this->assertSame(['내 댓글'], collect($result->items())->pluck('content')->all()); + } + + public function test_pages_do_not_overlap_and_last_page_is_reported(): void + { + $postId = $this->createTestPost(); + + for ($i = 1; $i <= 15; $i++) { + $this->createUserComment($postId, [ + 'content' => "댓글 {$i}", + 'created_at' => now()->subMinutes(20 - $i), + ]); + } + + // 저장소는 페이지 번호를 인자로 받지 않고 Paginator 가 요청에서 해석한다. + // 단위 테스트에는 요청 컨텍스트가 없으므로 리졸버로 현재 페이지를 지정한다. + Paginator::currentPageResolver(fn () => 1); + $page1 = $this->repository->getUserComments($this->userId, [], 10); + + Paginator::currentPageResolver(fn () => 2); + $page2 = $this->repository->getUserComments($this->userId, [], 10); + + Paginator::currentPageResolver(fn () => 1); + + $this->assertSame(15, $page1->total()); + $this->assertSame(2, $page1->lastPage()); + $this->assertCount(10, $page1->items()); + $this->assertCount(5, $page2->items(), '마지막 페이지는 남은 5건만 있어야 한다'); + + $firstIds = collect($page1->items())->pluck('id')->all(); + $secondIds = collect($page2->items())->pluck('id')->all(); + + $this->assertEmpty(array_intersect($firstIds, $secondIds), '페이지 간 중복 행이 없어야 한다'); + $this->assertCount(15, array_unique(array_merge($firstIds, $secondIds)), '누락된 행이 없어야 한다'); + } + + public function test_search_filter_counts_only_matching_rows(): void + { + $postId = $this->createTestPost(); + $this->createUserComment($postId, ['content' => '검색어 포함 댓글 A']); + $this->createUserComment($postId, ['content' => '검색어 포함 댓글 B']); + $this->createUserComment($postId, ['content' => '무관한 댓글']); + + $result = $this->repository->getUserComments($this->userId, ['search' => '검색어'], 10); + + $this->assertSame(2, $result->total(), '검색 조건이 총 건수에 반영되어야 한다'); + $this->assertCount(2, $result->items()); + } + + public function test_cached_total_skips_the_count_query(): void + { + $postId = $this->createTestPost(); + $this->createUserComment($postId, ['content' => '댓글']); + + $this->startCapture(); + $result = $this->repository->getUserComments($this->userId, ['cached_total' => 999], 10); + + $this->assertEmpty($this->queriesContaining('count(*)'), '캐시된 total 이 있으면 COUNT 를 실행하지 않아야 한다'); + $this->assertSame(999, $result->total()); + } + + public function test_offset_scan_does_not_read_comment_content(): void + { + $postId = $this->createTestPost(); + + for ($i = 1; $i <= 25; $i++) { + $this->createUserComment($postId, ['content' => "댓글 본문 {$i}"]); + } + + $this->startCapture(); + $this->repository->getUserComments($this->userId, [], 10); + + $offsetQueries = $this->queriesContaining('offset'); + + $this->assertNotEmpty($offsetQueries, 'inner 는 OFFSET 으로 ID 집합을 구해야 한다'); + + foreach ($offsetQueries as $sql) { + $this->assertStringNotContainsString( + 'content`', + $sql, + 'OFFSET 스캔 구간에서 댓글 본문을 읽으면 안 된다' + ); + } + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostListDeferredJoinTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostListDeferredJoinTest.php new file mode 100644 index 00000000..6800e262 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostListDeferredJoinTest.php @@ -0,0 +1,196 @@ +repository = app(PostRepository::class); + } + + /** 쿼리 로그 수집을 시작합니다. */ + private function startCapture(): void + { + $this->queries = []; + + DB::listen(function ($query) { + $this->queries[] = $query->sql; + }); + } + + /** + * 수집된 쿼리 중 조건에 맞는 것만 돌려줍니다. + * + * @param string $needle 포함 문자열 + * @return array 일치 쿼리 목록 + */ + private function queriesContaining(string $needle): array + { + return array_values(array_filter($this->queries, fn (string $sql) => str_contains($sql, $needle))); + } + + public function test_offset_scan_does_not_read_content_preview(): void + { + for ($i = 1; $i <= 25; $i++) { + $this->createTestPost(['title' => "게시글 {$i}"]); + } + + $this->startCapture(); + $this->repository->paginate($this->board->slug, ['page' => 2], 10); + + $offsetQueries = $this->queriesContaining('offset'); + + $this->assertNotEmpty($offsetQueries, '페이지 조회에는 OFFSET 쿼리가 있어야 한다'); + + foreach ($offsetQueries as $sql) { + // 본문 앞부분을 잘라 읽는 것도 오버플로 페이지를 그대로 읽으므로 프루닝이 아니다. + // 잘라 읽기는 이번 페이지 분량(outer)에서만 일어나야 한다. + $this->assertStringNotContainsString('SUBSTRING', $sql); + $this->assertStringNotContainsString('content', $sql); + } + } + + public function test_page_one_shows_notices_and_page_two_does_not(): void + { + $this->createTestPost(['title' => '공지사항', 'is_notice' => true]); + + for ($i = 1; $i <= 25; $i++) { + $this->createTestPost(['title' => "일반글 {$i}"]); + } + + $page1 = $this->repository->paginate($this->board->slug, ['page' => 1], 10); + $page2 = $this->repository->paginate($this->board->slug, ['page' => 2], 10); + + $titles1 = collect($page1->items())->pluck('title')->all(); + $titles2 = collect($page2->items())->pluck('title')->all(); + + $this->assertSame('공지사항', $titles1[0], '공지는 1페이지 선두에 있어야 한다'); + $this->assertNotContains('공지사항', $titles2, '공지는 2페이지에 반복되지 않아야 한다'); + } + + public function test_replies_follow_their_parent_in_the_same_page(): void + { + $parentId = $this->createTestPost(['title' => '원글']); + $this->createTestPost(['title' => '답글 1', 'parent_id' => $parentId, 'depth' => 1]); + $this->createTestPost(['title' => '답글 2', 'parent_id' => $parentId, 'depth' => 1]); + + $result = $this->repository->paginate($this->board->slug, [], 10); + $titles = collect($result->items())->pluck('title')->all(); + + $this->assertSame(['원글', '답글 1', '답글 2'], $titles); + } + + public function test_reply_content_preview_is_available_like_parent(): void + { + $parentId = $this->createTestPost(['title' => '원글', 'content' => '원글 본문입니다.']); + $this->createTestPost([ + 'title' => '답글', + 'content' => '답글 본문입니다.', + 'parent_id' => $parentId, + 'depth' => 1, + ]); + + $result = $this->repository->paginate($this->board->slug, [], 10); + $items = collect($result->items()); + + // 원글과 답글은 같은 컬럼 목록으로 조회되므로 스키마가 어긋나면 안 된다 + // (한쪽만 바뀌면 화면에서 조용히 빈 값이 된다) + foreach ($items as $item) { + $this->assertNotNull($item->content_preview_raw ?? null); + } + } + + public function test_pages_do_not_overlap(): void + { + for ($i = 1; $i <= 25; $i++) { + $this->createTestPost(['title' => "게시글 {$i}"]); + } + + $page1 = $this->repository->paginate($this->board->slug, ['page' => 1], 10); + $page2 = $this->repository->paginate($this->board->slug, ['page' => 2], 10); + + $ids1 = collect($page1->items())->pluck('id')->all(); + $ids2 = collect($page2->items())->pluck('id')->all(); + + $this->assertCount(10, $ids1); + $this->assertEmpty(array_intersect($ids1, $ids2)); + } + + public function test_last_page_reports_no_more_pages(): void + { + for ($i = 1; $i <= 15; $i++) { + $this->createTestPost(['title' => "게시글 {$i}"]); + } + + $page1 = $this->repository->paginate($this->board->slug, ['page' => 1], 10); + $page2 = $this->repository->paginate($this->board->slug, ['page' => 2], 10); + + $this->assertTrue($page1->hasMorePages(), '1페이지에서는 다음 페이지가 있어야 한다'); + $this->assertFalse($page2->hasMorePages(), '마지막 페이지에서 다음 버튼이 남으면 안 된다'); + } + + public function test_soft_deleted_posts_are_excluded_by_default(): void + { + $this->createTestPost(['title' => '살아있는 글']); + $this->createTestPost(['title' => '삭제된 글', 'deleted_at' => now()]); + + $result = $this->repository->paginate($this->board->slug, [], 10); + $titles = collect($result->items())->pluck('title')->all(); + + $this->assertContains('살아있는 글', $titles); + $this->assertNotContains('삭제된 글', $titles); + } + + public function test_with_trashed_includes_deleted_posts(): void + { + $this->createTestPost(['title' => '살아있는 글']); + $this->createTestPost(['title' => '삭제된 글', 'deleted_at' => now()]); + + $result = $this->repository->paginate($this->board->slug, [], 10, true); + $titles = collect($result->items())->pluck('title')->all(); + + $this->assertContains('삭제된 글', $titles); + } + + public function test_sort_order_is_applied_and_deterministic(): void + { + // 조회수가 모두 같은 상태 — 2차 정렬(id)이 없으면 페이지 경계가 흔들린다 + for ($i = 1; $i <= 12; $i++) { + $this->createTestPost(['title' => "게시글 {$i}", 'view_count' => 5]); + } + + $result = $this->repository->paginate( + $this->board->slug, + ['order_by' => 'view_count', 'order_direction' => 'desc'], + 5 + ); + + $ids = collect($result->items())->pluck('id')->all(); + $sorted = $ids; + rsort($sorted); + + $this->assertSame($sorted, $ids, '동률 정렬에서도 id 내림차순이 유지되어야 한다'); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/UserCommentedActivityDeferredJoinTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/UserCommentedActivityDeferredJoinTest.php new file mode 100644 index 00000000..336838ae --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/UserCommentedActivityDeferredJoinTest.php @@ -0,0 +1,167 @@ +app->make(PostRepositoryInterface::class); + } + + /** 쿼리 로그 수집을 시작합니다. */ + private function startCapture(): void + { + $this->queries = []; + + DB::listen(function ($query) { + $this->queries[] = $query->sql; + }); + } + + /** + * 수집된 쿼리 중 조건에 맞는 것만 돌려줍니다. + * + * @param string $needle 포함 문자열 + * @return array 일치 쿼리 목록 + */ + private function queriesContaining(string $needle): array + { + return array_values(array_filter($this->queries, fn (string $sql) => str_contains($sql, $needle))); + } + + public function test_offset_scan_excludes_latest_comment_subquery(): void + { + $user = $this->createUser(); + $other = $this->createUser(); + + for ($i = 1; $i <= 25; $i++) { + $postId = $this->createTestPost(['user_id' => $other->id, 'title' => "글 {$i}"]); + $this->createTestComment($postId, ['user_id' => $user->id, 'content' => "댓글 {$i}"]); + } + + $this->startCapture(); + $this->repository()->getUserActivities($user->id, ['activity_type' => 'commented'], 10); + + $offsetQueries = $this->queriesContaining('offset'); + + $this->assertNotEmpty($offsetQueries, 'inner 는 OFFSET 으로 ID 집합을 구해야 한다'); + + foreach ($offsetQueries as $sql) { + $this->assertStringNotContainsString( + 'as `lc`', + $sql, + 'OFFSET 스캔 구간에 최근 댓글 서브쿼리가 남으면 건너뛸 행마다 상관 서브쿼리가 실행된다' + ); + $this->assertStringNotContainsString( + 'substring', + strtolower($sql), + 'OFFSET 스캔 구간에서 본문을 읽으면 안 된다' + ); + } + } + + public function test_latest_comment_columns_are_available_in_result(): void + { + $user = $this->createUser(); + $other = $this->createUser(); + + $postId = $this->createTestPost(['user_id' => $other->id, 'title' => '대상 글']); + $this->createTestComment($postId, [ + 'user_id' => $user->id, + 'content' => '예전 댓글', + 'created_at' => now()->subDay(), + ]); + $this->createTestComment($postId, [ + 'user_id' => $user->id, + 'content' => '가장 최근 댓글', + 'created_at' => now(), + ]); + + $items = $this->repository() + ->getUserActivities($user->id, ['activity_type' => 'commented'], 10) + ->items(); + + $this->assertCount(1, $items, '같은 글에 댓글을 여러 번 달아도 목록에는 글 1건으로 접혀야 한다'); + $this->assertSame(2, $items[0]['activity_count'], '해당 글에 단 댓글 수가 집계되어야 한다'); + } + + public function test_total_counts_posts_not_comments_when_filter_bypasses_cache(): void + { + $user = $this->createUser(); + $other = $this->createUser(); + + // 첫 글에만 댓글 3건 — 그룹 수(3)와 첫 그룹의 행 수(3)가 우연히 같아지지 않도록 + // 나머지 글은 댓글 1건씩 둔다. + $firstPost = $this->createTestPost(['user_id' => $other->id, 'title' => '검색어 글 A']); + for ($i = 0; $i < 3; $i++) { + $this->createTestComment($firstPost, ['user_id' => $user->id, 'content' => '검색어 댓글']); + } + + foreach (['검색어 글 B', '검색어 글 C', '검색어 글 D', '검색어 글 E'] as $title) { + $postId = $this->createTestPost(['user_id' => $other->id, 'title' => $title]); + $this->createTestComment($postId, ['user_id' => $user->id, 'content' => '검색어 댓글']); + } + + // 검색 필터가 걸리면 서비스가 캐시 total 을 담지 않으므로 trait 이 직접 COUNT 한다. + $paginator = $this->repository()->getUserActivities( + $user->id, + ['activity_type' => 'commented', 'search' => '검색어'], + 10 + ); + + $this->assertSame(5, $paginator->total(), '총 건수는 댓글 수가 아니라 글(그룹) 수여야 한다'); + $this->assertCount(5, $paginator->items()); + } + + public function test_pages_do_not_overlap(): void + { + $user = $this->createUser(); + $other = $this->createUser(); + + for ($i = 1; $i <= 15; $i++) { + $postId = $this->createTestPost([ + 'user_id' => $other->id, + 'title' => "글 {$i}", + 'created_at' => now()->subMinutes(20 - $i), + ]); + $this->createTestComment($postId, ['user_id' => $user->id, 'content' => "댓글 {$i}"]); + } + + $paginator = $this->repository()->getUserActivities($user->id, ['activity_type' => 'commented'], 10); + + $this->assertSame(15, $paginator->total()); + $this->assertSame(2, $paginator->lastPage()); + $this->assertCount(10, $paginator->items()); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 7831a85f..00a88499 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -26,6 +26,9 @@ - 실제로 사용되지 않던 설정 항목(상품·주문 목록의 페이지당 개수, 결제 자동취소 기한 중복 항목)을 정리했습니다. 미입금 자동취소 기한은 관리자 환경설정의 값 하나만 사용합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 상품 리뷰·마일리지 내역·쿠폰 발급 내역 목록도 같은 방식으로 뒤쪽 페이지 조회를 개선했습니다. 상품 리뷰는 작성일 정렬용 인덱스를 추가하고 기간 검색 조건을 인덱스가 활용되는 형태로 바꿔, 리뷰가 많은 쇼핑몰에서 목록과 기간 검색이 함께 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품 목록(관리자·쇼핑몰 화면)·상품 문의 관리 목록·추가배송비 목록도 같은 방식으로 개선했습니다. 상품 문의는 기간 검색 조건도 인덱스가 활용되는 형태로 바꿔 기간을 좁혀 찾을 때 더 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 주문의 스냅샷·관리자 메모 같은 큰 항목까지 함께 읽어 느려졌지만, 이제 현재 페이지의 주문만 상세 정보를 읽습니다. 주문 10만 건 기준 5,000번째 페이지에서 조회 시간이 약 6분의 1로 줄어듭니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ### Fixed @@ -67,6 +70,10 @@ - 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. - 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다. - 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다. +- 쿠폰을 수정할 때 유효기간을 비운 채로 저장할 수 있던 문제를 수정했습니다. 등록 화면과 동일하게, "기간 지정" 쿠폰은 시작일·종료일이, "발급일 기준" 쿠폰은 유효일수가 있어야 저장됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 쿠폰의 다른 항목만 고치는 경우에도 "1인당 사용 제한"을 함께 보내지 않으면 저장이 거절되던 문제를 수정했습니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 배송지를 수정할 때 주소를 통째로 비운 채 저장할 수 있던 문제를 수정했습니다. 국내 배송지는 우편번호와 주소가, 해외 배송지는 주소·도시·우편번호가 갖춰져야 저장됩니다. 기본 배송지 지정처럼 주소를 건드리지 않는 수정은 이전과 동일하게 저장됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 해외 배송지를 수정하면 도시·주·우편번호가 안내 없이 비워지던 문제를 수정했습니다. 이번 수정에서 다시 입력하지 않은 항목은 저장된 값이 그대로 유지됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php new file mode 100644 index 00000000..a460f62d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php @@ -0,0 +1,72 @@ +index( + ['product_id', 'status', 'deleted_at', 'created_at'], + 'idx_reviews_product_status_deleted_created' + ); + } + + if (! in_array('idx_reviews_deleted_created', $existingIndexes)) { + $table->index(['deleted_at', 'created_at'], 'idx_reviews_deleted_created'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('ecommerce_product_reviews')) { + return; + } + + $existingIndexes = array_column(Schema::getIndexes('ecommerce_product_reviews'), 'name'); + + Schema::table('ecommerce_product_reviews', function (Blueprint $table) use ($existingIndexes) { + $indexes = [ + 'idx_reviews_product_status_deleted_created', + 'idx_reviews_deleted_created', + ]; + + foreach ($indexes as $index) { + if (in_array($index, $existingIndexes)) { + $table->dropIndex($index); + } + } + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md index dd125472..ecee15fa 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md @@ -402,6 +402,8 @@ HTTP/1.1 200 | intl_postal_code | body | string | 아니오 | max 20 | 우편번호 (국제 주소) | | is_default | body | boolean | 아니오 | — | 기본값 지정 여부 | +개별 필드는 모두 선택이지만 주소 필수 조합은 "기존 저장값 + 이번 요청" 결과 상태 기준으로 검증됩니다. 국내(`country_code=KR` 또는 미전송 시 기존 국가가 KR)는 `zipcode` + `address`, 해외는 `address_line_1` + `intl_city` + `intl_postal_code` 가 결과적으로 모두 채워져 있어야 하며, 그렇지 않으면 422 로 거절됩니다. 요청에 없는 필드는 저장된 값으로 판정하므로 `is_default` 만 바꾸는 요청이나 도로명 주소만 교체하는 요청은 그대로 통과합니다. 해외 주소의 저장 컬럼은 `city`/`state`/`postal_code` 이므로 요청에 `intl_city`/`intl_postal_code` 가 없으면 그 저장값이 판정 기준이 됩니다. + > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.user_address.update_validation_rules`). **요청 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index a5292864..4b9acd42 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -47,7 +47,7 @@ | user_id | query | integer | 아니오 | — | user 식별자 | | orderer_uuid | query | uuid | 아니오 | — | 특정 회원의 주문만 조회하는 주문자 UUID 필터 (회원 검색 연동용) | | member_type | query | string | 아니오 | `member`, `guest` | 회원 구분 필터 (member 회원 주문 / guest 비회원 주문) | -| sort_by | query | string | 아니오 | `ordered_at`, `paid_at`, `total_amount` | 정렬 기준 필드명 | +| sort_by | query | string | 아니오 | `ordered_at`, `paid_at`, `total_amount` | 정렬 기준 필드명 (그 외 값은 422) | | sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | | per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md index fb590202..cb996aea 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md @@ -701,6 +701,8 @@ HTTP/1.1 200 | products | body | array | 아니오 | — | 적용 상품 목록 (`target_scope=products`), 항목별 `{id, type: include\|exclude}` | | categories | body | array | 아니오 | — | 적용 카테고리 목록 (`target_scope=categories`), 항목별 `{id, type: include\|exclude}` | +> 필수 표기는 "전송했을 때 값이 있어야 함"을 뜻합니다. 이 엔드포인트는 부분 수정을 지원하므로 body 필드는 모두 생략할 수 있습니다 (생략하면 기존 값 유지). 유효기간은 `valid_type` 을 함께 전송할 때만 조건부 필수가 적용됩니다 — `period` 면 `valid_from`/`valid_to`, `days_from_issue` 면 `valid_days` 가 비어 있으면 422 입니다. `valid_type` 을 보내지 않는 부분 수정은 유효기간 필드를 요구하지 않습니다. + > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.coupon.update_validation_rules`). **요청 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php index 644e97c0..1cfa5a83 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php @@ -18,8 +18,8 @@ use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateStatusRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateStockRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductListRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductLogsRequest; +use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductShowForCopyRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ReorderProductImagesRequest; -use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ShowProductForCopyRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreProductRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateProductRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UploadProductImageRequest; @@ -566,11 +566,11 @@ class ProductController extends AdminBaseController /** * 상품 복사용 데이터를 조회합니다. * - * @param ShowProductForCopyRequest $request 복사 옵션 요청 데이터 + * @param ProductShowForCopyRequest $request 검증된 복사 옵션 요청 * @param Product $product 복사할 상품 모델 * @return JsonResponse 복사용 데이터 JSON 응답 */ - public function showForCopy(ShowProductForCopyRequest $request, Product $product): JsonResponse + public function showForCopy(ProductShowForCopyRequest $request, Product $product): JsonResponse { try { $copyData = $this->productService->getDetailForCopy($product->id, $request->getCopyOptions()); @@ -797,14 +797,14 @@ class ProductController extends AdminBaseController /** * 상품 처리로그(활동 로그) 목록을 조회합니다. * - * @param ProductLogsRequest $request 활동 로그 조회 요청 데이터 + * @param ProductLogsRequest $request 검증된 조회 요청 * @param Product $product 상품 * @return JsonResponse 활동 로그 목록 */ public function logs(ProductLogsRequest $request, Product $product): JsonResponse { try { - // 상품 + 상품옵션 로그 합산 조회는 Repository 위임 (Service 경유) + // 상품 + 상품옵션 로그 합산 조회는 Service → Repository 경유 (컨트롤러 직접 조회 금지) $logs = $this->productService->getActivityLogs($product, $request->getFilters()); return ResponseHelper::moduleSuccess( diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductLogsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductLogsRequest.php index d643e0ba..191c8063 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductLogsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductLogsRequest.php @@ -2,11 +2,12 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin; +use App\Extension\HookManager; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; /** - * 상품 활동 로그 조회 요청 (관리자) + * 상품 처리로그(활동 로그) 목록 조회 요청 (관리자) * * 권한은 라우트 미들웨어(permission:admin,sirsoft-ecommerce.products.read)에서 처리하며, * 여기서는 페이지 크기·정렬 옵션만 검증합니다. @@ -30,22 +31,27 @@ class ProductLogsRequest extends FormRequest */ public function rules(): array { - return [ + $rules = [ 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], 'sort_order' => ['sometimes', 'string', Rule::in(['asc', 'desc'])], ]; + + return HookManager::applyFilters('sirsoft-ecommerce.product.logs_validation_rules', $rules, $this); } /** * 활동 로그 조회 필터(페이지 크기·정렬)를 반환합니다. * - * @return array{per_page: int, sort_order: string} 조회 필터 + * 고정 키만 열거하면 확장이 훅으로 규칙을 얹어도 그 값이 Service 까지 도달하지 못하므로, + * 검증을 통과한 전체 데이터 위에 기본값이 적용된 고정 키를 덮어쓴다. + * + * @return array 조회 필터 (per_page, sort_order + 확장 추가 필드) */ public function getFilters(): array { - return [ + return array_merge($this->validated(), [ 'per_page' => (int) ($this->query('per_page', 10)), 'sort_order' => $this->query('sort_order', 'desc'), - ]; + ]); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ShowProductForCopyRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductShowForCopyRequest.php similarity index 71% rename from modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ShowProductForCopyRequest.php rename to modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductShowForCopyRequest.php index f34c2189..aee8696f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ShowProductForCopyRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductShowForCopyRequest.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin; +use App\Extension\HookManager; use Illuminate\Foundation\Http\FormRequest; /** @@ -10,7 +11,7 @@ use Illuminate\Foundation\Http\FormRequest; * 권한은 라우트 미들웨어(permission:admin,sirsoft-ecommerce.products.create)에서 처리하며, * 여기서는 어떤 항목을 함께 복사할지 고르는 플래그만 검증합니다. */ -class ShowProductForCopyRequest extends FormRequest +class ProductShowForCopyRequest extends FormRequest { /** * 복사 대상 항목과 미지정 시 기본값 @@ -54,22 +55,29 @@ class ShowProductForCopyRequest extends FormRequest $rules["copy_{$target}"] = ['sometimes', 'boolean']; } - return $rules; + return HookManager::applyFilters('sirsoft-ecommerce.product.show_for_copy_validation_rules', $rules, $this); } /** * 복사 옵션을 반환합니다. * - * @return array 항목별 복사 여부 + * 코어 항목은 `copy_` 접두사를 뗀 키로, 확장이 훅으로 추가한 필드는 검증된 이름 그대로 + * 함께 담는다 — 고정 키만 열거하면 확장이 규칙을 얹어도 값이 Service 에 도달하지 못한다. + * + * @return array 항목별 복사 여부 + 확장 추가 필드 */ public function getCopyOptions(): array { + $coreKeys = []; $options = []; foreach (self::COPY_TARGETS as $target => $default) { + $coreKeys[] = "copy_{$target}"; $options[$target] = $this->boolean("copy_{$target}", $default); } - return $options; + $extra = array_diff_key($this->validated(), array_flip($coreKeys)); + + return array_merge($extra, $options); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php index cfb28897..001aca77 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php @@ -98,13 +98,17 @@ class UpdateCouponRequest extends FormRequest 'issue_condition' => 'sometimes|required|string|in:'.implode(',', CouponIssueCondition::values()), 'issue_status' => 'sometimes|required|string|in:'.implode(',', CouponIssueStatus::values()), 'total_quantity' => 'nullable|integer|min:1', - 'per_user_limit' => 'required|integer|min:0', + // 같은 파일의 다른 필드와 동일하게 부분 수정을 지원한다. sometimes 가 없으면 + // 다른 탭만 고치는 요청도 1인당 사용 제한을 매번 실어 보내야 저장된다. + 'per_user_limit' => 'sometimes|required|integer|min:0', - // 유효기간 + // 유효기간 — 조건부 규칙은 StoreCouponRequest 와 동일해야 한다. + // required_if 는 조건 필드(valid_type)가 요청에 없으면 발화하지 않으므로 + // 부분 수정을 깨지 않으면서 "기간 지정인데 기간이 비어 있는" 저장만 차단한다. 'valid_type' => 'sometimes|required|string|in:period,days_from_issue', - 'valid_days' => 'nullable|integer|min:1', - 'valid_from' => 'nullable|date', - 'valid_to' => 'nullable|date|after_or_equal:valid_from', + 'valid_days' => 'nullable|required_if:valid_type,days_from_issue|integer|min:1', + 'valid_from' => 'nullable|required_if:valid_type,period|date', + 'valid_to' => 'nullable|required_if:valid_type,period|date|after_or_equal:valid_from', // 발급기간 'issue_from' => 'nullable|date', @@ -195,6 +199,9 @@ class UpdateCouponRequest extends FormRequest ? __('sirsoft-ecommerce::validation.coupon.discount_value_rate_min') : __('sirsoft-ecommerce::validation.coupon.discount_value_fixed_min'), 'discount_value.max' => __('sirsoft-ecommerce::validation.coupon.discount_value_rate_max'), + 'valid_days.required_if' => __('sirsoft-ecommerce::validation.coupon.valid_days_required'), + 'valid_from.required_if' => __('sirsoft-ecommerce::validation.coupon.valid_from_required'), + 'valid_to.required_if' => __('sirsoft-ecommerce::validation.coupon.valid_to_required'), 'valid_to.after_or_equal' => __('sirsoft-ecommerce::validation.coupon.valid_to_after_from'), ]; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/MapsAddressBookFields.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/MapsAddressBookFields.php index 80d2c6c0..19db690b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/MapsAddressBookFields.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/MapsAddressBookFields.php @@ -12,6 +12,10 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Concerns; * 본 트레이트는 validated() 결과를 국가별로 정규화한다: * - KR(국내): zipcode/address/address_detail 만 유지, 해외 필드 제거 * - 그 외(해외): intl_* → city/state/postal_code 변환, 국내 필드 제거 + * + * 변환은 요청에 실려 온 intl_* 키에 대해서만 수행한다. 조회 응답은 해외 주소를 + * city/state/postal_code 로 내보내므로 폼이 intl_* 를 다시 싣지 않는 수정 요청이 존재하며, + * 미전송 키를 null 로 채우면 저장된 도시/주/우편번호가 조용히 유실된다. */ trait MapsAddressBookFields { @@ -51,9 +55,15 @@ trait MapsAddressBookFields ); } else { // 해외: intl_* → city/state/postal_code 변환, 국내 전용 필드 제거 - $data['city'] = $data['intl_city'] ?? null; - $data['state'] = $data['intl_state'] ?? null; - $data['postal_code'] = $data['intl_postal_code'] ?? null; + // + // 전송되지 않은 intl_* 는 매핑 대상에서 제외한다. `?? null` 로 채우면 부분 수정 + // 요청(조회 응답이 city/state/postal_code 키로 내보내므로 폼이 intl_* 를 다시 + // 싣지 않는 경우)에서 저장된 도시/주/우편번호가 null 로 덮어써져 조용히 유실된다. + foreach (['city' => 'intl_city', 'state' => 'intl_state', 'postal_code' => 'intl_postal_code'] as $column => $formKey) { + if (array_key_exists($formKey, $data)) { + $data[$column] = $data[$formKey]; + } + } unset( $data['intl_city'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UpdateUserAddressRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UpdateUserAddressRequest.php index c8164347..e027a107 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UpdateUserAddressRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UpdateUserAddressRequest.php @@ -4,7 +4,11 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\User; use App\Extension\HookManager; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\Validator; use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\MapsAddressBookFields; +use Modules\Sirsoft\Ecommerce\Models\UserAddress; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\UserAddressRepositoryInterface; /** * 사용자 배송지 수정 요청 @@ -39,6 +43,11 @@ class UpdateUserAddressRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * + * 주소 필수 조합(국내 = 우편번호+주소 / 해외 = 주소+도시+우편번호)은 규칙 배열이 + * 아니라 withValidator() 에서 검증한다. StoreUserAddressRequest 의 + * required_with/required_without 를 그대로 옮기면 부분 수정이 깨지기 때문이다 — + * 상세 근거는 validateAddressPresence() docblock 참조. + * * @return array 주소록 수정 검증 규칙 */ public function rules(): array @@ -55,18 +64,26 @@ class UpdateUserAddressRequest extends FormRequest 'country_code' => 'nullable|string|size:2', // 국내 배송 주소 + // audit:allow formrequest-store-update-conditional-symmetry reason: 주소 필수 조합은 + // 페이로드 단독이 아니라 "기존 레코드 + 페이로드" 결과 상태 기준으로 판정한다 + // (validateAddressPresence). Store 의 required_without 를 그대로 옮기면 배송지명만 + // 바꾸는 요청과 해외 배송지 수정 요청이 422 가 된다. 'zipcode' => 'nullable|string|max:10', 'province_code' => 'nullable|string|max:10', 'city' => 'nullable|string|max:100', + // audit:allow formrequest-store-update-conditional-symmetry reason: 결과 상태 기준 판정 (validateAddressPresence) 'address' => 'nullable|string|max:255', 'address_detail' => 'nullable|string|max:255', 'address_type_code' => 'nullable|string|in:R,J', - // 해외 배송 주소 + // 해외 배송 주소 (필수 조합은 위와 동일하게 validateAddressPresence 가 담당) + // audit:allow formrequest-store-update-conditional-symmetry reason: 결과 상태 기준 판정 (validateAddressPresence) 'address_line_1' => 'nullable|string|max:255', 'address_line_2' => 'nullable|string|max:255', + // audit:allow formrequest-store-update-conditional-symmetry reason: 결과 상태 기준 판정 (validateAddressPresence) 'intl_city' => 'nullable|string|max:100', 'intl_state' => 'nullable|string|max:100', + // audit:allow formrequest-store-update-conditional-symmetry reason: 결과 상태 기준 판정 (validateAddressPresence) 'intl_postal_code' => 'nullable|string|max:20', // 기본 배송지 여부 @@ -76,6 +93,129 @@ class UpdateUserAddressRequest extends FormRequest return HookManager::applyFilters('sirsoft-ecommerce.user_address.update_validation_rules', $rules, $this); } + /** + * 검증기에 주소 필수 조합 검증을 추가합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $this->validateAddressPresence($validator); + }); + } + + /** + * 주소 필수 조합을 "기존 레코드 + 요청 페이로드" 결과 상태 기준으로 검증합니다. + * + * StoreUserAddressRequest 는 required_with/required_without 로 같은 정책을 강제하지만, + * 그 규칙을 수정 요청에 그대로 쓰면 두 가지 정상 경로가 막힌다. + * + * 1) 부분 수정 — 기본 배송지 토글처럼 주소를 건드리지 않는 요청도 required_without 이 + * "상대 필드 부재" 자체를 발화 조건으로 삼아 422 가 된다. + * 2) 해외 배송지 수정 — 조회 응답은 해외 주소를 city/state/postal_code 로 내보내고 + * 폼은 intl_* 로 제출하므로, 사용자가 도시/우편번호를 다시 입력하지 않으면 요청에 + * intl_city/intl_postal_code 가 없다. 페이로드만 보면 값이 비어 보인다. + * + * 따라서 요청에 없는 필드는 저장된 값으로 메워 판정한다. 판정 기준 국가는 + * MapsAddressBookFields 의 매핑 기준과 동일하게 country_code(미전송 시 기존 값)다. + * + * @param Validator $validator 검증기 + */ + protected function validateAddressPresence(Validator $validator): void + { + $payload = $this->all(); + + // 주소를 정의하는 필드도 국가도 건드리지 않는 요청은 판정 대상이 아니다. + $addressKeys = ['zipcode', 'address', 'address_line_1', 'intl_city', 'intl_postal_code', 'country_code']; + + if (! array_intersect($addressKeys, array_keys($payload))) { + return; + } + + $existing = $this->findExistingAddress(); + + // 저장된 컬럼명 → 요청 키 매핑 (해외는 city/state/postal_code 컬럼에 저장된다) + $fallback = [ + 'zipcode' => $existing?->zipcode, + 'address' => $existing?->address, + 'address_line_1' => $existing?->address_line_1, + 'intl_city' => $existing?->city, + 'intl_postal_code' => $existing?->postal_code, + ]; + + $effective = []; + + foreach ($fallback as $key => $storedValue) { + $effective[$key] = array_key_exists($key, $payload) ? $payload[$key] : $storedValue; + } + + $country = array_key_exists('country_code', $payload) + ? strtoupper((string) $payload['country_code']) + : strtoupper((string) ($existing?->country_code ?? 'KR')); + + if ($country === '') { + $country = 'KR'; + } + + if ($country === 'KR') { + if (! $this->isFilled($effective['zipcode'])) { + $validator->errors()->add('zipcode', __('sirsoft-ecommerce::validation.user_address.zipcode_required')); + } + + if (! $this->isFilled($effective['address'])) { + $validator->errors()->add('address', __('sirsoft-ecommerce::validation.user_address.address_required')); + } + + return; + } + + if (! $this->isFilled($effective['address_line_1'])) { + $validator->errors()->add('address_line_1', __('sirsoft-ecommerce::validation.user_address.address_line_1_required')); + } + + if (! $this->isFilled($effective['intl_city'])) { + $validator->errors()->add('intl_city', __('sirsoft-ecommerce::validation.user_address.intl_city_required')); + } + + if (! $this->isFilled($effective['intl_postal_code'])) { + $validator->errors()->add('intl_postal_code', __('sirsoft-ecommerce::validation.user_address.intl_postal_code_required')); + } + } + + /** + * 수정 대상 배송지를 조회합니다. (본인 소유 스코프) + * + * @return UserAddress|null 배송지 (없으면 null — 404 는 컨트롤러가 판정) + */ + protected function findExistingAddress(): ?UserAddress + { + $userId = Auth::id(); + $addressId = (int) $this->route('id'); + + if ($userId === null || $addressId <= 0) { + return null; + } + + // Repository 인터페이스 경유 — FormRequest 의 Model 직접 호출 금지 규정 준수 + return app(UserAddressRepositoryInterface::class)->findByUserIdAndId($userId, $addressId); + } + + /** + * 값이 실제로 채워져 있는지 판정합니다. (공백만 있는 문자열은 빈 값) + * + * @param mixed $value 판정 대상 값 + * @return bool 채워져 있으면 true + */ + private function isFilled(mixed $value): bool + { + if ($value === null) { + return false; + } + + return trim((string) $value) !== ''; + } + /** * 검증 오류 메시지 커스터마이징 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php index 07831061..4eed0e64 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php @@ -258,7 +258,7 @@ interface ProductRepositoryInterface * 상품과 그 옵션의 활동 로그를 합쳐 페이지네이션으로 조회 * * @param Product $product 대상 상품 - * @param array $filters 조회 필터 (per_page, sort_order) + * @param array $filters 조회 필터 (per_page, sort_order + 확장이 훅으로 추가한 필드) * @return LengthAwarePaginator 활동 로그 페이지네이터 */ public function getActivityLogsForProduct(Product $product, array $filters = []): LengthAwarePaginator; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php index fc67823d..25cc4be9 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php @@ -2,8 +2,10 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus; use Modules\Sirsoft\Ecommerce\Models\CouponIssue; use Modules\Sirsoft\Ecommerce\Models\Product; @@ -14,6 +16,8 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterf */ class CouponIssueRepository implements CouponIssueRepositoryInterface { + use PaginatesWithDeferredJoin; + public function __construct( protected CouponIssue $model ) {} @@ -161,14 +165,19 @@ class CouponIssueRepository implements CouponIssueRepositoryInterface } } - return $query->orderByDesc('created_at') - ->paginate($perPage); + // 쿠폰 발급 내역은 발급량에 비례해 계속 늘어난다 + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'created_at', 'direction' => 'desc']], + perPage: $perPage, + ); } /** * {@inheritDoc} */ - public function findByIdsForUser(array $couponIssueIds, int $userId): \Illuminate\Support\Collection + public function findByIdsForUser(array $couponIssueIds, int $userId): Collection { if (empty($couponIssueIds)) { return collect(); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php index c2ffec0e..b0f1d298 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php @@ -3,6 +3,8 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Helpers\PermissionHelper; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -18,6 +20,12 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponRepositoryInterface; */ class CouponRepository implements CouponRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (CouponListRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = ['created_at', 'name', 'discount_value', 'issued_count']; + public function __construct( protected Coupon $model, protected CouponIssue $issueModel @@ -89,10 +97,9 @@ class CouponRepository implements CouponRepositoryInterface */ private function finalizeListQuery(Builder $query, array $filters, array $with, int $perPage): LengthAwarePaginator { - // 정렬 - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder)->orderBy('id', $sortOrder); + // 정렬 (허용 컬럼 화이트리스트로 해석) + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at')[0]; + $query->orderBy($sort['column'], $sort['direction'])->orderBy('id', $sort['direction']); // Eager loading if (! empty($with)) { @@ -102,6 +109,8 @@ class CouponRepository implements CouponRepositoryInterface // 기본 관계 로드 $query->with(['creator:id,uuid,name,email']); + // audit:allow repository-paginate-column-pruning reason: 쿠폰 "정의" 목록 — + // 행 수가 운영자가 만든 쿠폰 수에 묶인다(발급 이력은 별도 테이블). description 외에 넓은 컬럼이 없다 return $query->paginate($perPage); } @@ -178,13 +187,19 @@ class CouponRepository implements CouponRepositoryInterface $query->where('status', $filters['status']); } - // 기본 정렬: 발급일 최신순 - $query->orderBy('issued_at', 'desc')->orderBy('id', 'desc'); - - // 관계 로드 (사용처 주문번호 표시를 위해 order 도 로드) - $query->with(['user:id,uuid,name', 'order:id,order_number']); - - return $query->paginate($perPage); + // 지연 조인: 쿠폰 1건의 발급 이력은 대량 발급 시 수십만 건이 될 수 있어 + // 뒤쪽 페이지에서 OFFSET 비용이 그대로 누적된다. inner 는 id 만 훑는다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [ + ['column' => 'issued_at', 'direction' => 'desc'], + ['column' => 'id', 'direction' => 'desc'], + ], + perPage: $perPage, + // 관계 로드 (사용처 주문번호 표시를 위해 order 도 로드) + relations: ['user:id,uuid,name', 'order:id,order_number'], + ); } /** @@ -249,6 +264,8 @@ class CouponRepository implements CouponRepositoryInterface ->orderBy('created_at', 'desc'); if ($perPage !== null) { + // audit:allow repository-paginate-column-pruning reason: 다운로드 가능 쿠폰 "정의" 목록 — + // 발급중 상태의 쿠폰만 남는 좁은 집합이라 OFFSET 이 깊어질 수 없다 return $query->paginate($perPage); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ExtraFeeTemplateRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ExtraFeeTemplateRepository.php index abf4f4eb..177d9d27 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ExtraFeeTemplateRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ExtraFeeTemplateRepository.php @@ -2,9 +2,10 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Ecommerce\Models\ExtraFeeTemplate; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ExtraFeeTemplateRepositoryInterface; @@ -13,6 +14,12 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ExtraFeeTemplateRepositoryI */ class ExtraFeeTemplateRepository implements ExtraFeeTemplateRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (ExtraFeeTemplate::scopeOrderByField 와 동일 집합) */ + private const SORTABLE_COLUMNS = ['id', 'zipcode', 'fee', 'region', 'is_active', 'created_at', 'updated_at']; + public function __construct( protected ExtraFeeTemplate $model ) {} @@ -56,12 +63,17 @@ class ExtraFeeTemplateRepository implements ExtraFeeTemplateRepositoryInterface $query->where('is_active', $isActive); } - // 정렬 - $sortBy = $filters['sort_by'] ?? 'zipcode'; - $sortOrder = $filters['sort_order'] ?? 'asc'; - $query->orderByField($sortBy, $sortOrder); + // 정렬 (모델 scopeOrderByField 와 동일한 허용 집합) + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'zipcode', 'asc'); - return $query->paginate($perPage); + // 지연 조인: 우편번호 단위 행이라 건수가 커질 수 있고 description(text)이 있어 + // OFFSET 이 훑는 구간에서 넓은 컬럼까지 읽힌다. inner 는 id 만 훑는다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + ); } /** @@ -228,12 +240,12 @@ class ExtraFeeTemplateRepository implements ExtraFeeTemplateRepositoryInterface * ID 목록으로 조회하고 ID 키 맵으로 반환합니다 (bulk activity log lookup). * * @param array $ids ID 목록 - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ - public function findByIdsKeyed(array $ids): \Illuminate\Database\Eloquent\Collection + public function findByIdsKeyed(array $ids): Collection { if (empty($ids)) { - return new \Illuminate\Database\Eloquent\Collection(); + return new Collection; } return ExtraFeeTemplate::whereIn('id', $ids)->get()->keyBy('id'); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageBalanceRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageBalanceRepository.php index b235addd..4417e2bc 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageBalanceRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageBalanceRepository.php @@ -7,6 +7,8 @@ use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Ecommerce\Enums\MileageTransactionTypeEnum; use Modules\Sirsoft\Ecommerce\Models\MileageBalance; use Modules\Sirsoft\Ecommerce\Models\MileageTransaction; +use Modules\Sirsoft\Ecommerce\Models\Order; +use Modules\Sirsoft\Ecommerce\Models\OrderOption; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInterface; /** @@ -202,15 +204,21 @@ class MileageBalanceRepository implements MileageBalanceRepositoryInterface */ private function computePending(int $userId, string $currency): float { - return (float) DB::table('ecommerce_order_options as opt') - ->join('ecommerce_orders as ord', 'opt.order_id', '=', 'ord.id') + // 테이블명은 모델에서 얻는다 (문자열 하드코딩 금지). 별칭은 빌더가 붙이므로 + // 프리픽스 처리도 빌더가 맡는다. + $optionsTable = (new OrderOption)->getTable(); + $ordersTable = (new Order)->getTable(); + $transactionsTable = (new MileageTransaction)->getTable(); + + return (float) DB::table("{$optionsTable} as opt") + ->join("{$ordersTable} as ord", 'opt.order_id', '=', 'ord.id') ->where('ord.user_id', $userId) ->where('ord.currency', $currency) ->whereNotIn('opt.option_status', self::CANCELLED_STATUSES) ->where('opt.subtotal_earned_points_amount', '>', 0) - ->whereNotExists(function ($q) { + ->whereNotExists(function ($q) use ($transactionsTable) { $q->select(DB::raw(1)) - ->from('ecommerce_mileage_transactions as tx') + ->from("{$transactionsTable} as tx") ->whereColumn('tx.order_option_id', 'opt.id') ->whereIn('tx.type', self::EARN_TYPES); }) diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php index f847dc1c..8e836d88 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Models\User; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -10,6 +11,8 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Ecommerce\Enums\MileageTransactionTypeEnum; use Modules\Sirsoft\Ecommerce\Models\MileageTransaction; +use Modules\Sirsoft\Ecommerce\Models\Order; +use Modules\Sirsoft\Ecommerce\Models\OrderOption; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface; /** @@ -17,6 +20,8 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositor */ class MileageTransactionRepository implements MileageTransactionRepositoryInterface { + use PaginatesWithDeferredJoin; + /** * 적립(잔액 증가) 유형 목록 * @@ -170,17 +175,20 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf public function paginateWithFilters(array $filters, int $perPage = 20): LengthAwarePaginator { // 적립 lot 의 소멸 합 eager 집계 (N+1 회피) — 이 lot 을 source 로 하는 expired 거래 amount 합(음수). - // 상관 서브쿼리는 self-reference + alias 가 빌더 prefix 처리와 충돌하므로 raw 로 일관 작성한다 - // (prefix 는 실제 테이블명에 한 번만 직접 적용). - $prefixedTable = DB::connection()->getTablePrefix().(new MileageTransaction)->getTable(); - $expiredType = MileageTransactionTypeEnum::EXPIRED->value; - $expiredSumSub = "(select COALESCE(SUM(ABS(exp.amount)), 0) from `{$prefixedTable}` as exp" - ." where exp.type = '{$expiredType}' and exp.source_transaction_id = `{$prefixedTable}`.id)"; + // 소멸분 합계는 같은 테이블을 다시 보는 상관 서브쿼리다. 별칭은 빌더가 만들고 + // (grammar 가 프리픽스를 별칭에도 붙인다), raw 는 집계 함수 한 곳만 남긴다. + // 서브쿼리 FROM 이 한 테이블뿐이라 집계 대상 컬럼은 한정 없이 써도 모호하지 않다. + $table = (new MileageTransaction)->getTable(); + + $expiredAmount = DB::table("{$table} as exp") + ->select(DB::raw('COALESCE(SUM(ABS(amount)), 0)')) + ->where('exp.type', MileageTransactionTypeEnum::EXPIRED->value) + ->whereColumn('exp.source_transaction_id', "{$table}.id"); $query = MileageTransaction::query() ->with(['user', 'grantedByUser', 'order']) ->addSelect('*') - ->addSelect(DB::raw("{$expiredSumSub} as expired_amount")); + ->addSelect(['expired_amount' => $expiredAmount]); if (! empty($filters['user_id'])) { $query->where('user_id', $filters['user_id']); @@ -233,8 +241,12 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf }); } + // 적립/사용 이력은 계속 쌓이므로 지연 조인으로 뒤쪽 페이지 비용을 고정한다. + // 정렬은 applySort 가 닫힌 슬러그 집합으로 해석해 이미 쿼리에 적용돼 있다. $this->applySort($query, $filters['sort'] ?? 'created_at_desc'); + // audit:allow repository-paginate-column-pruning reason: 정렬이 applySort 로 쿼리에 직접 + // 적용돼 지연 조인 계약(정렬 미적용 쿼리)을 만족하지 않는다. 컬럼 프루닝은 후속 정리 대상. return $query->paginate($perPage); } @@ -278,7 +290,13 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf $query->where('currency', $filters['currency']); } - return $query->orderByDesc('id')->paginate($perPage); + // 회원 마일리지 내역 — 계속 쌓이는 이력이라 지연 조인으로 뒤쪽 페이지 비용을 고정한다 + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'id', 'direction' => 'desc']], + perPage: $perPage, + ); } /** @@ -360,8 +378,9 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf { $threshold = $now->copy()->subDays($delayDays); - $query = DB::table('ecommerce_order_options as opt') - ->join('ecommerce_orders as ord', 'opt.order_id', '=', 'ord.id') + // 테이블명은 모델에서 얻는다 (문자열 하드코딩 금지) + $query = DB::table((new OrderOption)->getTable().' as opt') + ->join((new Order)->getTable().' as ord', 'opt.order_id', '=', 'ord.id') ->whereNotNull('ord.user_id') ->where('opt.option_status', $triggerStatus) ->whereNotNull("opt.{$triggerColumn}") @@ -371,7 +390,7 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf // (나눠 확정·병합으로 목표액이 늘어난 옵션의 잔여분까지 스케줄러가 포착) // 빌더 서브쿼리로 표현 — raw SQL/테이블 별칭은 prefix 자동 적용을 받지 못하므로 별칭 없이 컬럼만 참조. ->where('opt.subtotal_earned_points_amount', '>', function ($sub) { - $sub->from('ecommerce_mileage_transactions') + $sub->from((new MileageTransaction)->getTable()) ->selectRaw('COALESCE(SUM(amount), 0)') ->whereColumn('order_option_id', 'opt.id') ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php index b69e239f..a2aee2dc 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php @@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Helpers\PermissionHelper; use App\Models\ActivityLog; use App\Models\User; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -23,6 +25,63 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; */ class OrderRepository implements OrderRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** + * 주문 목록이 실제로 사용하는 컬럼 + * + * `ecommerce_orders` 는 스냅샷 mediumText 5종 + 관리자 메모 + 통화별 금액 text 16종을 갖는다. + * 목록에서 쓰지 않는 이 컬럼들까지 읽으면 뒤쪽 페이지에서 건너뛸 행의 넓은 컬럼까지 함께 + * 읽혀 비용이 선형으로 커진다. 관리자 목록(OrderListResource)과 회원 주문내역 + * (UserOrderListResource)이 참조하는 컬럼의 합집합만 남긴다. + * + * @var array + */ + private const LIST_COLUMNS = [ + 'id', + 'user_id', + 'order_number', + 'order_status', + 'order_device', + 'is_first_order', + 'currency', + 'currency_snapshot', + 'total_amount', + 'total_shipping_amount', + 'total_paid_amount', + 'total_cancelled_amount', + 'total_refunded_amount', + 'total_points_used_amount', + 'total_earned_points_amount', + 'mc_total_amount', + 'mc_total_shipping_amount', + 'ordered_at', + 'created_at', + 'updated_at', + 'deleted_at', + ]; + + /** + * 주문 목록 정렬 허용 컬럼 + * + * 요청 값을 그대로 orderBy 에 넘기면 없는 컬럼으로 SQL 오류가 나거나 인덱스 없는 넓은 + * 컬럼 정렬을 강제할 수 있다. + * + * @var array + */ + private const SORTABLE_COLUMNS = [ + 'id', + 'order_number', + 'order_status', + 'total_amount', + 'total_shipping_amount', + 'total_paid_amount', + 'ordered_at', + 'paid_at', + 'created_at', + ]; + public function __construct( protected Order $model ) {} @@ -90,14 +149,9 @@ class OrderRepository implements OrderRepositoryInterface */ public function getListWithFilters(array $filters, int $perPage = 20): LengthAwarePaginator { - $query = $this->model->newQuery() - ->with([ - 'user', - 'options', - 'shippingAddress', - 'payment', - 'shippings', - ]); + // 관계는 지연 조인의 outer 에서만 로드한다 (inner 에 붙으면 관계 쿼리가 두 번 실행되고, + // inner 는 키 컬럼만 조회하므로 eager load 가 성립하지도 않는다). + $query = $this->model->newQuery(); // 권한 스코프 필터링 PermissionHelper::applyPermissionScope($query, 'sirsoft-ecommerce.orders.read'); @@ -119,8 +173,8 @@ class OrderRepository implements OrderRepositoryInterface if ($ordererUser) { $query->where('user_id', $ordererUser->id); } else { - // UUID에 해당하는 회원이 없으면 결과 없음 - $query->whereRaw('1 = 0'); + // UUID에 해당하는 회원이 없으면 결과 없음. 빈 whereIn 은 `0 = 1` 로 컴파일된다 + $query->whereIn($query->getModel()->getKeyName(), []); } } @@ -285,12 +339,22 @@ class OrderRepository implements OrderRepositoryInterface $query->whereIn('order_device', $devices); } - // 정렬 - $sortBy = $filters['sort_by'] ?? 'ordered_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 — 요청 값은 허용 목록으로만 해석한다 + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'ordered_at'); - return $query->paginate($perPage); + return $this->paginateWithDeferredJoin( + query: $query, + columns: self::LIST_COLUMNS, + sort: $sort, + perPage: $perPage, + relations: [ + 'user', + 'options', + 'shippingAddress', + 'payment', + 'shippings', + ], + ); } /** @@ -496,10 +560,10 @@ class OrderRepository implements OrderRepositoryInterface $this->applyFiltersToQuery($query, $filters); } - // 정렬 - $sortBy = $filters['sort_by'] ?? 'ordered_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 (목록 조회와 동일한 허용 컬럼 화이트리스트) + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'ordered_at') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); + } return $query->get(); } @@ -639,7 +703,7 @@ class OrderRepository implements OrderRepositoryInterface $optionIds = $order->options()->pluck('id')->toArray(); $addressIds = $order->addresses()->pluck('id')->toArray(); - return ActivityLog::where(function (Builder $q) use ($order, $optionIds, $addressIds) { + $query = ActivityLog::where(function (Builder $q) use ($order, $optionIds, $addressIds) { // 주문 자체 로그 $q->where(function (Builder $sub) use ($order) { $sub->where('loggable_type', $order->getMorphClass()) @@ -661,6 +725,15 @@ class OrderRepository implements OrderRepositoryInterface ->whereIn('loggable_id', $addressIds); }); } - })->orderBy('created_at', $sortOrder)->paginate($perPage); + }); + + // 활동 로그는 변경 내역(mediumText)을 리소스가 그대로 노출하므로 컬럼은 좁히지 않고 + // 지연 조인으로 넓은 컬럼을 읽는 행 수만 이번 페이지 분량으로 고정한다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'created_at', 'direction' => $sortOrder]], + perPage: $perPage, + ); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductCommonInfoRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductCommonInfoRepository.php index 2dce4a26..e8c0df83 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductCommonInfoRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductCommonInfoRepository.php @@ -87,6 +87,8 @@ class ProductCommonInfoRepository implements ProductCommonInfoRepositoryInterfac $query->with($with); } }) + // audit:allow repository-paginate-column-pruning reason: 상품 공통정보 "템플릿" 목록 — + // 행 수가 운영자가 만든 템플릿 수에 묶여 OFFSET 이 깊어질 수 없다 ->paginate($perPage); } @@ -113,6 +115,8 @@ class ProductCommonInfoRepository implements ProductCommonInfoRepositoryInterfac $query->with($with); } + // audit:allow repository-paginate-column-pruning reason: 상품 공통정보 "템플릿" 목록 — + // 행 수가 운영자가 만든 템플릿 수에 묶여 OFFSET 이 깊어질 수 없다 return $query->paginate($perPage); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php index aedd75cb..6eb5605a 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductInquiryRepository.php @@ -2,8 +2,10 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Modules\Sirsoft\Ecommerce\Models\ProductInquiry; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductInquiryRepositoryInterface; @@ -12,6 +14,8 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductInquiryRepositoryInt */ class ProductInquiryRepository implements ProductInquiryRepositoryInterface { + use PaginatesWithDeferredJoin; + public function __construct( protected ProductInquiry $model ) {} @@ -43,6 +47,8 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface return $this->model->newQuery() ->where('product_id', $productId) ->orderBy('created_at', 'desc') + // audit:allow repository-paginate-column-pruning reason: 상품 1건에 종속된 문의 목록 — + // where(product_id) 로 이미 좁혀져 OFFSET 이 깊어질 수 없고, 목록이 본문을 그대로 쓴다 ->paginate($perPage); } @@ -86,6 +92,8 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface }); } + // audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 내 문의 목록 — + // where(user_id) 로 이미 좁혀져 OFFSET 이 깊어질 수 없다 return $query->paginate($perPage); } @@ -94,9 +102,7 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface */ public function getListWithFilters(array $filters, int $perPage = 20): LengthAwarePaginator { - $query = $this->model->newQuery() - ->with(['product', 'user']) - ->orderBy('created_at', 'desc'); + $query = $this->model->newQuery(); // 상품명 검색 if (! empty($filters['search_keyword'])) { @@ -116,15 +122,23 @@ class ProductInquiryRepository implements ProductInquiryRepositoryInterface $query->where('is_answered', (bool) $filters['is_answered']); } - // 기간 필터 + // 기간 필터 — whereDate 는 컬럼에 함수를 씌워 인덱스를 무력화하므로 범위 조건으로 준다 if (! empty($filters['date_from'])) { - $query->whereDate('created_at', '>=', $filters['date_from']); + $query->where('created_at', '>=', Carbon::parse($filters['date_from'])->startOfDay()); } if (! empty($filters['date_to'])) { - $query->whereDate('created_at', '<=', $filters['date_to']); + $query->where('created_at', '<=', Carbon::parse($filters['date_to'])->endOfDay()); } - return $query->paginate($perPage); + // 지연 조인: 전역 문의 목록은 무제한 성장하고 content(text)가 있어 + // OFFSET 이 훑는 구간에서 넓은 컬럼까지 읽힌다. inner 는 id 만 훑는다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'created_at', 'direction' => 'desc']], + perPage: $perPage, + relations: ['product', 'user'], + ); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductNoticeTemplateRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductNoticeTemplateRepository.php index acbb48b5..0f279755 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductNoticeTemplateRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductNoticeTemplateRepository.php @@ -90,6 +90,8 @@ class ProductNoticeTemplateRepository implements ProductNoticeTemplateRepository $query->with($with); } + // audit:allow repository-paginate-column-pruning reason: 상품 안내 "템플릿" 목록 — + // 행 수가 운영자가 만든 템플릿 수에 묶여 OFFSET 이 깊어질 수 없다 return $query->paginate($perPage); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php index 6bddf8c0..980d1699 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php @@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Helpers\PermissionHelper; use App\Models\ActivityLog; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use App\Search\Engines\DatabaseFulltextEngine; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -21,6 +23,12 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; */ class ProductRepository implements ProductRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (ProductListRequest 와 동일 집합) */ + private const ADMIN_SORTABLE_COLUMNS = ['created_at', 'updated_at', 'selling_price', 'stock_quantity', 'name']; + public function __construct( protected Product $model ) {} @@ -69,11 +77,14 @@ class ProductRepository implements ProductRepositoryInterface */ public function getListWithFilters(array $filters, int $perPage = 20): LengthAwarePaginator { - $query = $this->model->newQuery()->with(['options', 'categories', 'images', 'brand', 'shippingPolicy']); + $query = $this->model->newQuery(); // 권한 스코프 필터링 PermissionHelper::applyPermissionScope($query, 'sirsoft-ecommerce.products.read'); + // 지연 조인 outer 전용 관계 (inner 는 id 만 훑으므로 여기서 with() 를 걸지 않는다) + $listRelations = ['options', 'categories', 'images', 'brand', 'shippingPolicy']; + // 문자열 검색 if (! empty($filters['search_keyword'])) { $keyword = $filters['search_keyword']; @@ -110,11 +121,16 @@ class ProductRepository implements ProductRepositoryInterface // 매칭 ID 로 조건 한정 (빈 매칭은 존재하지 않는 ID 로 빈 결과 → total=0 이 정상) $query->whereIn('id', $matchedIds ?: [0]); - // 필터 + 정렬 적용 + // 필터 적용 $this->applyAdminFilters($query, $filters); - $this->applyAdminSorting($query, $filters); - return $query->paginate($perPage); + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $this->resolveAdminSortSpec($filters), + perPage: $perPage, + relations: $listRelations, + ); } // FULLTEXT 미대상 필드 (product_code, sku, barcode) → LIKE 직접 @@ -134,10 +150,13 @@ class ProductRepository implements ProductRepositoryInterface // 필터 적용 $this->applyAdminFilters($query, $filters); - // 정렬 - $this->applyAdminSorting($query, $filters); - - return $query->paginate($perPage); + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $this->resolveAdminSortSpec($filters), + perPage: $perPage, + relations: $listRelations, + ); } /** @@ -398,11 +417,13 @@ class ProductRepository implements ProductRepositoryInterface public function getPublicList(array $filters, int $perPage = 20): LengthAwarePaginator { $query = $this->model->newQuery() - ->with(['images', 'categories', 'brand', 'activeLabelAssignments.label']) ->withCount('visibleReviews as review_count') ->withAvg('visibleReviews as rating_avg', 'rating') ->where('display_status', 'visible'); + // 지연 조인 outer 전용 관계 (inner 는 id 만 훑으므로 with() 를 걸지 않는다) + $listRelations = ['images', 'categories', 'brand', 'activeLabelAssignments.label']; + // 카테고리 필터 (ID) — 선택 카테고리 + 모든 하위 카테고리 포함 if (! empty($filters['category_id'])) { $categoryIds = Category::selfAndDescendantIds((int) $filters['category_id']); @@ -420,8 +441,8 @@ class ProductRepository implements ProductRepositoryInterface $q->whereIn('ecommerce_product_categories.category_id', $categoryIds); }); } else { - // 존재하지 않는 slug → 빈 결과 (500 아님) - $query->whereRaw('1 = 0'); + // 존재하지 않는 slug → 빈 결과 (500 아님). 빈 whereIn 은 `0 = 1` 로 컴파일된다 + $query->whereIn($query->getModel()->getKeyName(), []); } } @@ -446,18 +467,35 @@ class ProductRepository implements ProductRepositoryInterface // 정렬 $sort = $filters['sort'] ?? 'latest'; - match ($sort) { - 'sales' => $query + + // 판매량 정렬은 집계 서브쿼리(total_sold) 를 select 에 올려 두고 그 별칭으로 정렬한다. + // 지연 조인의 inner 는 select 를 id 하나로 교체하므로 별칭이 사라져 성립하지 않는다. + if ($sort === 'sales') { + $query ->addSelect(['total_sold' => OrderOption::selectRaw('COALESCE(SUM(quantity), 0)') ->whereColumn('product_id', 'ecommerce_products.id'), ]) - ->orderByDesc('total_sold'), - 'price_asc' => $query->orderBy('selling_price', 'asc'), - 'price_desc' => $query->orderBy('selling_price', 'desc'), - default => $query->orderBy('created_at', 'desc'), // latest + ->orderByDesc('total_sold') + ->with($listRelations); + + // audit:allow repository-paginate-column-pruning reason: 판매량 정렬은 집계 서브쿼리 + // 별칭 기준이라 지연 조인 inner(=id 단일 select)로 옮길 수 없다. 나머지 정렬은 지연 조인 적용 + return $query->paginate($perPage); + } + + $sortSpec = match ($sort) { + 'price_asc' => [['column' => 'selling_price', 'direction' => 'asc']], + 'price_desc' => [['column' => 'selling_price', 'direction' => 'desc']], + default => [['column' => 'created_at', 'direction' => 'desc']], // latest }; - return $query->paginate($perPage); + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sortSpec, + perPage: $perPage, + relations: $listRelations, + ); } /** @@ -536,6 +574,10 @@ class ProductRepository implements ProductRepositoryInterface ->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) + // audit:allow repository-paginate-column-pruning reason: 통합검색 — + // whereIn(id, $matchedIds) 로 FULLTEXT 매칭 ID 집합에 먼저 한정되므로 스캔 대상이 + // 상품 전체가 아니라 매칭 건수로 묶인다(OFFSET 이 훑는 범위도 그 안). 지연 조인의 + // inner 가 하는 일을 매칭 ID 산출이 이미 수행하는 구조다 ->paginate($limit, ['*'], 'page', $page); return ['total' => $paginator->total(), 'items' => $paginator->getCollection()]; @@ -672,23 +714,25 @@ class ProductRepository implements ProductRepositoryInterface } /** - * 관리자 상품 목록 정렬을 쿼리에 적용합니다. + * 관리자 상품 목록 정렬 스펙을 해석합니다. + * + * 지연 조인은 정렬을 inner/outer 양쪽에 동일하게 적용해야 하므로, 쿼리에 직접 orderBy 를 + * 거는 대신 스펙 배열을 돌려준다. * - * @param Builder $query Eloquent 쿼리 빌더 * @param array $filters 필터 배열 + * @return array 정렬 스펙 */ - private function applyAdminSorting($query, array $filters): void + private function resolveAdminSortSpec(array $filters): array { - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; + // 허용 컬럼 화이트리스트로 해석 (없는 컬럼 정렬은 기본값으로 흡수) + $sort = $this->resolveSortSpec($filters, self::ADMIN_SORTABLE_COLUMNS, 'created_at')[0]; // 다국어 이름 정렬 처리 - if ($sortBy === 'name') { - $locale = app()->getLocale(); - $query->orderBy("name->{$locale}", $sortOrder); - } else { - $query->orderBy($sortBy, $sortOrder); + if ($sort['column'] === 'name') { + $sort['column'] = 'name->'.app()->getLocale(); } + + return [$sort]; } /** @@ -755,7 +799,7 @@ class ProductRepository implements ProductRepositoryInterface $optionIds = $product->options()->pluck('id')->toArray(); - return ActivityLog::where(function (Builder $q) use ($product, $optionIds) { + $query = ActivityLog::where(function (Builder $q) use ($product, $optionIds) { // 상품 자체 로그 $q->where(function (Builder $sub) use ($product) { $sub->where('loggable_type', $product->getMorphClass()) @@ -769,6 +813,15 @@ class ProductRepository implements ProductRepositoryInterface ->whereIn('loggable_id', $optionIds); }); } - })->orderBy('created_at', $sortOrder)->paginate($perPage); + }); + + // 활동 로그는 변경 내역(mediumText)을 리소스가 그대로 노출하므로 컬럼은 좁히지 않고 + // 지연 조인으로 넓은 컬럼을 읽는 행 수만 이번 페이지 분량으로 고정한다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'created_at', 'direction' => $sortOrder]], + perPage: $perPage, + ); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductReviewRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductReviewRepository.php index 63230662..26d9fbdf 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductReviewRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductReviewRepository.php @@ -2,10 +2,14 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Ecommerce\Enums\ReviewStatus; +use Modules\Sirsoft\Ecommerce\Models\OrderOption; +use Modules\Sirsoft\Ecommerce\Models\ProductOption; use Modules\Sirsoft\Ecommerce\Models\ProductReview; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInterface; @@ -14,6 +18,8 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInte */ class ProductReviewRepository implements ProductReviewRepositoryInterface { + use PaginatesWithDeferredJoin; + public function __construct( protected ProductReview $model ) {} @@ -96,24 +102,31 @@ class ProductReviewRepository implements ProductReviewRepositoryInterface $query->where('status', $filters['status']); } - // 기간 필터 + // 기간 필터 — whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 못 쓰게 만든다. + // 같은 결과를 내는 범위 조건으로 바꿔 created_at 인덱스를 살린다 + // (종료일은 그날 23:59:59.999999 까지 포함해야 whereDate 와 동일한 경계를 갖는다). if (! empty($filters['start_date'])) { - $query->whereDate('created_at', '>=', $filters['start_date']); + $query->where('created_at', '>=', Carbon::parse($filters['start_date'])->startOfDay()); } if (! empty($filters['end_date'])) { - $query->whereDate('created_at', '<=', $filters['end_date']); + $query->where('created_at', '<=', Carbon::parse($filters['end_date'])->endOfDay()); } - // 정렬 - $sort = $filters['sort'] ?? 'created_at_desc'; - match ($sort) { - 'created_at_asc' => $query->orderBy('created_at'), - 'rating_desc' => $query->orderByDesc('rating'), - 'rating_asc' => $query->orderBy('rating'), - default => $query->orderByDesc('created_at'), + // 정렬 — 선택지가 닫힌 집합이라 match 로 충분하다 + $sort = match ($filters['sort'] ?? 'created_at_desc') { + 'created_at_asc' => [['column' => 'created_at', 'direction' => 'asc']], + 'rating_desc' => [['column' => 'rating', 'direction' => 'desc']], + 'rating_asc' => [['column' => 'rating', 'direction' => 'asc']], + default => [['column' => 'created_at', 'direction' => 'desc']], }; - return $query->paginate($perPage); + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + relations: ['user', 'product', 'images', 'orderOption.order', 'replyAdmin'], + ); } /** @@ -143,7 +156,7 @@ class ProductReviewRepository implements ProductReviewRepositoryInterface if ($value === '' || $value === null) { continue; } - $optionIds = DB::table('ecommerce_product_options') + $optionIds = DB::table((new ProductOption)->getTable()) ->where('product_id', $productId) ->whereRaw( "JSON_CONTAINS(option_values, JSON_OBJECT('key', JSON_OBJECT('ko', ?), 'value', JSON_OBJECT('ko', ?)))", @@ -153,7 +166,8 @@ class ProductReviewRepository implements ProductReviewRepositoryInterface ->toArray(); if (empty($optionIds)) { - $query->whereRaw('0 = 1'); + // 일치 옵션 없음 → 빈 결과. 빈 whereIn 은 `0 = 1` 로 컴파일된다 + $query->whereIn($query->getModel()->getKeyName(), []); break; } @@ -163,15 +177,20 @@ class ProductReviewRepository implements ProductReviewRepositoryInterface } } - // 정렬 - $sort = $filters['sort'] ?? 'created_at_desc'; - match ($sort) { - 'rating_desc' => $query->orderByDesc('rating'), - 'rating_asc' => $query->orderBy('rating'), - default => $query->orderByDesc('created_at'), + // 정렬 — 선택지가 닫힌 집합이라 match 로 충분하다 + $sort = match ($filters['sort'] ?? 'created_at_desc') { + 'rating_desc' => [['column' => 'rating', 'direction' => 'desc']], + 'rating_asc' => [['column' => 'rating', 'direction' => 'asc']], + default => [['column' => 'created_at', 'direction' => 'desc']], }; - return $query->paginate($perPage); + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + relations: ['user', 'images'], + ); } /** @@ -222,7 +241,7 @@ class ProductReviewRepository implements ProductReviewRepositoryInterface public function getOptionFilters(int $productId): array { // 상품의 모든 옵션 조회 (기준: product_options 전체) - $options = DB::table('ecommerce_product_options') + $options = DB::table((new ProductOption)->getTable()) ->where('product_id', $productId) ->orderBy('sort_order') ->pluck('option_values', 'id') @@ -233,8 +252,9 @@ class ProductReviewRepository implements ProductReviewRepositoryInterface } // 옵션별 리뷰 건수 집계 (option_id → review count) - $reviewCounts = DB::table('ecommerce_product_reviews as r') - ->join('ecommerce_order_options as oo', 'r.order_option_id', '=', 'oo.id') + // 테이블명은 모델에서 얻는다 (문자열 하드코딩 금지) + $reviewCounts = DB::table((new ProductReview)->getTable().' as r') + ->join((new OrderOption)->getTable().' as oo', 'r.order_option_id', '=', 'oo.id') ->where('r.product_id', $productId) ->where('r.status', ReviewStatus::VISIBLE->value) ->whereNull('r.deleted_at') diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductWishlistRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductWishlistRepository.php index fcb8412f..0b0dfe9b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductWishlistRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductWishlistRepository.php @@ -59,6 +59,8 @@ class ProductWishlistRepository implements ProductWishlistRepositoryInterface ->where('user_id', $userId) ->with(['product.brand', 'product.categories', 'product.activeLabelAssignments.label']) ->orderByDesc('created_at') + // audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 찜 목록 — + // where(user_id) 로 이미 좁혀지고, 피벗 성격의 테이블이라 넓은 컬럼이 없다 ->paginate($perPage); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingPolicyRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingPolicyRepository.php index f73af9c9..1136e207 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingPolicyRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingPolicyRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Helpers\PermissionHelper; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -15,6 +16,11 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInt */ class ShippingPolicyRepository implements ShippingPolicyRepositoryInterface { + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (ShippingPolicyListRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = ['id', 'name', 'is_active', 'sort_order', 'created_at', 'updated_at']; + public function __construct( protected ShippingPolicy $model ) {} @@ -75,18 +81,19 @@ class ShippingPolicyRepository implements ShippingPolicyRepositoryInterface $query->where('is_active', $isActive); } - // 정렬 - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; + // 정렬 (허용 컬럼 화이트리스트로 해석) + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at')[0]; // 다국어 이름 정렬 처리 - if ($sortBy === 'name') { + if ($sort['column'] === 'name') { $locale = app()->getLocale(); - $query->orderBy("name->{$locale}", $sortOrder); + $query->orderBy("name->{$locale}", $sort['direction']); } else { - $query->orderBy($sortBy, $sortOrder); + $query->orderBy($sort['column'], $sort['direction']); } + // audit:allow repository-paginate-column-pruning reason: 배송정책 "정의" 목록 — + // 행 수가 운영자가 만든 정책 수에 묶여 OFFSET 이 깊어질 수 없다 return $query->paginate($perPage); } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponUpdateConditionalValidationTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponUpdateConditionalValidationTest.php new file mode 100644 index 00000000..426b07a5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponUpdateConditionalValidationTest.php @@ -0,0 +1,172 @@ +setLocale('ko'); + + $this->adminUser = $this->createAdminUser([ + 'sirsoft-ecommerce.promotion-coupon.read', + 'sirsoft-ecommerce.promotion-coupon.create', + 'sirsoft-ecommerce.promotion-coupon.update', + ]); + } + + /** + * 쿠폰 생성 데이터 + * + * @param array $overrides 오버라이드할 속성 + * @return array 생성 요청 본문 + */ + private function validCouponData(array $overrides = []): array + { + return array_merge([ + 'name' => ['ko' => '유효기간 쿠폰', 'en' => 'Validity Coupon'], + 'target_type' => CouponTargetType::PRODUCT_AMOUNT->value, + 'discount_type' => CouponDiscountType::FIXED->value, + 'discount_value' => 1000, + 'min_order_amount' => 5000, + 'issue_method' => CouponIssueMethod::DIRECT->value, + 'issue_condition' => CouponIssueCondition::MANUAL->value, + 'issue_status' => CouponIssueStatus::ISSUING->value, + 'per_user_limit' => 0, + 'valid_type' => 'period', + 'valid_from' => now()->format('Y-m-d'), + 'valid_to' => now()->addMonth()->format('Y-m-d'), + ], $overrides); + } + + /** + * 테스트용 쿠폰을 생성하고 ID를 반환합니다. + * + * @param array $overrides 생성 데이터 오버라이드 + * @return int 생성된 쿠폰 ID + */ + private function createCoupon(array $overrides = []): int + { + $response = $this->actingAs($this->adminUser) + ->postJson($this->baseUrl, $this->validCouponData($overrides)); + + $response->assertStatus(201); + + return (int) $response->json('data.id'); + } + + /** + * 기간 지정 쿠폰의 시작일/종료일을 비운 수정은 거부됩니다. + */ + public function test_update_period_type_without_dates_fails(): void + { + $couponId = $this->createCoupon(); + + $response = $this->actingAs($this->adminUser) + ->putJson("{$this->baseUrl}/{$couponId}", [ + 'valid_type' => 'period', + 'valid_from' => null, + 'valid_to' => null, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['valid_from', 'valid_to']); + } + + /** + * 발급일 기준 쿠폰의 유효일수를 비운 수정은 거부됩니다. + */ + public function test_update_days_from_issue_type_without_valid_days_fails(): void + { + $couponId = $this->createCoupon([ + 'valid_type' => 'days_from_issue', + 'valid_days' => 30, + 'valid_from' => null, + 'valid_to' => null, + ]); + + $response = $this->actingAs($this->adminUser) + ->putJson("{$this->baseUrl}/{$couponId}", [ + 'valid_type' => 'days_from_issue', + 'valid_days' => null, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['valid_days']); + } + + /** + * 유효기간을 함께 보내는 정상 수정은 통과합니다. + */ + public function test_update_period_type_with_dates_passes(): void + { + $couponId = $this->createCoupon(); + + $response = $this->actingAs($this->adminUser) + ->putJson("{$this->baseUrl}/{$couponId}", [ + 'valid_type' => 'period', + 'valid_from' => now()->addDay()->format('Y-m-d'), + 'valid_to' => now()->addMonths(2)->format('Y-m-d'), + ]); + + $response->assertStatus(200); + } + + /** + * valid_type 을 보내지 않는 부분 수정은 유효기간 필드를 요구하지 않습니다. + * + * required_if 는 조건 필드가 요청에 없으면 발화하지 않으므로, 다른 탭만 수정하는 + * 요청이 유효기간 때문에 막히지 않아야 합니다. + */ + public function test_update_without_valid_type_does_not_require_dates(): void + { + $couponId = $this->createCoupon(); + + $response = $this->actingAs($this->adminUser) + ->putJson("{$this->baseUrl}/{$couponId}", [ + 'min_order_amount' => 7000, + ]); + + $response->assertStatus(200); + } + + /** + * per_user_limit 을 보내지 않는 부분 수정이 통과합니다. + * + * 같은 파일의 다른 필드는 모두 sometimes 를 갖는데 per_user_limit 만 누락돼 있어 + * 부분 수정 요청이 "1인당 사용 제한" 미전송으로 422 가 되던 결함입니다. + */ + public function test_update_without_per_user_limit_passes(): void + { + $couponId = $this->createCoupon(); + + $response = $this->actingAs($this->adminUser) + ->putJson("{$this->baseUrl}/{$couponId}", [ + 'min_order_amount' => 8000, + ]); + + $response->assertStatus(200); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderListDeferredJoinTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderListDeferredJoinTest.php new file mode 100644 index 00000000..01891fd2 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderListDeferredJoinTest.php @@ -0,0 +1,199 @@ +adminUser = $this->createAdminUser(['sirsoft-ecommerce.orders.read']); + } + + /** 쿼리 로그 수집을 시작합니다. */ + private function startCapture(): void + { + $this->queries = []; + + DB::listen(function ($query) { + $this->queries[] = $query->sql; + }); + } + + /** + * 수집된 쿼리 중 조건에 맞는 것만 돌려줍니다. + * + * @param string $needle 포함 문자열 + * @return array 일치 쿼리 목록 + */ + private function queriesContaining(string $needle): array + { + return array_values(array_filter($this->queries, fn (string $sql) => str_contains($sql, $needle))); + } + + /** + * 주문 목록을 조회합니다. + * + * 페이지 번호는 페이지네이터가 요청에서 해석하므로(HTTP 경로와 동일), 저장소를 직접 + * 호출하는 테스트에서는 resolver 로 지정한다. + * + * @param array $filters 조회 필터 + * @param int $perPage 페이지당 건수 + * @param int $page 조회할 페이지 + * @return LengthAwarePaginator 조회 결과 + */ + private function listOrders(array $filters = [], int $perPage = 10, int $page = 1) + { + Paginator::currentPageResolver(fn () => $page); + + try { + return app(OrderRepositoryInterface::class)->getListWithFilters($filters, $perPage); + } finally { + Paginator::currentPageResolver(fn () => 1); + } + } + + public function test_offset_scan_reads_only_id_column(): void + { + OrderFactory::new()->count(25)->create(); + + $this->startCapture(); + $this->listOrders(page: 2); + + $offsetQueries = $this->queriesContaining('offset'); + + $this->assertNotEmpty($offsetQueries, '페이지 조회에는 OFFSET 쿼리가 있어야 한다'); + + foreach ($offsetQueries as $sql) { + // 넓은 컬럼이 OFFSET 스캔에 실리면 뒤쪽 페이지 비용이 선형으로 커진다 + $this->assertStringNotContainsString('admin_memo', $sql); + $this->assertStringNotContainsString('order_meta', $sql); + $this->assertStringNotContainsString('promotions_applied_snapshot', $sql); + $this->assertStringNotContainsString('mc_subtotal_amount', $sql); + } + } + + public function test_list_query_does_not_select_unused_wide_columns(): void + { + OrderFactory::new()->count(3)->create(); + + $this->startCapture(); + $this->listOrders(); + + $selectQueries = $this->queriesContaining('from `g7_ecommerce_orders`'); + + $this->assertNotEmpty($selectQueries); + + foreach ($selectQueries as $sql) { + $this->assertStringNotContainsString('admin_memo', $sql, '목록은 관리자 메모를 읽지 않는다'); + $this->assertStringNotContainsString('mileage_policy_snapshot', $sql); + } + } + + public function test_relation_queries_run_once_for_the_page(): void + { + OrderFactory::new()->count(5)->create(); + + $this->startCapture(); + $result = $this->listOrders(); + + // 관계는 outer 에서만 로드된다 — inner 에도 붙으면 같은 관계 쿼리가 2회 실행된다 + $this->assertCount(1, $this->queriesContaining('from `g7_ecommerce_order_options`')); + $this->assertTrue($result->getCollection()->first()->relationLoaded('options')); + $this->assertTrue($result->getCollection()->first()->relationLoaded('shippingAddress')); + } + + public function test_pages_do_not_overlap_and_follow_sort_order(): void + { + // ordered_at 이 같은 주문이 섞여 있어도 페이지 경계가 흔들리면 안 된다 + $sharedTime = now()->subDay(); + OrderFactory::new()->count(12)->create(['ordered_at' => $sharedTime]); + OrderFactory::new()->count(8)->create(); + + $page1 = $this->listOrders([], 10, 1); + $page2 = $this->listOrders([], 10, 2); + + $firstIds = $page1->getCollection()->pluck('id')->all(); + $secondIds = $page2->getCollection()->pluck('id')->all(); + + $this->assertCount(10, $firstIds); + $this->assertEmpty(array_intersect($firstIds, $secondIds), '페이지 간 중복 주문이 없어야 한다'); + $this->assertSame(20, $page1->total()); + } + + public function test_sort_by_falls_back_when_column_is_not_allowed(): void + { + OrderFactory::new()->count(3)->create(); + + // 예전에는 요청 값이 그대로 orderBy 로 흘러 SQL 오류가 났다 + $result = $this->listOrders(['sort_by' => 'admin_memo; drop table users', 'sort_order' => 'sideways']); + + $this->assertSame(3, $result->total()); + } + + public function test_filters_still_narrow_the_result(): void + { + OrderFactory::new()->count(4)->create(['order_device' => 'pc']); + OrderFactory::new()->count(3)->create(['order_device' => 'mobile']); + + $result = $this->listOrders(['order_device' => 'mobile']); + + $this->assertSame(3, $result->total()); + $this->assertCount(3, $result->getCollection()); + } + + public function test_soft_deleted_orders_stay_excluded(): void + { + $orders = OrderFactory::new()->count(5)->create(); + $orders->first()->delete(); + + $result = $this->listOrders(); + + $this->assertSame(4, $result->total()); + $this->assertNotContains($orders->first()->id, $result->getCollection()->pluck('id')->all()); + } + + public function test_admin_list_endpoint_still_returns_expected_payload(): void + { + OrderFactory::new()->count(3)->create(); + + $response = $this->actingAs($this->adminUser) + ->getJson('/api/modules/sirsoft-ecommerce/admin/orders'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + 'data' => [ + '*' => ['id', 'order_number', 'order_status', 'total_amount', 'ordered_at'], + ], + 'pagination', + ], + ]); + + $this->assertCount(3, $response->json('data.data')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserAddressUpdatePresenceValidationTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserAddressUpdatePresenceValidationTest.php new file mode 100644 index 00000000..19b9042e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserAddressUpdatePresenceValidationTest.php @@ -0,0 +1,187 @@ +create(); + $address = UserAddress::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user) + ->putJson("{$this->baseUrl}/{$address->id}", ['is_default' => true]); + + $response->assertStatus(200); + } + + /** + * 국내 주소를 통째로 비우는 수정은 거부됩니다. + */ + public function test_update_blanking_domestic_address_fails(): void + { + $user = User::factory()->create(); + $address = UserAddress::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user) + ->putJson("{$this->baseUrl}/{$address->id}", [ + 'zipcode' => '', + 'address' => '', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['zipcode', 'address']); + } + + /** + * 우편번호만 비우는 수정은 거부됩니다. + */ + public function test_update_blanking_zipcode_only_fails(): void + { + $user = User::factory()->create(); + $address = UserAddress::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user) + ->putJson("{$this->baseUrl}/{$address->id}", ['zipcode' => '']); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['zipcode']); + } + + /** + * 도로명 주소만 바꾸는 수정은 기존 우편번호를 근거로 통과합니다. + */ + public function test_update_address_only_passes_with_existing_zipcode(): void + { + $user = User::factory()->create(); + $address = UserAddress::factory()->create([ + 'user_id' => $user->id, + 'zipcode' => '06236', + ]); + + $response = $this->actingAs($user) + ->putJson("{$this->baseUrl}/{$address->id}", [ + 'address' => '서울시 강남구 테헤란로 500', + ]); + + $response->assertStatus(200); + } + + /** + * 해외 배송지 수정 요청(조회 응답 형태 그대로 PUT)이 통과합니다. + * + * 배송지 조회 응답은 해외 주소를 city/state/postal_code 키로 내보내고 폼은 intl_* + * 키로 제출하므로, 해외 주소를 수정할 때 요청에 intl_city/intl_postal_code 가 없는 + * 경우가 정상 경로에 존재합니다. 페이로드만 보고 required_with 를 걸면 이 경로가 + * 전부 422 가 됩니다. + */ + public function test_update_overseas_address_without_intl_keys_passes(): void + { + $user = User::factory()->create(); + $address = UserAddress::factory()->create([ + 'user_id' => $user->id, + 'country_code' => 'US', + 'zipcode' => null, + 'address' => null, + 'address_detail' => null, + 'address_line_1' => '350 5th Ave', + 'address_line_2' => 'Suite 1000', + 'city' => 'New York', + 'state' => 'NY', + 'postal_code' => '10118', + ]); + + $response = $this->actingAs($user) + ->putJson("{$this->baseUrl}/{$address->id}", [ + 'name' => '뉴욕 사무실', + 'recipient_name' => 'John Doe', + 'recipient_phone' => '010-1234-5678', + 'country_code' => 'US', + 'address_line_1' => '350 5th Ave', + 'address_line_2' => 'Suite 2000', + ]); + + $response->assertStatus(200); + + // 전송되지 않은 해외 도시/주/우편번호는 저장된 값이 유지되어야 한다. + // (intl_* 미전송 시 null 로 덮어써져 조용히 유실되던 회귀 방지) + $this->assertDatabaseHas('ecommerce_user_addresses', [ + 'id' => $address->id, + 'city' => 'New York', + 'state' => 'NY', + 'postal_code' => '10118', + 'address_line_2' => 'Suite 2000', + ]); + } + + /** + * 국내 주소를 해외로 전환하면서 도시/우편번호를 빠뜨리면 거부됩니다. + */ + public function test_update_switching_to_overseas_without_city_fails(): void + { + $user = User::factory()->create(); + $address = UserAddress::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user) + ->putJson("{$this->baseUrl}/{$address->id}", [ + 'country_code' => 'US', + 'zipcode' => '', + 'address' => '', + 'address_line_1' => '350 5th Ave', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['intl_city', 'intl_postal_code']); + } + + /** + * 해외 주소 3필드를 모두 채운 전환은 통과합니다. + */ + public function test_update_switching_to_overseas_with_intl_fields_passes(): void + { + $user = User::factory()->create(); + $address = UserAddress::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user) + ->putJson("{$this->baseUrl}/{$address->id}", [ + 'country_code' => 'US', + 'zipcode' => '', + 'address' => '', + 'address_line_1' => '350 5th Ave', + 'intl_city' => 'New York', + 'intl_state' => 'NY', + 'intl_postal_code' => '10118', + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('ecommerce_user_addresses', [ + 'id' => $address->id, + 'country_code' => 'US', + 'city' => 'New York', + 'postal_code' => '10118', + ]); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_AddProductReviewSortIndexes.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_AddProductReviewSortIndexes.php new file mode 100644 index 00000000..5a84ab61 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_AddProductReviewSortIndexes.php @@ -0,0 +1,78 @@ + 컬럼 목록) + * + * @var array> + */ + private const INDEXES = [ + 'idx_reviews_product_status_deleted_created' => ['product_id', 'status', 'deleted_at', 'created_at'], + // 관리자 목록: status 는 선택적 필터라 선행 컬럼으로 두면 필터 없는 기본 진입에서 + // created_at 정렬을 못 덮는다. 항상 IS NULL 로 붙는 deleted_at 을 선행에 둔다. + 'idx_reviews_deleted_created' => ['deleted_at', 'created_at'], + ]; + + /** + * 마이그레이션 식별자를 반환합니다. + * + * @return string 마이그레이션 이름 + */ + public function name(): string + { + return 'AddProductReviewSortIndexes'; + } + + /** + * 정렬 인덱스를 추가합니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! Schema::hasTable(self::TABLE)) { + $context->logger->info('[ecommerce:1.0.5] 상품 리뷰 테이블 부재 — 정렬 인덱스 추가 스킵'); + + return; + } + + $existing = array_column(Schema::getIndexes(self::TABLE), 'name'); + + foreach (self::INDEXES as $name => $columns) { + if (in_array($name, $existing, true)) { + $context->logger->info("[ecommerce:1.0.5] 이미 존재하는 인덱스 — 스킵: {$name}"); + + continue; + } + + $context->logger->info("[ecommerce:1.0.5] 정렬 인덱스 추가 시작: {$name} (리뷰가 많으면 수 분 걸릴 수 있고 그동안 리뷰 쓰기가 대기합니다)"); + + Schema::table(self::TABLE, function ($table) use ($columns, $name) { + $table->index($columns, $name); + }); + + $context->logger->info("[ecommerce:1.0.5] 정렬 인덱스 추가 완료: {$name}"); + } + } +} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 8d663bb7..f8ef96fa 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -18,6 +18,7 @@ ### Changed - 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 페이지 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 항목의 본문까지 함께 읽어 느려졌지만, 이제 현재 페이지의 항목만 본문을 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. - 첨부파일 저장 디스크 설정 항목을 제거했습니다. 설정 파일 자체가 그 디스크에 저장되는 구조라 값을 바꿔도 반영될 수 없는 항목이었습니다(기존에도 항상 기본값으로 동작). 저장 위치는 서버 파일 저장소 설정으로 관리합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php index be5c37f5..053da489 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php @@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Page\Repositories; use App\Helpers\PermissionHelper; use App\Repositories\Concerns\HasMultipleSearchFilters; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; use App\Search\Engines\DatabaseFulltextEngine; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -20,6 +22,11 @@ use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; class PageRepository implements PageRepositoryInterface { use HasMultipleSearchFilters; + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (PageListRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = ['created_at', 'published_at']; /** * 페이지 목록을 페이지네이션하여 조회합니다. @@ -39,15 +46,22 @@ class PageRepository implements PageRepositoryInterface // - slug : 슬러그만 // - all : 제목 + 슬러그 (본문 content 은 검색 대상 아님 — UI '제목 또는 슬러그로 검색') // (Scout 콜백에 slug orWhere 를 넣으면 total 카운트가 부풀려지는 회귀가 있어 #225 에서 제거됨) - $query = Page::with(['creator', 'updater']); + $query = Page::query(); // 권한 스코프 필터링 PermissionHelper::applyPermissionScope($query, 'sirsoft-page.pages.read'); $this->applyFilters($query, $filters); - $this->applySorting($query, $filters); - return $query->paginate($perPage); + // 지연 조인: 본문 content(longText)는 목록에 쓰이지 않는데도 OFFSET 이 훑는 + // 모든 행에서 함께 읽힌다. inner 는 id 만 훑고 본문은 이번 페이지에서만 읽는다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $this->resolveListSortSpec($filters), + perPage: $perPage, + relations: ['creator', 'updater'], + ); } /** @@ -170,6 +184,9 @@ class PageRepository implements PageRepositoryInterface { $results = $this->buildKeywordQuery($keyword) ->orderBy($orderBy, $direction) + // audit:allow repository-paginate-column-pruning reason: 통합검색 전용 진입점 — + // 유일한 호출자(SearchPagesListener)가 전체 탭은 limit=5, 페이지 탭은 limit=PHP_INT_MAX 로 + // 1페이지에 결과를 받아가므로 OFFSET 이 발생하지 않는다 ->paginate($limit); return [ @@ -224,14 +241,20 @@ class PageRepository implements PageRepositoryInterface */ private function applySorting($query, array $filters): void { - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = strtolower($filters['sort_order'] ?? 'desc'); - - if (! in_array($sortOrder, ['asc', 'desc'])) { - $sortOrder = 'desc'; + foreach ($this->resolveListSortSpec($filters) as $sort) { + $query->orderBy($sort['column'], $sort['direction']); } + } - $query->orderBy($sortBy, $sortOrder); + /** + * 목록 정렬 스펙을 허용 컬럼 화이트리스트로 해석합니다. + * + * @param array $filters 필터 조건 + * @return array 정렬 스펙 + */ + private function resolveListSortSpec(array $filters): array + { + return $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at'); } /** diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 4ff710e5..2d159329 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.2] - 2026-07-27 +## [1.0.2] - 2026-07-30 ### Added @@ -23,6 +23,9 @@ - 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시). - 관리자 환경설정의 「쿠키 배너 노출」 안내 문구를 실제 동작에 맞게 바로잡았습니다 — 이 토글은 배너와 자동 차단만 제어하며, 마이페이지 「쿠키 동의 관리」 카드는 동의·철회 이력이 있는 회원에게 이 토글과 무관하게 항상 노출됩니다. - 사이트 언어를 바꾼 뒤 쿠키 동의를 저장하면 차단 해제·재차단이 즉시 반영되지 않던 문제를 수정했습니다. 페이지를 새로 고쳐야 반영되던 증상입니다. +- 관리자 「GDPR 동의 이력」 목록이 뒤쪽 페이지로 갈수록 느려지던 문제를 개선했습니다. 이력이 많이 쌓인 사이트에서 특히 체감됩니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 정책 버전 이력 목록에서 화면에 쓰지 않는 발행 시점 스냅샷 본문을 더 이상 함께 읽지 않습니다. 목록 조회가 가벼워집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 코어 최소 요구 버전을 7.0.6 으로 상향했습니다. 이 플러그인을 업데이트하기 전에 코어를 먼저 최신으로 업데이트하세요. ### Fixed diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.json b/plugins/_bundled/sirsoft-gdpr/plugin.json index a21d784b..0c8f2fe3 100644 --- a/plugins/_bundled/sirsoft-gdpr/plugin.json +++ b/plugins/_bundled/sirsoft-gdpr/plugin.json @@ -11,7 +11,7 @@ "en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal." }, "license": "MIT", - "g7_version": ">=7.0.5", + "g7_version": ">=7.0.6", "dependencies": { "modules": {}, "plugins": {} diff --git a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprPolicyVersionRepository.php b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprPolicyVersionRepository.php index 2b9533b9..78c04fcd 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprPolicyVersionRepository.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprPolicyVersionRepository.php @@ -28,7 +28,7 @@ class GdprPolicyVersionRepository implements GdprPolicyVersionRepositoryInterfac * * createdBy 관계를 eager load 하여 admin snapshot 모달의 발행자 표시에 활용. * - * @param int $version 조회할 정책 버전 정수 + * @param int $version 조회할 정책 버전 정수 * @return GdprPolicyVersion|null 해당 버전 row 가 없으면 null */ public function getByVersion(int $version): ?GdprPolicyVersion @@ -54,7 +54,7 @@ class GdprPolicyVersionRepository implements GdprPolicyVersionRepositoryInterfac /** * 관리자 화면용 페이지네이션 조회 (version DESC). * - * @param int $perPage 페이지당 행 수 (1~100 사이로 clamp) + * @param int $perPage 페이지당 행 수 (1~100 사이로 clamp) * @return LengthAwarePaginator */ public function paginate(int $perPage): LengthAwarePaginator @@ -62,13 +62,19 @@ class GdprPolicyVersionRepository implements GdprPolicyVersionRepositoryInterfac return GdprPolicyVersion::query() ->with('createdBy') ->orderByDesc('version') - ->paginate(max(1, min(100, $perPage))); + // audit:allow repository-paginate-column-pruning reason: 발행 이력은 정책 변경 시에만 + // 늘어나는 정의성 테이블이라 깊은 OFFSET 이 성립하지 않는다. 넓은 컬럼(snapshot JSON)은 + // 아래 컬럼 목록에서 제외했고, 본문은 상세 조회(getByVersion)에서만 읽는다. + ->paginate( + max(1, min(100, $perPage)), + ['id', 'version', 'change_type', 'memo', 'created_by', 'created_at'], + ); } /** * 새 정책 버전 행을 생성합니다. * - * @param array $data ['version', 'change_type', 'memo', 'snapshot', 'created_by'] + * @param array $data ['version', 'change_type', 'memo', 'snapshot', 'created_by'] * @return GdprPolicyVersion */ public function create(array $data): GdprPolicyVersion diff --git a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentHistoryRepository.php b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentHistoryRepository.php index 84efb4b8..04ad376a 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentHistoryRepository.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentHistoryRepository.php @@ -2,6 +2,7 @@ namespace Plugins\Sirsoft\Gdpr\Repositories; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Plugins\Sirsoft\Gdpr\Models\GdprUserConsentHistory; @@ -12,10 +13,31 @@ use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepository */ class GdprUserConsentHistoryRepository implements GdprUserConsentHistoryRepositoryInterface { + use PaginatesWithDeferredJoin; + + /** + * 관리자 목록이 실제로 표시하는 컬럼 (GdprConsentLogResource 와 1:1). + * + * @var array + */ + private const LIST_COLUMNS = [ + 'id', + 'user_id', + 'session_id', + 'consent_key', + 'action', + 'source', + 'policy_version', + 'categories', + 'ip_address', + 'user_agent', + 'created_at', + ]; + /** * 동의 이력 레코드를 생성합니다. * - * @param array $data 이력 데이터 + * @param array $data 이력 데이터 * @return GdprUserConsentHistory */ public function record(array $data): GdprUserConsentHistory @@ -32,10 +54,8 @@ class GdprUserConsentHistoryRepository implements GdprUserConsentHistoryReposito */ public function paginateForAdmin(array $filters, int $perPage): LengthAwarePaginator { - $query = GdprUserConsentHistory::query() - ->with('user') - ->orderByDesc('created_at') - ->orderByDesc('id'); + // 정렬/관계는 지연 조인이 담당한다 (inner 는 키 컬럼만 조회) + $query = GdprUserConsentHistory::query(); if (! empty($filters['email'])) { $email = (string) $filters['email']; @@ -63,13 +83,22 @@ class GdprUserConsentHistoryRepository implements GdprUserConsentHistoryReposito $query->whereIn('source', $filters['sources']); } - return $query->paginate(max(1, min(100, $perPage))); + // 동의 이력은 방문자의 동의 변경마다 한 행씩 쌓여 상한이 없다. 뒤쪽 페이지에서 + // OFFSET 이 커져도 넓은 컬럼(categories JSON / user_agent)을 읽는 행 수가 페이지 + // 크기로 고정되도록 지연 조인을 쓴다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: self::LIST_COLUMNS, + sort: [['column' => 'created_at', 'direction' => 'desc']], + perPage: max(1, min(100, $perPage)), + relations: ['user'], + ); } /** * 사용자 ID로 동의 이력을 조회합니다 (최신순). * - * @param int $userId 사용자 ID + * @param int $userId 사용자 ID * @return Collection */ public function getByUserId(int $userId): Collection @@ -83,7 +112,7 @@ class GdprUserConsentHistoryRepository implements GdprUserConsentHistoryReposito /** * 게스트 세션 ID로 동의 이력을 조회합니다 (최신순). * - * @param string $sessionId 게스트 세션 ID + * @param string $sessionId 게스트 세션 ID * @return Collection */ public function getBySessionId(string $sessionId): Collection @@ -97,7 +126,7 @@ class GdprUserConsentHistoryRepository implements GdprUserConsentHistoryReposito /** * 사용자 식별 정보를 NULL로 익명화합니다 (사용자 완전 삭제 시 감사 추적용). * - * @param int $userId 사용자 ID + * @param int $userId 사용자 ID * @return int 영향받은 행 수 */ public function anonymizeForUser(int $userId): int diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index bf69da51..e62aea59 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -37,6 +37,8 @@ - 장바구니에서 옵션을 변경할 때 옵션 목록이 아직 준비되지 않은 상태면 화면이 비어 보이던 문제를 수정했습니다. - 모달 창의 닫기 버튼 안내 라벨이 번역되지 않아 화면 낭독기에서 내부 식별자가 그대로 읽히던 문제를 수정했습니다. - 본인확인 코드 입력 화면에서 "남은 시도" 안내를 제거했습니다. 외부 인증 화면을 거쳐 돌아온 경우 실제보다 많은 횟수가 표시될 수 있어, 잘못된 숫자를 보여주는 대신 표시하지 않도록 했습니다. 시도 횟수 제한 자체는 종전대로 적용됩니다. +- 해외 배송지를 수정할 때 도시·주·우편번호 입력칸이 비어 보이고, 그대로 저장하면 저장돼 있던 값이 사라지던 문제를 수정했습니다. 마이페이지 배송지 관리와 주문서의 배송지 관리 모두에 적용됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 마이페이지 배송지 목록에서 해외 배송지가 주소 대신 "[]" 로만 표시되던 문제를 수정했습니다. 이제 국가에 맞는 전체 주소가 표시됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) ### Changed diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/mypage/addresses/_list.json b/templates/_bundled/sirsoft-basic/layouts/partials/mypage/addresses/_list.json index 0471ce95..01c3c182 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/mypage/addresses/_list.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/mypage/addresses/_list.json @@ -32,7 +32,7 @@ } }, "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "plus", "className": "w-4 h-4" } }, + { "type": "basic", "name": "Icon", "props": { "name": "plus", "className": "text-base" } }, { "type": "basic", "name": "Span", "text": "$t:mypage.addresses.add" } ], "actions": [ @@ -65,7 +65,10 @@ "type": "basic", "name": "Div", "iteration": { "source": "{{addresses?.data?.addresses?.data ?? []}}", "item_var": "address", "index_var": "addressIdx" }, - "props": { "className": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800" }, + "props": { + "className": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800", + "data-testid": "address-card" + }, "responsive": { "portable": { "props": { "className": "border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-white dark:bg-gray-800" } @@ -96,7 +99,7 @@ "if": "{{address.is_default}}", "props": { "className": "inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded font-medium" }, "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "star", "className": "w-3 h-3" } }, + { "type": "basic", "name": "Icon", "props": { "name": "star", "className": "text-xs" } }, { "type": "basic", "name": "Span", "text": "$t:mypage.addresses.default" } ] } @@ -123,7 +126,30 @@ "type": "click", "handler": "sequence", "actions": [ - { "handler": "setState", "params": { "target": "global", "editingAddress": "{{address}}" } }, + { + "comment": "조회 응답은 해외 주소를 city/state/postal_code 로 내려주고 폼은 intl_* 키로 제출한다. 원본 객체를 그대로 넣으면 해외 도시/주/우편번호 입력칸이 빈 값으로 보인다 (체크아웃 배송지 매핑과 동일 규칙).", + "handler": "setState", + "params": { + "target": "global", + "editingAddress": { + "id": "{{address.id}}", + "name": "{{address.name ?? ''}}", + "recipient_name": "{{address.recipient_name ?? ''}}", + "recipient_phone": "{{address.recipient_phone ?? ''}}", + "country_code": "{{address.country_code ?? 'KR'}}", + "zipcode": "{{address.zipcode ?? ''}}", + "address": "{{address.address ?? ''}}", + "address_detail": "{{address.address_detail ?? ''}}", + "city": "{{address.city ?? ''}}", + "address_line_1": "{{address.address_line_1 ?? ''}}", + "address_line_2": "{{address.address_line_2 ?? ''}}", + "intl_city": "{{address.city ?? ''}}", + "intl_state": "{{address.state ?? ''}}", + "intl_postal_code": "{{address.postal_code ?? ''}}", + "is_default": "{{address.is_default ?? false}}" + } + } + }, { "handler": "openModal", "target": "addressModal" } ] } @@ -162,11 +188,20 @@ "text": "{{address.recipient_name}} | {{address.recipient_phone}}" }, { - "comment": "주소", + "comment": "주소 (국내: [우편번호] 도로명 상세)", "type": "basic", "name": "P", + "if": "{{(address.country_code ?? 'KR') === 'KR'}}", "props": { "className": "text-sm text-gray-600 dark:text-gray-400 break-words" }, "text": "[{{address.zipcode}}] {{address.address}} {{address.address_detail}}" + }, + { + "comment": "주소 (해외: 서버가 조립한 full_address — 국내 전용 형식으로 찍으면 '[]' 만 보인다)", + "type": "basic", + "name": "P", + "if": "{{(address.country_code ?? 'KR') !== 'KR'}}", + "props": { "className": "text-sm text-gray-600 dark:text-gray-400 break-words" }, + "text": "{{address.full_address ?? ''}}" } ] } @@ -187,10 +222,10 @@ { "type": "basic", "name": "Icon", - "props": { "name": "map-pin", "className": "w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" }, + "props": { "name": "map-pin", "className": "text-6xl mx-auto text-gray-300 dark:text-gray-600 mb-4" }, "responsive": { "portable": { - "props": { "name": "map-pin", "className": "w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" } + "props": { "name": "map-pin", "className": "text-5xl mx-auto text-gray-300 dark:text-gray-600 mb-3" } } } }, @@ -205,7 +240,7 @@ "name": "Button", "props": { "className": "inline-flex items-center gap-2 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg text-sm font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors" }, "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "plus", "className": "w-4 h-4" } }, + { "type": "basic", "name": "Icon", "props": { "name": "plus", "className": "text-base" } }, { "type": "basic", "name": "Span", "text": "$t:mypage.addresses.add" } ], "actions": [ diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_modal_address_manage.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_modal_address_manage.json index b010d77c..8814fffd 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_modal_address_manage.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_modal_address_manage.json @@ -270,7 +270,23 @@ "handler": "setState", "params": { "target": "local", - "editingAddress": "{{addr}}", + "editingAddress": { + "id": "{{addr.id}}", + "name": "{{addr.name ?? ''}}", + "recipient_name": "{{addr.recipient_name ?? ''}}", + "recipient_phone": "{{addr.recipient_phone ?? ''}}", + "country_code": "{{addr.country_code ?? 'KR'}}", + "zipcode": "{{addr.zipcode ?? ''}}", + "address": "{{addr.address ?? ''}}", + "address_detail": "{{addr.address_detail ?? ''}}", + "city": "{{addr.city ?? ''}}", + "address_line_1": "{{addr.address_line_1 ?? ''}}", + "address_line_2": "{{addr.address_line_2 ?? ''}}", + "intl_city": "{{addr.city ?? ''}}", + "intl_state": "{{addr.state ?? ''}}", + "intl_postal_code": "{{addr.postal_code ?? ''}}", + "is_default": "{{addr.is_default ?? false}}" + }, "addressModalMode": "form", "addressErrors": null } diff --git a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/address-edit-intl-field-mapping.test.tsx b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/address-edit-intl-field-mapping.test.tsx new file mode 100644 index 00000000..a47f460e --- /dev/null +++ b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/address-edit-intl-field-mapping.test.tsx @@ -0,0 +1,112 @@ +/** + * @file address-edit-intl-field-mapping.test.tsx + * @description 배송지 수정 진입 시 해외 주소 필드가 폼 키(intl_*)로 매핑되는지 검증 + * + * 배경: 배송지 조회 응답은 해외 주소를 city/state/postal_code 로 내려주지만 주소 폼의 + * 입력칸 이름은 intl_city/intl_state/intl_postal_code 다. 수정 진입에서 조회 응답 + * 객체를 그대로 editingAddress 에 넣으면 해외 도시/주/우편번호 입력칸이 빈 값으로 + * 보이고, 그 상태로 저장하면 저장돼 있던 값이 함께 유실된다. + * 체크아웃 배송지 선택(_checkout_shipping.json)은 이미 같은 매핑을 하고 있었고, + * 마이페이지 목록과 체크아웃 배송지 관리 모달의 "수정" 진입만 누락돼 있었다. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; + +import mypageListPartial from '../../../layouts/partials/mypage/addresses/_list.json'; +import checkoutAddressModal from '../../../layouts/partials/shop/_modal_address_manage.json'; + +/** 레이아웃 트리를 순회하며 조건에 맞는 노드를 모두 수집한다 */ +function collectNodes(node: unknown, predicate: (value: Record) => boolean): Record[] { + const found: Record[] = []; + + const walk = (current: unknown): void => { + if (Array.isArray(current)) { + current.forEach(walk); + + return; + } + + if (current === null || typeof current !== 'object') { + return; + } + + const value = current as Record; + + if (predicate(value)) { + found.push(value); + } + + Object.values(value).forEach(walk); + }; + + walk(node); + + return found; +} + +/** editingAddress 를 객체로 설정하는 setState 액션을 찾는다 */ +function findEditingAddressAssignments(layout: unknown): Record[] { + return collectNodes(layout, (node) => { + if (node.handler !== 'setState') { + return false; + } + + const params = node.params as Record | undefined; + + return Boolean(params && typeof params.editingAddress === 'object' && params.editingAddress !== null); + }); +} + +/** + * 기존 배송지를 채워 넣는 "수정" 진입 액션만 추린다. + * + * 같은 레이아웃의 "새 배송지 추가" 액션도 editingAddress 를 객체로 설정하므로 + * (`{ is_default: false }`) 필드 매핑 검사 대상에서 제외해야 한다. + */ +function findEditAssignments(layout: unknown): Record[] { + return findEditingAddressAssignments(layout) + .map((assignment) => (assignment.params as Record).editingAddress as Record) + .filter((editingAddress) => typeof editingAddress.intl_city === 'string'); +} + +describe('배송지 수정 진입 — 해외 주소 필드 매핑', () => { + it('마이페이지 목록의 수정 액션이 intl_* 키를 city/state/postal_code 에서 채운다', () => { + const editAssignments = findEditAssignments(mypageListPartial); + + expect(editAssignments.length).toBeGreaterThan(0); + + const editingAddress = editAssignments[0]; + + expect(editingAddress.intl_city).toContain('address.city'); + expect(editingAddress.intl_state).toContain('address.state'); + expect(editingAddress.intl_postal_code).toContain('address.postal_code'); + }); + + it('마이페이지 목록의 수정 액션이 폼이 요구하는 나머지 키도 함께 채운다', () => { + const editingAddress = findEditAssignments(mypageListPartial)[0]; + + // id 가 없으면 저장이 POST(신규 등록)로 나가 수정이 등록으로 바뀐다 + expect(editingAddress.id).toContain('address.id'); + + ['name', 'recipient_name', 'recipient_phone', 'country_code', 'zipcode', 'address', 'address_detail', 'address_line_1', 'address_line_2'].forEach( + (key) => { + expect(editingAddress[key]).toBeDefined(); + } + ); + }); + + it('체크아웃 배송지 관리 모달의 수정 액션도 동일하게 매핑한다', () => { + const editAssignments = findEditAssignments(checkoutAddressModal); + + expect(editAssignments.length).toBeGreaterThan(0); + + const editingAddress = editAssignments[0]; + + expect(editingAddress.intl_city).toContain('addr.city'); + expect(editingAddress.intl_state).toContain('addr.state'); + expect(editingAddress.intl_postal_code).toContain('addr.postal_code'); + expect(editingAddress.id).toContain('addr.id'); + }); +}); diff --git a/templates/_bundled/sirsoft-basic/tests/Playwright/specs/mypage-address-overseas-edit.spec.ts b/templates/_bundled/sirsoft-basic/tests/Playwright/specs/mypage-address-overseas-edit.spec.ts new file mode 100644 index 00000000..ec1137da --- /dev/null +++ b/templates/_bundled/sirsoft-basic/tests/Playwright/specs/mypage-address-overseas-edit.spec.ts @@ -0,0 +1,158 @@ +/** + * 마이페이지 배송지 수정 — 해외 주소 필드 표시/보존 E2E. + * 템플릿 sirsoft-basic (유저 화면). + * + * @scenario address-overseas-edit + * @effects overseas_edit_form_prefills_intl_fields, + * overseas_edit_save_preserves_city_state_postal_code + * + * 배경: 배송지 조회 응답은 해외 주소를 city/state/postal_code 로 내려주지만 주소 폼의 + * 입력칸 이름은 intl_city/intl_state/intl_postal_code 다. 수정 진입에서 조회 응답 + * 객체를 그대로 editingAddress 에 넣던 탓에 해외 도시/주/우편번호 입력칸이 빈 값으로 + * 보였고, 그대로 저장하면 서버가 그 세 값을 null 로 덮어써 조용히 유실됐다. + * (백엔드는 MapsAddressBookFields 가 미전송 intl_* 를 덮어쓰지 않도록 함께 수정) + * + * 매트릭스: + * T1 해외 배송지 수정 진입 → intl_city/intl_state/intl_postal_code 입력칸에 저장값이 채워져 있다 + * T2 도시/주/우편번호를 건드리지 않고 저장 → 저장된 값이 그대로 유지된다 + * T3 목록 카드가 해외 주소를 국내 전용 형식('[] ')이 아니라 전체 주소로 표시한다 + * + * 라벨 매칭은 로케일 비의존으로 둔다 (사이트 기본 언어가 ko/en 어느 쪽이든 동작). + * + * 실행: + * $env:PLAYWRIGHT_BASE_URL='https://example.com' + * npx playwright test --config templates/_bundled/sirsoft-basic/tests/Playwright/playwright.config.ts mypage-address-overseas-edit + */ +import { test, expect, type APIRequestContext } from '@playwright/test'; +import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth'; + +const ADDRESS_API = '/api/modules/sirsoft-ecommerce/user/addresses'; + +/** 검증용 해외 배송지를 API 로 생성하고 ID 를 돌려준다 */ +async function createOverseasAddress(request: APIRequestContext, token: string, name: string): Promise { + const response = await request.post(ADDRESS_API, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + data: { + name, + recipient_name: 'John Doe', + recipient_phone: '010-1234-5678', + country_code: 'US', + address_line_1: '350 5th Ave', + address_line_2: 'Suite 1000', + intl_city: 'New York', + intl_state: 'NY', + intl_postal_code: '10118', + }, + }); + + expect(response.status(), await response.text()).toBe(201); + + return response.json().then((body) => body.data.address.id); +} + +/** 배송지 단건을 API 로 조회한다 */ +async function fetchAddress(request: APIRequestContext, token: string, id: number): Promise> { + const response = await request.get(`${ADDRESS_API}/${id}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + + expect(response.ok(), await response.text()).toBe(true); + + const body = await response.json(); + + return body.data.address; +} + +/** 로케일 비의존 라벨 매처 */ +const EDIT_LABEL = /^(수정|Edit)$/; +const SAVE_LABEL = /^(저장|Save)$/; + +/** 배송지명으로 카드 전체를 집는다 (카드 루트에 data-testid="address-card") */ +function addressCard(page: import('@playwright/test').Page, addressName: string) { + return page.getByTestId('address-card').filter({ hasText: addressName }); +} + +/** GDPR 쿠키 배너가 떠 있으면 클릭을 가로채므로 먼저 닫는다 */ +async function dismissCookieNotice(page: import('@playwright/test').Page): Promise { + const necessaryOnly = page.getByRole('button', { name: /Necessary Only|필수만/ }); + + if (await necessaryOnly.isVisible().catch(() => false)) { + await necessaryOnly.click(); + } +} + +test.describe('마이페이지 해외 배송지 수정', () => { + let token: string; + + test.beforeAll(() => { + // 유저 화면 흐름이라 관리자 권한은 필요 없다 (인증만 필요). + token = issueToken('sirsoft-ecommerce.user.addresses.self'); + }); + + test('T1 수정 진입 시 해외 도시/주/우편번호 입력칸에 저장값이 채워진다', async ({ page, request }) => { + const addressName = `E2E 해외 ${Date.now()}`; + await createOverseasAddress(request, token, addressName); + + await authenticatePage(page, token); + await page.goto('/mypage/addresses'); + await dismissCookieNotice(page); + + const card = addressCard(page, addressName); + + await card.getByRole('button', { name: EDIT_LABEL }).click(); + + await expect(page.locator('input[name="intl_city"]')).toHaveValue('New York'); + await expect(page.locator('input[name="intl_state"]')).toHaveValue('NY'); + await expect(page.locator('input[name="intl_postal_code"]')).toHaveValue('10118'); + }); + + test('T2 도시/주/우편번호를 건드리지 않고 저장해도 값이 유지된다', async ({ page, request }) => { + const addressName = `E2E 해외 보존 ${Date.now()}`; + const addressId = await createOverseasAddress(request, token, addressName); + + await authenticatePage(page, token); + await page.goto('/mypage/addresses'); + await dismissCookieNotice(page); + + const card = addressCard(page, addressName); + + await card.getByRole('button', { name: EDIT_LABEL }).click(); + await expect(page.locator('input[name="intl_city"]')).toHaveValue('New York'); + + // 주소 2행만 바꾸고 저장 — 도시/주/우편번호는 손대지 않는다 + await page.locator('input[name="address_line_2"]').fill('Suite 2000'); + + const [response] = await Promise.all([ + page.waitForResponse((r) => r.url().includes(ADDRESS_API) && r.request().method() === 'PUT'), + page.getByRole('button', { name: SAVE_LABEL }).click(), + ]); + + expect(response.status()).toBe(200); + + const saved = await fetchAddress(request, token, addressId); + + expect(saved.city).toBe('New York'); + expect(saved.state).toBe('NY'); + expect(saved.postal_code).toBe('10118'); + expect(saved.address_line_2).toBe('Suite 2000'); + }); + + test('T3 목록 카드가 해외 주소를 전체 주소로 표시한다', async ({ page, request }) => { + const addressName = `E2E 해외 표시 ${Date.now()}`; + await createOverseasAddress(request, token, addressName); + + await authenticatePage(page, token); + await page.goto('/mypage/addresses'); + await dismissCookieNotice(page); + + // 클릭 단계가 없는 검사라 목록 하이드레이션 완료를 명시적으로 기다린다 + await expect(page.getByTestId('address-card').first()).toBeVisible({ timeout: 15000 }); + + const card = addressCard(page, addressName); + + await expect(card).toContainText('350 5th Ave'); + await expect(card).toContainText('New York'); + // 국내 전용 형식으로 찍히면 우편번호 자리만 남아 "[]" 가 노출된다 + await expect(card).not.toContainText('[]'); + }); +}); diff --git a/tests/Playwright/specs/pagination/deep-offset-list.spec.ts b/tests/Playwright/specs/pagination/deep-offset-list.spec.ts new file mode 100644 index 00000000..4c44a614 --- /dev/null +++ b/tests/Playwright/specs/pagination/deep-offset-list.spec.ts @@ -0,0 +1,235 @@ +/** + * E2E: 지연 조인 전환 후 목록 페이지네이션 종단 확인 + * + * 목록 조회가 "이번 페이지의 ID 를 먼저 구하고, 그 ID 에 대해서만 목록 컬럼과 관계를 읽는" + * 2단계로 바뀌었다. 단위 테스트는 쿼리 구조와 ID 시퀀스를 잠그지만, 실제 화면에서 드러나는 + * 증상(마지막 페이지에서 "다음" 이 계속 눌리거나, 페이지 이동 후 목록이 비어 보이거나, + * 콘솔에 렌더 에러가 남는 것)은 브라우저 레벨에서만 잡힌다. + * + * 계획서의 브라우저 확인 항목을 화면별로 잠근다: + * (1) 목록이 렌더된다 (관계/컬럼 프루닝으로 화면이 비지 않는다) + * (2) 마지막 페이지에서 "다음" 이 비활성이다 (총 건수 계산 정합) + * (3) 콘솔/페이지 에러가 없다 + * + * 데이터 의존 스킵: 목록이 한 페이지 분량이면 페이지네이션 컨트롤이 없으므로 (2)를 건너뛴다. + * 스킵 사유는 실행 로그에 남는다 — 조용한 통과가 아니다. + * + * @scenario case=deep_offset_list_browser + * + * @effects pages_do_not_overlap_or_drop_rows, simple_mode_reports_last_page_without_next + */ +import type { Page } from '@playwright/test'; +import { test, expect, authenticatePage, issueToken } from '../../fixtures/auth'; + +/** 점검 대상 화면 — 계획서가 지목한 4개 목록 */ +const LIST_SCREENS = [ + { name: '활동 로그', path: '/admin/activity-logs', permissions: ['core.activities.read'] }, + { name: '알림 발송 이력', path: '/admin/notification-logs', permissions: ['core.notification-logs.read'] }, + { name: '관리자 주문 목록', path: '/admin/ecommerce/orders', permissions: ['sirsoft-ecommerce.orders.read'] }, + { + name: '게시판 게시글 목록', + path: '/admin/board/notice', + permissions: ['sirsoft-board.notice.admin.posts.read', 'sirsoft-board.notice.admin.manage'], + }, +] as const; + +/** + * 관리자 화면 진입 공통 대기 (기존 admin spec 과 동일 패턴). + * + * `networkidle` 은 폴링/실시간 연결이 있는 화면에서 영영 idle 이 되지 않아 쓰지 않는다. + * + * @param page 대상 페이지 + * @param path 이동할 경로 + */ +async function gotoAdmin(page: Page, path: string): Promise { + await page.goto(path); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await page.waitForFunction( + () => !window.location.pathname.includes('/admin/login') || document.readyState === 'complete', + { timeout: 15_000 }, + ); +} + +/** + * 페이지에서 수집한 콘솔/페이지 에러를 담을 배열을 부착합니다. + * + * @param page 대상 페이지 + * @returns 수집된 에러 메시지 배열 (테스트 진행에 따라 채워진다) + */ +function collectPageErrors(page: Page): string[] { + const errors: string[] = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()); + } + }); + page.on('pageerror', (error) => { + errors.push(error.message); + }); + + return errors; +} + +/** + * 목록 데이터가 도착할 때까지 기다립니다. + * + * 진입 직후에는 목록이 비어 있어 페이지네이션이 아직 렌더되지 않는다. 데이터 로드를 + * 기다리지 않고 컨트롤 개수를 세면 "컨트롤 없음" 으로 잘못 판정해 조용히 스킵된다. + * + * @param page 대상 페이지 + * @returns 행이 하나라도 렌더되면 true + */ +async function waitForListRows(page: Page): Promise { + return page + .locator('tbody tr, [data-testid$="-row"]') + .first() + .waitFor({ state: 'attached', timeout: 20_000 }) + .then(() => true) + .catch(() => false); +} + +/** + * 페이지네이션의 "다음" 컨트롤을 찾습니다. + * + * `aria-label` 은 앱 로케일에 따라 한국어("다음 페이지")로도, 언어팩이 아직 붙지 않은 + * 시점에는 기본 영문("Next page")으로도 렌더된다. 한쪽만 보면 컨트롤이 있는데도 + * "없음" 으로 판정해 조용히 스킵되므로 양쪽을 모두 매칭한다. + * + * @param page 대상 페이지 + * @returns 다음 페이지 컨트롤 locator + */ +function nextPageControl(page: Page) { + return page + .locator( + [ + '[aria-label="다음 페이지"]', + '[aria-label="Next page"]', + '[data-testid="pagination-next"]', + 'button:has-text("다음")', + ].join(', '), + ) + .first(); +} + +/** + * 페이지네이션의 "마지막 페이지" 컨트롤을 찾습니다. + * + * @param page 대상 페이지 + * @returns 마지막 페이지 컨트롤 locator + */ +function lastPageControl(page: Page) { + return page.locator('[aria-label="마지막 페이지"], [aria-label="Last page"]').first(); +} + +/** + * "다음" 컨트롤이 비활성인지 판정합니다. + * + * @param page 대상 페이지 + * @returns 비활성이면 true + */ +async function isNextDisabled(page: Page): Promise { + const next = nextPageControl(page); + + if ((await next.count()) === 0) { + return true; + } + + if (await next.isDisabled().catch(() => false)) { + return true; + } + + return (await next.getAttribute('aria-disabled')) === 'true'; +} + +test.describe('목록 페이지네이션 (지연 조인)', () => { + for (const screen of LIST_SCREENS) { + // @scenario case=deep_offset_list_browser + // @effects pages_do_not_overlap_or_drop_rows + test(`${screen.name} — 목록이 렌더되고 페이지 에러가 없다`, async ({ page }) => { + const errors = collectPageErrors(page); + + await authenticatePage(page, issueToken(...screen.permissions)); + await gotoAdmin(page, screen.path); + + if (/\/admin\/login/.test(page.url())) { + test.skip(true, `${screen.name} 접근 불가 — 확장 미설치 또는 권한 미부여`); + + return; + } + + // 목록 컨테이너가 떠야 한다 — 렌더 자체가 깨지면 여기서 잡힌다. + await expect( + page.locator('table, [role="table"], [data-testid$="-list"]').first(), + ).toBeAttached({ timeout: 20_000 }); + + await waitForListRows(page); + + expect(errors, `페이지 에러: ${errors.join(' | ')}`).toHaveLength(0); + }); + + // @scenario case=deep_offset_list_browser + // @effects simple_mode_reports_last_page_without_next + test(`${screen.name} — 마지막 페이지에서 "다음" 이 비활성이다`, async ({ page }) => { + await authenticatePage(page, issueToken(...screen.permissions)); + await gotoAdmin(page, screen.path); + + if (/\/admin\/login/.test(page.url())) { + test.skip(true, `${screen.name} 접근 불가 — 확장 미설치 또는 권한 미부여`); + + return; + } + + if (! (await waitForListRows(page))) { + test.skip(true, `${screen.name} 목록 행 없음 — 데이터 미존재`); + + return; + } + + const next = nextPageControl(page); + + // 목록 행과 페이지네이션은 렌더 시점이 다르다. 행이 떴다고 바로 세면 아직 없는 + // 컨트롤을 "없음" 으로 오판해 조용히 스킵된다 — 컨트롤이 붙을 때까지 기다린다. + const hasControl = await next + .waitFor({ state: 'attached', timeout: 15_000 }) + .then(() => true) + .catch(() => false); + + if (! hasControl) { + test.skip(true, `${screen.name} 페이지네이션 컨트롤 없음 — 데이터가 한 페이지 분량`); + + return; + } + + // 마지막 페이지로 한 번에 이동한다. 컨트롤이 없는 템플릿이면 "다음" 을 반복 클릭한다. + // 이미 마지막 페이지면(한 페이지 분량) 이동 없이 곧바로 판정한다 — 비활성 버튼을 + // 클릭하려 들면 Playwright 가 enabled 를 기다리다 타임아웃한다. + const last = lastPageControl(page); + const alreadyLast = await isNextDisabled(page); + + if (alreadyLast) { + // 이동 불필요 + } else if ((await last.count()) > 0 && (await last.isEnabled().catch(() => false))) { + await last.click(); + } else { + for (let hop = 0; hop < 10; hop += 1) { + if (await isNextDisabled(page)) { + break; + } + + await next.click(); + await page.waitForTimeout(1_000); + } + } + + // 고정 대기 대신 상태가 바뀔 때까지 폴링한다 — 병렬 실행에서 서버 응답이 늦어도 + // 결과 판정이 흔들리지 않는다. + await expect + .poll(() => isNextDisabled(page), { + message: '마지막 페이지에서 "다음" 이 계속 활성이면 총 건수 계산이 어긋난 것이다', + timeout: 20_000, + }) + .toBe(true); + }); + } +}); diff --git a/tests/Unit/Repositories/Concerns/DeferredJoinCallSiteContractTest.php b/tests/Unit/Repositories/Concerns/DeferredJoinCallSiteContractTest.php new file mode 100644 index 00000000..31b2d4e5 --- /dev/null +++ b/tests/Unit/Repositories/Concerns/DeferredJoinCallSiteContractTest.php @@ -0,0 +1,161 @@ + [파일, 줄번호, 인자 텍스트] + */ + public static function callSiteProvider(): array + { + // tests/Unit/Repositories/Concerns → 저장소 루트 + $root = dirname(__DIR__, 4); + $cases = []; + + foreach (self::SCAN_ROOTS as $scanRoot) { + $base = $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $scanRoot); + + if (! is_dir($base)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $path = str_replace('\\', '/', $file->getPathname()); + + // 트레이트 본체와 테스트 코드는 호출처가 아니다 + if (str_contains($path, '/tests/') || str_contains($path, '/Concerns/PaginatesWithDeferredJoin.php')) { + continue; + } + + $content = file_get_contents($path); + + if (! str_contains($content, 'paginateWithDeferredJoin(')) { + continue; + } + + foreach (self::extractCallSites($content) as [$line, $args]) { + $relative = substr($path, strlen(str_replace('\\', '/', $root)) + 1); + $cases["{$relative}:{$line}"] = [$relative, $line, $args]; + } + } + } + + return $cases; + } + + /** + * 파일 내용에서 호출처의 인자 텍스트를 추출합니다. + * + * @param string $content 파일 내용 + * @return array [줄번호, 인자 텍스트] + */ + private static function extractCallSites(string $content): array + { + $sites = []; + $offset = 0; + + while (($pos = strpos($content, 'paginateWithDeferredJoin(', $offset)) !== false) { + $open = $pos + strlen('paginateWithDeferredJoin(') - 1; + $depth = 0; + $end = null; + + for ($i = $open; $i < strlen($content); $i++) { + $ch = $content[$i]; + + if ($ch === '(' || $ch === '[') { + $depth++; + } elseif ($ch === ')' || $ch === ']') { + $depth--; + + if ($depth === 0) { + $end = $i; + break; + } + } + } + + if ($end === null) { + break; + } + + $line = substr_count(substr($content, 0, $pos), "\n") + 1; + $sites[] = [$line, substr($content, $open + 1, $end - $open - 1)]; + $offset = $end; + } + + return $sites; + } + + #[DataProvider('callSiteProvider')] + public function test_call_site_declares_columns(string $file, int $line, string $args): void + { + $this->assertMatchesRegularExpression( + '/\bcolumns\s*:/', + $args, + "{$file}:{$line} — columns: 를 명시해야 한다 (미지정 시 outer 가 읽는 범위가 호출처에서 보이지 않는다)" + ); + + $this->assertDoesNotMatchRegularExpression( + '/\bcolumns\s*:\s*\[\s*\]/', + $args, + "{$file}:{$line} — 빈 컬럼 목록은 어떤 컬럼도 선택하지 않는다" + ); + } + + #[DataProvider('callSiteProvider')] + public function test_call_site_declares_sort_spec(string $file, int $line, string $args): void + { + $this->assertMatchesRegularExpression( + '/\bsort\s*:/', + $args, + "{$file}:{$line} — sort: 를 명시해야 한다 (미지정 시 키 컬럼 정렬만 남는다)" + ); + + $this->assertDoesNotMatchRegularExpression( + '/\bsort\s*:\s*\[\s*\]/', + $args, + "{$file}:{$line} — 빈 정렬 스펙은 화면 정렬을 잃는다" + ); + } + + public function test_scan_finds_call_sites(): void + { + // 스캔이 0건이면 위 두 테스트가 조용히 통과해 계약 검사가 무력화된다 + $this->assertNotEmpty(self::callSiteProvider(), '지연 조인 호출처를 하나도 찾지 못했다 — 스캔 경로를 확인할 것'); + } +} diff --git a/tests/Unit/Repositories/Concerns/PaginatesWithDeferredJoinTest.php b/tests/Unit/Repositories/Concerns/PaginatesWithDeferredJoinTest.php new file mode 100644 index 00000000..62ee9f0f --- /dev/null +++ b/tests/Unit/Repositories/Concerns/PaginatesWithDeferredJoinTest.php @@ -0,0 +1,690 @@ +id(); + $table->unsignedBigInteger('group_id')->default(1); + $table->string('title', 100); + $table->unsignedInteger('view_count')->default(0); + $table->longText('body')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('deferred_join_sample_comments', function ($table) { + $table->id(); + $table->unsignedBigInteger('sample_id'); + $table->string('content', 100); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + Schema::dropIfExists('deferred_join_sample_comments'); + Schema::dropIfExists('deferred_join_samples'); + + parent::tearDown(); + } + + /** + * 샘플 행을 생성합니다. + * + * @param int $count 생성 건수 + * @param int $viewCount 조회수 (동률 tie-break 검증용 고정값) + */ + private function seedSamples(int $count, int $viewCount = 0): void + { + $rows = []; + + for ($i = 1; $i <= $count; $i++) { + $rows[] = [ + 'group_id' => 1, + 'title' => "제목 {$i}", + 'view_count' => $viewCount, + 'body' => str_repeat('본문 ', 200), + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + DB::table('deferred_join_samples')->insert($rows); + } + + /** 쿼리 로그 수집을 시작합니다. */ + private function startCapture(): void + { + $this->queries = []; + + DB::listen(function ($query) { + $this->queries[] = $query->sql; + }); + } + + /** + * 수집된 쿼리 중 조건에 맞는 것만 돌려줍니다. + * + * @param string $needle 포함 문자열 + * @return array 일치 쿼리 목록 + */ + private function queriesContaining(string $needle): array + { + return array_values(array_filter($this->queries, fn (string $sql) => str_contains($sql, $needle))); + } + + /** 테스트용 리포지토리를 생성합니다. */ + private function repository(): object + { + return new class + { + use PaginatesWithDeferredJoin; + + /** + * 지연 조인 페이지네이션을 그대로 노출합니다. (Trait 메서드는 protected) + * + * @param Builder $query 대상 쿼리 + * @param array $args 나머지 인자 + */ + public function paginate(Builder $query, array $columns, array $sort, int $perPage, ...$args) + { + return $this->paginateWithDeferredJoin($query, $columns, $sort, $perPage, ...$args); + } + }; + } + + /** + * @scale n=200000 asserts=id_only_inner_query + */ + public function test_inner_query_selects_only_key_column_without_wide_columns(): void + { + $this->seedSamples(30); + $this->startCapture(); + + $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 2, + ); + + $offsetQueries = $this->queriesContaining('offset'); + + $this->assertCount(1, $offsetQueries, 'OFFSET 스캔은 정확히 한 번(inner)만 일어나야 한다'); + // 테이블 프리픽스는 환경 설정값이므로 패턴으로 판정한다 + $this->assertMatchesRegularExpression('/^select `[^`]*deferred_join_samples`\.`id` from/', $offsetQueries[0]); + $this->assertStringNotContainsString('body', $offsetQueries[0], 'inner 는 넓은 컬럼을 읽지 않아야 한다'); + $this->assertStringNotContainsString('title', $offsetQueries[0]); + } + + /** + * @scale n=200000 asserts=no_wide_column_in_offset_scan + */ + public function test_outer_query_filters_by_id_set_without_offset(): void + { + $this->seedSamples(30); + $this->startCapture(); + + $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 2, + ); + + $outer = $this->queriesContaining('`id` in ('); + + $this->assertNotEmpty($outer, 'outer 는 ID 집합으로 조회해야 한다'); + $this->assertStringNotContainsString('offset', $outer[0], 'outer 에는 OFFSET 이 없어야 한다'); + } + + public function test_relations_are_not_eager_loaded_for_inner_query(): void + { + $this->seedSamples(5); + DB::table('deferred_join_sample_comments')->insert([ + ['sample_id' => 1, 'content' => '댓글', 'created_at' => now(), 'updated_at' => now()], + ]); + + $this->startCapture(); + + $result = $this->repository()->paginate( + DeferredJoinSample::query()->with('comments'), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 1, + ['comments'], + ); + + $commentQueries = $this->queriesContaining('deferred_join_sample_comments'); + + $this->assertCount(1, $commentQueries, '관계 쿼리는 outer 에서 한 번만 실행되어야 한다'); + $this->assertTrue($result->getCollection()->first()->relationLoaded('comments')); + } + + public function test_count_query_is_skipped_when_total_is_injected(): void + { + $this->seedSamples(30); + $this->startCapture(); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 1, + [], + [], + 'id', + 30, + ); + + $this->assertEmpty($this->queriesContaining('count(*)'), '주입된 total 이 있으면 COUNT 를 실행하지 않아야 한다'); + $this->assertInstanceOf(LengthAwarePaginator::class, $result); + $this->assertSame(30, $result->total()); + } + + public function test_simple_mode_skips_count_and_detects_next_page(): void + { + $this->seedSamples(25); + $this->startCapture(); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 1, + [], + [], + 'id', + null, + true, + ); + + $this->assertEmpty($this->queriesContaining('count(*)')); + $this->assertInstanceOf(Paginator::class, $result); + $this->assertCount(10, $result->items(), 'perPage + 1 건을 읽어도 페이지 항목은 perPage 건이어야 한다'); + $this->assertTrue($result->hasMorePages()); + } + + public function test_simple_mode_reports_last_page_correctly(): void + { + $this->seedSamples(15); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 2, + [], + [], + 'id', + null, + true, + ); + + $this->assertCount(5, $result->items()); + $this->assertFalse($result->hasMorePages(), '마지막 페이지에서 다음 페이지가 있다고 보고하면 안 된다'); + } + + public function test_tie_breaking_key_is_appended_to_sort_spec(): void + { + // 모든 행의 view_count 가 동일 — 키 컬럼 tie-break 가 없으면 페이지 경계가 흔들린다 + $this->seedSamples(20, viewCount: 7); + + $page1 = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'view_count'], + [['column' => 'view_count', 'direction' => 'desc']], + 10, + 1, + ); + $page2 = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'view_count'], + [['column' => 'view_count', 'direction' => 'desc']], + 10, + 2, + ); + + $firstIds = $page1->getCollection()->pluck('id')->all(); + $secondIds = $page2->getCollection()->pluck('id')->all(); + + $this->assertSame([20, 19, 18, 17, 16, 15, 14, 13, 12, 11], $firstIds); + $this->assertSame([10, 9, 8, 7, 6, 5, 4, 3, 2, 1], $secondIds); + $this->assertEmpty(array_intersect($firstIds, $secondIds), '페이지 간 중복 행이 없어야 한다'); + } + + public function test_outer_result_order_matches_inner_order(): void + { + $this->seedSamples(12); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'title', 'direction' => 'asc']], + 5, + 1, + ); + + $ids = $result->getCollection()->pluck('id')->all(); + $sorted = $result->getCollection()->sortBy('title')->pluck('id')->all(); + + $this->assertSame(array_values($sorted), $ids, 'outer 결과 순서가 정렬 스펙과 일치해야 한다'); + } + + public function test_soft_deleted_rows_stay_excluded(): void + { + $this->seedSamples(10); + DeferredJoinSample::query()->whereIn('id', [3, 7])->delete(); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 20, + 1, + ); + + $ids = $result->getCollection()->pluck('id')->all(); + + $this->assertNotContains(3, $ids); + $this->assertNotContains(7, $ids); + $this->assertSame(8, $result->total()); + } + + public function test_with_trashed_scope_is_preserved(): void + { + $this->seedSamples(10); + DeferredJoinSample::query()->whereIn('id', [3, 7])->delete(); + + $result = $this->repository()->paginate( + DeferredJoinSample::query()->withTrashed(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 20, + 1, + ); + + $this->assertSame(10, $result->total()); + $this->assertContains(3, $result->getCollection()->pluck('id')->all()); + } + + public function test_filters_applied_to_query_are_preserved(): void + { + $this->seedSamples(10); + DB::table('deferred_join_samples')->whereIn('id', [2, 4, 6])->update(['group_id' => 9]); + + $result = $this->repository()->paginate( + DeferredJoinSample::query()->where('group_id', 9), + ['id', 'group_id'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 1, + ); + + $this->assertSame([2, 4, 6], $result->getCollection()->pluck('id')->all()); + $this->assertSame(3, $result->total()); + } + + public function test_preserve_id_order_restores_inner_sequence(): void + { + $this->seedSamples(6); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'desc']], + 3, + 1, + [], + [], + 'id', + null, + false, + true, + ); + + $this->assertSame([6, 5, 4], $result->getCollection()->pluck('id')->all()); + } + + public function test_with_count_is_applied_on_outer_only(): void + { + $this->seedSamples(3); + DB::table('deferred_join_sample_comments')->insert([ + ['sample_id' => 1, 'content' => 'a', 'created_at' => now(), 'updated_at' => now()], + ['sample_id' => 1, 'content' => 'b', 'created_at' => now(), 'updated_at' => now()], + ]); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 1, + [], + ['comments'], + ); + + $this->assertSame(2, (int) $result->getCollection()->firstWhere('id', 1)->comments_count); + } + + public function test_grouped_query_total_counts_groups_not_first_group_rows(): void + { + // 조인으로 행이 불어난 뒤 groupBy 로 접히는 형태 — 조인 행 7건, 그룹 5건 + $this->seedSamples(5); + DB::table('deferred_join_sample_comments')->insert([ + ['sample_id' => 1, 'content' => 'a', 'created_at' => now(), 'updated_at' => now()], + ['sample_id' => 1, 'content' => 'b', 'created_at' => now(), 'updated_at' => now()], + ['sample_id' => 1, 'content' => 'c', 'created_at' => now(), 'updated_at' => now()], + ['sample_id' => 2, 'content' => 'd', 'created_at' => now(), 'updated_at' => now()], + ['sample_id' => 3, 'content' => 'e', 'created_at' => now(), 'updated_at' => now()], + ['sample_id' => 4, 'content' => 'f', 'created_at' => now(), 'updated_at' => now()], + ['sample_id' => 5, 'content' => 'g', 'created_at' => now(), 'updated_at' => now()], + ]); + + $result = $this->repository()->paginate( + DeferredJoinSample::query() + ->join('deferred_join_sample_comments as c', 'c.sample_id', '=', 'deferred_join_samples.id') + ->groupBy('deferred_join_samples.id'), + ['deferred_join_samples.id'], + [['column' => 'deferred_join_samples.id', 'direction' => 'asc']], + 10, + 1, + ); + + // 집계 쿼리를 서브쿼리로 감싸지 않으면 첫 그룹의 행 수(3)가 총 건수로 잡힌다 + $this->assertSame(5, $result->total(), '그룹 쿼리의 총 건수는 그룹 수여야 한다'); + $this->assertCount(5, $result->items()); + $this->assertSame(1, $result->lastPage()); + } + + public function test_grouped_query_paginates_across_pages_with_correct_total(): void + { + $this->seedSamples(12); + + $rows = []; + for ($sampleId = 1; $sampleId <= 12; $sampleId++) { + // 1번 샘플만 댓글 5건 — 첫 그룹 행 수와 그룹 수를 다르게 만든다 + $repeat = $sampleId === 1 ? 5 : 1; + for ($i = 0; $i < $repeat; $i++) { + $rows[] = ['sample_id' => $sampleId, 'content' => "c{$sampleId}-{$i}", 'created_at' => now(), 'updated_at' => now()]; + } + } + DB::table('deferred_join_sample_comments')->insert($rows); + + $grouped = fn () => DeferredJoinSample::query() + ->join('deferred_join_sample_comments as c', 'c.sample_id', '=', 'deferred_join_samples.id') + ->groupBy('deferred_join_samples.id'); + + $page2 = $this->repository()->paginate( + $grouped(), + ['deferred_join_samples.id'], + [['column' => 'deferred_join_samples.id', 'direction' => 'asc']], + 5, + 2, + ); + + $this->assertSame(12, $page2->total()); + $this->assertSame(3, $page2->lastPage()); + $this->assertSame([6, 7, 8, 9, 10], $page2->getCollection()->pluck('id')->all()); + } + + public function test_returned_paginator_supports_query_string_appending(): void + { + // 필터 조건을 페이지 링크에 보존하는 책임은 호출자의 appends()/withQueryString() 에 있다. + // 지연 조인 전환 후에도 그 계약이 유지되는지 확인한다 (Laravel paginate() 도 동일). + $this->seedSamples(30); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 1, + ); + + $url = $result->appends(['search' => '검색어', 'sort' => 'latest'])->nextPageUrl(); + + $this->assertStringContainsString('search=', (string) $url); + $this->assertStringContainsString('sort=latest', (string) $url); + $this->assertStringContainsString('page=2', (string) $url); + } + + public function test_custom_page_name_is_reflected_in_paginator_links(): void + { + $this->seedSamples(30); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 1, + [], + [], + 'id', + null, + false, + false, + 'p', + ); + + $this->assertStringContainsString('p=2', (string) $result->nextPageUrl()); + } + + /** + * 깊은 OFFSET 의 비용 증가가 유계인지 확인합니다. + * + * 절대 시간 임계값(`assertLessThan(50, $ms)`)은 CI 머신 성능에 좌우되므로 쓰지 않는다. + * 대신 **같은 머신 안에서의 비율**만 단언한다 — 뒤쪽 페이지 소요 시간이 첫 페이지의 몇 배인가. + * 지연 조인 이전에는 이 비율이 20배를 넘었고(OFFSET 이 커질수록 넓은 컬럼을 그만큼 더 읽는다), + * 이후에는 넓은 컬럼을 읽는 행 수가 페이지 크기로 고정되므로 배수가 상수에 가깝게 붙는다. + * 상한 5배는 실측(21.6배 → ~2배)에 여유를 둔 값이다. + * + * @scale n=200000 asserts=deep_offset_bounded_growth + */ + public function test_deep_offset_cost_growth_is_bounded(): void + { + $rows = 20000; + $perPage = 20; + + // 넓은 컬럼(longText)이 있어야 OFFSET 구간의 읽기 비용 차이가 드러난다 + $body = str_repeat('본문 ', 250); + $now = now(); + + for ($chunk = 0; $chunk < $rows / 1000; $chunk++) { + $batch = []; + + for ($i = 1; $i <= 1000; $i++) { + $batch[] = [ + 'group_id' => 1, + 'title' => '제목 '.($chunk * 1000 + $i), + 'view_count' => 0, + 'body' => $body, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + DB::table('deferred_join_samples')->insert($batch); + } + + $measure = function (int $page) use ($perPage): float { + $run = fn () => $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title', 'body'], + [['column' => 'id', 'direction' => 'asc']], + $perPage, + $page, + ); + + $run(); // 버퍼 풀 워밍 — 첫 회는 버린다 + + $samples = []; + + for ($i = 0; $i < 5; $i++) { + $start = microtime(true); + $run(); + $samples[] = (microtime(true) - $start) * 1000; + } + + sort($samples); + + return $samples[2]; // 중앙값 + }; + + $firstPage = $measure(1); + $lastPage = $measure((int) ($rows / $perPage)); + + $this->assertGreaterThan(0.0, $firstPage, '측정값이 0 이면 비율 판정이 무의미하다'); + + $ratio = $lastPage / $firstPage; + + $this->assertLessThanOrEqual( + 5.0, + $ratio, + sprintf( + '마지막 페이지가 첫 페이지의 %.1f배 — 깊은 OFFSET 비용이 유계가 아니다 (첫 %.2fms / 마지막 %.2fms)', + $ratio, + $firstPage, + $lastPage + ) + ); + } + + /** + * 지연 조인 결과가 기존 `paginate()` 와 **같은 ID 시퀀스**인지 확인합니다. + * + * 정적 골든 fixture 를 파일로 떠 두면 기대값을 사람이 다시 만들어야 해서, 정렬 규칙이 + * 바뀔 때 fixture 를 함께 고쳐 회귀를 덮어버리기 쉽다. 여기서는 **교체 전 경로 자체** + * (`Builder::paginate`)를 오라클로 두고 매 실행마다 기대값을 다시 얻는다. 순서까지 + * 비교하므로 tie-break 회귀도 잡힌다. + */ + public function test_id_sequence_matches_plain_paginate_oracle(): void + { + // 조회수가 겹치도록 만들어 tie-break 가 개입하게 한다 + for ($i = 1; $i <= 47; $i++) { + DB::table('deferred_join_samples')->insert([ + 'group_id' => $i % 3, + 'title' => sprintf('제목 %03d', $i), + 'view_count' => $i % 5, + 'body' => str_repeat('본문 ', 50), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $perPage = 10; + $sort = [ + ['column' => 'view_count', 'direction' => 'desc'], + ['column' => 'id', 'direction' => 'asc'], + ]; + + foreach ([1, 2, 3, 5] as $page) { + $oracle = DeferredJoinSample::query() + ->where('group_id', '!=', 2) + ->orderBy('view_count', 'desc') + ->orderBy('id', 'asc') + ->paginate($perPage, ['id'], 'page', $page); + + $actual = $this->repository()->paginate( + DeferredJoinSample::query()->where('group_id', '!=', 2), + ['id', 'title'], + $sort, + $perPage, + $page, + ); + + $this->assertSame( + $oracle->getCollection()->pluck('id')->all(), + $actual->getCollection()->pluck('id')->all(), + "{$page}페이지의 ID 시퀀스가 기존 paginate() 결과와 달라졌다" + ); + $this->assertSame($oracle->total(), $actual->total(), "{$page}페이지의 총 건수가 달라졌다"); + $this->assertSame($oracle->lastPage(), $actual->lastPage()); + } + } + + public function test_empty_result_does_not_run_outer_query(): void + { + $this->startCapture(); + + $result = $this->repository()->paginate( + DeferredJoinSample::query(), + ['id', 'title'], + [['column' => 'id', 'direction' => 'asc']], + 10, + 1, + ); + + $this->assertTrue($result->getCollection()->isEmpty()); + $this->assertEmpty($this->queriesContaining('`id` in ('), '빈 결과에서는 outer 쿼리를 실행하지 않아야 한다'); + } +} + +/** + * 지연 조인 테스트용 샘플 모델 + */ +class DeferredJoinSample extends Model +{ + use SoftDeletes; + + protected $table = 'deferred_join_samples'; + + protected $guarded = []; + + /** + * 샘플 댓글 관계 + * + * @return HasMany 댓글 관계 + */ + public function comments() + { + return $this->hasMany(DeferredJoinSampleComment::class, 'sample_id'); + } +} + +/** + * 지연 조인 테스트용 샘플 댓글 모델 + */ +class DeferredJoinSampleComment extends Model +{ + protected $table = 'deferred_join_sample_comments'; + + protected $guarded = []; +} diff --git a/tests/Unit/Repositories/Concerns/ResolvesSortSpecTest.php b/tests/Unit/Repositories/Concerns/ResolvesSortSpecTest.php new file mode 100644 index 00000000..10ab7013 --- /dev/null +++ b/tests/Unit/Repositories/Concerns/ResolvesSortSpecTest.php @@ -0,0 +1,143 @@ +resolveSortSpec(...$args); + } + }; + } + + public function test_allowed_column_and_direction_pass_through(): void + { + $spec = $this->repository()->resolve( + ['sort_by' => 'created_at', 'sort_order' => 'asc'], + ['id', 'created_at', 'status'], + 'id', + ); + + $this->assertSame([['column' => 'created_at', 'direction' => 'asc']], $spec); + } + + public function test_unknown_column_falls_back_to_default(): void + { + $spec = $this->repository()->resolve( + ['sort_by' => 'admin_memo', 'sort_order' => 'desc'], + ['id', 'created_at'], + 'id', + ); + + $this->assertSame('id', $spec[0]['column']); + } + + public function test_invalid_direction_falls_back_to_default(): void + { + $spec = $this->repository()->resolve( + ['sort_by' => 'created_at', 'sort_order' => 'sideways'], + ['created_at'], + 'created_at', + 'asc', + ); + + $this->assertSame('asc', $spec[0]['direction']); + } + + public function test_column_alias_is_applied_before_whitelist_check(): void + { + $spec = $this->repository()->resolve( + ['sort_by' => 'author', 'sort_order' => 'desc'], + ['id', 'author_name'], + 'id', + 'desc', + ['author' => 'author_name'], + ); + + $this->assertSame('author_name', $spec[0]['column']); + } + + public function test_custom_filter_keys_are_honored(): void + { + $spec = $this->repository()->resolve( + ['order_by' => 'view_count', 'order_direction' => 'asc'], + ['id', 'view_count'], + 'id', + 'desc', + [], + 'order_by', + 'order_direction', + ); + + $this->assertSame([['column' => 'view_count', 'direction' => 'asc']], $spec); + } + + public function test_backed_enum_values_are_normalized(): void + { + $spec = $this->repository()->resolve( + ['sort_by' => SampleSortColumn::CreatedAt, 'sort_order' => SampleSortDirection::Asc], + ['id', 'created_at'], + 'id', + ); + + $this->assertSame([['column' => 'created_at', 'direction' => 'asc']], $spec); + } + + public function test_missing_and_blank_values_fall_back(): void + { + $spec = $this->repository()->resolve( + ['sort_by' => ' '], + ['id', 'created_at'], + 'created_at', + 'desc', + ); + + $this->assertSame([['column' => 'created_at', 'direction' => 'desc']], $spec); + } + + public function test_array_value_is_rejected(): void + { + $spec = $this->repository()->resolve( + ['sort_by' => ['created_at'], 'sort_order' => ['asc']], + ['id', 'created_at'], + 'id', + ); + + $this->assertSame([['column' => 'id', 'direction' => 'desc']], $spec); + } +} + +/** 테스트용 정렬 컬럼 Enum */ +enum SampleSortColumn: string +{ + case CreatedAt = 'created_at'; +} + +/** 테스트용 정렬 방향 Enum */ +enum SampleSortDirection: string +{ + case Asc = 'asc'; +} diff --git a/tests/Unit/Repositories/Concerns/SortableColumnsGateContractTest.php b/tests/Unit/Repositories/Concerns/SortableColumnsGateContractTest.php new file mode 100644 index 00000000..8a726ce5 --- /dev/null +++ b/tests/Unit/Repositories/Concerns/SortableColumnsGateContractTest.php @@ -0,0 +1,248 @@ + + */ + private const GATE_MAP = [ + 'app/Repositories/ActivityLogRepository.php' => 'app/Http/Requests/ActivityLog/ActivityLogIndexRequest.php', + 'app/Repositories/IdentityMessageDefinitionRepository.php' => 'app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php', + 'app/Repositories/MenuRepository.php' => 'app/Http/Requests/Menu/MenuListRequest.php', + 'app/Repositories/NotificationDefinitionRepository.php' => 'app/Http/Requests/NotificationDefinition/NotificationDefinitionIndexRequest.php', + 'app/Repositories/NotificationLogRepository.php' => 'app/Http/Requests/NotificationLog/NotificationLogIndexRequest.php', + 'app/Repositories/ScheduleHistoryRepository.php' => 'app/Http/Requests/Schedule/ScheduleHistoryListRequest.php', + 'app/Repositories/ScheduleRepository.php' => 'app/Http/Requests/Schedule/ScheduleListRequest.php', + 'app/Repositories/UserRepository.php' => 'app/Http/Requests/User/UserListRequest.php', + 'modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponRepository.php' => 'modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CouponListRequest.php', + 'modules/_bundled/sirsoft-ecommerce/src/Repositories/ExtraFeeTemplateRepository.php' => 'modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ExtraFeeTemplateListRequest.php', + 'modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php' => 'modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/OrderListRequest.php', + 'modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php' => 'modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductListRequest.php', + 'modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingPolicyRepository.php' => 'modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ShippingPolicyListRequest.php', + 'modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php' => 'modules/_bundled/sirsoft-page/src/Http/Requests/PageListRequest.php', + ]; + + /** + * 저장소 루트 절대경로를 돌려줍니다. + * + * @return string 저장소 루트 경로 + */ + private static function repositoryRoot(): string + { + // tests/Unit/Repositories/Concerns → 저장소 루트 + return dirname(__DIR__, 4); + } + + /** + * resolveSortSpec 을 호출하는 파일 목록을 스캔합니다. + * + * @return array 저장소 상대 경로 목록 + */ + private static function scanCallSites(): array + { + $root = self::repositoryRoot(); + $found = []; + + foreach (self::SCAN_ROOTS as $scanRoot) { + $base = $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $scanRoot); + + if (! is_dir($base)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $path = str_replace('\\', '/', $file->getPathname()); + $relative = ltrim(str_replace(str_replace('\\', '/', $root), '', $path), '/'); + + // 트레이트 본체와 테스트 코드는 호출처가 아니다 + if (str_contains($relative, '/tests/') || str_contains($relative, 'Concerns/ResolvesSortSpec.php')) { + continue; + } + + if (str_contains((string) file_get_contents($path), '$this->resolveSortSpec(')) { + $found[] = $relative; + } + } + } + + sort($found); + + return $found; + } + + /** + * 게이트 대조 대상 케이스를 제공합니다. + * + * @return array [저장소 파일, 요청 파일] + */ + public static function gatePairProvider(): array + { + $cases = []; + + foreach (self::GATE_MAP as $repository => $request) { + if ($request === null) { + continue; + } + + $cases[$repository] = [$repository, $request]; + } + + return $cases; + } + + /** + * 파일에서 정렬 허용 컬럼 집합을 추출합니다. (상수 참조 해석 포함) + * + * @param string $content 파일 내용 + * @return array 허용 컬럼 목록 + */ + private function extractRepositoryColumns(string $content): array + { + $columns = []; + + // resolveSortSpec 의 두 번째 인자 — self::CONST 또는 인라인 배열 + preg_match_all('/resolveSortSpec\(\s*\$\w+\s*,\s*(self::(\w+)|\[[^\]]*\])/s', $content, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $literal = $match[1]; + + if (str_starts_with($literal, 'self::')) { + $constName = $match[2]; + + if (! preg_match('/const\s+'.preg_quote($constName, '/').'\s*=\s*\[(.*?)\];/s', $content, $constMatch)) { + continue; + } + + $literal = $constMatch[1]; + } + + preg_match_all("/'([a-zA-Z0-9_]+)'/", $literal, $columnMatches); + $columns = array_merge($columns, $columnMatches[1]); + } + + return array_values(array_unique($columns)); + } + + /** + * FormRequest 에서 sort_by 허용 값을 추출합니다. + * + * @param string $content 파일 내용 + * @return array 허용 값 목록 + */ + private function extractGateColumns(string $content): array + { + // 'sort_by' => ... 한 줄(또는 Rule::in 블록) 안의 in: / Rule::in([...]) 를 읽는다 + if (! preg_match("/'sort_by'\s*=>\s*(.*?)(?:\],|\],\s*$|\n\s*'[a-z_]+'\s*=>)/s", $content, $match)) { + return []; + } + + $rule = $match[1]; + $columns = []; + + if (preg_match('/Rule::in\(\[(.*?)\]\)/s', $rule, $ruleInMatch)) { + preg_match_all("/'([a-zA-Z0-9_]+)'/", $ruleInMatch[1], $columnMatches); + $columns = $columnMatches[1]; + } elseif (preg_match('/in:([a-zA-Z0-9_,]+)/', $rule, $inMatch)) { + $columns = explode(',', $inMatch[1]); + } + + return array_values(array_filter(array_unique($columns))); + } + + /** + * 모든 resolveSortSpec 호출처가 매핑에 등록되어 있는지 검사합니다. + */ + public function test_every_call_site_is_mapped(): void + { + $callSites = self::scanCallSites(); + + $this->assertNotEmpty($callSites, 'resolveSortSpec 호출처를 하나도 찾지 못했습니다 — 스캔 경로를 확인하세요.'); + + $unmapped = array_diff($callSites, array_keys(self::GATE_MAP)); + + $this->assertSame( + [], + array_values($unmapped), + "GATE_MAP 에 등록되지 않은 resolveSortSpec 호출처가 있습니다:\n ".implode("\n ", $unmapped) + ."\n대응 FormRequest 를 매핑에 추가하세요 (요청 경로가 없으면 null)." + ); + + $stale = array_diff(array_keys(self::GATE_MAP), $callSites); + + $this->assertSame( + [], + array_values($stale), + "GATE_MAP 에 남아 있으나 더 이상 resolveSortSpec 을 호출하지 않는 항목:\n ".implode("\n ", $stale) + ); + } + + /** + * Repository 정렬 허용 목록이 FormRequest 게이트를 모두 덮는지 검사합니다. + * + * @param string $repositoryPath 저장소 상대 경로 (Repository) + * @param string $requestPath 저장소 상대 경로 (FormRequest) + */ + #[DataProvider('gatePairProvider')] + public function test_repository_whitelist_covers_gate(string $repositoryPath, string $requestPath): void + { + $root = self::repositoryRoot(); + $repositoryFile = $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $repositoryPath); + $requestFile = $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $requestPath); + + $this->assertFileExists($repositoryFile, "매핑된 Repository 파일이 없습니다: {$repositoryPath}"); + $this->assertFileExists($requestFile, "매핑된 FormRequest 파일이 없습니다: {$requestPath}"); + + $repositoryColumns = $this->extractRepositoryColumns((string) file_get_contents($repositoryFile)); + $gateColumns = $this->extractGateColumns((string) file_get_contents($requestFile)); + + $this->assertNotEmpty($repositoryColumns, "정렬 허용 컬럼을 추출하지 못했습니다: {$repositoryPath}"); + $this->assertNotEmpty($gateColumns, "sort_by 허용 값을 추출하지 못했습니다: {$requestPath}"); + + $missing = array_diff($gateColumns, $repositoryColumns); + + $this->assertSame( + [], + array_values($missing), + "{$repositoryPath} 의 정렬 허용 목록이 게이트({$requestPath})보다 좁습니다. " + ."검증을 통과한 정렬이 조용히 기본값으로 되돌아갑니다.\n 누락: ".implode(', ', $missing) + ); + } +} diff --git a/tests/scenarios/deep-offset-pagination.yaml b/tests/scenarios/deep-offset-pagination.yaml new file mode 100644 index 00000000..d93326a7 --- /dev/null +++ b/tests/scenarios/deep-offset-pagination.yaml @@ -0,0 +1,91 @@ +# audit:allow test-scenario-coverage reason: 이슈 #492(공개 #74) 깊은 OFFSET 목록 페이지네이션 SSoT. axes cross product(수백 조합 × 26개 호출처)는 docblock 전수 @scenario 매핑이 비현실적이라 면제하고, 회귀 가드는 test_files(트레이트 단위 + 호출 계약 전수 스캔 + 도메인별 저장소 회귀 + Playwright E2E)로 커버한다. 대용량 축(scale)은 scenario-scale-axis-required 룰이 별도 강제(면제 대상 아님) — scale.assert 는 @scale 마킹으로 전수 커버. + +feature: deep_offset_pagination + +description: | + 목록 페이지네이션이 뒤쪽 페이지로 갈수록 선형으로 느려지던 것을, 지연 조인(deferred join) + 2단계 조회로 재설계한다. `OFFSET n` 은 건너뛸 n 건도 실제로 읽으며, 목록 SELECT 에 넓은 + 컬럼(text/longText/JSON)이 있으면 그 n 건의 넓은 컬럼까지 읽힌다. InnoDB 는 행 밖 오버플로 + 페이지에 저장된 값을 앞부분만 필요해도 페이지째 읽으므로 비용이 그대로 누적된다. + + 핵심 설계: + - inner: 필터/정렬만으로 **키 컬럼만** 조회해 이번 페이지의 ID 집합을 구한다. + - outer: 그 ID 집합에 대해서만 목록 컬럼과 관계를 조회한다 — 넓은 컬럼을 읽는 행 수가 + OFFSET 과 무관하게 페이지 크기로 고정된다. + - 정렬은 닫힌 집합(ResolvesSortSpec)이어야 한다. 인덱스 없는 컬럼 정렬은 inner 도 전체 + 스캔하므로 개선 폭이 보장되지 않는다. + - 키 컬럼을 정렬 말미에 자동으로 덧붙여 전순서를 만든다 — 동률 정렬에서 페이지 경계가 + 흔들려 행이 중복/누락되는 것을 막는다. + - 총 건수는 Laravel paginate() 와 같은 집계 경로(getCountForPagination)를 쓴다. count() 는 + groupBy/having 쿼리에서 첫 그룹의 행 수를 총 건수로 돌려준다. + - `SUBSTRING(col, 1, N)` 은 프루닝으로 인정하지 않는다 — 오버플로 페이지 읽기가 그대로 + 발생하므로 잘라내기는 outer 에서만 수행한다. + +axes: + page_position: [first, middle, last] + sort_column: [key, indexed_non_key, tied_values] + total_source: [counted, injected_cache, simple_no_count] + soft_delete: [default_excluded, with_trashed] + query_shape: [flat, joined, grouped] + relations: [none, eager_load, with_count] + filter: [none, search, scoped] + columns: [pruned, all] + +exclusions: + - { total_source: simple_no_count, page_position: last, reason: "simplePaginate 는 총 건수를 세지 않아 마지막 페이지 개념이 없다 — hasMorePages 판정으로 대체" } + - { query_shape: grouped, total_source: simple_no_count, reason: "그룹 쿼리의 다음 페이지 판정은 총 건수 경로와 무관 — 그룹 수 정확성이 검증 대상" } + - { soft_delete: with_trashed, query_shape: grouped, reason: "그룹 목록(회원 활동)에 휴지통 조회 진입점이 없다" } + +effects: + # ── 조회 구조 (P0) ── + - inner_selects_key_column_only + - inner_excludes_wide_columns_from_offset_scan + - outer_filters_by_id_set_without_offset + - relations_eager_loaded_on_outer_only + - with_count_applied_on_outer_only + - empty_id_set_skips_outer_query + # ── 총 건수 (P0) ── + - injected_total_skips_count_query + - simple_mode_skips_count_query + - grouped_query_total_counts_groups_not_first_group_rows + - filtered_query_total_reflects_filters + # ── 페이지 경계 정합 (P0) ── + - key_column_appended_to_sort_for_total_order + - pages_do_not_overlap_or_drop_rows + - outer_result_order_matches_inner_order + - preserve_id_order_restores_inner_sequence + - simple_mode_reports_last_page_without_next + # ── 스코프 보존 (P1) ── + - soft_deleted_rows_stay_excluded + - with_trashed_scope_is_preserved + - query_filters_are_preserved_on_both_stages + - self_scoped_list_returns_only_own_rows + - comments_of_soft_deleted_parents_stay_excluded + # ── 페이지네이터 계약 (P1) ── + - paginator_supports_query_string_appending + - custom_page_name_is_reflected_in_links + # ── 호출 계약 (P1) ── + - every_call_site_declares_columns + - every_call_site_declares_non_empty_sort + # ── 정렬 화이트리스트 (P0) ── + - sort_column_resolved_from_closed_set + - unknown_sort_column_falls_back_to_default + +# scale: 대용량 회귀 축. block-array of flow objects (내장 fallback 파서 제약 — flow 객체만 중첩 파싱) +scale: + - { n: 200000, dataset: board_posts, assert: [deep_offset_bounded_growth, id_only_inner_query, no_wide_column_in_offset_scan] } + +test_files: + # 코어 — 트레이트 단위 (조회 구조 / 총 건수 / 페이지 경계 / 스코프) + - tests/Unit/Repositories/Concerns/PaginatesWithDeferredJoinTest.php + - tests/Unit/Repositories/Concerns/ResolvesSortSpecTest.php + # 코어 — 호출 계약 전수 스캔 (호출처가 늘어나도 자동 편입) + - tests/Unit/Repositories/Concerns/DeferredJoinCallSiteContractTest.php + # 게시판 — 공지 병합 / 답글 트리 / 소프트 삭제 / 정렬 경계 + - modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostListDeferredJoinTest.php + # 게시판 — 삭제된 글의 댓글 제외 / 캐시 total / 검색 총 건수 + - modules/_bundled/sirsoft-board/tests/Unit/Repositories/CommentListDeferredJoinTest.php + # 이커머스 — 주문 목록 소프트 삭제 + 페이지 경계 + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderListDeferredJoinTest.php + # 브라우저 — 마지막 페이지 다음 버튼 / 페이지 이동 후 목록 정합 + - tests/Playwright/specs/pagination/deep-offset-list.spec.ts