대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 1단계로, 전체 URL을 메모리에 적재하던 구조를 스트리밍 분할 생성으로 교체한다. 사이트맵 스트리밍 코어: - SitemapWriter — 자식 파일 1개 분량만 버퍼링하고 임계 도달 시 flush. StorageInterface가 append를 제공하지 않아(put이 전체 덮어쓰기) 택한 구조이며, 최대 메모리가 "자식 파일 1개 크기"로 유계가 된다. URL 수(50,000)와 바이트(45MB) 두 임계로 분할해 sitemaps.org 프로토콜을 지킨다. - 커밋은 _tmp 전량 기록 → live 재배치 → manifest 마지막 기록 순. manifest 존재가 커밋 완료 신호다. 기존 manifest를 먼저 지우지 않아 스왑 중 봇에게 503이 나가지 않는다. - SitemapFileStore — 읽기측 + 파일 경로 SSoT. SitemapXmlRenderer — escape 단일 출처. 데이터 접근 계층 정리: - contributor 3종이 쿼리를 직접 조립하던 것을 Repository 위임으로 전환. MySQL 버퍼드 쿼리에서 cursor는 결과셋 전체를 적재하므로 lazyById를 쓴다. - SeoCacheStatsService → SeoCacheStatRepository 신설 (집계 4쿼리 → 1쿼리). - audit 룰 service-direct-data-access의 사각 2건 수정 — 확장 디렉토리 정규식이 상대경로를 매칭하지 못해 *Contributor처럼 접미사 없는 클래스가 전부 미검출이었다. SEO 캐시 설정 정합화: - SEO 설정의 캐시 3키가 cache 카테고리 형제 키에 항상 져서 운영자 입력이 조용히 무시되고 있었다(설정을 cache 카테고리로 이관하며 SEO 탭 옛 칸을 미제거). 고급 탭을 기준값으로 두되 SEO 탭에 지정이 있으면 오버라이드하도록 SeoCacheSettings 단일 출처로 정리. "지정 여부"는 null로 판정하므로 기본값을 비웠다. - 기존 설치는 Upgrade_7_1_0이 이행 — 옛 기본값과 같으면 미설정으로 비우고, 다르면 운영자 의도로 보아 보존한다. 코어 버전 bump와 루트 CHANGELOG는 계획서 결정(D20)에 따라 마감 단계에서 일괄 수행한다.
1040 lines
44 KiB
PHP
1040 lines
44 KiB
PHP
<?php
|
|
|
|
namespace App\Providers;
|
|
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
|
use App\Contracts\Extension\HookListenerInterface;
|
|
use App\Contracts\Extension\ModuleSettingsInterface;
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use App\Contracts\Extension\TemplateManagerInterface;
|
|
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
|
|
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
|
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
|
use App\Contracts\Repositories\IdentityMessageDefinitionRepositoryInterface;
|
|
use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
|
|
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
|
|
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
|
|
use App\Contracts\Repositories\LayoutExtensionRepositoryInterface;
|
|
use App\Contracts\Repositories\LayoutExtensionVersionRepositoryInterface;
|
|
use App\Contracts\Repositories\LayoutPreviewRepositoryInterface;
|
|
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
|
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
|
|
use App\Contracts\Repositories\MenuRepositoryInterface;
|
|
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
|
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
|
|
use App\Contracts\Repositories\NotificationLogRepositoryInterface;
|
|
use App\Contracts\Repositories\NotificationRepositoryInterface;
|
|
use App\Contracts\Repositories\NotificationTemplateRepositoryInterface;
|
|
use App\Contracts\Repositories\PasswordResetTokenRepositoryInterface;
|
|
use App\Contracts\Repositories\PermissionRepositoryInterface;
|
|
use App\Contracts\Repositories\PluginRepositoryInterface;
|
|
use App\Contracts\Repositories\RoleRepositoryInterface;
|
|
use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
|
|
use App\Contracts\Repositories\ScheduleRepositoryInterface;
|
|
use App\Contracts\Repositories\SeoCacheStatRepositoryInterface;
|
|
use App\Contracts\Repositories\SystemConfigRepositoryInterface;
|
|
use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface;
|
|
use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface;
|
|
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
|
use App\Contracts\Repositories\UserConsentRepositoryInterface;
|
|
use App\Contracts\Repositories\UserRepositoryInterface;
|
|
use App\Contracts\UniqueIdServiceInterface;
|
|
use App\Enums\DeactivationReason;
|
|
use App\Extension\Cache\CoreCacheDriver;
|
|
use App\Extension\CoreVersionChecker;
|
|
use App\Extension\ExtensionManager;
|
|
use App\Extension\ExtensionMiddlewareRegistry;
|
|
use App\Extension\HookCacheManager;
|
|
use App\Extension\HookListenerRegistrar;
|
|
use App\Extension\HookManager;
|
|
use App\Extension\IdentityVerification\IdentityVerificationManager;
|
|
use App\Extension\IdentityVerification\Providers\MailIdentityProvider;
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Extension\Storage\CoreStorageDriver;
|
|
use App\Extension\TemplateManager;
|
|
use App\Listeners\ExtensionCompatibilityAlertListener;
|
|
use App\Listeners\Identity\EnforceIdentityPolicyListener;
|
|
use App\Repositories\ActivityLogRepository;
|
|
use App\Repositories\AttachmentRepository;
|
|
use App\Repositories\IdentityMessageDefinitionRepository;
|
|
use App\Repositories\IdentityMessageTemplateRepository;
|
|
use App\Repositories\IdentityPolicyRepository;
|
|
use App\Repositories\IdentityVerificationLogRepository;
|
|
use App\Repositories\JsonConfigRepository;
|
|
use App\Repositories\LayoutExtensionRepository;
|
|
use App\Repositories\LayoutExtensionVersionRepository;
|
|
use App\Repositories\LayoutPreviewRepository;
|
|
use App\Repositories\LayoutRepository;
|
|
use App\Repositories\LayoutVersionRepository;
|
|
use App\Repositories\MenuRepository;
|
|
use App\Repositories\ModuleRepository;
|
|
use App\Repositories\NotificationDefinitionRepository;
|
|
use App\Repositories\NotificationLogRepository;
|
|
use App\Repositories\NotificationRepository;
|
|
use App\Repositories\NotificationTemplateRepository;
|
|
use App\Repositories\PasswordResetTokenRepository;
|
|
use App\Repositories\PermissionRepository;
|
|
use App\Repositories\PluginRepository;
|
|
use App\Repositories\RoleRepository;
|
|
use App\Repositories\ScheduleHistoryRepository;
|
|
use App\Repositories\ScheduleRepository;
|
|
use App\Repositories\SeoCacheStatRepository;
|
|
use App\Repositories\SystemConfigRepository;
|
|
use App\Repositories\TemplateCustomTranslationRepository;
|
|
use App\Repositories\TemplateLayoutAttachmentRepository;
|
|
use App\Repositories\TemplateRepository;
|
|
use App\Repositories\UserConsentRepository;
|
|
use App\Repositories\UserRepository;
|
|
use App\Services\AttachmentService;
|
|
use App\Services\DriverRegistryService;
|
|
use App\Services\LayoutExtensionService;
|
|
use App\Services\TemplateLayoutAttachmentService;
|
|
use App\Services\UniqueIdService;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\ServiceProvider;
|
|
|
|
class CoreServiceProvider extends ServiceProvider
|
|
{
|
|
/**
|
|
* registerDynamicHooks() 메서드를 가진 리스너 클래스 목록.
|
|
* boot 후반부(DB 접근 가능 시점)에서 일괄 실행됩니다.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
private array $deferredDynamicListeners = [];
|
|
|
|
/**
|
|
* 코어 서비스들을 등록합니다.
|
|
*/
|
|
public function register(): void
|
|
{
|
|
// Extension autoload 등록 (진입점에서 누락된 경우를 대비)
|
|
$this->registerExtensionAutoload();
|
|
|
|
$this->registerRepositoryBindings();
|
|
$this->registerExtensionManagers();
|
|
// ActivityLogManager 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체
|
|
}
|
|
|
|
/**
|
|
* Extension(모듈/플러그인) PSR-4 오토로드를 등록합니다.
|
|
*
|
|
* index.php 또는 artisan에서 이미 등록된 경우 스킵합니다.
|
|
* 진입점에서 등록되지 않은 경우(예: queue worker)에만 실행됩니다.
|
|
*/
|
|
private function registerExtensionAutoload(): void
|
|
{
|
|
// 이미 진입점(index.php, artisan)에서 등록된 경우 스킵
|
|
if (defined('G7_EXTENSION_AUTOLOAD_REGISTERED')) {
|
|
return;
|
|
}
|
|
|
|
$extensionAutoloadFile = base_path('bootstrap/cache/autoload-extensions.php');
|
|
|
|
if (! file_exists($extensionAutoloadFile)) {
|
|
return;
|
|
}
|
|
|
|
$loader = require base_path('vendor/autoload.php');
|
|
$extensionAutoloads = require $extensionAutoloadFile;
|
|
|
|
// PSR-4 네임스페이스 등록
|
|
if (! empty($extensionAutoloads['psr4'])) {
|
|
foreach ($extensionAutoloads['psr4'] as $namespace => $paths) {
|
|
$paths = (array) $paths;
|
|
foreach ($paths as $path) {
|
|
$absolutePath = base_path($path);
|
|
if (is_dir($absolutePath)) {
|
|
$loader->addPsr4($namespace, $absolutePath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 확장 소스 classmap 등록 (FQCN → 절대경로) — findFile 파일시스템 스캔 제거 (lazy include).
|
|
if (! empty($extensionAutoloads['src_classmap'])) {
|
|
$absoluteClassmap = [];
|
|
foreach ($extensionAutoloads['src_classmap'] as $fqcn => $relPath) {
|
|
$absoluteClassmap[$fqcn] = base_path($relPath);
|
|
}
|
|
$loader->addClassMap($absoluteClassmap);
|
|
}
|
|
|
|
// Classmap 파일 로드 (module.php, plugin.php)
|
|
if (! empty($extensionAutoloads['classmap'])) {
|
|
foreach ($extensionAutoloads['classmap'] as $file) {
|
|
$absolutePath = base_path($file);
|
|
if (file_exists($absolutePath)) {
|
|
require_once $absolutePath;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Files 로드 (헬퍼 함수 등)
|
|
if (! empty($extensionAutoloads['files'])) {
|
|
foreach ($extensionAutoloads['files'] as $file) {
|
|
$absolutePath = base_path($file);
|
|
if (file_exists($absolutePath)) {
|
|
require_once $absolutePath;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Repository 인터페이스 바인딩을 등록합니다.
|
|
*/
|
|
private function registerRepositoryBindings(): void
|
|
{
|
|
$this->app->bind(ActivityLogRepositoryInterface::class, ActivityLogRepository::class);
|
|
$this->app->bind(AttachmentRepositoryInterface::class, AttachmentRepository::class);
|
|
$this->app->bind(PasswordResetTokenRepositoryInterface::class, PasswordResetTokenRepository::class);
|
|
$this->app->bind(LayoutExtensionRepositoryInterface::class, LayoutExtensionRepository::class);
|
|
$this->app->bind(LayoutExtensionVersionRepositoryInterface::class, LayoutExtensionVersionRepository::class);
|
|
$this->app->singleton(ConfigRepositoryInterface::class, JsonConfigRepository::class);
|
|
$this->app->bind(LayoutPreviewRepositoryInterface::class, LayoutPreviewRepository::class);
|
|
$this->app->bind(LayoutRepositoryInterface::class, LayoutRepository::class);
|
|
$this->app->bind(LayoutVersionRepositoryInterface::class, LayoutVersionRepository::class);
|
|
$this->app->bind(MenuRepositoryInterface::class, MenuRepository::class);
|
|
$this->app->bind(ModuleRepositoryInterface::class, ModuleRepository::class);
|
|
$this->app->bind(PermissionRepositoryInterface::class, PermissionRepository::class);
|
|
$this->app->bind(PluginRepositoryInterface::class, PluginRepository::class);
|
|
$this->app->bind(RoleRepositoryInterface::class, RoleRepository::class);
|
|
$this->app->bind(ScheduleHistoryRepositoryInterface::class, ScheduleHistoryRepository::class);
|
|
$this->app->bind(ScheduleRepositoryInterface::class, ScheduleRepository::class);
|
|
$this->app->bind(SystemConfigRepositoryInterface::class, SystemConfigRepository::class);
|
|
$this->app->bind(TemplateRepositoryInterface::class, TemplateRepository::class);
|
|
$this->app->bind(TemplateCustomTranslationRepositoryInterface::class, TemplateCustomTranslationRepository::class);
|
|
$this->app->bind(TemplateLayoutAttachmentRepositoryInterface::class, TemplateLayoutAttachmentRepository::class);
|
|
$this->app->bind(UserConsentRepositoryInterface::class, UserConsentRepository::class);
|
|
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
|
|
$this->app->bind(NotificationDefinitionRepositoryInterface::class, NotificationDefinitionRepository::class);
|
|
$this->app->bind(NotificationLogRepositoryInterface::class, NotificationLogRepository::class);
|
|
$this->app->bind(NotificationRepositoryInterface::class, NotificationRepository::class);
|
|
$this->app->bind(NotificationTemplateRepositoryInterface::class, NotificationTemplateRepository::class);
|
|
|
|
// SEO Repository 바인딩
|
|
$this->app->bind(SeoCacheStatRepositoryInterface::class, SeoCacheStatRepository::class);
|
|
|
|
// IdentityVerification Repository 바인딩
|
|
$this->app->bind(IdentityVerificationLogRepositoryInterface::class, IdentityVerificationLogRepository::class);
|
|
$this->app->bind(IdentityPolicyRepositoryInterface::class, IdentityPolicyRepository::class);
|
|
$this->app->bind(IdentityMessageDefinitionRepositoryInterface::class, IdentityMessageDefinitionRepository::class);
|
|
$this->app->bind(IdentityMessageTemplateRepositoryInterface::class, IdentityMessageTemplateRepository::class);
|
|
|
|
// IdentityVerification Manager + 기본 MailProvider 등록
|
|
$this->app->singleton(IdentityVerificationManager::class, function ($app) {
|
|
$manager = new IdentityVerificationManager;
|
|
$manager->register($app->make(MailIdentityProvider::class));
|
|
|
|
return $manager;
|
|
});
|
|
|
|
// UniqueIdService 바인딩
|
|
$this->app->singleton(UniqueIdServiceInterface::class, UniqueIdService::class);
|
|
|
|
// AttachmentService용 CoreStorageDriver 바인딩
|
|
$this->app->when(AttachmentService::class)
|
|
->needs(StorageInterface::class)
|
|
->give(function () {
|
|
return new CoreStorageDriver(config('attachment.disk', 'local'));
|
|
});
|
|
|
|
// TemplateLayoutAttachmentService용 CoreStorageDriver 바인딩
|
|
$this->app->when(TemplateLayoutAttachmentService::class)
|
|
->needs(StorageInterface::class)
|
|
->give(function () {
|
|
return new CoreStorageDriver(config('attachment.disk', 'local'));
|
|
});
|
|
|
|
// 코어 서비스용 CoreCacheDriver 바인딩
|
|
// Phase 7(이관)에서 각 서비스에 CacheInterface DI 추가 시 when() 목록 확대 예정
|
|
$this->app->bind(CacheInterface::class, function () {
|
|
return new CoreCacheDriver(config('cache.default'));
|
|
});
|
|
|
|
// 확장 선언 미들웨어 self-gate 인덱스 (요청 내 인덱스 1회 빌드 → singleton)
|
|
$this->app->singleton(ExtensionMiddlewareRegistryInterface::class, ExtensionMiddlewareRegistry::class);
|
|
}
|
|
|
|
/**
|
|
* 확장 매니저들을 등록합니다.
|
|
*/
|
|
private function registerExtensionManagers(): void
|
|
{
|
|
// 확장 매니저 등록 (모듈/플러그인 공통 기능)
|
|
$this->app->singleton(ExtensionManager::class, function ($app) {
|
|
return new ExtensionManager(
|
|
$app->make(ModuleRepositoryInterface::class),
|
|
$app->make(PluginRepositoryInterface::class)
|
|
);
|
|
});
|
|
|
|
// 모듈 매니저 등록
|
|
$this->app->singleton(ModuleManager::class, function ($app) {
|
|
return new ModuleManager(
|
|
$app->make(ExtensionManager::class),
|
|
$app->make(ModuleRepositoryInterface::class),
|
|
$app->make(PermissionRepositoryInterface::class),
|
|
$app->make(RoleRepositoryInterface::class),
|
|
$app->make(MenuRepositoryInterface::class),
|
|
$app->make(TemplateRepositoryInterface::class),
|
|
$app->make(PluginRepositoryInterface::class),
|
|
$app->make(LayoutRepositoryInterface::class),
|
|
$app->make(LayoutExtensionService::class)
|
|
);
|
|
});
|
|
|
|
// 플러그인 매니저 등록
|
|
$this->app->singleton(PluginManager::class, function ($app) {
|
|
return new PluginManager(
|
|
$app->make(ExtensionManager::class),
|
|
$app->make(PluginRepositoryInterface::class),
|
|
$app->make(PermissionRepositoryInterface::class),
|
|
$app->make(RoleRepositoryInterface::class),
|
|
$app->make(TemplateRepositoryInterface::class),
|
|
$app->make(ModuleRepositoryInterface::class),
|
|
$app->make(LayoutRepositoryInterface::class),
|
|
$app->make(LayoutExtensionService::class)
|
|
);
|
|
});
|
|
|
|
// 템플릿 매니저 등록
|
|
$this->app->singleton(TemplateManager::class, function ($app) {
|
|
return new TemplateManager(
|
|
$app->make(ExtensionManager::class),
|
|
$app->make(TemplateRepositoryInterface::class),
|
|
$app->make(LayoutRepositoryInterface::class),
|
|
$app->make(ModuleRepositoryInterface::class),
|
|
$app->make(PluginRepositoryInterface::class),
|
|
$app->make(LayoutExtensionService::class)
|
|
);
|
|
});
|
|
|
|
// 템플릿 매니저 인터페이스 바인딩
|
|
$this->app->singleton(TemplateManagerInterface::class, function ($app) {
|
|
return $app->make(TemplateManager::class);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 코어 서비스들을 부트스트랩합니다.
|
|
*/
|
|
public function boot(): void
|
|
{
|
|
// 코어 훅 리스너 자동 발견 및 등록 (환경 무관)
|
|
$this->registerCoreHookListeners();
|
|
|
|
// 시스템 라우트 주입 (프리뷰 등 코어 전역 라우트)
|
|
$this->registerSystemRouteFilters();
|
|
|
|
// .env 파일이 없으면 스킵 (인스톨러 실행 전)
|
|
if (! File::exists(base_path('.env'))) {
|
|
return;
|
|
}
|
|
|
|
// DB 연결 유효성 검증 (root 사용자 접속 방지)
|
|
if (! $this->isDatabaseConnectionValid()) {
|
|
Log::error('Database connection invalid: using root user or missing credentials. Skipping extension loading.');
|
|
|
|
return;
|
|
}
|
|
|
|
// 콘솔 환경에서는 템플릿 로딩 건너뛰기 (성능 최적화)
|
|
$skipTemplateLoading = $this->app->runningInConsole() &&
|
|
! in_array(request()->server('argv')[1] ?? '', ['serve', 'test']);
|
|
|
|
// 모듈 로드 및 버전 검증
|
|
$moduleManager = $this->app->make(ModuleManager::class);
|
|
$moduleManager->loadModules();
|
|
$this->validateAndDeactivateIncompatibleExtensions($moduleManager, 'modules');
|
|
// 코어 업그레이드 후 재호환된 모듈 감지 (자동 재활성화 없음, 알림만 저장)
|
|
$this->detectRecoveredExtensions('modules');
|
|
|
|
// 모듈 환경설정 로딩 (활성화된 모듈만)
|
|
$this->loadModuleSettingsToConfig($moduleManager);
|
|
|
|
// 플러그인 로드 및 버전 검증
|
|
$pluginManager = $this->app->make(PluginManager::class);
|
|
$pluginManager->loadPlugins();
|
|
$this->validateAndDeactivateIncompatibleExtensions($pluginManager, 'plugins');
|
|
$this->detectRecoveredExtensions('plugins');
|
|
|
|
// 플러그인 환경설정 로딩 (활성화된 플러그인만)
|
|
$this->loadPluginSettingsToConfig($pluginManager);
|
|
|
|
// 확장 드라이버 Config 적용: 유효성 검증 + 폴백 + 훅 발행
|
|
$this->applyExtensionDriverConfigs();
|
|
|
|
// 템플릿 로드 (콘솔 환경에서는 조건부 실행)
|
|
if (! $skipTemplateLoading) {
|
|
$templateManager = $this->app->make(TemplateManager::class);
|
|
$templateManager->loadTemplates();
|
|
$this->validateAndDeactivateIncompatibleTemplates($templateManager);
|
|
$this->detectRecoveredExtensions('templates');
|
|
}
|
|
|
|
// 동적 훅 리스너 일괄 실행 (registerDynamicHooks 메서드를 가진 리스너)
|
|
$this->registerDeferredDynamicHooks();
|
|
|
|
// 활성 모듈/플러그인이 선언한 IDV purpose 를 Manager 레지스트리에 수집
|
|
// (DB 저장 없음 — 런타임 계약)
|
|
$this->collectDeclaredIdentityPurposes($moduleManager, $pluginManager);
|
|
|
|
// 모듈/플러그인 로드가 끝나 모듈 IDV 정책이 모두 identity_policies 에 적재된 뒤,
|
|
// hook scope 정책 target 에 enforce 구독을 멱등 (재)바인딩한다.
|
|
// 코어 리스너 자동발견(registerCoreHookListeners, boot 전반부)은 모듈 로드보다 먼저
|
|
// 일어나므로, 모듈 hook target(예: 결제 직전 가드)이 그 시점엔 누락될 수 있다.
|
|
// 이 호출이 누락분을 보충한다(이미 바인딩된 target 은 멱등 스킵 — 이중 enforce 없음).
|
|
EnforceIdentityPolicyListener::syncDynamicHookSubscriptions();
|
|
}
|
|
|
|
/**
|
|
* 활성 모듈·플러그인이 `getIdentityPurposes()` 로 선언한 purpose 들을
|
|
* `IdentityVerificationManager` 에 일괄 등록합니다.
|
|
*
|
|
* DB 에 저장되지 않으며, 매 요청 부팅 시 수집됩니다 (코드 계약).
|
|
*/
|
|
private function collectDeclaredIdentityPurposes(ModuleManager $moduleManager, PluginManager $pluginManager): void
|
|
{
|
|
try {
|
|
$manager = $this->app->make(IdentityVerificationManager::class);
|
|
} catch (\Throwable) {
|
|
return;
|
|
}
|
|
|
|
// 코어 purpose 메타데이터: config/core.php 가 SSoT
|
|
$corePurposes = config('core.identity_purposes', []);
|
|
if (is_array($corePurposes) && ! empty($corePurposes)) {
|
|
$manager->registerDeclaredPurposes($corePurposes, 'core', 'core');
|
|
}
|
|
|
|
foreach ($moduleManager->getActiveModules() as $module) {
|
|
if (method_exists($module, 'getIdentityPurposes')) {
|
|
$purposes = $module->getIdentityPurposes();
|
|
if (is_array($purposes) && ! empty($purposes)) {
|
|
$manager->registerDeclaredPurposes(
|
|
$purposes,
|
|
'module',
|
|
method_exists($module, 'getIdentifier') ? $module->getIdentifier() : null,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($pluginManager->getActivePlugins() as $plugin) {
|
|
if (method_exists($plugin, 'getIdentityPurposes')) {
|
|
$purposes = $plugin->getIdentityPurposes();
|
|
if (is_array($purposes) && ! empty($purposes)) {
|
|
$manager->registerDeclaredPurposes(
|
|
$purposes,
|
|
'plugin',
|
|
method_exists($plugin, 'getIdentifier') ? $plugin->getIdentifier() : null,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터베이스 연결이 유효한지 검증합니다.
|
|
*
|
|
* root 사용자로 접속하거나 비밀번호가 없는 경우를 감지하여
|
|
* 잘못된 설정으로 인한 접속 오류를 방지합니다.
|
|
*
|
|
* @return bool 연결이 유효하면 true
|
|
*/
|
|
protected function isDatabaseConnectionValid(): bool
|
|
{
|
|
try {
|
|
$config = DB::connection()->getConfig();
|
|
|
|
// read/write 분리 설정인 경우 read 설정 확인
|
|
$username = $config['username'] ?? null;
|
|
|
|
// read 설정이 배열로 있는 경우
|
|
if (isset($config['read']['username'])) {
|
|
$username = $config['read']['username'];
|
|
}
|
|
|
|
// root 사용자 또는 빈 username인 경우 무효
|
|
if (empty($username) || $username === 'root') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
Log::error('Database configuration check failed: '.$e->getMessage());
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 호환되지 않는 확장(모듈/플러그인)을 자동 비활성화합니다.
|
|
*
|
|
* 코어 버전 업데이트 후 기존 확장이 새 코어 버전과 호환되지 않는 경우
|
|
* 자동으로 비활성화하고 로그를 기록합니다.
|
|
*
|
|
* @param ModuleManager|PluginManager $manager 확장 매니저
|
|
* @param string $type 확장 타입 (modules, plugins)
|
|
*/
|
|
protected function validateAndDeactivateIncompatibleExtensions($manager, string $type): void
|
|
{
|
|
// 코어 업데이트 진행 중에는 자동 비활성화 스킵.
|
|
//
|
|
// 업데이트는 다음 순서로 진행된다:
|
|
// 1. 코어 파일 교체 (디스크)
|
|
// 2. 업그레이드 스텝 실행 (spawn 프로세스)
|
|
// 3. 번들 확장 일괄 업데이트 (spawn 내부, 파일 교체 + DB sync)
|
|
// 4. .env APP_VERSION 갱신
|
|
//
|
|
// 3단계 동안 spawn 프로세스가 부팅될 때, 코어는 이미 신버전이지만 확장의 manifest 는
|
|
// 아직 구버전이거나 그 반대. 버전 기반 호환성 판정을 수행하면 일시적 불일치로 전 확장이
|
|
// 자동 비활성화되며, updateModule 이 `previousStatus` 를 잘못 캡처해 영구 비활성 상태로
|
|
// 복원된다. 업데이트 중에는 판정 자체가 의미 없으므로 전면 skip 한다.
|
|
//
|
|
// `G7_UPDATE_IN_PROGRESS` 는 CoreUpdateCommand 가 시작 시 설정하고 spawn 의 `$env` 로
|
|
// 전파된다. 업데이트가 종료되면 부모 프로세스 종료와 함께 env 도 소멸.
|
|
if (self::isCoreUpdateInProgress()) {
|
|
return;
|
|
}
|
|
|
|
$cache = $this->app->make(CacheInterface::class);
|
|
$cacheKey = CoreVersionChecker::getCacheKey($type);
|
|
|
|
// 캐시가 있으면 이미 검증된 것으로 간주
|
|
if ($cache->has($cacheKey)) {
|
|
return;
|
|
}
|
|
|
|
$deactivated = [];
|
|
$activeMethod = $type === 'modules' ? 'getActiveModules' : 'getActivePlugins';
|
|
$deactivateMethod = $type === 'modules' ? 'deactivateModule' : 'deactivatePlugin';
|
|
|
|
foreach ($manager->$activeMethod() as $identifier => $extension) {
|
|
$requiredVersion = $extension->getRequiredCoreVersion();
|
|
|
|
if (! CoreVersionChecker::isCompatible($requiredVersion)) {
|
|
// 자동 비활성화: reason='incompatible_core' + 요구 버전 전달
|
|
// (수동 비활성화와 DB 레벨 구분 → UI 라벨링 / 알림 영속화 / 재호환 복구 판정)
|
|
$manager->$deactivateMethod(
|
|
$identifier,
|
|
false,
|
|
DeactivationReason::IncompatibleCore->value,
|
|
$requiredVersion
|
|
);
|
|
$deactivated[] = [
|
|
'identifier' => $identifier,
|
|
'required' => $requiredVersion,
|
|
];
|
|
|
|
Log::warning(__('extensions.warnings.auto_deactivated'), [
|
|
'type' => $type,
|
|
'identifier' => $identifier,
|
|
'required_version' => $requiredVersion,
|
|
'core_version' => config('app.version'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 비활성화된 확장이 있으면 세션에 저장 (관리자 알림용)
|
|
if (! empty($deactivated)) {
|
|
$this->storeDeactivatedExtensionsAlert($type, $deactivated);
|
|
}
|
|
|
|
$cache->put($cacheKey, true, CoreVersionChecker::getCacheTtl());
|
|
}
|
|
|
|
/**
|
|
* 코어 업그레이드 후 재호환된 자동 비활성화 확장을 감지합니다.
|
|
*
|
|
* 자동 재활성화는 수행하지 않습니다 — 사용자 명시적 복구 (recover 엔드포인트) 만 허용.
|
|
* 결과는 `ext.recovery_check.{type}.{coreVersion}` 캐시에 저장되어
|
|
* `ExtensionCompatibilityAlertListener` 가 대시보드 알림으로 표시합니다.
|
|
*
|
|
* @param string $type 확장 타입 (modules|plugins|templates)
|
|
*/
|
|
protected function detectRecoveredExtensions(string $type): void
|
|
{
|
|
// 코어 업데이트 진행 중에는 스킵 (validateAndDeactivate 와 동일 정책)
|
|
if (self::isCoreUpdateInProgress()) {
|
|
return;
|
|
}
|
|
|
|
$cache = $this->app->make(CacheInterface::class);
|
|
$cacheKey = ExtensionCompatibilityAlertListener::RECOVERY_CACHE_PREFIX
|
|
.$type.'.'.CoreVersionChecker::getCoreVersion();
|
|
|
|
// 이미 감지된 결과가 있으면 재계산 스킵 (TTL 1시간 + 코어 버전 변경 시 키 자체가 바뀜)
|
|
if ($cache->has($cacheKey)) {
|
|
return;
|
|
}
|
|
|
|
$repo = match ($type) {
|
|
'modules' => $this->app->make(ModuleRepositoryInterface::class),
|
|
'plugins' => $this->app->make(PluginRepositoryInterface::class),
|
|
'templates' => $this->app->make(TemplateRepositoryInterface::class),
|
|
default => null,
|
|
};
|
|
|
|
if (! $repo) {
|
|
return;
|
|
}
|
|
|
|
$recovered = [];
|
|
foreach ($repo->findAutoDeactivated() as $record) {
|
|
$required = $record->incompatible_required_version;
|
|
if ($required && CoreVersionChecker::isCompatible($required)) {
|
|
$recovered[] = [
|
|
'identifier' => $record->identifier,
|
|
'previously_required' => $required,
|
|
'deactivated_at' => $record->deactivated_at,
|
|
];
|
|
}
|
|
}
|
|
|
|
$cache->put($cacheKey, $recovered, CoreVersionChecker::getCacheTtl());
|
|
}
|
|
|
|
/**
|
|
* 호환되지 않는 템플릿을 자동 비활성화합니다.
|
|
*
|
|
* 코어 버전 업데이트 후 템플릿이 새 코어 버전과 호환되지 않는 경우
|
|
* 자동으로 비활성화하고 로그를 기록합니다.
|
|
*
|
|
* @param TemplateManager $templateManager 템플릿 매니저
|
|
*/
|
|
/**
|
|
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
|
|
*
|
|
* 판정 조건 (OR):
|
|
* 1. 환경변수 `G7_UPDATE_IN_PROGRESS=1` — 부모 CoreUpdateCommand 가 시작 시 설정,
|
|
* spawn 자식에도 `$env` 로 전파
|
|
* 2. artisan command 이름이 `core:update` / `core:execute-upgrade-steps` — 1 이 전파되지
|
|
* 않은 극단 상황 대비 보조 판정
|
|
*
|
|
* 둘 중 하나라도 true 면 업데이트 컨텍스트로 간주하여 version-based 자동 비활성화 스킵.
|
|
*/
|
|
public static function isCoreUpdateInProgress(): bool
|
|
{
|
|
$envFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
|
|
if ($envFlag === '1' || $envFlag === 1 || $envFlag === true) {
|
|
return true;
|
|
}
|
|
|
|
$argv = $_SERVER['argv'] ?? [];
|
|
$command = $argv[1] ?? '';
|
|
|
|
return in_array($command, ['core:update', 'core:execute-upgrade-steps'], true);
|
|
}
|
|
|
|
protected function validateAndDeactivateIncompatibleTemplates(TemplateManager $templateManager): void
|
|
{
|
|
// 업데이트 중 자동 비활성화 스킵 (validateAndDeactivateIncompatibleExtensions 와 동일 사유)
|
|
if (self::isCoreUpdateInProgress()) {
|
|
return;
|
|
}
|
|
|
|
$cache = $this->app->make(CacheInterface::class);
|
|
$cacheKey = CoreVersionChecker::getCacheKey('templates');
|
|
|
|
// 캐시가 있으면 이미 검증된 것으로 간주
|
|
if ($cache->has($cacheKey)) {
|
|
return;
|
|
}
|
|
|
|
$deactivated = [];
|
|
$allTemplates = $templateManager->getAllTemplates();
|
|
$templateRepository = $this->app->make(TemplateRepositoryInterface::class);
|
|
|
|
foreach ($allTemplates as $identifier => $template) {
|
|
// 설치되고 활성화된 템플릿만 검증
|
|
$templateRecord = $templateRepository->findByIdentifier($identifier);
|
|
if (! $templateRecord || $templateRecord->status !== 'active') {
|
|
continue;
|
|
}
|
|
|
|
$requiredVersion = $template['g7_version'] ?? null;
|
|
|
|
if (! CoreVersionChecker::isCompatible($requiredVersion)) {
|
|
// 자동 비활성화: reason='incompatible_core' + 요구 버전 전달
|
|
$templateManager->deactivateTemplate(
|
|
$identifier,
|
|
DeactivationReason::IncompatibleCore->value,
|
|
$requiredVersion
|
|
);
|
|
$deactivated[] = [
|
|
'identifier' => $identifier,
|
|
'required' => $requiredVersion,
|
|
];
|
|
|
|
Log::warning(__('extensions.warnings.auto_deactivated'), [
|
|
'type' => 'templates',
|
|
'identifier' => $identifier,
|
|
'required_version' => $requiredVersion,
|
|
'core_version' => config('app.version'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 비활성화된 템플릿이 있으면 세션에 저장 (관리자 알림용)
|
|
if (! empty($deactivated)) {
|
|
$this->storeDeactivatedExtensionsAlert('templates', $deactivated);
|
|
}
|
|
|
|
$cache->put($cacheKey, true, CoreVersionChecker::getCacheTtl());
|
|
}
|
|
|
|
/**
|
|
* 비활성화된 확장 알림을 캐시에 저장합니다.
|
|
*
|
|
* 관리자 대시보드에서 비활성화된 확장에 대한 알림을 표시하기 위해
|
|
* 캐시에 정보를 저장합니다.
|
|
*
|
|
* @param string $type 확장 타입 (modules, plugins, templates)
|
|
* @param array $deactivated 비활성화된 확장 목록
|
|
*/
|
|
protected function storeDeactivatedExtensionsAlert(string $type, array $deactivated): void
|
|
{
|
|
$cache = $this->app->make(CacheInterface::class);
|
|
$alerts = $cache->get('ext.compatibility_alerts', []);
|
|
$alerts[$type] = [
|
|
'deactivated' => $deactivated,
|
|
'core_version' => config('app.version'),
|
|
'timestamp' => now()->toIso8601String(),
|
|
];
|
|
$cache->put('ext.compatibility_alerts', $alerts, 86400); // 24시간
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모듈의 환경설정을 Config에 로드합니다.
|
|
*
|
|
* ModuleSettingsInterface를 구현한 서비스가 바인딩된 모듈의 경우
|
|
* getAllSettings()를 호출하여 g7_settings.modules.{identifier}에 저장합니다.
|
|
*
|
|
* @param ModuleManager $moduleManager 모듈 매니저
|
|
*/
|
|
protected function loadModuleSettingsToConfig(ModuleManager $moduleManager): void
|
|
{
|
|
$moduleSettings = [];
|
|
|
|
foreach (array_keys($moduleManager->getActiveModules()) as $identifier) {
|
|
try {
|
|
// 모듈별 환경설정 서비스 조회
|
|
$settingsService = $this->resolveModuleSettingsService($identifier);
|
|
|
|
if ($settingsService instanceof ModuleSettingsInterface) {
|
|
$settings = $settingsService->getAllSettings();
|
|
if (! empty($settings)) {
|
|
$moduleSettings[$identifier] = $settings;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning("모듈 환경설정 로딩 실패: {$identifier}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
Config::set('g7_settings.modules', $moduleSettings);
|
|
}
|
|
|
|
/**
|
|
* 모듈의 환경설정 서비스를 찾아 인스턴스화합니다.
|
|
*
|
|
* 다음 순서로 설정 서비스를 찾습니다:
|
|
* 1. 인터페이스 바인딩: Modules\Vendor\Module\Contracts\ModuleSettingsServiceInterface
|
|
* 2. 구체 클래스: Modules\Vendor\Module\Services\ModuleSettingsService
|
|
*
|
|
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
|
|
* @return ModuleSettingsInterface|null 설정 서비스 인스턴스
|
|
*/
|
|
protected function resolveModuleSettingsService(string $identifier): ?ModuleSettingsInterface
|
|
{
|
|
// vendor-module 형식을 네임스페이스로 변환
|
|
$parts = explode('-', $identifier);
|
|
if (count($parts) < 2) {
|
|
return null;
|
|
}
|
|
|
|
$vendor = ucfirst($parts[0]);
|
|
$moduleName = ucfirst($parts[1]);
|
|
|
|
// 1. 인터페이스 바인딩 확인
|
|
$interfaceClass = "Modules\\{$vendor}\\{$moduleName}\\Contracts\\{$moduleName}SettingsServiceInterface";
|
|
if ($this->app->bound($interfaceClass)) {
|
|
$service = $this->app->make($interfaceClass);
|
|
if ($service instanceof ModuleSettingsInterface) {
|
|
return $service;
|
|
}
|
|
}
|
|
|
|
// 2. 구체 클래스 확인
|
|
$concreteClass = "Modules\\{$vendor}\\{$moduleName}\\Services\\{$moduleName}SettingsService";
|
|
if (class_exists($concreteClass)) {
|
|
$service = $this->app->make($concreteClass);
|
|
if ($service instanceof ModuleSettingsInterface) {
|
|
return $service;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 활성화된 플러그인의 환경설정을 Config에 로드합니다.
|
|
*
|
|
* storage/app/plugins/{identifier}/settings/setting.json 파일을 읽어
|
|
* g7_settings.plugins.{identifier}에 저장합니다.
|
|
*
|
|
* @param PluginManager $pluginManager 플러그인 매니저
|
|
*/
|
|
protected function loadPluginSettingsToConfig(PluginManager $pluginManager): void
|
|
{
|
|
$pluginSettings = [];
|
|
|
|
foreach (array_keys($pluginManager->getActivePlugins()) as $identifier) {
|
|
try {
|
|
$settingsPath = storage_path("app/plugins/{$identifier}/settings/setting.json");
|
|
|
|
if (File::exists($settingsPath)) {
|
|
$content = File::get($settingsPath);
|
|
$settings = json_decode($content, true);
|
|
|
|
if (json_last_error() === JSON_ERROR_NONE && ! empty($settings)) {
|
|
$pluginSettings[$identifier] = $settings;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning("플러그인 환경설정 로딩 실패: {$identifier}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
Config::set('g7_settings.plugins', $pluginSettings);
|
|
}
|
|
|
|
/**
|
|
* 확장 드라이버 Config을 적용합니다.
|
|
*
|
|
* 플러그인이 필터 훅으로 등록한 드라이버의 유효성을 검증하고,
|
|
* 사용 가능하면 액션 훅을 발행하여 Config을 적용합니다.
|
|
* 사용 불가능하면 기본 드라이버로 안전하게 폴백합니다.
|
|
*
|
|
* SettingsServiceProvider::register()에서 코어 드라이버 Config은 이미 적용된 상태이므로,
|
|
* 여기서는 플러그인 드라이버만 처리합니다.
|
|
*/
|
|
protected function applyExtensionDriverConfigs(): void
|
|
{
|
|
try {
|
|
$driverRegistry = $this->app->make(DriverRegistryService::class);
|
|
$configRepository = $this->app->make(JsonConfigRepository::class);
|
|
|
|
foreach ($driverRegistry->getCategories() as $category) {
|
|
$settingsKey = $driverRegistry->getSettingsKey($category);
|
|
|
|
if ($settingsKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$settings = $configRepository->getCategory($settingsKey['category']);
|
|
$selectedDriver = $settings[$settingsKey['key']] ?? '';
|
|
|
|
if (empty($selectedDriver)) {
|
|
continue;
|
|
}
|
|
|
|
// 코어 드라이버는 SettingsServiceProvider::register()에서 이미 적용됨
|
|
if ($driverRegistry->isCoreDriver($category, $selectedDriver)) {
|
|
continue;
|
|
}
|
|
|
|
// 플러그인 드라이버: 사용 가능 여부 확인
|
|
if ($driverRegistry->isDriverAvailable($category, $selectedDriver)) {
|
|
// 플러그인이 Config을 직접 적용하도록 액션 훅 발행
|
|
HookManager::doAction(
|
|
'core.settings.apply_driver_config',
|
|
$category,
|
|
$selectedDriver,
|
|
$settings
|
|
);
|
|
} else {
|
|
// 플러그인 드라이버가 사용 불가 → 기본 드라이버로 폴백
|
|
$defaultDriver = $driverRegistry->getDefaultDriver($category);
|
|
$configKey = $driverRegistry->getConfigKey($category);
|
|
|
|
if ($configKey && $defaultDriver) {
|
|
Config::set($configKey, $defaultDriver);
|
|
}
|
|
|
|
Log::warning("플러그인 드라이버 '{$selectedDriver}'가 '{$category}' 카테고리에서 사용 불가능합니다. 기본 드라이버 '{$defaultDriver}'로 폴백합니다.");
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('확장 드라이버 Config 적용 실패', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// registerActivityLogManager() 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체
|
|
|
|
private function registerCoreHookListeners(): void
|
|
{
|
|
// 캐시 우선: bootstrap/cache/hooks.php 가 있으면 스캔·리플렉션 없이 등록.
|
|
// 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔).
|
|
if (! $this->app->environment('testing') && $this->registerCoreHookListenersFromCache()) {
|
|
return;
|
|
}
|
|
|
|
$listenersPath = app_path('Listeners');
|
|
|
|
if (! is_dir($listenersPath)) {
|
|
return;
|
|
}
|
|
|
|
// 재귀적으로 모든 PHP 파일 스캔
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS)
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
if ($file->getExtension() !== 'php') {
|
|
continue;
|
|
}
|
|
|
|
// 파일 경로에서 클래스명 추출
|
|
$relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
|
|
$relativePath = str_replace('.php', '', $relativePath);
|
|
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
|
|
$listenerClass = 'App\\Listeners\\'.$relativePath;
|
|
|
|
// 클래스 존재 여부 확인
|
|
if (! class_exists($listenerClass)) {
|
|
Log::warning("코어 훅 리스너 클래스를 찾을 수 없습니다: {$listenerClass}");
|
|
|
|
continue;
|
|
}
|
|
|
|
// HookListenerInterface 구현 여부 확인
|
|
if (! in_array(HookListenerInterface::class, class_implements($listenerClass))) {
|
|
continue;
|
|
}
|
|
|
|
$this->registerCoreHookListener($listenerClass);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 훅 캐시에서 코어 리스너를 등록합니다.
|
|
*
|
|
* 캐시 파일(bootstrap/cache/hooks.php)이 존재하면 디렉토리 스캔·class_implements
|
|
* 리플렉션·getSubscribedHooks() 클래스 로딩 없이 사전 계산 매핑으로 등록한다.
|
|
* 동적 훅(registerDynamicHooks) 보유 리스너는 스캔 경로와 동일하게 boot 후반부 실행을 위해 지연 목록에 담는다.
|
|
*
|
|
* @return bool 캐시로 등록했으면 true, 캐시 부재/손상 시 false (스캔 폴백)
|
|
*/
|
|
private function registerCoreHookListenersFromCache(): bool
|
|
{
|
|
$cache = $this->app->make(HookCacheManager::class)->read();
|
|
|
|
if ($cache === null) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($cache['core'] as $entry) {
|
|
try {
|
|
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], 'core');
|
|
|
|
if (! empty($entry['dynamic'])) {
|
|
$this->deferredDynamicListeners[] = $entry['listener'];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('코어 훅 리스너 캐시 등록 중 오류 발생', [
|
|
'listener' => $entry['listener'] ?? null,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 단일 코어 리스너를 HookManager에 등록합니다.
|
|
*
|
|
* @param string $listenerClass 리스너 클래스명
|
|
*/
|
|
private function registerCoreHookListener(string $listenerClass): void
|
|
{
|
|
try {
|
|
HookListenerRegistrar::register($listenerClass, 'core');
|
|
|
|
// registerDynamicHooks() 메서드를 가진 리스너는 boot 후반부에서 실행
|
|
if (method_exists($listenerClass, 'registerDynamicHooks')) {
|
|
$this->deferredDynamicListeners[] = $listenerClass;
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('코어 훅 리스너 등록 중 오류 발생', [
|
|
'listener' => $listenerClass,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* registerDynamicHooks() 메서드를 가진 리스너들의 동적 훅을 일괄 등록합니다.
|
|
*
|
|
* DB 접근이 필요하므로 boot() 후반부(DB 유효성 검증 후)에서 호출됩니다.
|
|
*/
|
|
private function registerDeferredDynamicHooks(): void
|
|
{
|
|
foreach ($this->deferredDynamicListeners as $listenerClass) {
|
|
try {
|
|
$listener = app($listenerClass);
|
|
$listener->registerDynamicHooks();
|
|
|
|
Log::info('동적 훅 리스너 등록 완료', ['listener' => $listenerClass]);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('동적 훅 리스너 등록 실패', [
|
|
'listener' => $listenerClass,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 시스템 전역 라우트를 주입하는 필터를 등록합니다.
|
|
*
|
|
* core.routes.filter_merged 필터를 통해 프리뷰 등 코어 시스템 라우트를
|
|
* 모든 템플릿의 routes.json 응답에 자동 주입합니다.
|
|
*/
|
|
private function registerSystemRouteFilters(): void
|
|
{
|
|
HookManager::addFilter('core.routes.filter_merged', function (array $routes, string $templateType, string $identifier) {
|
|
$basePath = $templateType === 'admin' ? '*/admin' : '*';
|
|
|
|
$routes[] = [
|
|
'path' => "{$basePath}/preview/:token",
|
|
'layout' => '__preview__',
|
|
'auth_required' => false,
|
|
'meta' => [
|
|
'title' => 'Preview',
|
|
'is_system_route' => true,
|
|
],
|
|
];
|
|
|
|
return $routes;
|
|
}, 100);
|
|
}
|
|
}
|