- 버전 이력 변경량(+N/-N 줄) 계산이 변경 영역 (줄 수)² 크기의 LCS 표를 만들어 큰 공통 레이아웃의 첫 편집기 저장이 PHP 기본 메모리 한도(128M) 서버에서 500 으로 끝났다. 두 행 DP 로 길이만 구하도록 바꿔 메모리가 줄 수에 비례하고 표시 숫자는 종전과 같다(참조 구현 동치·메모리 상한 회귀 테스트) - 확장 편집 모드 저장이 overlay 확장의 injections 를 비우던 결함: 서버가 주입 노드 출처 메타에 injection 순번을 싣고, 편집기는 그 순번(없으면 원본 노드 id) 으로 되돌리며, 되돌릴 수 없는 노드가 있으면 저장하지 않고 안내한다 - 서빙 캐시 키를 서버 현재 확장 캐시 버전으로만 조립해 저장·복원 두 번째부터 재로드·「최신 불러오기」가 옛 내용을 받던 결함 수정(요청 v 는 HTTP 캐시 우회용) - 409 충돌 안내가 errors 아래의 버전을 읽지 못해 「최신 버전: -1」 로 표시되던 결함을 세 저장 경로 공용 판독으로 수정 - 버전 저장 시 저장자를 기록하고, 복원 시에도 잠금 번호를 올린다 - 회귀 테스트(PHPUnit·Vitest), 트러블슈팅 사례 32~34, 규정·API 문서, ja 언어팩, 편집기 번들 재빌드, 이력 문서 동반
2310 lines
96 KiB
PHP
2310 lines
96 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Contracts\Repositories\LayoutExtensionRepositoryInterface;
|
|
use App\Contracts\Repositories\LayoutExtensionVersionRepositoryInterface;
|
|
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
|
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
|
use App\Contracts\Repositories\PluginRepositoryInterface;
|
|
use App\Enums\LayoutExtensionType;
|
|
use App\Enums\LayoutSourceType;
|
|
use App\Exceptions\ConcurrentModificationException;
|
|
use App\Extension\HookManager;
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Extension\TemplateManager;
|
|
use App\Extension\Traits\ClearsTemplateCaches;
|
|
use App\Extension\Traits\ComputesLayoutContentHash;
|
|
use App\Models\LayoutExtension;
|
|
use App\Models\TemplateLayoutExtensionVersion;
|
|
use Composer\Semver\Semver;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 레이아웃 확장 서비스
|
|
*
|
|
* 모듈/플러그인이 기존 레이아웃에 UI를 동적으로 주입하는 기능을 처리합니다.
|
|
*/
|
|
class LayoutExtensionService
|
|
{
|
|
use ClearsTemplateCaches;
|
|
use ComputesLayoutContentHash;
|
|
|
|
/**
|
|
* 활성화된 모듈 식별자 목록 (캐시)
|
|
*
|
|
* @var array<string>|null
|
|
*/
|
|
private ?array $activeModules = null;
|
|
|
|
/**
|
|
* 활성화된 플러그인 식별자 목록 (캐시)
|
|
*
|
|
* @var array<string>|null
|
|
*/
|
|
private ?array $activePlugins = null;
|
|
|
|
/**
|
|
* 버전 비호환 오버라이드 목록
|
|
*
|
|
* @var array<array{extension_id: int, source: string, target: string, constraint: string, current_version: string}>
|
|
*/
|
|
private array $incompatibleOverrides = [];
|
|
|
|
/**
|
|
* 확장 표시명 해석 캐시 (요청 단위) — 동일 식별자 반복 조회 회피.
|
|
*
|
|
* @var array<string, string|null>
|
|
*/
|
|
private array $extensionNameCache = [];
|
|
|
|
/**
|
|
* 미리보기 확장 content 오버라이드
|
|
*
|
|
* applyExtensions 실행 시, 이 키에 해당하는 extension_id 의 확장은
|
|
* DB content 대신 여기 저장된 content 로 치환됩니다.
|
|
* 레이아웃 확장 미리보기에서 편집 중인 content 를 임시 적용할 때 사용합니다.
|
|
*
|
|
* @var array<int, array>
|
|
*/
|
|
private array $previewContentOverrides = [];
|
|
|
|
/**
|
|
* 미리보기 확장 content 오버라이드를 설정합니다.
|
|
*
|
|
* @param int $extensionId 확장 ID
|
|
* @param array $content 치환할 확장 정의 JSON
|
|
*/
|
|
public function setPreviewContentOverride(int $extensionId, array $content): void
|
|
{
|
|
$this->previewContentOverrides[$extensionId] = $content;
|
|
}
|
|
|
|
/**
|
|
* 미리보기 확장 content 오버라이드를 모두 제거합니다.
|
|
*/
|
|
public function clearPreviewContentOverrides(): void
|
|
{
|
|
$this->previewContentOverrides = [];
|
|
}
|
|
|
|
/**
|
|
* 확장 컬렉션에 미리보기 content 오버라이드를 적용합니다.
|
|
*
|
|
* @param Collection<int, LayoutExtension> $extensions 확장 컬렉션
|
|
* @return Collection<int, LayoutExtension>
|
|
*/
|
|
private function applyPreviewOverrides(Collection $extensions): Collection
|
|
{
|
|
if (empty($this->previewContentOverrides)) {
|
|
return $extensions;
|
|
}
|
|
|
|
foreach ($extensions as $extension) {
|
|
if (isset($this->previewContentOverrides[$extension->id])) {
|
|
$extension->content = $this->previewContentOverrides[$extension->id];
|
|
}
|
|
}
|
|
|
|
return $extensions;
|
|
}
|
|
|
|
/**
|
|
* 생성자
|
|
*
|
|
* 순환 의존성 방지를 위해 ModuleManager, PluginManager는 생성자에서 주입받지 않고
|
|
* 필요한 시점에 app() 헬퍼로 가져옵니다.
|
|
*/
|
|
public function __construct(
|
|
private LayoutExtensionRepositoryInterface $repository,
|
|
private LayoutExtensionVersionRepositoryInterface $versionRepository,
|
|
private LayoutRepositoryInterface $layoutRepository,
|
|
private PluginRepositoryInterface $pluginRepository,
|
|
private ModuleRepositoryInterface $moduleRepository,
|
|
) {}
|
|
|
|
/**
|
|
* 레이아웃에 확장 적용
|
|
*
|
|
* @param array $layout 원본 레이아웃
|
|
* @param int $templateId 템플릿 ID
|
|
* @param bool $withSourceMeta 편집 모드 출처 메타 부여 여부
|
|
* @return array 확장이 적용된 레이아웃
|
|
*/
|
|
public function applyExtensions(array $layout, int $templateId, bool $withSourceMeta = false): array
|
|
{
|
|
$layoutName = $layout['layout_name'] ?? '';
|
|
|
|
// overlay 매칭 대상 = 서빙 레이아웃명 + 상속 체인(base 레이아웃들).
|
|
// home(extends _user_base) 같은 자식 서빙 시, base(_user_base)를 target_layout 으로 하는
|
|
// overlay(헤더 통화 슬롯 주입 등)도 매칭되도록 LayoutService 가 부여한 __extends_chain 을
|
|
// 포함한다. Extension Point 는 병합 트리를 스캔하므로(체인 무관) 영향 없다.
|
|
$extendsChain = is_array($layout['__extends_chain'] ?? null) ? $layout['__extends_chain'] : [];
|
|
$overlayTargetNames = array_values(array_unique(array_filter(
|
|
array_merge([$layoutName], $extendsChain),
|
|
fn ($name) => is_string($name) && $name !== ''
|
|
)));
|
|
|
|
// 비호환 오버라이드 목록 초기화
|
|
$this->incompatibleOverrides = [];
|
|
|
|
// 활성화된 모듈/플러그인 목록 캐시 초기화
|
|
$this->initializeActiveExtensions();
|
|
|
|
// 훅: 확장 적용 전
|
|
HookManager::doAction('core.layout_extension.before_apply', $layout, $templateId);
|
|
|
|
// scripts 배열 초기화 (기존 scripts 유지)
|
|
$scripts = $layout['scripts'] ?? [];
|
|
|
|
// 1. Extension Point 처리
|
|
$layout = $this->applyExtensionPoints($layout, $templateId, $scripts, $withSourceMeta);
|
|
|
|
// 2. Overlay 처리
|
|
$modals = $layout['modals'] ?? [];
|
|
$layout = $this->applyOverlays($layout, $templateId, $overlayTargetNames, $scripts, $modals, $withSourceMeta);
|
|
if (! empty($modals)) {
|
|
$layout['modals'] = $modals;
|
|
}
|
|
|
|
// 3. scripts 병합 결과 적용
|
|
if (! empty($scripts)) {
|
|
$layout['scripts'] = $scripts;
|
|
}
|
|
|
|
// 4. 비호환 오버라이드 경고를 warnings 필드에 추가
|
|
if (! empty($this->incompatibleOverrides)) {
|
|
$layout['warnings'] = $this->formatWarningsForFrontend();
|
|
}
|
|
|
|
// 상속 체인 매칭 메타는 내부 전용 — 최종 응답(일반 렌더 + 편집 모드 둘 다)에서 제거한다.
|
|
unset($layout['__extends_chain']);
|
|
|
|
// 훅: 확장 적용 후
|
|
$layout = HookManager::applyFilters('core.layout_extension.after_apply', $layout, $templateId);
|
|
|
|
return $layout;
|
|
}
|
|
|
|
/**
|
|
* 확장의 호스트 레이아웃 후보 목록을 반환합니다.
|
|
*
|
|
* 확장 편집 모드 캔버스가 "호스트 레이아웃 전체를 렌더하고 그 안에서 확장 조각만 편집"
|
|
* 하기 위한 대상 레이아웃. overlay 는 `content.target_layout` 1개로 확정되며,
|
|
* extension_point 는 그 확장점을 포함하는 레이아웃이 여럿일 수 있어 모두 반환한다
|
|
* (클라이언트가 1개면 즉시 진입, 복수면 대표 호스트 선택 picker 를 띄운다).
|
|
*
|
|
* @param LayoutExtension $extension 확장 모델
|
|
* @return array<int, string> 호스트 레이아웃 이름 목록
|
|
*/
|
|
public function getExtensionHostLayouts(LayoutExtension $extension): array
|
|
{
|
|
$content = is_array($extension->content) ? $extension->content : [];
|
|
|
|
// 판별 기준: 호스트 인정 = "그 호스트에 주입이
|
|
// **성립**하는가" 수준만 백엔드가 판정한다. 주입이 성립하면 트리에 노출하되, 시각 편집
|
|
// 가능 여부의 정밀 판정(게이트/모달 뒤 노출, 조각 0건)은 프론트 진입 시
|
|
// editability(useExtensionDocument 1단계) + 렌더 검증(PreviewCanvas 2단계) + 폴백 안내로
|
|
// 일원화한다(숨김이 아니라 디그레이드). 종전 "components 유무" 게이트는 부정확
|
|
// (components 가 있어도 게이트 뒤면 미노출, modals 주입도 편집기 모달 표시 메커니즘으로
|
|
// 노출 가능)하여 폐기.
|
|
if ($extension->extension_type === LayoutExtensionType::Overlay) {
|
|
$target = $content['target_layout'] ?? null;
|
|
if (! is_string($target) || $target === '') {
|
|
return [];
|
|
}
|
|
|
|
// overlay 주입 성립 조건 (하나라도 충족 시 호스트 인정):
|
|
// (a) target_id 가 호스트에 실재하는 injection 존재 — components 주입 또는
|
|
// inject_props(속성 병합 — 속성 모달에서 편집). target_id 부재 injection
|
|
// 은 applyExtensions no-op 이므로 제외.
|
|
// (b) content.modals / data_sources / scripts 주입 — 호스트에 무언가 병합됨.
|
|
// 시각 조각이 없으면 프론트가 'no-injection' 디그레이드 안내(코드 편집 유도)로
|
|
// 처리한다(완전 빈 content 만 백엔드가 제외).
|
|
$injections = is_array($content['injections'] ?? null) ? $content['injections'] : [];
|
|
foreach ($injections as $inj) {
|
|
if (! is_array($inj)) {
|
|
continue;
|
|
}
|
|
$tid = $inj['target_id'] ?? null;
|
|
if (! is_string($tid) || $tid === '') {
|
|
continue;
|
|
}
|
|
if ($this->layoutRepository->layoutContainsNodeId($extension->template_id, $target, $tid)) {
|
|
return [$target];
|
|
}
|
|
}
|
|
|
|
// initActions(표준 camelCase) 와 init_actions(deprecated snake) 둘 다 부수 주입으로 인정.
|
|
foreach (['modals', 'data_sources', 'scripts', 'initActions', 'init_actions'] as $key) {
|
|
if (is_array($content[$key] ?? null) && $content[$key] !== []) {
|
|
return [$target];
|
|
}
|
|
}
|
|
|
|
// 실재 target injection 도, 부수 주입(modals/ds/scripts/initActions)도 없음 — 주입 불성립.
|
|
return [];
|
|
}
|
|
|
|
// extension_point — 그 확장점을 포함하는 레이아웃 전체. 주입 성립 조건: content 에
|
|
// 병합할 무언가(components/modals/data_sources/scripts)가 있어야 한다. components 가
|
|
// 없어도(예: 토스페이먼츠 — modals 만) 트리에는 노출하고, 클릭 시 프론트가 시각 조각
|
|
// 유무를 판정해 디그레이드 안내한다.
|
|
$hasAnyPayload = false;
|
|
foreach (['components', 'modals', 'data_sources', 'scripts', 'initActions', 'init_actions'] as $key) {
|
|
if (is_array($content[$key] ?? null) && $content[$key] !== []) {
|
|
$hasAnyPayload = true;
|
|
break;
|
|
}
|
|
}
|
|
if (! $hasAnyPayload) {
|
|
return [];
|
|
}
|
|
|
|
$extensionPoint = $content['extension_point'] ?? $extension->target_name;
|
|
if (! is_string($extensionPoint) || $extensionPoint === '') {
|
|
return [];
|
|
}
|
|
|
|
return $this->layoutRepository->findLayoutNamesWithExtensionPoint(
|
|
$extension->template_id,
|
|
$extensionPoint
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 트리에 확장 출처 메타를 재귀적으로 부여합니다.
|
|
*
|
|
* 확장이 주입한 노드와 그 자식 모두에 `__source.kind = 'extension'` + `extensionId` 를
|
|
* 부여하여 편집기 잠금 판정 이 가능하게 합니다.
|
|
*
|
|
* `$extension` 이 전달되면 출처 타입/식별자/표시명(`extensionSourceType`/
|
|
* `extensionIdentifier`/`extensionName`)도 함께 부여하여, 편집기 오버레이가
|
|
* "어느 확장인지"를 로케일 표시명 + 식별자로 표시할 수 있게 합니다.
|
|
*
|
|
* 출처 메타는 확장 1건당 한 번만 계산(표시명 조회 포함)하여 트리 전체에 전파한다.
|
|
*
|
|
* @param array $components 주입된 컴포넌트 배열
|
|
* @param int $extensionId 확장 PK
|
|
* @param LayoutExtension|null $extension 출처 라벨 부여용 확장 모델
|
|
* @param int|null $injectionIndex overlay `injections[]` 순번 — 편집기가 저장 시 노드를 원래 injection 으로 되돌리는 열쇠. extension_point 주입은 null
|
|
* @return array 메타가 부여된 컴포넌트 배열
|
|
*
|
|
* @since engine-v1.50.0
|
|
*/
|
|
private function markExtensionSource(array $components, int $extensionId, ?LayoutExtension $extension = null, ?int $injectionIndex = null): array
|
|
{
|
|
$meta = $this->buildExtensionSourceMeta($extension, $extensionId);
|
|
if ($injectionIndex !== null) {
|
|
$meta['injectionIndex'] = $injectionIndex;
|
|
}
|
|
|
|
return $this->applySourceMetaRecursively($components, $meta);
|
|
}
|
|
|
|
/**
|
|
* 확장 출처 `__source` 메타를 구성합니다 (컴포넌트·data_source 공용).
|
|
*
|
|
* `kind = 'extension'` + 출처 타입(module/plugin)/식별자/표시명. 편집기가 "어느 확장이
|
|
* 주입했는지"를 모듈/플러그인 + 식별자 + 로케일 표시명으로 표시하는 데 쓴다.
|
|
*
|
|
* @param LayoutExtension|null $extension 출처 확장 모델
|
|
* @param int|null $extensionId 확장 PK (미지정 시 `$extension->id`)
|
|
* @return array<string, mixed> `__source` 메타
|
|
*
|
|
* @since engine-v1.50.0
|
|
*/
|
|
private function buildExtensionSourceMeta(?LayoutExtension $extension, ?int $extensionId = null): array
|
|
{
|
|
$sourceMeta = ['kind' => 'extension', 'extensionId' => $extensionId ?? $extension?->id];
|
|
|
|
if ($extension !== null) {
|
|
$sourceMeta['extensionSourceType'] = $extension->source_type->value;
|
|
$sourceMeta['extensionIdentifier'] = $extension->source_identifier;
|
|
$name = $this->resolveExtensionDisplayName($extension);
|
|
if ($name !== null) {
|
|
$sourceMeta['extensionName'] = $name;
|
|
}
|
|
}
|
|
|
|
return $sourceMeta;
|
|
}
|
|
|
|
/**
|
|
* 사전 계산된 출처 메타를 컴포넌트 트리에 재귀 부여합니다.
|
|
*
|
|
* @param array $components 컴포넌트 배열
|
|
* @param array $sourceMeta 부여할 `__source` 메타
|
|
* @return array 메타가 부여된 컴포넌트 배열
|
|
*/
|
|
private function applySourceMetaRecursively(array $components, array $sourceMeta): array
|
|
{
|
|
$result = [];
|
|
|
|
foreach ($components as $component) {
|
|
if (! is_array($component)) {
|
|
$result[] = $component;
|
|
|
|
continue;
|
|
}
|
|
|
|
$resultComponent = $component;
|
|
$resultComponent['__source'] = $sourceMeta;
|
|
|
|
if (isset($component['children']) && is_array($component['children'])) {
|
|
$resultComponent['children'] = $this->applySourceMetaRecursively($component['children'], $sourceMeta);
|
|
}
|
|
|
|
$result[] = $resultComponent;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 확장의 현재 로케일 표시명을 해석합니다(편집기 오버레이 라벨용).
|
|
*
|
|
* 모듈/플러그인은 매니저의 다국어 표시명을, 템플릿 오버라이드는 오버라이드 대상
|
|
* 모듈/플러그인의 표시명을 사용합니다. 해석 실패 시 null(식별자만 표시).
|
|
* 동일 식별자 반복 조회를 피하기 위해 요청 단위로 캐시합니다.
|
|
*
|
|
* @param LayoutExtension $extension 확장 모델
|
|
* @return string|null 로케일 표시명 (해석 불가 시 null)
|
|
*/
|
|
private function resolveExtensionDisplayName(LayoutExtension $extension): ?string
|
|
{
|
|
// 템플릿 오버라이드는 오버라이드 대상(모듈/플러그인)이 실제 확장 출처.
|
|
$sourceType = $extension->source_type;
|
|
$identifier = $extension->override_target ?: $extension->source_identifier;
|
|
|
|
if ($identifier === '' || $identifier === null) {
|
|
return null;
|
|
}
|
|
|
|
$cacheKey = $sourceType->value.':'.$identifier;
|
|
if (array_key_exists($cacheKey, $this->extensionNameCache)) {
|
|
return $this->extensionNameCache[$cacheKey];
|
|
}
|
|
|
|
$name = $this->lookupExtensionDisplayName($sourceType, $identifier);
|
|
$this->extensionNameCache[$cacheKey] = $name;
|
|
|
|
return $name;
|
|
}
|
|
|
|
/**
|
|
* 매니저에서 모듈/플러그인 표시명을 조회합니다.
|
|
*
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 확장 식별자
|
|
* @return string|null 표시명 (없으면 null)
|
|
*/
|
|
private function lookupExtensionDisplayName(LayoutSourceType $sourceType, string $identifier): ?string
|
|
{
|
|
// 템플릿 자체 확장(override_target 없음)은 모듈/플러그인 어느 쪽도 아니므로
|
|
// 식별자만 노출(표시명 없음).
|
|
$info = match ($sourceType) {
|
|
LayoutSourceType::Module => app(ModuleManager::class)->getModuleInfo($identifier),
|
|
LayoutSourceType::Plugin => app(PluginManager::class)->getPluginInfo($identifier),
|
|
LayoutSourceType::Template => app(ModuleManager::class)->getModuleInfo($identifier)
|
|
?? app(PluginManager::class)->getPluginInfo($identifier),
|
|
};
|
|
|
|
$name = $info['name'] ?? null;
|
|
|
|
return is_string($name) && $name !== '' ? $name : null;
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모듈/플러그인 목록 초기화
|
|
*
|
|
* 순환 의존성 방지를 위해 app() 헬퍼로 매니저를 가져옵니다.
|
|
*/
|
|
private function initializeActiveExtensions(): void
|
|
{
|
|
if ($this->activeModules === null) {
|
|
$moduleManager = app(ModuleManager::class);
|
|
$this->activeModules = array_keys($moduleManager->getActiveModules());
|
|
}
|
|
|
|
if ($this->activePlugins === null) {
|
|
$pluginManager = app(PluginManager::class);
|
|
$this->activePlugins = array_keys($pluginManager->getActivePlugins());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 확장의 출처가 활성화되어 있는지 확인
|
|
*
|
|
* 템플릿 오버라이드의 경우, 오버라이드 대상 모듈/플러그인이 활성화되어 있는지 확인합니다.
|
|
*
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $sourceIdentifier 출처 식별자
|
|
* @param string|null $overrideTarget 오버라이드 대상 (템플릿 오버라이드인 경우)
|
|
* @return bool 활성화 여부
|
|
*/
|
|
private function isExtensionSourceActive(
|
|
LayoutSourceType $sourceType,
|
|
string $sourceIdentifier,
|
|
?string $overrideTarget = null
|
|
): bool {
|
|
// 템플릿 오버라이드인 경우, 오버라이드 대상의 활성화 여부 확인
|
|
if ($sourceType === LayoutSourceType::Template && $overrideTarget !== null) {
|
|
return $this->isModuleOrPluginActive($overrideTarget);
|
|
}
|
|
|
|
// 모듈/플러그인 직접 확장인 경우
|
|
return match ($sourceType) {
|
|
LayoutSourceType::Module => in_array($sourceIdentifier, $this->activeModules, true),
|
|
LayoutSourceType::Plugin => in_array($sourceIdentifier, $this->activePlugins, true),
|
|
LayoutSourceType::Template => true, // 템플릿 자체 확장은 항상 활성화
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 모듈 또는 플러그인이 활성화되어 있는지 확인
|
|
*
|
|
* 메모리 캐시에 없으면 DB에서 직접 조회합니다.
|
|
* (파일 시스템에 없지만 DB에만 존재하는 경우 대응)
|
|
*
|
|
* @param string $identifier 모듈/플러그인 식별자
|
|
* @return bool 활성화 여부
|
|
*/
|
|
private function isModuleOrPluginActive(string $identifier): bool
|
|
{
|
|
// 먼저 메모리 캐시 확인 (빠른 경로)
|
|
if (in_array($identifier, $this->activeModules, true)
|
|
|| in_array($identifier, $this->activePlugins, true)) {
|
|
return true;
|
|
}
|
|
|
|
// 캐시에 없으면 DB에서 직접 확인
|
|
$moduleManager = app(ModuleManager::class);
|
|
if ($moduleManager->getModuleVersion($identifier) !== null) {
|
|
$module = app(ModuleRepositoryInterface::class)
|
|
->findActiveByIdentifier($identifier);
|
|
if ($module) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$pluginManager = app(PluginManager::class);
|
|
if ($pluginManager->getPluginVersion($identifier) !== null) {
|
|
$plugin = app(PluginRepositoryInterface::class)
|
|
->findActiveByIdentifier($identifier);
|
|
if ($plugin) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function applyExtensionPoints(array $layout, int $templateId, array &$scripts, bool $withSourceMeta = false): array
|
|
{
|
|
if (! isset($layout['components'])) {
|
|
return $layout;
|
|
}
|
|
|
|
$dataSources = $layout['data_sources'] ?? [];
|
|
$modals = $layout['modals'] ?? [];
|
|
|
|
// components 트리를 순회하며 type: extension_point 찾기
|
|
$layout['components'] = $this->processExtensionPointsRecursive(
|
|
$layout['components'],
|
|
$templateId,
|
|
$dataSources,
|
|
$scripts,
|
|
$modals,
|
|
$withSourceMeta
|
|
);
|
|
|
|
// modals 트리도 순회하며 extension_point 처리
|
|
// 모달 내부에 정의된 extension_point에도 플러그인/모듈 컴포넌트가 주입되도록 함
|
|
if (! empty($modals)) {
|
|
$modals = $this->processExtensionPointsRecursive(
|
|
$modals,
|
|
$templateId,
|
|
$dataSources,
|
|
$scripts,
|
|
$modals,
|
|
$withSourceMeta
|
|
);
|
|
}
|
|
|
|
$layout['data_sources'] = $dataSources;
|
|
|
|
if (! empty($modals)) {
|
|
$layout['modals'] = $modals;
|
|
}
|
|
|
|
return $layout;
|
|
}
|
|
|
|
/**
|
|
* 재귀적으로 Extension Point 처리
|
|
*
|
|
* 오버라이드 해석 결과를 사용하여 템플릿 우선순위가 적용된 확장만 주입합니다.
|
|
*
|
|
* @param array $components 컴포넌트 배열
|
|
* @param int $templateId 템플릿 ID
|
|
* @param array &$dataSources 데이터 소스 배열 (참조)
|
|
* @param array &$scripts 스크립트 배열 (참조)
|
|
* @param array &$modals 모달 배열 (참조)
|
|
* @return array 처리된 컴포넌트 배열
|
|
*/
|
|
private function processExtensionPointsRecursive(array $components, int $templateId, array &$dataSources, array &$scripts, array &$modals, bool $withSourceMeta = false): array
|
|
{
|
|
foreach ($components as $index => &$component) {
|
|
// Extension Point 발견
|
|
if (($component['type'] ?? '') === 'extension_point' && isset($component['name'])) {
|
|
$extensionPointName = $component['name'];
|
|
|
|
// 오버라이드를 고려한 확장 조회 (템플릿 > 플러그인 > 모듈)
|
|
$extensions = $this->applyPreviewOverrides(
|
|
$this->repository->getResolvedExtensionPoints($templateId, $extensionPointName)
|
|
);
|
|
|
|
$injectedComponents = $component['default'] ?? [];
|
|
|
|
foreach ($extensions as $extension) {
|
|
// 모듈/플러그인 활성화 상태 확인
|
|
if (! $this->isExtensionSourceActive(
|
|
$extension->source_type,
|
|
$extension->source_identifier,
|
|
$extension->override_target
|
|
)) {
|
|
Log::debug('Extension Point 확장 스킵 (비활성화된 출처)', [
|
|
'extension_point' => $extensionPointName,
|
|
'source_type' => $extension->source_type->value,
|
|
'source_identifier' => $extension->source_identifier,
|
|
'override_target' => $extension->override_target,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 버전 호환성 검사 (템플릿 오버라이드인 경우)
|
|
if (! $this->checkVersionCompatibility($extension)) {
|
|
Log::info('버전 비호환으로 Extension Point 오버라이드 스킵', [
|
|
'extension_id' => $extension->id,
|
|
'source' => $extension->source_identifier,
|
|
'extension_point' => $extensionPointName,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$content = $extension->content;
|
|
$mode = $content['mode'] ?? 'append';
|
|
|
|
// 컴포넌트 추가 (extension_point props/callbacks를 주입 컴포넌트에 전달)
|
|
if (isset($content['components'])) {
|
|
$extensionPointProps = $component['props'] ?? [];
|
|
$extensionPointCallbacks = $component['callbacks'] ?? [];
|
|
$componentsWithProps = array_map(function ($injectedComponent) use ($extensionPointProps, $extensionPointCallbacks) {
|
|
if (! empty($extensionPointProps)) {
|
|
$injectedComponent['extensionPointProps'] = $extensionPointProps;
|
|
}
|
|
if (! empty($extensionPointCallbacks)) {
|
|
$injectedComponent['extensionPointCallbacks'] = $extensionPointCallbacks;
|
|
}
|
|
|
|
return $injectedComponent;
|
|
}, $content['components']);
|
|
|
|
// 편집 모드 출처 메타 부여 — 주입 노드와 그 자식 모두에 extension 메타
|
|
// @since engine-v1.50.0
|
|
if ($withSourceMeta) {
|
|
$componentsWithProps = $this->markExtensionSource($componentsWithProps, $extension->id, $extension);
|
|
}
|
|
|
|
$injectedComponents = match ($mode) {
|
|
'replace' => $componentsWithProps,
|
|
'prepend' => array_merge($componentsWithProps, $injectedComponents),
|
|
default => array_merge($injectedComponents, $componentsWithProps),
|
|
};
|
|
}
|
|
|
|
// 데이터 소스 병합 — 편집 모드에서는 주입한 data_source 에 확장 출처 메타
|
|
// (`__source`)를 부여해, 편집기 데이터 소스 모달이 "어느 확장이 주입했는지"
|
|
// (모듈/플러그인 + 식별자/표시명)를 표시할 수 있게 한다.
|
|
// 일반 렌더는 메타 미부여(운영 화면 영향 0). 컴포넌트 markExtensionSource 와 동형.
|
|
if (isset($content['data_sources']) && is_array($content['data_sources'])) {
|
|
$injectedDataSources = $content['data_sources'];
|
|
if ($withSourceMeta) {
|
|
$dsSourceMeta = $this->buildExtensionSourceMeta($extension);
|
|
$injectedDataSources = array_map(function ($ds) use ($dsSourceMeta) {
|
|
if (is_array($ds) && ! isset($ds['__source'])) {
|
|
$ds['__source'] = $dsSourceMeta;
|
|
}
|
|
|
|
return $ds;
|
|
}, $injectedDataSources);
|
|
}
|
|
$dataSources = array_merge($dataSources, $injectedDataSources);
|
|
}
|
|
|
|
// 스크립트 병합 (중복 제거)
|
|
if (isset($content['scripts']) && is_array($content['scripts'])) {
|
|
$scripts = $this->mergeScripts($scripts, $content['scripts']);
|
|
}
|
|
|
|
// 모달 병합
|
|
if (isset($content['modals']) && is_array($content['modals'])) {
|
|
$modals = array_merge($modals, $content['modals']);
|
|
}
|
|
|
|
Log::debug('Extension Point 확장 적용', [
|
|
'extension_point' => $extensionPointName,
|
|
'source_type' => $extension->source_type->value,
|
|
'source_identifier' => $extension->source_identifier,
|
|
'is_override' => $extension->source_type === LayoutSourceType::Template,
|
|
]);
|
|
}
|
|
|
|
// Extension Point를 컨테이너로 변환하고 주입된 컴포넌트를 children으로 설정
|
|
$component['children'] = $injectedComponents;
|
|
}
|
|
|
|
// 자식 컴포넌트 재귀 처리
|
|
if (isset($component['children']) && is_array($component['children'])) {
|
|
$component['children'] = $this->processExtensionPointsRecursive(
|
|
$component['children'],
|
|
$templateId,
|
|
$dataSources,
|
|
$scripts,
|
|
$modals,
|
|
$withSourceMeta
|
|
);
|
|
}
|
|
}
|
|
|
|
return $components;
|
|
}
|
|
|
|
/**
|
|
* Overlay 기반 컴포넌트 주입
|
|
*
|
|
* 템플릿 오버라이드를 우선 적용하고, 오버라이드되지 않은 모듈/플러그인 확장을 병합합니다.
|
|
*
|
|
* @param array $layout 레이아웃 배열
|
|
* @param int $templateId 템플릿 ID
|
|
* @param array<int, string> $targetNames overlay 매칭 대상 레이아웃명 목록 (서빙 레이아웃 + 상속 체인 base)
|
|
* @param array &$scripts 스크립트 배열 (참조)
|
|
* @param array &$modals 모달 배열 (참조)
|
|
* @return array 확장이 적용된 레이아웃
|
|
*/
|
|
private function applyOverlays(array $layout, int $templateId, array $targetNames, array &$scripts, array &$modals, bool $withSourceMeta = false): array
|
|
{
|
|
if (empty($targetNames) || ! isset($layout['components'])) {
|
|
return $layout;
|
|
}
|
|
|
|
// 로그용 대표 레이아웃명(서빙 레이아웃)
|
|
$layoutName = $targetNames[0];
|
|
|
|
// 버전 호환성을 고려한 오버레이 조회 — 서빙 레이아웃 + 상속 체인 base 전체에서 수집.
|
|
// 각 target 별 getVersionAwareOverlays 결과(템플릿 오버라이드 해석 포함)를 id 기준으로
|
|
// 합집합한다. 같은 overlay 가 두 target 에 중복 매칭되는 일은 없지만(각 행은 단일
|
|
// target_name), priority 정렬을 위해 합친 뒤 재정렬한다.
|
|
$overlays = collect($targetNames)
|
|
->flatMap(fn ($name) => $this->getVersionAwareOverlays($templateId, $name)->all())
|
|
->unique(fn ($overlay) => $overlay->id)
|
|
->sortBy('priority')
|
|
->values();
|
|
|
|
if ($overlays->isEmpty()) {
|
|
return $layout;
|
|
}
|
|
|
|
$dataSources = $layout['data_sources'] ?? [];
|
|
|
|
foreach ($overlays as $overlay) {
|
|
// 모듈/플러그인 활성화 상태 확인
|
|
if (! $this->isExtensionSourceActive(
|
|
$overlay->source_type,
|
|
$overlay->source_identifier,
|
|
$overlay->override_target
|
|
)) {
|
|
Log::debug('Overlay 확장 스킵 (비활성화된 출처)', [
|
|
'layout' => $layoutName,
|
|
'source_type' => $overlay->source_type->value,
|
|
'source_identifier' => $overlay->source_identifier,
|
|
'override_target' => $overlay->override_target,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$content = $overlay->content;
|
|
$injections = $content['injections'] ?? [];
|
|
|
|
Log::debug('Overlay 확장 적용', [
|
|
'layout' => $layoutName,
|
|
'source_type' => $overlay->source_type->value,
|
|
'source_identifier' => $overlay->source_identifier,
|
|
'is_override' => $overlay->source_type === LayoutSourceType::Template,
|
|
'injection_count' => count($injections),
|
|
]);
|
|
|
|
// injection 순번을 함께 순회한다 — 편집기 확장 편집 모드가 호스트 병합 트리에서 이 확장의
|
|
// 노드를 추출해 `injections[].components` 로 되돌릴 때 어느 injection 인지 알 수 있어야
|
|
// 한다. 순번이 메타에 없으면 재조립이 모든 노드를 버려 저장본의 injections 가 통째로
|
|
// 비워진다(무변경 저장으로도 발생, 예외·경고 없음).
|
|
foreach ($injections as $injectionIndex => $injection) {
|
|
$targetId = $injection['target_id'] ?? null;
|
|
$position = $injection['position'] ?? 'append_child';
|
|
|
|
if (! $targetId) {
|
|
continue;
|
|
}
|
|
|
|
if ($position === 'inject_props') {
|
|
// Props 주입: components 대신 props 필드 사용
|
|
$propsToInject = $injection['props'] ?? [];
|
|
if (empty($propsToInject)) {
|
|
continue;
|
|
}
|
|
|
|
// 편집 모드 출처 메타 — inject_props 대상 호스트 노드에
|
|
// `__injectedProps` 메타를 기록해 편집기 속성 모달이 "어느 확장이 이 props 를
|
|
// 주입했는지"를 출처 섹션으로 보여주고 교차 저장(확장 행 PUT)할 수 있게 한다.
|
|
$injectMeta = $withSourceMeta
|
|
? $this->buildInjectedPropsMeta($overlay, $propsToInject)
|
|
: null;
|
|
|
|
$injected = $this->injectPropsAtTarget(
|
|
$layout['components'],
|
|
$targetId,
|
|
$propsToInject,
|
|
$injectMeta
|
|
);
|
|
} else {
|
|
// 기존 컴포넌트 주입 로직
|
|
$components = $injection['components'] ?? [];
|
|
if (empty($components)) {
|
|
continue;
|
|
}
|
|
|
|
// 편집 모드 출처 메타 부여 — 주입 노드와 그 자식 모두에 extension 메타
|
|
// @since engine-v1.50.0
|
|
if ($withSourceMeta) {
|
|
$components = $this->markExtensionSource(
|
|
$components,
|
|
$overlay->id,
|
|
$overlay,
|
|
is_int($injectionIndex) ? $injectionIndex : null
|
|
);
|
|
}
|
|
|
|
$injected = $this->injectAtTarget(
|
|
$layout['components'],
|
|
$targetId,
|
|
$position,
|
|
$components
|
|
);
|
|
}
|
|
|
|
if (! $injected) {
|
|
Log::warning('Layout extension target not found', [
|
|
'layout' => $layoutName,
|
|
'target_id' => $targetId,
|
|
'position' => $position,
|
|
'source' => $overlay->source_identifier,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 데이터 소스 병합 — 편집 모드에서는 확장 출처 메타(`__source`) 부여.
|
|
if (isset($content['data_sources']) && is_array($content['data_sources'])) {
|
|
$injectedDataSources = $content['data_sources'];
|
|
if ($withSourceMeta) {
|
|
$dsSourceMeta = $this->buildExtensionSourceMeta($overlay);
|
|
$injectedDataSources = array_map(function ($ds) use ($dsSourceMeta) {
|
|
if (is_array($ds) && ! isset($ds['__source'])) {
|
|
$ds['__source'] = $dsSourceMeta;
|
|
}
|
|
|
|
return $ds;
|
|
}, $injectedDataSources);
|
|
}
|
|
$dataSources = array_merge($dataSources, $injectedDataSources);
|
|
}
|
|
|
|
// 스크립트 병합 (중복 제거)
|
|
if (isset($content['scripts']) && is_array($content['scripts'])) {
|
|
$scripts = $this->mergeScripts($scripts, $content['scripts']);
|
|
}
|
|
|
|
// computed 병합
|
|
if (isset($content['computed']) && is_array($content['computed'])) {
|
|
$layout['computed'] = array_merge($layout['computed'] ?? [], $content['computed']);
|
|
}
|
|
|
|
// state 병합
|
|
if (isset($content['state']) && is_array($content['state'])) {
|
|
$layout['state'] = array_merge($layout['state'] ?? [], $content['state']);
|
|
}
|
|
|
|
// initActions 병합 — 호스트 initActions 뒤에 확장 init 단계를 추가한다.
|
|
// 확장이 _global 초기화(예: 모듈별 표시 통화 복원)를 기여할 수 있게 한다.
|
|
// 호스트는 추가 단계의 존재/모듈을 모른다(확장 주입 인프라 일관).
|
|
//
|
|
// 표준 키는 `initActions`(camelCase). `init_actions`(snake)는 deprecated 별칭이다
|
|
// (docs/frontend/layout-json.md). 호스트/확장 양쪽 모두 두 키를 입력으로 허용하되
|
|
// 결과는 표준 `initActions` 단일 배열로 통합한다. 두 키를 따로 쌓으면 한 레이아웃에
|
|
// 두 키가 공존하게 되고, 엔진(TemplateApp)의 `initActions || init_actions` OR 단락이
|
|
// 한쪽(snake)을 통째로 무시 → 확장이 기여한 init_actions 가 실행되지 않는다.
|
|
// (자체 initActions 를 가진 자식 레이아웃에서만 헤더 통화/배송국가 셀렉터가
|
|
// 미표시되던 회귀의 근본 원인 — Chrome MCP 실측으로 확인)
|
|
$extensionInitActions = $content['initActions'] ?? $content['init_actions'] ?? null;
|
|
if (is_array($extensionInitActions) && $extensionInitActions !== []) {
|
|
$hostInitActions = $layout['initActions'] ?? $layout['init_actions'] ?? [];
|
|
$layout['initActions'] = array_merge($hostInitActions, $extensionInitActions);
|
|
// deprecated 별칭 키가 잔존하면 OR 단락으로 통합 결과가 무시되므로 제거한다.
|
|
unset($layout['init_actions']);
|
|
}
|
|
|
|
// modals 병합
|
|
if (isset($content['modals']) && is_array($content['modals'])) {
|
|
$modals = array_merge($modals, $content['modals']);
|
|
}
|
|
}
|
|
|
|
$layout['data_sources'] = $dataSources;
|
|
|
|
return $layout;
|
|
}
|
|
|
|
/**
|
|
* 타겟 ID를 찾아 컴포넌트 주입
|
|
*
|
|
* @param array &$components 컴포넌트 배열 (참조)
|
|
* @param string $targetId 타겟 컴포넌트 ID
|
|
* @param string $position 주입 위치
|
|
* @param array $newComponents 주입할 컴포넌트들
|
|
* @return bool 주입 성공 여부
|
|
*/
|
|
private function injectAtTarget(array &$components, string $targetId, string $position, array $newComponents): bool
|
|
{
|
|
foreach ($components as $index => &$component) {
|
|
// 타겟 ID 발견
|
|
if (($component['id'] ?? '') === $targetId) {
|
|
$this->injectComponents($components, $component, $index, $position, $newComponents);
|
|
|
|
return true;
|
|
}
|
|
|
|
// 자식에서 재귀 탐색
|
|
if (isset($component['children']) && is_array($component['children'])) {
|
|
if ($this->injectAtTarget($component['children'], $targetId, $position, $newComponents)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* position에 따라 컴포넌트 삽입
|
|
*
|
|
* @param array &$siblings 형제 컴포넌트 배열
|
|
* @param array &$target 타겟 컴포넌트
|
|
* @param int $targetIndex 타겟 인덱스
|
|
* @param string $position 주입 위치
|
|
* @param array $newComponents 주입할 컴포넌트들
|
|
*/
|
|
private function injectComponents(
|
|
array &$siblings,
|
|
array &$target,
|
|
int $targetIndex,
|
|
string $position,
|
|
array $newComponents
|
|
): void {
|
|
switch ($position) {
|
|
case 'prepend':
|
|
// 타겟 앞에 형제로 삽입
|
|
array_splice($siblings, $targetIndex, 0, $newComponents);
|
|
break;
|
|
|
|
case 'append':
|
|
// 타겟 뒤에 형제로 삽입
|
|
array_splice($siblings, $targetIndex + 1, 0, $newComponents);
|
|
break;
|
|
|
|
case 'prepend_child':
|
|
// 타겟 children 맨 앞에 삽입
|
|
if (! isset($target['children'])) {
|
|
$target['children'] = [];
|
|
}
|
|
$target['children'] = array_merge($newComponents, $target['children']);
|
|
break;
|
|
|
|
case 'append_child':
|
|
// 타겟 children 맨 뒤에 삽입
|
|
if (! isset($target['children'])) {
|
|
$target['children'] = [];
|
|
}
|
|
$target['children'] = array_merge($target['children'], $newComponents);
|
|
break;
|
|
|
|
case 'replace':
|
|
// 타겟 완전 교체 (첫 번째 컴포넌트로)
|
|
if (! empty($newComponents)) {
|
|
$siblings[$targetIndex] = $newComponents[0];
|
|
// 나머지는 뒤에 추가
|
|
if (count($newComponents) > 1) {
|
|
array_splice($siblings, $targetIndex + 1, 0, array_slice($newComponents, 1));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 타겟 ID를 찾아 Props 주입
|
|
*
|
|
* @param array &$components 컴포넌트 배열 (참조)
|
|
* @param string $targetId 타겟 컴포넌트 ID
|
|
* @param array $propsToInject 주입할 Props 정의
|
|
* @param array|null $injectMeta 편집 모드 출처 메타 — 전달 시 호스트 노드에
|
|
* `__injectedProps[]` 로 누적 기록(편집기 출처 섹션용)
|
|
* @return bool 주입 성공 여부
|
|
*/
|
|
private function injectPropsAtTarget(array &$components, string $targetId, array $propsToInject, ?array $injectMeta = null): bool
|
|
{
|
|
foreach ($components as &$component) {
|
|
if (($component['id'] ?? '') === $targetId) {
|
|
$this->injectProps($component, $propsToInject);
|
|
|
|
// 편집 모드 — 이 호스트 노드에 주입된 확장 출처를 누적 기록(다중 확장 주입 대비).
|
|
if ($injectMeta !== null) {
|
|
if (! isset($component['__injectedProps']) || ! is_array($component['__injectedProps'])) {
|
|
$component['__injectedProps'] = [];
|
|
}
|
|
$component['__injectedProps'][] = $injectMeta;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (isset($component['children']) && is_array($component['children'])) {
|
|
if ($this->injectPropsAtTarget($component['children'], $targetId, $propsToInject, $injectMeta)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* inject_props 호스트 노드 출처 메타를 구성합니다.
|
|
*
|
|
* 편집기 속성 모달이 "확장이 주입한 속성" 섹션을 출처 배지와 함께 렌더하고, 값 편집 시
|
|
* 그 확장 행으로 교차 저장하는 데 쓴다. `props` 는 주입 정의 원본(편집 대상).
|
|
*
|
|
* @param LayoutExtension $extension 주입 확장
|
|
* @param array $props 주입한 props 정의
|
|
* @return array<string, mixed> `__injectedProps[]` 한 항목
|
|
*/
|
|
private function buildInjectedPropsMeta(LayoutExtension $extension, array $props): array
|
|
{
|
|
$meta = [
|
|
'extensionId' => $extension->id,
|
|
'extensionSourceType' => $extension->source_type->value,
|
|
'extensionIdentifier' => $extension->source_identifier,
|
|
'props' => $props,
|
|
];
|
|
|
|
$name = $this->resolveExtensionDisplayName($extension);
|
|
if ($name !== null) {
|
|
$meta['extensionName'] = $name;
|
|
}
|
|
|
|
return $meta;
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트의 props에 값을 주입
|
|
*
|
|
* 병합 전략:
|
|
* - _append: 배열 끝에 추가
|
|
* - _prepend: 배열 앞에 추가
|
|
* - _merge: 객체 병합 (shallow)
|
|
* - (직접 값): 스칼라 덮어쓰기 또는 객체 통째로 대체
|
|
*
|
|
* @param array &$component 대상 컴포넌트 (참조)
|
|
* @param array $propsToInject 주입할 Props 정의
|
|
*/
|
|
private function injectProps(array &$component, array $propsToInject): void
|
|
{
|
|
if (! isset($component['props'])) {
|
|
$component['props'] = [];
|
|
}
|
|
|
|
foreach ($propsToInject as $propKey => $propValue) {
|
|
if (! is_array($propValue)) {
|
|
// 스칼라: 직접 대체
|
|
$component['props'][$propKey] = $propValue;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 병합 전략 감지
|
|
if (isset($propValue['_append'])) {
|
|
$existing = $component['props'][$propKey] ?? [];
|
|
if (is_string($existing)) {
|
|
Log::warning('inject_props _append 대상이 표현식 문자열', [
|
|
'component_id' => $component['id'] ?? 'unknown',
|
|
'prop_key' => $propKey,
|
|
'existing_value' => $existing,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
$component['props'][$propKey] = array_merge(
|
|
is_array($existing) ? $existing : [],
|
|
$propValue['_append']
|
|
);
|
|
} elseif (isset($propValue['_prepend'])) {
|
|
$existing = $component['props'][$propKey] ?? [];
|
|
if (is_string($existing)) {
|
|
Log::warning('inject_props _prepend 대상이 표현식 문자열', [
|
|
'component_id' => $component['id'] ?? 'unknown',
|
|
'prop_key' => $propKey,
|
|
'existing_value' => $existing,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
$component['props'][$propKey] = array_merge(
|
|
$propValue['_prepend'],
|
|
is_array($existing) ? $existing : []
|
|
);
|
|
} elseif (isset($propValue['_merge'])) {
|
|
$existing = $component['props'][$propKey] ?? [];
|
|
if (is_string($existing)) {
|
|
Log::warning('inject_props _merge 대상이 표현식 문자열', [
|
|
'component_id' => $component['id'] ?? 'unknown',
|
|
'prop_key' => $propKey,
|
|
'existing_value' => $existing,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
$component['props'][$propKey] = array_merge(
|
|
is_array($existing) ? $existing : [],
|
|
$propValue['_merge']
|
|
);
|
|
} else {
|
|
// 전략 키 없으면 객체 통째로 대체
|
|
$component['props'][$propKey] = $propValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 확장 등록
|
|
*
|
|
* updateOrCreate를 사용하여 중복 등록을 방지합니다.
|
|
* 재활성화 시 기존 레코드가 있으면 복원 및 업데이트하고, 없으면 새로 생성합니다.
|
|
*
|
|
* $preserveModified 가 true 이면, 관리자가 편집한 확장(현재 content 해시가
|
|
* original_content_hash 와 다른 경우)은 content/priority 갱신을 건너뛰고
|
|
* is_active 만 갱신합니다 ('skipped' 반환).
|
|
*
|
|
* 대상 템플릿 적용 가능 여부(target_layout / extension_point 존재)는 호출 측
|
|
* (RefreshesLayoutExtensions trait)이 사전 판별한다 — isApplicableToTemplate() 참고.
|
|
*
|
|
* @param array $content 확장 파일 내용
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @param int $templateId 템플릿 ID
|
|
* @param bool $preserveModified 사용자 수정분 보존 여부
|
|
* @return string|null 'created', 'updated', 'skipped', 또는 null (처리하지 않음)
|
|
*/
|
|
public function registerExtension(
|
|
array $content,
|
|
LayoutSourceType $sourceType,
|
|
string $identifier,
|
|
int $templateId,
|
|
bool $preserveModified = false
|
|
): ?string {
|
|
$result = null;
|
|
|
|
// Extension Point 타입
|
|
if (isset($content['extension_point'])) {
|
|
$result = $this->registerExtensionRecord(
|
|
$content,
|
|
LayoutExtensionType::ExtensionPoint,
|
|
$content['extension_point'],
|
|
$sourceType,
|
|
$identifier,
|
|
$templateId,
|
|
$preserveModified
|
|
);
|
|
}
|
|
|
|
// Overlay 타입
|
|
if (isset($content['target_layout'])) {
|
|
$result = $this->registerExtensionRecord(
|
|
$content,
|
|
LayoutExtensionType::Overlay,
|
|
$content['target_layout'],
|
|
$sourceType,
|
|
$identifier,
|
|
$templateId,
|
|
$preserveModified
|
|
);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 단일 확장 레코드를 등록/갱신합니다.
|
|
*
|
|
* preserveModified 전략 (TemplateManager::refreshTemplateLayouts 패턴과 동일):
|
|
* - 신규 생성 → content + original_content_hash/size 함께 저장 → 'created'
|
|
* - 기존 존재 + preserveModified + 사용자 수정 감지 → content/priority SKIP,
|
|
* is_active 만 갱신 → 'skipped'
|
|
* - 그 외 → content 덮어쓰기 + original_content_hash/size 새 파일 기준 갱신 → 'updated'
|
|
*
|
|
* @param array $content 확장 파일 내용
|
|
* @param LayoutExtensionType $extensionType 확장 타입
|
|
* @param string $targetName 타겟 이름
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @param int $templateId 템플릿 ID
|
|
* @param bool $preserveModified 사용자 수정분 보존 여부
|
|
* @return string 'created', 'updated', 또는 'skipped'
|
|
*/
|
|
private function registerExtensionRecord(
|
|
array $content,
|
|
LayoutExtensionType $extensionType,
|
|
string $targetName,
|
|
LayoutSourceType $sourceType,
|
|
string $identifier,
|
|
int $templateId,
|
|
bool $preserveModified
|
|
): string {
|
|
$attributes = [
|
|
'template_id' => $templateId,
|
|
'extension_type' => $extensionType,
|
|
'target_name' => $targetName,
|
|
'source_type' => $sourceType,
|
|
'source_identifier' => $identifier,
|
|
];
|
|
|
|
$existing = LayoutExtension::withTrashed()->where($attributes)->first();
|
|
|
|
// 기존 확장 존재 + 사용자 수정분 보존 전략
|
|
if ($existing && $preserveModified) {
|
|
$currentHash = $this->computeContentHash($existing->content);
|
|
$originalHash = $existing->original_content_hash;
|
|
|
|
// 사용자가 편집한 확장 → content/priority 덮어쓰기 SKIP, is_active 만 갱신
|
|
if ($originalHash && $currentHash !== $originalHash) {
|
|
if ($existing->trashed()) {
|
|
$existing->restore();
|
|
}
|
|
$existing->update(['is_active' => true]);
|
|
|
|
Log::info('레이아웃 확장 갱신 SKIP (사용자 수정 보존)', [
|
|
'extension_id' => $existing->id,
|
|
'target_name' => $targetName,
|
|
'source' => $identifier,
|
|
]);
|
|
|
|
return 'skipped';
|
|
}
|
|
}
|
|
|
|
$normalizedContent = $this->computeContentHash($content);
|
|
|
|
$values = [
|
|
'content' => $content,
|
|
'priority' => $content['priority'] ?? 100,
|
|
'is_active' => true,
|
|
'original_content_hash' => $normalizedContent,
|
|
'original_content_size' => $this->computeContentSize($content),
|
|
];
|
|
|
|
$model = $this->repository->updateOrCreate($attributes, $values);
|
|
|
|
return $model->wasRecentlyCreated ? 'created' : 'updated';
|
|
}
|
|
|
|
/**
|
|
* 확장 정의가 해당 템플릿에 적용 가능한지(대상이 존재하는지) 판별합니다.
|
|
*
|
|
* - Overlay: content.target_layout 이 그 템플릿의 레이아웃명으로 존재해야 함
|
|
* - Extension Point: content.extension_point(확장점명)이 그 템플릿의 어느 레이아웃에 정의되어 있어야 함
|
|
*
|
|
* 모듈/플러그인 확장이 admin/user 등 모든 활성 템플릿에 일괄 등록될 때,
|
|
* 대상이 실제로 존재하지 않는 템플릿(예: admin 레이아웃 대상 확장 ↔ user 템플릿)에는
|
|
* 등록하지 않기 위한 사전 판별이다. RefreshesLayoutExtensions trait 이 호출한다.
|
|
*
|
|
* @param array $content 확장 정의 (extension_point 또는 target_layout 키 보유)
|
|
* @param int $templateId 템플릿 ID
|
|
* @return bool 적용 가능 여부
|
|
*/
|
|
public function isExtensionApplicableToTemplate(array $content, int $templateId): bool
|
|
{
|
|
if (isset($content['extension_point'])) {
|
|
return $this->layoutRepository->hasExtensionPoint($templateId, $content['extension_point']);
|
|
}
|
|
|
|
if (isset($content['target_layout'])) {
|
|
return $this->layoutRepository->exists($templateId, $content['target_layout']);
|
|
}
|
|
|
|
// extension_point/target_layout 둘 다 없는 정의는 처리 대상이 아님
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 대상이 존재하지 않게 된 확장 행을 정리(soft delete)합니다.
|
|
*
|
|
* 과거 무차별 등록으로 잘못된 템플릿에 생성된 확장 행을 제거합니다.
|
|
* RefreshesLayoutExtensions trait 이 적용 불가 판정 시 호출한다.
|
|
*
|
|
* @param array $content 확장 정의
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @param int $templateId 템플릿 ID
|
|
* @return bool 정리된 행이 있으면 true
|
|
*/
|
|
public function removeInapplicableExtension(
|
|
array $content,
|
|
LayoutSourceType $sourceType,
|
|
string $identifier,
|
|
int $templateId
|
|
): bool {
|
|
$extensionType = isset($content['extension_point'])
|
|
? LayoutExtensionType::ExtensionPoint
|
|
: (isset($content['target_layout']) ? LayoutExtensionType::Overlay : null);
|
|
|
|
if ($extensionType === null) {
|
|
return false;
|
|
}
|
|
|
|
$targetName = $content['extension_point'] ?? $content['target_layout'];
|
|
|
|
$existing = $this->repository->findByAttributes([
|
|
'template_id' => $templateId,
|
|
'extension_type' => $extensionType,
|
|
'target_name' => $targetName,
|
|
'source_type' => $sourceType,
|
|
'source_identifier' => $identifier,
|
|
]);
|
|
|
|
if (! $existing) {
|
|
return false;
|
|
}
|
|
|
|
// cross-template 가드 — "이 템플릿엔 부재" 만으로 삭제하지 않는다.
|
|
//
|
|
// 이 확장점/오버레이를 담는 호스트 레이아웃이 *다른 확장(템플릿)* 소유인 경우
|
|
// (예: admin_dashboard_commerce 확장점은 ecommerce 모듈이 주입하지만 호스트인
|
|
// admin_dashboard 레이아웃은 admin 템플릿 소유), 업데이트 순서에 따라 호스트
|
|
// 레이아웃이 아직 구버전(슬롯 부재)일 수 있다. 그 순간 모든 등록 템플릿에서 동시에
|
|
// "부재" 가 되는데, 이는 오등록이 아니라 단지 호스트 content 가 아직 stale 인 것이다.
|
|
//
|
|
// 따라서 동일 확장(같은 source/type/target)이 *다른* 템플릿에서 적용 가능할 때만
|
|
// 삭제한다(그 다른 템플릿이 정답 타입 → 이 행은 타입 불일치 오등록). 어느 템플릿
|
|
// 에서도 적용 불가(전부 stale)면 보존하여 정상 확장 전멸을 막는다.
|
|
if (! $this->isApplicableInAnyOtherTemplate($content, $extensionType, $targetName, $sourceType, $identifier, $templateId)) {
|
|
return false;
|
|
}
|
|
|
|
$existing->delete();
|
|
|
|
Log::info('오등록 레이아웃 확장 제거 (다른 템플릿엔 적용 가능)', [
|
|
'extension_id' => $existing->id,
|
|
'template_id' => $templateId,
|
|
'target_name' => $targetName,
|
|
'source' => $identifier,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 동일 확장(같은 source/type/target)이 후보 템플릿이 아닌 *다른* 템플릿에서
|
|
* 적용 가능한지(호스트 레이아웃/확장점이 그 템플릿에 존재) 판별합니다.
|
|
*
|
|
* true → 그 다른 템플릿이 이 확장의 정답 타입 → 후보 행은 타입 불일치 오등록.
|
|
* false → 어느 템플릿에서도 적용 불가(전부 stale 또는 단독 부재) → 삭제 보류(보존).
|
|
*
|
|
* @param array $content 확장 정의
|
|
* @param LayoutExtensionType $extensionType 확장 타입
|
|
* @param string $targetName 대상 이름
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @param int $candidateTemplateId 부재로 판정된 후보 행의 템플릿 ID
|
|
* @return bool 다른 템플릿에서 적용 가능한 동일 확장이 존재하는지
|
|
*/
|
|
private function isApplicableInAnyOtherTemplate(
|
|
array $content,
|
|
LayoutExtensionType $extensionType,
|
|
string $targetName,
|
|
LayoutSourceType $sourceType,
|
|
string $identifier,
|
|
int $candidateTemplateId
|
|
): bool {
|
|
$siblings = $this->repository->getAllByAttributesAcrossTemplates([
|
|
'extension_type' => $extensionType,
|
|
'target_name' => $targetName,
|
|
'source_type' => $sourceType,
|
|
'source_identifier' => $identifier,
|
|
]);
|
|
|
|
foreach ($siblings as $sibling) {
|
|
if ($sibling->template_id === $candidateTemplateId) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->isExtensionApplicableToTemplate($content, $sibling->template_id)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 특정 템플릿의 모든 레이아웃 확장을 출처별로 그룹핑하여 조회합니다.
|
|
*
|
|
* 관리자 레이아웃 편집 화면의 좌측 트리에서 사용합니다.
|
|
*
|
|
* 템플릿 오버라이드 행(source_type=template)은 자기 자신(템플릿)이 아니라
|
|
* 오버라이드 대상 모듈/플러그인(override_target) 기준으로 그룹핑한다 —
|
|
* 트리에서 일반 확장과 동일하게 "어느 플러그인/모듈의 확장인지" 로 묶이고,
|
|
* 오버라이드 여부는 각 항목의 is_override 플래그로 별도 표시한다.
|
|
*
|
|
* @param int $templateId 템플릿 ID
|
|
* @return array<array{source_identifier: string, source_type: string, source_label: string, extensions: array<LayoutExtension>}>
|
|
*/
|
|
public function getExtensionsByTemplateId(int $templateId): array
|
|
{
|
|
HookManager::doAction('core.layout_extension.before_index', $templateId);
|
|
|
|
// 템플릿 오버라이드에 가려진 확장은 화면에 적용되지 않으므로 트리에서 제외 —
|
|
// 렌더링 경로(getResolvedExtensionPoints/getResolvedOverlays)와 동일한 가시성 기준.
|
|
$extensions = $this->repository->getResolvedByTemplateId($templateId);
|
|
|
|
// 확장별 현재(최신) 저장 버전 부착 — 라우트 트리 확장 노드
|
|
// 버전 배지 데이터. 이력이 없는(원본) 확장은 맵에 없어 null → 배지 미표시.
|
|
$currentVersions = $this->versionRepository->getCurrentVersionsByExtensionIds(
|
|
$extensions->pluck('id')->all()
|
|
);
|
|
foreach ($extensions as $extension) {
|
|
$extension->setAttribute('current_version', $currentVersions[$extension->id] ?? null);
|
|
}
|
|
|
|
// 출처별 그룹핑 — 오버라이드 행은 override_target(대상 확장) 기준으로 묶는다.
|
|
$groups = [];
|
|
foreach ($extensions as $extension) {
|
|
$isOverride = $extension->source_type === LayoutSourceType::Template;
|
|
|
|
// 그룹 출처: 오버라이드는 대상 모듈/플러그인, 그 외는 자기 source.
|
|
[$groupType, $groupIdentifier] = $isOverride
|
|
? $this->resolveOverrideGroupSource($extension)
|
|
: [$extension->source_type, $extension->source_identifier];
|
|
|
|
$key = $groupType->value.':'.$groupIdentifier;
|
|
|
|
if (! isset($groups[$key])) {
|
|
$groups[$key] = [
|
|
'source_identifier' => $groupIdentifier,
|
|
'source_type' => $groupType->value,
|
|
'source_label' => $this->resolveSourceLabel($groupType, $groupIdentifier),
|
|
'extensions' => [],
|
|
];
|
|
}
|
|
|
|
// 리소스 직렬화 시 읽을 수 있도록 모델에 부착
|
|
$extension->source_label = $groups[$key]['source_label'];
|
|
$extension->is_override = $isOverride;
|
|
|
|
$groups[$key]['extensions'][] = $extension;
|
|
}
|
|
|
|
$result = array_values($groups);
|
|
|
|
return HookManager::applyFilters('core.layout_extension.after_index', $result, $templateId);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 오버라이드 행이 묶일 그룹 출처(타입, 식별자)를 해석합니다.
|
|
*
|
|
* override_target 이 가리키는 모듈/플러그인을 그룹 출처로 사용한다.
|
|
* 매니저로 모듈/플러그인 여부를 판정하며, 어느 쪽에도 없으면
|
|
* (대상 확장이 제거된 경우 등) 오버라이드 행 자신의 출처로 fallback 한다.
|
|
*
|
|
* @param LayoutExtension $extension 템플릿 오버라이드 확장
|
|
* @return array{0: LayoutSourceType, 1: string} [그룹 타입, 그룹 식별자]
|
|
*/
|
|
private function resolveOverrideGroupSource(LayoutExtension $extension): array
|
|
{
|
|
$target = $extension->override_target;
|
|
|
|
if (is_string($target) && $target !== '') {
|
|
try {
|
|
if (app(ModuleManager::class)->getModule($target)) {
|
|
return [LayoutSourceType::Module, $target];
|
|
}
|
|
if (app(PluginManager::class)->getPlugin($target)) {
|
|
return [LayoutSourceType::Plugin, $target];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::debug('오버라이드 대상 출처 해석 실패', [
|
|
'extension_id' => $extension->id,
|
|
'override_target' => $target,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 대상을 식별할 수 없으면 오버라이드 행 자신의 출처로 fallback
|
|
return [$extension->source_type, $extension->source_identifier];
|
|
}
|
|
|
|
/**
|
|
* 출처(모듈/플러그인/템플릿)의 표시명을 해석합니다.
|
|
*
|
|
* 매니저에서 표시명을 가져오고, 없으면 식별자를 그대로 반환합니다.
|
|
*
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @return string 표시명
|
|
*/
|
|
private function resolveSourceLabel(LayoutSourceType $sourceType, string $identifier): string
|
|
{
|
|
try {
|
|
if ($sourceType === LayoutSourceType::Module) {
|
|
// DB 레코드의 name 을 우선 사용 — 설치/동기화 시 manifest.translations 필터가
|
|
// 활성 언어팩(ja 등)을 병합해 저장하므로 매니페스트 객체(ko/en 만)보다 로케일 커버리지가 넓다.
|
|
$dbName = $this->moduleRepository->findByIdentifier($identifier)?->getRawOriginal('name');
|
|
$name = $this->decodeMultilingualName($dbName)
|
|
?? app(ModuleManager::class)->getModule($identifier)?->getName();
|
|
|
|
return $this->normalizeSourceLabel($name, $identifier);
|
|
}
|
|
|
|
if ($sourceType === LayoutSourceType::Plugin) {
|
|
$dbName = $this->pluginRepository->findByIdentifier($identifier)?->getRawOriginal('name');
|
|
$name = $this->decodeMultilingualName($dbName)
|
|
?? app(PluginManager::class)->getPlugin($identifier)?->getName();
|
|
|
|
return $this->normalizeSourceLabel($name, $identifier);
|
|
}
|
|
|
|
if ($sourceType === LayoutSourceType::Template) {
|
|
// 템플릿 오버라이드 출처 — getTemplate() 은 메타데이터 배열을 반환
|
|
$template = app(TemplateManager::class)->getTemplate($identifier);
|
|
|
|
return $this->normalizeSourceLabel($template['name'] ?? null, $identifier);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::debug('확장 출처 표시명 해석 실패', [
|
|
'source_type' => $sourceType->value,
|
|
'identifier' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
return $identifier;
|
|
}
|
|
|
|
/**
|
|
* 확장 표시명(string|array)을 현재 로케일 문자열로 정규화합니다.
|
|
*
|
|
* getName() 은 다국어 배열을 반환할 수 있으므로, 현재 로케일 → 첫 항목 →
|
|
* 식별자 순으로 fallback 합니다.
|
|
*
|
|
* @param string|array|null $name 표시명 (다국어 배열 가능)
|
|
* @param string $identifier fallback 식별자
|
|
* @return string 정규화된 표시명
|
|
*/
|
|
private function normalizeSourceLabel(string|array|null $name, string $identifier): string
|
|
{
|
|
if (is_string($name) && $name !== '') {
|
|
return $name;
|
|
}
|
|
|
|
if (is_array($name) && ! empty($name)) {
|
|
$locale = app()->getLocale();
|
|
|
|
return (string) ($name[$locale] ?? reset($name) ?: $identifier);
|
|
}
|
|
|
|
return $identifier;
|
|
}
|
|
|
|
/**
|
|
* DB 의 다국어 name(`getRawOriginal('name')`) 을 배열로 디코딩합니다.
|
|
*
|
|
* 컬럼은 JSON 문자열(`{"ko":..,"ja":..}`)로 저장되므로 디코딩해 로케일 배열을 얻습니다.
|
|
* 이미 배열이면 그대로, 비었거나 디코딩 실패(평문 등)면 null 을 반환해 호출부가
|
|
* 매니페스트 객체 폴백을 타도록 합니다.
|
|
*
|
|
* @param string|array|null $raw DB 원본 name 값
|
|
* @return array<string, string>|null 로케일 배열 또는 null
|
|
*/
|
|
private function decodeMultilingualName(string|array|null $raw): ?array
|
|
{
|
|
if (is_array($raw)) {
|
|
return ! empty($raw) ? $raw : null;
|
|
}
|
|
|
|
if (is_string($raw) && $raw !== '') {
|
|
$decoded = json_decode($raw, true);
|
|
|
|
return is_array($decoded) && ! empty($decoded) ? $decoded : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* ID로 단일 레이아웃 확장을 조회합니다.
|
|
*
|
|
* @param int $extensionId 확장 ID
|
|
* @return LayoutExtension 확장 모델
|
|
*
|
|
* @throws ModelNotFoundException 확장을 찾을 수 없는 경우
|
|
*/
|
|
public function getExtensionById(int $extensionId): LayoutExtension
|
|
{
|
|
$extension = $this->repository->findById($extensionId);
|
|
|
|
if (! $extension) {
|
|
throw new ModelNotFoundException("Layout extension not found: id={$extensionId}");
|
|
}
|
|
|
|
return $extension;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 확장의 content/priority 를 업데이트합니다.
|
|
*
|
|
* 관리자가 확장 레이아웃 JSON 을 편집할 때 사용합니다.
|
|
* original_content_hash 는 변경하지 않습니다 — 모듈/플러그인 업데이트 시
|
|
* 사용자 수정 감지(preserveModified)가 동작하려면 원본 해시가 유지되어야 합니다.
|
|
*
|
|
* @param int $extensionId 확장 ID
|
|
* @param array $data 업데이트 데이터 (content, priority)
|
|
* @return LayoutExtension 업데이트된 확장 모델
|
|
*
|
|
* @throws ModelNotFoundException 확장을 찾을 수 없는 경우
|
|
*/
|
|
public function updateExtension(int $extensionId, array $data): LayoutExtension
|
|
{
|
|
HookManager::doAction('core.layout_extension.before_update', $extensionId, $data);
|
|
|
|
$data = HookManager::applyFilters('core.layout_extension.filter_update_data', $data, $extensionId);
|
|
|
|
$extension = $this->getExtensionById($extensionId);
|
|
|
|
// 낙관적 잠금 — expected_lock_version 검증
|
|
$expectedVersion = isset($data['expected_lock_version'])
|
|
? (int) $data['expected_lock_version']
|
|
: null;
|
|
$currentVersion = (int) ($extension->lock_version ?? 0);
|
|
|
|
if ($expectedVersion !== null && $expectedVersion !== $currentVersion) {
|
|
throw new ConcurrentModificationException(
|
|
currentVersion: $currentVersion,
|
|
expectedVersion: $expectedVersion,
|
|
resource: "template_layout_extensions:{$extension->id}",
|
|
);
|
|
}
|
|
|
|
// content 키가 있으면 추출 (UpdateLayoutExtensionContentRequest 사용 시)
|
|
$newContent = $data['content'] ?? null;
|
|
$oldContent = $extension->content;
|
|
|
|
$updateData = [];
|
|
if ($newContent !== null) {
|
|
$updateData['content'] = $newContent;
|
|
}
|
|
if (isset($data['priority'])) {
|
|
$updateData['priority'] = $data['priority'];
|
|
}
|
|
|
|
$extension = $this->repository->updateWithLock($extensionId, $updateData, $currentVersion + 1);
|
|
|
|
// 버전 히스토리 저장 — 저장 시점 content 스냅샷. changes_summary 는 직전(oldContent)
|
|
// 대비 이번 저장본(newContent)의 변경을 기록한다(레이아웃 본체와 동일 정책 — 종전 2건
|
|
// 저장 + 자기 비교로 최신 버전 변경 요약이 0 이던 결함 수정).
|
|
if ($newContent !== null) {
|
|
// 첫 수정 시 수정 전 원본을 baseline 버전으로 먼저 백업한다(이력이 하나도 없을 때만).
|
|
// 이게 없으면 첫 수정본만 남아 "수정 전 상태"로 복원할 수 없다(레이아웃 본체와 동일).
|
|
$hasHistory = $this->versionRepository->getNextVersion($extensionId) > 1;
|
|
if (! $hasHistory) {
|
|
$this->versionRepository->saveVersion($extensionId, $oldContent, null);
|
|
}
|
|
|
|
$savedVersion = $this->versionRepository->saveVersion($extensionId, $newContent, $oldContent);
|
|
|
|
// 저장 응답에 현재(최신) 버전 번호 동봉 — 편집기 라우트
|
|
// 트리 확장 노드 버전 배지가 저장 직후 재fetch 없이 동기화되도록 transient 부착
|
|
// (LayoutExtensionResource 가 current_version 으로 직렬화 — DB 컬럼 아님).
|
|
$extension->setAttribute('current_version', $savedVersion->version);
|
|
}
|
|
|
|
// 캐시 무효화
|
|
$this->invalidateExtensionCache(
|
|
$extension->template_id,
|
|
$extension->target_name,
|
|
$extension->extension_type
|
|
);
|
|
|
|
HookManager::doAction('core.layout_extension.after_update', $extension, $extensionId, $data);
|
|
|
|
return $extension;
|
|
}
|
|
|
|
/**
|
|
* 특정 확장의 최근 버전 목록을 조회합니다 (최신순).
|
|
*
|
|
* 버전 행은 저장할 때마다 쌓이고 정리되지 않으므로 조회 건수에 상한이 있습니다.
|
|
*
|
|
* @param int $extensionId 확장 ID
|
|
* @param int $limit 조회할 최대 버전 수 (미지정 시 100)
|
|
* @return EloquentCollection<int, TemplateLayoutExtensionVersion>
|
|
*
|
|
* @throws ModelNotFoundException 확장을 찾을 수 없는 경우
|
|
*/
|
|
public function getExtensionVersions(int $extensionId, int $limit = 100): EloquentCollection
|
|
{
|
|
HookManager::doAction('core.layout_extension.before_versions_index', $extensionId);
|
|
|
|
// 확장 존재 검증
|
|
$this->getExtensionById($extensionId);
|
|
|
|
$versions = $this->versionRepository->getVersions($extensionId, $limit);
|
|
|
|
return HookManager::applyFilters('core.layout_extension.after_versions_index', $versions, $extensionId);
|
|
}
|
|
|
|
/**
|
|
* 특정 확장의 특정 버전을 조회합니다.
|
|
*
|
|
* @param int $extensionId 확장 ID
|
|
* @param int $version 버전 번호
|
|
* @return TemplateLayoutExtensionVersion 버전 모델
|
|
*
|
|
* @throws ModelNotFoundException 확장 또는 버전을 찾을 수 없는 경우
|
|
*/
|
|
public function getExtensionVersion(int $extensionId, int $version): TemplateLayoutExtensionVersion
|
|
{
|
|
HookManager::doAction('core.layout_extension.before_version_show', $extensionId, $version);
|
|
|
|
// 확장 존재 검증
|
|
$this->getExtensionById($extensionId);
|
|
|
|
// 목록용 getVersions() 를 재사용하지 않는다 — 목록은 경량 조회라 확장 본문(content)을
|
|
// 담지 않고 건수 상한도 있어, 재사용하면 버전 비교가 "전 항목 삭제"로 표시되고 상한 밖의
|
|
// 오래된 버전이 404 가 된다.
|
|
$extensionVersion = $this->versionRepository->findVersionByNumber($extensionId, $version);
|
|
|
|
if (! $extensionVersion) {
|
|
throw new ModelNotFoundException(
|
|
"Layout extension version not found: extension_id={$extensionId}, version={$version}"
|
|
);
|
|
}
|
|
|
|
return HookManager::applyFilters('core.layout_extension.after_version_show', $extensionVersion, $extensionId, $version);
|
|
}
|
|
|
|
/**
|
|
* 확장을 특정 버전으로 복원합니다.
|
|
*
|
|
* @param int $extensionId 확장 ID
|
|
* @param int $versionId 복원할 버전 ID
|
|
* @return TemplateLayoutExtensionVersion 복원 후 생성된 새 버전
|
|
*
|
|
* @throws ModelNotFoundException 확장 또는 버전을 찾을 수 없는 경우
|
|
*/
|
|
public function restoreExtensionVersion(int $extensionId, int $versionId): TemplateLayoutExtensionVersion
|
|
{
|
|
HookManager::doAction('core.layout_extension.before_version_restore', $extensionId, $versionId);
|
|
|
|
$extension = $this->getExtensionById($extensionId);
|
|
|
|
// 버전 복원 (트랜잭션으로 처리됨)
|
|
$newVersion = $this->versionRepository->restoreVersion($extensionId, $versionId);
|
|
|
|
// 캐시 무효화
|
|
$this->invalidateExtensionCache(
|
|
$extension->template_id,
|
|
$extension->target_name,
|
|
$extension->extension_type
|
|
);
|
|
|
|
HookManager::doAction('core.layout_extension.after_version_restore', $newVersion, $extensionId, $versionId);
|
|
|
|
return $newVersion;
|
|
}
|
|
|
|
/**
|
|
* 특정 템플릿에서 관리자가 수정한 확장 목록을 반환합니다.
|
|
*
|
|
* 현재 content 해시가 original_content_hash 와 다른 확장을 수정된 것으로 판단합니다.
|
|
* hasModifiedLayouts 와 대칭 메서드입니다.
|
|
*
|
|
* @param int $templateId 템플릿 ID
|
|
* @return array<array{id: int, target_name: string, source_identifier: string}> 수정된 확장 목록
|
|
*/
|
|
public function hasModifiedExtensions(int $templateId): array
|
|
{
|
|
$modified = [];
|
|
|
|
foreach ($this->repository->getByTemplateId($templateId) as $extension) {
|
|
$originalHash = $extension->original_content_hash;
|
|
|
|
if (! $originalHash) {
|
|
continue;
|
|
}
|
|
|
|
$currentHash = $this->computeContentHash($extension->content);
|
|
|
|
if ($currentHash !== $originalHash) {
|
|
$modified[] = [
|
|
'id' => $extension->id,
|
|
'target_name' => $extension->target_name,
|
|
'source_identifier' => $extension->source_identifier,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $modified;
|
|
}
|
|
|
|
/**
|
|
* 특정 출처(모듈/플러그인)의 수정된 확장 목록을 반환합니다.
|
|
*
|
|
* 모듈/플러그인 업데이트 커맨드의 overwrite 전략 경고에 사용합니다.
|
|
* 모든 활성 템플릿에 걸쳐 해당 출처의 확장을 스캔합니다.
|
|
*
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @return array<array{id: int, target_name: string, source_identifier: string}> 수정된 확장 목록
|
|
*/
|
|
public function getModifiedExtensionsBySource(LayoutSourceType $sourceType, string $identifier): array
|
|
{
|
|
$modified = [];
|
|
|
|
$extensions = $this->repository->getBySource($sourceType, $identifier);
|
|
|
|
foreach ($extensions as $extension) {
|
|
$originalHash = $extension->original_content_hash;
|
|
|
|
if (! $originalHash) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->computeContentHash($extension->content) !== $originalHash) {
|
|
$modified[] = [
|
|
'id' => $extension->id,
|
|
'target_name' => $extension->target_name,
|
|
'source_identifier' => $extension->source_identifier,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $modified;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 확장 캐시를 무효화합니다.
|
|
*
|
|
* - overlay → target_name 이 레이아웃명 → 의존 레이아웃 캐시 무효화
|
|
* - extension_point → target_name 은 확장점명 → ext.cache_version 전역 갱신
|
|
*
|
|
* @param int $templateId 템플릿 ID
|
|
* @param string $targetName 타겟 이름
|
|
* @param LayoutExtensionType $type 확장 타입
|
|
*/
|
|
public function invalidateExtensionCache(int $templateId, string $targetName, LayoutExtensionType $type): void
|
|
{
|
|
if ($type === LayoutExtensionType::Overlay) {
|
|
// overlay 의 target_name 은 레이아웃명 → 의존 레이아웃 캐시 무효화
|
|
app(LayoutService::class)->clearDependentLayoutsCache($templateId, $targetName);
|
|
}
|
|
|
|
// extension_point 는 여러 레이아웃에서 사용될 수 있으므로 전역 캐시 버전 갱신
|
|
// overlay 도 프론트엔드 브라우저 캐시 무효화를 위해 동일하게 갱신.
|
|
// 쓰기는 트레이트 단일 지점(고정 CoreCacheDriver + 메모이즈 스토어) 경유 —
|
|
// 주입 CacheInterface 직접 put 은 재바인딩/스토어 상이 시 read 와 어긋난다.
|
|
$this->incrementExtensionCacheVersion();
|
|
}
|
|
|
|
/**
|
|
* 출처별 확장 제거
|
|
*
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @return int 삭제된 레코드 수
|
|
*/
|
|
public function unregisterBySource(LayoutSourceType $sourceType, string $identifier): int
|
|
{
|
|
return $this->repository->softDeleteBySource($sourceType, $identifier);
|
|
}
|
|
|
|
/**
|
|
* 출처별 확장 복원
|
|
*
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @return int 복원된 레코드 수
|
|
*/
|
|
public function restoreBySource(LayoutSourceType $sourceType, string $identifier): int
|
|
{
|
|
return $this->repository->restoreBySource($sourceType, $identifier);
|
|
}
|
|
|
|
/**
|
|
* 출처별 확장 영구 삭제
|
|
*
|
|
* 모듈/플러그인 삭제 시 사용합니다.
|
|
*
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @return int 삭제된 레코드 수
|
|
*/
|
|
public function forceDeleteBySource(LayoutSourceType $sourceType, string $identifier): int
|
|
{
|
|
return $this->repository->forceDeleteBySource($sourceType, $identifier);
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모든 모듈/플러그인의 레이아웃 확장을 특정 템플릿에 등록합니다.
|
|
*
|
|
* 템플릿 활성화 시 호출하여, 이미 활성화된 모듈/플러그인의 확장이
|
|
* 새 템플릿에도 적용되도록 합니다.
|
|
*
|
|
* @param int $templateId 대상 템플릿 ID
|
|
* @return array{modules: int, plugins: int} 소스 타입별 등록된 확장 수
|
|
*/
|
|
public function registerAllActiveExtensionsToTemplate(int $templateId): array
|
|
{
|
|
$stats = ['modules' => 0, 'plugins' => 0];
|
|
|
|
// 활성 모듈의 레이아웃 확장 등록
|
|
$moduleManager = app(ModuleManager::class);
|
|
foreach ($moduleManager->getActiveModules() as $module) {
|
|
$stats['modules'] += $this->registerExtensionFilesToTemplate(
|
|
$module->getLayoutExtensions(),
|
|
LayoutSourceType::Module,
|
|
$module->getIdentifier(),
|
|
$templateId
|
|
);
|
|
}
|
|
|
|
// 활성 플러그인의 레이아웃 확장 등록
|
|
$pluginManager = app(PluginManager::class);
|
|
foreach ($pluginManager->getActivePlugins() as $plugin) {
|
|
$stats['plugins'] += $this->registerExtensionFilesToTemplate(
|
|
$plugin->getLayoutExtensions(),
|
|
LayoutSourceType::Plugin,
|
|
$plugin->getIdentifier(),
|
|
$templateId
|
|
);
|
|
}
|
|
|
|
$total = $stats['modules'] + $stats['plugins'];
|
|
if ($total > 0) {
|
|
Log::info("템플릿에 활성 확장 등록 완료: 모듈 {$stats['modules']}건, 플러그인 {$stats['plugins']}건", [
|
|
'template_id' => $templateId,
|
|
]);
|
|
}
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* 확장 JSON 파일 목록을 특정 템플릿에 등록합니다.
|
|
*
|
|
* @param array<string> $extensionFiles JSON 파일 경로 목록
|
|
* @param LayoutSourceType $sourceType 출처 타입
|
|
* @param string $identifier 출처 식별자
|
|
* @param int $templateId 템플릿 ID
|
|
* @return int 등록된 확장 수
|
|
*/
|
|
private function registerExtensionFilesToTemplate(
|
|
array $extensionFiles,
|
|
LayoutSourceType $sourceType,
|
|
string $identifier,
|
|
int $templateId
|
|
): int {
|
|
if (empty($extensionFiles)) {
|
|
return 0;
|
|
}
|
|
|
|
$registered = 0;
|
|
|
|
foreach ($extensionFiles as $extensionFile) {
|
|
try {
|
|
$content = File::get($extensionFile);
|
|
$extensionData = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
Log::error("레이아웃 확장 JSON 파싱 실패: {$extensionFile}", [
|
|
'source' => $identifier,
|
|
'error' => json_last_error_msg(),
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$this->registerExtension($extensionData, $sourceType, $identifier, $templateId);
|
|
$registered++;
|
|
} catch (\Exception $e) {
|
|
Log::error("레이아웃 확장 등록 실패: {$extensionFile}", [
|
|
'source' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $registered;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 오버라이드 Extension 등록
|
|
*
|
|
* 템플릿이 모듈/플러그인의 Extension을 오버라이드하는 경우 사용합니다.
|
|
* updateOrCreate를 사용하여 중복 등록을 방지합니다.
|
|
*
|
|
* @param array $content 확장 파일 내용
|
|
* @param string $templateIdentifier 템플릿 식별자
|
|
* @param string $overrideTarget 오버라이드 대상 모듈/플러그인 식별자
|
|
* @param int $templateId 템플릿 ID
|
|
* @return string|null 'created', 'updated', 또는 null (처리하지 않음)
|
|
*/
|
|
public function registerTemplateOverride(
|
|
array $content,
|
|
string $templateIdentifier,
|
|
string $overrideTarget,
|
|
int $templateId
|
|
): ?string {
|
|
$result = null;
|
|
|
|
// Extension Point 타입 오버라이드
|
|
if (isset($content['extension_point'])) {
|
|
$model = $this->repository->updateOrCreate(
|
|
[
|
|
'template_id' => $templateId,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => $content['extension_point'],
|
|
'source_type' => LayoutSourceType::Template,
|
|
'source_identifier' => $templateIdentifier,
|
|
'override_target' => $overrideTarget,
|
|
],
|
|
[
|
|
'content' => $content,
|
|
'priority' => $content['priority'] ?? 100,
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
$result = $model->wasRecentlyCreated ? 'created' : 'updated';
|
|
|
|
Log::info('템플릿 Extension Point 오버라이드 등록', [
|
|
'template' => $templateIdentifier,
|
|
'extension_point' => $content['extension_point'],
|
|
'override_target' => $overrideTarget,
|
|
]);
|
|
}
|
|
|
|
// Overlay 타입 오버라이드
|
|
if (isset($content['target_layout'])) {
|
|
$model = $this->repository->updateOrCreate(
|
|
[
|
|
'template_id' => $templateId,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => $content['target_layout'],
|
|
'source_type' => LayoutSourceType::Template,
|
|
'source_identifier' => $templateIdentifier,
|
|
'override_target' => $overrideTarget,
|
|
],
|
|
[
|
|
'content' => $content,
|
|
'priority' => $content['priority'] ?? 100,
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
$result = $model->wasRecentlyCreated ? 'created' : 'updated';
|
|
|
|
Log::info('템플릿 Overlay 오버라이드 등록', [
|
|
'template' => $templateIdentifier,
|
|
'target_layout' => $content['target_layout'],
|
|
'override_target' => $overrideTarget,
|
|
]);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 버전 호환성을 고려한 오버레이 목록을 반환합니다.
|
|
*
|
|
* 1. 모든 오버레이 조회 (모듈/플러그인 + 템플릿 오버라이드)
|
|
* 2. 템플릿 오버라이드가 있고 버전이 호환되면 → 원본 제외, 템플릿 오버라이드 사용
|
|
* 3. 템플릿 오버라이드가 있지만 버전이 비호환이면 → 템플릿 오버라이드 제외, 원본 사용
|
|
* 4. 템플릿 오버라이드가 없으면 → 원본 사용
|
|
*
|
|
* @param int $templateId 템플릿 ID
|
|
* @param string $layoutName 레이아웃 이름
|
|
* @return Collection<int, LayoutExtension>
|
|
*/
|
|
private function getVersionAwareOverlays(int $templateId, string $layoutName): Collection
|
|
{
|
|
// 모든 오버레이 조회 (모듈 + 플러그인 + 템플릿)
|
|
$allOverlays = $this->applyPreviewOverrides(
|
|
$this->repository->getOverlaysByLayout($templateId, $layoutName)
|
|
);
|
|
|
|
if ($allOverlays->isEmpty()) {
|
|
return $allOverlays;
|
|
}
|
|
|
|
// 템플릿 오버라이드 분리 (override_target이 있는 Template 소스)
|
|
$templateOverrides = $allOverlays->filter(
|
|
fn ($overlay) => $overlay->source_type === LayoutSourceType::Template
|
|
&& ! empty($overlay->override_target)
|
|
);
|
|
|
|
// 템플릿 오버라이드가 없으면 모든 오버레이 반환
|
|
if ($templateOverrides->isEmpty()) {
|
|
return $allOverlays;
|
|
}
|
|
|
|
// 타겟별 상태 추적: 'compatible' | 'incompatible'
|
|
// 동일 타겟에 대해 여러 오버라이드가 있을 경우, 첫 번째 호환되는 것만 사용
|
|
$targetStatus = [];
|
|
$compatibleOverrideIds = [];
|
|
|
|
foreach ($templateOverrides as $override) {
|
|
$targetIdentifier = $override->override_target;
|
|
|
|
// 모든 오버라이드에 대해 버전 호환성 검사 수행 (경고 수집을 위해)
|
|
$isCompatible = $this->checkVersionCompatibility($override);
|
|
|
|
if ($isCompatible) {
|
|
// 이미 이 타겟에 대해 호환되는 오버라이드를 찾았으면 ID 추가 스킵
|
|
if (! isset($targetStatus[$targetIdentifier]) || $targetStatus[$targetIdentifier] !== 'compatible') {
|
|
$targetStatus[$targetIdentifier] = 'compatible';
|
|
$compatibleOverrideIds[] = $override->id;
|
|
}
|
|
} else {
|
|
// 아직 호환되는 오버라이드가 없는 경우에만 비호환 상태 설정
|
|
if (! isset($targetStatus[$targetIdentifier])) {
|
|
$targetStatus[$targetIdentifier] = 'incompatible';
|
|
}
|
|
|
|
Log::info('버전 비호환으로 원본 모듈 UI 사용', [
|
|
'template_override_id' => $override->id,
|
|
'source' => $override->source_identifier,
|
|
'target' => $targetIdentifier,
|
|
'layout' => $layoutName,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 호환되는 오버라이드의 타겟 목록 생성
|
|
$compatibleOverrideTargets = array_keys(array_filter(
|
|
$targetStatus,
|
|
fn ($status) => $status === 'compatible'
|
|
));
|
|
|
|
// 최종 오버레이 목록 필터링
|
|
return $allOverlays->filter(function ($overlay) use ($compatibleOverrideTargets, $compatibleOverrideIds) {
|
|
// 템플릿 오버라이드인 경우
|
|
if ($overlay->source_type === LayoutSourceType::Template && ! empty($overlay->override_target)) {
|
|
// 호환되는 오버라이드 ID 목록에 있는 것만 포함
|
|
// (동일 타겟에 여러 오버라이드가 있어도 첫 번째 호환 것만 사용)
|
|
return in_array($overlay->id, $compatibleOverrideIds, true);
|
|
}
|
|
|
|
// 모듈/플러그인 원본인 경우
|
|
// 호환되는 템플릿 오버라이드가 있으면 원본 제외
|
|
// 비호환 오버라이드만 있으면 원본 사용
|
|
return ! in_array($overlay->source_identifier, $compatibleOverrideTargets, true);
|
|
})->values();
|
|
}
|
|
|
|
/**
|
|
* 오버라이드의 버전 호환성을 검사합니다.
|
|
*
|
|
* version_constraint가 없으면 항상 호환으로 간주합니다.
|
|
* 템플릿 오버라이드인 경우에만 버전 검사를 수행합니다.
|
|
*
|
|
* @param LayoutExtension $extension 레이아웃 확장 모델
|
|
* @return bool 호환 여부 (true: 호환, false: 비호환)
|
|
*/
|
|
private function checkVersionCompatibility(LayoutExtension $extension): bool
|
|
{
|
|
$content = $extension->content;
|
|
|
|
// version_constraint가 없으면 항상 호환
|
|
if (! isset($content['version_constraint'])) {
|
|
return true;
|
|
}
|
|
|
|
// 템플릿 오버라이드가 아니면 버전 검사 스킵
|
|
if ($extension->source_type !== LayoutSourceType::Template) {
|
|
return true;
|
|
}
|
|
|
|
// 오버라이드 대상이 없으면 스킵
|
|
if (empty($extension->override_target)) {
|
|
return true;
|
|
}
|
|
|
|
$constraint = $content['version_constraint'];
|
|
$targetIdentifier = $extension->override_target;
|
|
|
|
// 모듈/플러그인 버전 조회
|
|
$version = $this->getExtensionSourceVersion($targetIdentifier);
|
|
|
|
if (! $version) {
|
|
Log::warning('버전 정보 없음', [
|
|
'extension' => $extension->id,
|
|
'target' => $targetIdentifier,
|
|
]);
|
|
|
|
// 버전 정보 없으면 적용 (하위 호환성)
|
|
return true;
|
|
}
|
|
|
|
// Composer Semver로 검증
|
|
try {
|
|
$compatible = Semver::satisfies($version, $constraint);
|
|
} catch (\Exception $e) {
|
|
Log::error('버전 제약 조건 파싱 실패', [
|
|
'extension' => $extension->id,
|
|
'constraint' => $constraint,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
// 파싱 실패 시 적용
|
|
return true;
|
|
}
|
|
|
|
if (! $compatible) {
|
|
$this->incompatibleOverrides[] = [
|
|
'extension_id' => $extension->id,
|
|
'source' => $extension->source_identifier,
|
|
'target' => $targetIdentifier,
|
|
'constraint' => $constraint,
|
|
'current_version' => $version,
|
|
];
|
|
}
|
|
|
|
return $compatible;
|
|
}
|
|
|
|
/**
|
|
* 모듈 또는 플러그인의 버전을 조회합니다.
|
|
*
|
|
* @param string $identifier 모듈/플러그인 식별자
|
|
* @return string|null 버전 문자열 또는 null (빈 문자열도 null 반환)
|
|
*/
|
|
private function getExtensionSourceVersion(string $identifier): ?string
|
|
{
|
|
// 모듈에서 먼저 조회
|
|
$moduleManager = app(ModuleManager::class);
|
|
$version = $moduleManager->getModuleVersion($identifier);
|
|
|
|
// 빈 문자열도 null로 처리
|
|
if ($version !== null && $version !== '') {
|
|
return $version;
|
|
}
|
|
|
|
// 플러그인에서 조회
|
|
$pluginManager = app(PluginManager::class);
|
|
$version = $pluginManager->getPluginVersion($identifier);
|
|
|
|
// 빈 문자열도 null로 처리
|
|
return ($version !== null && $version !== '') ? $version : null;
|
|
}
|
|
|
|
/**
|
|
* 비호환 오버라이드 목록을 프론트엔드용 warnings 형식으로 변환합니다.
|
|
*
|
|
* 프론트엔드에서 렌더링할 수 있도록 표준화된 warning 객체 배열을 반환합니다.
|
|
* 각 warning은 고유 ID를 가지며, 프론트엔드에서 세션 기반 dismiss 처리에 사용됩니다.
|
|
*
|
|
* @return array<array{id: string, type: string, level: string, message: string, source: string, target: string, constraint: string, current_version: string}>
|
|
*/
|
|
private function formatWarningsForFrontend(): array
|
|
{
|
|
return array_map(fn ($override) => [
|
|
'id' => 'compatibility_'.$override['extension_id'],
|
|
'type' => 'compatibility',
|
|
'level' => 'warning',
|
|
'message' => __('layout_extension.version_incompatible', [
|
|
'source' => $override['source'],
|
|
'constraint' => $override['constraint'],
|
|
'current_version' => $override['current_version'],
|
|
]),
|
|
'source' => $override['source'],
|
|
'target' => $override['target'],
|
|
'constraint' => $override['constraint'],
|
|
'current_version' => $override['current_version'],
|
|
], $this->incompatibleOverrides);
|
|
}
|
|
|
|
/**
|
|
* 비호환 오버라이드 목록을 반환합니다.
|
|
*
|
|
* @return array<array{extension_id: int, source: string, target: string, constraint: string, current_version: string}>
|
|
*/
|
|
public function getIncompatibleOverrides(): array
|
|
{
|
|
return $this->incompatibleOverrides;
|
|
}
|
|
|
|
/**
|
|
* 스크립트 배열 병합 (중복 제거)
|
|
*
|
|
* 동일한 id를 가진 스크립트는 중복 추가하지 않습니다.
|
|
* 프론트엔드에서 스크립트를 로드할 때 id를 기준으로 중복 체크합니다.
|
|
*
|
|
* @param array $existingScripts 기존 스크립트 배열
|
|
* @param array $newScripts 추가할 스크립트 배열
|
|
* @return array 병합된 스크립트 배열
|
|
*/
|
|
private function mergeScripts(array $existingScripts, array $newScripts): array
|
|
{
|
|
// 기존 스크립트 ID 목록 추출
|
|
$existingIds = array_column($existingScripts, 'id');
|
|
|
|
foreach ($newScripts as $script) {
|
|
$scriptId = $script['id'] ?? null;
|
|
|
|
// id가 없거나 이미 존재하면 스킵
|
|
if ($scriptId === null || in_array($scriptId, $existingIds, true)) {
|
|
continue;
|
|
}
|
|
|
|
$existingScripts[] = $script;
|
|
$existingIds[] = $scriptId;
|
|
}
|
|
|
|
return $existingScripts;
|
|
}
|
|
}
|