*/ private array $deferredDynamicListeners = []; /** * 코어 서비스들을 등록합니다. */ public function register(): void { // Extension autoload 등록 (진입점에서 누락된 경우를 대비) $this->registerExtensionAutoload(); $this->registerRepositoryBindings(); $this->registerExtensionManagers(); $this->registerBenchmarkAxes(); // ActivityLogManager 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체 } /** * 성능 계측 축 실행기를 등록합니다. * * 축이 늘어날 때 `g7:bench` 커맨드를 고치지 않고 여기에 실행기만 추가하면 되도록 * 태그로 묶어 주입합니다. 실행기는 CLI 계측 시점에만 해석되므로 웹 요청 비용은 없습니다. */ private function registerBenchmarkAxes(): void { $this->app->tag([ ListAxisRunner::class, ScreenAxisRunner::class, WriteAxisRunner::class, BatchAxisRunner::class, ], 'benchmark.axes'); $this->app->when(BenchCommand::class) ->needs('$runners') ->giveTagged('benchmark.axes'); } /** * 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', 'attachments')); }); // TemplateLayoutAttachmentService용 CoreStorageDriver 바인딩 $this->app->when(TemplateLayoutAttachmentService::class) ->needs(StorageInterface::class) ->give(function () { return new CoreStorageDriver(config('attachment.disk', 'attachments')); }); // 코어 서비스용 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 연결 유효성 검증 (최고권한 계정 접속 방지) if (! $this->isDatabaseConnectionValid()) { Log::error('Database connection invalid: using a privileged database account 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, ); } } } } /** * 데이터베이스 연결이 유효한지 검증합니다. * * DB 최고권한 계정으로 접속하거나 사용자명이 비어 있는 경우를 감지하여 * 잘못된 설정으로 인한 접속 오류를 방지합니다. * 계정 판정은 `PrivilegedDatabaseAccounts` 가 SSoT 입니다. * * @return bool 연결이 유효하면 true */ protected function isDatabaseConnectionValid(): bool { try { // read/write 분리 설정의 우선순위 규칙까지 PrivilegedDatabaseAccounts 가 소유한다. // 호출처마다 규칙이 달라지면 같은 설정에 서로 다른 판정이 나온다. $username = PrivilegedDatabaseAccounts::resolveUsername(DB::connection()->getConfig()); return PrivilegedDatabaseAccounts::isUsable($username); } 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) { // log 카테고리의 적용 키(stack.channels)는 배열형 — 형태 변환은 레지스트리가 담당 Config::set($configKey, $driverRegistry->getConfigValueForDriver($category, $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(); // 등록 성공은 로그로 남기지 않는다 — 요청마다 부팅되는 경로다. 실패만 아래에 남긴다. } 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); } }