Merge pull request from gnuboard:HeuJung/issue519

HeuJung/issue519
This commit is contained in:
정정홍
2026-08-06 11:17:37 +09:00
committed by GitHub
310 changed files with 15243 additions and 1697 deletions
+3 -1
View File
@@ -22,7 +22,9 @@ BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# 설치 직후 기본값은 운영 기준(error). 관리자 환경설정 > 고급에서 조정하며,
# 그 값이 이 항목보다 우선합니다. debug 로 두면 정상 동작 기록까지 매 요청 쌓입니다.
LOG_LEVEL=error
DB_CONNECTION=mysql
+45 -3
View File
@@ -6,7 +6,7 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (33개)
### 백엔드 [backend/](docs/backend/) (34개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -33,6 +33,7 @@
| [language-pack-service.md](docs/backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactivate/uninsta... |
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
| [pagination.md](docs/backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [routing.md](docs/backend/routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](docs/backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ... |
@@ -151,7 +152,7 @@
| 대상 | 진입점 | 문서/엔드포인트 |
|------|--------|----------------|
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 |
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
@@ -162,7 +163,7 @@
|------|------|--------------|----------------|
| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 |
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 232 |
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
@@ -397,6 +398,28 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [service-repository.md "목록 조회 컬럼 프루닝과 지연 조인" / "정렬 컬럼 화이트리스트" / "화면 정렬 옵션은 게이트의 부분집합이어야 한다" / "관계 테이블 컬럼 기준 정렬" / "허용되는 Raw 쿼리"](docs/backend/service-repository.md)
### 대용량 목록의 총 건수와 페이지 이동
총 건수 상한과 페이지 이동 범위는 **별개 결정**이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고, "다음" 이동은 `per_page + 1` 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 같은 술어를 `count()` 한 번, `get()` 한 번 실행 | `BoundedPaginator::paginate()` 한 번 (총 건수 + 페이지를 한 번에) |
| `paginate(PHP_INT_MAX)` 후 PHP `array_slice` | 실제 `page`/`per_page` 를 저장소까지 하달 |
| `forPage($page, $perPage + 1)` | offset 은 `per_page` 기준으로 따로 계산 (안 그러면 페이지가 깊어질수록 경계가 밀린다) |
| Scout `->keys()->all()` + 무제한 `whereIn` | 키워드 술어를 페이지 쿼리에 직접 밀어넣기 (`DatabaseFulltextEngine::whereFulltext`) |
| FULLTEXT 원문 키워드를 raw 로 바인딩 | 코어 sanitizer 경유 — `+` `-` `*` `"` 입력이 파싱 오류로 500 이 된다 |
| `whereDate` / `whereYear`+`whereMonth` | `FiltersByDateRange` 의 범위 조건 (컬럼에 함수를 씌우면 인덱스를 못 쓴다) |
| 총 건수를 모르는데 `last_page` 를 1 로 채움 | `null` 로 내보내 화면이 마지막 페이지 점프만 감추게 한다 |
| 상한값을 저장소·화면에 리터럴로 재기입 | `PaginationLimits` 단일 해석 + 확장은 `core.pagination.filter_*` 필터 훅으로만 조정 |
| 결과 크기가 데이터 증가에 비례하는데 상한 없는 `->get()` / `->pluck()` | 목록은 페이지네이션, 순회는 `chunkById`/`lazyById`, 몇 건이면 `limit` — 운영자 등록 수에 묶인 설정성 테이블만 예외이며 그 근거를 코드에 남긴다 |
| 배지·요약 건수를 `int` 하나로 돌려주기 | `BoundedPaginator::count()` 의 `BoundedCount` — 잘린 값과 정확한 값이 구분되지 않으면 잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나간다 |
| 여러 카테고리 건수를 합치며 정확도는 버리기 | 하나라도 부정확하면 합계도 부정확. 단, 특정 탭만 볼 때는 그 카테고리의 정확도만 본다 |
| 정렬 마지막이 비고유 컬럼 | 기본키를 덧붙여 전순서 보장 (동률 구간에서 행이 겹치거나 샌다) |
| 관련도순(`_ft_score`)에 커서 적용 | 계산값은 WHERE 절 경계로 쓸 수 없다 — offset 유지 (`KeysetPaginator::supports` 가 판정) |
> 상세: [pagination.md](docs/backend/pagination.md)
### 검색 인덱스 재생성(리인덱싱)
| ❌ 금지 | ✅ 올바른 사용 |
@@ -412,6 +435,24 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [search-system.md](docs/backend/search-system.md)
### 검색 질의는 활성 엔진이 만든다
검색 엔진은 `core.search.engine_drivers` 훅으로 교체 가능하다. 그런데 그 교체가 실제로 먹는 것은 **활성 엔진을 거치는 경로뿐**이다. 저장소가 구체 엔진 클래스를 지목하면 등록된 엔진은 호출될 기회 자체를 잃고, 오류도 경고도 없이 그 사이트의 검색만 조용히 다른 방식으로 동작한다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| `DatabaseFulltextEngine::whereFulltext(...)` 등 구체 엔진 정적 호출 | `KeywordSearch::apply()` / `::applyAny()` (해석기가 활성 엔진에 위임) |
| `DB::getDriverName() === 'pgsql'` 처럼 드라이버명을 코드에 비교 | 선언형 `config('core.search.*')` + `core.search.like_operators` 필터 훅 |
| 매칭 ID 전량을 PHP 로 적재 후 `whereIn` (`search()->keys()->all()`) | 술어를 페이지 쿼리에 직접 부착 — ID 왕복도 목록 폭발도 없다 |
| 엔진에게 페이지 번호를 넘겨 한 페이지만 받기 | 페이지네이션은 DB 담당. 엔진은 **키 집합 상한**만 책임진다 (`KeywordSearchContext`) |
| 키 집합 상한을 총 건수 상한과 다른 값으로 두기 | 둘 다 `PaginationLimits::resultCap()` — 갈라지면 엔진이 돌려준 건수와 화면 총 건수의 근거가 달라진다 |
| 부분일치 폴백을 "전문검색 없을 때의 임시방편" 으로 취급 | 전문검색 미제공 DBMS 에서는 **그것이 정상 경로** — 와일드카드 escape + 대소문자 규칙을 갖춘다 |
| 확장이 `Model::search()` 를 쓰는 것을 금지로 오해 | Scout 경로는 그대로 유효하다. 새 계약은 대체가 아니라 **추가 통로** |
정적 검사는 이 저장소 안만 볼 수 있다 — 외부 엔진이 상한을 지키는지는 강제할 수 없으므로, 코어는 **값을 손에 쥐어 주는 것**까지 하고 그 값이 도달하는지를 계약 테스트가 고정한다.
> 상세: [search-system.md "키워드 술어는 활성 엔진이 만든다"](docs/backend/search-system.md)
### Listener 데이터 접근
| 금지 | 올바른 사용 |
@@ -873,6 +914,7 @@ BaseApiController (최상위)
필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지)
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
필수: 모든 확장 작업은 Artisan 커맨드로 수행
```
+25
View File
@@ -66,6 +66,19 @@
- 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. 주문 목록의 「발송일」처럼 정렬 기준이 다른 표에 있는 값일 때도 총 건수와 페이지 경계가 어긋나지 않게 조회하는 공통 처리를 함께 넣었습니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
#### 대용량 목록
- 검색 결과나 목록이 아주 많을 때 화면이 열리지 않거나 서버가 멈추던 문제를 개선했습니다. 이제 총 건수는 일정 규모까지만 정확히 세고 그보다 많으면 "10,000건 이상" 처럼 표시하며, 다음 페이지로 넘기는 것은 끝까지 그대로 됩니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다 — 그 위치를 계산하려면 전체를 세야 하기 때문입니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 총 건수를 세는 범위와 주소로 직접 요청할 수 있는 최대 페이지 번호를 관리자 > 환경설정 > 고급에서 조정할 수 있습니다. 두 값 모두 0 으로 두면 제한하지 않습니다.
- 검색 결과가 많아 총 건수를 정확히 세지 못한 경우, 검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있다는 안내를 함께 표시합니다.
- 활동 로그·알림 발송 기록·본인인증 기록·스케줄 실행 이력·회원 목록에도 같은 방식을 적용했습니다. 기록이 수십만 건 쌓여도 목록 첫 화면이 총 건수를 세는 비용에 끌려가지 않습니다.
- 알림함도 같은 방식으로 조회합니다. 알림이 오래 쌓인 계정에서도 목록이 빠르게 열립니다.
- 알림 발송 기록을 최신순으로 볼 때 뒤쪽 페이지를 이동하는 방식이 활동 로그와 동일해졌습니다. 몇 페이지를 넘겨도 속도가 일정하게 유지됩니다.
- 검색 결과 화면에서 다른 분류의 건수 배지가 필요 없을 때는 그 숫자를 세는 작업을 건너뛸 수 있습니다.
- 검색 결과의 탭 배지(게시글 N · 상품 N · 페이지 N)도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. 탭을 열어 본 목록과 배지의 숫자가 서로 다른 기준을 말하던 문제가 사라집니다.
- 활동 로그 목록을 페이지 번호 대신 이어보기 방식으로도 조회할 수 있습니다. 기록이 많이 쌓인 사이트에서 뒤쪽으로 갈수록 느려지지 않습니다. 화면 동작은 그대로이며, 필요할 때만 쓰는 선택지입니다.
- 통합 검색도 최신순·조회순·가격순처럼 목록 자체의 순서로 볼 때는 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 검색 결과가 아주 많아도 몇 페이지를 넘기든 속도가 일정합니다. 관련도순은 계산된 점수로 정렬하므로 종전의 페이지 번호 방식을 유지합니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
#### 성능 점검
- 확장이 자기 성능 측정 대상을 직접 등록할 수 있게 했습니다. 모듈·플러그인이 자신의 목록 화면, 관리자 화면, 저장 동작, 정기 작업 중 속도를 재고 싶은 것을 선언해 두면, 사이트 관리자가 성능 점검을 실행할 때 코어 항목과 함께 측정됩니다. 확장을 설치하면 그 확장의 측정 대상이 자동으로 목록에 나타나고, 제거하면 함께 사라집니다. 화면 측정은 응답 시간과 함께 그 화면이 실행한 데이터베이스 조회 횟수를 보여주므로, 목록 자체는 빠른데 화면이 느린 원인을 찾을 수 있습니다.
@@ -78,6 +91,13 @@
- 알림 정의 목록이 지금 보고 있는 채널의 템플릿만 받아오도록 바꿨습니다. 예전에는 정의마다 모든 채널의 제목과 본문이 함께 실려, 채널을 여러 개 쓰는 사이트일수록 목록이 무거웠습니다. 채널을 지정하지 않고 목록을 조회하면 템플릿 본문은 함께 오지 않으며, 전체 채널의 내용이 필요하면 정의 단건 조회를 이용하면 됩니다. 화면에 보이는 내용과 '되돌리기' 버튼 표시 조건은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 본인인증 메시지 정의 목록이 대표 템플릿 한 건만 받아오도록 바꿨습니다. 전체 템플릿은 정의를 열었을 때 종전대로 받아옵니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 역할 목록에 할당된 권한 개수를 함께 표시할 수 있도록 집계 값을 제공합니다. 권한 목록 자체를 싣지 않고도 규모를 확인할 수 있습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 페이지를 한 번 열 때마다 서버가 되풀이하던 준비 작업을 정리했습니다. 내부 동작 기록을 매 요청 400줄씩 남기던 부분, 환경설정 파일을 한 요청에서 스무 번 넘게 다시 읽던 부분, 이미 저장해 둔 목록을 매번 데이터베이스에서 다시 가져오던 부분을 없앴습니다. 화면에 보이는 내용은 그대로이며 응답이 빨라집니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 권한 확인이 화면 요소마다 데이터베이스를 다시 조회하던 것을 요청당 한 번으로 줄였습니다. 메뉴·버튼이 많은 화면일수록 체감 차이가 큽니다. 권한을 바꾸면 종전처럼 다음 요청부터 즉시 반영됩니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시글·상품·쿠폰 검색이 조건에 맞는 항목 전체를 먼저 메모리에 올린 뒤 다시 조회하던 방식을 없앴습니다. 검색 결과가 아주 많아도 메모리 사용량이 늘지 않습니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 플러그인으로 다른 검색엔진을 연결했을 때 게시판·상품·페이지 검색이 그 엔진을 쓰지 않고 기본 방식으로 동작하던 문제를 해소했습니다. 이제 연결한 검색엔진이 모든 검색 화면에 적용됩니다. 전문검색을 제공하지 않는 데이터베이스에서도 검색어의 `%` `_` 같은 기호를 입력한 글자 그대로 찾도록 개선했습니다.
- 기간으로 걸러 보는 목록(주문·회원·쿠폰·스케줄 등)의 날짜 조건을 색인을 활용하는 방식으로 바꿨습니다. 기록이 많은 사이트에서 기간 검색이 빨라집니다.
- 설치 직후 기록 수준(`LOG_LEVEL`) 기본값을 운영 기준(`error`)으로 바꿨습니다. 이전 기본값은 정상 동작까지 모두 기록해 로그 파일이 빠르게 커졌습니다. 관리자 > 환경설정 > 고급에서 조정할 수 있으며, 그 값이 이 항목보다 우선합니다.
- 권장 서버 사양(CPU·메모리)을 시스템 요구사항 문서에 추가했습니다.
- 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
@@ -97,6 +117,8 @@
#### 확장 업데이트
- 속도 최적화를 적용한 사이트에서, 확장을 설치·활성화·업데이트해도 그 확장이 새로 추가한 주소가 동작하지 않던 문제를 수정했습니다. 예전에는 최적화 시점에 저장해 둔 주소 목록만 사용해 새 주소가 목록에 없었고, 화면에는 오류 대신 "페이지를 찾을 수 없음"만 나와 원인을 알기 어려웠습니다. 이제 확장을 설치·활성화·비활성화·삭제·업데이트할 때와 코어 업데이트 후에 주소 목록이 자동으로 다시 만들어집니다.
- 코어를 업데이트하면 주소 목록 최적화가 꺼진 채로 남던 문제를 함께 수정했습니다. 업데이트 도중 목록을 비우기만 하고 다시 만들지 않아, 관리자가 직접 '시스템 최적화'를 실행할 때까지 사이트가 최적화 없이 동작했습니다.
- 모듈·플러그인 업데이트에서 '수정 유지'를 선택해도 직접 고친 화면이 새 버전으로 덮어써지던 문제를 수정했습니다. 업데이트 과정이 보존 여부를 판단하기 전에 모든 화면을 파일 기준으로 먼저 되돌려, 비교할 수정본이 남지 않아 '수정 유지'가 항상 무효가 됐습니다. 이제 선택한 대로 수정한 화면이 그대로 유지됩니다.
- 업데이트 전 표시되는 "수정된 레이아웃" 안내가 확인에 실패했을 때도 "수정된 레이아웃이 없습니다"라고 단언하던 문제를 수정했습니다. 네트워크가 끊긴 상태에서 그대로 '모두 교체'를 진행하면 직접 고친 화면이 사라질 수 있었습니다. 이제 확인하지 못했다는 사실과 함께 '수정 유지' 권장 안내가 표시됩니다.
- 템플릿 업데이트 안내가 그 템플릿에 등록된 모듈·플러그인 화면의 수정까지 자기 것으로 세던 문제를 수정했습니다. 실제로 교체·보존되는 대상은 템플릿 자신의 화면뿐이라 표시 건수와 실제 동작이 어긋났습니다.
@@ -122,6 +144,9 @@
#### 화면 표시·목록 상태
- 건수가 아주 많아 총 건수를 끝까지 세지 못하는 목록에서 항목 번호가 0 이나 음수로 표시되던 문제를 수정했습니다. 번호를 지어내지 않고 「-」로 표시하며, 총 건수를 정확히 센 목록의 번호는 종전과 동일합니다.
- 활동 로그 목록의 총 건수가 끝까지 세지 못한 값인데도 정확한 숫자처럼 표시되던 문제를 수정했습니다. 이제 그 경우 「이상」 표시가 함께 나옵니다.
- 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.)
- 위 문제와 같은 뿌리에서 비롯된 화면 표시 오류를 함께 정리했습니다. 같은 방식으로 작성한 값이 어디에 놓이느냐(목록 칸·본문·표시 조건·버튼 동작)에 따라 다르게 해석되던 것을 한 가지 기준으로 통일했으며, 아래 항목이 그 결과입니다. 대부분 오류 메시지 없이 값이 비거나 잘못 보이던 증상이라 눈치채기 어려웠습니다.
- 행마다 달라지는 조건(예: "활성 상태인 항목만 표시")을 목록에 걸면 해당하는 행만 걸러지지 않고 **목록 전체가 사라지던** 문제를 수정했습니다. 이제 행마다 조건을 따져 표시하며, 검색엔진 봇이 보는 화면에도 같은 규칙이 적용됩니다.
@@ -17,6 +17,7 @@ use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -543,6 +544,11 @@ class CoreUpdateCommand extends Command
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
// 라우트 캐시도 같은 이유로 되살린다. 코어 업데이트는 routes/*.php 와 vendor 를
// 교체하므로 흐름 중간에 비우는 것이 맞지만, 비운 채로 끝내면 이후 모든 요청이
// 라우트를 다시 등록한다. 재생성은 파일이 전부 안착한 이 지점에서만 안전하다.
RouteCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
@@ -12,6 +12,8 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -251,7 +253,10 @@ class ExecuteUpgradeStepsCommand extends Command
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
\App\Support\ConfigCacheHelper::rebuild();
ConfigCacheHelper::rebuild();
// 라우트 캐시도 같은 이유로 되살린다 — 업그레이드 스텝이나 번들 확장 업데이트가
// 라우트를 바꿨을 수 있고, 비운 채로 끝내면 이후 모든 요청이 라우트를 재등록한다.
RouteCacheHelper::rebuild();
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
@@ -0,0 +1,39 @@
<?php
namespace App\Contracts\Pagination;
use App\Enums\TotalRelation;
/**
* 총 건수 정확도를 스스로 밝히는 페이지 결과 계약
*
* 표준 `LengthAwarePaginator` 는 `total()` 이 항상 정확하다고 가정한다. 대용량 목록에서
* 상한을 걸고 센 결과는 그 가정을 만족하지 못하므로, 정확도를 함께 실어 나른다.
*
* 이 계약을 구현한 페이지는 `BaseApiCollection::paginationMeta()` 가 자동으로 알아보고
* `total_relation` / `total_is_exact` / `result_cap` 메타를 응답에 덧붙인다. 컬렉션 쪽
* 코드 변경은 필요 없다.
*/
interface BoundedTotalAware
{
/**
* 총 건수와 실제 매칭 건수의 관계를 반환합니다.
*
* @return TotalRelation 정확(Exact) 또는 하한(AtLeast)
*/
public function totalRelation(): TotalRelation;
/**
* 총 건수 집계에 적용된 상한을 반환합니다.
*
* @return int|null 상한 (무제한이면 null)
*/
public function resultCap(): ?int;
/**
* 총 건수가 상한에 걸려 잘렸는지 여부를 반환합니다.
*
* @return bool 잘렸으면 true (= totalRelation 이 AtLeast)
*/
public function isTruncated(): bool;
}
@@ -2,6 +2,7 @@
namespace App\Contracts\Repositories;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -18,20 +19,22 @@ interface ActivityLogRepositoryInterface
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator;
/**
* 활동 로그 목록을 페이지네이션하여 조회합니다.
*
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로 응답합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getPaginated(array $filters = []): LengthAwarePaginator;
public function getPaginated(array $filters = []): LengthAwarePaginator|CursorPaginator;
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
@@ -39,7 +42,7 @@ interface ActivityLogRepositoryInterface
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int;
@@ -5,6 +5,7 @@ namespace App\Contracts\Repositories;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
interface NotificationLogRepositoryInterface
@@ -44,5 +45,5 @@ interface NotificationLogRepositoryInterface
*
* @param User|null $scopeUser 스코프 적용 대상 사용자 (null이면 스코프 미적용)
*/
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator;
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator|CursorPaginator;
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Enums;
/**
* 목록 총 건수(total)와 실제 매칭 건수의 관계
*
* 대용량 목록에서 `COUNT(*)` 전량 집계는 비용이 커서 상한을 걸고 센다.
* 상한 이하면 센 값이 곧 실제 건수이고, 상한을 넘으면 "그 이상"이라는 사실만 알 수 있다.
* 화면은 이 값을 보고 "1,234건" 과 "10,000건 이상" 을 구분해 표기한다.
*/
enum TotalRelation: string
{
/** 정확 — total 이 실제 매칭 건수와 같다 */
case Exact = 'exact';
/** 하한 — 실제 매칭 건수가 total 이상이다 (상한 초과로 정확히 세지 않음) */
case AtLeast = 'at_least';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 총 건수가 정확한지 여부를 반환합니다.
*
* @return bool 정확하면 true
*/
public function isExact(): bool
{
return $this === self::Exact;
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string lang 키 해석 결과
*/
public function label(): string
{
return __('pagination.total_relation.'.$this->value);
}
}
+4 -9
View File
@@ -124,15 +124,10 @@ class HookListenerRegistrar
self::addQueuedAction($hookName, $listenerClass, $method, $priority);
}
Log::info('훅 리스너 등록 완료', [
'hook' => $hookName,
'listener' => $listenerClass,
'method' => $method,
'priority' => $priority,
'type' => $type,
'sync' => $forceSync,
'source' => $source,
]);
// 등록 성공은 로그로 남기지 않는다. 리스너 112개 × 구독 400건이 요청마다 부팅되므로
// 한 줄씩만 남겨도 매 요청 400줄이 쌓인다. 기본 설치가 production + LOG_LEVEL 조합상
// 이 줄들을 그대로 기록하므로, 정상 동작을 알리는 데 드는 비용이 동작 자체보다 커진다.
// 등록 실패는 아래 catch 에서 계속 기록한다.
}
}
+6
View File
@@ -43,6 +43,7 @@ use App\Models\Template;
use App\Providers\CoreServiceProvider;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use App\Support\RouteCacheHelper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
@@ -517,6 +518,7 @@ class ModuleManager implements ModuleManagerInterface
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
@@ -646,6 +648,7 @@ class ModuleManager implements ModuleManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
@@ -788,6 +791,7 @@ class ModuleManager implements ModuleManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module);
@@ -968,6 +972,7 @@ class ModuleManager implements ModuleManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module);
@@ -4512,6 +4517,7 @@ class ModuleManager implements ModuleManagerInterface
$this->clearAllTemplateLanguageCaches();
$this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
self::invalidateModuleStatusCache();
// 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
+6
View File
@@ -43,6 +43,7 @@ use App\Providers\CoreServiceProvider;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use App\Support\RouteCacheHelper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
@@ -495,6 +496,7 @@ class PluginManager implements PluginManagerInterface
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
@@ -627,6 +629,7 @@ class PluginManager implements PluginManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
@@ -772,6 +775,7 @@ class PluginManager implements PluginManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin);
@@ -996,6 +1000,7 @@ class PluginManager implements PluginManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin);
@@ -4692,6 +4697,7 @@ class PluginManager implements PluginManagerInterface
$this->clearAllTemplateLanguageCaches();
$this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
self::invalidatePluginStatusCache();
// 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Base;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
@@ -21,7 +22,7 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자 소유권을 확인합니다.
*
* @param int $userId 확인할 사용자 ID
* @param int $userId 확인할 사용자 ID
* @return bool
*/
protected function isOwner(int $userId): bool
@@ -32,7 +33,7 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자가 특정 리소스에 접근할 수 있는지 확인합니다.
*
* @param mixed $resource 접근하려는 리소스
* @param mixed $resource 접근하려는 리소스
* @return bool
*/
protected function canAccessResource($resource): bool
@@ -48,8 +49,8 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자 활동을 기록합니다.
*
* @param string $action 수행한 작업
* @param array $data 관련 데이터
* @param string $action 수행한 작업
* @param array $data 관련 데이터
* @return void
*/
protected function logUserActivity(string $action, array $data = []): void
@@ -58,20 +59,20 @@ abstract class AuthBaseController extends BaseApiController
Log::info("User Activity: {$action}", [
'user_id' => $this->getCurrentUser()?->uuid,
'data' => $data,
'timestamp' => now()
'timestamp' => now(),
]);
}
/**
* 리소스 소유권을 확인하고, 소유자가 아니면 Forbidden 응답을 반환합니다.
*
* @param mixed $resource 확인할 리소스
* @param string $messageKey 오류 메시지 키
* @return \Illuminate\Http\JsonResponse|null 소유자이면 null, 아니면 Forbidden 응답
* @param mixed $resource 확인할 리소스
* @param string $messageKey 오류 메시지 키
* @return JsonResponse|null 소유자이면 null, 아니면 Forbidden 응답
*/
protected function checkOwnership($resource, string $messageKey = 'common.forbidden')
{
if (!$this->canAccessResource($resource)) {
if (! $this->canAccessResource($resource)) {
return $this->forbidden($messageKey);
}
@@ -81,14 +82,21 @@ abstract class AuthBaseController extends BaseApiController
/**
* API 사용량을 기록합니다.
*
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @return void
*/
protected function logApiUsage(string $endpoint, array $data = []): void
{
// TODO: API 사용량 통계 시스템 구현
Log::info("Auth API Usage: {$endpoint}", [
//
// PublicBaseController 와 같은 이유로 디버그 모드에서만 기록한다.
// 통계 시스템이 서기 전까지 요청마다 로그 파일에 줄을 쌓지 않는다.
if (! config('app.debug')) {
return;
}
Log::debug("Auth API Usage: {$endpoint}", [
'user_id' => $this->getCurrentUser()?->uuid,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
@@ -3,9 +3,7 @@
namespace App\Http\Controllers\Api\Base;
use App\Contracts\Extension\CacheInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 API용 베이스 컨트롤러
@@ -27,9 +25,9 @@ abstract class PublicBaseController extends BaseApiController
* 이는 확장 설치 직후·활성화 전 등의 일시적 상태에서 얻은 "not found" 같은
* 에러 응답이 영구 캐시되어 복구 후에도 잘못된 응답을 반환하는 문제를 방지한다.
*
* @param string $key 캐시 키
* @param callable $callback 데이터 생성 콜백
* @param int $ttl 캐시 유지 시간 (초)
* @param string $key 캐시 키
* @param callable $callback 데이터 생성 콜백
* @param int $ttl 캐시 유지 시간 (초)
* @return mixed
*/
protected function cached(string $key, callable $callback, int $ttl = 3600)
@@ -54,18 +52,27 @@ abstract class PublicBaseController extends BaseApiController
/**
* API 사용량을 기록합니다.
*
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @return void
*/
protected function logApiUsage(string $endpoint, array $data = []): void
{
// TODO: API 사용량 통계 시스템 구현
Log::info("Public API Usage: {$endpoint}", [
//
// 호출 지점은 레이아웃·확장 번들·정적 자산처럼 페이지 로드마다 여러 번 열리는
// 공개 엔드포인트다. 통계 시스템이 서기 전까지 이 자리가 요청마다 로그 파일에
// 줄을 쌓지 않도록, 디버그 모드에서만 기록한다. 기본 설치는 APP_DEBUG=false 이므로
// 아무것도 쓰지 않는다.
if (! config('app.debug')) {
return;
}
Log::debug("Public API Usage: {$endpoint}", [
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'data' => $data,
'timestamp' => now()
'timestamp' => now(),
]);
}
@@ -80,8 +87,7 @@ abstract class PublicBaseController extends BaseApiController
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'referer' => request()->header('referer'),
'timestamp' => now()
'timestamp' => now(),
];
}
}
@@ -2,9 +2,11 @@
namespace App\Http\Controllers\Api\Public;
use App\Enums\TotalRelation;
use App\Extension\HookManager;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\SearchRequest;
use App\Support\Query\PaginationLimits;
use Illuminate\Http\JsonResponse;
/**
@@ -18,7 +20,7 @@ class PublicSearchController extends PublicBaseController
/**
* 통합 검색 수행
*
* @param SearchRequest $request HTTP 요청
* @param SearchRequest $request HTTP 요청
* @return JsonResponse 검색 결과
*/
public function search(SearchRequest $request): JsonResponse
@@ -36,6 +38,14 @@ class PublicSearchController extends PublicBaseController
return $this->success(__('search.empty_keyword'), $this->buildEmptyResponse());
}
// 특정 탭을 보는 중이라면, 다른 탭의 배지 건수를 아예 세지 않을 수 있다.
// 기본값은 세는 쪽이다 — 탭 배지가 화면 요소라 끄면 숫자가 사라진다. 다만 배지가
// 필요 없는 화면(더 보기 페이지 등)은 with_counts=false 로 그 비용을 없앤다.
// 전체 탭은 배지가 화면의 주 정보라 이 값과 무관하게 항상 센다.
$includeInactiveCounts = $type === 'all'
? true
: $request->boolean('with_counts', true);
// 검색 컨텍스트 구성 (모듈별 파라미터는 request 전체를 전달하여 각 모듈이 직접 처리)
$context = [
'q' => $q,
@@ -44,6 +54,10 @@ class PublicSearchController extends PublicBaseController
'page' => $page,
'per_page' => $perPage,
'all_tab_limit' => 5,
// 커서는 특정 탭을 볼 때만 의미가 있다. 전체 탭은 카테고리마다 몇 건씩만
// 보여 주므로 깊은 페이지 자체가 없고, 카테고리별 커서를 하나로 합칠 수도 없다.
'cursor' => $type === 'all' ? null : ($validated['cursor'] ?? null),
'include_inactive_counts' => $includeInactiveCounts,
'user' => $request->user(),
'request' => $request,
];
@@ -56,8 +70,14 @@ class PublicSearchController extends PublicBaseController
// 프론트엔드용 응답 구조로 변환 (훅을 통해 모듈이 처리)
$response = $this->buildResponse($q, $results, $context);
// 총 건수가 상한에 걸렸으면 "N건" 이 아니라 "N건 이상" 으로 알린다.
// 세지 않은 값을 정확한 것처럼 말하지 않는다.
$messageKey = ($response['total_is_exact'] ?? true)
? 'search.results_found'
: 'search.results_found_at_least';
return $this->success(
__('search.results_found', ['count' => number_format($response['total'])]),
__($messageKey, ['count' => number_format($response['total'])]),
$response
);
}
@@ -72,6 +92,9 @@ class PublicSearchController extends PublicBaseController
return [
'q' => '',
'total' => 0,
'total_relation' => TotalRelation::Exact->value,
'total_is_exact' => true,
'result_cap' => null,
];
}
@@ -81,9 +104,9 @@ class PublicSearchController extends PublicBaseController
* 코어는 기본 응답 구조만 생성하고,
* 각 모듈이 core.search.build_response 훅을 통해 자신의 카테고리 응답을 구성합니다.
*
* @param string $q 검색어
* @param array $results Hook에서 반환된 검색 결과
* @param array $context 검색 컨텍스트
* @param string $q 검색어
* @param array $results Hook에서 반환된 검색 결과
* @param array $context 검색 컨텍스트
* @return array 프론트엔드용 응답 구조
*/
private function buildResponse(string $q, array $results, array $context): array
@@ -97,8 +120,15 @@ class PublicSearchController extends PublicBaseController
// 모듈은 $results에서 자신의 데이터를 가져와 $response에 추가
$response = HookManager::applyFilters('core.search.build_response', $response, $results, $context);
// 전체 합계를 항상 보존 (탭 UI에서 전체 건수 표시용)
// 전체 합계를 항상 보존 (탭 UI에서 전체 건수 표시용).
// "전체" 탭 배지도 잘린 합계인지 알아야 하므로 정확도를 함께 붙인다.
$response['all_count'] = $response['total'];
$response['all_count_is_exact'] = $this->resolveTotalAccuracy($results)['total_is_exact'];
// 탭 배지는 카테고리마다 하나씩 그려지므로 정확도도 카테고리마다 필요하다.
// 모듈이 각자 자기 키를 내보내면 어떤 배지는 정확도를 받고 어떤 배지는 못 받아,
// 상한에 걸린 숫자가 그 배지에서만 정확한 값처럼 보인다. 코어가 일괄로 붙인다.
$response['counts_are_exact'] = $this->resolveCategoryAccuracy($results);
// 특정 탭 조회 시 total을 해당 탭의 count로 설정
$type = $context['type'] ?? 'all';
@@ -109,13 +139,14 @@ class PublicSearchController extends PublicBaseController
}
}
return $response;
// 정확도 메타 — 전체 탭은 카테고리 합집합, 특정 탭은 그 카테고리 하나만 본다.
return array_merge($response, $this->resolveTotalAccuracy($results, $type));
}
/**
* 전체 검색 결과 수 계산
*
* @param array $results Hook에서 반환된 검색 결과
* @param array $results Hook에서 반환된 검색 결과
* @return int 전체 결과 수
*/
private function calculateTotal(array $results): int
@@ -127,4 +158,60 @@ class PublicSearchController extends PublicBaseController
return $total;
}
/**
* 카테고리별 총 건수 정확도를 모읍니다.
*
* 탭 배지는 카테고리 수만큼 그려지므로 정확도도 그 수만큼 있어야 합니다. 하나라도
* 빠지면 그 배지에서만 상한에 걸린 숫자가 정확한 값처럼 보이고, 오류로는 드러나지
* 않은 채 그냥 틀린 숫자로 남습니다.
*
* @param array $results Hook에서 반환된 검색 결과
* @return array<string, bool> 카테고리 => 정확 여부
*/
private function resolveCategoryAccuracy(array $results): array
{
$accuracy = [];
foreach ($results as $category => $categoryData) {
$accuracy[$category] = ($categoryData['total_is_exact'] ?? true) === true;
}
return $accuracy;
}
/**
* 응답 전체의 총 건수 정확도를 정합니다.
*
* 전체 탭에서는 카테고리 중 하나라도 상한에 걸리면 합계도 "이상" 이다 — 정확한
* 카테고리 몇 개를 더해 봐야 전체가 정확해지지 않는다.
*
* 특정 탭을 보는 중이면 `total` 이 그 카테고리 건수로 바뀌므로 정확도도 그 카테고리
* 하나만 본다. 다른 카테고리가 잘렸다는 이유로 정확한 값을 "이상" 이라 말하지 않는다.
*
* @param array $results Hook에서 반환된 검색 결과
* @param string $type 조회 탭 (all 이면 전체 합계)
* @return array{total_relation: string, total_is_exact: bool, result_cap: int|null} 정확도 메타
*/
private function resolveTotalAccuracy(array $results, string $type = 'all'): array
{
$scoped = ($type !== 'all' && array_key_exists($type, $results))
? [$results[$type]]
: $results;
$isExact = true;
foreach ($scoped as $categoryData) {
if (($categoryData['total_is_exact'] ?? true) === false) {
$isExact = false;
break;
}
}
return [
'total_relation' => $isExact ? TotalRelation::Exact->value : TotalRelation::AtLeast->value,
'total_is_exact' => $isExact,
'result_cap' => PaginationLimits::resultCap('search'),
];
}
}
+6 -19
View File
@@ -6,6 +6,7 @@ use App\Enums\PermissionType;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Models\Role;
use App\Support\GuestRoleResolver;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
@@ -14,13 +15,6 @@ use Symfony\Component\HttpFoundation\Response;
class PermissionMiddleware
{
/**
* guest role 캐시
*
* @var Role|null
*/
protected static $guestRoleCache = null;
/**
* 특정 권한을 가진 사용자 또는 비회원(guest)의 접근을 허용합니다.
*
@@ -125,10 +119,9 @@ class PermissionMiddleware
return false;
}
return $guestRole->permissions()
->where('identifier', $permission)
->where('type', $type)
->exists();
// 적재해 둔 권한 컬렉션에서 판정한다. permissions() 로 빌더를 다시 열면
// 캐시해 둔 의미가 사라져 판정마다 쿼리가 나간다.
return GuestRoleResolver::hasPermission($permission, $type);
}
/**
@@ -138,13 +131,7 @@ class PermissionMiddleware
*/
protected function getGuestRole(): ?Role
{
if (self::$guestRoleCache === null) {
self::$guestRoleCache = Role::where('identifier', 'guest')
->with('permissions')
->first();
}
return self::$guestRoleCache;
return GuestRoleResolver::resolve();
}
/**
@@ -158,7 +145,7 @@ class PermissionMiddleware
*/
public static function clearGuestRoleCache(): void
{
self::$guestRoleCache = null;
GuestRoleResolver::flush();
}
/**
@@ -47,6 +47,10 @@ class ActivityLogIndexRequest extends FormRequest
// 조용히 기본 정렬로 되돌리지 않도록 한다 (service-repository.md "정렬 컬럼 화이트리스트").
'sort_by' => ['nullable', 'string', Rule::in(['created_at', 'action', 'log_type'])],
'sort_order' => ['nullable', 'string', Rule::in(['asc', 'desc'])],
// 커서를 주면 목록이 키셋 방식으로 응답한다. 로그는 계속 쌓이기만 하므로 깊은
// 페이지를 OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 한다.
// 형식이 깨진 값은 KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다.
'cursor' => ['nullable', 'string', 'max:500'],
];
return HookManager::applyFilters('core.activity_log.index_validation_rules', $rules, $this);
@@ -40,6 +40,10 @@ class NotificationLogIndexRequest extends FormRequest
// 게이트가 더 좁으면 화면 정렬 셀렉트가 제공하는 옵션(수신자명순/제목순)이 422 로 막힌다.
'sort_by' => ['nullable', 'string', 'in:id,channel,notification_type,status,sent_at,created_at,recipient_name,subject'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
// 커서를 주면 목록이 키셋 방식으로 응답한다. 로그는 계속 쌓이기만 하므로 깊은
// 페이지를 OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 한다.
// 형식이 깨진 값은 KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다.
'cursor' => ['nullable', 'string', 'max:500'],
];
return HookManager::applyFilters(
+38 -2
View File
@@ -3,6 +3,8 @@
namespace App\Http\Requests\Public;
use App\Extension\HookManager;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
@@ -23,10 +25,30 @@ class SearchRequest extends FormRequest
return true;
}
/**
* 검증 전에 쿼리 파라미터를 정규화합니다.
*
* 쿼리스트링은 언제나 문자열로 도착하므로 `with_counts=false` 가 `boolean` 규칙에
* 걸려 422 가 된다. 해석 가능한 값만 캐스팅하고, 그 외(오타 등)는 손대지 않아
* 규칙이 그대로 걸러내게 둔다 — null 로 바꾸면 오타가 "미지정" 으로 통과한다.
*/
protected function prepareForValidation(): void
{
if (! $this->has('with_counts')) {
return;
}
$normalized = filter_var($this->input('with_counts'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($normalized !== null) {
$this->merge(['with_counts' => $normalized]);
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -35,8 +57,21 @@ class SearchRequest extends FormRequest
'q' => ['nullable', 'string', 'min:2', 'max:200'],
'type' => ['nullable', 'string'],
'sort' => ['nullable', 'string'],
'page' => ['nullable', 'integer', 'min:1'],
// 페이지 상한은 남용 차단용이다. 정상 탐색은 has_more_pages 로 계속 열려 있고,
// 임의의 큰 페이지 번호를 직접 던져 초대형 OFFSET 을 만드는 것만 막는다.
'page' => array_filter([
'nullable',
'integer',
'min:1',
($maxPage = PaginationLimits::maxPage('search')) !== null ? 'max:'.$maxPage : null,
]),
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
// 깊은 페이지를 OFFSET 없이 넘기기 위한 커서. 형식이 깨진 값은
// KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다.
'cursor' => ['nullable', 'string', 'max:500'],
// 특정 탭만 볼 때 다른 탭의 배지 건수를 세지 않게 하는 스위치.
// 배지를 그리지 않는 화면에서 그 집계 비용을 없앤다 (기본값은 세는 쪽).
'with_counts' => ['nullable', 'boolean'],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
@@ -59,6 +94,7 @@ class SearchRequest extends FormRequest
'q.max' => __('search.validation.q_max'),
'page.integer' => __('search.validation.page_integer'),
'page.min' => __('search.validation.page_min'),
'page.max' => __('search.validation.page_max'),
'per_page.integer' => __('search.validation.per_page_integer'),
'per_page.min' => __('search.validation.per_page_min'),
'per_page.max' => __('search.validation.per_page_max'),
@@ -308,6 +308,10 @@ class SaveSettingsRequest extends FormRequest
'advanced.geoip_license_key' => ['nullable', 'string', 'max:200', 'regex:/^[A-Za-z0-9_]+$/'],
'advanced.geoip_auto_update_enabled' => ['nullable', 'boolean'],
// 목록 한계값 (advanced 탭) — 0 은 무제한
'advanced.pagination_result_cap' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_pagination_result_cap_min', 0), 'max:'.config('core.settings_limits.advanced_pagination_result_cap_max', 1000000)],
'advanced.pagination_max_page' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_pagination_max_page_min', 0), 'max:'.config('core.settings_limits.advanced_pagination_max_page_max', 100000)],
// 드라이버 설정 (drivers 탭)
'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS)]),
'drivers.s3_bucket' => ['nullable', 'string', 'max:255'],
@@ -713,6 +717,14 @@ class SaveSettingsRequest extends FormRequest
'advanced.sql_query_log.required' => __('validation.settings.sql_query_log_required'),
'advanced.sql_query_log.boolean' => __('validation.settings.sql_query_log_boolean'),
// 목록 한계값
'advanced.pagination_result_cap.integer' => __('validation.settings.pagination_result_cap_integer'),
'advanced.pagination_result_cap.min' => __('validation.settings.pagination_result_cap_min'),
'advanced.pagination_result_cap.max' => __('validation.settings.pagination_result_cap_max'),
'advanced.pagination_max_page.integer' => __('validation.settings.pagination_max_page_integer'),
'advanced.pagination_max_page.min' => __('validation.settings.pagination_max_page_min'),
'advanced.pagination_max_page.max' => __('validation.settings.pagination_max_page_max'),
// 코어 업데이트 설정
'advanced.core_update_github_url.url' => __('validation.settings.core_update_github_url_invalid'),
'advanced.core_update_github_url.max' => __('validation.settings.core_update_github_url_max'),
@@ -895,6 +907,8 @@ class SaveSettingsRequest extends FormRequest
'advanced.geoip_enabled' => __('validation.attributes.geoip_enabled'),
'advanced.geoip_license_key' => __('validation.attributes.geoip_license_key'),
'advanced.geoip_auto_update_enabled' => __('validation.attributes.geoip_auto_update_enabled'),
'advanced.pagination_result_cap' => __('validation.attributes.pagination_result_cap'),
'advanced.pagination_max_page' => __('validation.attributes.pagination_max_page'),
// drivers
'drivers.storage_driver' => __('validation.attributes.storage_driver'),
'drivers.s3_bucket' => __('validation.attributes.s3_bucket'),
+4 -10
View File
@@ -3,7 +3,6 @@
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 활동 로그 컬렉션 리소스
@@ -36,15 +35,10 @@ class ActivityLogCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($activityLog) {
return (new ActivityLogResource($activityLog))->toArray(request());
}),
'pagination' => $this->resource instanceof LengthAwarePaginator ? [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
] : null,
// 메타를 손으로 조립하면 정확도 필드(total_relation·total_is_exact·result_cap)가
// 빠진다. 그러면 상한에 걸려 잘린 건수가 화면에서 정확한 값처럼 읽힌다
// (실측: 실제 88,792 건이 "10000건" 으로 표기). 표준 메타 한 곳만 쓴다.
...$this->paginationMeta(),
...($abilities ? ['abilities' => $abilities] : []),
];
}
@@ -36,15 +36,8 @@ class IdentityMessageDefinitionCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($definition) use ($request) {
return (new IdentityMessageDefinitionResource($definition))->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
'has_more_pages' => $this->hasMorePages(),
],
// 표준 메타를 쓴다 — 페이지 결과의 형태를 스스로 판정해 그 형태가 아는 값만 낸다.
...$this->paginationMeta(),
...($abilities ? ['abilities' => $abilities] : []),
];
}
+65 -10
View File
@@ -2,10 +2,15 @@
namespace App\Http\Resources;
use App\Contracts\Pagination\BoundedTotalAware;
use App\Helpers\PermissionHelper;
use App\Http\Resources\Traits\HasRowNumber;
use App\Support\Query\BoundedPage;
use App\Support\Query\KeysetPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\CursorPaginator;
/**
* API 컬렉션 리소스 기본 클래스
@@ -61,24 +66,74 @@ abstract class BaseApiCollection extends ResourceCollection
* 정확히 판정할 수 있도록 노출합니다. 전체 조회(get) 등 paginator 가 아닌
* 경우에는 빈 배열을 반환하므로 toArray 에서 array_merge 로 안전하게 합칠 수 있습니다.
*
* 페이지 결과는 세 형태 중 하나이며, 형태를 스스로 판정해 그 형태가 실제로 아는
* 값만 내보냅니다. 모르는 값을 0 이나 1 로 채우면 화면이 그것을 사실로 읽습니다.
*
* | 형태 | 예 | total | last_page | 추가 |
* |---|---|---|---|---|
* | 전체건수형 | `paginate()` | 정확 | 정확 | — |
* | 상한형 | {@see BoundedPage} | 정확 또는 하한 | 하한이면 null | `total_relation` `total_is_exact` `result_cap` |
* | 단순형 | `simplePaginate()` / `cursorPaginate()` | 없음 | 없음 | 커서면 `next_cursor` `prev_cursor` |
*
* 기존 필드는 하나도 제거하지 않으므로, 표준 `paginate()` 를 쓰는 기존 컬렉션의
* 응답은 이전과 완전히 동일합니다.
*
* @return array<string, mixed> ['pagination' => [...]] 또는 빈 배열
*/
protected function paginationMeta(): array
{
if (! method_exists($this->resource, 'currentPage')) {
$resource = $this->resource;
if ($resource instanceof CursorPaginator) {
return ['pagination' => $this->cursorPaginationMeta($resource)];
}
if (! method_exists($resource, 'currentPage')) {
return [];
}
$meta = [
'current_page' => $resource->currentPage(),
'per_page' => $resource->perPage(),
'from' => $resource->firstItem(),
'to' => $resource->lastItem(),
'has_more_pages' => $resource->hasMorePages(),
];
// 단순형(simplePaginate)은 총 건수를 아예 세지 않으므로 total/last_page 를 내보내지 않는다.
if ($resource instanceof LengthAwarePaginatorContract) {
$meta['last_page'] = $resource->lastPage();
$meta['total'] = $resource->total();
}
if ($resource instanceof BoundedTotalAware) {
$meta['total_relation'] = $resource->totalRelation()->value;
$meta['total_is_exact'] = $resource->totalRelation()->isExact();
$meta['result_cap'] = $resource->resultCap();
}
return ['pagination' => $meta];
}
/**
* 커서 페이지의 pagination 메타를 만듭니다.
*
* 커서 방식은 총 건수와 페이지 번호를 계산하지 않습니다. 대신 앞뒤 이동 커서를
* 그대로 실어 보내며, 화면은 이 값으로 이전/다음 버튼을 만듭니다.
*
* 인코딩은 {@see KeysetPaginator} 를 거칩니다. 커서 문자열의 형식은 디코딩과 짝을
* 이뤄야 하므로, 한쪽만 여기서 직접 만들면 표준이 두 벌이 됩니다.
*
* @param CursorPaginator $resource 커서 페이지 결과
* @return array<string, mixed> pagination 메타
*/
private function cursorPaginationMeta(CursorPaginator $resource): array
{
return [
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
'per_page' => $resource->perPage(),
'has_more_pages' => $resource->hasMorePages(),
'next_cursor' => KeysetPaginator::nextCursor($resource),
'prev_cursor' => KeysetPaginator::previousCursor($resource),
];
}
}
@@ -19,7 +19,7 @@ class NotificationDefinitionCollection extends BaseApiCollection
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
@@ -31,15 +31,8 @@ class NotificationDefinitionCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($definition) use ($request) {
return (new NotificationDefinitionResource($definition))->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
'has_more_pages' => $this->hasMorePages(),
],
// 표준 메타를 쓴다 — 페이지 결과의 형태를 스스로 판정해 그 형태가 아는 값만 낸다.
...$this->paginationMeta(),
...($abilities ? ['abilities' => $abilities] : []),
];
}
@@ -19,7 +19,7 @@ class NotificationLogCollection extends BaseApiCollection
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
@@ -31,15 +31,10 @@ class NotificationLogCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($log) use ($request) {
return (new NotificationLogResource($log))->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
// 표준 메타를 쓴다. 이 목록은 커서 요청이면 CursorPaginator 로, 그 외에는 상한형
// 페이지로 온다. 손으로 조립하면 커서 결과에 없는 total()/lastPage() 를 불러
// 그 요청만 500 이 되고, 상한형에서는 정확도 메타가 빠진다.
...$this->paginationMeta(),
...($abilities ? ['abilities' => $abilities] : []),
];
}
+12 -2
View File
@@ -2,6 +2,7 @@
namespace App\Http\Resources\Traits;
use App\Contracts\Pagination\BoundedTotalAware;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@@ -57,7 +58,7 @@ trait HasRowNumber
* @param int $total 전체 항목 수
* @param int $perPage 페이지당 항목 수
* @param int $currentPage 현재 페이지 번호
* @return int 계산된 순번
* @return int|null 계산된 순번 (총 건수가 잘린 내림차순이면 null)
*/
protected function calculateRowNumber(
int $index,
@@ -65,13 +66,22 @@ trait HasRowNumber
int $total,
int $perPage,
int $currentPage
): int {
): ?int {
if ($sortOrder === 'asc') {
// 오름차순: 1, 2, 3, ... (페이지별 연속)
// 예: 2페이지 → ((2-1) * 10) + 0 + 1 = 11, 12, 13, ...
// offset 만으로 정해지므로 총 건수가 잘려도 그대로 정확하다.
return (($currentPage - 1) * $perPage) + $index + 1;
}
// 내림차순 순번은 "전체 몇 건 중 몇 번째" 라 총 건수를 알아야 나온다. 총 건수가
// 상한에 걸려 잘렸으면 그 값으로 역산한 순번은 첫 페이지부터 이미 틀리고, 상한을
// 넘어선 페이지에서는 0 과 음수까지 내려간다. 틀린 숫자를 내보내는 것보다
// 내보내지 않는 편이 낫으므로 `last_page` 와 같은 원칙으로 null 을 돌려준다.
if ($this->resource instanceof BoundedTotalAware && ! $this->resource->totalRelation()->isExact()) {
return null;
}
// 내림차순: total, total-1, ... (역순)
// 예: 총 30개, 2페이지 → 30 - ((2-1) * 10) - 0 = 20, 19, 18, ...
return $total - (($currentPage - 1) * $perPage) - $index;
@@ -5,14 +5,16 @@ namespace App\Http\Resources;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use Illuminate\Http\Request;
/**
* 알림함 컬렉션
*/
class UserNotificationCollection extends BaseApiCollection
{
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @return array
* @param Request $request HTTP 요청
* @return array<string, mixed> 변환된 배열
*/
public function toArray(Request $request): array
{
@@ -29,15 +31,10 @@ class UserNotificationCollection extends BaseApiCollection
->withTypeLabelMap($typeLabelMap)
->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
// 표준 메타를 쓴다. 손으로 조립하면 페이지 결과의 형태(전체건수형/상한형/단순형)를
// 구분하지 못해, 상한에 걸려 잘린 총 건수가 정확한 값처럼 나가고 마지막 페이지도
// 계산할 수 없는데 숫자가 채워진다.
...$this->paginationMeta(),
];
}
}
+114 -50
View File
@@ -51,6 +51,22 @@ class User extends Authenticatable implements HasLocalePreference
*/
protected array $effectiveScopeCache = [];
/**
* 보유 권한 부여 내역 캐시 (인스턴스 레벨)
*
* 한 요청에서 권한 판정은 수십~수백 번 일어난다 — 레이아웃 노드마다, 목록 행마다,
* 리소스 필드마다 부른다. 판정마다 DB 에 물으면 노드/행 수에 비례해 쿼리가 늘어난다.
* 첫 판정에서 `roles.permissions` 를 한 번 적재해 두고 이후로는 배열만 본다.
*
* 캐시 수명은 **모델 인스턴스**, 즉 요청 스코프다. 크로스 요청 캐시를 두지 않으므로
* 권한을 바꾸면 다음 요청부터 즉시 반영된다.
*
* 구조: 권한 식별자 ⇒ [{type, scope_type}, ...] (같은 권한을 여러 역할이 주면 여러 건)
*
* @var array<string, array<int, array{type: string|null, scope_type: string|null}>>|null
*/
protected ?array $permissionGrantsCache = null;
/**
* 테이블명
*
@@ -244,14 +260,79 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function hasPermission(string $permission, ?PermissionType $type = null): bool
{
return $this->roles()
->whereHas('permissions', function ($query) use ($permission, $type) {
$query->where('identifier', $permission);
if ($type !== null) {
$query->where('type', $type);
}
})
->exists();
$grants = $this->permissionGrants()[$permission] ?? null;
if ($grants === null) {
return false;
}
if ($type === null) {
return true;
}
foreach ($grants as $grant) {
if ($grant['type'] === $type->value) {
return true;
}
}
return false;
}
/**
* 보유 권한 부여 내역을 반환합니다 (인스턴스 캐시).
*
* 역할 → 권한을 한 번만 적재하고, 이후 판정은 전부 이 배열에서 이루어집니다.
*
* @return array<string, array<int, array{type: string|null, scope_type: string|null}>> 권한 식별자 ⇒ 부여 내역
*/
protected function permissionGrants(): array
{
if ($this->permissionGrantsCache !== null) {
return $this->permissionGrantsCache;
}
$grants = [];
foreach ($this->roles()->with('permissions')->get() as $role) {
foreach ($role->permissions as $permission) {
$grants[$permission->identifier][] = [
'type' => $this->enumValue($permission->type),
'scope_type' => $this->enumValue($permission->pivot->scope_type ?? null),
];
}
}
return $this->permissionGrantsCache = $grants;
}
/**
* Enum 또는 원시 값을 문자열로 정규화합니다.
*
* 캐스팅 설정 유무에 따라 Enum 인스턴스와 문자열이 섞여 들어오므로 한 형태로 맞춥니다.
*
* @param mixed $value Enum 인스턴스 · 문자열 · null
* @return string|null 정규화된 문자열 (부재 시 null)
*/
private function enumValue(mixed $value): ?string
{
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
return $value === null ? null : (string) $value;
}
/**
* 권한 판정 캐시를 비웁니다.
*
* 같은 인스턴스에서 역할을 바꾼 직후 다시 판정해야 하는 경우에 호출합니다.
* (역할 동기화 서비스가 호출하며, 일반 조회 경로에서는 필요하지 않습니다)
*/
public function flushPermissionCaches(): void
{
$this->permissionGrantsCache = null;
$this->effectiveScopeCache = [];
}
/**
@@ -263,27 +344,17 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function hasPermissions(array $permissions, bool $requireAll = true, ?PermissionType $type = null): bool
{
$userPermissions = $this->roles()
->whereHas('permissions', function ($query) use ($permissions, $type) {
$query->whereIn('identifier', $permissions);
if ($type !== null) {
$query->where('type', $type);
}
})
->with(['permissions' => function ($query) use ($permissions, $type) {
$query->whereIn('identifier', $permissions);
if ($type !== null) {
$query->where('type', $type);
}
}])
->get()
->pluck('permissions')
->flatten()
->pluck('identifier')
->unique()
->count();
// 같은 권한 집합을 hasPermission 과 공유한다 — 권한 개수만큼 쿼리가 늘지 않는다.
// 비교 기준(고유 매칭 수 vs 인자 개수)은 종전과 동일하게 유지한다.
$matched = 0;
return $requireAll ? $userPermissions === count($permissions) : $userPermissions > 0;
foreach (array_unique($permissions) as $permission) {
if ($this->hasPermission($permission, $type)) {
$matched++;
}
}
return $requireAll ? $matched === count($permissions) : $matched > 0;
}
/**
@@ -303,33 +374,21 @@ class User extends Authenticatable implements HasLocalePreference
return $this->effectiveScopeCache[$identifier];
}
$scopeTypes = $this->roles()
->whereHas('permissions', function ($query) use ($identifier) {
$query->where('identifier', $identifier);
})
->with(['permissions' => function ($query) use ($identifier) {
$query->where('identifier', $identifier);
}])
->get()
->pluck('permissions')
->flatten()
->pluck('pivot.scope_type');
// 같은 권한 집합에서 scope_type 만 뽑는다 — 권한마다 쿼리를 다시 내지 않는다.
$values = array_column($this->permissionGrants()[$identifier] ?? [], 'scope_type');
// 권한 미보유 시 null 반환 (기본값: 전체 접근)
if ($scopeTypes->isEmpty()) {
if ($values === []) {
return $this->effectiveScopeCache[$identifier] = null;
}
// union 정책: 하나라도 null → 전체 접근
if ($scopeTypes->contains(null)) {
if (in_array(null, $values, true)) {
return $this->effectiveScopeCache[$identifier] = null;
}
// ScopeType Enum 값을 문자열로 변환하여 비교
$values = $scopeTypes->map(fn ($scope) => $scope instanceof ScopeType ? $scope->value : $scope);
// 하나라도 'role' → role 적용
if ($values->contains('role')) {
if (in_array(ScopeType::Role->value, $values, true)) {
return $this->effectiveScopeCache[$identifier] = 'role';
}
@@ -369,11 +428,16 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function isAdmin(): bool
{
return $this->roles()
->whereHas('permissions', function ($query) {
$query->where('type', PermissionType::Admin);
})
->exists();
// 같은 권한 집합을 재사용한다 — 관리자 판정은 미들웨어·리소스·레이아웃에서 반복 호출된다.
foreach ($this->permissionGrants() as $grants) {
foreach ($grants as $grant) {
if ($grant['type'] === PermissionType::Admin->value) {
return true;
}
}
}
return false;
}
/**
+8 -20
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Models\Role;
use App\Models\User;
use App\Support\GuestRoleResolver;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
@@ -11,11 +12,6 @@ use Illuminate\Support\Facades\Log;
class AuthServiceProvider extends ServiceProvider
{
/**
* guest role 캐시
*/
protected static ?Role $guestRoleCache = null;
/**
* Register any authentication / authorization services.
*/
@@ -236,9 +232,9 @@ class AuthServiceProvider extends ServiceProvider
return null; // guest role 없으면 다음 Gate 정의로 위임
}
return $guestRole->permissions()
->where('identifier', $ability)
->exists() ? true : null;
// 적재해 둔 권한 컬렉션에서 판정한다. 빌더를 다시 열면 캐시가 무의미해져
// Gate 호출마다 쿼리가 나간다.
return GuestRoleResolver::hasPermission($ability) ? true : null;
}
/**
@@ -246,13 +242,7 @@ class AuthServiceProvider extends ServiceProvider
*/
protected static function getGuestRole(): ?Role
{
if (self::$guestRoleCache === null) {
self::$guestRoleCache = Role::where('identifier', 'guest')
->with('permissions')
->first();
}
return self::$guestRoleCache;
return GuestRoleResolver::resolve();
}
/**
@@ -260,7 +250,7 @@ class AuthServiceProvider extends ServiceProvider
*/
public static function clearGuestRoleCache(): void
{
self::$guestRoleCache = null;
GuestRoleResolver::flush();
}
/**
@@ -275,16 +265,14 @@ class AuthServiceProvider extends ServiceProvider
$hasPermission = false;
if ($guestRole) {
$hasPermission = $guestRole->permissions()
->where('identifier', $ability)
->exists();
$hasPermission = GuestRoleResolver::hasPermission($ability);
}
return [
'result' => self::checkGuestPermission($ability),
'guestRole' => $guestRole,
'hasPermission' => $hasPermission,
'cachedRoleId' => self::$guestRoleCache?->id,
'cachedRoleId' => $guestRole?->id,
];
}
}
+1 -1
View File
@@ -1019,7 +1019,7 @@ class CoreServiceProvider extends ServiceProvider
$listener = app($listenerClass);
$listener->registerDynamicHooks();
Log::info('동적 훅 리스너 등록 완료', ['listener' => $listenerClass]);
// 등록 성공은 로그로 남기지 않는다 — 요청마다 부팅되는 경로다. 실패만 아래에 남긴다.
} catch (\Throwable $e) {
Log::warning('동적 훅 리스너 등록 실패', [
'listener' => $listenerClass,
+7 -6
View File
@@ -2,10 +2,9 @@
namespace App\Providers;
use App\Enums\ExtensionStatus;
use App\Extension\ExtensionManager;
use App\Extension\Testing\ExtensionTestAllowlist;
use App\Models\Module;
use App\Extension\Traits\CachesModuleStatus;
use App\Support\InstallerContext;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\File;
@@ -14,6 +13,8 @@ use Illuminate\Support\Facades\Schema;
class ModuleRouteServiceProvider extends ServiceProvider
{
use CachesModuleStatus;
/**
* The path to the "home" route for your application.
*
@@ -74,10 +75,10 @@ class ModuleRouteServiceProvider extends ServiceProvider
}
}
// 활성화된 모듈 identifier 목록 가져오기
$activeModuleIdentifiers = Module::where('status', ExtensionStatus::Active->value)
->pluck('identifier')
->toArray();
// 활성화된 모듈 identifier 목록 가져오기.
// 같은 목록을 ModuleManager·ModuleServiceProvider 가 이미 캐시(TTL 기본 하루)해 두므로
// 여기서 다시 조회하지 않고 그 캐시를 공유한다. 상태 변경 시 무효화도 같이 따라온다.
$activeModuleIdentifiers = self::getActiveModuleIdentifiers();
$modules = File::directories($modulesPath);
$allowlistActive = ExtensionTestAllowlist::isActive();
@@ -30,6 +30,7 @@ class SettingsServiceProvider extends ServiceProvider
'geoip',
'seo',
'identity',
'pagination',
];
/**
+36 -3
View File
@@ -5,10 +5,14 @@ namespace App\Repositories;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Helpers\PermissionHelper;
use App\Helpers\TimezoneHelper;
use App\Http\Resources\BaseApiCollection;
use App\Models\ActivityLog;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\KeysetPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -65,16 +69,22 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
columns: ['*'],
sort: [['column' => 'created_at', 'direction' => $sortOrder]],
perPage: (int) ($filters['per_page'] ?? 15),
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.activity_logs'),
);
}
/**
* 활동 로그 목록을 페이지네이션하여 조회합니다.
*
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로, 없으면 페이지 번호 방식으로 응답합니다.
* 두 방식의 응답 봉투 차이는 {@see BaseApiCollection} 이 흡수합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getPaginated(array $filters = []): LengthAwarePaginator
public function getPaginated(array $filters = []): LengthAwarePaginator|CursorPaginator
{
// 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다)
$query = ActivityLog::query();
@@ -122,6 +132,26 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
}
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at');
$perPage = (int) ($filters['per_page'] ?? 15);
// 커서를 받은 요청은 키셋으로 응답한다. 로그는 계속 쌓이기만 해서 깊은 페이지를
// OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 하지만, 커서는 직전 페이지의 정렬
// 키를 WHERE 경계로 삼아 깊이와 무관하게 일정하다.
// 커서 모드에서는 OFFSET 자체가 없으므로 지연 조인이 해결하려던 문제도 함께 사라진다.
$sortKeys = array_map(
static fn (array $spec): array => [$spec['column'], $spec['direction']],
$sort
);
if (! empty($filters['cursor']) && KeysetPaginator::supports($sortKeys, self::SORTABLE_COLUMNS)) {
return KeysetPaginator::paginate(
query: $query->with('user:id,uuid,name,email'),
perPage: $perPage,
sortKeys: $sortKeys,
uniqueKey: 'id',
cursor: (string) $filters['cursor'],
);
}
// 목록 컬럼을 좁히지 않는 이유: 활동 로그 리소스는 변경 내역(mediumText `changes`)과
// 부가 정보(`properties`)까지 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다.
@@ -130,8 +160,11 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
query: $query,
columns: ['*'],
sort: $sort,
perPage: (int) ($filters['per_page'] ?? 15),
perPage: $perPage,
relations: ['user:id,uuid,name,email'],
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.activity_logs'),
);
}
@@ -0,0 +1,72 @@
<?php
namespace App\Repositories\Concerns;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
/**
* 날짜 필터를 인덱스가 살아 있는 범위 조건으로 적용하는 Trait
*
* `whereDate('created_at', ...)` 는 컬럼에 `DATE()` 를 씌운다. 함수가 씌워진 컬럼은
* 인덱스를 탈 수 없으므로, 그 조건 하나 때문에 목록 전체가 풀스캔이 된다. 같은 결과를
* 내는 `>= 하루 시작` / `<= 하루 끝` 범위 조건으로 바꾸면 인덱스가 그대로 쓰인다.
*
* 경계 처리를 각 저장소가 따로 적으면 한쪽이 종료일 하루를 통째로 빠뜨리기 쉽다
* (`<= '2026-08-02'` 는 그날 00:00:00 까지만 포함한다). 경계는 이 Trait 한 곳에서만 정한다.
*/
trait FiltersByDateRange
{
/**
* 기간(시작일~종료일) 필터를 범위 조건으로 적용합니다.
*
* 종료일은 그날 23:59:59.999999 까지 포함하므로 `whereDate(..., '<=', ...)` 와
* 같은 결과를 냅니다.
*
* @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리
* @param string $column 날짜 컬럼 (테이블 한정자 포함 가능)
* @param string|null $startDate 시작일 (빈 값이면 미적용)
* @param string|null $endDate 종료일 (빈 값이면 미적용)
*/
protected function applyDateRangeFilter($query, string $column, ?string $startDate, ?string $endDate): void
{
if (! empty($startDate)) {
$query->where($column, '>=', CarbonImmutable::parse($startDate)->startOfDay());
}
if (! empty($endDate)) {
$query->where($column, '<=', CarbonImmutable::parse($endDate)->endOfDay());
}
}
/**
* 특정 하루에 해당하는 행만 남기는 필터를 범위 조건으로 적용합니다.
*
* @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리
* @param string $column 날짜 컬럼 (테이블 한정자 포함 가능)
* @param \DateTimeInterface|string $day 대상 날짜
*/
protected function applyDayFilter($query, string $column, \DateTimeInterface|string $day): void
{
$target = CarbonImmutable::parse($day instanceof \DateTimeInterface ? $day->format('Y-m-d H:i:s') : $day);
$query->whereBetween($column, [$target->startOfDay(), $target->endOfDay()]);
}
/**
* 특정 연·월에 해당하는 행만 남기는 필터를 범위 조건으로 적용합니다.
*
* `whereYear` + `whereMonth` 조합은 컬럼에 함수를 두 번 씌워 인덱스를 완전히 막습니다.
*
* @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리
* @param string $column 날짜 컬럼 (테이블 한정자 포함 가능)
* @param int $year 연도
* @param int $month 월 (1~12)
*/
protected function applyMonthFilter($query, string $column, int $year, int $month): void
{
$start = CarbonImmutable::create($year, $month, 1)->startOfDay();
$query->whereBetween($column, [$start, $start->endOfMonth()]);
}
}
@@ -2,10 +2,14 @@
namespace App\Repositories\Concerns;
use App\Enums\TotalRelation;
use App\Support\Query\BoundedPage;
use App\Support\Query\BoundedPaginator;
use Closure;
use Illuminate\Contracts\Database\Query\Expression;
use Illuminate\Contracts\Pagination\Paginator as PaginatorContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
@@ -66,10 +70,11 @@ trait PaginatesWithDeferredJoin
* @param bool $preserveIdOrder true = inner 가 정한 ID 순서를 outer 에서 그대로 복원
* @param string $pageName 페이지 쿼리 파라미터명
* @param Closure|null $outerUsing outer 에만 적용할 조인/집계 (inner 에서는 실행되지 않는다)
* @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, 아니면 LengthAwarePaginator)
* @param int|null $resultCap 총 건수 집계 상한 (null = 항상 정확한 COUNT)
* @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, $resultCap 지정 시 BoundedPage, 아니면 LengthAwarePaginator)
*/
protected function paginateWithDeferredJoin(
Builder $query,
Builder|Relation $query,
array $columns,
array $sort,
int $perPage,
@@ -82,7 +87,14 @@ trait PaginatesWithDeferredJoin
bool $preserveIdOrder = false,
string $pageName = 'page',
?Closure $outerUsing = null,
?int $resultCap = null,
): PaginatorContract {
// 관계(`$model->items()`)도 받는다 — 표준 `paginate()` 가 되는 자리는 이 계약으로
// 바꿔도 되어야 한다. 관계의 소속 조건은 그 밑 빌더에 이미 들어가 있어 보존된다.
// (쿼리 빌더는 받지 않는다. 이 계약은 모델의 키 컬럼·eager load 를 다루므로
// Eloquent 가 전제다 — 넓힐 수 있는 범위와 없는 범위를 구분한다.)
$query = $query instanceof Relation ? $query->getQuery() : $query;
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$page = max(1, $page);
@@ -94,7 +106,16 @@ trait PaginatesWithDeferredJoin
// whereHas 는 결과 집합을 결정하는 조건이라 setEagerLoads 로 지워지지 않는다 (의도된 구분).
$inner = (clone $query)->setEagerLoads([]);
if (! $simple && $total === null) {
// 상한이 지정되면 총 건수를 상한까지만 센다. 다음 페이지 판정은 총 건수와 무관하게
// per_page + 1 실측으로 하므로, 마지막 페이지 번호 하나만 계산 불가가 된다.
$bounded = ! $simple && $resultCap !== null && $resultCap > 0;
$relation = null;
if ($bounded && $total === null) {
// Eloquent 빌더를 그대로 넘긴다 — countWithCap 이 내부에서 toBase() 로
// 글로벌 스코프까지 적용한 기반 쿼리를 만든다.
[$total, $relation] = BoundedPaginator::countWithCap(clone $inner, $resultCap);
} elseif (! $simple && $total === null) {
// Laravel 의 paginate() 와 같은 집계 경로를 쓴다. count() 는 groupBy/having 이 있는
// 쿼리에서 `select count(*) ... group by ...` 를 그대로 실행해 **첫 그룹의 행 수**를
// 총 건수로 돌려준다. getCountForPagination() 은 그룹 쿼리를 서브쿼리로 감싸므로
@@ -105,10 +126,11 @@ trait PaginatesWithDeferredJoin
$this->applySortSpec($inner, $sort);
// simple 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다 (Paginator 가 잘라낸다).
// simple/bounded 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다.
// forPage() 를 쓰면 offset 이 (page-1) * (perPage+1) 로 계산돼 페이지가 넘어갈수록
// 건너뛰는 행이 어긋나므로, offset 은 perPage 기준으로 직접 지정한다.
$fetchCount = $simple ? $perPage + 1 : $perPage;
$probesNextPage = $simple || $bounded;
$fetchCount = $probesNextPage ? $perPage + 1 : $perPage;
$ids = $inner
->select($inner->getModel()->qualifyColumn($keyName))
@@ -116,6 +138,12 @@ trait PaginatesWithDeferredJoin
->limit($fetchCount)
->pluck($keyName);
$hasMorePages = $probesNextPage && $ids->count() > $perPage;
if ($bounded && $hasMorePages) {
$ids = $ids->slice(0, $perPage)->values();
}
$items = $ids->isEmpty()
? new Collection
: $this->fetchDeferredJoinRows($query, $columns, $sort, $ids->all(), $relations, $withCount, $keyName, $preserveIdOrder, $outerUsing);
@@ -129,6 +157,22 @@ trait PaginatesWithDeferredJoin
return new Paginator($items, $perPage, $page, $options);
}
if ($bounded) {
// 동시 삽입으로 집계 시점과 조회 시점의 모수가 달라질 수 있다
$seenSoFar = ($page - 1) * $perPage + $items->count();
return new BoundedPage(
items: $items,
total: max((int) $total, $seenSoFar),
perPage: $perPage,
currentPage: $page,
totalRelation: $relation ?? TotalRelation::Exact,
resultCap: $resultCap,
hasMorePages: $hasMorePages,
options: $options,
);
}
return new LengthAwarePaginator($items, $total ?? $items->count(), $perPage, $page, $options);
}
@@ -263,6 +263,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
*/
public function allEnabled(): Collection
{
// audit:allow query-unbounded-get reason: 본인인증 정책은 운영자가 등록한 수만큼만 존재한다 (사용량과 무관)
return IdentityPolicy::query()->where('enabled', true)->get();
}
@@ -347,6 +348,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
return [];
}
// audit:allow query-unbounded-get reason: 본인인증 정책은 운영자가 등록한 수만큼만 존재한다 (사용량과 무관)
return IdentityPolicy::query()
->where('scope', 'hook')
->distinct()
@@ -8,6 +8,7 @@ use App\Helpers\TimezoneHelper;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
@@ -219,6 +220,9 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
columns: ['*'],
sort: [['column' => $sortBy, 'direction' => $sortOrder]],
perPage: $perPage,
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.identity_logs'),
);
}
+86 -5
View File
@@ -43,6 +43,17 @@ class JsonConfigRepository implements ConfigRepositoryInterface
*/
private ?array $cache = null;
/**
* 카테고리별 메모리 캐시
*
* 부팅 한 번에 같은 카테고리를 여러 곳에서 읽는다 — SettingsServiceProvider 의
* apply*Config 9종과 loadCoreSettingsToConfig 가 각각 조회하므로, 캐시가 없으면
* 요청마다 카테고리 수만큼 곱한 횟수로 파일 존재 확인·읽기·JSON 파싱이 반복된다.
*
* @var array<string, array<string, mixed>>
*/
private array $categoryCache = [];
/**
* 모든 카테고리의 설정을 조회합니다.
*
@@ -67,9 +78,27 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 특정 카테고리의 설정을 조회합니다.
*
* @return array<string, mixed>
* @param string $category 카테고리명
* @return array<string, mixed> 기본값과 병합된 설정
*/
public function getCategory(string $category): array
{
if (array_key_exists($category, $this->categoryCache)) {
return $this->categoryCache[$category];
}
return $this->categoryCache[$category] = $this->readCategory($category);
}
/**
* 카테고리 설정을 파일에서 읽어 기본값과 병합합니다.
*
* 캐시를 거치지 않는 실제 읽기 경로입니다.
*
* @param string $category 카테고리명
* @return array<string, mixed>
*/
private function readCategory(string $category): array
{
if (! $this->categoryExists($category)) {
return $this->getDefaultsForCategory($category);
@@ -77,11 +106,17 @@ class JsonConfigRepository implements ConfigRepositoryInterface
$path = $this->getCategoryPath($category);
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
if (! Storage::disk(self::STORAGE_DISK)->exists($path)) {
return $this->getDefaultsForCategory($category);
}
try {
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$content = Storage::disk(self::STORAGE_DISK)->get($path);
$data = json_decode($content, true);
@@ -113,6 +148,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface
* 도트 노테이션으로 특정 설정값을 조회합니다.
*
* @param string $key 예: 'mail.host', 'general.site_name'
* @param mixed $default 값이 없을 때 돌려줄 기본값
* @return mixed 설정값 (없으면 $default)
*/
public function get(string $key, mixed $default = null): mixed
{
@@ -131,6 +168,10 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 도트 노테이션으로 특정 설정값을 저장합니다.
*
* @param string $key 설정 키 (카테고리.항목)
* @param mixed $value 저장할 값
* @return bool 저장 성공 여부
*/
public function set(string $key, mixed $value): bool
{
@@ -150,7 +191,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 여러 설정을 일괄 저장합니다.
*
* @param array<string, mixed> $settings
* @param array<string, mixed> $settings 도트 노테이션 키 ⇒ 값
* @return bool 저장 성공 여부
*/
public function setMany(array $settings): bool
{
@@ -171,7 +213,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 특정 카테고리의 설정을 저장합니다.
*
* @param array<string, mixed> $settings
* @param string $category 카테고리명
* @param array<string, mixed> $settings 저장할 설정
* @return bool 저장 성공 여부
*/
public function saveCategory(string $category, array $settings): bool
{
@@ -195,6 +239,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
try {
// 파일 잠금으로 동시 쓰기 방지
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$fullPath = Storage::disk(self::STORAGE_DISK)->path($path);
$handle = fopen($fullPath, 'c');
@@ -211,8 +258,10 @@ class JsonConfigRepository implements ConfigRepositoryInterface
fclose($handle);
// 캐시 무효화
// 캐시 무효화 — 저장한 카테고리와 전체 맵 양쪽을 비운다.
// 한쪽만 비우면 저장 직후 조회가 이전 값을 돌려준다.
$this->cache = null;
unset($this->categoryCache[$category]);
return true;
} catch (\Exception $e) {
@@ -226,6 +275,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 설정 키 존재 여부를 확인합니다.
*
* @param string $key 설정 키 (카테고리.항목)
* @return bool 존재 여부
*/
public function has(string $key): bool
{
@@ -234,6 +286,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 특정 설정을 삭제합니다.
*
* @param string $key 설정 키 (카테고리.항목)
* @return bool 삭제 성공 여부
*/
public function delete(string $key): bool
{
@@ -269,6 +324,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 카테고리 존재 여부를 확인합니다.
*
* @param string $category 카테고리명
* @return bool 존재 여부
*/
public function categoryExists(string $category): bool
{
@@ -278,7 +336,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 설정 파일을 초기화합니다.
*
* @param array<string, array<string, mixed>> $settings
* @param array<string, array<string, mixed>> $settings 기본값 위에 덮어쓸 설정
* @return bool 초기화 성공 여부
*/
public function initialize(array $settings = []): bool
{
@@ -310,6 +369,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
$backupPath = self::BACKUP_DIR.'/'.$backupName;
$zip = new \ZipArchive;
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$fullBackupPath = Storage::disk(self::STORAGE_DISK)->path($backupPath);
if ($zip->open($fullBackupPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
@@ -318,6 +380,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
foreach ($this->getCategories() as $category) {
$categoryPath = $this->getCategoryPath($category);
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$fullCategoryPath = Storage::disk(self::STORAGE_DISK)->path($categoryPath);
if (file_exists($fullCategoryPath)) {
@@ -332,9 +397,15 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 백업에서 설정을 복원합니다.
*
* @param string $backupPath 백업 파일 경로 (disk 기준)
* @return bool 복원 성공 여부
*/
public function restore(string $backupPath): bool
{
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$fullBackupPath = Storage::disk(self::STORAGE_DISK)->path($backupPath);
if (! file_exists($fullBackupPath)) {
@@ -356,12 +427,16 @@ class JsonConfigRepository implements ConfigRepositoryInterface
if ($this->categoryExists($category)) {
$content = $zip->getFromIndex($i);
$categoryPath = $this->getCategoryPath($category);
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
Storage::disk(self::STORAGE_DISK)->put($categoryPath, $content);
}
}
$zip->close();
$this->cache = null;
$this->categoryCache = [];
return true;
}
@@ -457,6 +532,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
*/
private function ensureDirectoryExists(): void
{
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$disk = Storage::disk(self::STORAGE_DISK);
if (! $disk->exists('.')) {
@@ -469,6 +547,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
*/
private function ensureBackupDirectoryExists(): void
{
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$disk = Storage::disk(self::STORAGE_DISK);
if (! $disk->exists(self::BACKUP_DIR)) {
@@ -50,6 +50,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function getActivePacks(): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('status', LanguagePackStatus::Active->value)
->get();
@@ -96,6 +97,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
?string $targetIdentifier,
string $locale
): Collection {
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', $scope)
->where('target_identifier', $targetIdentifier)
@@ -112,6 +114,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function getActiveCoreLocales(): array
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', LanguagePackScope::Core->value)
->where('status', LanguagePackStatus::Active->value)
@@ -246,6 +249,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function getPacksForTarget(string $scope, string $targetIdentifier): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', $scope)
->where('target_identifier', $targetIdentifier)
@@ -260,6 +264,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function getPacksForLocale(string $locale): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()->where('locale', $locale)->get();
}
+26 -2
View File
@@ -8,8 +8,11 @@ use App\Models\NotificationLog;
use App\Models\User;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\KeysetPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
class NotificationLogRepository implements NotificationLogRepositoryInterface
@@ -101,9 +104,9 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지당 건수
* @param User|null $scopeUser 스코프 적용 대상 사용자 (null이면 스코프 미적용)
* @return LengthAwarePaginator 페이지네이션 결과
* @return LengthAwarePaginator|CursorPaginator 페이지네이션 결과 (커서 요청 시 키셋)
*/
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator|CursorPaginator
{
// 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다)
$query = NotificationLog::query();
@@ -152,6 +155,24 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'sent_at');
// 커서를 받은 요청은 키셋으로 응답한다. 로그는 계속 쌓이기만 해서 깊은 페이지를
// OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 하지만, 커서는 직전 페이지의 정렬
// 키를 WHERE 경계로 삼아 깊이와 무관하게 일정하다.
$sortKeys = array_map(
static fn (array $spec): array => [$spec['column'], $spec['direction']],
$sort
);
if (! empty($filters['cursor']) && KeysetPaginator::supports($sortKeys, self::SORTABLE_COLUMNS)) {
return KeysetPaginator::paginate(
query: $query->with(['senderUser', 'recipientUser']),
perPage: $perPage,
sortKeys: $sortKeys,
uniqueKey: 'id',
cursor: (string) $filters['cursor'],
);
}
// 목록 컬럼을 좁히지 않는 이유: 이 목록의 리소스는 렌더링된 본문(longText `body`)까지
// 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다. 지연 조인만으로도 본문을 읽는
// 행 수가 OFFSET 과 무관하게 이번 페이지 분량으로 고정된다.
@@ -161,6 +182,9 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
sort: $sort,
perPage: $perPage,
relations: ['senderUser', 'recipientUser'],
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.notification_logs'),
);
}
+10 -1
View File
@@ -4,6 +4,8 @@ namespace App\Repositories;
use App\Contracts\Repositories\NotificationRepositoryInterface;
use App\Models\User;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -32,7 +34,14 @@ class NotificationRepository implements NotificationRepositoryInterface
// 걷어낼 넓은 컬럼이 없다
// 정렬 마지막의 기본키는 전순서 보장용이다 — created_at 동률에서 페이지 경계가
// 흔들려 인접 페이지가 같은 알림을 중복 노출하고 다른 알림을 누락하는 것을 막는다.
return $query->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($perPage);
//
// 알림함은 한 사용자에 묶이지만 시간이 지날수록 계속 쌓인다(설정성 테이블이 아니다).
// 총 건수는 상한까지만 세고, 뒤쪽 페이지 이동은 실측으로 끝까지 열어 둔다.
return BoundedPaginator::paginate(
$query->orderBy('created_at', 'desc')->orderBy('id', 'desc'),
perPage: $perPage,
resultCap: PaginationLimits::resultCap('user.notifications'),
);
}
/**
+3
View File
@@ -17,6 +17,7 @@ class RoleRepository implements RoleRepositoryInterface
*/
public function getAll(): Collection
{
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::with(['permissions'])
->orderBy('id')
->get();
@@ -29,6 +30,7 @@ class RoleRepository implements RoleRepositoryInterface
*/
public function getActiveRoles(): Collection
{
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::where('is_active', true)
->orderBy('id')
->get();
@@ -142,6 +144,7 @@ class RoleRepository implements RoleRepositoryInterface
*/
public function getByExtension(ExtensionOwnerType $extensionType, string $extensionIdentifier): Collection
{
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::where('extension_type', $extensionType)
->where('extension_identifier', $extensionIdentifier)
->get();
@@ -6,6 +6,7 @@ use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
use App\Models\ScheduleHistory;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\PaginationLimits;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
@@ -116,6 +117,9 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
sort: $sort,
perPage: $perPage,
relations: ['triggeredBy'],
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.schedule_histories'),
);
}
+9 -7
View File
@@ -8,6 +8,7 @@ use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleType;
use App\Helpers\PermissionHelper;
use App\Models\Schedule;
use App\Repositories\Concerns\FiltersByDateRange;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -16,6 +17,7 @@ use Illuminate\Database\Eloquent\Collection;
class ScheduleRepository implements ScheduleRepositoryInterface
{
use FiltersByDateRange;
use HasMultipleSearchFilters;
use ResolvesSortSpec;
@@ -198,13 +200,13 @@ class ScheduleRepository implements ScheduleRepositoryInterface
*/
private function applyDateFilters(Builder $query, array $filters): void
{
if (! empty($filters['created_from'])) {
$query->whereDate('created_at', '>=', $filters['created_from']);
}
if (! empty($filters['created_to'])) {
$query->whereDate('created_at', '<=', $filters['created_to']);
}
// whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 범위 조건으로 준다.
$this->applyDateRangeFilter(
$query,
'created_at',
$filters['created_from'] ?? null,
$filters['created_to'] ?? null
);
}
/**
+12 -7
View File
@@ -5,9 +5,11 @@ namespace App\Repositories;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Helpers\PermissionHelper;
use App\Models\User;
use App\Repositories\Concerns\FiltersByDateRange;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -16,6 +18,7 @@ use Laravel\Sanctum\PersonalAccessToken;
class UserRepository implements UserRepositoryInterface
{
use FiltersByDateRange;
use HasMultipleSearchFilters;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
@@ -125,6 +128,8 @@ class UserRepository implements UserRepositoryInterface
sort: $sort,
perPage: $perPage,
relations: ['roles'],
// 회원 수가 커져도 목록 첫 화면 비용이 총 건수 COUNT 에 끌려가지 않도록 상한을 건다.
resultCap: PaginationLimits::resultCap('admin.users'),
);
}
@@ -153,13 +158,13 @@ class UserRepository implements UserRepositoryInterface
*/
private function applyDateFilters(Builder $query, array $filters): void
{
if (! empty($filters['start_date'])) {
$query->whereDate('created_at', '>=', $filters['start_date']);
}
if (! empty($filters['end_date'])) {
$query->whereDate('created_at', '<=', $filters['end_date']);
}
// whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 범위 조건으로 준다.
$this->applyDateRangeFilter(
$query,
'created_at',
$filters['start_date'] ?? null,
$filters['end_date'] ?? null
);
// 기본 날짜 필터 (전체가 아닌 경우)
if (empty($filters['start_date']) && empty($filters['end_date']) &&
@@ -0,0 +1,78 @@
<?php
namespace App\Search\Contracts;
use App\Search\DTO\KeywordSearchContext;
use App\Search\KeywordSearch;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Builder;
/**
* 진행 중인 DB 쿼리에 키워드 술어를 붙일 수 있는 검색 엔진 계약
*
* Scout 의 `Model::search()` 는 "엔진이 결과를 돌려준다" 모델입니다. 그래서 결과를 받은
* 뒤 그 ID 로 DB 를 다시 조회해야 하고, 매칭이 많으면 ID 전량을 메모리에 올려 무제한
* `IN (...)` 을 만들게 됩니다. 페이지네이션·조인·필터를 DB 에 남긴 채 키워드 조건만
* 얹으려면 **엔진에게 술어를 달라고 요청할 통로**가 따로 필요합니다.
*
* 이 계약이 그 통로입니다. 구현하는 엔진은 자기 방식(FULLTEXT MATCH, 외부 엔진이 미리
* 좁혀 준 키 집합 등)으로 조건을 붙이고, 코어는 무엇이 붙는지 모르는 채 호출만 합니다.
* 해석과 폴백은 {@see KeywordSearch} 가 단독으로 수행합니다.
*
* 구현하지 않은 엔진은 `LIKE` 폴백으로 내려갑니다 — 검색은 동작하지만 관련도 정렬이
* 사라지고 전체 스캔이 되므로, 폴백 사실은 조용히 넘어가지 않고 기록됩니다.
*
* ```php
* class MeilisearchEngine extends Engine implements KeywordPredicateProvider
* {
* public function applyKeywordPredicate(
* Builder $query,
* array $columns,
* string $keyword,
* string $boolean,
* KeywordSearchContext $context
* ): void {
* // 상한을 지킨다 — 지키지 않으면 매칭이 큰 검색어에서 이 배열이 곧 메모리 폭발이다
* $ids = $this->lookupIds($keyword, limit: $context->keyCap);
* $method = $boolean === 'or' ? 'orWhereIn' : 'whereIn';
* $query->$method($query->getModel()->getQualifiedKeyName(), $ids);
* }
* }
* ```
*/
interface KeywordPredicateProvider
{
/**
* 쿼리에 키워드 술어를 붙입니다.
*
* 컬럼 목록은 **하나의 조건으로 함께 평가**해 달라는 요청입니다. 엔진이 그 조합을
* 다룰 수 없으면(예: MySQL 이 그 컬럼 조합의 복합 FULLTEXT 인덱스를 요구하는 경우)
* 호출자가 {@see KeywordSearch::applyAny()} 로 컬럼별 OR 을 요청합니다.
*
* 매칭이 하나도 없을 수 있는 검색어(예: 연산자만 입력)에는 **항상 거짓인 조건**을
* 붙여야 합니다. 아무 조건도 붙이지 않으면 필터 없는 전체 목록이 검색 결과로 나갑니다.
*
* **페이지네이션은 호출자(DB)가 담당합니다.** 엔진에게 페이지 번호를 넘기지 않는 이유는,
* 엔진이 자기 순서로 한 페이지 분량만 돌려주면 그 뒤에 적용되는 DB 필터(분류·전시상태·
* 조인)에 일부가 탈락해 페이지가 비고, DB 정렬과 엔진의 관련도 순서가 달라 페이지 경계도
* 어긋나기 때문입니다. 엔진이 책임지는 것은 **"얼마까지 돌려줄 것인가"** 하나입니다.
*
* 키 집합을 만들어 조건으로 붙이는 구현은 `$context->keyCap` 을 반드시 지켜야 합니다.
* 지키지 않으면 매칭이 큰 검색어에서 그 집합 자체가 메모리 폭발이 됩니다. 상한에 걸려
* 잘린 경우 그 이상은 도달 불가이며, 총 건수는 "이상" 으로 보고됩니다
* ({@see PaginationLimits} 와 같은 의미).
*
* @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문 (정제는 엔진이 수행)
* @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or')
* @param KeywordSearchContext $context 술어 생성 조건 (키 집합 상한 등)
*/
public function applyKeywordPredicate(
Builder $query,
array $columns,
string $keyword,
string $boolean,
KeywordSearchContext $context
): void;
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Search\DTO;
/**
* 키워드 술어를 만들 때 엔진에게 전달되는 조건
*
* 엔진마다 술어를 만드는 방식이 다르고, 그중 일부는 **비용이 규모에 비례**합니다.
* 외부 검색 서버를 쓰는 엔진은 자기 서버에서 키 집합을 받아 조건으로 붙이는데, 매칭이
* 크면 그 키 집합 자체가 메모리 폭발이 됩니다 — 이 프로젝트가 한 번 겪은 결함입니다.
*
* 그래서 코어는 엔진에게 "얼마까지 가져와도 되는가" 를 함께 넘깁니다. 규정만으로는
* 강제할 수 없으므로(외부 엔진 코드는 이 저장소 밖입니다) **값을 손에 쥐어 주는 것**까지가
* 코어가 할 수 있는 최선입니다.
*
* 값 추가가 필요해질 때 계약 시그니처를 다시 깨지 않도록 객체로 감쌉니다.
*/
final class KeywordSearchContext
{
/**
* @param int|null $keyCap 엔진이 돌려줄 수 있는 최대 키 개수 (null = 무제한)
*/
public function __construct(
public readonly ?int $keyCap = null,
) {}
/**
* 상한이 정해져 있는지 반환합니다.
*
* @return bool 상한이 있으면 true
*/
public function hasKeyCap(): bool
{
return $this->keyCap !== null && $this->keyCap > 0;
}
}
+256 -44
View File
@@ -2,7 +2,13 @@
namespace App\Search\Engines;
use App\Enums\TotalRelation;
use App\Search\Contracts\FulltextSearchable;
use App\Search\Contracts\KeywordPredicateProvider;
use App\Search\DTO\KeywordSearchContext;
use App\Search\KeywordSearch;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
@@ -18,7 +24,7 @@ use Laravel\Scout\Engines\Engine;
*
* MySQL 자체가 인덱스 소스이므로 update/delete/flush는 no-op입니다.
*/
class DatabaseFulltextEngine extends Engine
class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
{
/**
* MariaDB 감지 결과 캐시 (프로세스 수명 동안 유지)
@@ -53,20 +59,52 @@ class DatabaseFulltextEngine extends Engine
* FULLTEXT 검색을 수행합니다.
*
* @param Builder $builder Scout 빌더
* @return array{query: \Illuminate\Database\Eloquent\Builder, total: int}
* @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null}
*/
public function search(Builder $builder): array
{
return $this->performSearch($builder);
}
/**
* 검색 결과의 키 목록만 조회합니다.
*
* 총 건수를 쓰지 않는 경로이므로 COUNT 를 아예 실행하지 않고, SELECT 도 키 컬럼과
* 정렬용 스코어로만 좁힙니다. 좁히지 않으면 매칭된 전 행의 모든 컬럼(본문 포함)을
* 읽고 나서 ID 만 뽑아내게 됩니다.
*
* @param Builder $builder Scout 빌더
* @return \Illuminate\Support\Collection 키 컬렉션
*/
public function keys(Builder $builder): \Illuminate\Support\Collection
{
return $this->mapIds($this->performSearch($builder, withTotal: false, keysOnly: true));
}
/**
* 검색 결과를 모델 컬렉션으로 조회합니다.
*
* 총 건수를 쓰지 않는 경로이므로 COUNT 를 실행하지 않습니다.
*
* @param Builder $builder Scout 빌더
* @return Collection 모델 컬렉션
*/
public function get(Builder $builder): Collection
{
return $this->map(
$builder,
$builder->applyAfterRawSearchCallback($this->performSearch($builder, withTotal: false)),
$builder->model
);
}
/**
* 페이지네이션 적용 FULLTEXT 검색을 수행합니다.
*
* @param Builder $builder Scout 빌더
* @param int $perPage 페이지당 결과 수
* @param int $page 페이지 번호
* @return array{query: \Illuminate\Database\Eloquent\Builder, total: int}
* @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null}
*/
public function paginate(Builder $builder, $perPage, $page): array
{
@@ -85,7 +123,13 @@ class DatabaseFulltextEngine extends Engine
return collect();
}
return $query->pluck($query->getModel()->getKeyName());
// `pluck()` 은 이미 걸려 있는 SELECT 를 보존한다(onceWithColumns). 검색 쿼리는
// 관련도 점수를 위해 `table.*` 를 선택해 두므로, 그대로 두면 ID 만 필요한 자리에서
// 전 컬럼을 읽는다. 사본에서 키 컬럼만 남겨 좁힌다.
$model = $query->getModel();
$key = $model->getQualifiedKeyName();
return $query->clone()->select($key)->pluck($model->getKeyName());
}
/**
@@ -129,7 +173,31 @@ class DatabaseFulltextEngine extends Engine
*/
public function getTotalCount($results): int
{
return $results['total'] ?? 0;
return (int) ($results['total'] ?? 0);
}
/**
* 검색 결과의 총 건수 정확도를 반환합니다.
*
* 상한을 넘겨 정확히 세지 않은 경우 `AtLeast` 입니다.
*
* @param array $results 검색 결과
* @return TotalRelation 총 건수 정확도
*/
public function getTotalRelation($results): TotalRelation
{
return $results['total_relation'] ?? TotalRelation::Exact;
}
/**
* 총 건수 집계에 적용된 상한을 반환합니다.
*
* @param array $results 검색 결과
* @return int|null 상한 (무제한이면 null)
*/
public function getResultCap($results): ?int
{
return $results['result_cap'] ?? null;
}
/**
@@ -239,6 +307,14 @@ class DatabaseFulltextEngine extends Engine
* MySQL 파싱 오류(ER_PARSE_ERROR)를 유발하므로, 사용자 입력을 일반 검색어로만
* 취급하도록 연산자를 제거하고 남은 토큰을 따옴표 구문으로 묶습니다.
*
* 토큰은 공백으로 잇습니다 — BOOLEAN MODE 에서 공백 결합은 OR 입니다. 각 토큰에 `+` 를
* 붙이면 AND 가 되어 매칭 집합이 줄고 대용량에서 검색 시간이 짧아질 여지가 있지만,
* **의도적으로 OR 을 유지합니다**. 입력한 단어 중 하나만 든 문서가 결과에서 통째로
* 사라지는 손실이 성능 이득보다 크기 때문입니다. 한글은 ngram 파서가 2글자 단위로
* 토큰을 쪼개므로 AND 전환의 결과 축소 폭이 특히 큽니다. 성능 축이 문제가 되면 결합
* 방식이 아니라 전용 검색 엔진(core.search.engine_drivers)으로 해결합니다.
* 배경·실측: docs/backend/search-system.md, docs/backend/pagination.md
*
* @param string $keyword 원본 검색어
* @return string 안전하게 정제된 BOOLEAN MODE 검색식 (정제 결과가 없으면 빈 문자열)
*/
@@ -267,32 +343,114 @@ class DatabaseFulltextEngine extends Engine
*
* DBMS별로 MATCH...AGAINST 또는 LIKE fallback을 자동 적용합니다.
*
* 검색어는 반드시 이 헬퍼를 거쳐야 합니다. BOOLEAN MODE 는 `+ - * " ( )` 등을
* 연산자로 해석하므로, 원문 키워드를 그대로 바인딩하면 사용자가 `+` 하나만 입력해도
* 파싱 오류로 500 이 됩니다. 정제는 sanitizeBooleanModeKeyword 한 곳에서만 합니다.
*
* **컬럼을 배열로 넘기면 하나의 `MATCH(a, b)` 가 됩니다.** MySQL 은 이 형태에 정확히
* 그 컬럼 조합의 **복합 FULLTEXT 인덱스**를 요구하며, 없으면
* `Can't find FULLTEXT index matching the column list` 오류가 납니다.
* 컬럼별 단일 인덱스만 있는 테이블은 {@see self::whereFulltextAny()} 를 쓰세요.
*
* @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더
* @param string $column 검색 대상 컬럼명
* @param string $keyword 검색어
* @param string|array<int, string> $columns 검색 대상 컬럼명 (배열이면 복합 인덱스 필요)
* @param string $keyword 검색어 (원문 — 정제는 이 메서드가 수행)
* @param string $boolean 조건 결합 방식 ('and' 또는 'or')
*/
public static function whereFulltext(
\Illuminate\Database\Eloquent\Builder $query,
string $column,
string|array $columns,
string $keyword,
string $boolean = 'and'
): void {
if (static::supportsFulltext()) {
$ftKeyword = static::sanitizeBooleanModeKeyword($keyword);
if ($ftKeyword === '') {
// 정제 결과 없음 → 항상 false 조건으로 빈 결과 (void 반환 계약 유지)
$falseMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->$falseMethod('1 = 0');
// 활성 엔진 해석을 거친다 — 다른 엔진이 켜져 있으면 그 엔진이 조건을 만든다.
// 이 정적 헬퍼를 직접 부르던 코드도 이 경유로 엔진 교체 혜택을 받는다.
KeywordSearch::apply($query, $columns, $keyword, $boolean);
}
return;
}
$method = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->$method("MATCH(`{$column}`) AGAINST(? IN BOOLEAN MODE)", [$ftKeyword]);
} else {
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->$method($column, 'LIKE', "%{$keyword}%");
/**
* {@inheritDoc}
*
* 이 엔진의 술어는 `MATCH ... AGAINST IN BOOLEAN MODE` 입니다. DBMS 가 FULLTEXT 를
* 지원하지 않으면 같은 자리에서 부분일치로 내려갑니다.
*
* `$context->keyCap` 은 쓰지 않습니다 — 이 엔진의 술어는 SQL 조건 그 자체라 중간
* 키 집합을 만들지 않으므로 상한을 적용할 대상이 없습니다. 상한은 키 집합을 만들어
* 조건으로 붙이는 엔진(외부 검색 서버 등)을 위한 것입니다.
*/
public function applyKeywordPredicate(
\Illuminate\Database\Eloquent\Builder $query,
array $columns,
string $keyword,
string $boolean,
KeywordSearchContext $context
): void {
static::applyFulltextPredicate($query, $columns, $keyword, $boolean);
}
/**
* FULLTEXT 조건을 실제로 조립합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문
* @param string $boolean 조건 결합 방식 ('and' 또는 'or')
*/
protected static function applyFulltextPredicate(
\Illuminate\Database\Eloquent\Builder $query,
array $columns,
string $keyword,
string $boolean = 'and'
): void {
$columns = array_values($columns);
if (! static::supportsFulltext()) {
// 전문검색을 제공하지 않는 DBMS 로 설치된 사이트에서는 부분일치가 정상 경로다.
// 연산자 선택과 와일드카드 escape 는 코어 해석기가 단독으로 수행한다 —
// 여기서 다시 조립하면 DBMS 가 늘 때마다 고쳐야 할 곳이 둘이 된다.
KeywordSearch::applyLikeMatch($query, $columns, $keyword, $boolean);
return;
}
$ftKeyword = static::sanitizeBooleanModeKeyword($keyword);
if ($ftKeyword === '') {
// 정제 결과 없음(연산자만 입력) → 빈 결과. 빌더가 바인딩까지 처리하도록
// 빈 whereIn 을 쓴다 (raw '1 = 0' 은 쓰지 않는다).
$method = $boolean === 'or' ? 'orWhereIn' : 'whereIn';
$query->$method($query->getModel()->getQualifiedKeyName(), []);
return;
}
$match = 'MATCH(`'.implode('`, `', $columns).'`)';
$method = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->$method($match.' AGAINST(? IN BOOLEAN MODE)', [$ftKeyword]);
}
/**
* 여러 컬럼 중 하나라도 매칭하면 되는 FULLTEXT 조건을 추가합니다.
*
* 컬럼마다 별도의 `MATCH(col)` 를 만들어 OR 로 묶습니다. 컬럼별 단일 FULLTEXT 인덱스만
* 있는 테이블(대부분의 경우)에서 쓰는 형태이며, 복합 인덱스가 없어도 동작합니다.
*
* 복합 인덱스가 있어 `MATCH(a, b)` 한 번으로 끝내야 하는 테이블은
* {@see self::whereFulltext()} 에 배열을 넘기세요.
*
* @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명 목록
* @param string $keyword 검색어 (원문 — 정제는 내부에서 수행)
* @param string $boolean 바깥 쿼리와의 결합 방식 ('and' 또는 'or')
*/
public static function whereFulltextAny(
\Illuminate\Database\Eloquent\Builder $query,
array $columns,
string $keyword,
string $boolean = 'and'
): void {
// 활성 엔진 해석을 거친다 ({@see self::whereFulltext()} 와 동일한 이유).
KeywordSearch::applyAny($query, $columns, $keyword, $boolean);
}
/**
@@ -329,39 +487,37 @@ class DatabaseFulltextEngine extends Engine
* @param Builder $builder Scout 빌더
* @param int|null $perPage 페이지당 결과 수
* @param int|null $page 페이지 번호
* @return array{query: \Illuminate\Database\Eloquent\Builder, total: int}
* @param bool $withTotal 총 건수를 계산할지 여부 (쓰지 않는 경로에서는 false)
* @param bool $keysOnly 키 컬럼과 정렬용 스코어만 조회할지 여부
* @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null}
*/
protected function performSearch(Builder $builder, ?int $perPage = null, ?int $page = null): array
{
protected function performSearch(
Builder $builder,
?int $perPage = null,
?int $page = null,
bool $withTotal = true,
bool $keysOnly = false
): array {
$model = $builder->model;
$keyword = $builder->query;
// 빈 검색어인 경우 빈 결과 반환
if (empty(trim($keyword))) {
return [
'query' => null,
'total' => 0,
];
return self::emptyResult();
}
$query = $model->newQuery();
// FulltextSearchable 인터페이스 구현 확인
if (! ($model instanceof FulltextSearchable)) {
return [
'query' => null,
'total' => 0,
];
return self::emptyResult();
}
$columns = $model->searchableColumns();
$weights = $model->searchableWeights();
if (empty($columns)) {
return [
'query' => null,
'total' => 0,
];
return self::emptyResult();
}
$useFulltext = static::supportsFulltext();
@@ -375,10 +531,7 @@ class DatabaseFulltextEngine extends Engine
$ftKeyword = static::sanitizeBooleanModeKeyword($keyword);
if ($ftKeyword === '') {
// 연산자만 입력 → 500 대신 빈 결과 (빈 검색어와 동일 반환 형태)
return [
'query' => null,
'total' => 0,
];
return self::emptyResult();
}
// MATCH...AGAINST WHERE 조건 생성 (MySQL, MariaDB)
@@ -399,7 +552,7 @@ class DatabaseFulltextEngine extends Engine
$scoreBindings[] = $ftKeyword;
}
$scoreRaw = '('.implode(' + ', $scoreExpressions).') as _ft_score';
$query->selectRaw($qualifiedTable.'.*, '.$scoreRaw, $scoreBindings);
$this->applySelect($query, $model, $qualifiedTable, $scoreRaw, $scoreBindings, $keysOnly);
} else {
// LIKE fallback (PostgreSQL, SQLite 등)
$query->where(function ($q) use ($columns, $keyword) {
@@ -409,7 +562,7 @@ class DatabaseFulltextEngine extends Engine
});
// 스코어 고정 0 (관련성 순위 불가)
$query->selectRaw($qualifiedTable.'.*, 0 as _ft_score');
$this->applySelect($query, $model, $qualifiedTable, '0 as _ft_score', [], $keysOnly);
}
// Scout Builder 콜백 적용 (추가 where 조건 등)
@@ -459,8 +612,16 @@ class DatabaseFulltextEngine extends Engine
$query->orderByDesc('_ft_score');
}
// 전체 건수 계산 (페이지네이션 전)
$total = $query->toBase()->getCountForPagination();
// 전체 건수 계산 (페이지네이션 전).
// 총 건수를 쓰지 않는 경로에서는 아예 세지 않는다 — 대용량 매칭에서 COUNT 한 번이
// 조회 본체보다 비쌀 수 있다.
$resultCap = PaginationLimits::resultCap('search');
$total = null;
$relation = null;
if ($withTotal) {
[$total, $relation] = BoundedPaginator::countWithCap($query, $resultCap);
}
// 페이지네이션 적용
if ($perPage !== null) {
@@ -468,11 +629,62 @@ class DatabaseFulltextEngine extends Engine
$query->limit($perPage)->offset($offset);
} elseif ($builder->limit !== null) {
$query->limit($builder->limit);
} elseif ($resultCap !== null) {
// perPage 도 limit 도 없으면 LIMIT 이 아예 붙지 않아 매칭 전량을 PHP 로 끌어온다.
// 상한을 걸어 한 요청이 읽는 행 수에 천장을 둔다.
$query->limit($resultCap);
}
return [
'query' => $query,
'total' => $total,
'total_relation' => $relation,
'result_cap' => $resultCap,
];
}
/**
* 조회 컬럼을 적용합니다.
*
* 키만 필요한 경로에서는 키 컬럼과 정렬용 스코어로 좁힙니다. 스코어는 `ORDER BY` 가
* 참조하므로 좁힐 때도 함께 남겨야 합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query 대상 쿼리
* @param Model $model 기준 모델
* @param string $qualifiedTable 프리픽스 포함 테이블명
* @param string $scoreRaw 스코어 SELECT 표현식 (`... as _ft_score`)
* @param array<int, mixed> $scoreBindings 스코어 표현식 바인딩
* @param bool $keysOnly 키 컬럼만 조회할지 여부
*/
protected function applySelect(
$query,
Model $model,
string $qualifiedTable,
string $scoreRaw,
array $scoreBindings,
bool $keysOnly
): void {
if ($keysOnly) {
$query->select($model->getQualifiedKeyName())->selectRaw($scoreRaw, $scoreBindings);
return;
}
$query->selectRaw($qualifiedTable.'.*, '.$scoreRaw, $scoreBindings);
}
/**
* 검색이 성립하지 않을 때의 빈 결과 형태를 반환합니다.
*
* @return array{query: null, total: int, total_relation: TotalRelation, result_cap: null}
*/
protected static function emptyResult(): array
{
return [
'query' => null,
'total' => 0,
'total_relation' => TotalRelation::Exact,
'result_cap' => null,
];
}
}
+280
View File
@@ -0,0 +1,280 @@
<?php
namespace App\Search;
use App\Extension\HookManager;
use App\Search\Contracts\KeywordPredicateProvider;
use App\Search\DTO\KeywordSearchContext;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Scout\EngineManager;
use Throwable;
/**
* 진행 중인 DB 쿼리에 키워드 술어를 붙이는 단일 진입점
*
* 저장소는 "이 컬럼들로 이 키워드를 걸어라" 만 말하고, **어떤 엔진이 그 조건을 만드는지는
* 알지 않는다.** 활성 Scout 엔진이 {@see KeywordPredicateProvider} 를 구현하면 그 엔진에게
* 위임하고, 구현하지 않았으면 `LIKE` 로 내려간다.
*
* 이 지점이 없으면 저장소가 구체 엔진 클래스(예: `DatabaseFulltextEngine::whereFulltext()`)를
* 직접 부르게 되고, 그 순간 플러그인이 등록한 검색 엔진은 **호출될 기회 자체가 없어진다** —
* 오류도 경고도 없이 그 사이트의 검색만 조용히 다른 방식으로 동작한다.
*
* 확장이 자기 엔진을 등록하는 방법은 [search-system.md](../../docs/backend/search-system.md) 참조.
*/
final class KeywordSearch
{
/**
* DBMS 별 부분일치 연산자 표를 조정하는 필터 훅
*
* 확장이 새 DBMS 의 연산자를 선언할 때 씁니다.
*
* ```php
* HookManager::addFilter('core.search.like_operators', function (array $operators) {
* $operators['somedb'] = 'imatch';
*
* return $operators;
* });
* ```
*/
public const LIKE_OPERATORS_FILTER = 'core.search.like_operators';
/**
* 폴백 경고를 요청당 한 번만 남기기 위한 기록 (드라이버명 집합)
*
* @var array<string, true>
*/
private static array $warnedDrivers = [];
/**
* 쿼리에 키워드 술어를 붙입니다.
*
* 컬럼을 배열로 넘기면 **하나의 조건으로 함께 평가**해 달라는 뜻입니다. 엔진이 그 조합을
* 다룰 수 없는 경우(MySQL FULLTEXT 는 그 컬럼 조합의 복합 인덱스를 요구한다)에는
* {@see self::applyAny()} 로 컬럼별 OR 을 요청하세요.
*
* @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더
* @param string|array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문
* @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or')
* @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름 (예: 'search')
*/
public static function apply(
Builder $query,
string|array $columns,
string $keyword,
string $boolean = 'and',
?string $limitContext = null
): void {
$columns = array_values((array) $columns);
if ($columns === []) {
return;
}
$provider = self::provider();
if ($provider !== null) {
$provider->applyKeywordPredicate(
$query,
$columns,
$keyword,
$boolean,
self::context($limitContext)
);
return;
}
self::applyLikeMatch($query, $columns, $keyword, $boolean);
}
/**
* 엔진에게 넘길 술어 생성 조건을 만듭니다.
*
* 키 집합 상한은 목록 총 건수 상한과 같은 값을 쓴다 — 두 상한이 갈라지면 "엔진이
* 돌려준 건수" 와 "화면이 보고하는 총 건수" 가 서로 다른 근거를 갖게 된다.
*
* @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름
* @return KeywordSearchContext 술어 생성 조건
*/
private static function context(?string $limitContext): KeywordSearchContext
{
return new KeywordSearchContext(
keyCap: PaginationLimits::resultCap($limitContext),
);
}
/**
* 컬럼별로 따로 평가해 OR 로 묶은 키워드 술어를 붙입니다.
*
* 컬럼마다 개별 인덱스만 있는 테이블에서 씁니다. 바깥은 하나의 그룹으로 감싸므로
* 호출자의 기존 조건과 섞이지 않습니다.
*
* @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문
* @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or')
* @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름 (예: 'search')
*/
public static function applyAny(
Builder $query,
array $columns,
string $keyword,
string $boolean = 'and',
?string $limitContext = null
): void {
$columns = array_values($columns);
if ($columns === []) {
return;
}
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->$method(function ($group) use ($columns, $keyword, $limitContext) {
foreach ($columns as $index => $column) {
self::apply($group, $column, $keyword, $index === 0 ? 'and' : 'or', $limitContext);
}
});
}
/**
* 활성 엔진이 키워드 술어를 제공하는지 확인하고 그 엔진을 반환합니다.
*
* 제공하지 않으면 null 을 돌려주고, 그 사실을 드라이버당 한 번 기록합니다.
* 기록하지 않으면 "검색이 느리고 관련도가 이상하다" 는 증상만 남고 원인을 찾을 단서가
* 없어진다 — 폴백은 정상 동작처럼 보이기 때문이다.
*
* @return KeywordPredicateProvider|null 술어를 제공하는 엔진 (없으면 null)
*/
private static function provider(): ?KeywordPredicateProvider
{
try {
$engine = app(EngineManager::class)->engine();
} catch (Throwable) {
// 부팅 초기 등 엔진 해석이 불가한 상황 — 경고 없이 폴백한다.
return null;
}
if ($engine instanceof KeywordPredicateProvider) {
return $engine;
}
self::warnFallbackOnce();
return null;
}
/**
* 폴백 사용 사실을 드라이버당 한 번 기록합니다.
*/
private static function warnFallbackOnce(): void
{
$driver = (string) config('scout.driver', 'mysql-fulltext');
if (isset(self::$warnedDrivers[$driver])) {
return;
}
self::$warnedDrivers[$driver] = true;
Log::warning('활성 검색 엔진이 키워드 술어를 제공하지 않아 LIKE 로 검색합니다. 관련도 정렬이 적용되지 않고 전체 스캔이 발생합니다.', [
'driver' => $driver,
'contract' => KeywordPredicateProvider::class,
]);
}
/**
* 부분일치(LIKE) 조건을 붙입니다.
*
* 이 경로는 "엔진이 없을 때의 임시방편" 이 아니다. 전문검색을 제공하지 않는 DBMS 로
* 설치된 사이트에서는 **이것이 정상 검색 경로**이므로 이식성을 갖춰야 한다.
*
* - 대소문자: PostgreSQL 의 `LIKE` 는 대소문자를 구분한다. MySQL 은 기본 collation 이
* 구분하지 않으므로, 드라이버별로 연산자를 갈라 **같은 검색어가 DBMS 에 따라 다른
* 결과를 내지 않도록** 한다.
* - 와일드카드: 검색어의 `%` `_` `\` 를 escape 해 이용자가 입력한 글자 그대로 찾는다.
* escape 하지 않으면 `50%` 검색이 `50` 으로 시작하는 모든 행을 반환한다.
*
* @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문
* @param string $boolean 기존 조건과의 결합 방식
*/
public static function applyLikeMatch(
Builder $query,
array $columns,
string $keyword,
string $boolean = 'and'
): void {
$columns = array_values($columns);
if ($columns === []) {
return;
}
$pattern = '%'.self::escapeLikeWildcards($keyword).'%';
$operator = self::caseInsensitiveLikeOperator();
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->$method(function ($nested) use ($columns, $pattern, $operator) {
foreach ($columns as $index => $column) {
$nested->{$index === 0 ? 'where' : 'orWhere'}($column, $operator, $pattern);
}
});
}
/**
* 대소문자를 구분하지 않는 부분일치 연산자를 드라이버별로 반환합니다.
*
* 드라이버명을 코드에 적지 않는다 — 앞으로 어떤 DBMS 가 공식 지원될지 정해져 있지
* 않으므로, 코드에 박으면 DBMS 가 늘 때마다 코어를 고쳐야 한다. 표는 선언형 config
* (`core.search.like_operators`)에 있고, 확장은 필터 훅으로 조정한다.
*
* @return string 사용할 비교 연산자
*/
private static function caseInsensitiveLikeOperator(): string
{
$default = (string) config('core.search.like_operator_default', 'like');
$operators = config('core.search.like_operators', []);
$operators = HookManager::applyFilters(self::LIKE_OPERATORS_FILTER, $operators);
if (! is_array($operators)) {
return $default;
}
try {
$driver = DB::getDriverName();
} catch (Throwable) {
return $default;
}
$operator = $operators[$driver] ?? $default;
return is_string($operator) && $operator !== '' ? $operator : $default;
}
/**
* `LIKE` 패턴에서 특수 의미를 갖는 문자를 escape 합니다.
*
* @param string $keyword 검색어 원문
* @return string escape 된 검색어
*/
public static function escapeLikeWildcards(string $keyword): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword);
}
/**
* 폴백 경고 기록을 초기화합니다 (테스트 전용).
*/
public static function forgetFallbackWarnings(): void
{
self::$warnedDrivers = [];
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Search;
use App\Enums\TotalRelation;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPage;
use App\Support\Query\KeysetPaginator;
use Illuminate\Pagination\CursorPaginator;
/**
* 검색 카테고리 응답 페이로드를 만드는 단일 지점
*
* 검색 결과는 컬렉션 리소스를 거치지 않고 리스너가 배열을 만들어 넘긴다. 그 조립을
* 도메인마다 손으로 하면 응답 형태가 갈라지고, 나중에 필드가 하나 늘 때 어떤 화면은
* 받고 어떤 화면은 못 받는다. 그 형태 확장이 조용히 깨지는 것을 막기 위해 키 구성을
* 여기 한 곳에 둔다.
*
* offset 응답과 커서 응답은 채워지는 값만 다르고 **키 집합은 같다**. 화면이 두 형태를
* 분기 없이 그릴 수 있어야 하기 때문이다.
*/
final class SearchCategoryPayload
{
/**
* offset(페이지 번호) 방식 결과로 페이로드를 만듭니다.
*
* @param BoundedPage $page 상한 총 건수를 가진 페이지 결과
* @param array<int, mixed> $items 화면에 실을 항목 (가공 완료 상태)
* @param array<string, mixed> $extra 도메인 고유 필드 (available_boards 등)
* @return array<string, mixed> 카테고리 페이로드
*/
public static function fromBounded(BoundedPage $page, array $items, array $extra = []): array
{
return array_merge([
'total' => $page->total(),
'total_relation' => $page->totalRelation()->value,
'total_is_exact' => $page->totalRelation()->isExact(),
'result_cap' => $page->resultCap(),
'last_page' => $page->lastPage(),
'has_more_pages' => $page->hasMorePages(),
// offset 응답에는 커서가 없다. 키 자체는 남겨 화면이 분기 없이 읽게 한다.
'next_cursor' => null,
'prev_cursor' => null,
'items' => $items,
], $extra);
}
/**
* 커서(키셋) 방식 결과로 페이로드를 만듭니다.
*
* 커서 응답에는 총 건수가 없으므로 건수는 별도 집계로 받습니다. 마지막 페이지 번호는
* 커서 방식에 존재하지 않는 개념이라 언제나 null 이며, 화면은 그때 마지막 페이지
* 점프만 감추고 "다음" 이동은 그대로 유지합니다.
*
* @param CursorPaginator $page 커서 페이지 결과
* @param BoundedCount|null $count 총 건수 집계 (배지를 그리지 않으면 null)
* @param array<int, mixed> $items 화면에 실을 항목 (가공 완료 상태)
* @param array<string, mixed> $extra 도메인 고유 필드
* @return array<string, mixed> 카테고리 페이로드
*/
public static function fromCursor(
CursorPaginator $page,
?BoundedCount $count,
array $items,
array $extra = []
): array {
$relation = $count?->totalRelation() ?? TotalRelation::AtLeast;
return array_merge([
'total' => $count?->total() ?? count($items),
'total_relation' => $relation->value,
'total_is_exact' => $count !== null && $relation->isExact(),
'result_cap' => $count?->resultCap(),
// 커서 방식에는 마지막 페이지 번호가 없다 (총 건수를 알아도 계산하지 않는다).
'last_page' => null,
'has_more_pages' => $page->hasMorePages(),
'next_cursor' => KeysetPaginator::nextCursor($page),
'prev_cursor' => KeysetPaginator::previousCursor($page),
'items' => $items,
], $extra);
}
/**
* 목록 없이 건수만 필요한 자리(비활성 탭 배지)의 페이로드를 만듭니다.
*
* @param BoundedCount $count 총 건수 집계
* @param array<string, mixed> $extra 도메인 고유 필드
* @return array<string, mixed> 카테고리 페이로드
*/
public static function fromCountOnly(BoundedCount $count, array $extra = []): array
{
return array_merge($count->toArray(), [
'last_page' => null,
'has_more_pages' => false,
'next_cursor' => null,
'prev_cursor' => null,
'items' => [],
], $extra);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Search;
use App\Support\Query\KeysetPaginator;
/**
* 검색 목록이 커서(키셋)로 응답할 수 있는지 판정하는 단일 지점
*
* 이 판정을 도메인마다 각자 구현하면 같은 규칙이 검색 모듈 수만큼 복제되고, 한 곳만
* 고치면 다른 도메인은 조용히 옛 규칙으로 남는다. 코어가 규칙을 소유하고, 확장은
* "내 정렬 이름이 어떤 실제 컬럼인가" 만 선언한다.
*
* 커서는 정렬 키를 WHERE 절 경계로 삼으므로 정렬 키가 전부 실제 컬럼이어야 한다.
* 관련도순처럼 계산값(FULLTEXT 점수)으로 정렬하는 경우에는 쓸 수 없고 offset 을 유지한다.
*/
final class SearchPagePolicy
{
/**
* 이 요청을 커서로 응답할지 판정합니다.
*
* 커서를 받은 요청만 커서로 처리하면 첫 커서가 만들어질 자리가 없다. 서버는 커서
* 모드에서만 다음 커서를 내보내므로, 화면은 건넬 커서가 없어 영원히 offset 에 머문다.
* 첫 페이지에 커서가 없는 것은 정상이므로 "커서 없음" 이 아니라 "시작점" 으로 읽는다.
*
* 다만 커서 없이 깊은 페이지를 직접 지목한 요청(주소로 열어 둔 딥링크·북마크)은
* 그 페이지를 그대로 보여줘야 한다. 커서로 바꾸면 첫 페이지로 되돌아가 링크가
* 가리키던 자리를 잃으므로, 그 경우에만 offset 을 유지한다.
*
* @param string|null $cursor 요청이 보낸 커서 (첫 페이지면 없음)
* @param array<int, array{0: string, 1: string}> $sortKeys [[컬럼, 방향], ...]
* @param array<int, string> $cursorColumns 이 도메인에서 커서로 허용한 실제 컬럼
* @param int $page 요청이 지목한 페이지 번호 (없으면 1)
* @return bool 커서로 응답할 수 있으면 true
*/
public static function usesCursor(?string $cursor, array $sortKeys, array $cursorColumns, int $page = 1): bool
{
if (! KeysetPaginator::supports($sortKeys, $cursorColumns)) {
return false;
}
if ($cursor !== null && $cursor !== '') {
return true;
}
return $page <= 1;
}
/**
* 정렬 이름을 정렬 키 배열로 바꿉니다.
*
* 확장이 선언한 `정렬이름 => [컬럼, 방향]` 맵을 코어가 해석합니다. 맵에 없는 이름은
* 커서로 처리할 수 없는 정렬(관련도순 등)로 보고 빈 배열을 돌려주며,
* {@see self::usesCursor()} 가 이를 "커서 불가" 로 판정합니다.
*
* @param string $sort 요청이 보낸 정렬 이름
* @param array<string, array{0: string, 1: string}> $sortMap 정렬 이름 → [컬럼, 방향]
* @return array<int, array{0: string, 1: string}> 정렬 키 배열 (해석 불가 시 빈 배열)
*/
public static function sortKeys(string $sort, array $sortMap): array
{
if (! array_key_exists($sort, $sortMap)) {
return [];
}
return [$sortMap[$sort]];
}
}
+11 -8
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Extension\HookManager;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
@@ -18,7 +19,7 @@ class ActivityLogService
/**
* ActivityLogService 생성자
*
* @param ActivityLogRepositoryInterface $repository 활동 로그 리포지토리
* @param ActivityLogRepositoryInterface $repository 활동 로그 리포지토리
*/
public function __construct(
private ActivityLogRepositoryInterface $repository
@@ -27,8 +28,8 @@ class ActivityLogService
/**
* 특정 모델의 활동 로그 목록을 조회합니다.
*
* @param Model $model 대상 모델
* @param array $filters 필터 조건
* @param Model $model 대상 모델
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getLogsForModel(Model $model, array $filters = []): LengthAwarePaginator
@@ -39,10 +40,12 @@ class ActivityLogService
/**
* 활동 로그 목록을 조회합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로 응답합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getList(array $filters = []): LengthAwarePaginator
public function getList(array $filters = []): LengthAwarePaginator|CursorPaginator
{
return $this->repository->getPaginated($filters);
}
@@ -50,7 +53,7 @@ class ActivityLogService
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
@@ -67,7 +70,7 @@ class ActivityLogService
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int
+1
View File
@@ -356,6 +356,7 @@ class AuthService
$userRole = $this->roleRepository->findByIdentifier('user');
if ($userRole) {
$user->roles()->sync([$userRole->id]);
$user->flushPermissionCaches();
}
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
@@ -56,8 +56,14 @@ class LanguagePackRegistry
return $this->activeCoreLocalesCache;
}
$fromDb = $this->repository->getActiveCoreLocales();
$merged = array_values(array_unique(array_merge(self::BUNDLED_CORE_LOCALES, $fromDb)));
// 활성 언어팩 전체는 getActivePacks() 가 이미 한 번 적재해 캐시한다. 코어 로케일은
// 그 컬렉션의 부분집합이므로 DB 를 다시 부르지 않고 여기서 걸러 낸다.
// (부팅 경로에서 두 메서드가 모두 호출되므로, 재조회하면 요청마다 쿼리가 하나 더 는다)
$fromPacks = $this->getActivePacks(LanguagePackScope::Core->value)
->pluck('locale')
->all();
$merged = array_values(array_unique(array_merge(self::BUNDLED_CORE_LOCALES, $fromPacks)));
return $this->activeCoreLocalesCache = $merged;
}
+23 -1
View File
@@ -7,8 +7,12 @@ use App\Enums\NotificationLogStatus;
use App\Extension\HookManager;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 알림 발송 이력 서비스
*/
class NotificationLogService
{
public function __construct(
@@ -17,6 +21,9 @@ class NotificationLogService
/**
* 발송 성공 로그를 기록합니다.
*
* @param array<string, mixed> $data 로그 데이터
* @return NotificationLog 기록된 로그
*/
public function logSent(array $data): NotificationLog
{
@@ -33,6 +40,9 @@ class NotificationLogService
/**
* 발송 실패 로그를 기록합니다.
*
* @param array<string, mixed> $data 로그 데이터
* @return NotificationLog 기록된 로그
*/
public function logFailed(array $data): NotificationLog
{
@@ -49,6 +59,9 @@ class NotificationLogService
/**
* 발송 건너뜀 로그를 기록합니다.
*
* @param array<string, mixed> $data 로그 데이터
* @return NotificationLog 기록된 로그
*/
public function logSkipped(array $data): NotificationLog
{
@@ -65,6 +78,9 @@ class NotificationLogService
/**
* 로그를 삭제합니다.
*
* @param NotificationLog $log 삭제할 로그
* @return bool 삭제 성공 여부
*/
public function deleteLog(NotificationLog $log): bool
{
@@ -79,6 +95,9 @@ class NotificationLogService
/**
* 다건 삭제합니다.
*
* @param array<int, int> $ids 삭제할 로그 ID 목록
* @return int 삭제된 건수
*/
public function bulkDelete(array $ids): int
{
@@ -94,9 +113,12 @@ class NotificationLogService
/**
* 페이지네이션 목록을 조회합니다.
*
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지당 건수
* @param User|null $user 스코프 적용 대상 사용자 (null이면 스코프 미적용)
* @return LengthAwarePaginator|CursorPaginator 페이지 결과 (커서 요청 시 키셋)
*/
public function getLogs(array $filters = [], int $perPage = 20, ?User $user = null): LengthAwarePaginator
public function getLogs(array $filters = [], int $perPage = 20, ?User $user = null): LengthAwarePaginator|CursorPaginator
{
return $this->repository->getPaginated($filters, $perPage, $user);
}
+34 -9
View File
@@ -590,21 +590,46 @@ class SettingsService
}
/**
* advanced 탭 설정을 cache와 debug 카테고리로 분리하여 저장합니다.
* advanced 탭 설정의 카테고리별 필드 분류표를 만듭니다.
*
* 분류표는 스키마(`frontend_schema.*.merge_into === 'advanced'`)에서 도출합니다.
* 손으로 열거하면 고급 탭에 카테고리가 새로 합류할 때 분류표가 뒤처지고, 그 값은
* 어느 카테고리에도 담기지 않은 채 조용히 버려집니다(저장은 성공으로 보고됨).
*
* 아래 기본 목록은 스키마가 노출하지 않지만 고급 탭이 계속 저장해 온 레거시 필드
* (cache 카테고리 전반, debug.log_level)를 보존하기 위한 것입니다.
*
* @return array<string, array<int, string>> 카테고리 → 원본 필드명 목록
*/
private function buildAdvancedCategoryFieldMap(): array
{
// (frontend_key → 원본 키 역변환 후의 키 기준)
$map = [
'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl', 'seo_sitemap_ttl'],
'debug' => ['mode', 'sql_query_log', 'log_level'],
];
foreach ($this->configRepository->getFrontendSchema() as $category => $categorySchema) {
if (str_starts_with($category, '_') || ($categorySchema['merge_into'] ?? null) !== 'advanced') {
continue;
}
$fields = array_keys($categorySchema['fields'] ?? []);
$map[$category] = array_values(array_unique(array_merge($map[$category] ?? [], $fields)));
}
return $map;
}
/**
* advanced 탭 설정을 소속 카테고리로 분리하여 저장합니다.
*
* @param array $settings 저장할 설정 배열
* @return bool 저장 성공 여부
*/
private function saveAdvancedSettings(array $settings): bool
{
// 각 카테고리에 속하는 원본 필드명 목록
// (frontend_key → 원본 키 역변환 후의 키 기준)
$categoryFieldMap = [
'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl', 'seo_sitemap_ttl'],
'debug' => ['mode', 'sql_query_log', 'log_level'],
'core_update' => ['github_url', 'github_token'],
'geoip' => ['feature_enabled', 'license_key', 'auto_update_enabled', 'last_updated_at'],
];
$categoryFieldMap = $this->buildAdvancedCategoryFieldMap();
// 설정을 카테고리별로 분류
$categorized = array_fill_keys(array_keys($categoryFieldMap), []);
+3
View File
@@ -88,6 +88,7 @@ class UserService
// 역할 동기화
if ($roleIds !== null && count($roleIds) > 0) {
$user->roles()->sync($roleIds);
$user->flushPermissionCaches();
}
// After 훅: 사용자 객체와 원본 데이터 전달
@@ -196,6 +197,7 @@ class UserService
// 역할 동기화
if ($roleIds !== null) {
$user->roles()->sync($roleIds);
$user->flushPermissionCaches();
}
// After 훅: 사용자 객체와 원본 데이터, 스냅샷 전달
@@ -313,6 +315,7 @@ class UserService
// 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
$user->roles()->detach();
$user->flushPermissionCaches();
// 약관 동의 이력 삭제
$user->consents()->delete();
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Support;
use App\Enums\PermissionType;
use App\Models\Permission;
use App\Models\Role;
/**
* 비회원(guest) 역할 해석기
*
* 비회원 권한 판정은 한 요청에서 수십 번 일어난다 — 미들웨어, Gate, 리소스가 각각 묻는다.
* 매번 역할과 권한을 다시 조회하지 않도록 요청당 한 번만 적재하고 그 결과를 공유한다.
*
* 캐시 수명은 **요청 스코프**다. 프로세스 정적으로 두면 상주형 실행 환경에서 권한을
* 바꿔도 그 프로세스가 살아 있는 동안 예전 권한으로 판정한다 — 권한 변경 즉시 반영이
* 깨지므로 그렇게 하지 않는다.
*
* 미들웨어와 Gate 가 각자 같은 조회를 들고 있으면 한쪽만 고쳐졌을 때 두 경로의 판정이
* 갈린다. 그래서 해석은 이 클래스 한 곳에서만 한다.
*/
class GuestRoleResolver
{
/**
* 해석 결과를 담는 요청 속성 키
*/
private const CACHE_KEY = '_guest_role_cache';
/**
* guest 역할을 권한과 함께 조회합니다 (요청당 1회).
*
* @return Role|null guest 역할 (미정의 시 null)
*/
public static function resolve(): ?Role
{
$request = request();
if ($request->attributes->has(self::CACHE_KEY)) {
return $request->attributes->get(self::CACHE_KEY);
}
$role = Role::where('identifier', 'guest')
->with('permissions')
->first();
$request->attributes->set(self::CACHE_KEY, $role);
return $role;
}
/**
* guest 역할이 특정 권한을 보유하는지 판정합니다.
*
* 적재해 둔 권한 컬렉션에서 판정하므로 판정마다 쿼리가 나가지 않습니다.
*
* @param string $identifier 권한 식별자
* @param PermissionType|null $type 권한 타입 (null 이면 타입 무관)
* @return bool 보유 여부
*/
public static function hasPermission(string $identifier, ?PermissionType $type = null): bool
{
$role = self::resolve();
if (! $role) {
return false;
}
foreach ($role->permissions as $permission) {
if ($permission->identifier !== $identifier) {
continue;
}
if ($type === null || self::permissionType($permission) === $type) {
return true;
}
}
return false;
}
/**
* 캐시를 비웁니다.
*
* 권한 재시드(코어 업데이트 / 확장 설치) 직후처럼 같은 요청 안에서 권한 구성이
* 바뀌는 경우에 호출합니다.
*/
public static function flush(): void
{
request()->attributes->remove(self::CACHE_KEY);
}
/**
* 권한의 타입을 Enum 으로 정규화합니다.
*
* @param Permission $permission 권한 모델
* @return PermissionType|null 정규화된 타입 (해석 불가 시 null)
*/
private static function permissionType($permission): ?PermissionType
{
$type = $permission->type;
if ($type instanceof PermissionType) {
return $type;
}
return $type === null ? null : PermissionType::tryFrom((string) $type);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Support\Query;
use App\Contracts\Pagination\BoundedTotalAware;
use App\Enums\TotalRelation;
/**
* 상한을 건 총 건수 집계 결과
*
* 목록을 조회하지 않고 건수만 필요한 자리(탭 배지, 요약 수치)를 위한 값 객체다.
*
* `int` 하나만 돌려주면 상한에 걸려 잘린 값과 정확히 센 값이 구분되지 않는다.
* 잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나가는 것은 오류로 드러나지 않고
* 그냥 틀린 숫자로만 보이므로, 건수와 정확도를 한 덩어리로 옮긴다.
*
* @see BoundedPaginator::count() 이 값을 만드는 유일한 자리
*/
final class BoundedCount implements BoundedTotalAware
{
/**
* @param int $total 총 건수 (정확도가 AtLeast 면 "이 값 이상")
* @param TotalRelation $relation 총 건수 정확도
* @param int|null $resultCap 집계에 적용된 상한 (null 이면 무제한)
*/
public function __construct(
public readonly int $total,
private readonly TotalRelation $relation,
private readonly ?int $resultCap,
) {}
/**
* 총 건수 정확도를 반환합니다.
*
* @return TotalRelation 정확도
*/
public function totalRelation(): TotalRelation
{
return $this->relation;
}
/**
* 집계에 적용된 상한을 반환합니다.
*
* @return int|null 상한 (무제한이면 null)
*/
public function resultCap(): ?int
{
return $this->resultCap;
}
/**
* 상한에 걸려 잘렸는지 여부를 반환합니다.
*
* @return bool 잘렸으면 true
*/
public function isTruncated(): bool
{
return ! $this->relation->isExact();
}
/**
* 총 건수를 반환합니다.
*
* @return int 총 건수
*/
public function total(): int
{
return $this->total;
}
/**
* 응답에 실을 정확도 필드 묶음을 반환합니다.
*
* 배지·요약 자리마다 같은 키를 손으로 조립하면 한 군데만 빠져도
* 그 화면에서만 잘린 값이 정확한 것처럼 나간다.
*
* @return array{total: int, total_relation: string, total_is_exact: bool, result_cap: int|null}
*/
public function toArray(): array
{
return [
'total' => $this->total,
'total_relation' => $this->relation->value,
'total_is_exact' => $this->relation->isExact(),
'result_cap' => $this->resultCap,
];
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Support\Query;
use App\Contracts\Pagination\BoundedTotalAware;
use App\Enums\TotalRelation;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/**
* 상한 집계 기반 페이지 결과
*
* 한 페이지의 조회 결과와 그 총 건수의 정확도를 함께 담는 불변 값이다. 생성 후 상태를
* 바꾸는 메서드를 두지 않으며, 값은 전적으로 {@see BoundedPaginator} 가 채운다.
*
* 표준 `LengthAwarePaginator` 를 상속하는 이유는 재사용 때문이다. 기존 컬렉션·리소스·
* 컨트롤러는 전부 페이지네이터 인터페이스에 맞춰져 있으므로, 별도 타입을 새로 정의하면
* 소비 지점마다 언랩 코드가 생긴다. 상속하면 기존 컬렉션이 코드 변경 없이 그대로 받고,
* {@see BoundedTotalAware} 구현 덕에 정확도 메타까지 자동으로 얻는다.
*
* 표준 페이지네이터와 다른 점은 셋뿐이다:
* - `total()` 은 상한 이하일 때만 실제 건수다 (초과 시 상한값 = 하한 보증)
* - `lastPage()` 는 총 건수가 부정확하면 null 이다 (마지막 페이지 점프 계산 불가)
* - `hasMorePages()` 는 총 건수가 아니라 `per_page + 1` 실측으로 판정한다
*/
class BoundedPage extends LengthAwarePaginator implements BoundedTotalAware
{
/**
* 총 건수와 실제 매칭 건수의 관계
*/
private TotalRelation $totalRelation;
/**
* 총 건수 집계에 적용된 상한 (무제한이면 null)
*/
private ?int $resultCap;
/**
* 다음 페이지 존재 여부 (per_page + 1 실측 결과)
*/
private bool $probedHasMorePages;
/**
* @param Collection<int, mixed>|array<int, mixed> $items 현재 페이지 항목
* @param int $total 총 건수 (상한 초과 시 상한값)
* @param int $perPage 페이지당 건수
* @param int $currentPage 현재 페이지 번호
* @param TotalRelation $totalRelation 총 건수 정확도
* @param int|null $resultCap 적용된 상한 (무제한이면 null)
* @param bool $hasMorePages 다음 페이지 존재 여부 (per_page + 1 실측)
* @param array<string, mixed> $options 페이지네이터 옵션 (path, pageName 등)
*/
public function __construct(
$items,
int $total,
int $perPage,
int $currentPage,
TotalRelation $totalRelation,
?int $resultCap,
bool $hasMorePages,
array $options = []
) {
parent::__construct($items, $total, $perPage, $currentPage, $options);
$this->totalRelation = $totalRelation;
$this->resultCap = $resultCap;
$this->probedHasMorePages = $hasMorePages;
}
/**
* 총 건수와 실제 매칭 건수의 관계를 반환합니다.
*
* @return TotalRelation 정확(Exact) 또는 하한(AtLeast)
*/
public function totalRelation(): TotalRelation
{
return $this->totalRelation;
}
/**
* 총 건수 집계에 적용된 상한을 반환합니다.
*
* @return int|null 상한 (무제한이면 null)
*/
public function resultCap(): ?int
{
return $this->resultCap;
}
/**
* 총 건수가 상한에 걸려 잘렸는지 여부를 반환합니다.
*
* @return bool 잘렸으면 true
*/
public function isTruncated(): bool
{
return ! $this->totalRelation->isExact();
}
/**
* 마지막 페이지 번호를 반환합니다.
*
* 총 건수가 부정확하면 마지막 페이지를 계산할 수 없으므로 null 을 반환합니다.
* 화면은 이 값이 null 이면 마지막 페이지 점프 버튼을 감춥니다.
*
* @return int|null 마지막 페이지 번호 (총 건수 부정확 시 null)
*/
public function lastPage(): ?int
{
return $this->isTruncated() ? null : $this->lastPage;
}
/**
* 다음 페이지 존재 여부를 반환합니다.
*
* 총 건수를 몰라도 `per_page + 1` 조회로 정확히 판정되므로, 상한에 걸린 상태에서도
* "다음" 이동은 끝까지 열려 있습니다.
*
* @return bool 다음 페이지가 있으면 true
*/
public function hasMorePages(): bool
{
return $this->probedHasMorePages;
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Support\Query;
use App\Enums\TotalRelation;
use Illuminate\Contracts\Database\Query\Builder as BuilderContract;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\DB;
/**
* 상한을 건 총 건수 집계 페이지네이터
*
* 임의의 빌더를 받아 한 페이지를 조회하고, 총 건수는 상한까지만 센다. 검색이나 특정
* 도메인에 대한 지식이 없으므로 어떤 목록에서도 쓸 수 있다.
*
* 표준 `paginate()` 와 다른 점 둘:
*
* 1. **총 건수만 상한을 받는다.** `SELECT COUNT(*) FROM (SELECT 1 FROM ... LIMIT cap+1) t`
* 로 감싸므로, 상한 이하면 표준 `COUNT(*)` 와 값이 같고 초과할 때만 "이상" 이 된다.
* 2. **다음 페이지 판정은 총 건수와 무관하다.** `per_page + 1` 건을 읽어 초과분 존재
* 여부로 판정하므로, 총 건수를 몰라도 "다음" 이동은 끝까지 정확하다.
*
* 상한을 넘겨 잘린 경우에도 페이지 이동이 막히지 않는다. 계산이 불가능해지는 것은
* 마지막 페이지 번호 하나뿐이며, 그 사실은 {@see BoundedPage::lastPage()} 가 null 로 알린다.
*
* **입력 범위는 표준 `paginate()` 와 같아야 한다.** Eloquent 빌더뿐 아니라 쿼리 빌더
* (`DB::table(...)`)와 관계(`$user->notifications()`)도 받는다. 관계에서 `->paginate()` 가
* 되는 이유는 관계가 빌더로 호출을 전달해 주기 때문인데, 정적 메서드는 그 전달을 받지
* 못한다. 그래서 여기서 직접 이해한다 — 이 입구가 표준보다 좁으면, 표준을 이 계약으로
* 바꾸는 것만으로 멀쩡하던 목록이 TypeError 로 죽는다.
*/
class BoundedPaginator
{
/**
* 관계를 그 밑의 빌더로 환원합니다.
*
* 관계의 소속 조건(`where user_id = ?` 등)은 관계 생성 시점에 빌더에 이미 들어가 있어
* 그대로 보존된다.
*
* @param EloquentBuilder|QueryBuilder|Relation $query 대상
* @return EloquentBuilder|QueryBuilder 빌더
*/
private static function toBuilder(EloquentBuilder|QueryBuilder|Relation $query): EloquentBuilder|QueryBuilder
{
return $query instanceof Relation ? $query->getQuery() : $query;
}
/**
* 한 페이지를 조회하고 상한 총 건수를 함께 계산합니다.
*
* @param EloquentBuilder|QueryBuilder|Relation $query 대상 빌더/관계 (정렬·필터가 이미 적용된 상태)
* @param int $perPage 페이지당 건수
* @param int|null $page 현재 페이지 번호 (null 이면 요청에서 해석)
* @param int|null $resultCap 총 건수 집계 상한 (null 이면 무제한 = 정확한 COUNT)
* @param array<int, string> $columns 조회 컬럼
* @param string $pageName 페이지 쿼리 파라미터 이름
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
*/
public static function paginate(
EloquentBuilder|QueryBuilder|Relation $query,
int $perPage,
?int $page = null,
?int $resultCap = null,
array $columns = ['*'],
string $pageName = 'page'
): BoundedPage {
$query = self::toBuilder($query);
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$page = max(1, $page);
$perPage = max(1, $perPage);
// per_page + 1 건을 읽어 다음 페이지 존재 여부를 실측한다.
// 총 건수 상한과 무관하게 항상 정확하다. 호출자의 빌더는 건드리지 않는다.
//
// offset 은 per_page 기준으로 계산한다. forPage($page, $perPage + 1) 을 쓰면
// offset 까지 per_page + 1 배수가 되어 페이지가 깊어질수록 경계가 밀린다.
$items = (clone $query)
->offset(($page - 1) * $perPage)
->limit($perPage + 1)
->get($columns);
$hasMorePages = $items->count() > $perPage;
if ($hasMorePages) {
$items = $items->slice(0, $perPage)->values();
}
[$total, $relation] = self::countWithCap($query, $resultCap);
// 상한 이하인데 이번 페이지가 상한 경계를 넘겨 조회된 경우를 방어한다.
// (동시 삽입으로 카운트 시점과 조회 시점의 모수가 다를 수 있다)
$seenSoFar = ($page - 1) * $perPage + $items->count();
if ($total < $seenSoFar) {
$total = $seenSoFar;
}
return new BoundedPage(
items: $items,
total: $total,
perPage: $perPage,
currentPage: $page,
totalRelation: $relation,
resultCap: $resultCap,
hasMorePages: $hasMorePages,
options: [
'path' => Paginator::resolveCurrentPath(),
'pageName' => $pageName,
],
);
}
/**
* 목록 조회 없이 상한을 건 총 건수만 셉니다.
*
* 탭 배지처럼 건수만 필요한 자리에서 쓴다. {@see countWithCap()} 과 같은 쿼리를
* 실행하지만 정확도를 값에 담아 돌려주므로, 잘린 값이 정확한 것처럼 화면에
* 나가는 일이 생기지 않는다.
*
* @param EloquentBuilder|QueryBuilder|Relation $query 대상 빌더/관계
* @param int|null $resultCap 상한 (null 이면 무제한 = 정확한 COUNT)
* @return BoundedCount 건수 + 정확도
*/
public static function count(EloquentBuilder|QueryBuilder|Relation $query, ?int $resultCap): BoundedCount
{
[$total, $relation] = self::countWithCap($query, $resultCap);
return new BoundedCount($total, $relation, $resultCap);
}
/**
* 상한을 건 총 건수를 셉니다.
*
* 상한을 넘는지만 알면 되므로 `LIMIT cap + 1` 로 감싼 파생 테이블을 센다.
* 결과가 `cap + 1` 이면 실제 건수는 그 이상이므로 상한값을 하한으로 보고한다.
*
* `GROUP BY` 가 걸린 쿼리도 파생 테이블 안에서 그룹이 만들어지므로 그룹 수가
* 그대로 센다 (표준 `count()` 가 그룹별 건수를 돌려주는 문제가 없다).
*
* @param EloquentBuilder|QueryBuilder|Relation $query 대상 빌더/관계
* @param int|null $resultCap 상한 (null 이면 무제한 = 정확한 COUNT)
* @return array{0: int, 1: TotalRelation} [총 건수, 정확도]
*/
public static function countWithCap(EloquentBuilder|QueryBuilder|Relation $query, ?int $resultCap): array
{
$base = self::toCountableBase(self::toBuilder($query));
if ($resultCap === null || $resultCap <= 0) {
return [$base->getCountForPagination(), TotalRelation::Exact];
}
$bounded = $base->limit($resultCap + 1);
// newQuery() 는 같은 커넥션의 빈 빌더를 만든다. fromSub 가 바인딩까지 옮겨 주므로
// 서브쿼리 SQL 을 문자열로 조립하거나 mergeBindings 를 부를 필요가 없다.
$counted = (int) $bounded->newQuery()
->fromSub($bounded, 'g7_bounded_total')
->count();
return $counted > $resultCap
? [$resultCap, TotalRelation::AtLeast]
: [$counted, TotalRelation::Exact];
}
/**
* 총 건수 집계용 기본 빌더를 만듭니다.
*
* 정렬은 건수에 영향을 주지 않으므로 제거하고, `DISTINCT`·`GROUP BY` 가 없으면
* SELECT 목록을 상수로 좁혀 불필요한 컬럼 읽기를 없앤다.
*
* @param EloquentBuilder|QueryBuilder $query 대상 빌더
* @return QueryBuilder 집계용 기본 빌더 (원본 불변)
*/
private static function toCountableBase(EloquentBuilder|QueryBuilder $query): QueryBuilder
{
// toBase() 는 Eloquent 빌더에만 있다. 이 클래스는 쿼리 빌더도 받겠다고 선언했으므로
// (supports() 가 그것을 허용한다) 종류를 보고 갈라야 한다 — 그러지 않으면
// `DB::table(...)` 을 그대로 넘긴 호출이 BadMethodCallException 으로 죽는다.
$clone = clone $query;
$base = $clone instanceof EloquentBuilder ? $clone->toBase() : $clone;
$base->reorder();
$base->limit(null)->offset(null);
// DISTINCT / GROUP BY 는 어떤 컬럼을 세는지가 결과를 좌우하므로 SELECT 를 건드리지 않는다.
if (! $base->distinct && empty($base->groups)) {
$base->select(DB::raw('1'));
}
return $base;
}
/**
* 빌더가 상한 집계에 쓸 수 있는 형태인지 확인합니다.
*
* @param mixed $query 검사 대상
* @return bool 사용 가능하면 true
*/
public static function supports(mixed $query): bool
{
return $query instanceof EloquentBuilder
|| $query instanceof QueryBuilder
|| $query instanceof Relation
|| $query instanceof BuilderContract;
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Support\Query;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\Cursor;
use Illuminate\Pagination\CursorPaginator;
/**
* 커서(키셋) 기반 페이지네이터
*
* OFFSET 방식은 페이지가 깊어질수록 건너뛸 행을 실제로 읽어야 해서 마지막 페이지에 갈수록
* 느려진다. 커서 방식은 직전 페이지 마지막 행의 정렬 키를 WHERE 절 경계로 삼으므로 깊이와
* 무관하게 일정하다.
*
* 커서 문자열의 인코딩·디코딩은 전부 이 클래스를 통한다. 각 목록이 자기 방식으로 커서를
* 만들면 형식이 갈라져 한 화면의 커서를 다른 화면이 해석하지 못한다.
*
* 적용 조건: 정렬 키가 모두 **실제 컬럼**이어야 한다. 계산값(예: FULLTEXT 관련도 점수)은
* WHERE 절 경계로 쓸 수 없으므로 그 정렬에서는 커서를 쓰지 못하고 OFFSET 을 유지한다.
* 판정은 {@see self::supports()} 가 담당한다.
*/
class KeysetPaginator
{
/**
* 커서 쿼리 파라미터의 표준 이름
*/
public const CURSOR_PARAM = 'cursor';
/**
* 커서 기반으로 한 페이지를 조회합니다.
*
* 정렬 키 마지막에 고유 컬럼(기본키)이 없으면 자동으로 덧붙입니다. 동률 구간에서
* 전순서가 보장되지 않으면 인접 페이지가 같은 행을 중복 노출하고 다른 행을 누락합니다.
*
* @param EloquentBuilder|QueryBuilder $query 대상 빌더 (정렬 미적용 상태로 전달)
* @param int $perPage 페이지당 건수
* @param array<int, array{0: string, 1: string}> $sortKeys [[컬럼, 방향], ...] 순서대로 적용
* @param string $uniqueKey 전순서 보장용 고유 컬럼 (보통 기본키)
* @param string|null $cursor 인코딩된 커서 문자열 (첫 페이지면 null)
* @param array<int, string> $columns 조회 컬럼
* @return CursorPaginator 커서 페이지 결과
*/
public static function paginate(
EloquentBuilder|QueryBuilder $query,
int $perPage,
array $sortKeys,
string $uniqueKey,
?string $cursor = null,
array $columns = ['*']
): CursorPaginator {
$perPage = max(1, $perPage);
$applied = [];
foreach ($sortKeys as [$column, $direction]) {
$query->orderBy($column, self::normalizeDirection($direction));
$applied[] = $column;
}
// 전순서 보장 — 마지막 정렬 키가 고유하지 않으면 페이지 경계에서 행이 새거나 겹친다.
if (! in_array($uniqueKey, $applied, true)) {
$lastDirection = $sortKeys === [] ? 'desc' : self::normalizeDirection(end($sortKeys)[1]);
$query->orderBy($uniqueKey, $lastDirection);
}
return $query->cursorPaginate($perPage, $columns, self::CURSOR_PARAM, self::decode($cursor));
}
/**
* 정렬 키 전부가 커서로 쓸 수 있는 실제 컬럼인지 판정합니다.
*
* 계산값·표현식·별칭은 WHERE 절 경계로 쓸 수 없습니다.
*
* @param array<int, array{0: string, 1: string}> $sortKeys [[컬럼, 방향], ...]
* @param array<int, string> $columnWhitelist 커서로 허용된 실제 컬럼 목록
* @return bool 전부 허용 컬럼이면 true
*/
public static function supports(array $sortKeys, array $columnWhitelist): bool
{
if ($sortKeys === []) {
return false;
}
foreach ($sortKeys as [$column]) {
if (! in_array($column, $columnWhitelist, true)) {
return false;
}
}
return true;
}
/**
* 커서 문자열을 디코딩합니다.
*
* 형식이 깨진 값은 예외 대신 null 로 취급해 첫 페이지로 되돌립니다. 사용자가 URL 을
* 손으로 고쳤다는 이유로 목록 화면이 오류를 띄우게 두지 않습니다.
*
* @param string|null $cursor 인코딩된 커서 문자열
* @return Cursor|null 디코딩된 커서 (부재·해석 실패 시 null)
*/
public static function decode(?string $cursor): ?Cursor
{
if ($cursor === null || $cursor === '') {
return null;
}
try {
return Cursor::fromEncoded($cursor);
} catch (\Throwable) {
return null;
}
}
/**
* 커서 페이지에서 다음 페이지 커서 문자열을 뽑아냅니다.
*
* @param CursorPaginator $page 커서 페이지 결과
* @return string|null 다음 페이지 커서 (마지막 페이지면 null)
*/
public static function nextCursor(CursorPaginator $page): ?string
{
return $page->nextCursor()?->encode();
}
/**
* 커서 페이지에서 이전 페이지 커서 문자열을 뽑아냅니다.
*
* @param CursorPaginator $page 커서 페이지 결과
* @return string|null 이전 페이지 커서 (첫 페이지면 null)
*/
public static function previousCursor(CursorPaginator $page): ?string
{
return $page->previousCursor()?->encode();
}
/**
* 정렬 방향을 asc/desc 로 정규화합니다.
*
* @param string $direction 입력 방향
* @return string 'asc' 또는 'desc'
*/
private static function normalizeDirection(string $direction): string
{
return strtolower($direction) === 'asc' ? 'asc' : 'desc';
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Support\Query;
use App\Extension\HookManager;
/**
* 목록 한계값의 단일 출처
*
* 총 건수 집계 상한과 페이지 번호 상한을 한 곳에서 해석한다. 값의 출처는
* 관리자 환경설정(`g7_settings.core.pagination.*`) 이고, 미설정 시 `config('core.pagination.*')`
* 로 폴백한다. 확장은 이 값을 리터럴로 다시 적지 않고 필터 훅으로만 조정한다.
*
* 필터 훅:
* - `core.pagination.filter_result_cap` — 총 건수 집계 상한 (int, 0 이하 = 무제한)
* - `core.pagination.filter_max_page` — 페이지 번호 상한 (int, 0 이하 = 무제한)
*/
class PaginationLimits
{
/**
* 총 건수 집계 상한 기본값 (환경설정·config 모두 부재 시)
*/
private const FALLBACK_RESULT_CAP = 10000;
/**
* 페이지 번호 상한 기본값 (환경설정·config 모두 부재 시)
*/
private const FALLBACK_MAX_PAGE = 1000;
/**
* 총 건수 집계 상한을 반환합니다.
*
* 이 값을 넘는 매칭은 정확히 세지 않고 "이상" 으로만 보고합니다.
*
* @param string|null $context 상한을 조정할 확장이 구분에 쓸 컨텍스트 키 (예: 'search', 'admin.users')
* @return int|null 상한 (무제한이면 null)
*/
public static function resultCap(?string $context = null): ?int
{
$value = self::setting('result_cap', self::FALLBACK_RESULT_CAP);
$value = (int) HookManager::applyFilters('core.pagination.filter_result_cap', $value, $context);
return $value > 0 ? $value : null;
}
/**
* 페이지 번호 상한을 반환합니다.
*
* 정상 탐색은 `has_more_pages` 와 커서로 열려 있고, 이 값은 임의의 큰 페이지 번호를
* 직접 던지는 남용을 막기 위한 것입니다.
*
* @param string|null $context 상한을 조정할 확장이 구분에 쓸 컨텍스트 키
* @return int|null 상한 (무제한이면 null)
*/
public static function maxPage(?string $context = null): ?int
{
$value = self::setting('max_page', self::FALLBACK_MAX_PAGE);
$value = (int) HookManager::applyFilters('core.pagination.filter_max_page', $value, $context);
return $value > 0 ? $value : null;
}
/**
* 관리자 환경설정 값을 읽고, 없으면 config 기본값으로 폴백합니다.
*
* @param string $key pagination 하위 키
* @param int $fallback 둘 다 부재할 때 쓸 값
* @return int 해석된 값
*/
private static function setting(string $key, int $fallback): int
{
$configured = config('g7_settings.core.pagination.'.$key);
if ($configured === null || $configured === '') {
$configured = config('core.pagination.'.$key, $fallback);
}
return (int) $configured;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
/**
* 라우트 캐시(bootstrap/cache/routes-v*.php) 재빌드 헬퍼.
*
* 라우트의 소스(코어 routes/*.php, 활성 모듈·플러그인의 routes/api.php)를 변경하는
* 모든 라이프사이클 지점이 이 헬퍼를 호출해 "변경 반영 + 캐시 재최적화"를 일관되게
* 수행한다. 산발적으로 route:clear 를 각 지점에 뿌리면 누락이 생기고(확장 설치·활성화는
* clear 조차 하지 않았다), 반대로 clear 만 하면 한 번 비워진 캐시가 재생성되지 않아
* 성능 이점이 영구히 사라진다 — 이를 단일 SSoT 로 막는다.
*
* 라우트 캐시는 훅 캐시와 성질이 다르다. 훅 캐시는 항목이 없으면 스캔 폴백으로 동작해
* 낡아도 안전하지만, **라우트 캐시에는 폴백이 없다** — 캐시에 없는 라우트는 그대로 404 다.
* 오류도 경고도 남지 않고 그 엔드포인트만 조용히 사라지므로, 확장을 설치한 쪽에서는
* "내 코드는 분명히 있는데 라우트가 없다" 는 형태로만 관측된다.
*
* 정책: 환경 무관 항상 재생성 (`ConfigCacheHelper` 와 동일). route:cache 는 그 자체로
* 부팅 비용을 절감하고, 라우트는 배포 단위로 고정되므로 항상 켜두는 것이 이득이다.
* local 개발 시 라우트 파일 수정이 즉시 반영되지 않는 점은 개발자가 `php artisan route:clear`
* 로 대응하는 개발자 책임 영역이며, 확장 라우트는 `{module|plugin}:update` 가 이 헬퍼를
* 경유하므로 그 경로에서는 자동 반영된다.
*/
class RouteCacheHelper
{
/**
* 라우트 캐시를 비우고 즉시 재생성합니다.
*
* `route:cache` 는 내부적으로 기존 캐시를 지운 뒤 새 애플리케이션 인스턴스를 부팅해
* 라우트를 수집하므로, 방금 설치·활성화한 확장의 라우트도 함께 잡힌다.
*
* 설치 미완료(installer 실행 전) 환경에서는 불완전한 부팅을 캐시에 박제할 수 있으므로
* clear 만 수행한다. 테스트 환경은 캐시 생성이 격리를 깨므로 동일하게 스킵한다.
*
* 재생성 실패(직렬화 불가한 클로저 라우트, 권한/디스크 등)는 치명적이지 않다 —
* clear 로 stale 캐시는 이미 제거되어 다음 요청이 fresh 라우트로 안전하게 부팅된다.
* 비어 있으면 느릴 뿐 정확하지만, 낡으면 방금 설치한 확장이 통째로 동작하지 않는다.
*
* @return void
*/
public static function rebuild(): void
{
// 항상 stale 캐시부터 제거 (변경 반영 보장).
Artisan::call('route:clear');
if (app()->environment('testing') || ! self::isInstalled()) {
return;
}
try {
Artisan::call('route:cache');
} catch (\Throwable $e) {
Log::warning('라우트 캐시 재생성 실패 (route:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [
'error' => $e->getMessage(),
]);
}
}
/**
* 라우트 캐시만 제거합니다 (재생성 없음).
*
* 재생성이 부적절한 특수 경로(예: 코어 업데이트 흐름 중간 — vendor 교체 중 상태를
* 캐시에 구울 수 없다)를 위한 보조 진입점.
*
* @return void
*/
public static function clear(): void
{
Artisan::call('route:clear');
}
/**
* G7 설치 완료 여부를 확인합니다.
*
* @return bool 설치 완료 시 true
*/
private static function isInstalled(): bool
{
if (config('app.installer_completed')) {
return true;
}
return file_exists(storage_path('app/g7_installed'));
}
}
+48
View File
@@ -75,6 +75,54 @@ return [
'identity_challenge_ttl_minutes_max' => 1440,
'identity_max_attempts_min' => 1,
'identity_max_attempts_max' => 20,
// 목록 한계값 (0 = 무제한)
'advanced_pagination_result_cap_min' => 0,
'advanced_pagination_result_cap_max' => 1000000,
'advanced_pagination_max_page_min' => 0,
'advanced_pagination_max_page_max' => 100000,
],
/*
|--------------------------------------------------------------------------
| 목록 한계값 기본값
|--------------------------------------------------------------------------
| 관리자 환경설정(`pagination` 카테고리)이 비어 있을 때 쓰는 코드 기본값입니다.
| 실제 해석은 App\Support\Query\PaginationLimits 가 단독으로 수행하며, 확장은
| 이 값을 리터럴로 다시 적지 않고 필터 훅으로만 조정합니다.
|
| - result_cap: 총 건수를 정확히 세는 상한. 이 값을 넘는 매칭은 "이상" 으로만 보고합니다.
| 페이지 이동은 상한과 무관하게 끝까지 열려 있고, 계산이 불가능해지는 것은
| 마지막 페이지 번호 하나뿐입니다.
| - max_page: 직접 요청할 수 있는 페이지 번호 상한 (남용 차단용).
|
| 둘 다 0 이면 무제한입니다.
*/
'pagination' => [
'result_cap' => 10000,
'max_page' => 1000,
],
/*
|--------------------------------------------------------------------------
| 검색 — DBMS 별 부분일치 연산자
|--------------------------------------------------------------------------
| 전문검색을 제공하지 않는 DBMS 로 설치된 사이트에서는 부분일치(LIKE)가 정상 검색
| 경로입니다. 그런데 "대소문자를 구분하지 않는 부분일치" 를 어떤 연산자로 쓰는지는
| DBMS 마다 다릅니다 — 대부분 기본 비교가 구분하지 않아 `like` 로 충분하지만,
| 그렇지 않은 DBMS 는 전용 연산자를 씁니다.
|
| 코어 코드에 드라이버명을 적지 않기 위해 이 표에 선언합니다. 새 DBMS 를 공식 지원할
| 때는 여기에 한 줄을 더하면 되고, 확장은 `core.search.like_operators` 필터 훅으로
| 조정합니다. 표에 없는 드라이버는 `like_operator_default` 를 씁니다.
|
| 키는 `DB::getDriverName()` 이 돌려주는 값입니다.
*/
'search' => [
'like_operators' => [
'pgsql' => 'ilike',
],
'like_operator_default' => 'like',
],
/*
+14 -1
View File
@@ -2,7 +2,7 @@
"_meta": {
"version": "1.0.0",
"description": "그누보드7 환경설정 기본값 및 프론트엔드 스키마",
"categories": ["general", "security", "mail", "upload", "seo", "cache", "debug", "drivers", "core_update", "geoip", "notifications", "identity"]
"categories": ["general", "security", "mail", "upload", "seo", "cache", "debug", "drivers", "core_update", "geoip", "notifications", "identity", "pagination"]
},
"defaults": {
"general": {
@@ -112,6 +112,10 @@
"auto_update_enabled": true,
"last_updated_at": ""
},
"pagination": {
"result_cap": 10000,
"max_page": 1000
},
"drivers": {
"storage_driver": "local",
"s3_bucket": "",
@@ -282,6 +286,15 @@
"last_updated_at": { "type": "string", "sensitive": false, "frontend_key": "geoip_last_updated_at" }
}
},
"pagination": {
"expose": true,
"merge_into": "advanced",
"_comment": "목록 한계값 — advanced 카테고리에 병합",
"fields": {
"result_cap": { "type": "integer", "sensitive": false, "frontend_key": "pagination_result_cap" },
"max_page": { "type": "integer", "sensitive": false, "frontend_key": "pagination_max_page" }
}
},
"drivers": {
"expose": false,
"_comment": "드라이버 설정 — 프론트엔드 미노출 (admin_settings에서 API 원본값 기반 비교로 변경)",
+3 -2
View File
@@ -9,7 +9,7 @@
| 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------|
| [백엔드](backend/) | 34개 | 정상 |
| [백엔드](backend/) | 35개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 |
| [확장 시스템](extension/) | 31개 | 정상 |
| 공통 | 20개 | 정상 |
@@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
## 카테고리별 전체 문서 목록
### 백엔드 (34개)
### 백엔드 (35개)
| 문서 | 제목 |
|------|------|
@@ -152,6 +152,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [language-pack-service.md](backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) |
| [middleware.md](backend/middleware.md) | 미들웨어 등록 규칙 |
| [notification-system.md](backend/notification-system.md) | 알림 시스템 (Notification System) |
| [pagination.md](backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) |
| [README.md](backend/README.md) | 백엔드 개발 가이드 |
| [response-helper.md](backend/response-helper.md) | API 응답 규칙 (ResponseHelper) |
| [routing.md](backend/routing.md) | 라우트 네이밍 및 경로 |
+1
View File
@@ -53,6 +53,7 @@
| [language-pack-service.md](language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactiva... |
| [middleware.md](middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스... |
| [pagination.md](pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relat... |
| [response-helper.md](response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [routing.md](routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL F... |
+1
View File
@@ -100,6 +100,7 @@ g7_core_settings('drivers.queue_driver'); // dev 공유 drivers.json 의 datab
2. sync 코드를 추가하지 않으면 → `g7_core_settings()` 만 사용 가능. `config()` 는 sync 되지 않은 키를 모르기 때문이다.
3. testing 격리가 필요한 키 (드라이버, 외부 서비스 자격증명 등) 는 `! $isTestingEnv` 가드로 sync 를 차단한다. 이 키들은 `config()` 가 testing 격리 SSoT 다.
4. 의미가 다른 키 (`app.timezone` 처럼) 는 sync 하지 않고 별도 키 (`app.default_user_timezone`) 로 분리한다.
5. 고급 탭 화면에 얹을 카테고리는 `config/settings/defaults.json` 의 `frontend_schema.{카테고리}.merge_into` 를 `advanced` 로 선언한다. 저장 시 어느 카테고리 파일에 쓸지는 이 선언에서 도출되므로 별도 등록이 필요 없다. 선언이 없으면 화면·검증·읽기가 모두 정상인데 입력값만 저장되지 않고 버려진다 — 저장 응답은 성공이고 화면에도 값이 보여 실패 신호가 없으므로, 새 카테고리를 추가했으면 저장 후 `storage/app/settings/{카테고리}.json` 이 생성되는지 직접 확인한다.
---
+27
View File
@@ -80,6 +80,33 @@ Authorization: Bearer {YOUR_TOKEN}
}
```
#### 총 건수 정확도 (대용량 목록)
매칭이 아주 많을 수 있는 목록(검색 등)은 총 건수를 상한까지만 셉니다. 그런 목록은 위 필드에
더해 정확도를 함께 내보내며, 세지 않은 값을 정확한 것처럼 말하지 않습니다.
| 필드 | 타입 | 의미 |
| --- | --- | --- |
| `total_relation` | string | `exact`(정확) 또는 `at_least`(그 이상) |
| `total_is_exact` | boolean | 총 건수가 정확한지 여부 |
| `result_cap` | integer\|null | 집계에 적용된 상한 (무제한이면 `null`) |
상한을 넘긴 경우 동작은 이렇습니다.
- `total` 은 상한값이며 **그 이상**이라는 뜻입니다 (화면은 "10,000건 이상" 으로 표기)
- `last_page` 는 **`null`** 입니다 — 총 건수를 알아야 계산되는 유일한 값이라 계산할 수 없습니다
- `has_more_pages` 는 그대로 정확합니다. 다음 페이지 이동은 끝까지 열려 있습니다
즉 상한에 걸려도 막히는 것은 마지막 페이지 점프 하나뿐입니다.
#### 단순형·커서형 응답
총 건수를 아예 세지 않는 목록(`simplePaginate`)은 `total` 과 `last_page` 를 **내보내지 않습니다**.
커서 방식 목록은 대신 `next_cursor` / `prev_cursor` 를 실어 보냅니다. 없는 필드를 0 이나 1 로
채우지 않으므로, 화면은 필드 존재 여부로 목록의 종류를 구분할 수 있습니다.
> 상한·커서 규약 상세: [pagination.md](../pagination.md)
일부 목록은 `data.abilities` 에 컬렉션 레벨 권한(`can_create`, `can_delete` 등)을 함께 반환합니다.
화면의 버튼 노출 여부를 이 값으로 판정하세요.
+22
View File
@@ -39,6 +39,7 @@
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
| sort_by | query | string | 아니오 | — | 정렬 기준 필드명 |
| sort_order | query | string | 아니오 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) |
| cursor | query | string | 아니오 | max 500 | 이어보기 커서. 주면 페이지 번호 대신 키셋 방식으로 응답합니다 (기록이 많이 쌓인 사이트에서 뒤쪽 페이지가 느려지지 않음). 형식이 깨진 값은 오류 없이 첫 페이지로 해석됩니다 |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.activity_log.index_validation_rules`).
@@ -169,6 +170,27 @@ HTTP/1.1 200
}
```
**커서 방식 응답 (cursor 파라미터를 준 경우)**
페이지 번호 대신 앞뒤 커서를 싣습니다. 총 건수를 세지 않으므로 `total` 과 `last_page` 는 없습니다.
```json
{
"success": true,
"data": {
"data": [],
"pagination": {
"per_page": 25,
"next_cursor": "eyJpZCI6MTIzLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9",
"prev_cursor": null,
"has_more_pages": true
}
}
}
```
총 건수가 상한을 넘겨 정확히 세지 못한 경우(페이지 번호 방식)에는 `pagination` 에 `total_relation`·`total_is_exact`·`result_cap` 이 함께 실리고 `last_page` 가 `null` 이 됩니다. 상세는 [pagination.md](../pagination.md).
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
+22 -3
View File
@@ -30,8 +30,9 @@
| q | query | string | 아니오 | min 2, max 200 | 검색어 (부분 일치) |
| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) |
| sort | query | string | 아니오 | `relevance`, `latest`, `oldest`, `views`, `popular`, `price_asc`, `price_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) |
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
| page | query | integer | 아니오 | min 1, max = 목록 한계값 설정 | 조회할 페이지 번호 (1부터 시작). 상한은 관리자 > 환경설정 > 고급의 «페이지 번호 상한» 이며 남용 차단용입니다 — 정상 탐색은 `has_more_pages` 로 계속 열려 있습니다 |
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
| cursor | query | string | 아니오 | max 500 | 커서(키셋) 페이지 이동용 커서. 응답의 `next_cursor` / `prev_cursor` 를 그대로 돌려보냅니다. 특정 탭을 볼 때만 유효하며(전체 탭은 무시), 실제 컬럼 기준 정렬(`latest`·`oldest`·`views`·`popular`·`price_asc`·`price_desc`)에서만 적용됩니다. 관련도순은 계산값 정렬이라 커서를 쓸 수 없어 `page` 기반 이동을 유지합니다. 형식이 깨진 값은 오류 없이 첫 페이지로 처리됩니다 |
| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.index_validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) |
| category_id | query | integer | 아니오 | — | category 식별자 |
@@ -50,7 +51,22 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| q | string | `` | 실제 검색에 사용된 검색어 (요청 `q` 를 trim 하여 에코, 검색어가 비어 있으면 빈 문자열) |
| total | integer | `0` | 전체 개수 (집계) |
| total | integer | `0` | 전체 개수 (집계). 상한을 넘기면 상한값이며 «그 이상» 을 뜻합니다 |
| total_relation | string | `exact` | 총 건수 정확도 (`exact` 정확 / `at_least` 그 이상) |
| total_is_exact | boolean | `true` | 총 건수가 정확한지 여부. `false` 면 화면이 "N건 이상" 으로 표기합니다 |
| result_cap | integer\|null | `10000` | 총 건수 집계에 적용된 상한 (무제한이면 `null`) |
| all_count | integer | `0` | 전체 탭 기준 합계 (탭 배지 표기용) |
| all_count_is_exact | boolean | `true` | 전체 합계가 정확한지 여부. 카테고리 중 하나라도 상한에 걸리면 `false` 입니다 |
| last_page | integer\|null | `1` | 특정 탭 조회 시의 마지막 페이지. **총 건수가 부정확하면 `null`** |
| has_more_pages | boolean | `false` | 다음 페이지 존재 여부 (총 건수를 몰라도 정확) |
| next_cursor | string\|null | `null` | 다음 페이지 커서. 커서 방식으로 응답했을 때만 채워지며, `page` 방식 응답에서는 `null` 입니다 |
| prev_cursor | string\|null | `null` | 이전 페이지 커서. 위와 같습니다 |
| counts_are_exact | object | `{}` | 카테고리별 총 건수 정확도 (`{"posts": true, "products": false}`). 탭 배지가 카테고리마다 그려지므로 정확도도 카테고리마다 제공됩니다 — 정확하지 않은 배지는 화면에서 "이상" 으로 표기됩니다 |
카테고리(탭) 중 하나라도 상한에 걸리면 합계도 정확하지 않습니다 — 정확한 카테고리 몇 개를
더해 봐야 전체가 정확해지지 않기 때문입니다. 그 경우 `total_is_exact` 는 `false` 가 됩니다.
> 상한·페이지 이동 규약 상세: [pagination.md](../pagination.md)
**응답 예시**
@@ -64,7 +80,10 @@ HTTP/1.1 200
"message": "검색어를 입력해주세요.",
"data": {
"q": "",
"total": 0
"total": 0,
"total_relation": "exact",
"total_is_exact": true,
"result_cap": 10000
}
}
```
+16
View File
@@ -4,6 +4,22 @@
---
## 목록 한계값 (`advanced` 탭)
대용량 목록에서 총 건수를 세는 범위와 직접 요청할 수 있는 페이지 번호의 상한입니다.
저장 경로는 다른 고급 설정과 같은 `advanced` 탭이며, 저장소에는 `pagination` 카테고리로 남습니다.
| 필드 | 타입 | 범위 | 의미 |
| --- | --- | --- | --- |
| `advanced.pagination_result_cap` | integer | 0 ~ 1,000,000 | 총 건수를 정확히 세는 상한. 0 이면 항상 전부 셉니다 |
| `advanced.pagination_max_page` | integer | 0 ~ 100,000 | 주소로 직접 요청할 수 있는 최대 페이지 번호. 0 이면 제한하지 않습니다 |
경계값은 설정 응답의 `_meta.limits` 로 함께 내려오며(`advanced_pagination_result_cap_min` 등),
화면 입력 칸의 min/max 와 저장 검증이 같은 값을 공유합니다.
상한을 넘긴 목록의 응답 형태는 [pagination.md](../pagination.md) 를 참고하세요.
## TL;DR (5초 요약)
```text
+18 -4
View File
@@ -29,16 +29,30 @@
## 파일 구조 개요
`config/core.php`는 3개 최상위 키로 구성됩니다:
`config/core.php`는 최상위 키로 구성되며, 이 문서는 그중 권한·역할·메뉴를 다룹니다.
나머지 키는 각자의 규정 문서가 소유합니다.
```php
return [
'permissions' => [...], // 코어 권한 정의
'roles' => [...], // 코어 역할 정의
'menus' => [...], // 코어 메뉴 정의
// 이 문서가 다루는 범위
'permissions' => [...], // 코어 권한 정의
'roles' => [...], // 코어 역할 정의
'menus' => [...], // 코어 메뉴 정의
// 다른 문서가 소유하는 키
'settings_limits' => [...], // 관리자 환경설정 입력 한계값
'pagination' => [...], // 목록 한계값 기본값 → pagination.md
'search' => [...], // DBMS 별 부분일치 연산자 → search-system.md
'notification_definitions' => [...], // 알림 정의 → notification-system.md
'identity_policies' => [...], // 본인인증 정책 → identity-policies.md
'identity_messages' => [...], // 본인인증 메시지 → identity-messages.md
'identity_purposes' => [...], // 본인인증 목적 → identity-policies.md
];
```
키를 새로 추가할 때는 그 키를 설명하는 규정 문서를 함께 지정하고 이 목록에 한 줄을 더합니다.
목록이 실제 구조와 어긋나면 "여기 없으니 없는 값" 으로 읽혀 중복 키가 생깁니다.
> **변경 이력 (7.0.0-beta.2)**: `mail_templates` 키는 알림 시스템 통합(#146)으로 제거되었습니다. 코어 메일 템플릿은 `notification_definitions` + `notification_templates` 로 이전되었으며, `database/seeders/NotificationDefinitionSeeder` 가 시드 데이터를 직접 보유합니다.
---
+344
View File
@@ -0,0 +1,344 @@
# 대용량 목록 페이지네이션 (Pagination)
> **관련 문서**: [Service-Repository 패턴](./service-repository.md) | [API 리소스](./api-resources.md) | [검색 시스템](./search-system.md)
---
## TL;DR (5초 요약)
```text
1. 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least)
2. "다음" 이동은 상한과 무관하게 끝까지 열려 있다 (per_page + 1 실측)
3. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐 → last_page = null
4. 최신순 등 실제 컬럼 정렬은 커서(키셋)로 전환 가능, 관련도순은 offset 유지
5. 한계값은 관리자 환경설정 > 고급이 SSoT — 확장은 필터 훅으로만 조정
```
---
## 목차
- [왜 상한을 두는가](#왜-상한을-두는가)
- [상한과 페이지 이동의 관계](#상한과-페이지-이동의-관계)
- [구성 요소](#구성-요소)
- [저장소에서 쓰는 법](#저장소에서-쓰는-법)
- [상한 없는 전량 조회](#상한-없는-전량-조회)
- [건수만 필요할 때](#건수만-필요할-때)
- [응답 계약](#응답-계약)
- [커서(키셋) 페이지네이션](#커서키셋-페이지네이션)
- [한계값 설정](#한계값-설정)
- [화면 쪽 규약](#화면-쪽-규약)
- [체크리스트](#체크리스트)
---
## 왜 상한을 두는가
목록 화면은 보통 한 페이지 분량만 보여 주면서 총 건수를 함께 표시한다. 그 총 건수를 구하는
`COUNT(*)` 는 조건에 맞는 행을 **전부** 세야 하므로, 매칭이 많을수록 한 페이지를 그리는 데
드는 비용이 커진다. 검색처럼 매칭이 수십만 건에 이를 수 있는 목록에서는 페이지 조회보다
건수 집계가 더 비싸지는 역전이 일어난다.
한편 사용자가 총 건수에서 실제로 얻는 정보는 "많다/적다" 와 "마지막 페이지가 어디쯤인가"
정도다. 12,842건과 "10,000건 이상" 은 실질적으로 같은 정보를 준다. 그래서 **세는 범위에만
상한을 두고**, 상한을 넘으면 정확한 수 대신 하한을 보고한다.
### 상한이 줄여 주지 못하는 것 — FULLTEXT 술어
상한은 **세는 행 수**를 줄인다. 그러나 조건을 만족하는 행을 찾는 일 자체는 줄이지 못한다.
FULLTEXT(`MATCH ... AGAINST`) 검색에서 이 차이가 크게 드러난다.
30만 행 전부가 한 토큰에 매칭되는 조건으로 실측한 결과다.
| 쿼리 | 소요 |
|------|------|
| 상한 COUNT (`LIMIT 10001` 로 감싼 파생 테이블) | 588.96초 |
| 상한 없는 COUNT | 560.48초 |
| 페이지 조회 (`LIMIT 21`) | 594.76초 |
세 경우가 사실상 같다. InnoDB 전문 검색은 매칭 집합을 먼저 구성하고 그 뒤에 `LIMIT` 을
적용하므로, 몇 건을 돌려주든 매칭 집합을 만드는 비용은 동일하게 든다. 같은 조건에서
실행계획을 보면 상한 COUNT 는 파생 테이블로 감싸는 과정에서 FULLTEXT 인덱스 사용
계획을 잃고 전체 스캔이 되기도 한다.
따라서 검색 목록에서 상한이 주는 이득은 **PHP 메모리·전송량·중복 실행 제거**이며,
데이터베이스가 매칭을 찾는 시간은 상한으로 해결되지 않는다. 이 축이 문제라면 전용 검색
엔진(`core.search.engine_drivers` 훅으로 교체)을 쓰는 것이 남은 경로다.
토큰 결합을 OR 에서 AND 로 바꿔 매칭 집합 자체를 줄이는 안은 **채택하지 않는다**. 입력한
단어 중 하나만 든 문서가 결과에서 통째로 사라지는 손실이 성능 이득보다 크다고 판단했다
(한글은 ngram 파서가 2글자 단위로 토큰을 쪼개 축소 폭이 특히 크다). 상세는
[search-system.md "검색어 토큰은 OR 로 결합한다"](search-system.md) 참조.
측정 한계도 함께 적는다. 위 수치는 한 토큰이 전 행에 걸리도록 만든 인위적 조건이고,
단일 개발 장비 기준이다. 매칭이 적은 일반적인 검색어에는 해당하지 않는다.
`innodb_ft_result_cache_limit` 에 대한 메모리 증가분은 기준선을 확보하지 못해 확정하지
못했다 — 확정된 것은 위 시간 축뿐이다.
---
## 상한과 페이지 이동의 관계
총 건수 상한과 페이지 이동 범위는 **별개 결정**이다. 묶으면 필요 없이 기능이 깎인다.
| 항목 | 상한을 넘었을 때 |
|---|---|
| 총 건수 | "N건 이상" 으로 표기 (`total_relation = at_least`) |
| 다음 페이지 이동 | **그대로 가능** — `per_page + 1` 조회로 정확히 판정 |
| 이전 페이지 이동 | 그대로 가능 |
| 마지막 페이지 점프 | **감춤** — 총 건수를 알아야 계산되는 유일한 값 |
마지막 페이지 점프를 감추는 것은 기능 축소가 아니라 **계산 불가 사실의 정직한 표시**다.
그 버튼은 대량 매칭에서 초대형 OFFSET 을 실행하므로, 눌러도 정상 응답이 오지 않는 경우가 많다.
---
## 구성 요소
| 구성 요소 | 위치 | 역할 |
|---|---|---|
| `TotalRelation` | `app/Enums/TotalRelation.php` | 총 건수 정확도 (`Exact` / `AtLeast`) |
| `BoundedPaginator` | `app/Support/Query/BoundedPaginator.php` | `per_page + 1` 조회 + 상한 파생 테이블 COUNT |
| `BoundedPage` | `app/Support/Query/BoundedPage.php` | 페이지 결과. 표준 페이지네이터 인터페이스를 그대로 만족 |
| `KeysetPaginator` | `app/Support/Query/KeysetPaginator.php` | 커서 인코딩/디코딩 표준 |
| `PaginationLimits` | `app/Support/Query/PaginationLimits.php` | 한계값 해석 (설정 → 필터 훅) |
| `BoundedTotalAware` | `app/Contracts/Pagination/BoundedTotalAware.php` | 정확도를 밝히는 페이지 결과 계약 |
`BoundedPage` 는 `LengthAwarePaginator` 를 상속한다. 기존 컬렉션·리소스·컨트롤러가 전부
페이지네이터 인터페이스에 맞춰져 있으므로, 별도 타입을 새로 정의하면 소비 지점마다 언랩
코드가 생긴다. 상속하면 기존 코드가 그대로 받고 정확도 메타까지 자동으로 얻는다.
---
## 저장소에서 쓰는 법
```php
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
public function searchByKeyword(string $keyword, int $perPage = 10, int $page = 1): BoundedPage
{
$query = $this->model->newQuery()
->where('published', true)
->orderBy('created_at', 'desc')
// 전순서 보장 — 정렬 컬럼이 비고유면 페이지 경계에서 행이 겹치거나 샌다
->orderBy('id', 'desc');
return BoundedPaginator::paginate(
$query,
perPage: $perPage,
page: $page,
resultCap: PaginationLimits::resultCap('search'),
// 목록이 실제로 쓰는 컬럼만 명시 (프루닝)
columns: ['id', 'slug', 'title', 'created_at'],
);
}
```
건수만 필요한 경우(탭 배지 등)는 조회 없이 집계만 한다.
```php
[$total, $relation] = BoundedPaginator::countWithCap($query, PaginationLimits::resultCap('search'));
```
`GROUP BY` 가 걸린 쿼리도 파생 테이블 안에서 그룹이 만들어지므로 **그룹 수**가 그대로 센다.
표준 `count()` 가 그룹별 건수를 돌려주는 문제가 없다.
### 받는 입력은 표준 `paginate()` 와 같다
Eloquent 빌더뿐 아니라 쿼리 빌더(`DB::table(...)`)와 **관계**(`$user->notifications()`)도
그대로 넘길 수 있다.
```php
// 관계를 그대로 넘겨도 된다 — 소속 조건은 그 밑 빌더에 이미 들어가 있어 보존된다
BoundedPaginator::paginate($user->notifications(), perPage: 15, resultCap: $cap);
```
이 폭은 **좁히면 안 된다.** 이 계약은 기존 `->paginate()` 호출을 대체하려고 만든 것이라,
받는 형태가 표준보다 좁으면 바꾸는 것만으로 멀쩡하던 목록이 죽는다. 관계에서 `->paginate()`
가 되는 이유는 관계가 빌더로 호출을 전달해 주기 때문인데, 정적 메서드는 그 전달을 받지
못하므로 계약이 직접 이해해야 한다.
`PaginatesWithDeferredJoin` 은 빌더와 관계를 받는다. 이쪽은 모델의 키 컬럼과 eager load 를
다루므로 Eloquent 가 전제이며, 쿼리 빌더는 받지 않는다.
### 하지 말 것
| ❌ 금지 | ✅ 올바른 사용 |
|---|---|
| 같은 술어로 `count()` 한 번, `get()` 한 번 | `BoundedPaginator::paginate()` 한 번 |
| `paginate(PHP_INT_MAX)` 후 PHP `array_slice` | 실제 `page`/`per_page` 를 저장소까지 하달 |
| `forPage($page, $perPage + 1)` | offset 은 `per_page` 기준으로 따로 계산 |
| 총 건수를 모르는데 `last_page` 를 1 로 채움 | `null` 로 내보내 화면이 감추게 한다 |
| 정렬 마지막이 비고유 컬럼 | 기본키를 덧붙여 전순서 보장 |
| 컬렉션에서 `'pagination' => [...]` 를 손으로 조립 | `...$this->paginationMeta()` — 형태를 스스로 판정한다 |
---
## 상한 없는 전량 조회
`->get()` / `->pluck()` 자체가 문제인 것은 아니다. 문제는 **결과 크기가 데이터 증가에
비례해 커지는데 아무 상한도 없는** 경우다. 그런 조회는 개발 데이터에서 늘 빠르고, 운영
데이터가 쌓인 뒤에야 메모리와 응답 시간을 함께 무너뜨린다. 예외도 경고도 없이 느려지기만
하므로 테스트로는 드러나지 않는다.
한 문장 안에 결과 크기를 묶는 근거가 하나는 있어야 한다.
| 상황 | 쓸 것 |
|---|---|
| 화면에 목록으로 보여 준다 | `BoundedPaginator::paginate()` / `paginateWithDeferredJoin()` |
| 전량을 순회해 처리한다 | `chunkById()` / `lazyById()` (키셋 — OFFSET 밀림이 없다) |
| 몇 건만 필요하다 | `limit()` / `take()` |
| 이미 좁혀진 키 집합이다 | `whereIn($key, $ids)` / `find()` |
행 수가 사용량이 아니라 **운영자의 등록 수**에 묶이는 설정성 테이블(역할·언어팩·정책 등)은
예외다. 그 경우 근거를 코드에 남긴다.
```php
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::where('is_active', true)->orderBy('id')->get();
```
---
## 건수만 필요할 때
목록을 조회하지 않고 건수만 필요한 자리(탭 배지, 요약 수치)는 `BoundedPaginator::count()`
를 쓴다. `int` 하나만 돌려주면 **상한에 걸려 잘린 값과 정확히 센 값이 구분되지 않는다** —
잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나가는 것은 오류로 드러나지 않고 그냥 틀린
숫자로만 보인다.
```php
$count = BoundedPaginator::count($query, PaginationLimits::resultCap('search'));
return $count->toArray();
// ['total' => 10000, 'total_relation' => 'at_least', 'total_is_exact' => false, 'result_cap' => 10000]
```
여러 카테고리의 건수를 합칠 때는 **하나라도 부정확하면 합계도 부정확**하다. 정확한
카테고리 몇 개를 더해 봐야 전체가 정확해지지 않는다. 반대로 특정 탭 하나만 보는
중이라면 그 카테고리의 정확도만 본다 — 다른 카테고리가 잘렸다는 이유로 정확한 값을
"이상" 이라고 말하지 않는다.
---
## 응답 계약
`BaseApiCollection::paginationMeta()` 가 페이지 결과의 형태를 스스로 판정해 그 형태가 실제로
아는 값만 내보낸다. 모르는 값을 0 이나 1 로 채우면 화면이 그것을 사실로 읽는다.
| 형태 | 예 | `total` | `last_page` | 추가 필드 |
|---|---|---|---|---|
| 전체건수형 | `paginate()` | 정확 | 정확 | — |
| 상한형 | `BoundedPage` | 정확 또는 하한 | 하한이면 `null` | `total_relation` `total_is_exact` `result_cap` |
| 단순형 | `simplePaginate()` | 없음 | 없음 | — |
| 커서형 | `cursorPaginate()` | 없음 | 없음 | `next_cursor` `prev_cursor` |
표준 `paginate()` 를 쓰는 기존 컬렉션의 응답은 필드 단위로 이전과 완전히 같다.
컬렉션이 이 블록을 손으로 조립하면 두 가지가 함께 깨진다. 커서 결과에는 `total()` 과
`lastPage()` 가 없어 **그 요청만 500** 이 되고, 상한형에서는 정확도 필드가 빠져 잘린 건수가
정확한 값처럼 화면에 나간다. 둘 다 표준 `paginate()` 만 쓰던 시절에는 드러나지 않던 형태라,
목록 하나를 상한형이나 커서형으로 바꾸는 순간 처음 나타난다.
```json
{
"pagination": {
"current_page": 3,
"per_page": 20,
"from": 41,
"to": 60,
"has_more_pages": true,
"last_page": null,
"total": 10000,
"total_relation": "at_least",
"total_is_exact": false,
"result_cap": 10000
}
}
```
---
## 커서(키셋) 페이지네이션
OFFSET 방식은 건너뛸 행을 실제로 읽어야 해서 페이지가 깊어질수록 느려진다. 커서 방식은
직전 페이지 마지막 행의 정렬 키를 WHERE 절 경계로 삼으므로 깊이와 무관하게 일정하다.
```php
use App\Support\Query\KeysetPaginator;
$page = KeysetPaginator::paginate(
$query,
perPage: 20,
sortKeys: [['created_at', 'desc']],
uniqueKey: 'id',
cursor: $request->query(KeysetPaginator::CURSOR_PARAM),
);
```
### 관련도순에는 적용할 수 없다
커서는 정렬 키를 WHERE 절 경계로 쓴다. 따라서 정렬 키가 **실제 컬럼**이어야 한다.
FULLTEXT 관련도 점수(`_ft_score`)는 행마다 계산되는 값이고 컬럼이 아니므로 경계로 쓸 수 없다.
`sort=relevance` 는 이 제약 때문에 OFFSET 을 유지한다. 판정은
`KeysetPaginator::supports($sortKeys, $columnWhitelist)` 가 담당하며, 허용 컬럼 목록 밖의
정렬 키가 하나라도 있으면 `false` 를 돌려준다.
깨진 커서 문자열은 예외 대신 첫 페이지로 처리한다. 사용자가 주소를 손으로 고쳤다는 이유로
목록 화면이 오류를 띄우게 두지 않는다.
---
## 한계값 설정
| 설정 | 기본값 | 의미 |
|---|---|---|
| `pagination.result_cap` | 10000 | 총 건수를 정확히 세는 상한 (0 = 무제한) |
| `pagination.max_page` | 1000 | 직접 요청할 수 있는 페이지 번호 상한 (0 = 무제한) |
해석 순서는 **관리자 환경설정 > 고급 → `config/core.php` 기본값 → 확장 필터 훅** 이다.
해석은 `PaginationLimits` 한 곳에서만 하며, 확장은 값을 리터럴로 다시 적지 않고 훅으로 조정한다.
```php
HookManager::addFilter('core.pagination.filter_result_cap', function (int $cap, ?string $context) {
return $context === 'search' ? 50000 : $cap;
});
```
`max_page` 는 남용 차단용이다. 정상 탐색은 `has_more_pages` 와 커서로 계속 열려 있고,
이 값은 임의의 큰 페이지 번호를 직접 던져 초대형 OFFSET 을 만드는 것만 막는다.
---
## 화면 쪽 규약
`Pagination` 컴포넌트는 세 가지 입력으로 동작한다.
| Prop | 의미 |
|---|---|
| `totalPages` | 마지막 페이지. **`null` 이면 페이지 번호 목록과 마지막 페이지 점프가 사라진다** |
| `hasMorePages` | 다음 페이지 존재 여부. 총 건수를 몰라도 정확하다 |
| `showFirst` / `showLast` | 첫/마지막 버튼을 따로 제어 (미지정 시 `showFirstLast` 를 따름) |
총 건수 표기는 정확도에 따라 문구가 갈린다.
```text
정확: 총 1,234건
하한: 총 10,000건 이상 (+ 검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있다는 안내)
```
세지 않은 값을 정확한 것처럼 말하지 않는다.
---
## 체크리스트
- [ ] 목록 조회가 같은 술어를 `count()` + `get()` 으로 두 번 실행하지 않는가
- [ ] `paginate()` 에 조회 컬럼을 명시했는가 (`['*']` 방치 금지)
- [ ] 정렬 마지막에 기본키를 덧붙여 전순서를 보장했는가
- [ ] 총 건수를 모를 때 `last_page` 를 `null` 로 내보내는가
- [ ] 화면이 `total_is_exact` 를 보고 문구를 바꾸는가
- [ ] 상한값을 리터럴로 적지 않고 `PaginationLimits` 를 거치는가
- [ ] 커서를 쓴다면 정렬 키가 전부 실제 컬럼인가
+42
View File
@@ -322,6 +322,48 @@ Route::prefix('products')->group(function () {
---
## 라우트 캐시
`php artisan route:cache` 를 적용한 사이트는 캐시 파일에 직렬화된 라우트만 서빙한다.
따라서 **라우트 정의를 바꾸는 모든 지점은 캐시를 함께 갱신해야 한다.**
라우트 캐시는 훅 캐시와 성질이 다르다. 훅 캐시는 항목이 없으면 스캔으로 폴백해 낡아도
안전하지만, **라우트 캐시에는 폴백이 없다** — 캐시에 없는 라우트는 그대로 404 다.
예외도 경고도 남지 않고 그 엔드포인트만 조용히 사라지므로, 확장을 만든 쪽에서는
"내 코드에는 분명히 있는데 라우트가 없다" 는 형태로만 관측된다.
### 갱신은 헬퍼 하나로만 한다
각 지점에 `route:clear` 를 흩어 놓으면 누락이 생기고, 반대로 비우기만 하면 한 번 비워진
캐시가 재생성되지 않아 성능 이점이 영구히 사라진다. `App\Support\RouteCacheHelper` 가
단일 해석 지점이다 (`ConfigCacheHelper` 와 동일한 정책).
| 메서드 | 동작 |
|--------|------|
| `RouteCacheHelper::rebuild()` | 비운 뒤 즉시 재생성. 테스트 환경·설치 미완료는 비우기까지만 |
| `RouteCacheHelper::clear()` | 재생성 없이 비우기만 — 재생성이 부적절한 흐름 중간용 |
`rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 새 애플리케이션을 부팅해 라우트를
수집하므로 방금 설치·활성화한 확장의 라우트도 함께 잡힌다. 재생성이 실패하면(직렬화
불가한 클로저 라우트 등) 비운 상태로 둔다 — 비어 있으면 느릴 뿐 정확하지만, 낡은 캐시는
방금 설치한 확장을 통째로 없는 것으로 만든다.
### 갱신이 필요한 지점
| 지점 | 이유 |
|------|------|
| 확장 설치 / 활성화 / 비활성화 / 삭제 / 업데이트 | 확장 라우트 등록이 활성 목록에 게이트되어 있다 |
| 코어 업데이트 · 업그레이드 스텝 | 코어 라우트 파일과 vendor 가 교체된다 |
코어 업데이트처럼 파일 교체가 진행 중인 흐름에서는 **중간에 비우고 끝에서 재생성**한다.
교체 중 상태를 캐시에 구우면 안 되기 때문이며, config 캐시가 같은 형태로 처리된다.
템플릿 설치·활성화는 갱신 대상이 아니다 — 템플릿의 `routes.json` 은 프론트엔드 라우팅이라
서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는
`api/modules/{identifier}` 로 식별자에 고정).
---
## 개발 체크리스트
### 라우트 정의 시 확인사항
+161 -4
View File
@@ -24,10 +24,11 @@
2. [FulltextSearchable 인터페이스](#fulltextsearchable-인터페이스)
3. [검색 엔진 드라이버](#검색-엔진-드라이버)
4. [확장 포인트](#확장-포인트)
5. [마이그레이션](#마이그레이션)
6. [AsUnicodeJson 캐스트](#asunicodejson-캐스트)
7. [환경설정](#환경설정)
8. [관련 문서](#관련-문서)
5. [검색 목록의 페이지 이동](#검색-목록의-페이지-이동)
6. [마이그레이션](#마이그레이션)
7. [AsUnicodeJson 캐스트](#asunicodejson-캐스트)
8. [환경설정](#환경설정)
9. [관련 문서](#관련-문서)
---
@@ -188,6 +189,93 @@ DBMS에 따라 자동 분기:
- MySQL/MariaDB: `WHERE MATCH(\`content\`) AGAINST(? IN BOOLEAN MODE)`
- 그 외: `WHERE content LIKE '%keyword%'`
### 키워드 술어는 활성 엔진이 만든다
Scout 의 `Model::search()` 는 "엔진이 결과를 돌려준다" 모델입니다. 그래서 결과를 받은 뒤 그 ID
로 DB 를 다시 조회해야 하고, 매칭이 많으면 ID 전량을 메모리에 올려 무제한 `IN (...)` 을 만들게
됩니다. 페이지네이션·조인·필터를 DB 에 남긴 채 키워드 조건만 얹으려면 **엔진에게 술어를 달라고
요청할 통로**가 따로 필요합니다.
그 통로가 `App\Search\Contracts\KeywordPredicateProvider` 이고, 해석은
`App\Search\KeywordSearch` 가 단독으로 수행합니다.
```php
use App\Search\KeywordSearch;
// 컬럼들을 하나의 조건으로 함께 평가 (복합 인덱스가 있는 테이블)
KeywordSearch::apply($query, ['title', 'content'], $keyword);
// 컬럼마다 따로 평가해 OR 로 묶기 (컬럼별 단일 인덱스만 있는 테이블)
KeywordSearch::applyAny($query, ['name', 'description'], $keyword);
```
**저장소는 구체 엔진 클래스를 지목하지 않습니다.** 지목하는 순간 플러그인이 등록한 검색 엔진은
호출될 기회 자체를 잃고, 오류도 경고도 없이 그 사이트의 검색만 조용히 다른 방식으로 동작합니다.
활성 엔진이 이 계약을 구현하지 않으면 부분일치로 내려가며, 그 사실이 기록에 남습니다 — 폴백은
정상 동작처럼 보이므로 기록하지 않으면 "검색이 느리고 관련도가 이상하다" 는 증상만 남습니다.
#### 페이지네이션은 DB 가, 상한은 엔진이
엔진에게 페이지 번호를 넘기지 않습니다. 엔진이 자기 순서로 한 페이지 분량만 돌려주면, 그 뒤에
적용되는 DB 필터(분류·전시상태·조인)에 일부가 탈락해 페이지가 비고, DB 정렬과 엔진의 관련도
순서가 달라 페이지 경계도 어긋나기 때문입니다.
엔진이 책임지는 것은 **"얼마까지 돌려줄 것인가"** 하나이며, 그 값은 `KeywordSearchContext` 로
전달됩니다. 외부 검색 서버를 쓰는 엔진은 자기 서버에서 키 집합을 받아 조건으로 붙이는데,
상한을 지키지 않으면 매칭이 큰 검색어에서 그 집합 자체가 메모리 폭발이 됩니다.
```php
KeywordSearch::applyAny($query, ['name', 'description'], $keyword, 'and', 'search');
// ↑ 상한 해석 컨텍스트
```
상한은 목록 총 건수 상한(`PaginationLimits::resultCap()`)과 **같은 값**을 씁니다. 두 기준이
갈라지면 "엔진이 돌려준 건수" 와 "화면이 보고하는 총 건수" 가 서로 다른 근거를 갖게 됩니다.
상한에 걸려 잘린 경우 그 이상은 도달 불가이고 총 건수는 "이상" 으로 보고됩니다.
이 의무는 정적 검사로 강제할 수 없습니다 — 외부 엔진 코드는 이 저장소 밖입니다. 코어가 할 수
있는 것은 **값을 손에 쥐어 주는 것**까지이며, 그 값이 실제로 도달하는지는 계약 테스트가
고정합니다.
#### 전문검색을 제공하지 않는 DBMS
부분일치 경로는 임시방편이 아닙니다. 전문검색을 제공하지 않는 DBMS 로 설치된 사이트에서는
**이것이 정상 검색 경로**이므로 이식성을 갖춥니다.
- 검색어의 `%` `_` `\` 를 escape 해 이용자가 입력한 글자 그대로 찾습니다.
- "대소문자를 구분하지 않는 부분일치" 를 어떤 연산자로 쓰는지는 DBMS 마다 다릅니다. 그 표는
`config('core.search.like_operators')` 에 **선언형으로** 있고, 표에 없는 드라이버는
`like_operator_default` 를 씁니다. 코어 코드에는 드라이버명을 적지 않습니다 — 적으면
DBMS 가 공식 지원 목록에 추가될 때마다 코어를 고쳐야 합니다.
- 확장은 `core.search.like_operators` 필터 훅으로 새 DBMS 의 연산자를 선언합니다.
```php
HookManager::addFilter('core.search.like_operators', function (array $operators) {
$operators['somedb'] = 'imatch';
return $operators;
});
```
해당 DBMS 의 진짜 전문검색(예: PostgreSQL `tsvector`)은 그 엔진을 `core.search.engine_drivers`
로 등록하고 `KeywordPredicateProvider` 를 구현하면 코어 수정 없이 이 경로를 그대로 탑니다.
### 검색어 토큰은 OR 로 결합한다 (확정)
`sanitizeBooleanModeKeyword()` 는 BOOLEAN MODE 연산자를 제거한 뒤 남은 토큰을 각각
따옴표로 묶어 **공백으로 잇는다**. BOOLEAN MODE 에서 공백 결합은 OR 이므로, "빨간 운동화"
는 "빨간" 이 든 행과 "운동화" 가 든 행을 **모두** 매치한다.
각 토큰에 `+` 를 붙여 AND 로 바꾸면 매칭 집합 자체가 줄어 대용량에서 검색 시간이 줄어들
여지가 있다. 그럼에도 **OR 을 유지한다** — 이용자가 입력한 단어 중 하나만 든 문서가
결과에서 통째로 사라지는 것은 성능과 맞바꿀 수 없는 손실이기 때문이다. 한글은 ngram 파서가
2글자 단위로 토큰을 쪼개므로 AND 전환의 결과 축소 폭이 특히 크다.
성능 축이 문제가 되면 토큰 결합 방식이 아니라 전용 검색 엔진(`core.search.engine_drivers`
훅으로 교체)으로 해결한다. 상한 COUNT 가 매칭 시간을 줄이지 못하는 이유와 실측치는
[pagination.md](pagination.md) 참조.
---
## 확장 포인트
@@ -481,8 +569,77 @@ SCOUT_QUEUE=false
---
## 검색 목록의 페이지 이동
검색 결과는 컬렉션 리소스를 거치지 않고 리스너가 배열을 만들어 넘긴다. 그 조립과 판정을
도메인마다 손으로 하면 검색 모듈 수만큼 같은 코드가 복제되고, 나중에 필드가 하나 늘 때
어떤 화면은 받고 어떤 화면은 못 받는다. **코어가 계약을 소유하고 확장은 선언만 한다.**
| 코어가 소유하는 것 | 위치 |
|---|---|
| 커서 적용 가능 여부 판정 | `App\Search\SearchPagePolicy` |
| 카테고리 응답 페이로드 조립 | `App\Search\SearchCategoryPayload` |
| 커서 인코딩·디코딩 | `App\Support\Query\KeysetPaginator` |
확장이 선언하는 것은 **정렬 이름이 어떤 실제 컬럼인가** 둘뿐이다.
```php
/** 정렬 이름 → [실제 컬럼, 방향]. 여기에 없는 이름은 커서를 쓰지 않는다. */
public const SEARCH_SORT_MAP = [
'latest' => ['created_at', 'desc'],
'oldest' => ['created_at', 'asc'],
];
/** 커서 경계로 쓸 수 있는 실제 컬럼 */
public const SEARCH_CURSOR_COLUMNS = ['created_at'];
```
서비스는 규칙을 다시 쓰지 않고 코어에 판정을 위임한다. 적용할 수 없는 정렬이면 `null` 을
돌려주고, 호출자는 기존 offset 경로를 그대로 쓴다.
```php
$sortKeys = SearchPagePolicy::sortKeys($sort, self::SEARCH_SORT_MAP);
if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS)) {
return null;
}
return $this->repository->searchByKeywordWithCursor($keyword, $sortKeys, $perPage, $cursor);
```
### 관련도순에는 커서를 쓸 수 없다
커서는 정렬 키를 WHERE 절 경계로 삼으므로 정렬 키가 **실제 컬럼**이어야 한다. 관련도순은
전문 검색 점수라는 계산값으로 정렬하므로 경계로 쓸 수 없고, 그 정렬에서는 offset 을
유지한다. 이 판정은 선언에 그 이름이 없는 것으로 자연히 이루어진다.
### 응답 키 집합은 세 형태가 모두 같다
offset·커서·건수전용 세 응답은 채워지는 값만 다르고 키 구성은 동일하다. 화면이 분기 없이
같은 키를 읽을 수 있어야 하기 때문이다.
| 키 | offset | 커서 | 건수전용 |
|---|---|---|---|
| `total` / `total_relation` / `total_is_exact` / `result_cap` | 채움 | 채움(별도 집계) | 채움 |
| `last_page` | 상한 초과 시 `null` | 언제나 `null` | `null` |
| `has_more_pages` | 실측 | 실측 | `false` |
| `next_cursor` / `prev_cursor` | `null` | 채움 | `null` |
| `items` | 채움 | 채움 | 빈 배열 |
커서 응답에 `last_page` 가 없는 것은 결함이 아니라 **커서 방식에 마지막 페이지 번호라는
개념이 없기 때문**이다. 화면은 그때 마지막 페이지 점프만 감추고 "다음" 이동은 유지한다.
### 탭 배지에도 정확도가 따라간다
배지는 숫자 하나만 그리므로, 상한에 걸려 잘린 값이 정확한 것처럼 나가도 오류로 드러나지
않고 그냥 틀린 숫자로만 보인다. 코어가 카테고리별 정확도를 `counts_are_exact` 로 일괄
제공하며, 화면은 정확하지 않은 배지에 "이상" 표시를 붙인다.
---
## 관련 문서
- [대용량 목록 페이지네이션](pagination.md) - 상한 COUNT·커서 적용 기준
- [Service-Repository 패턴](service-repository.md) - Repository에서 whereFulltext() 사용
- [훅 시스템](../extension/hooks.md) - core.search.engine_drivers 필터 훅
- [데이터베이스 가이드](../database-guide.md) - 마이그레이션 규칙
+19 -5
View File
@@ -942,11 +942,25 @@ class OrderRepository implements OrderRepositoryInterface
`paginate` / `simplePaginate` / 캐시 total 주입은 인자로 구분한다 — inner 쿼리는 동일하고 COUNT 수행 여부와 반환 클래스만 달라진다.
| 호출자 상황 | `$simple` | `$total` | 반환 |
| ------ | ------ | ------ | ------ |
| 일반 목록 | `false` | `null` | `LengthAwarePaginator` (COUNT 1회) |
| COUNT 를 피하고 다음 페이지 유무만 필요 | `true` | — | `Paginator` |
| 총 건수를 이미 캐시해 둠 | `false` | 캐시값 | `LengthAwarePaginator` (COUNT 없음) |
| 호출자 상황 | `$simple` | `$total` | `$resultCap` | 반환 |
| ------ | ------ | ------ | ------ | ------ |
| 일반 목록 | `false` | `null` | `null` | `LengthAwarePaginator` (COUNT 1회) |
| COUNT 를 피하고 다음 페이지 유무만 필요 | `true` | — | — | `Paginator` |
| 총 건수를 이미 캐시해 둠 | `false` | 캐시값 | `null` | `LengthAwarePaginator` (COUNT 없음) |
| 계속 쌓이기만 하는 목록 (로그·회원 등) | `false` | `null` | 상한 | `BoundedPage` (상한 COUNT + `per_page + 1` 실측) |
상한을 지정하면 총 건수는 그 값까지만 세고, 다음 페이지 판정은 `per_page + 1` 실측으로 따로 한다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이다. 상세는 [pagination.md](pagination.md).
```php
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: $sort,
perPage: $perPage,
relations: ['user:id,uuid,name,email'],
resultCap: PaginationLimits::resultCap('admin.activity_logs'),
);
```
정렬 순서 복원은 `whereIn` + 같은 정렬 스펙 재적용으로 한다. 키 컬럼이 정렬에 포함돼 전순서가 성립하므로 결과가 inner 순서와 동일하다. MySQL 전용 `FIELD()` 는 쓰지 않는다. outer 에 존재하지 않는 표현식(집계·조인 서브쿼리)으로 정렬해 재현이 불가능한 경우에만 `$preserveIdOrder = true` 로 표준 SQL `CASE WHEN` 경로를 쓴다.
+34 -2
View File
@@ -8,7 +8,7 @@
1. PHP 8.2+ 필수
2. MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4, utf8mb4_unicode_ci)
3. PHP 필수 모듈 30개 (ctype, curl, gd, intl, redis, imagick 등)
4. 디스크 용량: 최소 700MB / 권장 2GB+
4. 하드웨어: 최소 2 vCPU·2GB / 권장 4 vCPU·8GB, 디스크 최소 700MB / 권장 2GB+
5. 프로덕션: HTTPS 필수, Redis 권장, 큐 워커/스케줄러/Reverb 데몬 필요
```
@@ -174,7 +174,39 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
---
## 3. 디스크 용량
## 3. 하드웨어 사양
### 3.1 CPU · 메모리
| 수준 | CPU | 메모리 | 상정 환경 |
|------|-----|--------|----------|
| 최소 | 2 vCPU | 2GB | 설치·기능 확인용. 동시 접속이 거의 없는 개발/검토 환경 |
| 권장 | 4 vCPU | 8GB | 웹서버 + PHP-FPM + MySQL + Redis 를 한 대에 올린 소규모 운영 |
| 분리 구성 | 웹 2 vCPU / DB 2 vCPU | 각 4GB 이상 | 트래픽이 늘면 DB 를 먼저 분리 |
메모리 배분은 어떤 구성 요소를 같은 서버에 올렸는지에 따라 달라진다. 한 대에 모두 올린
경우 MySQL 의 `innodb_buffer_pool_size` 가 전체 메모리의 절반을 넘지 않도록 두고, PHP-FPM
자식 프로세스 수 × `memory_limit` 가 남은 메모리를 넘지 않는지 확인한다.
위 수치는 코어와 번들 확장을 기본 설정으로 운영할 때의 출발점이다. 실제 필요량은 데이터
규모·동시 접속·설치한 확장에 따라 달라지므로, 운영 전 대상 트래픽으로 직접 측정할 것을
권한다(`php artisan g7:bench` 로 목록·화면·쓰기·배치 4축을 잰다).
**이 수치의 근거**
- **권장 4 vCPU · 8GB** — 사용자가 4 vCPU · 8GB 가상머신에서 수행한 운영 환경 측정 보고
(`gnuboard/g7#82`)에서 코어와 번들 확장을 함께 올린 구성이 동작한 사양이다. 같은 보고에서
대량 데이터의 검색·목록 조회는 이 사양에서도 메모리 압박이 관측되었으므로, "이 사양이면
어떤 규모든 충분하다" 는 뜻이 아니라 **한 대 구성의 하한선**으로 읽어야 한다.
- **최소 2 vCPU · 2GB** — 설치와 기능 확인이 가능한 수준으로, 위 보고의 측정 대상이 아니다.
동시 접속이 있는 운영에는 적합하지 않다.
- **분리 구성** — 위 보고에서 부하가 먼저 걸린 지점이 데이터베이스였다는 관측에 따른 순서
제안이다. 분리 시점의 트래픽 임계값은 측정하지 않았다.
우리가 직접 측정한 범위는 쿼리 실행 횟수와 조회 구조이며, 특정 사양에서의 동시 접속 한계나
응답 시간은 측정하지 않았다. 그 값이 필요하면 대상 환경에서 직접 재야 한다.
### 3.2 디스크 용량
| 수준 | 용량 | 포함 범위 |
|------|------|----------|
@@ -10,6 +10,7 @@
- 예약 작업에 등록할 수 없는 Artisan 명령·옵션을 안내하는 문구 일본어 번역 추가 — 왜 등록이 거부됐는지(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)가 사유별로 표시됩니다.
- 언어팩 설치 시 거부 사유를 안내하는 문구 일본어 번역 추가 — 허용되지 않는 파일 형식, 번역 배열이 아닌 PHP 파일(줄 번호 포함), 심볼릭 링크, 그리고 설치 직후 활성화에 필요한 권한 안내입니다.
- 환경설정 고급 탭의 목록 상한 항목명(총 건수 집계 상한·페이지 번호 상한) 일본어 번역 추가 — 값이 범위를 벗어났을 때의 안내에 내부 식별자 대신 항목명이 표시됩니다.
### Changed
@@ -26,6 +27,9 @@
- 레이아웃 편집기 버전 이력의 "더 보기" 버튼과 변경내용이 너무 클 때의 안내 문구 일본어 번역 추가.
- 버전 목록을 한 번에 몇 개까지 가져올지 정하는 값의 입력 안내 문구 일본어 번역 추가.
- 통신이 끊겼을 때 표시되는 네트워크 오류 안내 문구 일본어 번역 추가 — 종전에는 내부 식별 문구가 영문 그대로 노출됐습니다.
- 목록·검색의 총 건수 정확도 안내 문구(`pagination.*`) 일본어 번역 추가 — 총 건수를 상한까지만 센 경우의 "N건 이상" 표기와 검색어를 좁히라는 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 검색 결과 건수 안내와 검색어 구체화 안내(`search.results_found_at_least`, `search.refine_query_hint`) 일본어 번역 추가.
- 페이지 번호 상한 초과 시의 검증 오류 문구(`validation.page_max`) 일본어 번역 추가.
## [1.0.8] - 2026-08-01
@@ -0,0 +1,12 @@
<?php
return [
'total_relation' => [
'exact' => '正確',
'at_least' => '以上',
],
'total_exact' => '合計 :count件',
'total_at_least' => '合計 :count件以上',
'result_cap_notice' => '一致する項目が :cap件を超えているため、正確な件数を数えていません。次のページに続けて移動できます。',
'refine_query_hint' => '検索語をより具体的に入力すると、正確な件数と最後のページを表示できます。',
];
@@ -13,6 +13,7 @@ return [
'per_page_integer' => 'ページあたりの項目数は数字である必要があります。',
'per_page_min' => 'ページあたりの項目数は1以上である必要があります。',
'per_page_max' => 'ページあたりの項目数は最大100個まで可能です。',
'page_max' => 'ページ番号は :max 以下である必要があります。検索キーワードをより具体的に入力してください。',
],
'index' => [
'status' => [
@@ -56,4 +57,7 @@ return [
],
],
],
'results_found_at_least' => ':count件以上の検索結果が見つかりました。',
'result_cap_notice' => '一致する項目が :cap件を超えており、合計件数を正確にカウントしていません。次のページに続行できます。',
'refine_query_hint' => '検索キーワードをより具体的に入力すると、正確な件数と最後のページを確認できます。',
];
@@ -977,6 +977,12 @@ return [
'identity_max_attempts_min' => '最大試行回数は最小1回以上である必要があります。',
'identity_max_attempts_max' => '最大試行回数は最大20回を超えることはできません。',
'sitemap_hreflang_enabled_boolean' => 'Sitemap 多言語代替リンク(hreflang) 設定は true または false 値である必要があります。',
'pagination_result_cap_integer' => '総件数集計上限は数値である必要があります。',
'pagination_result_cap_min' => '総件数集計上限は:min以上である必要があります。(0 = 無制限)',
'pagination_result_cap_max' => '総件数集計上限は:maxを超えることはできません。',
'pagination_max_page_integer' => 'ページ番号上限は数値である必要があります。',
'pagination_max_page_min' => 'ページ番号上限は:min以上である必要があります。(0 = 無制限)',
'pagination_max_page_max' => 'ページ番号上限は:maxを超えることはできません。',
],
'identity_policy' => [
'key_required' => 'ポリシーキーを入力してください。',
@@ -1133,6 +1139,8 @@ return [
'geoip_enabled' => 'GeoIP の使用',
'geoip_license_key' => 'GeoIP ライセンスキー',
'geoip_auto_update_enabled' => 'GeoIP 自動更新',
'pagination_result_cap' => '総件数集計上限',
'pagination_max_page' => 'ページ番号上限',
'websocket_app_id' => 'WebSocket アプリ ID',
'websocket_app_secret' => 'WebSocket アプリ シークレット',
'websocket_verify_ssl' => 'WebSocket SSL 証明書の検証',
@@ -16,6 +16,7 @@
- 역할 관리 목록의 「권한 수」 열 제목 일본어 번역 추가.
- 확장 업데이트 모달의 「검색 인덱스 재생성」 선택 항목 문구 일본어 번역 추가 — 항목 이름과 설명(재생성 시 인덱스 잠금·재색인 안내)이 일본어 로케일에서 자연스럽게 표시됩니다.
- 관리자 목록의 총 건수 정확도 표기와 고급 환경설정의 목록 한계값 항목 문구 일본어 번역 추가 — 총 건수를 상한까지만 센 경우의 "N건 이상" 표기와 상한 설정 항목이 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.4] - 2026-07-17
@@ -1788,7 +1788,13 @@
"geoip_update_success": "GeoIP DBの更新が完了しました。",
"geoip_update_failed": "GeoIP DBの更新に失敗しました。",
"seo_sitemap_cache": "Sitemap キャッシュ",
"seo_sitemap_cache_desc": "sitemap.xml をキャッシュします。"
"seo_sitemap_cache_desc": "sitemap.xml をキャッシュします。",
"pagination": "リスト制限値",
"pagination_desc": "大規模リストにおいて、総件数を集計する範囲と直接リクエスト可能なページ番号の上限です。上限を超えると、総件数を「N件以上」と表示し、最後のページへのジャンプのみ非表示になります — 次ページへの移動はそのまま利用可能です。",
"pagination_result_cap": "総件数集計の上限",
"pagination_result_cap_desc": "この件数までのみ正確に集計します。0の場合は常にすべてを集計します(大規模リストの場合、リストが遅くなる可能性があります)。",
"pagination_max_page": "ページ番号の上限",
"pagination_max_page_desc": "アドレスから直接リクエスト可能な最大ページ番号です。0の場合は制限しません。通常の次ページへの移動はこの値に関わらず引き続き可能です。"
},
"notification_definitions": {
"title": "通知設定",
@@ -2307,7 +2313,8 @@
"no_value": "-"
},
"pagination": {
"total": "合計{{count}}件"
"total": "合計{{count}}件",
"total_at_least": "合計{{count}}件以上"
},
"empty": {
"title": "アクティビティログがありません",
@@ -10,6 +10,8 @@
- 회원가입 화면의 휴대폰번호·전화번호 입력란 라벨·안내 문구(`auth.mobile`, `auth.phone`) 일본어 번역 추가 — 회원가입 시 연락처 입력란이 일본어 로케일에서 자연스럽게 표시됩니다.
- 모달 닫기 버튼의 안내 라벨(`common.close_modal`) 일본어 번역 추가 — 화면 낭독기 사용자에게 버튼 용도가 일본어로 안내됩니다.
- 검색 결과 건수 표기와 검색어 구체화 안내(`search.result_count_suffix_at_least`, `search.refine_query_hint`) 일본어 번역 추가 — 총 건수를 상한까지만 센 경우의 "N건 이상" 표기가 일본어 로케일에서 자연스럽게 표시됩니다.
- 편집기 화면 문구(`editor.*`) 일본어 번역 보강.
## [1.0.1] - 2026-07-08
@@ -47,5 +47,7 @@
"hint_title": "検索のコツ",
"hint_1": "2語以上を入力するとより正確な結果が得られます",
"hint_2": "検索キーワードはタイトルと内容から一緒に検索されます"
}
},
"result_count_suffix_at_least": "件以上",
"refine_query_hint": "検索キーワードをより具体的に入力すると、正確な件数と最後のページを確認できます。"
}
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| List pagination
|--------------------------------------------------------------------------
| Total count accuracy labels and large-result-set notices.
*/
'total_relation' => [
'exact' => 'exact',
'at_least' => 'at least',
],
'total_exact' => ':count results',
'total_at_least' => 'more than :count results',
'result_cap_notice' => 'More than :cap items matched, so the exact total was not counted. You can still move to the next page.',
'refine_query_hint' => 'Narrow your search terms to see the exact total and jump to the last page.',
];
+4
View File
@@ -7,6 +7,9 @@
return [
'empty_keyword' => 'Please enter a search keyword.',
'results_found' => ':count results found.',
'results_found_at_least' => 'More than :count results found.',
'result_cap_notice' => 'More than :cap items matched, so the exact total was not counted. You can still move to the next page.',
'refine_query_hint' => 'Narrow your search terms to see the exact total and jump to the last page.',
'no_results' => 'No results found.',
'view_more' => 'View more',
@@ -16,6 +19,7 @@ return [
'q_max' => 'Search keyword must not exceed 200 characters.',
'page_integer' => 'Page number must be a number.',
'page_min' => 'Page number must be at least 1.',
'page_max' => 'Page number must not exceed :max. Please narrow your search terms.',
'per_page_integer' => 'Items per page must be a number.',
'per_page_min' => 'Items per page must be at least 1.',
'per_page_max' => 'Items per page must not exceed 100.',
+10
View File
@@ -906,6 +906,14 @@ return [
'sql_query_log_required' => 'Please select the SQL query log setting.',
'sql_query_log_boolean' => 'SQL query log must be true or false.',
// List limits
'pagination_result_cap_integer' => 'The total count cap must be a number.',
'pagination_result_cap_min' => 'The total count cap must be at least :min. (0 = unlimited)',
'pagination_result_cap_max' => 'The total count cap may not be greater than :max.',
'pagination_max_page_integer' => 'The maximum page number must be a number.',
'pagination_max_page_min' => 'The maximum page number must be at least :min. (0 = unlimited)',
'pagination_max_page_max' => 'The maximum page number may not be greater than :max.',
// Core update settings
'core_update_github_url_invalid' => 'The GitHub repository URL format is invalid.',
'core_update_github_url_max' => 'The GitHub repository URL may not be greater than 500 characters.',
@@ -1273,6 +1281,8 @@ return [
'geoip_enabled' => 'GeoIP',
'geoip_license_key' => 'GeoIP license key',
'geoip_auto_update_enabled' => 'GeoIP auto update',
'pagination_result_cap' => 'list total count cap',
'pagination_max_page' => 'list maximum page number',
// Driver settings (additional)
'websocket_app_id' => 'WebSocket app ID',
'websocket_app_secret' => 'WebSocket app secret',
+23
View File
@@ -0,0 +1,23 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| 목록 페이지네이션
|--------------------------------------------------------------------------
| 총 건수 정확도 표기와 대용량 목록 안내 문구입니다.
*/
'total_relation' => [
'exact' => '정확',
'at_least' => '이상',
],
// 건수 표기 — 상한 이하일 때는 정확한 건수, 초과할 때는 "이상"
'total_exact' => '총 :count건',
'total_at_least' => '총 :count건 이상',
// 상한 초과 안내 — 검색어를 좁히면 정확한 건수를 볼 수 있다
'result_cap_notice' => '일치하는 항목이 :cap건을 넘어 총 건수를 정확히 세지 않았습니다. 다음 페이지로 계속 이동할 수 있습니다.',
'refine_query_hint' => '검색어를 더 구체적으로 입력하면 정확한 건수와 마지막 페이지를 볼 수 있습니다.',
];
+4
View File
@@ -7,6 +7,9 @@
return [
'empty_keyword' => '검색어를 입력해주세요.',
'results_found' => ':count건의 검색 결과를 찾았습니다.',
'results_found_at_least' => ':count건 이상의 검색 결과를 찾았습니다.',
'result_cap_notice' => '일치하는 항목이 :cap건을 넘어 총 건수를 정확히 세지 않았습니다. 다음 페이지로 계속 이동할 수 있습니다.',
'refine_query_hint' => '검색어를 더 구체적으로 입력하면 정확한 건수와 마지막 페이지를 볼 수 있습니다.',
'no_results' => '검색 결과가 없습니다.',
'view_more' => '더보기',
@@ -16,6 +19,7 @@ return [
'q_max' => '검색어는 최대 200자까지 입력 가능합니다.',
'page_integer' => '페이지 번호는 숫자여야 합니다.',
'page_min' => '페이지 번호는 1 이상이어야 합니다.',
'page_max' => '페이지 번호는 :max 이하여야 합니다. 검색어를 더 구체적으로 입력해 주세요.',
'per_page_integer' => '페이지당 항목 수는 숫자여야 합니다.',
'per_page_min' => '페이지당 항목 수는 1 이상이어야 합니다.',
'per_page_max' => '페이지당 항목 수는 최대 100개까지 가능합니다.',
+10
View File
@@ -989,6 +989,14 @@ return [
'sql_query_log_required' => 'SQL 쿼리 로그 설정을 선택해주세요.',
'sql_query_log_boolean' => 'SQL 쿼리 로그는 true 또는 false 값이어야 합니다.',
// 목록 한계값
'pagination_result_cap_integer' => '총 건수 집계 상한은 숫자여야 합니다.',
'pagination_result_cap_min' => '총 건수 집계 상한은 :min 이상이어야 합니다. (0 = 무제한)',
'pagination_result_cap_max' => '총 건수 집계 상한은 :max 를 초과할 수 없습니다.',
'pagination_max_page_integer' => '페이지 번호 상한은 숫자여야 합니다.',
'pagination_max_page_min' => '페이지 번호 상한은 :min 이상이어야 합니다. (0 = 무제한)',
'pagination_max_page_max' => '페이지 번호 상한은 :max 를 초과할 수 없습니다.',
// 코어 업데이트 설정
'core_update_github_url_invalid' => 'GitHub 저장소 URL 형식이 올바르지 않습니다.',
'core_update_github_url_max' => 'GitHub 저장소 URL은 500자를 초과할 수 없습니다.',
@@ -1266,6 +1274,8 @@ return [
'geoip_enabled' => 'GeoIP 사용',
'geoip_license_key' => 'GeoIP 라이선스 키',
'geoip_auto_update_enabled' => 'GeoIP 자동 업데이트',
'pagination_result_cap' => '목록 총 건수 상한',
'pagination_max_page' => '목록 최대 페이지 번호',
// 드라이버 설정 (추가)
'websocket_app_id' => '웹소켓 앱 ID',
'websocket_app_secret' => '웹소켓 앱 시크릿',
@@ -19,18 +19,36 @@
### Changed
- 통합 검색의 게시글 결과를 최신순·오래된순·조회순으로 볼 때 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 관련도순은 종전의 페이지 번호 방식을 유지합니다.
- 플러그인으로 다른 검색엔진을 연결하면 게시글·신고 검색에도 그 엔진이 적용됩니다. 이전에는 연결한 엔진과 무관하게 기본 방식으로만 검색됐습니다.
- 게시글을 열 때 같은 글과 같은 게시판을 여러 번 다시 읽던 것을 정리했습니다. 글은 한 번만, 게시판도 한 번만 읽습니다. 화면에 보이는 내용은 동일하며 글이 열리는 속도가 빨라집니다. 조회수는 열람 권한을 확인한 뒤에만 올라갑니다.
- 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
- 게시글 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 게시글의 본문 앞부분까지 함께 읽어 느려졌지만, 이제 현재 페이지의 게시글만 본문 미리보기를 읽습니다. 공지 노출·답글 표시·다음 페이지 버튼은 이전과 동일하게 동작합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 신고 관리 목록도 같은 방식으로 개선했습니다. 신고 건수·대상 글 상태처럼 목록에 함께 표시되는 정보를 예전에는 건너뛰는 신고까지 모두 계산했지만, 이제 현재 페이지의 신고에 대해서만 계산합니다. 신고가 많이 쌓인 사이트에서 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 마이페이지의 「내가 쓴 댓글」·「내가 댓글 단 글」 목록도 같은 방식으로 개선했습니다. 각 글의 최근 댓글을 예전에는 건너뛰는 글까지 모두 찾아봤지만, 이제 현재 페이지의 글에 대해서만 찾습니다. 댓글 활동이 많은 회원의 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시글이 많은 게시판에서 목록 정렬이 더 빨라지도록 색인을 정비했습니다. 신고 관리 목록에도 같은 정비를 적용했습니다. 같은 시각에 등록된 게시글이 많은 구간에서 순서를 정하느라 생기던 추가 작업이 사라집니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 게시글이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 글쓰기가 잠시 대기할 수 있습니다.
- 관리자 게시판 목록이 게시판마다 매니저·스텝 역할과 그 역할에 속한 회원 명단(이름·이메일)을 함께 내려주지 않습니다. 목록 화면이 쓰지 않는 값이라 게시판 수만큼 늘어나던 조회와 전송량이 사라집니다. 역할 지정은 종전대로 게시판 상세·설정 화면에서 하며, 목록에 보이는 항목은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 한 글의 댓글이 아주 많아 목록이 일정 수에서 끊긴 경우, 끊겼다는 사실과 전체 건수를 함께 알려 줍니다. 예전에는 조용히 잘려 "댓글이 그만큼뿐" 으로 보였습니다.
- 댓글이 아주 많은 글에서도 뒤쪽 댓글을 볼 수 있도록 댓글 목록을 나눠 받을 수 있게 했습니다. 페이지 단위는 원댓글이며, 그 원댓글에 달린 답글은 함께 따라오므로 답글이 부모와 떨어지지 않습니다. 기존처럼 한 번에 받는 방식도 그대로 동작합니다.
- 게시글 검색 탭의 배지 숫자도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. 배지를 표시하지 않는 화면에서는 그 숫자를 세는 작업 자체를 건너뛸 수 있습니다.
- 게시글 목록의 총 건수도 일정 규모까지만 정확히 세고 그보다 많으면 "N건 이상" 으로 표시합니다. 페이지 이동은 끝까지 열려 있습니다.
- 신고 관리 목록의 총 건수에도 같은 방식을 적용했습니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
- 게시판 검색이 조건에 맞는 글을 두 번 조회하던 것을 한 번으로 합쳤습니다. 글이 많은 게시판일수록 검색이 빨라집니다.
- 검색 결과가 아주 많으면 총 건수를 "N건 이상" 으로 표시하고, 다음 페이지 이동은 끝까지 열어 둡니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다.
- 관리자 게시판 목록이 삭제글을 포함해 열릴 때마다 전체 건수를 다시 세던 것을 저장해 두고 재사용하도록 바꿨습니다.
- 답변글이 아주 많은 글을 열 때 답변 전체를 한꺼번에 읽던 것에 상한을 두고, 답변을 트리로 묶는 처리도 개선했습니다.
- 한 글의 댓글을 무제한으로 읽던 것에 상한을 두고, 같은 댓글을 두 번 가져오던 부분을 없앴습니다.
### Fixed
- 게시판 알림 설정 화면에서 모든 알림의 제목·수신자·활성 여부가 비어 보이고 「이 채널에 대한 템플릿이 없습니다」로만 표시되던 문제를 수정했습니다. 편집 창을 열어도 내용이 채워지지 않았습니다. 채널을 전환하거나 새로고침해도 마찬가지였습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 게시판 설정의 '새 글 표시 시간'이 저장 직후에는 숫자가 아닌 형태로 다뤄져, 조회 시점에 따라 값의 형태가 달라지던 문제를 수정했습니다. 새 글 표시 여부 판정에 쓰이는 값이므로 항상 숫자로 처리합니다.
- 게시글이 아주 많은 게시판에서 목록의 글 번호가 0 이나 음수로 표시되던 문제를 수정했습니다. 총 건수를 끝까지 세지 못하는 경우에는 번호를 지어내지 않고 「-」로 표시합니다.
- 통합 검색 결과에서 작성자 자리에 번역되지 않은 내부 문구가 그대로 보이던 문제를 수정했습니다. 이제 「비회원」으로 표시됩니다.
- 통합 검색을 최신순으로 볼 때 첫 페이지부터 이어보기 방식이 적용되도록 수정했습니다. 이전에는 뒤쪽 페이지로 갈수록 느려지는 방식이 계속 쓰였습니다. 주소로 특정 페이지를 열어 둔 링크는 종전대로 그 페이지를 보여 줍니다.
- 게시판 목록에서 총 건수를 끝까지 세지 못했을 때 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다.
- 마이페이지의 내가 쓴 글·내가 쓴 댓글 목록에서 총 건수를 끝까지 세지 못했을 때 페이지 이동 막대가 사라지던 문제를 수정했습니다.
- 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -68,6 +86,9 @@
- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다.
- 게시판 모듈을 제거할 때 게시판별로 자동 생성된 관리자·승인 단계 역할이 1,000개를 넘으면 일부가 삭제되지 않고 남던 문제를 수정했습니다. 남은 역할은 사용하지 않는 게시판의 것이면서도 회원 권한 목록에 계속 노출됐습니다. 이제 개수와 무관하게 전부 정리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.)
- 검색어에 `+` `-` `*` `"` `<` `>` 같은 기호를 넣으면 게시판 검색이 오류 화면을 띄우던 문제를 수정했습니다. 이제 어떤 문자를 입력해도 검색이 정상 동작합니다.
- 게시글 상세를 열 때 같은 글과 게시판을 여러 번 다시 조회하던 것을 정리했습니다.
- 관리자 게시판 목록·신고 관리 목록과 신고 상세의 신고자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 페이지 이동이 막히던 문제를 수정했습니다. 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다.
## [1.0.2] - 2026-07-14
@@ -16,6 +16,22 @@
---
## 목록·검색의 총 건수와 답변·댓글 상한
게시판 목록에 `search` 를 얹으면 내부 검색이 수행됩니다. 매칭이 아주 많을 수 있으므로 총
건수는 상한까지만 세며, 상한을 넘으면 응답의 `pagination` 에 정확도가 함께 실립니다
(`total_relation` / `total_is_exact` / `result_cap`). 이때 `last_page` 는 `null` 이고
`has_more_pages` 는 그대로 정확하므로, 마지막 페이지 점프만 감춰지고 다음 페이지 이동은
끝까지 열려 있습니다. 상세 규약은 [pagination.md](../../../../../docs/backend/pagination.md) 를 참고하세요.
검색어에 `+` `-` `*` `"` `<` `>` 같은 문자가 들어와도 오류가 나지 않습니다. 코어 정제기가
FULLTEXT 연산자를 제거한 뒤 검색하며, 연산자만 입력한 경우에는 오류 대신 빈 결과를 돌려줍니다.
게시글 상세 응답의 답변 트리와 댓글 목록에도 같은 상한이 적용됩니다. 한 글에 답변·댓글이
극단적으로 많은 경우 그 지점에서 끊기며, 총 건수는 목록 응답의 집계로 확인할 수 있습니다.
---
### POST /api/modules/sirsoft-board/admin/board/{slug}/attachments
<!-- @generated:start:api.modules.sirsoft-board.admin.board.attachments.upload -->
@@ -864,6 +880,9 @@ _단건 응답: `data` 객체의 필드._
| navigation | object | `{"prev":null,"next":null}` | 이전/다음 게시글 이동 정보. `prev`·`next` 키에 인접 게시글 요약(없으면 null)이 담기며, 상세 로드 시 함께 계산됩니다. |
| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) |
| comments | array | `[{"id":760,"post_id":237,"parent_id":null,"content":"API …` | 게시글에 달린 댓글 목록(CommentResource 컬렉션). comments 관계가 로드된 경우에만 채워지며, 각 항목에 신고 여부가 사전 로드되어 담깁니다. |
| comments_truncated | boolean | `false` | 댓글 목록이 상한에서 끊겼는지 여부. `true` 면 `comments` 에 실린 것이 전부가 아닙니다 |
| comments_total | integer\|null | `12` | 댓글 총 건수. 끊기지 않았으면 `comments` 길이와 같고, 끊겼으면 상한값(그 이상)입니다 |
| comments_total_is_exact | boolean | `true` | 위 총 건수가 정확한지 여부. `false` 면 "N건 이상" 으로 표기합니다 |
| attachments | array | `[{"id":155,"hash":"apidocsmpl1","original_filename":"apid…` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). 비밀글은 열람 권한이 없으면 빈 배열, 삭제된 게시글은 관리 권한이 없으면 연쇄 삭제된 첨부만 노출됩니다. |
| replies | array | `[]` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
| is_already_reported | boolean | `false` | already reported 여부 |
@@ -86,7 +86,7 @@
"name": "Icon",
"props": {
"name": "refresh",
"className": "w-5 h-5"
"className": "text-xl"
}
}
]
@@ -116,7 +116,7 @@
"name": "Icon",
"props": {
"name": "plus",
"className": "w-5 h-5"
"className": "text-xl"
}
},
{
@@ -183,7 +183,8 @@
"pagination": true,
"serverSidePagination": true,
"serverCurrentPage": "{{boards?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{boards?.data?.pagination?.last_page ?? 1}}",
"serverTotalPages": "{{boards?.data?.pagination?.last_page ?? null}}",
"serverHasMorePages": "{{boards?.data?.pagination?.has_more_pages ?? false}}",
"alwaysShowPagination": true,
"emptyMessage": "$t:sirsoft-board.admin.board.index.empty_description",
"cardClassName": "rounded-lg bg-white dark:bg-gray-800 shadow-md hover:shadow-lg transition-shadow border border-gray-200 dark:border-gray-700 p-6 space-y-4",
@@ -457,12 +458,13 @@
"text": "$t:sirsoft-board.admin.board.index.categories_label"
},
{
"id": "category_tag",
"id": "category_tag_{{categoryIdx}}",
"type": "composite",
"name": "StatusBadge",
"iteration": {
"source": "row.categories",
"item_var": "category"
"item_var": "category",
"index_var": "categoryIdx"
},
"props": {
"status": "default",
@@ -749,7 +751,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white dark:text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [
@@ -744,7 +744,7 @@
"props": {
"className": "inline-flex items-center px-2.5 py-0.5 rounded text-xs font-bold bg-blue-600 text-white dark:bg-blue-500"
},
"text": "{{row.number}}",
"text": "{{row.number ?? '-'}}",
"if": "{{row.row_type === 'notice'}}"
},
{
@@ -753,7 +753,7 @@
"props": {
"className": "inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
},
"text": "{{row.number}}",
"text": "{{row.number ?? '-'}}",
"if": "{{row.row_type === 'reply'}}"
},
{
@@ -762,7 +762,7 @@
"props": {
"className": "font-medium text-gray-900 dark:text-white"
},
"text": "{{row.number}}",
"text": "{{row.number ?? '-'}}",
"if": "{{row.row_type === 'normal'}}"
}
]
@@ -1003,7 +1003,8 @@
"pageSize": 15,
"serverSidePagination": true,
"serverCurrentPage": "{{posts?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{posts?.data?.pagination?.last_page ?? 1}}",
"serverTotalPages": "{{posts?.data?.pagination?.last_page ?? null}}",
"serverHasMorePages": "{{posts?.data?.pagination?.has_more_pages ?? false}}",
"selectable": false,
"responsiveBreakpoint": 768,
"showFirstLast": true,
@@ -2096,7 +2096,8 @@
"pageSize": 20,
"serverSidePagination": true,
"serverCurrentPage": "{{reports?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{reports?.data?.pagination?.last_page ?? 1}}",
"serverTotalPages": "{{reports?.data?.pagination?.last_page ?? null}}",
"serverHasMorePages": "{{reports?.data?.pagination?.has_more_pages ?? false}}",
"selectable": true,
"selectedIds": "{{_global.selectedIds || []}}",
"idField": "id",
@@ -81,7 +81,7 @@
"name": "Icon",
"props": {
"name": "chart-bar",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -121,7 +121,7 @@
"name": "Icon",
"props": {
"name": "calendar",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -163,7 +163,7 @@
"name": "Icon",
"props": {
"name": "circle-info",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -336,7 +336,7 @@
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3"
"className": "text-xs"
}
}
]
@@ -410,7 +410,7 @@
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3 transform translate-x-0 group-hover:translate-x-0.5 transition-all"
"className": "text-xs transform translate-x-0 group-hover:translate-x-0.5 transition-all"
}
}
]
@@ -470,7 +470,7 @@
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3 transform translate-x-0 group-hover:translate-x-0.5 transition-all"
"className": "text-xs transform translate-x-0 group-hover:translate-x-0.5 transition-all"
}
}
]
@@ -511,7 +511,7 @@
"name": "Icon",
"props": {
"name": "clock",
"className": "w-4 h-4 text-gray-500 dark:text-gray-400"
"className": "text-base text-gray-500 dark:text-gray-400"
}
},
{
@@ -618,7 +618,7 @@
},
"children": [
{
"id": "reporters_list_container",
"id": "reporters_list_container_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.reporters_loading}}",
@@ -627,11 +627,12 @@
},
"iteration": {
"source": "reporters_list?.data?.data ?? report_detail?.data?.reporters ?? []",
"item_var": "reporter"
"item_var": "reporter",
"index_var": "reporterIdx"
},
"children": [
{
"id": "reporter_item_card",
"id": "reporter_item_card_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"props": {
@@ -639,7 +640,7 @@
},
"children": [
{
"id": "card_inner_grid",
"id": "card_inner_grid_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"props": {
@@ -647,7 +648,7 @@
},
"children": [
{
"id": "reporter_field",
"id": "reporter_field_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"children": [
@@ -713,7 +714,7 @@
]
},
{
"id": "reported_at_field",
"id": "reported_at_field_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"children": [
@@ -736,7 +737,7 @@
]
},
{
"id": "reason_type_field",
"id": "reason_type_field_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"children": [
@@ -759,7 +760,7 @@
]
},
{
"id": "reporter_item_reason",
"id": "reporter_item_reason_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"props": {
@@ -794,10 +795,11 @@
"id": "reporters_pagination",
"type": "composite",
"name": "Pagination",
"if": "{{(reporters_list?.data?.pagination?.last_page ?? 1) > 1}}",
"if": "{{((reporters_list?.data?.pagination?.last_page ?? 0) > 1) || (reporters_list?.data?.pagination?.has_more_pages === true) || ((reporters_list?.data?.pagination?.current_page ?? 1) > 1)}}",
"props": {
"currentPage": "{{reporters_list?.data?.pagination?.current_page ?? 1}}",
"totalPages": "{{reporters_list?.data?.pagination?.last_page ?? 1}}",
"totalPages": "{{reporters_list?.data?.pagination?.last_page ?? null}}",
"hasMorePages": "{{reporters_list?.data?.pagination?.has_more_pages ?? false}}",
"className": "mt-4 justify-end"
},
"actions": [
@@ -1045,7 +1045,7 @@
"props": {
"className": "text-tertiary"
},
"text": "$t:admin.identity.policies.pagination_summary|total={{boardIdentityPolicies?.data?.meta?.total ?? 0}}|page={{boardIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{boardIdentityPolicies?.data?.meta?.last_page ?? 1}}"
"text": "$t:admin.identity.policies.pagination_summary|total={{boardIdentityPolicies?.data?.meta?.total ?? 0}}|page={{boardIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{boardIdentityPolicies?.data?.meta?.last_page ?? '-'}}"
},
{
"type": "basic",
@@ -1098,14 +1098,14 @@
"props": {
"className": "px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-200"
},
"text": "$t:admin.identity.policies.page_indicator|page={{boardIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{boardIdentityPolicies?.data?.meta?.last_page ?? 1}}"
"text": "$t:admin.identity.policies.page_indicator|page={{boardIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{boardIdentityPolicies?.data?.meta?.last_page ?? '-'}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{(boardIdentityPolicies?.data?.meta?.current_page ?? 1) >= (boardIdentityPolicies?.data?.meta?.last_page ?? 1)}}",
"disabled": "{{boardIdentityPolicies?.data?.meta?.last_page ? ((boardIdentityPolicies?.data?.meta?.current_page ?? 1) >= boardIdentityPolicies?.data?.meta?.last_page) : (boardIdentityPolicies?.data?.meta?.has_more_pages !== true)}}",
"className": "px-3 py-1.5 text-xs font-medium bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
@@ -1118,7 +1118,7 @@
"mergeQuery": true,
"query": {
"tab": "identity_policies",
"page": "{{Math.min(boardIdentityPolicies?.data?.meta?.last_page ?? 1, (boardIdentityPolicies?.data?.meta?.current_page ?? 1) + 1)}}"
"page": "{{boardIdentityPolicies?.data?.meta?.last_page ? Math.min(boardIdentityPolicies?.data?.meta?.last_page, (boardIdentityPolicies?.data?.meta?.current_page ?? 1) + 1) : ((boardIdentityPolicies?.data?.meta?.current_page ?? 1) + 1)}}"
},
"transition_overlay_target": "policy_table_card",
"scroll": {

Some files were not shown because too many files have changed in this diff Show More