Files
Gnuboard7/app/Seo/DataSourceResolver.php
HeuJung 0dfb45951b fix(core,basic): 검색 봇 화면 렌더러 패리티 결손 일괄 수정
일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇
화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다
(공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다.

- 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속
 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구
- 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화.
 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다
- 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 /
 children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태
- 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로,
 비교식이 raw 경로에서 null 이 되던 문제
- 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강
- 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝

지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과
레이아웃 전수 순회 테스트로 잠갔다.
2026-07-27 10:03:06 +09:00

173 lines
5.9 KiB
PHP

<?php
namespace App\Seo;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DataSourceResolver
{
/**
* 레이아웃의 data_sources를 서버에서 호출하여 데이터를 로드합니다.
*
* @param array $dataSources 전체 data_sources 배열
* @param array $seoDataSourceIds SEO에 필요한 data_source ID 목록
* @param array $routeParams 라우트 파라미터 (예: ['id' => '123', 'slug' => 'clothes'])
* @param string $locale 로케일
* @param array $queryParams URL 쿼리 파라미터 (예: ['page' => '2', 'sort' => 'latest'])
* @param array $extraContext 엔드포인트/params 표현식 평가에 사용할 추가 컨텍스트 (_global 등)
* @return array data_source ID → 응답 데이터 매핑
*/
public function resolve(array $dataSources, array $seoDataSourceIds, array $routeParams, string $locale, array $queryParams = [], array $extraContext = []): array
{
$context = [];
$evalContext = array_merge($extraContext, [
'route' => array_merge($extraContext['route'] ?? [], $routeParams),
'query' => $queryParams,
]);
foreach ($dataSources as $ds) {
$dsId = $ds['id'] ?? '';
// seo.data_sources에 명시된 ID만 호출
if (! in_array($dsId, $seoDataSourceIds)) {
continue;
}
$endpoint = $ds['endpoint'] ?? '';
if ($endpoint === '') {
continue;
}
// 엔드포인트 표현식 치환 ({{route.id}}, {{query.q}}, {{_global.xxx}})
$endpoint = $this->resolveEndpoint($endpoint, $evalContext);
// params 해석 ({{query.xxx}}, {{route.xxx}} 표현식 치환)
$resolvedParams = $this->resolveParams($ds['params'] ?? [], $routeParams, $queryParams, $extraContext);
// API 호출
$data = $this->callApi($endpoint, $ds, $locale, $resolvedParams);
$context[$dsId] = $data;
}
return $context;
}
/**
* 엔드포인트의 표현식을 치환합니다.
*
* `{{route.xxx}}` 뿐 아니라 `{{query.xxx}}` / `{{_global.xxx}}` 등 컨텍스트 표현식을
* 모두 해석합니다 — 검색 결과처럼 엔드포인트 문자열 안에서 쿼리·전역 상태를 보간하는
* 데이터소스가 있기 때문입니다. 미해석 시 표현식이 URL에 그대로 남아 호출이 실패합니다.
*
* @param string $endpoint 원본 엔드포인트
* @param array $evalContext 평가 컨텍스트 (route/query/_global 등)
* @return string 치환된 엔드포인트
*/
private function resolveEndpoint(string $endpoint, array $evalContext): string
{
if (! str_contains($endpoint, '{{')) {
return $endpoint;
}
return app(ExpressionEvaluator::class)->evaluate($endpoint, $evalContext);
}
/**
* 데이터소스 params의 표현식을 해석합니다.
*
* {{query.xxx}} → 쿼리 파라미터, {{route.xxx}} → 라우트 파라미터,
* 숫자/문자열 리터럴 → 그대로 유지합니다.
*
* @param array $params 원본 params 정의
* @param array $routeParams 라우트 파라미터
* @param array $queryParams 쿼리 파라미터
* @param array $extraContext 추가 평가 컨텍스트 (_global 등)
* @return array 해석된 params (키 → 값)
*/
private function resolveParams(array $params, array $routeParams, array $queryParams, array $extraContext = []): array
{
if (empty($params)) {
return [];
}
$resolved = [];
$evalContext = array_merge($extraContext, [
'route' => $routeParams,
'query' => $queryParams,
]);
$evaluator = app(ExpressionEvaluator::class);
foreach ($params as $key => $value) {
if (is_numeric($value)) {
$resolved[$key] = $value;
continue;
}
$resolvedValue = $evaluator->evaluate((string) $value, $evalContext);
// 빈 문자열은 전달하지 않음 (선택적 파라미터)
if ($resolvedValue !== '') {
$resolved[$key] = $resolvedValue;
}
}
return $resolved;
}
/**
* 내부 API를 호출합니다.
*
* @param string $endpoint API 엔드포인트
* @param array $ds data_source 정의
* @param string $locale 로케일
* @param array $resolvedParams 해석된 쿼리 파라미터
* @return array 응답 데이터
*/
private function callApi(string $endpoint, array $ds, string $locale, array $resolvedParams = []): array
{
$method = strtolower($ds['method'] ?? 'GET');
$fallback = $ds['fallback'] ?? [];
$timeout = 5;
try {
$fullUrl = url($endpoint);
$request = Http::timeout($timeout)
->withoutVerifying()
->withHeaders([
'Accept' => 'application/json',
'Accept-Language' => $locale,
]);
// GET 요청 시 쿼리 파라미터 전달
if ($method === 'get' && ! empty($resolvedParams)) {
$fullUrl .= '?'.http_build_query($resolvedParams);
}
$response = $request->$method($fullUrl);
if ($response->successful()) {
return $response->json() ?? $fallback;
}
Log::warning('[SEO] DataSource API failed', [
'endpoint' => $endpoint,
'status' => $response->status(),
]);
return $fallback;
} catch (\Throwable $e) {
Log::warning('[SEO] DataSource API error', [
'endpoint' => $endpoint,
'error' => $e->getMessage(),
]);
return $fallback;
}
}
}