- 공개 : MATCH 는 커버 인덱스가 있을 때만 조립 — 부재 시 LIKE 폴백 + 1회 경고, 카테고리 검색 예외를 categories_failed/search_failed 로 표면화하고 basic 템플릿이 오류 안내 렌더 - 공개 동근원: paginate page 명시 전달 (언어팩 check-updates, 상품 문의 목록) - 공개 동근원: raw SQL 접두사/별칭 하드코딩 정리 (board 시더, 7.0.6 업그레이드 스텝, 결제 3플러그인 컨트롤러 51지점 모델 파생 전환) - audit 룰 3종 신설 + repository-raw-hardcoded-table 컨트롤러 확대, 확장 TestCase 오토로더 중복 선언 가드 16지점, ja 언어팩 동기
245 lines
10 KiB
PHP
245 lines
10 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
/**
|
|
* 통합 검색 API 컨트롤러
|
|
*
|
|
* 코어에서 검색 기반 구조를 제공하고,
|
|
* 각 모듈이 Filter Hook을 통해 검색 결과를 추가합니다.
|
|
*/
|
|
class PublicSearchController extends PublicBaseController
|
|
{
|
|
/**
|
|
* 통합 검색 수행
|
|
*
|
|
* @param SearchRequest $request HTTP 요청
|
|
* @return JsonResponse 검색 결과
|
|
*/
|
|
public function search(SearchRequest $request): JsonResponse
|
|
{
|
|
$validated = $request->validated();
|
|
|
|
$q = trim($validated['q'] ?? '');
|
|
$type = $validated['type'] ?? 'all';
|
|
$sort = $validated['sort'] ?? 'relevance';
|
|
$page = (int) ($validated['page'] ?? 1);
|
|
$perPage = (int) ($validated['per_page'] ?? 10);
|
|
|
|
// 검색어가 비어있으면 빈 결과 반환
|
|
if (empty($q)) {
|
|
return $this->success(__('search.empty_keyword'), $this->buildEmptyResponse());
|
|
}
|
|
|
|
// 특정 탭을 보는 중이라면, 다른 탭의 배지 건수를 아예 세지 않을 수 있다.
|
|
// 기본값은 세는 쪽이다 — 탭 배지가 화면 요소라 끄면 숫자가 사라진다. 다만 배지가
|
|
// 필요 없는 화면(더 보기 페이지 등)은 with_counts=false 로 그 비용을 없앤다.
|
|
// 전체 탭은 배지가 화면의 주 정보라 이 값과 무관하게 항상 센다.
|
|
$includeInactiveCounts = $type === 'all'
|
|
? true
|
|
: $request->boolean('with_counts', true);
|
|
|
|
// 검색 컨텍스트 구성 (모듈별 파라미터는 request 전체를 전달하여 각 모듈이 직접 처리)
|
|
$context = [
|
|
'q' => $q,
|
|
'type' => $type,
|
|
'sort' => $sort,
|
|
'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,
|
|
];
|
|
|
|
// Filter Hook을 통해 각 모듈이 검색 결과 추가
|
|
// 모듈들은 자신의 카테고리를 ['category' => ['total' => N, 'items' => [...]]] 형식으로 추가
|
|
// 빈 배열로 시작하여 모듈이 자유롭게 카테고리 추가 가능
|
|
$results = HookManager::applyFilters('core.search.results', [], $context);
|
|
|
|
// 프론트엔드용 응답 구조로 변환 (훅을 통해 모듈이 처리)
|
|
$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(
|
|
__($messageKey, ['count' => number_format($response['total'])]),
|
|
$response
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 빈 검색 결과 응답 구조 생성
|
|
*
|
|
* @return array 빈 결과 응답
|
|
*/
|
|
private function buildEmptyResponse(): array
|
|
{
|
|
return [
|
|
'q' => '',
|
|
'total' => 0,
|
|
'total_relation' => TotalRelation::Exact->value,
|
|
'total_is_exact' => true,
|
|
'result_cap' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Hook 결과를 프론트엔드용 응답 구조로 변환
|
|
*
|
|
* 코어는 기본 응답 구조만 생성하고,
|
|
* 각 모듈이 core.search.build_response 훅을 통해 자신의 카테고리 응답을 구성합니다.
|
|
*
|
|
* @param string $q 검색어
|
|
* @param array $results Hook에서 반환된 검색 결과
|
|
* @param array $context 검색 컨텍스트
|
|
* @return array 프론트엔드용 응답 구조
|
|
*/
|
|
private function buildResponse(string $q, array $results, array $context): array
|
|
{
|
|
$response = [
|
|
'q' => $q,
|
|
'total' => $this->calculateTotal($results),
|
|
];
|
|
|
|
// 각 모듈이 자신의 카테고리 응답 구조를 추가할 수 있도록 훅 제공
|
|
// 모듈은 $results에서 자신의 데이터를 가져와 $response에 추가
|
|
$response = HookManager::applyFilters('core.search.build_response', $response, $results, $context);
|
|
|
|
// 전체 합계를 항상 보존 (탭 UI에서 전체 건수 표시용).
|
|
// "전체" 탭 배지도 잘린 합계인지 알아야 하므로 정확도를 함께 붙인다.
|
|
$response['all_count'] = $response['total'];
|
|
$response['all_count_is_exact'] = $this->resolveTotalAccuracy($results)['total_is_exact'];
|
|
|
|
// 탭 배지는 카테고리마다 하나씩 그려지므로 정확도도 카테고리마다 필요하다.
|
|
// 모듈이 각자 자기 키를 내보내면 어떤 배지는 정확도를 받고 어떤 배지는 못 받아,
|
|
// 상한에 걸린 숫자가 그 배지에서만 정확한 값처럼 보인다. 코어가 일괄로 붙인다.
|
|
$response['counts_are_exact'] = $this->resolveCategoryAccuracy($results);
|
|
|
|
// 카테고리 검색 실패도 같은 이유로 코어가 일괄 조립한다 — 화면은 이 키로
|
|
// "검색 결과 없음" 과 "검색 중 오류" 를 구분해 그린다. 모듈별 복사에 맡기면
|
|
// 빠지는 카테고리가 생겨 그 카테고리의 실패만 0건으로 위장된다.
|
|
$response['categories_failed'] = $this->resolveCategoryFailures($results);
|
|
$response['search_failed'] = in_array(true, $response['categories_failed'], true);
|
|
|
|
// 특정 탭 조회 시 total을 해당 탭의 count로 설정
|
|
$type = $context['type'] ?? 'all';
|
|
if ($type !== 'all') {
|
|
$countKey = $type.'_count';
|
|
if (isset($response[$countKey])) {
|
|
$response['total'] = $response[$countKey];
|
|
}
|
|
}
|
|
|
|
// 정확도 메타 — 전체 탭은 카테고리 합집합, 특정 탭은 그 카테고리 하나만 본다.
|
|
return array_merge($response, $this->resolveTotalAccuracy($results, $type));
|
|
}
|
|
|
|
/**
|
|
* 전체 검색 결과 수 계산
|
|
*
|
|
* @param array $results Hook에서 반환된 검색 결과
|
|
* @return int 전체 결과 수
|
|
*/
|
|
private function calculateTotal(array $results): int
|
|
{
|
|
$total = 0;
|
|
foreach ($results as $categoryData) {
|
|
$total += $categoryData['total'] ?? 0;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 검색 실패 여부를 모읍니다.
|
|
*
|
|
* 실패 카테고리 페이로드(`SearchCategoryPayload::failed()`)의 `failed` 플래그를
|
|
* 카테고리 전수에 대해 수집합니다. 키가 없는 카테고리는 실패하지 않은 것으로
|
|
* 봅니다 — 구버전 모듈이 플래그를 싣지 않아도 종전 렌더가 유지되어야 합니다.
|
|
*
|
|
* @param array $results Hook에서 반환된 검색 결과
|
|
* @return array<string, bool> 카테고리 => 실패 여부
|
|
*/
|
|
private function resolveCategoryFailures(array $results): array
|
|
{
|
|
$failures = [];
|
|
|
|
foreach ($results as $category => $categoryData) {
|
|
$failures[$category] = ($categoryData['failed'] ?? false) === true;
|
|
}
|
|
|
|
return $failures;
|
|
}
|
|
|
|
/**
|
|
* 응답 전체의 총 건수 정확도를 정합니다.
|
|
*
|
|
* 전체 탭에서는 카테고리 중 하나라도 상한에 걸리면 합계도 "이상" 이다 — 정확한
|
|
* 카테고리 몇 개를 더해 봐야 전체가 정확해지지 않는다.
|
|
*
|
|
* 특정 탭을 보는 중이면 `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'),
|
|
];
|
|
}
|
|
}
|