일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
173 lines
5.9 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|