Files
Gnuboard7/app/Services/LayoutExtensionService.php
T
2026-04-20 20:37:49 +09:00

1184 lines
44 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Repositories\LayoutExtensionRepositoryInterface;
use App\Enums\LayoutExtensionType;
use App\Enums\LayoutSourceType;
use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Models\LayoutExtension;
use Composer\Semver\Semver;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 레이아웃 확장 서비스
*
* 모듈/플러그인이 기존 레이아웃에 UI를 동적으로 주입하는 기능을 처리합니다.
*/
class LayoutExtensionService
{
/**
* 활성화된 모듈 식별자 목록 (캐시)
*
* @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 = [];
/**
* 생성자
*
* 순환 의존성 방지를 위해 ModuleManager, PluginManager는 생성자에서 주입받지 않고
* 필요한 시점에 app() 헬퍼로 가져옵니다.
*/
public function __construct(
private LayoutExtensionRepositoryInterface $repository
) {}
/**
* 레이아웃에 확장 적용
*
* @param array $layout 원본 레이아웃
* @param int $templateId 템플릿 ID
* @return array 확장이 적용된 레이아웃
*/
public function applyExtensions(array $layout, int $templateId): array
{
$layoutName = $layout['layout_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);
// 2. Overlay 처리
$modals = $layout['modals'] ?? [];
$layout = $this->applyOverlays($layout, $templateId, $layoutName, $scripts, $modals);
if (! empty($modals)) {
$layout['modals'] = $modals;
}
// 3. scripts 병합 결과 적용
if (! empty($scripts)) {
$layout['scripts'] = $scripts;
}
// 4. 비호환 오버라이드 경고를 warnings 필드에 추가
if (! empty($this->incompatibleOverrides)) {
$layout['warnings'] = $this->formatWarningsForFrontend();
}
// 훅: 확장 적용 후
$layout = HookManager::applyFilters('core.layout_extension.after_apply', $layout, $templateId);
return $layout;
}
/**
* 활성화된 모듈/플러그인 목록 초기화
*
* 순환 의존성 방지를 위해 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(\App\Contracts\Repositories\ModuleRepositoryInterface::class)
->findActiveByIdentifier($identifier);
if ($module) {
return true;
}
}
$pluginManager = app(PluginManager::class);
if ($pluginManager->getPluginVersion($identifier) !== null) {
$plugin = app(\App\Contracts\Repositories\PluginRepositoryInterface::class)
->findActiveByIdentifier($identifier);
if ($plugin) {
return true;
}
}
return false;
}
private function applyExtensionPoints(array $layout, int $templateId, array &$scripts): 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
);
// modals 트리도 순회하며 extension_point 처리
// 모달 내부에 정의된 extension_point에도 플러그인/모듈 컴포넌트가 주입되도록 함
if (! empty($modals)) {
$modals = $this->processExtensionPointsRecursive(
$modals,
$templateId,
$dataSources,
$scripts,
$modals
);
}
$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): array
{
foreach ($components as $index => &$component) {
// Extension Point 발견
if (($component['type'] ?? '') === 'extension_point' && isset($component['name'])) {
$extensionPointName = $component['name'];
// 오버라이드를 고려한 확장 조회 (템플릿 > 플러그인 > 모듈)
$extensions = $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']);
$injectedComponents = match ($mode) {
'replace' => $componentsWithProps,
'prepend' => array_merge($componentsWithProps, $injectedComponents),
default => array_merge($injectedComponents, $componentsWithProps),
};
}
// 데이터 소스 병합
if (isset($content['data_sources'])) {
$dataSources = array_merge($dataSources, $content['data_sources']);
}
// 스크립트 병합 (중복 제거)
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
);
}
}
return $components;
}
/**
* Overlay 기반 컴포넌트 주입
*
* 템플릿 오버라이드를 우선 적용하고, 오버라이드되지 않은 모듈/플러그인 확장을 병합합니다.
*
* @param array $layout 레이아웃 배열
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param array &$scripts 스크립트 배열 (참조)
* @param array &$modals 모달 배열 (참조)
* @return array 확장이 적용된 레이아웃
*/
private function applyOverlays(array $layout, int $templateId, string $layoutName, array &$scripts, array &$modals): array
{
if (empty($layoutName) || ! isset($layout['components'])) {
return $layout;
}
// 버전 호환성을 고려한 오버레이 조회
$overlays = $this->getVersionAwareOverlays($templateId, $layoutName);
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),
]);
foreach ($injections as $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;
}
$injected = $this->injectPropsAtTarget(
$layout['components'],
$targetId,
$propsToInject
);
} else {
// 기존 컴포넌트 주입 로직
$components = $injection['components'] ?? [];
if (empty($components)) {
continue;
}
$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,
]);
}
}
// 데이터 소스 병합
if (isset($content['data_sources'])) {
$dataSources = array_merge($dataSources, $content['data_sources']);
}
// 스크립트 병합 (중복 제거)
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']);
}
// 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 정의
* @return bool 주입 성공 여부
*/
private function injectPropsAtTarget(array &$components, string $targetId, array $propsToInject): bool
{
foreach ($components as &$component) {
if (($component['id'] ?? '') === $targetId) {
$this->injectProps($component, $propsToInject);
return true;
}
if (isset($component['children']) && is_array($component['children'])) {
if ($this->injectPropsAtTarget($component['children'], $targetId, $propsToInject)) {
return true;
}
}
}
return false;
}
/**
* 컴포넌트의 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를 사용하여 중복 등록을 방지합니다.
* 재활성화 시 기존 레코드가 있으면 복원 및 업데이트하고, 없으면 새로 생성합니다.
*
* @param array $content 확장 파일 내용
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param int $templateId 템플릿 ID
* @return string|null 'created', 'updated', 또는 null (처리하지 않음)
*/
public function registerExtension(
array $content,
LayoutSourceType $sourceType,
string $identifier,
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' => $sourceType,
'source_identifier' => $identifier,
],
[
'content' => $content,
'priority' => $content['priority'] ?? 100,
'is_active' => true,
]
);
$result = $model->wasRecentlyCreated ? 'created' : 'updated';
}
// Overlay 타입
if (isset($content['target_layout'])) {
$model = $this->repository->updateOrCreate(
[
'template_id' => $templateId,
'extension_type' => LayoutExtensionType::Overlay,
'target_name' => $content['target_layout'],
'source_type' => $sourceType,
'source_identifier' => $identifier,
],
[
'content' => $content,
'priority' => $content['priority'] ?? 100,
'is_active' => true,
]
);
$result = $model->wasRecentlyCreated ? 'created' : 'updated';
}
return $result;
}
/**
* 출처별 확장 제거
*
* @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 \Illuminate\Support\Collection<int, LayoutExtension>
*/
private function getVersionAwareOverlays(int $templateId, string $layoutName): \Illuminate\Support\Collection
{
// 모든 오버레이 조회 (모듈 + 플러그인 + 템플릿)
$allOverlays = $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->getExtensionVersion($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 getExtensionVersion(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;
}
}