Files
Gnuboard7/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php
T
chym1217 4c4f90a091 fix(page): 페이지 목록 검색 범위·페이지당 기본값·긴 검색어 오류 정정
- 검색 조건 '전체'가 슬러그를 검색하도록 통합하고, 검색 대상을 제목·슬러그로
 한정(본문 제외 — UI '제목 또는 슬러그로 검색'과 정합). '제목' 선택 시 제목만 검색.
- 페이지당 표시 개수 기본값을 백엔드도 20으로 통일(화면 표시와 실제 조회 일치).
- 긴 검색어(공백 없는 한글 다수)가 FULLTEXT 토큰 한도를 넘겨 500을 유발하던 문제를
 검색어 100자 제한으로 422 처리. 검증 메시지 필드명을 '검색어'로 노출.
2026-07-06 09:37:03 +09:00

294 lines
10 KiB
PHP

<?php
namespace Modules\Sirsoft\Page\Repositories;
use App\Helpers\PermissionHelper;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
/**
* 페이지 Repository
*
* 페이지 데이터 접근 계층을 담당합니다.
*/
class PageRepository implements PageRepositoryInterface
{
use HasMultipleSearchFilters;
/**
* 페이지 목록을 페이지네이션하여 조회합니다.
*
* @param array $filters 필터 조건 (published, search, search_field)
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지 목록
*/
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
// filters 배열에서 search/search_field 변환
$filters = $this->normalizeSearchFilters($filters);
// 검색 필드(title/slug/all)별로 대상 컬럼을 정확히 분리하기 위해
// 모든 검색을 applyFilters 통합 쿼리로 처리한다.
// - title: 제목만 (Scout searchableColumns 는 title+content 를 함께 검색하므로 사용하지 않음)
// - slug : 슬러그만
// - all : 제목 + 슬러그 (본문 content 은 검색 대상 아님 — UI '제목 또는 슬러그로 검색')
// (Scout 콜백에 slug orWhere 를 넣으면 total 카운트가 부풀려지는 회귀가 있어 #225 에서 제거됨)
$query = Page::with(['creator', 'updater']);
// 권한 스코프 필터링
PermissionHelper::applyPermissionScope($query, 'sirsoft-page.pages.read');
$this->applyFilters($query, $filters);
$this->applySorting($query, $filters);
return $query->paginate($perPage);
}
/**
* ID로 페이지를 조회합니다.
*
* @param int $id 페이지 ID
* @return Page|null 페이지 모델 또는 null
*/
public function findById(int $id): ?Page
{
return Page::find($id);
}
/**
* 슬러그로 페이지를 조회합니다.
*
* @param string $slug 슬러그
* @return Page|null 페이지 모델 또는 null
*/
public function findBySlug(string $slug): ?Page
{
return Page::where('slug', $slug)->first();
}
/**
* ID로 페이지를 조회하며, 없으면 예외를 발생시킵니다.
*
* @param int $id 페이지 ID
* @return Page 페이지 모델
*
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): Page
{
return Page::findOrFail($id);
}
/**
* 페이지를 생성합니다.
*
* @param array $data 페이지 생성 데이터
* @return Page 생성된 페이지 모델
*/
public function create(array $data): Page
{
return Page::create($data);
}
/**
* 페이지를 수정합니다.
*
* @param Page $page 페이지 모델
* @param array $data 수정할 데이터
* @return Page 수정된 페이지 모델
*/
public function update(Page $page, array $data): Page
{
$page->fill($data)->save();
return $page->fresh();
}
/**
* 페이지를 삭제합니다 (소프트 삭제).
*
* @param Page $page 페이지 모델
* @return bool 삭제 성공 여부
*/
public function delete(Page $page): bool
{
return (bool) $page->delete();
}
/**
* 슬러그 중복 여부를 확인합니다.
*
* @param string $slug 확인할 슬러그
* @param int|null $excludeId 제외할 페이지 ID (수정 시)
* @return bool 중복 여부 (true: 중복)
*/
public function slugExists(string $slug, ?int $excludeId = null): bool
{
$query = Page::where('slug', $slug);
if ($excludeId !== null) {
$query->where('id', '!=', $excludeId);
}
return $query->exists();
}
/**
* 키워드로 발행된 페이지를 검색합니다.
*
* @param string $keyword 검색 키워드
* @param string $orderBy 정렬 컬럼
* @param string $direction 정렬 방향 (asc, desc)
* @param int $limit 조회할 최대 항목 수
* @return array{total: int, items: Collection}
*/
public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array
{
$results = $this->buildKeywordQuery($keyword)
->orderBy($orderBy, $direction)
->paginate($limit);
return [
'total' => $results->total(),
'items' => $results->getCollection(),
];
}
/**
* 키워드와 일치하는 발행된 페이지 수를 조회합니다.
*
* @param string $keyword 검색 키워드
* @return int 일치하는 페이지 수
*/
public function countByKeyword(string $keyword): int
{
return $this->buildKeywordQuery($keyword)->count();
}
/**
* 키워드 검색 공통 쿼리를 구성합니다.
*
* FULLTEXT(title, content) OR slug LIKE 를 properly grouped 상태로 AND published 와 결합.
*
* 주의: Scout 의 `Page::search($keyword)->query(fn)` 경로에서 non-FT `orWhere('slug')` 를
* 사용하면, Scout 내부 `Builder::getTotalCount` 가 queryCallback 이 존재할 때
* `queryScoutModelsByIds` 로 FT 없이 whereIn 을 붙여 재계수하는 경로에서 callback 을 재적용하며
* `WHERE published = ? OR slug LIKE ? AND id IN (...)` 형태로 연산자 우선순위가 깨져
* `published = ?` 만 유효해지고 total 이 모든 발행 페이지 수로 부풀려지는 회귀가 발생한다.
* 이 Repository 의 applyTitleKeywordSearch 에서 이미 사용중인
* `DatabaseFulltextEngine::whereFulltext` 정적 헬퍼 패턴과 동일하게 해결한다
* (Scout 엔진은 indexing 및 다른 검색에서 계속 사용됨).
*
* @param string $keyword 검색 키워드
*/
private function buildKeywordQuery(string $keyword): Builder
{
return Page::query()
->published()
->where(function ($q) use ($keyword) {
DatabaseFulltextEngine::whereFulltext($q, 'title', $keyword, 'and');
DatabaseFulltextEngine::whereFulltext($q, 'content', $keyword, 'or');
$q->orWhere('slug', 'like', '%'.$keyword.'%');
});
}
/**
* 쿼리에 정렬을 적용합니다.
*
* @param Builder $query 쿼리 빌더
* @param array $filters 필터 조건 (sort_by, sort_order 포함)
*/
private function applySorting($query, array $filters): void
{
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = strtolower($filters['sort_order'] ?? 'desc');
if (! in_array($sortOrder, ['asc', 'desc'])) {
$sortOrder = 'desc';
}
$query->orderBy($sortBy, $sortOrder);
}
/**
* 쿼리에 필터를 적용합니다.
*
* @param Builder $query 쿼리 빌더
* @param array $filters 필터 조건
*/
private function applyFilters($query, array $filters): void
{
// 발행 상태 필터
if (isset($filters['published']) && $filters['published'] !== '') {
$query->where('published', (bool) $filters['published']);
}
// 검색
if (! empty($filters['search'])) {
$keyword = $filters['search'];
$searchField = $filters['search_field'] ?? 'all';
if ($searchField === 'slug') {
$query->where('slug', 'like', "%{$keyword}%");
} elseif ($searchField === 'title') {
$query->where(function ($q) use ($keyword) {
$this->applyTitleKeywordSearch($q, $keyword, includeSlug: false);
});
} else {
// all: 제목 + 슬러그 통합 검색 (본문 content 은 검색 대상 아님 — UI '제목 또는 슬러그로 검색')
$query->where(function ($q) use ($keyword) {
$this->applyTitleKeywordSearch($q, $keyword, includeSlug: true);
});
}
}
}
/**
* 제목(JSON) + 슬러그 키워드 검색을 쿼리에 적용합니다.
*
* title은 {"ko": "...", "en": "..."} 형태의 JSON 컬럼이므로 FULLTEXT 로 검색한다.
* 검색 대상은 제목·슬러그이며 본문(content)은 포함하지 않는다
* (검색 UI 안내 '제목 또는 슬러그로 검색' 및 검색 필드 옵션 전체/제목/슬러그와 일치).
*
* @param Builder $query 쿼리 빌더
* @param string $keyword 검색 키워드
* @param bool $includeSlug 슬러그도 함께 검색할지 여부 (전체 검색 시 true)
*/
private function applyTitleKeywordSearch($query, string $keyword, bool $includeSlug = false): void
{
if ($includeSlug) {
$query->where('slug', 'like', "%{$keyword}%");
}
DatabaseFulltextEngine::whereFulltext($query, 'title', $keyword, 'or');
}
/**
* filters 배열을 search/search_field로 정규화합니다.
*
* 레이아웃에서 전달되는 filters[0][field]/filters[0][value] 형식을
* 기존 search/search_field 형식으로 변환합니다.
*
* @param array $filters 필터 조건
* @return array 정규화된 필터 조건
*/
private function normalizeSearchFilters(array $filters): array
{
if (! empty($filters['filters']) && is_array($filters['filters'])) {
$firstFilter = $filters['filters'][0] ?? null;
if ($firstFilter && ! empty($firstFilter['value'])) {
$filters['search'] = $firstFilter['value'];
$filters['search_field'] = $firstFilter['field'] ?? 'all';
}
}
return $filters;
}
}