Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지 않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를 --no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는 `composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를 찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상 종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다. (sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10) 3계층으로 막는다. 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다. 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다. 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI 자체에 도달할 수 없다). 업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고 CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의 복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다. 실측 중 드러난 결함 2건을 함께 고쳤다. - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다. - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다). 인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인 카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를 정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다. 코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3). 코어 7.0.10 → 7.0.11.
977 lines
43 KiB
PHP
977 lines
43 KiB
PHP
<?php
|
|
|
|
namespace App\Providers;
|
|
|
|
use App\Benchmark\Axes\BatchAxisRunner;
|
|
use App\Benchmark\Axes\ListAxisRunner;
|
|
use App\Benchmark\Axes\ScreenAxisRunner;
|
|
use App\Benchmark\Axes\WriteAxisRunner;
|
|
use App\Console\Commands\BenchCommand;
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
|
use App\Contracts\Extension\HookListenerInterface;
|
|
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\TemplateService;
|
|
use App\Services\UniqueIdService;
|
|
use App\Support\CoreUpdateContext;
|
|
use App\Support\ExtensionSettingsMirror;
|
|
use App\Support\PrivilegedDatabaseAccounts;
|
|
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();
|
|
$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);
|
|
});
|
|
|
|
// 템플릿 서비스도 공유 인스턴스로 등록한다.
|
|
// 미등록 상태에서는 주입 지점마다 새로 만들어지고, 그 생성자가 매번 템플릿
|
|
// 디렉토리를 재스캔했다. 요청 단위 상태는 라우트 병합 열화 플래그 하나뿐이며
|
|
// 그 플래그는 병합 진입 시 재설정되므로 공유해도 안전하다.
|
|
$this->app->singleton(TemplateService::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' => CoreVersionChecker::getCoreVersion(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 비활성화된 확장이 있으면 세션에 저장 (관리자 알림용)
|
|
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());
|
|
}
|
|
|
|
/**
|
|
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
|
|
*
|
|
* 판정은 `App\Support\CoreUpdateContext::isInProgress()` 가 단독으로 소유한다 — 같은
|
|
* 플래그가 확장 자동 비활성화 스킵 · 코어 버전의 env 우선 판독 · `bootstrap/app.php` 의
|
|
* 패키지 매니페스트 자가 치유를 함께 게이트하므로, 판정이 갈라지면 한 경로만 조용히
|
|
* 다르게 동작한다. 이 메서드는 기존 호출처(각 Manager)를 위한 위임으로 남는다.
|
|
*
|
|
* @return bool 업데이트 트리 안이면 true (version-based 자동 비활성화 스킵)
|
|
*/
|
|
public static function isCoreUpdateInProgress(): bool
|
|
{
|
|
return CoreUpdateContext::isInProgress();
|
|
}
|
|
|
|
/**
|
|
* 호환되지 않는 템플릿을 자동 비활성화합니다.
|
|
*
|
|
* 코어 버전 업데이트 후 템플릿이 새 코어 버전과 호환되지 않는 경우
|
|
* 자동으로 비활성화하고 로그를 기록합니다.
|
|
*
|
|
* @param TemplateManager $templateManager 템플릿 매니저
|
|
*/
|
|
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' => CoreVersionChecker::getCoreVersion(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 비활성화된 템플릿이 있으면 세션에 저장 (관리자 알림용)
|
|
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' => CoreVersionChecker::getCoreVersion(),
|
|
'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
|
|
{
|
|
// 미러 채움 로직은 ExtensionSettingsMirror 가 단일 소유한다 (공개이슈 #109).
|
|
app(ExtensionSettingsMirror::class)->refreshAllModules();
|
|
}
|
|
|
|
/**
|
|
* 활성화된 플러그인의 환경설정을 Config에 로드합니다.
|
|
*
|
|
* storage/app/plugins/{identifier}/settings/setting.json 파일을 읽어
|
|
* g7_settings.plugins.{identifier}에 저장합니다.
|
|
*
|
|
* @param PluginManager $pluginManager 플러그인 매니저
|
|
*/
|
|
protected function loadPluginSettingsToConfig(PluginManager $pluginManager): void
|
|
{
|
|
// 종전에는 setting.json 을 raw 로 읽어 defaults 병합·정규화가 빠지고 암호문이 그대로
|
|
// 실렸다 — 값의 형태가 전용 게터와 달랐다. 이제 미러 소유자에 위임한다 (공개이슈 #109).
|
|
app(ExtensionSettingsMirror::class)->refreshAllPlugins();
|
|
}
|
|
|
|
/**
|
|
* 확장 드라이버 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);
|
|
}
|
|
}
|