- 설치 흐름(installPlugin/installModule)이 파일 복사 직후 그 확장의 PSR-4 경로를 등록하도록 앞당김 (종전에는 시더 직전에만 등록해 검증·설정 초기화 단계가 사각) - gdpr: 잠금 집합을 진입 파일 Plugin::lockedNecessaryStorage 가 소유하고 Support\NecessaryAllowlist::locked 는 위임 — plugin.php 가 src/ 오토로드에 비의존 - 회귀 테스트: EntryFileAutoloadIndependenceTest, ExtensionInstallAutoloadOrderTest - CHANGELOG 정책 정리: 코어 항목에서 확장·쇼핑몰 문구 제거, 쇼핑몰 증상은 이커머스 1.2.1 로 이동, gdpr 1.0.4 항목·AGENTS.md 갱신
5051 lines
207 KiB
PHP
5051 lines
207 KiB
PHP
<?php
|
|
|
|
namespace App\Extension;
|
|
|
|
use App\Contracts\Extension\HookListenerInterface;
|
|
use App\Contracts\Extension\PluginInterface;
|
|
use App\Contracts\Extension\PluginManagerInterface;
|
|
use App\Contracts\Extension\UpgradeStepInterface;
|
|
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
|
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
|
use App\Contracts\Repositories\PermissionRepositoryInterface;
|
|
use App\Contracts\Repositories\PluginRepositoryInterface;
|
|
use App\Contracts\Repositories\RoleRepositoryInterface;
|
|
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
|
use App\Enums\DeactivationReason;
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Enums\ExtensionStatus;
|
|
use App\Enums\LayoutSourceType;
|
|
use App\Enums\PermissionType;
|
|
use App\Exceptions\LayoutIncludeException;
|
|
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
|
|
use App\Extension\Helpers\DependencyEnricher;
|
|
use App\Extension\Helpers\ExtensionBackupHelper;
|
|
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
|
|
use App\Extension\Helpers\ExtensionMenuSyncHelper;
|
|
use App\Extension\Helpers\ExtensionPendingHelper;
|
|
use App\Extension\Helpers\ExtensionRoleSyncHelper;
|
|
use App\Extension\Helpers\ExtensionStatusGuard;
|
|
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
|
|
use App\Extension\Helpers\FilePermissionHelper;
|
|
use App\Extension\Helpers\GithubHelper;
|
|
use App\Extension\Helpers\IdentityMessageSyncHelper;
|
|
use App\Extension\Helpers\IdentityPolicySyncHelper;
|
|
use App\Extension\Helpers\NotificationSyncHelper;
|
|
use App\Extension\Testing\ExtensionTestAllowlist;
|
|
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
|
use App\Extension\Vendor\VendorInstallContext;
|
|
use App\Extension\Vendor\VendorInstallResult;
|
|
use App\Extension\Vendor\VendorMode;
|
|
use App\Extension\Vendor\VendorResolver;
|
|
use App\Models\IdentityPolicy;
|
|
use App\Models\Module;
|
|
use App\Models\Plugin;
|
|
use App\Models\Template;
|
|
use App\Providers\CoreServiceProvider;
|
|
use App\Services\DriverRegistryService;
|
|
use App\Services\LayoutExtensionService;
|
|
use App\Support\AssetUrl;
|
|
use App\Support\ExtensionStoragePath;
|
|
use App\Support\RouteCacheHelper;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Broadcast;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
class PluginManager implements PluginManagerInterface
|
|
{
|
|
use ResolvesExtensionSharedRecords;
|
|
use Traits\CachesPluginStatus;
|
|
use Traits\ClearsTemplateCaches;
|
|
use Traits\ComputesLayoutContentHash;
|
|
use Traits\InspectsUninstallData;
|
|
use Traits\InvalidatesLayoutCache;
|
|
use Traits\RefreshesLayoutExtensions;
|
|
use Traits\ValidatesLayoutFiles;
|
|
use Traits\ValidatesSeoVariables;
|
|
use Traits\ValidatesTranslationPath;
|
|
|
|
/** @var int install 프로그레스바 단계 수 */
|
|
public const INSTALL_STEPS = 8;
|
|
|
|
/** @var int update 프로그레스바 단계 수 */
|
|
public const UPDATE_STEPS = 11;
|
|
|
|
/** @var int uninstall 프로그레스바 단계 수 */
|
|
public const UNINSTALL_STEPS = 5;
|
|
|
|
protected array $plugins = [];
|
|
|
|
protected string $pluginsPath;
|
|
|
|
/**
|
|
* _pending 디렉토리의 플러그인 메타데이터 (identifier => metadata)
|
|
*/
|
|
protected array $pendingPlugins = [];
|
|
|
|
/**
|
|
* _bundled 디렉토리의 플러그인 메타데이터 (identifier => metadata)
|
|
*/
|
|
protected array $bundledPlugins = [];
|
|
|
|
protected string $pendingPluginsPath;
|
|
|
|
protected string $bundledPluginsPath;
|
|
|
|
public function __construct(
|
|
protected ExtensionManager $extensionManager,
|
|
protected PluginRepositoryInterface $pluginRepository,
|
|
protected PermissionRepositoryInterface $permissionRepository,
|
|
protected RoleRepositoryInterface $roleRepository,
|
|
protected TemplateRepositoryInterface $templateRepository,
|
|
protected ModuleRepositoryInterface $moduleRepository,
|
|
protected LayoutRepositoryInterface $layoutRepository,
|
|
protected LayoutExtensionService $layoutExtensionService,
|
|
protected ?ExtensionRoleSyncHelper $roleSyncHelper = null
|
|
) {
|
|
$this->pluginsPath = base_path('plugins');
|
|
$this->pendingPluginsPath = $this->pluginsPath.DIRECTORY_SEPARATOR.'_pending';
|
|
$this->bundledPluginsPath = $this->pluginsPath.DIRECTORY_SEPARATOR.'_bundled';
|
|
}
|
|
|
|
/**
|
|
* ExtensionRoleSyncHelper 인스턴스를 반환합니다.
|
|
*
|
|
* @return ExtensionRoleSyncHelper 역할/권한 동기화 헬퍼
|
|
*/
|
|
protected function getRoleSyncHelper(): ExtensionRoleSyncHelper
|
|
{
|
|
if ($this->roleSyncHelper === null) {
|
|
$this->roleSyncHelper = app(ExtensionRoleSyncHelper::class);
|
|
}
|
|
|
|
return $this->roleSyncHelper;
|
|
}
|
|
|
|
/**
|
|
* 모든 플러그인을 로드하고 초기화합니다.
|
|
*/
|
|
public function loadPlugins(): void
|
|
{
|
|
if (! File::exists($this->pluginsPath)) {
|
|
return;
|
|
}
|
|
|
|
$directories = File::directories($this->pluginsPath);
|
|
|
|
foreach ($directories as $directory) {
|
|
$pluginName = basename($directory);
|
|
|
|
// _bundled, _pending 등 내부 디렉토리 건너뛰기
|
|
if (str_starts_with($pluginName, '_')) {
|
|
continue;
|
|
}
|
|
|
|
$pluginFile = $directory.'/plugin.php';
|
|
$pluginJson = $directory.'/plugin.json';
|
|
|
|
// 무결성 검사: 활성 디렉토리는 있으나 manifest 누락 감지
|
|
if (! File::exists($pluginFile) || ! File::exists($pluginJson)) {
|
|
Log::warning('플러그인 활성 디렉토리가 불완전합니다 (manifest 누락)', [
|
|
'plugin' => $pluginName,
|
|
'directory' => $directory,
|
|
'plugin_php' => File::exists($pluginFile),
|
|
'plugin_json' => File::exists($pluginJson),
|
|
'hint' => "복구: php artisan plugin:install {$pluginName} --force",
|
|
]);
|
|
}
|
|
|
|
if (File::exists($pluginFile)) {
|
|
// vendor-plugin 형식을 네임스페이스로 변환
|
|
$namespace = $this->convertDirectoryToNamespace($pluginName);
|
|
$pluginClass = "Plugins\\{$namespace}\\Plugin";
|
|
|
|
// 클래스가 아직 로드되지 않은 경우에만 require
|
|
// (_bundled에서 이미 로드된 경우 중복 선언 방지)
|
|
if (! class_exists($pluginClass, false)) {
|
|
require_once $pluginFile;
|
|
}
|
|
|
|
if (class_exists($pluginClass)) {
|
|
$plugin = new $pluginClass;
|
|
if ($plugin instanceof PluginInterface) {
|
|
$this->plugins[$pluginName] = $plugin;
|
|
|
|
// 플러그인 config 파일 로드
|
|
$this->loadPluginConfig($plugin);
|
|
|
|
// 훅 리스너 자동 등록
|
|
$this->registerPluginHookListeners($plugin);
|
|
|
|
// 브로드캐스트 채널 자동 등록
|
|
$this->registerPluginChannels($plugin);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// _pending 디렉토리 로드
|
|
$this->loadPendingPlugins();
|
|
|
|
// _bundled 디렉토리 로드
|
|
$this->loadBundledPlugins();
|
|
}
|
|
|
|
/**
|
|
* _pending 디렉토리의 플러그인 메타데이터를 로드합니다.
|
|
*
|
|
* 클래스 로드 없이 plugin.json 메타데이터만 읽어 저장합니다.
|
|
* 이미 활성 디렉토리에 로드된 플러그인은 제외합니다.
|
|
*/
|
|
protected function loadPendingPlugins(): void
|
|
{
|
|
$pending = ExtensionPendingHelper::loadPendingExtensions($this->pluginsPath, 'plugin.json');
|
|
|
|
foreach ($pending as $identifier => $metadata) {
|
|
// 이미 활성 디렉토리에 로드된 플러그인은 제외
|
|
if (isset($this->plugins[$identifier])) {
|
|
continue;
|
|
}
|
|
|
|
$this->pendingPlugins[$identifier] = $metadata;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _bundled 디렉토리의 플러그인 메타데이터를 로드합니다.
|
|
*
|
|
* 클래스 로드 없이 plugin.json 메타데이터만 읽어 저장합니다.
|
|
* 이미 활성 디렉토리 또는 _pending에 로드된 플러그인은 제외합니다.
|
|
*/
|
|
protected function loadBundledPlugins(): void
|
|
{
|
|
$bundled = ExtensionPendingHelper::loadBundledExtensions($this->pluginsPath, 'plugin.json');
|
|
|
|
foreach ($bundled as $identifier => $metadata) {
|
|
// 이미 활성 디렉토리 또는 pending에 로드된 플러그인은 제외
|
|
if (isset($this->plugins[$identifier]) || isset($this->pendingPlugins[$identifier])) {
|
|
continue;
|
|
}
|
|
|
|
$this->bundledPlugins[$identifier] = $metadata;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _pending 디렉토리의 플러그인 메타데이터를 반환합니다.
|
|
*
|
|
* @return array _pending 플러그인 메타데이터 배열
|
|
*/
|
|
public function getPendingPlugins(): array
|
|
{
|
|
return $this->pendingPlugins;
|
|
}
|
|
|
|
/**
|
|
* _bundled 디렉토리의 플러그인 메타데이터를 반환합니다.
|
|
*
|
|
* @return array _bundled 플러그인 메타데이터 배열
|
|
*/
|
|
public function getBundledPlugins(): array
|
|
{
|
|
return $this->bundledPlugins;
|
|
}
|
|
|
|
/**
|
|
* 디렉토리명(vendor-plugin)을 네임스페이스(Vendor\Plugin)로 변환합니다.
|
|
*
|
|
* 하이픈(-)은 네임스페이스 구분자(\)로, 언더스코어(_)는 PascalCase로 변환됩니다.
|
|
* 예: sirsoft-daum_postcode -> Sirsoft\DaumPostcode
|
|
*
|
|
* @param string $directoryName 디렉토리명 (예: sirsoft-payment, sirsoft-daum_postcode)
|
|
* @return string 네임스페이스 (예: Sirsoft\Payment, Sirsoft\DaumPostcode)
|
|
*/
|
|
protected function convertDirectoryToNamespace(string $directoryName): string
|
|
{
|
|
return ExtensionManager::directoryToNamespace($directoryName);
|
|
}
|
|
|
|
/**
|
|
* 활성화된 플러그인들만 반환합니다.
|
|
*
|
|
* @return array 활성화된 플러그인 배열
|
|
*/
|
|
public function getActivePlugins(): array
|
|
{
|
|
$activePlugins = [];
|
|
|
|
// 캐시된 활성화 플러그인 identifier 목록 활용
|
|
$activeIdentifiers = self::getActivePluginIdentifiers();
|
|
|
|
foreach ($this->plugins as $name => $plugin) {
|
|
if (in_array($plugin->getIdentifier(), $activeIdentifiers, true)) {
|
|
$activePlugins[$name] = $plugin;
|
|
}
|
|
}
|
|
|
|
return $activePlugins;
|
|
}
|
|
|
|
/**
|
|
* 지정된 플러그인을 시스템에 설치합니다.
|
|
*
|
|
* @param string $pluginName 설치할 플러그인명
|
|
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
|
|
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드
|
|
* @param bool $force 강제 설치 여부
|
|
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
|
* @return bool 설치 성공 여부
|
|
*
|
|
* @throws \Exception 플러그인을 찾을 수 없거나 의존성 문제 시
|
|
*/
|
|
public function installPlugin(
|
|
string $pluginName,
|
|
?\Closure $onProgress = null,
|
|
VendorMode $vendorMode = VendorMode::Auto,
|
|
bool $force = false,
|
|
?string &$failureReason = null,
|
|
): bool {
|
|
$failureReason = null;
|
|
|
|
// identifier 형식 검증 (내부 호출 방어)
|
|
ExtensionManager::validateIdentifierFormat($pluginName);
|
|
|
|
// 상태 가드: 이미 설치된 플러그인의 진행 중 상태 체크
|
|
$existingRecord = $this->pluginRepository->findByIdentifier($pluginName);
|
|
if ($existingRecord) {
|
|
ExtensionStatusGuard::assertNotInProgress(
|
|
ExtensionStatus::from($existingRecord->status),
|
|
$pluginName
|
|
);
|
|
}
|
|
|
|
// _pending 감지: 활성 디렉토리 부재 + _pending 존재 시 스테이징 설치 흐름
|
|
$activePath = $this->pluginsPath.DIRECTORY_SEPARATOR.$pluginName;
|
|
$composerDoneInPending = false;
|
|
$resolvedVendorMode = $vendorMode;
|
|
|
|
if (! File::isDirectory($activePath)
|
|
&& ExtensionPendingHelper::isPending($this->pluginsPath, $pluginName)) {
|
|
// _pending에서 Vendor 의존성 설치 (활성 디렉토리 이관 전)
|
|
if (! app()->environment('testing')) {
|
|
$pendingPath = ExtensionPendingHelper::getPendingPath($this->pluginsPath, $pluginName);
|
|
if ($this->extensionManager->hasComposerDependenciesAt($pendingPath)) {
|
|
$onProgress?->__invoke('composer', '_pending에서 Vendor 의존성 설치 중...');
|
|
try {
|
|
$vendorResult = $this->installVendorViaResolver(
|
|
$pluginName,
|
|
$pendingPath,
|
|
$vendorMode,
|
|
'install',
|
|
null,
|
|
$onProgress,
|
|
);
|
|
$resolvedVendorMode = $vendorResult->mode;
|
|
$composerDoneInPending = true;
|
|
} catch (VendorInstallException $e) {
|
|
Log::warning('플러그인 _pending Vendor 의존성 설치 실패', [
|
|
'plugin' => $pluginName,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// _pending 또는 _bundled에서 활성 디렉토리로 복사 (미설치 플러그인 설치 시)
|
|
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
|
|
// 검증은 로드된 확장 인스턴스를 요구해 복사보다 뒤에 온다. 그래서 검증이 실패하면
|
|
// 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고 오류도
|
|
// 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
|
|
$rollbackDirExisted = File::isDirectory($activePath);
|
|
|
|
$onProgress?->__invoke('copy', '파일 복사 중...');
|
|
$this->copyFromPendingOrBundled($pluginName, $onProgress, $force);
|
|
|
|
// 설치 시점에는 autoload-extensions.php 가 아직 갱신되지 않았다. plugin.php 의
|
|
// getConfigValues()/getSettingsSchema() 등이 자기 src/ 클래스를 호출하면 그 클래스가
|
|
// 해석되지 않아 "Class not found" 로 설치가 중단된다 (업그레이드 경로는 기설치본의
|
|
// 매핑이 이미 있어 재현되지 않는다). 시더 실행 직전이 아니라 진입 파일을 로드하기
|
|
// 전에 그 확장의 PSR-4 매핑을 등록한다.
|
|
ExtensionManager::registerExtensionAutoloadPaths('plugins', $pluginName);
|
|
|
|
try {
|
|
// 플러그인이 활성 디렉토리에 있지 않으면 로드 시도
|
|
$plugin = $this->getPlugin($pluginName);
|
|
if (! $plugin) {
|
|
// 복사 후 재로드 시도
|
|
$this->reloadPlugin($pluginName);
|
|
$plugin = $this->getPlugin($pluginName);
|
|
}
|
|
|
|
if (! $plugin) {
|
|
throw new \Exception(__('plugins.not_found', ['plugin' => $pluginName]));
|
|
}
|
|
|
|
// 그누보드7 코어 버전 호환성 검증
|
|
CoreVersionChecker::validateExtension(
|
|
$plugin->getRequiredCoreVersion(),
|
|
$plugin->getIdentifier(),
|
|
'plugin'
|
|
);
|
|
|
|
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
|
|
$this->checkDependencies($plugin);
|
|
|
|
// 언어 파일 경로 검증 (lang 경로 필수)
|
|
$this->validateTranslationPath($plugin, 'plugin');
|
|
|
|
// SEO 변수명 중복 검증
|
|
$this->validateSeoVariables($plugin, 'plugin');
|
|
|
|
// 플러그인 설치 실행
|
|
$onProgress?->__invoke('validate', '검증 중...');
|
|
$plugin->clearLifecycleFailureReason();
|
|
$result = $plugin->install();
|
|
|
|
if (! $result) {
|
|
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
|
|
}
|
|
|
|
if (! $result) {
|
|
return false;
|
|
}
|
|
|
|
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
|
|
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
|
|
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
|
|
$this->runMigrations($plugin);
|
|
|
|
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
|
|
$onProgress?->__invoke('db', 'DB 등록 중...');
|
|
try {
|
|
DB::beginTransaction();
|
|
|
|
// GitHub에서 최신 버전 정보 가져오기
|
|
$latestVersion = $this->fetchLatestVersion($plugin);
|
|
$updateAvailable = $latestVersion ? version_compare($latestVersion, $plugin->getVersion(), '>') : false;
|
|
|
|
// 다국어 name, description 처리 (역호환성 지원)
|
|
$name = $this->convertToMultilingual($plugin->getName());
|
|
$description = $this->convertToMultilingual($plugin->getDescription());
|
|
|
|
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
|
|
$manifest = HookManager::applyFilters(
|
|
"plugin.{$plugin->getIdentifier()}.manifest.translations",
|
|
['name' => $name, 'description' => $description]
|
|
);
|
|
$name = $manifest['name'] ?? $name;
|
|
$description = $manifest['description'] ?? $description;
|
|
|
|
// 데이터베이스에 플러그인 정보 저장
|
|
$this->pluginRepository->updateOrCreate(
|
|
['identifier' => $plugin->getIdentifier()],
|
|
[
|
|
'vendor' => $plugin->getVendor(),
|
|
'name' => $name,
|
|
'version' => $plugin->getVersion(),
|
|
'latest_version' => $latestVersion,
|
|
'description' => $description,
|
|
'github_url' => $plugin->getGithubUrl(),
|
|
'github_changelog_url' => $this->buildChangelogUrl($plugin->getGithubUrl()),
|
|
'update_available' => $updateAvailable,
|
|
'metadata' => $plugin->getMetadata(),
|
|
'status' => ExtensionStatus::Inactive->value,
|
|
'vendor_mode' => $resolvedVendorMode->value,
|
|
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
|
|
'created_by' => Auth::id(),
|
|
'updated_by' => Auth::id(),
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]
|
|
);
|
|
|
|
// Role 자동 생성
|
|
$this->createPluginRoles($plugin);
|
|
|
|
// 권한 자동 생성
|
|
$this->createPluginPermissions($plugin);
|
|
|
|
// 권한-Role 연결
|
|
$this->assignPermissionsToRoles($plugin);
|
|
|
|
// 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서)
|
|
$this->createPluginMenus($plugin);
|
|
|
|
// IDV 정책 자동 동기화 (identity_policies 테이블)
|
|
$this->syncPluginIdentityPolicies($plugin);
|
|
|
|
// IDV 메시지 정의/템플릿 자동 동기화
|
|
$this->syncPluginIdentityMessages($plugin);
|
|
|
|
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
|
|
$this->syncPluginNotificationDefinitions($plugin);
|
|
|
|
DB::commit();
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
throw $e;
|
|
}
|
|
|
|
// Phase 3: 시더 실행 (트랜잭션 외부)
|
|
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
|
|
$onProgress?->__invoke('seed', '시더 실행 중...');
|
|
$this->runPluginSeeders($plugin);
|
|
|
|
// Phase 4: 기본 설정 파일 생성
|
|
$onProgress?->__invoke('settings', '설정 초기화 중...');
|
|
$this->initializePluginSettings($plugin);
|
|
|
|
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
|
|
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
|
|
if (! $composerDoneInPending) {
|
|
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
|
|
if (! app()->environment('testing')
|
|
&& $this->extensionManager->hasComposerDependencies('plugins', $pluginName)) {
|
|
$composerResult = $this->extensionManager->runComposerInstall('plugins', $pluginName);
|
|
if (! $composerResult) {
|
|
Log::warning('플러그인 Composer 의존성 설치 실패', ['plugin' => $pluginName]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
|
|
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
|
|
$this->extensionManager->updateComposerAutoload();
|
|
|
|
// Phase 6: 플러그인 상태 캐시 무효화
|
|
self::invalidatePluginStatusCache();
|
|
|
|
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
|
$this->incrementExtensionCacheVersion();
|
|
RouteCacheHelper::rebuild();
|
|
|
|
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
|
|
ExtensionMiddlewareRegistry::flush();
|
|
|
|
// 훅 발행: 플러그인 설치 완료
|
|
HookManager::doAction('core.plugins.installed', $pluginName);
|
|
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
|
|
$activePath,
|
|
$rollbackDirExisted,
|
|
$pluginName,
|
|
'plugin',
|
|
);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* 지정된 플러그인을 활성화합니다.
|
|
*
|
|
* @param string $pluginName 활성화할 플러그인명
|
|
* @param bool $force 필요 의존성이 충족되지 않아도 강제 활성화 여부
|
|
* @return array{success: bool, layouts_registered: int, warning?: bool, missing_modules?: array, missing_plugins?: array, message?: string} 활성화 결과 및 등록된 레이아웃 개수
|
|
*/
|
|
public function activatePlugin(string $pluginName, bool $force = false): array
|
|
{
|
|
$plugin = $this->getPlugin($pluginName);
|
|
if (! $plugin) {
|
|
return [
|
|
'success' => false,
|
|
'layouts_registered' => 0,
|
|
'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
|
|
];
|
|
}
|
|
|
|
$plugin->clearLifecycleFailureReason();
|
|
|
|
// 상태 가드: 진행 중 상태 체크
|
|
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
|
|
if ($record) {
|
|
ExtensionStatusGuard::assertNotInProgress(
|
|
ExtensionStatus::from($record->status),
|
|
$plugin->getIdentifier()
|
|
);
|
|
}
|
|
|
|
// 코어 버전 호환성 사전 검증
|
|
// - force=true 시 우회 (CLI/웹 모두)
|
|
// - 코어 업데이트 spawn 컨텍스트에서는 매니페스트와 코어 버전이 일시적으로
|
|
// 어긋날 수 있어 자동 비활성화 가드와 동일 정책으로 스킵
|
|
if (! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
|
|
CoreVersionChecker::validateExtension(
|
|
$plugin->getRequiredCoreVersion(),
|
|
$plugin->getIdentifier(),
|
|
'plugin'
|
|
);
|
|
}
|
|
|
|
// 의존성 검증: 필요한 모듈/플러그인이 활성화되어 있는지 확인
|
|
// 중첩 구조 ['modules' => [...], 'plugins' => [...]] 를 순회
|
|
$missingModules = [];
|
|
$missingPlugins = [];
|
|
|
|
$dependencies = $plugin->getDependencies();
|
|
foreach ($this->iterateNestedDependencies($dependencies) as $depIdentifier => $declaredType) {
|
|
if ($declaredType === 'module') {
|
|
$depModule = $this->moduleRepository->findByIdentifier($depIdentifier);
|
|
if (! $depModule) {
|
|
$missingModules[] = [
|
|
'identifier' => $depIdentifier,
|
|
'name' => $depIdentifier,
|
|
'status' => 'not_installed',
|
|
];
|
|
} elseif ($depModule->status !== ExtensionStatus::Active->value) {
|
|
$missingModules[] = [
|
|
'identifier' => $depModule->identifier,
|
|
'name' => $depModule->getLocalizedName(),
|
|
'status' => 'inactive',
|
|
];
|
|
}
|
|
} else {
|
|
$depPlugin = $this->pluginRepository->findByIdentifier($depIdentifier);
|
|
if (! $depPlugin) {
|
|
$missingPlugins[] = [
|
|
'identifier' => $depIdentifier,
|
|
'name' => $depIdentifier,
|
|
'status' => 'not_installed',
|
|
];
|
|
} elseif ($depPlugin->status !== ExtensionStatus::Active->value) {
|
|
$missingPlugins[] = [
|
|
'identifier' => $depPlugin->identifier,
|
|
'name' => $depPlugin->getLocalizedName(),
|
|
'status' => 'inactive',
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$hasMissingDependencies = ! empty($missingModules) || ! empty($missingPlugins);
|
|
|
|
if ($hasMissingDependencies && ! $force) {
|
|
// 필요한 의존성이 충족되지 않고 강제 활성화가 아닌 경우 경고 반환
|
|
return [
|
|
'success' => false,
|
|
'warning' => true,
|
|
'missing_modules' => $missingModules,
|
|
'missing_plugins' => $missingPlugins,
|
|
'message' => __('plugins.warnings.missing_dependencies'),
|
|
'layouts_registered' => 0,
|
|
];
|
|
}
|
|
|
|
$result = $plugin->activate();
|
|
$layoutsRegistered = 0;
|
|
|
|
if ($result) {
|
|
$this->pluginRepository->updateByIdentifier($plugin->getIdentifier(), [
|
|
'status' => ExtensionStatus::Active->value,
|
|
'deactivated_reason' => null,
|
|
'deactivated_at' => null,
|
|
'incompatible_required_version' => null,
|
|
'updated_by' => Auth::id(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
// 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
|
|
// 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
|
|
// 새 애플리케이션을 부팅해 "캐시된" 활성 플러그인 목록을 읽는다. 여기서 비우지 않으면
|
|
// 방금 활성으로 바뀐 이 플러그인이 목록에서 빠진 채 라우트가 박제되고,
|
|
// 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
|
|
self::invalidatePluginStatusCache();
|
|
|
|
// soft deleted된 플러그인 레이아웃 복원 (재활성화 시)
|
|
$this->restorePluginLayouts($plugin->getIdentifier());
|
|
|
|
// 플러그인 레이아웃 등록 (새로운 레이아웃 또는 업데이트)
|
|
$layoutsRegistered = $this->registerPluginLayouts($pluginName);
|
|
|
|
// 플러그인 레이아웃 확장 복원 (재활성화 시)
|
|
$this->restoreLayoutExtensions($plugin);
|
|
|
|
// 플러그인 레이아웃 확장 등록
|
|
$this->registerLayoutExtensions($plugin);
|
|
|
|
// 플러그인 다국어 데이터가 변경되므로 템플릿 언어 캐시 삭제
|
|
$this->clearAllTemplateLanguageCaches();
|
|
|
|
// 플러그인 routes 데이터가 변경되므로 템플릿 routes 캐시 삭제
|
|
$this->clearAllTemplateRoutesCaches();
|
|
|
|
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
|
$this->incrementExtensionCacheVersion();
|
|
RouteCacheHelper::rebuild();
|
|
|
|
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이
|
|
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
|
|
IdentityPolicy::flushRouteScopeCache();
|
|
|
|
// 확장 미들웨어 인덱스 무효화 — 재활성화 시 이 플러그인이 선언한 미들웨어가
|
|
// 게이트 매칭 후보에 다시 포함되도록 한다.
|
|
ExtensionMiddlewareRegistry::flush();
|
|
}
|
|
|
|
// 훅 발행: 플러그인 활성화 완료
|
|
if ($result) {
|
|
HookManager::doAction('core.plugins.activated', $pluginName);
|
|
}
|
|
|
|
if (! $result) {
|
|
// 플러그인이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
|
|
// 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
|
|
// 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
|
|
return [
|
|
'success' => false,
|
|
'layouts_registered' => $layoutsRegistered,
|
|
'reason' => $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'),
|
|
];
|
|
}
|
|
|
|
return ['success' => true, 'layouts_registered' => $layoutsRegistered];
|
|
}
|
|
|
|
/**
|
|
* soft deleted된 플러그인 레이아웃을 복원합니다.
|
|
*
|
|
* 플러그인 재활성화 시 이전에 soft delete된 레이아웃을 복원합니다.
|
|
*
|
|
* @param string $pluginIdentifier 플러그인 식별자
|
|
*/
|
|
protected function restorePluginLayouts(string $pluginIdentifier): void
|
|
{
|
|
try {
|
|
$restoredCount = $this->layoutRepository->restoreByModule($pluginIdentifier);
|
|
|
|
if ($restoredCount > 0) {
|
|
Log::info('플러그인 레이아웃 복원 완료', [
|
|
'plugin' => $pluginIdentifier,
|
|
'restored_count' => $restoredCount,
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 복원 실패: {$pluginIdentifier}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
// 복원 실패가 플러그인 활성화를 중단시키지 않음
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정된 플러그인을 비활성화합니다.
|
|
*
|
|
* @param string $pluginName 비활성화할 플러그인명
|
|
* @param bool $force 의존 확장이 있어도 강제 비활성화 여부
|
|
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
|
|
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
|
|
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, dependent_modules?: array, dependent_plugins?: array, message?: string} 비활성화 결과 및 삭제된 레이아웃 개수
|
|
*/
|
|
public function deactivatePlugin(
|
|
string $pluginName,
|
|
bool $force = false,
|
|
string $reason = DeactivationReason::Manual->value,
|
|
?string $incompatibleRequiredVersion = null,
|
|
): array {
|
|
$plugin = $this->getPlugin($pluginName);
|
|
if (! $plugin) {
|
|
return [
|
|
'success' => false,
|
|
'layouts_deleted' => 0,
|
|
'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
|
|
];
|
|
}
|
|
|
|
$plugin->clearLifecycleFailureReason();
|
|
|
|
// 상태 가드: 진행 중 상태 체크
|
|
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
|
|
if ($record) {
|
|
ExtensionStatusGuard::assertNotInProgress(
|
|
ExtensionStatus::from($record->status),
|
|
$plugin->getIdentifier()
|
|
);
|
|
}
|
|
|
|
// 역의존성 검증: 이 플러그인에 의존하는 활성 템플릿/모듈/플러그인 확인
|
|
$dependentTemplates = $this->templateRepository->findActiveByPluginDependency($pluginName);
|
|
$dependentModules = $this->moduleRepository->findActiveByPluginDependency($pluginName);
|
|
$dependentPlugins = $this->pluginRepository->findActiveByPluginDependency($pluginName);
|
|
|
|
$hasDependents = $dependentTemplates->isNotEmpty() ||
|
|
$dependentModules->isNotEmpty() ||
|
|
$dependentPlugins->isNotEmpty();
|
|
|
|
if ($hasDependents && ! $force) {
|
|
// 의존하는 확장이 있고 강제 비활성화가 아닌 경우 경고 반환
|
|
return [
|
|
'success' => false,
|
|
'warning' => true,
|
|
'dependent_templates' => $dependentTemplates->map(function (Template $template) {
|
|
return [
|
|
'identifier' => $template->identifier,
|
|
'name' => $template->getLocalizedName(),
|
|
'type' => $template->type,
|
|
];
|
|
})->toArray(),
|
|
'dependent_modules' => $dependentModules->map(function (Module $module) {
|
|
return [
|
|
'identifier' => $module->identifier,
|
|
'name' => $module->getLocalizedName(),
|
|
];
|
|
})->toArray(),
|
|
'dependent_plugins' => $dependentPlugins->map(function (Plugin $plugin) {
|
|
return [
|
|
'identifier' => $plugin->identifier,
|
|
'name' => $plugin->getLocalizedName(),
|
|
];
|
|
})->toArray(),
|
|
'message' => __('plugins.warnings.has_dependents'),
|
|
'layouts_deleted' => 0,
|
|
];
|
|
}
|
|
|
|
// 드라이버 사용 중 경고 확인
|
|
$driverWarnings = $this->checkDriversInUse($plugin->getIdentifier());
|
|
|
|
$result = $plugin->deactivate();
|
|
$layoutsDeleted = 0;
|
|
|
|
if ($result) {
|
|
$this->pluginRepository->updateByIdentifier($plugin->getIdentifier(), [
|
|
'status' => ExtensionStatus::Inactive->value,
|
|
'deactivated_reason' => $reason,
|
|
'deactivated_at' => now(),
|
|
'incompatible_required_version' => $incompatibleRequiredVersion,
|
|
'updated_by' => Auth::id(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
// 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
|
|
// 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 플러그인 목록을 읽으므로,
|
|
// 여기서 비우지 않으면 방금 비활성으로 바꾼 플러그인의 라우트가 그대로 박제되어
|
|
// 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
|
|
self::invalidatePluginStatusCache();
|
|
|
|
// 플러그인 레이아웃 soft delete
|
|
$layoutsDeleted = $this->softDeletePluginLayouts($plugin->getIdentifier());
|
|
|
|
// 플러그인 레이아웃 확장 soft delete
|
|
$this->unregisterLayoutExtensions($plugin);
|
|
|
|
// 플러그인 다국어 데이터가 변경되므로 템플릿 언어 캐시 삭제
|
|
$this->clearAllTemplateLanguageCaches();
|
|
|
|
// 플러그인 routes 데이터가 변경되므로 템플릿 routes 캐시 삭제
|
|
$this->clearAllTemplateRoutesCaches();
|
|
|
|
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
|
$this->incrementExtensionCacheVersion();
|
|
RouteCacheHelper::rebuild();
|
|
|
|
// 플러그인 자체 캐시 전체 정리
|
|
$this->flushPluginCache($plugin);
|
|
|
|
// 본인인증 route scope 캐시 무효화 — 비활성 플러그인이 선언한 정책이 enforce
|
|
// 대상에서 즉시 제외되도록 한다. 정책 행은 변경하지 않으므로(enabled 보존)
|
|
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
|
|
IdentityPolicy::flushRouteScopeCache();
|
|
|
|
// 확장 미들웨어 인덱스 무효화 — 비활성 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외.
|
|
ExtensionMiddlewareRegistry::flush();
|
|
|
|
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
|
HookManager::doAction('core.plugins.after_deactivate', $plugin->getIdentifier());
|
|
}
|
|
|
|
$response = ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
|
|
|
|
if (! $result) {
|
|
$response['reason'] = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
|
|
}
|
|
|
|
if (! empty($driverWarnings)) {
|
|
$response['driver_warnings'] = $driverWarnings;
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* 플러그인이 제공하는 드라이버 중 현재 사용 중인 것이 있는지 확인합니다.
|
|
*
|
|
* @param string $pluginIdentifier 플러그인 식별자
|
|
* @return array<array{category: string, driver_id: string}> 사용 중인 드라이버 목록
|
|
*/
|
|
private function checkDriversInUse(string $pluginIdentifier): array
|
|
{
|
|
try {
|
|
$driverRegistry = app(DriverRegistryService::class);
|
|
|
|
$driversInUse = $driverRegistry->getPluginProvidedDriversInUse($pluginIdentifier);
|
|
|
|
if (! empty($driversInUse)) {
|
|
$driverNames = array_map(
|
|
fn (array $d) => "{$d['category']}:{$d['driver_id']}",
|
|
$driversInUse
|
|
);
|
|
|
|
Log::warning("플러그인 '{$pluginIdentifier}' 비활성화: 사용 중인 드라이버가 기본값으로 전환됩니다.", [
|
|
'drivers' => $driverNames,
|
|
]);
|
|
}
|
|
|
|
return $driversInUse;
|
|
} catch (\Throwable $e) {
|
|
// 드라이버 확인 실패가 비활성화를 차단해서는 안 됨
|
|
Log::warning("플러그인 드라이버 사용 확인 실패: {$pluginIdentifier}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃을 soft delete합니다.
|
|
*
|
|
* 플러그인 비활성화 시 해당 플러그인의 레이아웃만 선택적으로 soft delete하고
|
|
* 관련 캐시를 무효화합니다.
|
|
*
|
|
* @param string $pluginIdentifier 플러그인 식별자
|
|
* @return int 삭제된 레이아웃 개수
|
|
*/
|
|
protected function softDeletePluginLayouts(string $pluginIdentifier): int
|
|
{
|
|
try {
|
|
$deletedCount = $this->layoutRepository->softDeleteByModule($pluginIdentifier);
|
|
|
|
if ($deletedCount === 0) {
|
|
Log::info("플러그인에 삭제할 레이아웃이 없습니다: {$pluginIdentifier}");
|
|
|
|
return 0;
|
|
}
|
|
|
|
// 레이아웃 캐시 무효화
|
|
$this->invalidateLayoutCache($pluginIdentifier);
|
|
|
|
Log::info('플러그인 레이아웃 soft delete 완료', [
|
|
'plugin' => $pluginIdentifier,
|
|
'deleted_count' => $deletedCount,
|
|
]);
|
|
|
|
return $deletedCount;
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 soft delete 실패: {$pluginIdentifier}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
// 레이아웃 삭제 실패가 플러그인 비활성화를 중단시키지 않음
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정된 플러그인을 시스템에서 제거합니다.
|
|
*
|
|
* @param string $pluginName 제거할 플러그인명
|
|
* @param bool $deleteData 플러그인 데이터(테이블) 삭제 여부
|
|
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
|
|
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
|
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
|
|
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
|
|
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
|
|
* @return bool 제거 성공 여부
|
|
*
|
|
* @throws \Exception 플러그인을 찾을 수 없을 때
|
|
*/
|
|
public function uninstallPlugin(
|
|
string $pluginName,
|
|
bool $deleteData = false,
|
|
?\Closure $onProgress = null,
|
|
?string &$failureReason = null,
|
|
?array &$preservedBackups = null,
|
|
): bool {
|
|
$failureReason = null;
|
|
$preservedBackups = [];
|
|
|
|
// 상태 가드: 진행 중 상태 체크
|
|
$existingRecord = $this->pluginRepository->findByIdentifier($pluginName);
|
|
if ($existingRecord) {
|
|
ExtensionStatusGuard::assertNotInProgress(
|
|
ExtensionStatus::from($existingRecord->status),
|
|
$pluginName
|
|
);
|
|
}
|
|
|
|
$plugin = $this->getPlugin($pluginName);
|
|
if (! $plugin) {
|
|
throw new \Exception(__('plugins.not_found', ['plugin' => $pluginName]));
|
|
}
|
|
|
|
// Phase 1: 데이터 삭제 옵션이 true인 경우 동적 데이터 정리 및 마이그레이션 롤백 (DDL - 트랜잭션 외부)
|
|
// MySQL에서 DROP TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
|
|
$onProgress?->__invoke('data_cleanup', '데이터 정리 중...');
|
|
if ($deleteData) {
|
|
// 동적 데이터 정리 (마이그레이션 롤백 전에 실행해야 메타 테이블 조회 가능)
|
|
$this->cleanupDynamicPluginData($plugin);
|
|
$this->rollbackMigrations($plugin);
|
|
}
|
|
|
|
$onProgress?->__invoke('db', 'DB 삭제 중...');
|
|
try {
|
|
DB::beginTransaction();
|
|
|
|
// 플러그인 제거 실행
|
|
$plugin->clearLifecycleFailureReason();
|
|
$result = $plugin->uninstall();
|
|
|
|
if (! $result) {
|
|
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
|
|
}
|
|
|
|
if ($result) {
|
|
// 권한/역할은 $deleteData=true 시에만 삭제.
|
|
// 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
|
|
if ($deleteData) {
|
|
$this->removePluginPermissions($plugin);
|
|
|
|
// IDV 정책도 data 옵션 선택 시 제거 (user_overrides 손실 허용)
|
|
if (Schema::hasTable('identity_policies')) {
|
|
try {
|
|
app(IdentityPolicySyncHelper::class)
|
|
->cleanupStalePolicies('plugin', $plugin->getIdentifier(), []);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('IDV 정책 정리 실패 (uninstall plugin)', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// IDV 메시지 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
|
|
if (Schema::hasTable('identity_message_definitions')) {
|
|
try {
|
|
app(IdentityMessageSyncHelper::class)
|
|
->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), []);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('IDV 메시지 정리 실패 (uninstall plugin)', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 알림 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
|
|
if (Schema::hasTable('notification_definitions')) {
|
|
try {
|
|
app(NotificationSyncHelper::class)
|
|
->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), []);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('알림 정의 정리 실패 (uninstall plugin)', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 플러그인 레이아웃 영구 삭제
|
|
$this->deletePluginLayouts($plugin->getIdentifier());
|
|
|
|
// 플러그인 레이아웃 확장 영구 삭제
|
|
$this->deleteLayoutExtensions($plugin);
|
|
|
|
// 데이터베이스에서 플러그인 정보 제거
|
|
$this->pluginRepository->deleteByIdentifier($plugin->getIdentifier());
|
|
}
|
|
|
|
DB::commit();
|
|
|
|
// 트랜잭션 외부에서 실행
|
|
if ($result) {
|
|
// 플러그인 상태 캐시 무효화 — DB 에서 플러그인 행을 지운 직후(커밋 직후)에 둔다.
|
|
// 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
|
|
// 캐시된 활성 플러그인 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된
|
|
// 플러그인이 목록에 남은 채로 라우트·훅이 박제된다.
|
|
self::invalidatePluginStatusCache();
|
|
|
|
// 플러그인 설정 디렉토리 삭제 (deleteData 옵션이 true인 경우)
|
|
if ($deleteData) {
|
|
$this->deletePluginSettingsDirectory($plugin);
|
|
// Composer vendor 디렉토리 및 composer.lock 삭제
|
|
$this->deleteVendorDirectory('plugins', $plugin->getIdentifier());
|
|
}
|
|
|
|
// 오토로드 병합 실행
|
|
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
|
|
$this->extensionManager->updateComposerAutoload();
|
|
|
|
// 플러그인 다국어 데이터가 변경되므로 템플릿 언어 캐시 삭제
|
|
$onProgress?->__invoke('cache', '캐시 삭제 중...');
|
|
$this->clearAllTemplateLanguageCaches();
|
|
|
|
// 플러그인 routes 데이터가 변경되므로 템플릿 routes 캐시 삭제
|
|
$this->clearAllTemplateRoutesCaches();
|
|
|
|
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
|
$this->incrementExtensionCacheVersion();
|
|
RouteCacheHelper::rebuild();
|
|
|
|
// 플러그인 자체 캐시 전체 정리
|
|
$this->flushPluginCache($plugin);
|
|
|
|
// 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외.
|
|
ExtensionMiddlewareRegistry::flush();
|
|
|
|
// 활성 플러그인 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
|
|
$onProgress?->__invoke('files', '파일 삭제 중...');
|
|
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
|
|
$this->pluginsPath,
|
|
$plugin->getIdentifier()
|
|
);
|
|
|
|
// 메모리에서 플러그인 제거
|
|
unset($this->plugins[$plugin->getIdentifier()]);
|
|
}
|
|
|
|
return $result;
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정된 이름의 플러그인 인스턴스를 반환합니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
* @return PluginInterface|null 플러그인 인스턴스 또는 null
|
|
*/
|
|
public function getPlugin(string $pluginName): ?PluginInterface
|
|
{
|
|
return $this->plugins[$pluginName] ?? null;
|
|
}
|
|
|
|
/**
|
|
* 로드된 모든 플러그인 인스턴스들을 반환합니다.
|
|
*
|
|
* @return array 모든 플러그인 배열
|
|
*/
|
|
public function getAllPlugins(): array
|
|
{
|
|
return $this->plugins;
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 버전을 반환합니다.
|
|
*
|
|
* 설치된 플러그인의 경우 DB에서, 미설치 플러그인의 경우 plugin.php에서 버전을 조회합니다.
|
|
*
|
|
* @param string $identifier 플러그인 식별자 (예: sirsoft-payment)
|
|
* @return string|null 버전 문자열 또는 null (플러그인이 없는 경우)
|
|
*/
|
|
public function getPluginVersion(string $identifier): ?string
|
|
{
|
|
// 먼저 DB에서 조회 (설치된 플러그인)
|
|
$plugin = $this->pluginRepository->findByIdentifier($identifier);
|
|
if ($plugin) {
|
|
return $plugin->version;
|
|
}
|
|
|
|
// DB에 없으면 로드된 플러그인 인스턴스에서 조회
|
|
foreach ($this->plugins as $plugin) {
|
|
if ($plugin->getIdentifier() === $identifier) {
|
|
return $plugin->getVersion();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 설치되지 않은 플러그인들을 반환합니다 (목록용 간소화된 정보).
|
|
*
|
|
* 활성 디렉토리, _pending, _bundled의 미설치 플러그인을 모두 포함합니다.
|
|
*
|
|
* @return array 미설치 플러그인 배열
|
|
*/
|
|
public function getUninstalledPlugins(): array
|
|
{
|
|
$uninstalledPlugins = [];
|
|
// 캐시된 설치된 플러그인 identifier 목록 활용
|
|
$installedPluginIdentifiers = self::getInstalledPluginIdentifiers();
|
|
$locale = app()->getLocale();
|
|
|
|
// 1. 활성 디렉토리의 미설치 플러그인
|
|
foreach ($this->plugins as $name => $plugin) {
|
|
if (! in_array($plugin->getIdentifier(), $installedPluginIdentifiers)) {
|
|
$identifier = $plugin->getIdentifier();
|
|
|
|
// 에셋 정보 수집 (API URL 경로 포함)
|
|
$assets = null;
|
|
if ($plugin->hasAssets()) {
|
|
$builtPaths = $plugin->getBuiltAssetPaths();
|
|
$loadingConfig = $plugin->getAssetLoadingConfig();
|
|
|
|
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
|
|
$assets = [
|
|
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
|
|
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
|
|
'priority' => $loadingConfig['priority'] ?? 100,
|
|
];
|
|
}
|
|
}
|
|
|
|
$uninstalledPlugins[$name] = [
|
|
'identifier' => $identifier,
|
|
'vendor' => $plugin->getVendor(),
|
|
'name' => $this->getLocalizedValue($plugin->getName(), $locale),
|
|
'version' => $plugin->getVersion(),
|
|
'description' => $this->getLocalizedValue($plugin->getDescription(), $locale),
|
|
'dependencies' => $this->enrichDependencies($plugin->getDependencies()),
|
|
'status' => 'uninstalled',
|
|
'is_pending' => false,
|
|
'is_bundled' => false,
|
|
'hidden' => $plugin->isHidden(),
|
|
'has_settings' => $plugin->hasSettings(),
|
|
'settings_route' => $plugin->getSettingsRoute(),
|
|
'assets' => $assets,
|
|
];
|
|
}
|
|
}
|
|
|
|
// 2. _pending 디렉토리의 미설치 플러그인
|
|
foreach ($this->pendingPlugins as $identifier => $metadata) {
|
|
if (! in_array($identifier, $installedPluginIdentifiers) && ! isset($uninstalledPlugins[$identifier])) {
|
|
$uninstalledPlugins[$identifier] = [
|
|
'identifier' => $identifier,
|
|
'vendor' => $metadata['vendor'] ?? '',
|
|
'name' => $this->getLocalizedValue($metadata['name'] ?? $identifier, $locale),
|
|
'version' => $metadata['version'] ?? '0.0.0',
|
|
'description' => $this->getLocalizedValue($metadata['description'] ?? '', $locale),
|
|
'dependencies' => $this->enrichDependencies($metadata['dependencies'] ?? []),
|
|
'status' => 'uninstalled',
|
|
'is_pending' => true,
|
|
'is_bundled' => false,
|
|
'hidden' => (bool) ($metadata['hidden'] ?? false),
|
|
'has_settings' => false,
|
|
'settings_route' => null,
|
|
'assets' => null,
|
|
];
|
|
}
|
|
}
|
|
|
|
// 3. _bundled 디렉토리의 미설치 플러그인
|
|
foreach ($this->bundledPlugins as $identifier => $metadata) {
|
|
if (! in_array($identifier, $installedPluginIdentifiers) && ! isset($uninstalledPlugins[$identifier])) {
|
|
$uninstalledPlugins[$identifier] = [
|
|
'identifier' => $identifier,
|
|
'vendor' => $metadata['vendor'] ?? '',
|
|
'name' => $this->getLocalizedValue($metadata['name'] ?? $identifier, $locale),
|
|
'version' => $metadata['version'] ?? '0.0.0',
|
|
'description' => $this->getLocalizedValue($metadata['description'] ?? '', $locale),
|
|
'dependencies' => $this->enrichDependencies($metadata['dependencies'] ?? []),
|
|
'status' => 'uninstalled',
|
|
'is_pending' => false,
|
|
'is_bundled' => true,
|
|
'hidden' => (bool) ($metadata['hidden'] ?? false),
|
|
'has_settings' => false,
|
|
'settings_route' => null,
|
|
'assets' => null,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $uninstalledPlugins;
|
|
}
|
|
|
|
/**
|
|
* 설치된 플러그인 정보를 데이터베이스 레코드와 함께 반환합니다 (목록용 간소화된 정보).
|
|
*
|
|
* 업데이트 관련 필드(update_available, latest_version, file_version, github_url)를 포함합니다.
|
|
*
|
|
* @return array 설치된 플러그인 배열
|
|
*/
|
|
public function getInstalledPluginsWithDetails(): array
|
|
{
|
|
$installedPlugins = [];
|
|
$pluginRecords = $this->pluginRepository->getAllKeyedByIdentifier();
|
|
$locale = app()->getLocale();
|
|
|
|
foreach ($this->plugins as $name => $plugin) {
|
|
$identifier = $plugin->getIdentifier();
|
|
if ($pluginRecords->has($identifier)) {
|
|
$record = $pluginRecords->get($identifier);
|
|
|
|
// 에셋 정보 수집 (API URL 경로 포함)
|
|
$assets = null;
|
|
if ($plugin->hasAssets()) {
|
|
$builtPaths = $plugin->getBuiltAssetPaths();
|
|
$loadingConfig = $plugin->getAssetLoadingConfig();
|
|
|
|
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
|
|
$assets = [
|
|
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
|
|
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
|
|
'priority' => $loadingConfig['priority'] ?? 100,
|
|
];
|
|
}
|
|
}
|
|
|
|
// 업데이트 감지: GitHub URL이 있으면 DB latest_version 비교, 없으면 파일 버전 비교
|
|
$fileVersion = $plugin->getVersion();
|
|
$updateAvailable = $record->update_available ?? false;
|
|
$latestVersion = $record->latest_version ?? null;
|
|
|
|
// update_available이 true인데 latest_version이 null이면 _bundled 버전으로 보완
|
|
if ($updateAvailable && $latestVersion === null) {
|
|
$bundledVersion = $this->bundledPlugins[$identifier]['version'] ?? null;
|
|
if ($bundledVersion === null) {
|
|
$bundledMeta = ExtensionPendingHelper::loadBundledExtensions($this->pluginsPath, 'plugin.json');
|
|
$bundledVersion = $bundledMeta[$identifier]['version'] ?? null;
|
|
}
|
|
$latestVersion = $bundledVersion ?? $fileVersion;
|
|
}
|
|
|
|
$nameJson = $record->name ?: $plugin->getName();
|
|
$descriptionJson = $record->description ?: $plugin->getDescription();
|
|
$installedPlugins[$name] = [
|
|
'identifier' => $identifier,
|
|
'vendor' => $plugin->getVendor(),
|
|
'name' => $this->getLocalizedValue($nameJson, $locale),
|
|
'version' => $record->version,
|
|
'description' => $this->getLocalizedValue($descriptionJson, $locale),
|
|
'dependencies' => $this->enrichDependencies($plugin->getDependencies()),
|
|
'status' => $record->status,
|
|
'update_available' => $updateAvailable,
|
|
'latest_version' => $latestVersion,
|
|
'file_version' => $fileVersion,
|
|
'update_source' => $record->update_source ?? null,
|
|
'github_url' => $plugin->getGithubUrl(),
|
|
'github_changelog_url' => $record->github_changelog_url ?? null,
|
|
'hidden' => $plugin->isHidden(),
|
|
'has_settings' => $plugin->hasSettings(),
|
|
'settings_route' => $plugin->getSettingsRoute(),
|
|
'assets' => $assets,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $installedPlugins;
|
|
}
|
|
|
|
/**
|
|
* 플러그인 상세 정보를 반환합니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
* @return array|null 플러그인 정보 배열 또는 null
|
|
*/
|
|
public function getPluginInfo(string $pluginName): ?array
|
|
{
|
|
$plugin = $this->getPlugin($pluginName);
|
|
|
|
// 활성 디렉토리에 없으면 pending/bundled 메타데이터에서 폴백
|
|
if (! $plugin) {
|
|
return $this->getPluginInfoFromMetadata($pluginName);
|
|
}
|
|
|
|
$identifier = $plugin->getIdentifier();
|
|
$pluginRecord = $this->pluginRepository->findByIdentifier($identifier);
|
|
$locale = app()->getLocale();
|
|
$metadata = $plugin->getMetadata();
|
|
$isInstalled = (bool) $pluginRecord;
|
|
|
|
// 에셋 정보 수집 (API URL 경로 포함 - 활성화 시 프론트엔드에서 사용)
|
|
$assets = null;
|
|
if ($plugin->hasAssets()) {
|
|
$builtPaths = $plugin->getBuiltAssetPaths();
|
|
$loadingConfig = $plugin->getAssetLoadingConfig();
|
|
|
|
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
|
|
$assets = [
|
|
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
|
|
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
|
|
'priority' => $loadingConfig['priority'] ?? 100,
|
|
];
|
|
}
|
|
}
|
|
|
|
// 다국어 필드는 DB row(applyExtensionManifests 가 활성 언어팩 ja 등을 주입) 우선,
|
|
// 미설치 시 plugin.json 폴백.
|
|
$pluginRecord = $this->pluginRepository->findByIdentifier($identifier);
|
|
$nameJson = $pluginRecord?->name ?: $plugin->getName();
|
|
$descriptionJson = $pluginRecord?->description ?: $plugin->getDescription();
|
|
|
|
return [
|
|
'identifier' => $identifier,
|
|
'vendor' => $plugin->getVendor(),
|
|
'name' => $this->getLocalizedValue($nameJson, $locale),
|
|
'version' => $plugin->getVersion(),
|
|
'description' => $this->getLocalizedValue($descriptionJson, $locale),
|
|
'github_url' => $plugin->getGithubUrl(),
|
|
'metadata' => $metadata,
|
|
'requires_core' => $plugin->getRequiredCoreVersion(),
|
|
'dependencies' => $this->enrichDependencies($plugin->getDependencies()),
|
|
'permissions' => $plugin->getPermissions(),
|
|
'roles' => $plugin->getRoles(),
|
|
'config' => $plugin->getConfigValues(),
|
|
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
|
|
'license' => $plugin->getLicense(),
|
|
'layouts_count' => $this->countPluginLayoutFiles($pluginName),
|
|
'status' => $pluginRecord ? $pluginRecord->status : 'uninstalled',
|
|
'is_installed' => $isInstalled,
|
|
'has_settings' => $plugin->hasSettings(),
|
|
'settings_route' => $plugin->getSettingsRoute(),
|
|
'assets' => $assets,
|
|
'created_at' => $pluginRecord?->created_at,
|
|
'updated_at' => $pluginRecord?->updated_at,
|
|
];
|
|
}
|
|
|
|
protected function getPluginInfoFromMetadata(string $pluginName): ?array
|
|
{
|
|
// pending/bundled 메타데이터 확인
|
|
$isPending = isset($this->pendingPlugins[$pluginName]);
|
|
$isBundled = isset($this->bundledPlugins[$pluginName]);
|
|
|
|
if (! $isPending && ! $isBundled) {
|
|
return null;
|
|
}
|
|
|
|
$metadata = $isPending ? $this->pendingPlugins[$pluginName] : $this->bundledPlugins[$pluginName];
|
|
$locale = app()->getLocale();
|
|
|
|
// PHP 클래스 임시 로드 시도 (permissions, roles, hooks 등 상세 정보 획득)
|
|
$plugin = $this->tryLoadPluginInstance($pluginName, $isPending);
|
|
|
|
if ($plugin) {
|
|
// 인스턴스 기반 상세 정보 반환 (활성 플러그인과 동일 수준)
|
|
$pluginMetadata = $plugin->getMetadata();
|
|
|
|
return [
|
|
'identifier' => $plugin->getIdentifier(),
|
|
'vendor' => $plugin->getVendor(),
|
|
'name' => $this->getLocalizedValue($plugin->getName(), $locale),
|
|
'version' => $plugin->getVersion(),
|
|
'description' => $this->getLocalizedValue($plugin->getDescription(), $locale),
|
|
'github_url' => $plugin->getGithubUrl(),
|
|
'metadata' => $pluginMetadata,
|
|
'requires_core' => $plugin->getRequiredCoreVersion(),
|
|
'dependencies' => $this->enrichDependencies($plugin->getDependencies()),
|
|
'permissions' => $plugin->getPermissions(),
|
|
'roles' => $plugin->getRoles(),
|
|
'config' => $plugin->getConfigValues(),
|
|
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
|
|
'license' => $plugin->getLicense() ?? $metadata['license'] ?? null,
|
|
'layouts_count' => 0,
|
|
'status' => 'uninstalled',
|
|
'is_installed' => false,
|
|
'has_settings' => $plugin->hasSettings(),
|
|
'settings_route' => $plugin->getSettingsRoute(),
|
|
'assets' => null,
|
|
'created_at' => null,
|
|
'updated_at' => null,
|
|
];
|
|
}
|
|
|
|
// PHP 클래스 로드 실패 시 JSON 메타데이터 기반 폴백
|
|
return [
|
|
'identifier' => $metadata['identifier'] ?? $pluginName,
|
|
'vendor' => $metadata['vendor'] ?? '',
|
|
'name' => $this->getLocalizedValue($metadata['name'] ?? $pluginName, $locale),
|
|
'version' => $metadata['version'] ?? '0.0.0',
|
|
'description' => $this->getLocalizedValue($metadata['description'] ?? '', $locale),
|
|
'github_url' => $metadata['github_url'] ?? null,
|
|
'metadata' => $metadata,
|
|
'requires_core' => $metadata['g7_version'] ?? null,
|
|
'dependencies' => $this->enrichDependencies($metadata['dependencies'] ?? []),
|
|
'permissions' => $metadata['permissions'] ?? [],
|
|
'roles' => $metadata['roles'] ?? [],
|
|
'config' => $metadata['config'] ?? [],
|
|
'hooks' => $this->normalizeHooksToArray($metadata['hooks'] ?? []),
|
|
'license' => $metadata['license'] ?? null,
|
|
'layouts_count' => 0,
|
|
'status' => 'uninstalled',
|
|
'is_installed' => false,
|
|
'has_settings' => false,
|
|
'settings_route' => null,
|
|
'assets' => null,
|
|
'created_at' => null,
|
|
'updated_at' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 연관 배열(키-값) 형태의 훅 목록을 인덱스 배열로 변환합니다.
|
|
*
|
|
* getHooks()가 ['hook.name' => ['type' => 'action', ...]] 형태를 반환하면
|
|
* 프론트엔드에서 .filter(), .length 등을 사용할 수 있도록
|
|
* [['name' => 'hook.name', 'type' => 'action', ...]] 형태로 변환합니다.
|
|
*
|
|
* @param array $hooks 훅 배열 (연관 또는 인덱스)
|
|
* @return array 인덱스 배열로 변환된 훅 목록
|
|
*/
|
|
protected function normalizeHooksToArray(array $hooks): array
|
|
{
|
|
if (empty($hooks)) {
|
|
return [];
|
|
}
|
|
|
|
// 이미 인덱스 배열이면 그대로 반환
|
|
if (array_is_list($hooks)) {
|
|
return $hooks;
|
|
}
|
|
|
|
// 연관 배열 → 인덱스 배열 변환 (키를 'name' 필드로 이동)
|
|
$normalized = [];
|
|
foreach ($hooks as $hookName => $hookData) {
|
|
$normalized[] = array_merge(['name' => $hookName], $hookData);
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* pending/bundled 디렉토리에서 플러그인 PHP 클래스를 임시 로드합니다.
|
|
*
|
|
* 상세 정보 조회 시에만 사용되며, 주 배열($this->plugins)에는 등록하지 않습니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
* @param bool $isPending pending 디렉토리 여부
|
|
* @return PluginInterface|null 플러그인 인스턴스 또는 null
|
|
*/
|
|
protected function tryLoadPluginInstance(string $pluginName, bool $isPending): ?PluginInterface
|
|
{
|
|
$subDir = $isPending ? '_pending' : '_bundled';
|
|
$pluginFile = $this->pluginsPath.'/'.$subDir.'/'.$pluginName.'/plugin.php';
|
|
|
|
if (! File::exists($pluginFile)) {
|
|
return null;
|
|
}
|
|
|
|
$namespace = $this->convertDirectoryToNamespace($pluginName);
|
|
$pluginClass = "Plugins\\{$namespace}\\Plugin";
|
|
|
|
try {
|
|
// 같은 플러그인의 활성 디렉토리 사본이 이미 로드돼 있으면 `_bundled`/`_pending`
|
|
// 파일을 그대로 require 할 수 없다 — 같은 FQN 을 두 번 선언하게 되어
|
|
// "Cannot declare class ..., because the name is already in use" 로 죽는다.
|
|
// 이것은 Error 라 아래 catch(\Exception) 에 걸리지 않아 프로세스가 그대로 종료된다.
|
|
//
|
|
// 파일 내용을 임시 클래스명으로 eval 해 번들 쪽 메타데이터를 얻는다.
|
|
if (class_exists($pluginClass, false)) {
|
|
return $this->evalFreshPlugin($pluginFile, $pluginClass, dirname($pluginFile));
|
|
}
|
|
|
|
require_once $pluginFile;
|
|
|
|
if (class_exists($pluginClass)) {
|
|
$plugin = new $pluginClass;
|
|
if ($plugin instanceof PluginInterface) {
|
|
return $plugin;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::debug("Failed to load bundled plugin instance for {$pluginName}: ".$e->getMessage());
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 다국어 값 추출 헬퍼 메서드
|
|
*
|
|
* @param mixed $value 추출할 값 (문자열 또는 배열)
|
|
* @param string|null $locale 로케일 (null이면 현재 로케일)
|
|
* @return string 추출된 값
|
|
*/
|
|
protected function getLocalizedValue($value, ?string $locale = null): string
|
|
{
|
|
$locale = $locale ?? app()->getLocale();
|
|
|
|
// 문자열인 경우 그대로 반환
|
|
if (is_string($value)) {
|
|
return $value;
|
|
}
|
|
|
|
// 배열인 경우 로케일에 맞는 값 반환
|
|
if (is_array($value)) {
|
|
// 요청 언어 → fallback 언어 → 첫 번째 값 → 빈 문자열
|
|
return $value[$locale]
|
|
?? $value[config('app.fallback_locale')]
|
|
?? (! empty($value) ? array_values($value)[0] : '')
|
|
?? '';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* 문자열을 다국어 배열로 변환 (역호환성)
|
|
*
|
|
* @param mixed $value 변환할 값 (문자열 또는 배열)
|
|
* @return array 다국어 배열
|
|
*/
|
|
protected function convertToMultilingual($value): array
|
|
{
|
|
// 이미 배열인 경우 그대로 반환
|
|
if (is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
// 문자열인 경우 모든 지원 언어로 변환
|
|
if (is_string($value)) {
|
|
$locales = config('app.translatable_locales', ['ko', 'en']);
|
|
$result = [];
|
|
foreach ($locales as $locale) {
|
|
$result[$locale] = $value;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
// 그 외의 경우 빈 배열
|
|
$locales = config('app.translatable_locales', ['ko', 'en']);
|
|
$result = [];
|
|
foreach ($locales as $locale) {
|
|
$result[$locale] = '';
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
protected function checkDependencies(PluginInterface $plugin): void
|
|
{
|
|
$dependencies = $plugin->getDependencies();
|
|
$unmetDependencies = [];
|
|
|
|
// 중첩 구조 ['modules' => [...], 'plugins' => [...]] 를 순회
|
|
foreach ($this->iterateNestedDependencies($dependencies) as $identifier => $declaredType) {
|
|
if (! $this->isDependencySatisfied($identifier, $declaredType)) {
|
|
$unmetDependencies[] = __('plugins.dependency_not_active', ['dependency' => $identifier]);
|
|
}
|
|
}
|
|
|
|
if (! empty($unmetDependencies)) {
|
|
throw new \Exception(implode("\n", $unmetDependencies));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 중첩 구조 의존성 배열을 순회하며 (identifier => declaredType) 를 yield 합니다.
|
|
*
|
|
* @param array $dependencies ['modules' => [...], 'plugins' => [...]] 형식
|
|
* @return \Generator<string, string> identifier => 'module'|'plugin'
|
|
*/
|
|
private function iterateNestedDependencies(array $dependencies): \Generator
|
|
{
|
|
if (isset($dependencies['modules']) && is_array($dependencies['modules'])) {
|
|
foreach ($dependencies['modules'] as $identifier => $versionConstraint) {
|
|
yield $identifier => 'module';
|
|
}
|
|
}
|
|
if (isset($dependencies['plugins']) && is_array($dependencies['plugins'])) {
|
|
foreach ($dependencies['plugins'] as $identifier => $versionConstraint) {
|
|
yield $identifier => 'plugin';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 의존성이 활성 상태로 설치되어 있는지 확인합니다.
|
|
*
|
|
* declaredType 이 'module' 이면 modules 테이블만, 'plugin' 이면 plugins 테이블만 검색.
|
|
*
|
|
* @param string $identifier 확장 식별자
|
|
* @param string $declaredType 'module' | 'plugin'
|
|
* @return bool 활성 상태로 설치되어 있으면 true
|
|
*/
|
|
private function isDependencySatisfied(string $identifier, string $declaredType): bool
|
|
{
|
|
if ($declaredType === 'module') {
|
|
$record = app(ModuleRepositoryInterface::class)->findByIdentifier($identifier);
|
|
} else {
|
|
$record = $this->pluginRepository->findByIdentifier($identifier);
|
|
}
|
|
|
|
if (! $record) {
|
|
return false;
|
|
}
|
|
|
|
return $record->status === ExtensionStatus::Active->value;
|
|
}
|
|
|
|
/**
|
|
* 의존성 배열을 상세 정보로 변환합니다.
|
|
*
|
|
* 단순 문자열 배열을 identifier, name, type 포함 배열로 변환
|
|
*
|
|
* @param array $dependencies 의존성 문자열 배열 (모듈/플러그인 identifier)
|
|
* @return array 의존성 상세 정보 배열
|
|
*/
|
|
protected function enrichDependencies(array $dependencies): array
|
|
{
|
|
return DependencyEnricher::enrich($dependencies);
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 마이그레이션 파일들을 실행합니다.
|
|
*
|
|
* @param PluginInterface $plugin 마이그레이션을 실행할 플러그인 인스턴스
|
|
*/
|
|
protected function runMigrations(PluginInterface $plugin): void
|
|
{
|
|
$migrations = $plugin->getMigrations();
|
|
$basePath = base_path();
|
|
|
|
foreach ($migrations as $migration) {
|
|
// 절대 경로를 상대 경로로 변환 (Laravel migrate는 상대 경로만 지원)
|
|
$relativePath = str_replace($basePath.DIRECTORY_SEPARATOR, '', $migration);
|
|
// Windows 경로 구분자를 Unix 스타일로 변환
|
|
$relativePath = str_replace('\\', '/', $relativePath);
|
|
|
|
Artisan::call('migrate', [
|
|
'--path' => $relativePath,
|
|
'--force' => true,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 동적 데이터를 정리합니다.
|
|
*
|
|
* 플러그인이 런타임에 생성한 동적 테이블, 파일 등을 삭제합니다.
|
|
* 마이그레이션 롤백 전에 호출되어 메타 테이블이 아직 존재하는 상태에서 실행됩니다.
|
|
* 삭제 실패 시에도 언인스톨 프로세스는 계속 진행됩니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function cleanupDynamicPluginData(PluginInterface $plugin): void
|
|
{
|
|
try {
|
|
$tables = $plugin->getDynamicTables();
|
|
} catch (\Exception $e) {
|
|
Log::error('플러그인 동적 테이블 목록 조회 실패 (언인스톨 계속 진행)', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
if (empty($tables)) {
|
|
return;
|
|
}
|
|
|
|
$droppedCount = 0;
|
|
$failedCount = 0;
|
|
|
|
foreach ($tables as $table) {
|
|
try {
|
|
Schema::dropIfExists($table);
|
|
$droppedCount++;
|
|
} catch (\Exception $e) {
|
|
$failedCount++;
|
|
Log::error('플러그인 동적 테이블 삭제 실패 (계속 진행)', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'table' => $table,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
Log::info('플러그인 동적 테이블 정리 완료', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'total' => count($tables),
|
|
'dropped' => $droppedCount,
|
|
'failed' => $failedCount,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 마이그레이션을 롤백합니다 (테이블 삭제).
|
|
*
|
|
* 마이그레이션 디렉토리의 파일명을 기반으로 migrations 테이블에서 해당 마이그레이션을 찾아
|
|
* 역순으로 down() 메서드를 실행하여 테이블을 삭제합니다.
|
|
* FK 제약 조건으로 인한 DROP TABLE 실패를 방지하기 위해 롤백 전후로 FK 체크를 비활성화/활성화합니다.
|
|
*
|
|
* @param PluginInterface $plugin 롤백할 플러그인 인스턴스
|
|
*/
|
|
protected function rollbackMigrations(PluginInterface $plugin): void
|
|
{
|
|
$migrationPaths = $plugin->getMigrations();
|
|
|
|
if (empty($migrationPaths)) {
|
|
Log::info('롤백할 마이그레이션이 없습니다.', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($migrationPaths as $migrationPath) {
|
|
// 디렉토리 내 마이그레이션 파일 목록 (역순으로 정렬 - 최신 것부터 롤백)
|
|
$migrationFiles = glob($migrationPath.'/*.php');
|
|
|
|
if (empty($migrationFiles)) {
|
|
Log::info('롤백할 마이그레이션 파일이 없습니다.', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'path' => $migrationPath,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 파일명 역순 정렬 (최신 마이그레이션부터 롤백)
|
|
rsort($migrationFiles);
|
|
|
|
foreach ($migrationFiles as $filePath) {
|
|
try {
|
|
$this->rollbackSingleMigration($filePath, $plugin->getIdentifier());
|
|
} catch (\Exception $e) {
|
|
// 개별 마이그레이션 실패 시 로그 기록 후 다음 마이그레이션 계속 진행
|
|
Log::error('플러그인 개별 마이그레이션 롤백 실패', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'migration' => basename($filePath),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
Log::info('플러그인 마이그레이션 롤백 완료', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'path' => $migrationPath,
|
|
'migration_count' => count($migrationFiles),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 단일 마이그레이션 파일을 롤백합니다.
|
|
*
|
|
* @param string $filePath 마이그레이션 파일 경로
|
|
* @param string $pluginIdentifier 플러그인 식별자 (로깅용)
|
|
*/
|
|
protected function rollbackSingleMigration(string $filePath, string $pluginIdentifier): void
|
|
{
|
|
// 파일명에서 마이그레이션 이름 추출 (확장자 제외)
|
|
$migrationName = pathinfo($filePath, PATHINFO_FILENAME);
|
|
|
|
// migrations 테이블에 해당 마이그레이션이 있는지 확인
|
|
$migrationRecord = DB::table('migrations')
|
|
->where('migration', $migrationName)
|
|
->first();
|
|
|
|
if (! $migrationRecord) {
|
|
Log::info('마이그레이션이 실행되지 않았거나 이미 롤백됨', [
|
|
'plugin' => $pluginIdentifier,
|
|
'migration' => $migrationName,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 마이그레이션 파일 로드 및 down() 실행
|
|
$migration = require $filePath;
|
|
|
|
if (method_exists($migration, 'down')) {
|
|
// 개별 down() 실행 전후로 FK 제약 해제/복원 (DROP TABLE 시 FK 에러 방지)
|
|
Schema::disableForeignKeyConstraints();
|
|
try {
|
|
$migration->down();
|
|
} finally {
|
|
Schema::enableForeignKeyConstraints();
|
|
}
|
|
|
|
// migrations 테이블에서 기록 삭제
|
|
DB::table('migrations')
|
|
->where('migration', $migrationName)
|
|
->delete();
|
|
|
|
Log::info('마이그레이션 롤백 성공', [
|
|
'plugin' => $pluginIdentifier,
|
|
'migration' => $migrationName,
|
|
]);
|
|
} else {
|
|
Log::warning('down() 메서드가 없는 마이그레이션', [
|
|
'plugin' => $pluginIdentifier,
|
|
'migration' => $migrationName,
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('마이그레이션 롤백 실패', [
|
|
'plugin' => $pluginIdentifier,
|
|
'migration' => $migrationName,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 역할을 자동으로 생성/동기화합니다.
|
|
*
|
|
* ExtensionRoleSyncHelper를 통해 사용자 커스터마이징을 보존하면서
|
|
* 역할을 안전하게 동기화합니다.
|
|
*
|
|
* @param PluginInterface $plugin 역할을 생성할 플러그인 인스턴스
|
|
*/
|
|
protected function createPluginRoles(PluginInterface $plugin): void
|
|
{
|
|
$roles = $plugin->getRoles();
|
|
|
|
// 활성 언어팩이 plugin 의 roles 다국어 필드(name/description)에 추가 locale 을
|
|
// 주입할 수 있도록 필터 훅 적용 (LanguagePackSeedInjector::injectExtensionRoles 결선).
|
|
$roles = HookManager::applyFilters(
|
|
"plugin.{$plugin->getIdentifier()}.roles.translations",
|
|
$roles,
|
|
);
|
|
|
|
$syncHelper = $this->getRoleSyncHelper();
|
|
|
|
foreach ($roles as $role) {
|
|
$syncHelper->syncRole(
|
|
identifier: $role['identifier'],
|
|
newName: $role['name'],
|
|
newDescription: $role['description'],
|
|
extensionType: ExtensionOwnerType::Plugin,
|
|
extensionIdentifier: $plugin->getIdentifier(),
|
|
otherAttributes: ['is_active' => true],
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 권한을 자동으로 생성/동기화합니다.
|
|
*
|
|
* ExtensionRoleSyncHelper를 통해 사용자 커스터마이징을 보존하면서
|
|
* 권한을 안전하게 동기화하고, stale 권한을 정리합니다.
|
|
*
|
|
* @param PluginInterface $plugin 권한을 생성할 플러그인 인스턴스
|
|
*/
|
|
protected function createPluginPermissions(PluginInterface $plugin): void
|
|
{
|
|
$permissionConfig = $plugin->getPermissions();
|
|
$pluginIdentifier = $plugin->getIdentifier();
|
|
|
|
// 활성 언어팩이 권한 트리(plugin/categories/permissions 의 name/description) 에 추가
|
|
// locale 을 주입할 수 있도록 필터 훅 적용 (LanguagePackSeedInjector 결선).
|
|
$permissionConfig = HookManager::applyFilters(
|
|
"plugin.{$pluginIdentifier}.permissions.translations",
|
|
$permissionConfig,
|
|
);
|
|
|
|
$syncHelper = $this->getRoleSyncHelper();
|
|
$allIdentifiers = [];
|
|
|
|
// categories 키가 없으면 권한 없음 (역호환: flat 배열도 무시)
|
|
if (empty($permissionConfig) || ! isset($permissionConfig['categories'])) {
|
|
return;
|
|
}
|
|
|
|
// 1레벨: 플러그인 권한 노드 생성
|
|
$pluginName = $permissionConfig['name'] ?? $plugin->getName();
|
|
$pluginDesc = $permissionConfig['description'] ?? $plugin->getDescription();
|
|
|
|
if (is_string($pluginName)) {
|
|
$pluginName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pluginName);
|
|
}
|
|
if (is_string($pluginDesc)) {
|
|
$pluginDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pluginDesc);
|
|
}
|
|
|
|
$pluginNode = $syncHelper->syncPermission(
|
|
identifier: $pluginIdentifier,
|
|
newName: $pluginName,
|
|
newDescription: $pluginDesc,
|
|
extensionType: ExtensionOwnerType::Plugin,
|
|
extensionIdentifier: $pluginIdentifier,
|
|
otherAttributes: [
|
|
'type' => isset($permissionConfig['type'])
|
|
? PermissionType::from($permissionConfig['type'])
|
|
: PermissionType::Admin,
|
|
'order' => 100,
|
|
'parent_id' => null,
|
|
],
|
|
);
|
|
$allIdentifiers[] = $pluginIdentifier;
|
|
|
|
// 2레벨 & 3레벨: 카테고리별 권한 생성
|
|
$categoryOrder = 1;
|
|
foreach ($permissionConfig['categories'] as $categoryData) {
|
|
$categoryIdentifier = $pluginIdentifier.'.'.$categoryData['identifier'];
|
|
|
|
$catName = $categoryData['name'];
|
|
$catDesc = $categoryData['description'] ?? $catName;
|
|
if (is_string($catName)) {
|
|
$catName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $catName);
|
|
}
|
|
if (is_string($catDesc)) {
|
|
$catDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $catDesc);
|
|
}
|
|
|
|
// 카테고리 type을 하위 권한의 type에서 자동 결정
|
|
$childTypes = collect($categoryData['permissions'] ?? [])
|
|
->map(fn ($p) => $p['type'] ?? 'admin')
|
|
->unique();
|
|
|
|
$categoryType = ($childTypes->count() === 1 && $childTypes->first() === 'user')
|
|
? PermissionType::User
|
|
: PermissionType::Admin;
|
|
|
|
if (isset($categoryData['type'])) {
|
|
$categoryType = PermissionType::from($categoryData['type']);
|
|
}
|
|
|
|
// 2레벨: 카테고리 권한 노드
|
|
$categoryNode = $syncHelper->syncPermission(
|
|
identifier: $categoryIdentifier,
|
|
newName: $catName,
|
|
newDescription: $catDesc,
|
|
extensionType: ExtensionOwnerType::Plugin,
|
|
extensionIdentifier: $pluginIdentifier,
|
|
otherAttributes: [
|
|
'type' => $categoryType,
|
|
'order' => $categoryOrder++,
|
|
'parent_id' => $pluginNode->id,
|
|
],
|
|
);
|
|
$allIdentifiers[] = $categoryIdentifier;
|
|
|
|
// 3레벨: 개별 권한
|
|
$permissionOrder = 1;
|
|
foreach ($categoryData['permissions'] as $permData) {
|
|
$permIdentifier = $categoryIdentifier.'.'.$permData['action'];
|
|
|
|
$pName = $permData['name'];
|
|
$pDesc = $permData['description'] ?? $pName;
|
|
if (is_string($pName)) {
|
|
$pName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pName);
|
|
}
|
|
if (is_string($pDesc)) {
|
|
$pDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pDesc);
|
|
}
|
|
|
|
$permissionType = isset($permData['type'])
|
|
? PermissionType::from($permData['type'])
|
|
: PermissionType::Admin;
|
|
|
|
$syncHelper->syncPermission(
|
|
identifier: $permIdentifier,
|
|
newName: $pName,
|
|
newDescription: $pDesc,
|
|
extensionType: ExtensionOwnerType::Plugin,
|
|
extensionIdentifier: $pluginIdentifier,
|
|
otherAttributes: [
|
|
'type' => $permissionType,
|
|
'order' => $permissionOrder++,
|
|
'parent_id' => $categoryNode->id,
|
|
],
|
|
);
|
|
$allIdentifiers[] = $permIdentifier;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 권한을 지정된 역할에 할당합니다.
|
|
*
|
|
* ExtensionRoleSyncHelper를 통해 이전 할당과 비교하여
|
|
* 제거된 역할 할당만 해제하고, 사용자 수동 추가 할당은 보존합니다.
|
|
*
|
|
* @param PluginInterface $plugin 권한을 할당할 플러그인 인스턴스
|
|
*/
|
|
protected function assignPermissionsToRoles(PluginInterface $plugin): void
|
|
{
|
|
$permissionConfig = $plugin->getPermissions();
|
|
$pluginIdentifier = $plugin->getIdentifier();
|
|
|
|
// 권한→역할 맵 및 전체 권한 식별자 수집
|
|
$permissionRoleMap = [];
|
|
$allPermIdentifiers = [];
|
|
|
|
if (isset($permissionConfig['categories'])) {
|
|
// categories 구조: 카테고리별 개별 권한에서 roles 추출
|
|
foreach ($permissionConfig['categories'] as $categoryData) {
|
|
$categoryIdentifier = $pluginIdentifier.'.'.$categoryData['identifier'];
|
|
|
|
foreach ($categoryData['permissions'] as $permData) {
|
|
$permIdentifier = $categoryIdentifier.'.'.$permData['action'];
|
|
$allPermIdentifiers[] = $permIdentifier;
|
|
|
|
$definedRoles = $permData['roles'] ?? [];
|
|
if (! empty($definedRoles)) {
|
|
$permissionRoleMap[$permIdentifier] = $definedRoles;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$syncHelper = $this->getRoleSyncHelper();
|
|
$syncHelper->syncAllRoleAssignments(
|
|
permissionRoleMap: $permissionRoleMap,
|
|
allExtensionPermIdentifiers: $allPermIdentifiers,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 플러그인이 선언한 관리자 메뉴를 동기화합니다.
|
|
*
|
|
* 플러그인도 자기 화면을 가질 수 있으므로 `getAdminMenus()` 를 선언할 수 있습니다.
|
|
* 이 동기화가 설치·활성화 시점에만 일어나면, 이미 활성 상태인 사이트가 플러그인을
|
|
* 업데이트해 새 화면을 받아도 메뉴가 만들어지지 않아 그 화면은 주소를 직접 입력해야만
|
|
* 닿을 수 있습니다. 그래서 선언형 산출물 동기화(설치·업데이트 공통 경로)에 포함합니다.
|
|
*
|
|
* helper 의 upsert 패턴을 그대로 쓰므로 재호출은 무해하며(멱등) 운영자 커스터마이징
|
|
* (user_overrides) 도 보존됩니다.
|
|
*
|
|
* @param PluginInterface $plugin 메뉴를 동기화할 플러그인 인스턴스
|
|
*/
|
|
protected function createPluginMenus(PluginInterface $plugin): void
|
|
{
|
|
if (! method_exists($plugin, 'getAdminMenus')) {
|
|
return;
|
|
}
|
|
|
|
$menus = $plugin->getAdminMenus();
|
|
|
|
if (empty($menus)) {
|
|
return;
|
|
}
|
|
|
|
// 활성 언어팩이 admin_menus 다국어 필드(name 등)에 추가 locale 을 주입할 수 있도록
|
|
// 모듈과 동일한 필터 훅 규약을 적용한다 (LanguagePackSeedInjector 가 결선).
|
|
$menus = HookManager::applyFilters(
|
|
"plugin.{$plugin->getIdentifier()}.admin_menus.translations",
|
|
$menus,
|
|
);
|
|
|
|
$helper = app(ExtensionMenuSyncHelper::class);
|
|
|
|
foreach ($menus as $menuData) {
|
|
$helper->syncMenuRecursive(
|
|
$menuData,
|
|
ExtensionOwnerType::Plugin,
|
|
$plugin->getIdentifier(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인 정의 기준으로 stale 권한·역할을 정리합니다 (완전 동기화 원칙).
|
|
*
|
|
* 메뉴 stale 정리는 비활성화·삭제 시 플러그인 자신이 `cleanupStaleMenus` 로 수행하므로
|
|
* 여기서는 권한·역할만 대상으로 한다.
|
|
* user_overrides 보존 및 `users.role_id` 참조 역할 삭제 차단은 helper 가 담당.
|
|
*/
|
|
protected function cleanupStalePluginEntries(PluginInterface $plugin): void
|
|
{
|
|
$roleSyncHelper = $this->getRoleSyncHelper();
|
|
|
|
// 1. 권한 stale 정리 (정적 정의 + 동적 식별자 병합)
|
|
//
|
|
// 동적 권한을 보유한 플러그인은 AbstractPlugin::getDynamicPermissionIdentifiers()
|
|
// 를 override 하여 런타임 생성 권한을 보존 대상으로 등록한다.
|
|
$permissionConfig = $plugin->getPermissions();
|
|
if (isset($permissionConfig['categories'])) {
|
|
$expectedPermIds = [$plugin->getIdentifier()];
|
|
foreach ($permissionConfig['categories'] as $cat) {
|
|
$categoryIdentifier = $plugin->getIdentifier().'.'.($cat['identifier'] ?? '');
|
|
if (! empty($cat['identifier'])) {
|
|
$expectedPermIds[] = $categoryIdentifier;
|
|
}
|
|
foreach ($cat['permissions'] ?? [] as $p) {
|
|
if (isset($p['action'])) {
|
|
$expectedPermIds[] = $categoryIdentifier.'.'.$p['action'];
|
|
}
|
|
}
|
|
}
|
|
if (method_exists($plugin, 'getDynamicPermissionIdentifiers')) {
|
|
$expectedPermIds = array_merge($expectedPermIds, $plugin->getDynamicPermissionIdentifiers());
|
|
}
|
|
$roleSyncHelper->cleanupStalePermissions(
|
|
ExtensionOwnerType::Plugin,
|
|
$plugin->getIdentifier(),
|
|
$expectedPermIds,
|
|
);
|
|
}
|
|
|
|
// 2. 역할 stale 정리 (정적 + 동적 병합. 둘 다 비어있으면 skip)
|
|
if (method_exists($plugin, 'getRoles')) {
|
|
$roles = $plugin->getRoles();
|
|
$staticRoleIds = array_column($roles, 'identifier');
|
|
$dynamicRoleIds = method_exists($plugin, 'getDynamicRoleIdentifiers')
|
|
? $plugin->getDynamicRoleIdentifiers()
|
|
: [];
|
|
if (! empty($staticRoleIds) || ! empty($dynamicRoleIds)) {
|
|
$roleSyncHelper->cleanupStaleRoles(
|
|
ExtensionOwnerType::Plugin,
|
|
$plugin->getIdentifier(),
|
|
array_merge($staticRoleIds, $dynamicRoleIds),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인이 선언한 모든 선언형 산출물을 DB 에 동기화합니다.
|
|
*
|
|
* `installPlugin` / `updatePlugin` 트랜잭션 내부에서 호출되어 플러그인 디스크 상태와 DB
|
|
* 를 정합 상태로 유지한다. 외부 진입점으로도 노출되어 코어 업그레이드 사후 보정
|
|
* (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능.
|
|
*
|
|
* 동기화 대상:
|
|
* 1. 역할 (`getRoles`)
|
|
* 2. 권한 (`getPermissions`)
|
|
* 3. 역할-권한 매핑 (`getRolePermissions`)
|
|
* 4. 관리자 메뉴 (`getAdminMenus`)
|
|
* 5. stale cleanup (현재 선언에 없는 기존 레코드 제거)
|
|
* 6. IDV 정책 (`getIdentityPolicies`)
|
|
* 7. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
|
|
* 8. 알림 정의/템플릿 (`getNotificationDefinitions`)
|
|
*
|
|
* 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출
|
|
* 무해 (멱등).
|
|
*
|
|
* @param PluginInterface $plugin 대상 플러그인 인스턴스
|
|
*/
|
|
public function syncDeclarativeArtifacts(PluginInterface $plugin): void
|
|
{
|
|
$this->createPluginRoles($plugin);
|
|
$this->createPluginPermissions($plugin);
|
|
$this->assignPermissionsToRoles($plugin);
|
|
$this->createPluginMenus($plugin);
|
|
$this->cleanupStalePluginEntries($plugin);
|
|
$this->syncPluginIdentityPolicies($plugin);
|
|
$this->syncPluginIdentityMessages($plugin);
|
|
$this->syncPluginNotificationDefinitions($plugin);
|
|
}
|
|
|
|
/**
|
|
* 활성 플러그인 전체를 _bundled 디렉토리에서 fresh-load 하여 declarative sync 를 일괄 호출.
|
|
*
|
|
* 코어 업그레이드 transition 사후 보정용. 이전 코어 버전(예: beta.3)의 활성 dir 에는
|
|
* NEW declaration 인프라(IDV/메시지/알림 등) 가 아직 도입되지 않았으므로 활성 dir
|
|
* fresh-load 는 declaration count 0 을 반환해 시드가 발동하지 않는다. 본 메서드는
|
|
* 새 코어 버전이 _bundled 로 출하한 NEW 코드를 기준으로 declaration 을 재시드해
|
|
* 인프라 초기화 결함을 차단한다.
|
|
*
|
|
* 신규 인프라 도입 시점의 활성 dir 에는 사용자가 거부할 수 있는 OLD declaration 자체가
|
|
* 없으므로 본 보정은 사용자 의지(전역 yes/no/strategy) 와 양립한다. 미래 release 에서
|
|
* 사용자가 플러그인 업데이트를 거부한 케이스는 정상 흐름(ExecuteBundledUpdatesCommand →
|
|
* updatePlugin → syncDeclarativeArtifacts) 이 자동 처리하므로 본 보정의 추가 호출은
|
|
* 무해 (멱등) 하다.
|
|
*
|
|
* _bundled 에 plugin.php 가 부재한 외부 설치 플러그인(GitHub 직접 설치 등) 은 skip.
|
|
*
|
|
* @return array{synced: array<int, string>, skipped: array<int, string>, failed: array<string, string>}
|
|
*
|
|
* @since 7.0.0-beta.4
|
|
*/
|
|
public function resyncAllActiveDeclarativeArtifacts(): array
|
|
{
|
|
$synced = [];
|
|
$skipped = [];
|
|
$failed = [];
|
|
|
|
foreach (self::getActivePluginIdentifiers() as $identifier) {
|
|
$pluginDir = $this->bundledPluginsPath.DIRECTORY_SEPARATOR.$identifier;
|
|
$pluginFile = $pluginDir.DIRECTORY_SEPARATOR.'plugin.php';
|
|
|
|
// _bundled 에 진입점이 없는 외부 설치 플러그인(GitHub 등) 은 skip.
|
|
if (! File::exists($pluginFile)) {
|
|
$skipped[] = $identifier;
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$namespace = $this->convertDirectoryToNamespace($identifier);
|
|
$pluginClass = "Plugins\\{$namespace}\\Plugin";
|
|
|
|
if (class_exists($pluginClass, false)) {
|
|
$plugin = $this->evalFreshPlugin($pluginFile, $pluginClass, $pluginDir);
|
|
} else {
|
|
require_once $pluginFile;
|
|
$plugin = class_exists($pluginClass) ? new $pluginClass : null;
|
|
}
|
|
|
|
if (! $plugin instanceof PluginInterface) {
|
|
$failed[$identifier] = 'fresh-load 실패 (클래스 미생성)';
|
|
|
|
continue;
|
|
}
|
|
|
|
$this->syncDeclarativeArtifacts($plugin);
|
|
$synced[] = $identifier;
|
|
} catch (\Throwable $e) {
|
|
$failed[$identifier] = $e->getMessage();
|
|
Log::channel('upgrade')->warning('[resyncAllActiveDeclarativeArtifacts] sync 실패', [
|
|
'plugin' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return ['synced' => $synced, 'skipped' => $skipped, 'failed' => $failed];
|
|
}
|
|
|
|
/**
|
|
* 플러그인이 선언한 IDV 정책을 `identity_policies` 테이블에 동기화합니다.
|
|
*
|
|
* `AbstractPlugin::getIdentityPolicies()` 결과를 순회하며
|
|
* `IdentityPolicySyncHelper::syncPolicy()` 로 upsert 하고,
|
|
* 현재 선언에 없는 기존 정책은 `cleanupStalePolicies()` 로 제거합니다.
|
|
*
|
|
* 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
|
|
* 는 `user_overrides` JSON 으로 보존됩니다.
|
|
*
|
|
* @param PluginInterface $plugin IDV 정책을 동기화할 플러그인 인스턴스
|
|
*/
|
|
protected function syncPluginIdentityPolicies(PluginInterface $plugin): void
|
|
{
|
|
if (! method_exists($plugin, 'getIdentityPolicies')) {
|
|
return;
|
|
}
|
|
|
|
if (! Schema::hasTable('identity_policies')) {
|
|
return; // 마이그레이션 미실행 환경 보호
|
|
}
|
|
|
|
$policies = $plugin->getIdentityPolicies();
|
|
|
|
// 데이터 손실 방어막 — 자세한 배경은 ModuleManager::syncModuleIdentityPolicies 의 동일 가드 주석 참조.
|
|
if (empty($policies)) {
|
|
if ($this->hasExistingIdentityPolicies('plugin', $plugin->getIdentifier())) {
|
|
Log::warning('IDV 정책 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
]);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$helper = app(IdentityPolicySyncHelper::class);
|
|
$definedKeys = [];
|
|
|
|
foreach ($policies as $data) {
|
|
$data['source_type'] = 'plugin';
|
|
$data['source_identifier'] = $plugin->getIdentifier();
|
|
|
|
$helper->syncPolicy($data);
|
|
$definedKeys[] = $data['key'];
|
|
}
|
|
|
|
$helper->cleanupStalePolicies('plugin', $plugin->getIdentifier(), $definedKeys);
|
|
}
|
|
|
|
/**
|
|
* 플러그인이 선언한 IDV 메시지 정의/템플릿을 동기화합니다.
|
|
*
|
|
* `AbstractPlugin::getIdentityMessages()` 결과를 순회하며
|
|
* `IdentityMessageSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
|
|
* 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 제거합니다.
|
|
*
|
|
* @param PluginInterface $plugin IDV 메시지를 동기화할 플러그인 인스턴스
|
|
*/
|
|
protected function syncPluginIdentityMessages(PluginInterface $plugin): void
|
|
{
|
|
if (! method_exists($plugin, 'getIdentityMessages')) {
|
|
return;
|
|
}
|
|
|
|
if (! Schema::hasTable('identity_message_definitions')) {
|
|
return; // 마이그레이션 미실행 환경 보호
|
|
}
|
|
|
|
$messages = $plugin->getIdentityMessages();
|
|
|
|
// 언어팩 시스템: 활성 플러그인 언어팩의 seed/identity_messages.json 으로 다국어 키 병합.
|
|
$messages = HookManager::applyFilters(
|
|
"seed.{$plugin->getIdentifier()}.identity_messages.translations",
|
|
$messages
|
|
);
|
|
|
|
// 데이터 손실 방어막 — 자세한 배경은 ModuleManager::syncModuleIdentityPolicies 의 동일 가드 주석 참조.
|
|
if (empty($messages)) {
|
|
if ($this->hasExistingIdentityMessageDefinitions('plugin', $plugin->getIdentifier())) {
|
|
Log::warning('IDV 메시지 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
]);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$helper = app(IdentityMessageSyncHelper::class);
|
|
$definedScopes = [];
|
|
|
|
foreach ($messages as $data) {
|
|
$data['extension_type'] = 'plugin';
|
|
$data['extension_identifier'] = $plugin->getIdentifier();
|
|
|
|
$definition = $helper->syncDefinition($data);
|
|
$definedScopes[] = [
|
|
'provider_id' => $definition->provider_id,
|
|
'scope_type' => $definition->scope_type->value,
|
|
'scope_value' => $definition->scope_value,
|
|
];
|
|
|
|
$definedChannels = [];
|
|
foreach ($data['templates'] ?? [] as $template) {
|
|
$helper->syncTemplate($definition->id, $template);
|
|
$definedChannels[] = $template['channel'];
|
|
}
|
|
$helper->cleanupStaleTemplates($definition->id, $definedChannels);
|
|
}
|
|
|
|
$helper->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), $definedScopes);
|
|
}
|
|
|
|
/**
|
|
* 플러그인이 선언한 알림 정의/템플릿을 `notification_definitions` / `notification_templates` 에 동기화합니다.
|
|
*
|
|
* `AbstractPlugin::getNotificationDefinitions()` 결과를 순회하며
|
|
* `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
|
|
* 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 제거합니다.
|
|
*
|
|
* `extension_type='plugin'`, `extension_identifier=$plugin->getIdentifier()` 는 자동 주입.
|
|
* 운영자 user_overrides 보존은 helper 내부 trait 가 처리.
|
|
*
|
|
* @param PluginInterface $plugin 알림 정의를 동기화할 플러그인 인스턴스
|
|
*/
|
|
protected function syncPluginNotificationDefinitions(PluginInterface $plugin): void
|
|
{
|
|
if (! method_exists($plugin, 'getNotificationDefinitions')) {
|
|
return;
|
|
}
|
|
|
|
if (! Schema::hasTable('notification_definitions')) {
|
|
return; // 마이그레이션 미실행 환경 보호
|
|
}
|
|
|
|
$definitions = $plugin->getNotificationDefinitions();
|
|
|
|
// 언어팩 시스템: 활성 플러그인 언어팩의 seed/notifications.json 으로 다국어 키 병합.
|
|
$definitions = HookManager::applyFilters(
|
|
"seed.{$plugin->getIdentifier()}.notifications.translations",
|
|
$definitions
|
|
);
|
|
|
|
// 데이터 손실 방어막 — 자세한 배경은 ModuleManager::syncModuleIdentityPolicies 의 동일 가드 주석 참조.
|
|
if (empty($definitions)) {
|
|
if ($this->hasExistingNotificationDefinitions('plugin', $plugin->getIdentifier())) {
|
|
Log::warning('알림 정의 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
]);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$helper = app(NotificationSyncHelper::class);
|
|
$definedTypes = [];
|
|
|
|
foreach ($definitions as $data) {
|
|
$data['extension_type'] = 'plugin';
|
|
$data['extension_identifier'] = $plugin->getIdentifier();
|
|
|
|
$definition = $helper->syncDefinition($data);
|
|
$definedTypes[] = $definition->type;
|
|
|
|
$definedChannels = [];
|
|
foreach ($data['templates'] ?? [] as $template) {
|
|
$helper->syncTemplate($definition->id, $template);
|
|
$definedChannels[] = $template['channel'];
|
|
}
|
|
$helper->cleanupStaleTemplates($definition->id, $definedChannels);
|
|
}
|
|
|
|
$helper->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), $definedTypes);
|
|
}
|
|
|
|
/**
|
|
* 해당 source 가 기존에 등록한 IDV 정책이 있는지 확인합니다.
|
|
*/
|
|
protected function hasExistingIdentityPolicies(string $sourceType, string $sourceIdentifier): bool
|
|
{
|
|
try {
|
|
return DB::table('identity_policies')
|
|
->where('source_type', $sourceType)
|
|
->where('source_identifier', $sourceIdentifier)
|
|
->exists();
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 해당 source 가 기존에 등록한 IDV 메시지 정의가 있는지 확인합니다 (데이터 손실 방어용).
|
|
*/
|
|
protected function hasExistingIdentityMessageDefinitions(string $extensionType, string $extensionIdentifier): bool
|
|
{
|
|
try {
|
|
return DB::table('identity_message_definitions')
|
|
->where('extension_type', $extensionType)
|
|
->where('extension_identifier', $extensionIdentifier)
|
|
->exists();
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 해당 source 가 기존에 등록한 알림 정의가 있는지 확인합니다 (데이터 손실 방어용).
|
|
*/
|
|
protected function hasExistingNotificationDefinitions(string $extensionType, string $extensionIdentifier): bool
|
|
{
|
|
try {
|
|
return DB::table('notification_definitions')
|
|
->where('extension_type', $extensionType)
|
|
->where('extension_identifier', $extensionIdentifier)
|
|
->exists();
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 시더를 실행합니다.
|
|
*
|
|
* getSeeders()가 비어있지 않으면 해당 목록만 순서대로 실행합니다.
|
|
* 빈 배열이면 database/seeders/ 디렉토리의 모든 시더를 자동 검색하여 실행합니다. (역호환)
|
|
*
|
|
* @param PluginInterface $plugin 시더를 실행할 플러그인 인스턴스
|
|
*/
|
|
protected function runPluginSeeders(PluginInterface $plugin): void
|
|
{
|
|
// 플러그인 디렉토리명 조회 (오토로드 등록 및 glob 폴백에 필요)
|
|
$pluginDirName = null;
|
|
foreach ($this->plugins as $dirName => $pluginInstance) {
|
|
if ($pluginInstance === $plugin) {
|
|
$pluginDirName = $dirName;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (! $pluginDirName) {
|
|
return;
|
|
}
|
|
|
|
// 설치 시점에는 autoload-extensions.php가 아직 갱신되지 않으므로
|
|
// 플러그인의 composer.json에서 PSR-4 매핑을 읽어 동적으로 등록
|
|
ExtensionManager::registerExtensionAutoloadPaths('plugins', $pluginDirName);
|
|
|
|
// 플러그인이 명시적으로 시더를 정의한 경우 해당 목록만 실행
|
|
$definedSeeders = $plugin->getSeeders();
|
|
|
|
if (! empty($definedSeeders)) {
|
|
foreach ($definedSeeders as $seederClass) {
|
|
if (class_exists($seederClass)) {
|
|
try {
|
|
Artisan::call('db:seed', [
|
|
'--class' => $seederClass,
|
|
'--force' => true,
|
|
]);
|
|
|
|
Log::info("플러그인 시더 실행 완료: {$seederClass}");
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 시더 실행 실패: {$seederClass}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// 역호환: getSeeders()가 빈 배열이면 기존 glob 방식으로 자동 검색
|
|
$seederPath = base_path("plugins/{$pluginDirName}/database/seeders");
|
|
|
|
if (! File::exists($seederPath)) {
|
|
return;
|
|
}
|
|
|
|
// vendor-plugin 형식을 네임스페이스로 변환
|
|
$namespace = $this->convertDirectoryToNamespace($pluginDirName);
|
|
|
|
// 시더 파일들을 찾아서 실행
|
|
$seederFiles = File::glob($seederPath.'/*Seeder.php');
|
|
|
|
foreach ($seederFiles as $seederFile) {
|
|
$fileName = basename($seederFile, '.php');
|
|
$seederClass = "Plugins\\{$namespace}\\Database\\Seeders\\{$fileName}";
|
|
|
|
// 시더 클래스가 존재하는지 확인
|
|
if (class_exists($seederClass)) {
|
|
try {
|
|
Artisan::call('db:seed', [
|
|
'--class' => $seederClass,
|
|
'--force' => true,
|
|
]);
|
|
|
|
Log::info("플러그인 시더 실행 완료: {$seederClass}");
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 시더 실행 실패: {$seederClass}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인 설정 파일을 초기화합니다.
|
|
*
|
|
* defaults.json의 defaults 섹션을 우선 사용하고,
|
|
* 없으면 getConfigValues()에서 기본값을 가져와
|
|
* storage/app/plugins/{identifier}/settings/setting.json에 저장합니다.
|
|
*
|
|
* @param PluginInterface $plugin 설정을 초기화할 플러그인 인스턴스
|
|
*/
|
|
protected function initializePluginSettings(PluginInterface $plugin): void
|
|
{
|
|
$identifier = $plugin->getIdentifier();
|
|
$settingsDir = ExtensionStoragePath::plugin($identifier, 'settings');
|
|
$settingsPath = $settingsDir.'/setting.json';
|
|
|
|
// 이미 설정 파일이 존재하면 스킵 (재설치 시 기존 설정 유지)
|
|
if (File::exists($settingsPath)) {
|
|
Log::info('플러그인 설정 파일이 이미 존재합니다.', [
|
|
'plugin' => $identifier,
|
|
'path' => $settingsPath,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
// 1순위: defaults.json의 defaults 섹션
|
|
$defaults = $this->loadPluginDefaults($plugin);
|
|
|
|
// 2순위: getConfigValues() (하위 호환성)
|
|
if (empty($defaults)) {
|
|
$defaults = $plugin->getConfigValues();
|
|
}
|
|
|
|
// 기본 설정이 없으면 스킵
|
|
if (empty($defaults)) {
|
|
return;
|
|
}
|
|
|
|
// 디렉토리 생성 — sudo 코어 업데이트 경로에서 root 로 만들어지면 `storage/app/plugins` 는
|
|
// restore_ownership 제외 경로라 되돌려지지 않는다 → 부모 소유권 상속 (#651 F13)
|
|
if (! File::isDirectory($settingsDir)) {
|
|
File::makeDirectory($settingsDir, 0755, true);
|
|
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
|
|
}
|
|
|
|
// 기본값 저장
|
|
$content = json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
File::put($settingsPath, $content);
|
|
FilePermissionHelper::inheritOwnershipFromParent($settingsPath);
|
|
|
|
Log::info('플러그인 기본 설정 파일 생성 완료', [
|
|
'plugin' => $identifier,
|
|
'path' => $settingsPath,
|
|
'source' => $plugin->getSettingsDefaultsPath() ? 'defaults.json' : 'getConfigValues()',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 defaults.json에서 defaults 섹션을 로드합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
* @return array defaults 값 배열 (없으면 빈 배열)
|
|
*/
|
|
private function loadPluginDefaults(PluginInterface $plugin): array
|
|
{
|
|
$defaultsPath = $plugin->getSettingsDefaultsPath();
|
|
|
|
if ($defaultsPath === null || ! File::exists($defaultsPath)) {
|
|
return [];
|
|
}
|
|
|
|
$content = File::get($defaultsPath);
|
|
$data = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
Log::error('플러그인 defaults.json 파싱 실패', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'error' => json_last_error_msg(),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
return $data['defaults'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* 플러그인 설정 디렉토리를 삭제합니다.
|
|
*
|
|
* 플러그인 제거 시 deleteData 옵션이 true인 경우 호출됩니다.
|
|
*
|
|
* @param PluginInterface $plugin 설정을 삭제할 플러그인 인스턴스
|
|
*/
|
|
protected function deletePluginSettingsDirectory(PluginInterface $plugin): void
|
|
{
|
|
$identifier = $plugin->getIdentifier();
|
|
$pluginStorageDir = ExtensionStoragePath::plugin($identifier);
|
|
|
|
if (File::isDirectory($pluginStorageDir)) {
|
|
File::deleteDirectory($pluginStorageDir);
|
|
|
|
Log::info('플러그인 설정 디렉토리 삭제 완료', [
|
|
'plugin' => $identifier,
|
|
'path' => $pluginStorageDir,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 캐시를 전체 삭제합니다.
|
|
*
|
|
* 플러그인 비활성화/삭제 시 해당 플러그인의 격리된 캐시를 정리합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function flushPluginCache(PluginInterface $plugin): void
|
|
{
|
|
try {
|
|
$plugin->getCache()->flush();
|
|
} catch (\Exception $e) {
|
|
Log::warning("플러그인 캐시 정리 실패: {$plugin->getIdentifier()}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인 삭제 시 삭제될 데이터 정보를 조회합니다.
|
|
*
|
|
* 정적 테이블(마이그레이션) + 동적 테이블(getDynamicTables) 목록과 용량,
|
|
* 스토리지 디렉토리 1-depth 목록과 용량, 확장 디렉토리 정보를 반환합니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
* @return array|null 삭제 정보 배열 또는 null (플러그인 없음)
|
|
*/
|
|
public function getPluginUninstallInfo(string $pluginName): ?array
|
|
{
|
|
$plugin = $this->getPlugin($pluginName);
|
|
if (! $plugin) {
|
|
return null;
|
|
}
|
|
|
|
$identifier = $plugin->getIdentifier();
|
|
|
|
// 1. 정적 테이블 목록 (마이그레이션 파일에서 추출)
|
|
$staticTables = $this->extractTablesFromMigrations($plugin->getMigrations());
|
|
|
|
// 2. 동적 테이블 목록
|
|
$dynamicTables = [];
|
|
try {
|
|
$dynamicTables = $plugin->getDynamicTables();
|
|
} catch (\Exception $e) {
|
|
Log::warning('플러그인 동적 테이블 목록 조회 실패 (삭제 정보 조회)', [
|
|
'plugin' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
// 3. 테이블 목록 병합 (중복 제거)
|
|
$allTables = array_unique(array_merge($staticTables, $dynamicTables));
|
|
|
|
// 4. 테이블별 용량 조회
|
|
$tablesInfo = $this->getTablesSizeInfo($allTables);
|
|
|
|
// 5. 스토리지 디렉토리 1-depth 용량 조회
|
|
$storageInfo = $this->getStorageDirectoriesInfo(
|
|
ExtensionStoragePath::plugin($identifier)
|
|
);
|
|
|
|
// 6. Composer vendor 디렉토리 정보 조회
|
|
$vendorInfo = $this->getVendorDirectoryInfo('plugins', $identifier);
|
|
|
|
// 7. 확장 설치 디렉토리 정보 조회
|
|
$extensionDirInfo = $this->getExtensionDirectoryInfo('plugins', $identifier);
|
|
|
|
$totalTableSize = array_sum(array_column($tablesInfo, 'size_bytes'));
|
|
$totalStorageSize = array_sum(array_column($storageInfo, 'size_bytes'));
|
|
|
|
// 코어 공유 테이블에 적재된 이 플러그인의 데이터 (deleteData=true 시 정리 대상)
|
|
$sharedRecords = $this->resolveExtensionSharedRecords('plugin', $identifier);
|
|
|
|
return [
|
|
'tables' => $tablesInfo,
|
|
'storage_directories' => $storageInfo,
|
|
'vendor_directory' => $vendorInfo,
|
|
'extension_directory' => $extensionDirInfo,
|
|
'shared_records' => $sharedRecords,
|
|
'total_table_size_bytes' => $totalTableSize,
|
|
'total_table_size_formatted' => $this->formatBytes($totalTableSize),
|
|
'total_storage_size_bytes' => $totalStorageSize,
|
|
'total_storage_size_formatted' => $this->formatBytes($totalStorageSize),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 미설치 플러그인의 레이아웃 파일 개수를 파일 시스템에서 직접 셉니다.
|
|
*
|
|
* partial 파일(is_partial: true)은 제외합니다.
|
|
*
|
|
* @param string $pluginDirName 플러그인 디렉토리명 (vendor-plugin 형식)
|
|
* @return int 레이아웃 파일 개수
|
|
*/
|
|
protected function countPluginLayoutFiles(string $pluginDirName): int
|
|
{
|
|
$layoutsPath = base_path("plugins/{$pluginDirName}/resources/layouts");
|
|
|
|
if (! File::exists($layoutsPath)) {
|
|
return 0;
|
|
}
|
|
|
|
$count = 0;
|
|
$layoutFiles = $this->scanLayoutFiles($layoutsPath);
|
|
|
|
foreach ($layoutFiles as $layoutFile) {
|
|
try {
|
|
$content = File::get($layoutFile);
|
|
$layoutData = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
continue;
|
|
}
|
|
|
|
// is_partial이 true면 제외
|
|
if (isset($layoutData['meta']['is_partial']) && $layoutData['meta']['is_partial'] === true) {
|
|
continue;
|
|
}
|
|
|
|
$count++;
|
|
} catch (\Exception $e) {
|
|
// 파일 읽기 실패 시 스킵
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃 개수를 반환합니다.
|
|
*
|
|
* @param string $pluginIdentifier 플러그인 식별자
|
|
* @return int 레이아웃 개수
|
|
*/
|
|
public function getPluginLayoutsCount(string $pluginIdentifier): int
|
|
{
|
|
return $this->layoutRepository->countByModule($pluginIdentifier);
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃을 영구 삭제합니다.
|
|
*
|
|
* @param string $pluginIdentifier 플러그인 식별자
|
|
* @return int 삭제된 레이아웃 개수
|
|
*/
|
|
protected function deletePluginLayouts(string $pluginIdentifier): int
|
|
{
|
|
try {
|
|
// soft deleted 포함 모든 플러그인 레이아웃 영구 삭제
|
|
$deletedCount = $this->layoutRepository->forceDeleteByModule($pluginIdentifier);
|
|
|
|
// 레이아웃 캐시 무효화
|
|
$this->invalidateLayoutCache($pluginIdentifier);
|
|
|
|
Log::info('플러그인 레이아웃 영구 삭제 완료', [
|
|
'plugin' => $pluginIdentifier,
|
|
'deleted_count' => $deletedCount,
|
|
]);
|
|
|
|
return $deletedCount;
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 삭제 실패: {$pluginIdentifier}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인 제거 시 관련 권한을 삭제합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function removePluginPermissions(PluginInterface $plugin): void
|
|
{
|
|
$identifier = $plugin->getIdentifier();
|
|
|
|
// 플러그인 관련 권한에서 role 연결 해제 후 삭제
|
|
$permissions = $this->permissionRepository->getByExtension(ExtensionOwnerType::Plugin, $identifier);
|
|
foreach ($permissions as $permission) {
|
|
// 먼저 role_permissions 테이블에서 연결 해제
|
|
$permission->roles()->detach();
|
|
}
|
|
|
|
// 권한 삭제
|
|
$this->permissionRepository->deleteByExtension(ExtensionOwnerType::Plugin, $identifier);
|
|
|
|
// 플러그인 역할 삭제
|
|
$this->removePluginRoles($plugin);
|
|
}
|
|
|
|
/**
|
|
* 플러그인 제거 시 관련 역할을 삭제합니다.
|
|
*
|
|
* 조건:
|
|
* - 플러그인 소유(extension_type='plugin') 역할만 삭제
|
|
* - role에 부여된 permission이 하나도 없어야 삭제
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function removePluginRoles(PluginInterface $plugin): void
|
|
{
|
|
$roles = $plugin->getRoles();
|
|
|
|
foreach ($roles as $roleData) {
|
|
$role = $this->roleRepository->findExtensionRoleByIdentifier(
|
|
$roleData['identifier'],
|
|
ExtensionOwnerType::Plugin,
|
|
$plugin->getIdentifier()
|
|
);
|
|
|
|
if ($role) {
|
|
// role에 권한이 남아있는지 확인
|
|
$permissionCount = $this->roleRepository->getPermissionCount($role);
|
|
if ($permissionCount === 0) {
|
|
$this->roleRepository->delete($role);
|
|
} else {
|
|
Log::warning('플러그인 역할에 권한이 남아있어 삭제하지 않습니다.', [
|
|
'role' => $roleData['identifier'],
|
|
'remaining_permissions' => $permissionCount,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 config 파일을 Laravel config 시스템에 로드합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function loadPluginConfig(PluginInterface $plugin): void
|
|
{
|
|
$configs = $plugin->getConfig();
|
|
|
|
foreach ($configs as $key => $path) {
|
|
if (File::exists($path)) {
|
|
try {
|
|
$configData = require $path;
|
|
Config::set($key, $configData);
|
|
|
|
Log::debug("플러그인 config 로드 완료: {$key}", [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'path' => $path,
|
|
]);
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 config 로드 실패: {$key}", [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'path' => $path,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
} else {
|
|
Log::warning("플러그인 config 파일을 찾을 수 없음: {$key}", [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'path' => $path,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected function registerPluginHookListeners(PluginInterface $plugin): void
|
|
{
|
|
// 테스트 allowlist 확인 — allowlist 밖 플러그인은 ServiceProvider 가 등록되지 않으므로
|
|
// 리스너만 등록하면 훅 발화 시 의존 바인딩이 없어 컨테이너 해석이 실패한다.
|
|
// (플러그인 등록 행은 테스트 프로세스 간 DB 에 남을 수 있어 활성 판정만으로는 부족하다)
|
|
if (ExtensionTestAllowlist::isActive() && ! ExtensionTestAllowlist::isAllowed('plugin', $plugin->getIdentifier())) {
|
|
return;
|
|
}
|
|
|
|
// 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음)
|
|
$activeIdentifiers = self::getActivePluginIdentifiers();
|
|
if (! in_array($plugin->getIdentifier(), $activeIdentifiers, true)) {
|
|
return;
|
|
}
|
|
|
|
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
|
|
if ($this->registerExtensionHookListenersFromCache('plugins', $plugin->getIdentifier())) {
|
|
return;
|
|
}
|
|
|
|
$listeners = $plugin->getHookListeners();
|
|
|
|
foreach ($listeners as $listenerClass) {
|
|
// 클래스가 존재하는지 확인
|
|
if (! class_exists($listenerClass)) {
|
|
Log::warning("훅 리스너 클래스를 찾을 수 없습니다: {$listenerClass}");
|
|
|
|
continue;
|
|
}
|
|
|
|
// HookListenerInterface를 구현하는지 확인
|
|
if (! in_array(HookListenerInterface::class, class_implements($listenerClass))) {
|
|
Log::warning("훅 리스너가 HookListenerInterface를 구현하지 않습니다: {$listenerClass}");
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
HookListenerRegistrar::register($listenerClass, $plugin->getIdentifier());
|
|
} catch (\Exception $e) {
|
|
Log::error('훅 리스너 등록 중 오류 발생', [
|
|
'listener' => $listenerClass,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 훅 캐시에서 플러그인의 정적 훅 리스너를 등록합니다.
|
|
*
|
|
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 플러그인 식별자의 항목이 있으면
|
|
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
|
|
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
|
|
*
|
|
* @param string $bucket 캐시 버킷 ('plugins')
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
|
|
*/
|
|
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
|
|
{
|
|
if (app()->environment('testing')) {
|
|
return false;
|
|
}
|
|
|
|
$cache = app(HookCacheManager::class)->read();
|
|
|
|
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($cache[$bucket][$identifier] as $entry) {
|
|
try {
|
|
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
|
|
} catch (\Throwable $e) {
|
|
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
|
|
'listener' => $entry['listener'] ?? null,
|
|
'identifier' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 브로드캐스트 채널을 등록합니다.
|
|
*
|
|
* 플러그인의 getChannels() 메서드에서 정의한 채널을
|
|
* Broadcast::channel()로 자동 등록합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function registerPluginChannels(PluginInterface $plugin): void
|
|
{
|
|
if (! method_exists($plugin, 'getChannels')) {
|
|
return;
|
|
}
|
|
|
|
$activeIdentifiers = self::getActivePluginIdentifiers();
|
|
if (! in_array($plugin->getIdentifier(), $activeIdentifiers, true)) {
|
|
return;
|
|
}
|
|
|
|
$channels = $plugin->getChannels();
|
|
|
|
foreach ($channels as $channelName => $config) {
|
|
$permission = $config['permission'] ?? null;
|
|
|
|
Broadcast::channel($channelName, function ($user, ...$params) use ($permission) {
|
|
if ($permission) {
|
|
return $user->hasPermission($permission);
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
Log::info('플러그인 브로드캐스트 채널 등록 완료', [
|
|
'channel' => $channelName,
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'permission' => $permission,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub에서 플러그인의 최신 버전을 가져옵니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
* @return string|null 최신 버전 또는 null
|
|
*/
|
|
protected function fetchLatestVersion(PluginInterface $plugin): ?string
|
|
{
|
|
$githubUrl = $plugin->getGithubUrl();
|
|
|
|
if (! $githubUrl) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
[$owner, $repo] = GithubHelper::parseUrl($githubUrl);
|
|
} catch (\RuntimeException $e) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$token = (string) (config('app.update.github_token') ?? '');
|
|
$result = GithubHelper::fetchLatestRelease($owner, $repo, $token);
|
|
|
|
return $result['version'];
|
|
} catch (\Exception $e) {
|
|
Log::error('최신 버전 확인 중 오류 발생', [
|
|
'plugin' => $plugin->getName(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub URL로부터 변경 내역 URL을 생성합니다.
|
|
*
|
|
* @param string|null $githubUrl GitHub 저장소 URL
|
|
* @return string|null 변경 내역 URL 또는 null
|
|
*/
|
|
protected function buildChangelogUrl(?string $githubUrl): ?string
|
|
{
|
|
if (! $githubUrl) {
|
|
return null;
|
|
}
|
|
|
|
// GitHub URL 정규화 (뒤의 .git 제거)
|
|
$githubUrl = rtrim($githubUrl, '/');
|
|
$githubUrl = preg_replace('/\.git$/', '', $githubUrl);
|
|
|
|
return $githubUrl.'/releases';
|
|
}
|
|
|
|
/**
|
|
* 모든 활성 플러그인의 레이아웃을 활성화된 템플릿에 등록합니다.
|
|
*
|
|
* 새 템플릿 활성화 시 기존 활성 플러그인의 레이아웃을 등록하기 위해 사용됩니다.
|
|
* registerPluginLayouts()는 updateOrCreate를 사용하므로 기존 등록 중복은 안전합니다.
|
|
*
|
|
* @return int 등록된 레이아웃 총 개수
|
|
*/
|
|
public function registerLayoutsForAllActivePlugins(): int
|
|
{
|
|
$totalRegistered = 0;
|
|
|
|
foreach ($this->getActivePlugins() as $plugin) {
|
|
$totalRegistered += $this->registerPluginLayouts($plugin->getIdentifier());
|
|
}
|
|
|
|
return $totalRegistered;
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃을 활성화된 admin/user 템플릿에 등록합니다.
|
|
*
|
|
* 레이아웃 파일의 디렉토리 위치에 따라 대상 템플릿 타입이 결정됩니다:
|
|
* - layouts/admin/*.json → admin 타입 템플릿에 등록
|
|
* - layouts/user/*.json → user 타입 템플릿에 등록
|
|
* - layouts/*.json (루트) → 스킵 (경고 로그 출력)
|
|
*
|
|
* @param string $pluginName 플러그인 디렉토리명 (vendor-plugin 형식)
|
|
* @return int 등록된 레이아웃 개수
|
|
*/
|
|
protected function registerPluginLayouts(string $pluginName): int
|
|
{
|
|
$plugin = $this->getPlugin($pluginName);
|
|
if (! $plugin) {
|
|
return 0;
|
|
}
|
|
|
|
$layoutsPath = base_path("plugins/{$pluginName}/resources/layouts");
|
|
|
|
if (! File::exists($layoutsPath)) {
|
|
Log::info("플러그인에 레이아웃이 없습니다: {$pluginName}");
|
|
|
|
return 0;
|
|
}
|
|
|
|
// 활성화된 admin/user 타입 템플릿 조회
|
|
$adminTemplates = $this->templateRepository->getActiveByType('admin');
|
|
$userTemplates = $this->templateRepository->getActiveByType('user');
|
|
|
|
if ($adminTemplates->isEmpty() && $userTemplates->isEmpty()) {
|
|
Log::warning("활성화된 admin/user 템플릿이 없어 플러그인 레이아웃을 등록할 수 없습니다: {$pluginName}");
|
|
|
|
return 0;
|
|
}
|
|
|
|
$identifier = $plugin->getIdentifier();
|
|
|
|
// 레이아웃 검증 (등록 전 모든 레이아웃 파일 유효성 검사)
|
|
// 공통 Trait 메서드 사용 (recursive=true: 하위 디렉토리 전체 스캔)
|
|
$validatedLayouts = $this->validateLayoutFiles($layoutsPath, $identifier, 'plugin', true);
|
|
|
|
if (empty($validatedLayouts)) {
|
|
Log::info("플러그인에 등록할 레이아웃 파일이 없습니다: {$pluginName}");
|
|
|
|
return 0;
|
|
}
|
|
|
|
$registeredCount = 0;
|
|
|
|
// 검증 통과한 레이아웃만 등록
|
|
foreach ($validatedLayouts as $validatedLayout) {
|
|
try {
|
|
$layoutFile = $validatedLayout['file'];
|
|
$layoutData = $validatedLayout['data'];
|
|
$baseLayoutName = $validatedLayout['layout_name'];
|
|
|
|
// 대상 템플릿 타입 판별
|
|
$targetType = $this->getLayoutTargetType($layoutsPath, $layoutFile);
|
|
if ($targetType === null) {
|
|
continue; // 루트 파일 스킵
|
|
}
|
|
|
|
// 플러그인 식별자를 접두사로 추가하여 고유한 레이아웃 이름 생성
|
|
// 예: admin_payment_index -> sirsoft-payment.admin_payment_index
|
|
$layoutName = $identifier.'.'.$baseLayoutName;
|
|
|
|
// 대상 타입에 해당하는 템플릿 선택
|
|
$targetTemplates = ($targetType === 'user') ? $userTemplates : $adminTemplates;
|
|
|
|
if ($targetTemplates->isEmpty()) {
|
|
Log::info("활성화된 {$targetType} 템플릿이 없어 레이아웃 등록 스킵: {$layoutName}");
|
|
|
|
continue;
|
|
}
|
|
|
|
// 해당 타입 템플릿에 레이아웃 등록
|
|
foreach ($targetTemplates as $template) {
|
|
$this->registerLayoutToTemplate($template, $layoutName, $layoutData, $identifier);
|
|
$registeredCount++;
|
|
}
|
|
|
|
Log::info("플러그인 레이아웃 등록 완료: {$layoutName}", [
|
|
'plugin' => $identifier,
|
|
'target_type' => $targetType,
|
|
'templates_count' => $targetTemplates->count(),
|
|
]);
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 등록 실패: {$layoutFile}", [
|
|
'plugin' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 레이아웃 캐시 무효화
|
|
$this->invalidateLayoutCache($identifier);
|
|
|
|
Log::info('플러그인 레이아웃 전체 등록 완료', [
|
|
'plugin' => $identifier,
|
|
'registered_count' => $registeredCount,
|
|
]);
|
|
|
|
return $registeredCount;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 디렉토리에서 모든 JSON 파일을 재귀적으로 스캔합니다.
|
|
*
|
|
* @param string $basePath 스캔할 기본 경로
|
|
* @return array<string> 레이아웃 파일 경로 배열
|
|
*/
|
|
protected function scanLayoutFiles(string $basePath): array
|
|
{
|
|
$files = [];
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
if ($file->isFile() && $file->getExtension() === 'json') {
|
|
$files[] = $file->getPathname();
|
|
}
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* 파일 경로를 기반으로 레이아웃 이름을 생성합니다.
|
|
*
|
|
* 예시: plugins/sirsoft-payment/resources/layouts/admin/index.json
|
|
* -> sirsoft-payment_admin_index
|
|
*
|
|
* @param string $basePath 레이아웃 기본 경로
|
|
* @param string $filePath 레이아웃 파일 경로
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return string 생성된 레이아웃 이름
|
|
*/
|
|
protected function generateLayoutName(string $basePath, string $filePath, string $identifier): string
|
|
{
|
|
// 상대 경로 추출 (basePath 기준)
|
|
$relativePath = str_replace($basePath.DIRECTORY_SEPARATOR, '', $filePath);
|
|
|
|
// .json 확장자 제거
|
|
$relativePath = preg_replace('/\.json$/i', '', $relativePath);
|
|
|
|
// 디렉토리 구분자를 언더스코어로 변환
|
|
$layoutPath = str_replace([DIRECTORY_SEPARATOR, '/'], '_', $relativePath);
|
|
|
|
// 레이아웃 이름 생성: {identifier}_{path}
|
|
return $identifier.'_'.$layoutPath;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃을 특정 템플릿에 등록합니다.
|
|
*
|
|
* @param Template $template 템플릿 모델
|
|
* @param string $layoutName 레이아웃 이름
|
|
* @param array $layoutData 레이아웃 데이터
|
|
* @param string $pluginIdentifier 플러그인 식별자
|
|
*/
|
|
protected function registerLayoutToTemplate(Template $template, string $layoutName, array $layoutData, string $pluginIdentifier): void
|
|
{
|
|
// content 필드 구성 (TemplateManager와 동일하게 layoutData 전체 저장)
|
|
$content = $layoutData;
|
|
|
|
// DB에 레이아웃 등록 (updateOrCreate로 중복 방지)
|
|
// original_content_hash/size: 파일 원본 기준. keep 전략에서 사용자 수정 감지에 사용.
|
|
$this->layoutRepository->updateOrCreate(
|
|
[
|
|
'template_id' => $template->id,
|
|
'name' => $layoutName,
|
|
],
|
|
[
|
|
'content' => $content,
|
|
'extends' => $layoutData['extends'] ?? null,
|
|
'source_type' => LayoutSourceType::Plugin,
|
|
'source_identifier' => $pluginIdentifier,
|
|
'original_content_hash' => $this->computeContentHash($content),
|
|
'original_content_size' => $this->computeContentSize($content),
|
|
'created_by' => Auth::id(),
|
|
'updated_by' => Auth::id(),
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 플러그인 레이아웃 관련 캐시를 무효화합니다.
|
|
*
|
|
* @param string $pluginIdentifier 플러그인 식별자
|
|
*/
|
|
protected function invalidateLayoutCache(string $pluginIdentifier): void
|
|
{
|
|
$this->invalidateExtensionLayoutCache($pluginIdentifier, 'plugin');
|
|
}
|
|
|
|
/**
|
|
* partial 순환 참조 방지를 위한 스택
|
|
*/
|
|
private array $partialStack = [];
|
|
|
|
/**
|
|
* 레이아웃 데이터 내의 모든 partial을 재귀적으로 해석합니다.
|
|
*
|
|
* @param array $data 레이아웃 데이터
|
|
* @param string $basePath 기준 경로 (현재 레이아웃 파일의 디렉토리)
|
|
* @param int $depth 현재 partial 깊이
|
|
* @param array $dataSources 메인 레이아웃의 data_sources (partial 파일에서 참조 가능)
|
|
* @return array partial이 모두 해석된 데이터
|
|
*
|
|
* @throws LayoutIncludeException
|
|
*/
|
|
protected function resolveAllPartials(array $data, string $basePath, int $depth = 0, array $dataSources = []): array
|
|
{
|
|
// 1. 깊이 제한 검증
|
|
$maxDepth = config('template.layout.max_inheritance_depth', 10);
|
|
if ($depth > $maxDepth) {
|
|
throw new LayoutIncludeException(
|
|
__('exceptions.layout.max_include_depth_exceeded', ['max' => $maxDepth]),
|
|
null,
|
|
$this->partialStack
|
|
);
|
|
}
|
|
|
|
// 2. partial 키가 있으면 처리 (배열 항목 치환)
|
|
if (isset($data['partial']) && is_string($data['partial'])) {
|
|
return $this->resolvePartial($data['partial'], $basePath, $depth, $dataSources);
|
|
}
|
|
|
|
// 3. 배열의 각 요소를 재귀적으로 처리
|
|
foreach ($data as $key => $value) {
|
|
if (is_array($value)) {
|
|
$data[$key] = $this->resolveAllPartials($value, $basePath, $depth, $dataSources);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* 단일 partial을 해석하여 실제 데이터를 반환합니다.
|
|
*
|
|
* @param string $partialPath partial 경로
|
|
* @param string $basePath 기준 경로
|
|
* @param int $depth 현재 깊이
|
|
* @param array $dataSources 메인 레이아웃의 data_sources
|
|
* @return array 로드된 데이터
|
|
*
|
|
* @throws LayoutIncludeException
|
|
*/
|
|
protected function resolvePartial(string $partialPath, string $basePath, int $depth, array $dataSources = []): array
|
|
{
|
|
try {
|
|
// 1. 상대 경로를 절대 경로로 변환
|
|
$absolutePath = $this->resolvePartialPath($partialPath, $basePath);
|
|
} catch (LayoutIncludeException $e) {
|
|
// 경로 해석 실패 시 로그만 남기고 에러 표시 컴포넌트 반환
|
|
Log::warning('플러그인 Partial 경로 해석 실패', [
|
|
'partial_path' => $partialPath,
|
|
'base_path' => $basePath,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return $this->createPartialErrorComponent($partialPath, $e->getMessage());
|
|
}
|
|
|
|
// 2. 순환 참조 감지
|
|
if (in_array($absolutePath, $this->partialStack, true)) {
|
|
$trace = implode(' → ', $this->partialStack)." → {$absolutePath}";
|
|
$errorMessage = __('exceptions.layout.circular_include', ['trace' => $trace]);
|
|
|
|
Log::warning('플러그인 Partial 순환 참조 감지', [
|
|
'partial_path' => $partialPath,
|
|
'trace' => $trace,
|
|
]);
|
|
|
|
return $this->createPartialErrorComponent($partialPath, $errorMessage);
|
|
}
|
|
|
|
// 3. 파일 존재 여부 확인
|
|
if (! file_exists($absolutePath)) {
|
|
$errorMessage = __('exceptions.layout.include_file_not_found', [
|
|
'path' => $partialPath,
|
|
'resolved' => $absolutePath,
|
|
]);
|
|
|
|
Log::warning('플러그인 Partial 파일을 찾을 수 없음', [
|
|
'partial_path' => $partialPath,
|
|
'resolved_path' => $absolutePath,
|
|
]);
|
|
|
|
return $this->createPartialErrorComponent($partialPath, $errorMessage);
|
|
}
|
|
|
|
// 4. 스택에 추가
|
|
$this->partialStack[] = $absolutePath;
|
|
|
|
try {
|
|
// 5. JSON 파일 로드
|
|
$json = file_get_contents($absolutePath);
|
|
$data = json_decode($json, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$errorMessage = __('exceptions.layout.invalid_include_json', [
|
|
'path' => $partialPath,
|
|
'error' => json_last_error_msg(),
|
|
]);
|
|
|
|
Log::warning('플러그인 Partial JSON 파싱 실패', [
|
|
'partial_path' => $partialPath,
|
|
'error' => json_last_error_msg(),
|
|
]);
|
|
|
|
return $this->createPartialErrorComponent($partialPath, $errorMessage);
|
|
}
|
|
|
|
// 6. 로드된 데이터 내부의 partial도 재귀적으로 처리
|
|
$newBasePath = dirname($absolutePath);
|
|
$data = $this->resolveAllPartials($data, $newBasePath, $depth + 1, $dataSources);
|
|
|
|
return $data;
|
|
} finally {
|
|
array_pop($this->partialStack);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* partial 경로를 절대 경로로 변환하고 보안 검증을 수행합니다.
|
|
*
|
|
* @param string $partialPath partial 경로
|
|
* @param string $basePath 기준 경로
|
|
* @return string 절대 경로
|
|
*
|
|
* @throws LayoutIncludeException
|
|
*/
|
|
protected function resolvePartialPath(string $partialPath, string $basePath): string
|
|
{
|
|
// 1. 상대 경로 정규화
|
|
$absolutePath = $basePath.DIRECTORY_SEPARATOR.$partialPath;
|
|
|
|
// realpath로 실제 경로 확인
|
|
$realPath = realpath($absolutePath);
|
|
|
|
// 2. realpath 실패 시 수동 정규화
|
|
if ($realPath === false) {
|
|
$realPath = $this->normalizePath($absolutePath);
|
|
}
|
|
|
|
// 3. 보안 검증: layouts 디렉토리 내부인지 확인
|
|
$layoutsDir = $this->getLayoutsDirectory($basePath);
|
|
|
|
// Windows/Linux 호환을 위해 경로 정규화
|
|
$realPathNormalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $realPath);
|
|
$layoutsDirNormalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $layoutsDir);
|
|
|
|
if (! str_starts_with($realPathNormalized, $layoutsDirNormalized)) {
|
|
throw new LayoutIncludeException(
|
|
__('exceptions.layout.include_outside_directory', [
|
|
'path' => $partialPath,
|
|
'allowed_dir' => $layoutsDir,
|
|
]),
|
|
$partialPath,
|
|
$this->partialStack
|
|
);
|
|
}
|
|
|
|
return $realPath;
|
|
}
|
|
|
|
/**
|
|
* 경로를 정규화합니다 (.과 .. 처리).
|
|
*
|
|
* @param string $path 경로
|
|
* @return string 정규화된 경로
|
|
*/
|
|
protected function normalizePath(string $path): string
|
|
{
|
|
$parts = [];
|
|
$segments = explode(DIRECTORY_SEPARATOR, $path);
|
|
|
|
foreach ($segments as $segment) {
|
|
if ($segment === '..' && count($parts) > 0) {
|
|
array_pop($parts);
|
|
} elseif ($segment !== '.' && $segment !== '') {
|
|
$parts[] = $segment;
|
|
}
|
|
}
|
|
|
|
return implode(DIRECTORY_SEPARATOR, $parts);
|
|
}
|
|
|
|
/**
|
|
* 허용된 layouts 디렉토리를 추출합니다.
|
|
*
|
|
* @param string $basePath 기준 경로
|
|
* @return string layouts 디렉토리 경로
|
|
*/
|
|
protected function getLayoutsDirectory(string $basePath): string
|
|
{
|
|
// layouts 디렉토리 또는 그 하위 디렉토리까지만 허용
|
|
if (str_contains($basePath, 'plugins')) {
|
|
// plugins/{name}/resources/layouts
|
|
if (preg_match('#(.*plugins[\\\\/][^\\\\/]+[\\\\/]resources[\\\\/]layouts)#', $basePath, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
} elseif (str_contains($basePath, 'templates')) {
|
|
// templates/{name}/layouts
|
|
if (preg_match('#(.*templates[\\\\/][^\\\\/]+[\\\\/]layouts)#', $basePath, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
}
|
|
|
|
return $basePath;
|
|
}
|
|
|
|
/**
|
|
* Partial 파일 로드 실패 시 화면에 표시할 에러 컴포넌트를 생성합니다.
|
|
*
|
|
* @param string $partialPath Partial 경로
|
|
* @param string $errorMessage 에러 메시지
|
|
* @return array 에러 표시용 컴포넌트 배열
|
|
*/
|
|
protected function createPartialErrorComponent(string $partialPath, string $errorMessage): array
|
|
{
|
|
// 개발 환경에서만 에러 표시
|
|
if (! config('app.debug', false)) {
|
|
return []; // 프로덕션에서는 빈 배열 반환 (아무것도 렌더링 안 함)
|
|
}
|
|
|
|
return [
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'props' => [
|
|
'className' => 'p-4 my-2 bg-red-50 dark:bg-red-900 border-red-300 dark:border-red-600 rounded-lg',
|
|
],
|
|
'children' => [
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'props' => [
|
|
'className' => 'flex items-center gap-2 mb-2',
|
|
],
|
|
'children' => [
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'Span',
|
|
'props' => [
|
|
'className' => 'text-xl',
|
|
],
|
|
'text' => '⚠️',
|
|
],
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'Span',
|
|
'props' => [
|
|
'className' => 'font-bold text-red-900 dark:text-red-100',
|
|
],
|
|
'text' => 'Partial Error',
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'P',
|
|
'props' => [
|
|
'className' => 'text-sm text-red-700 dark:text-red-300 mb-1',
|
|
],
|
|
'text' => 'Path: '.$partialPath,
|
|
],
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'P',
|
|
'props' => [
|
|
'className' => 'text-sm text-red-600 dark:text-red-400',
|
|
],
|
|
'text' => 'Error: '.$errorMessage,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃 확장을 등록합니다.
|
|
*
|
|
* 플러그인의 resources/extensions 디렉토리에서 JSON 파일을 읽어
|
|
* 활성화된 모든 템플릿(admin, user)에 확장을 등록합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function registerLayoutExtensions(PluginInterface $plugin): void
|
|
{
|
|
$extensionFiles = $plugin->getLayoutExtensions();
|
|
|
|
if (empty($extensionFiles)) {
|
|
return;
|
|
}
|
|
|
|
$identifier = $plugin->getIdentifier();
|
|
|
|
// 활성화된 모든 템플릿 조회 (admin + user)
|
|
$allTemplates = $this->templateRepository->getActive();
|
|
|
|
if ($allTemplates->isEmpty()) {
|
|
Log::warning("활성화된 템플릿이 없어 플러그인 레이아웃 확장을 등록할 수 없습니다: {$identifier}");
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($extensionFiles as $extensionFile) {
|
|
try {
|
|
$content = File::get($extensionFile);
|
|
$extensionData = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
Log::error("레이아웃 확장 JSON 파싱 실패: {$extensionFile}", [
|
|
'plugin' => $identifier,
|
|
'error' => json_last_error_msg(),
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 모든 활성 템플릿에 확장 등록
|
|
foreach ($allTemplates as $template) {
|
|
$this->layoutExtensionService->registerExtension(
|
|
$extensionData,
|
|
LayoutSourceType::Plugin,
|
|
$identifier,
|
|
$template->id
|
|
);
|
|
}
|
|
|
|
Log::info("플러그인 레이아웃 확장 등록 완료: {$extensionFile}", [
|
|
'plugin' => $identifier,
|
|
'templates_count' => $allTemplates->count(),
|
|
]);
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 확장 등록 실패: {$extensionFile}", [
|
|
'plugin' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃 확장을 복원합니다.
|
|
*
|
|
* 플러그인 재활성화 시 soft delete된 확장을 복원합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function restoreLayoutExtensions(PluginInterface $plugin): void
|
|
{
|
|
try {
|
|
$restoredCount = $this->layoutExtensionService->restoreBySource(
|
|
LayoutSourceType::Plugin,
|
|
$plugin->getIdentifier()
|
|
);
|
|
|
|
if ($restoredCount > 0) {
|
|
Log::info('플러그인 레이아웃 확장 복원 완료', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'restored_count' => $restoredCount,
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 확장 복원 실패: {$plugin->getIdentifier()}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
// 복원 실패가 플러그인 활성화를 중단시키지 않음
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃 확장을 제거합니다 (soft delete).
|
|
*
|
|
* 플러그인 비활성화 시 해당 플러그인의 확장을 soft delete합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function unregisterLayoutExtensions(PluginInterface $plugin): void
|
|
{
|
|
try {
|
|
$deletedCount = $this->layoutExtensionService->unregisterBySource(
|
|
LayoutSourceType::Plugin,
|
|
$plugin->getIdentifier()
|
|
);
|
|
|
|
if ($deletedCount > 0) {
|
|
Log::info('플러그인 레이아웃 확장 soft delete 완료', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'deleted_count' => $deletedCount,
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 확장 soft delete 실패: {$plugin->getIdentifier()}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
// 확장 삭제 실패가 플러그인 비활성화를 중단시키지 않음
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃 확장을 영구 삭제합니다.
|
|
*
|
|
* 플러그인 삭제(uninstall) 시 호출됩니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
*/
|
|
protected function deleteLayoutExtensions(PluginInterface $plugin): void
|
|
{
|
|
try {
|
|
$deletedCount = $this->layoutExtensionService->forceDeleteBySource(
|
|
LayoutSourceType::Plugin,
|
|
$plugin->getIdentifier()
|
|
);
|
|
|
|
if ($deletedCount > 0) {
|
|
Log::info('플러그인 레이아웃 확장 영구 삭제 완료', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'deleted_count' => $deletedCount,
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 확장 영구 삭제 실패: {$plugin->getIdentifier()}", [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
* @param bool $preserveModified true 시 사용자가 UI에서 수정한 레이아웃은 덮어쓰지 않음
|
|
* @return array{success: bool, layouts_refreshed: int, created: int, updated: int, deleted: int, unchanged: int} 갱신 결과 및 갱신된 레이아웃 개수
|
|
*
|
|
* @throws \Exception 플러그인을 찾을 수 없거나 레이아웃 갱신 실패 시
|
|
*/
|
|
public function refreshPluginLayouts(string $pluginName, bool $preserveModified = false): array
|
|
{
|
|
$plugin = $this->getPlugin($pluginName);
|
|
|
|
if (! $plugin) {
|
|
throw new \Exception(__('plugins.errors.plugin_not_found', ['name' => $pluginName]));
|
|
}
|
|
|
|
// 플러그인이 활성화 상태인지 확인
|
|
$pluginRecord = $this->pluginRepository->findByIdentifier($pluginName);
|
|
if (! $pluginRecord || $pluginRecord->status !== 'active') {
|
|
throw new \Exception(__('plugins.errors.plugin_not_active', ['name' => $pluginName]));
|
|
}
|
|
|
|
$identifier = $plugin->getIdentifier();
|
|
$layoutsPath = base_path("plugins/{$pluginName}/resources/layouts");
|
|
|
|
// 활성화된 admin/user 타입 템플릿 조회
|
|
$adminTemplates = $this->templateRepository->getActiveByType('admin');
|
|
$userTemplates = $this->templateRepository->getActiveByType('user');
|
|
|
|
if ($adminTemplates->isEmpty() && $userTemplates->isEmpty()) {
|
|
Log::warning("활성화된 admin/user 템플릿이 없어 플러그인 레이아웃을 갱신할 수 없습니다: {$pluginName}");
|
|
|
|
return ['success' => true, 'layouts_refreshed' => 0, 'created' => 0, 'updated' => 0, 'deleted' => 0, 'unchanged' => 0];
|
|
}
|
|
|
|
$stats = ['created' => 0, 'updated' => 0, 'deleted' => 0, 'unchanged' => 0];
|
|
|
|
// 레이아웃 파일이 없는 경우 - admin/user 모두 삭제 처리
|
|
if (! File::exists($layoutsPath)) {
|
|
foreach (['admin' => $adminTemplates, 'user' => $userTemplates] as $type => $templates) {
|
|
foreach ($templates as $template) {
|
|
$existingLayouts = $this->layoutRepository->getByTemplateIdWithFilter(
|
|
$template->id,
|
|
'plugin',
|
|
$identifier
|
|
);
|
|
foreach ($existingLayouts as $layout) {
|
|
$layout->forceDelete();
|
|
$stats['deleted']++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($stats['deleted'] > 0) {
|
|
$this->invalidateLayoutCache($identifier);
|
|
$this->incrementExtensionCacheVersion();
|
|
$this->clearAllTemplateRoutesCaches();
|
|
$this->clearAllTemplateLanguageCaches();
|
|
Log::info("플러그인 레이아웃 삭제 완료 (파일 없음): {$pluginName}", ['deleted' => $stats['deleted']]);
|
|
}
|
|
|
|
return ['success' => true, 'layouts_refreshed' => 0, ...$stats];
|
|
}
|
|
|
|
// 레이아웃 검증 (등록 전 모든 레이아웃 파일 유효성 검사)
|
|
$validatedLayouts = $this->validateLayoutFiles($layoutsPath, $identifier, 'plugin', true);
|
|
|
|
// 레이아웃을 타입별로 그룹화
|
|
$layoutsByType = ['admin' => [], 'user' => []];
|
|
foreach ($validatedLayouts as $layout) {
|
|
$targetType = $this->getLayoutTargetType($layoutsPath, $layout['file']);
|
|
if ($targetType === null) {
|
|
continue; // 루트 파일 스킵
|
|
}
|
|
$layoutsByType[$targetType][] = $layout;
|
|
}
|
|
|
|
// 각 타입별로 해당 템플릿에 동기화
|
|
foreach (['admin' => $adminTemplates, 'user' => $userTemplates] as $type => $templates) {
|
|
if ($templates->isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
$typeLayouts = $layoutsByType[$type];
|
|
|
|
// 해당 타입의 파일 레이아웃 이름 목록
|
|
$typeLayoutNames = collect($typeLayouts)->map(function ($layout) use ($identifier) {
|
|
return $identifier.'.'.$layout['layout_name'];
|
|
})->toArray();
|
|
|
|
foreach ($templates as $template) {
|
|
// DB에 있는 해당 플러그인의 레이아웃 조회
|
|
$existingLayouts = $this->layoutRepository->getByTemplateIdWithFilter(
|
|
$template->id,
|
|
'plugin',
|
|
$identifier
|
|
)->keyBy('name');
|
|
|
|
// 파일 기반 레이아웃 동기화
|
|
foreach ($typeLayouts as $validatedLayout) {
|
|
try {
|
|
$baseLayoutName = $validatedLayout['layout_name'];
|
|
$layoutData = $validatedLayout['data'];
|
|
$layoutName = $identifier.'.'.$baseLayoutName;
|
|
|
|
$existingLayout = $existingLayouts->get($layoutName);
|
|
|
|
if ($existingLayout) {
|
|
// 기존 레이아웃이 있는 경우 - 내용 비교 후 업데이트
|
|
$existingContent = is_string($existingLayout->content)
|
|
? json_decode($existingLayout->content, true)
|
|
: $existingLayout->content;
|
|
|
|
if ($existingContent !== $layoutData) {
|
|
// 사용자 수정 감지: preserveModified 시 original_content_hash 와 현재 hash 비교
|
|
if ($preserveModified) {
|
|
$currentHash = $this->computeContentHash($existingContent);
|
|
$originalHash = $existingLayout->original_content_hash;
|
|
|
|
if ($originalHash && $currentHash !== $originalHash) {
|
|
$stats['skipped'] = ($stats['skipped'] ?? 0) + 1;
|
|
Log::info("플러그인 레이아웃 보존 (사용자 수정): {$layoutName}", ['plugin' => $identifier]);
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$this->registerLayoutToTemplate($template, $layoutName, $layoutData, $identifier);
|
|
$stats['updated']++;
|
|
} else {
|
|
$stats['unchanged']++;
|
|
}
|
|
} else {
|
|
// DB에 없으면 새로 생성
|
|
$this->registerLayoutToTemplate($template, $layoutName, $layoutData, $identifier);
|
|
$stats['created']++;
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error("플러그인 레이아웃 동기화 실패: {$validatedLayout['file']}", [
|
|
'plugin' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// DB에만 있고 파일에 없는 레이아웃 삭제
|
|
// 해당 타입의 레이아웃만 비교하여 삭제
|
|
foreach ($existingLayouts as $layoutName => $layout) {
|
|
if (! in_array($layoutName, $typeLayoutNames)) {
|
|
$layout->forceDelete();
|
|
$stats['deleted']++;
|
|
Log::info("플러그인 레이아웃 삭제: {$layoutName}", ['plugin' => $identifier]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 레이아웃 캐시 무효화
|
|
$this->invalidateLayoutCache($identifier);
|
|
|
|
$totalRefreshed = $stats['created'] + $stats['updated'];
|
|
|
|
Log::info('플러그인 레이아웃 동기화 완료', [
|
|
'plugin' => $identifier,
|
|
'created' => $stats['created'],
|
|
'updated' => $stats['updated'],
|
|
'deleted' => $stats['deleted'],
|
|
'unchanged' => $stats['unchanged'],
|
|
]);
|
|
|
|
// 레이아웃 확장(extension)은 모든 활성 템플릿에 적용될 수 있으므로
|
|
// admin 템플릿뿐만 아니라 모든 활성 템플릿에 대해 갱신
|
|
$allActiveTemplates = $this->templateRepository->getActive();
|
|
$extensionStats = $this->refreshLayoutExtensions($plugin, $allActiveTemplates, $preserveModified);
|
|
|
|
// 레이아웃 또는 레이아웃 확장이 실제로 변경된 경우에만 캐시 버전 증가
|
|
$extensionChanged = ($extensionStats['created'] ?? 0) > 0 || ($extensionStats['updated'] ?? 0) > 0;
|
|
if ($totalRefreshed > 0 || $stats['deleted'] > 0 || $extensionChanged) {
|
|
$this->incrementExtensionCacheVersion();
|
|
|
|
// 레이아웃 변경 시 routes/language 캐시도 함께 무효화
|
|
$this->clearAllTemplateRoutesCaches();
|
|
$this->clearAllTemplateLanguageCaches();
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'layouts_refreshed' => $totalRefreshed,
|
|
'created' => $stats['created'],
|
|
'updated' => $stats['updated'],
|
|
'deleted' => $stats['deleted'],
|
|
'unchanged' => $stats['unchanged'],
|
|
'extensions_refreshed' => $extensionStats['refreshed'] ?? 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃 확장을 파일에서 다시 읽어 DB에 갱신합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
* @param Collection $adminTemplates admin 템플릿 컬렉션
|
|
* @param bool $preserveModified 관리자가 편집한 확장을 보존할지 여부 (--layout-strategy=keep)
|
|
* @return array{refreshed: int, created: int, updated: int, deleted: int, skipped: int} 갱신 통계
|
|
*/
|
|
protected function refreshLayoutExtensions(PluginInterface $plugin, $adminTemplates, bool $preserveModified = false): array
|
|
{
|
|
return $this->refreshExtensionLayoutExtensions($plugin, $adminTemplates, LayoutSourceType::Plugin, $preserveModified);
|
|
}
|
|
|
|
/**
|
|
* _pending 또는 _bundled에서 활성 디렉토리로 플러그인을 복사합니다.
|
|
*
|
|
* _pending을 우선 확인하고, 없으면 _bundled를 확인합니다.
|
|
* 이미 활성 디렉토리에 존재하면 아무 작업도 하지 않습니다.
|
|
*
|
|
* @param string $pluginName 플러그인명 (identifier)
|
|
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
|
|
*/
|
|
protected function copyFromPendingOrBundled(string $pluginName, ?\Closure $onProgress = null, bool $force = false): void
|
|
{
|
|
$activePath = $this->pluginsPath.DIRECTORY_SEPARATOR.$pluginName;
|
|
|
|
// force=false 시 활성 디렉토리 존재하면 스킵.
|
|
// force=true 시 불완전 활성 디렉토리(manifest 누락 등)를 원본으로 덮어씀.
|
|
if (! $force && File::isDirectory($activePath)) {
|
|
return;
|
|
}
|
|
|
|
// _pending 우선 확인
|
|
if (ExtensionPendingHelper::isPending($this->pluginsPath, $pluginName)) {
|
|
$sourcePath = ExtensionPendingHelper::getPendingPath($this->pluginsPath, $pluginName);
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $activePath, $onProgress);
|
|
Log::info('플러그인을 _pending에서 활성 디렉토리로 복사', ['plugin' => $pluginName, 'force' => $force]);
|
|
|
|
return;
|
|
}
|
|
|
|
// _bundled 확인
|
|
if (ExtensionPendingHelper::isBundled($this->pluginsPath, $pluginName)) {
|
|
$sourcePath = ExtensionPendingHelper::getBundledPath($this->pluginsPath, $pluginName);
|
|
ExtensionPendingHelper::copyToActive($sourcePath, $activePath, $onProgress);
|
|
Log::info('플러그인을 _bundled에서 활성 디렉토리로 복사', ['plugin' => $pluginName, 'force' => $force]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인 인스턴스를 재로드합니다.
|
|
*
|
|
* 업데이트 후 새 파일에서 플러그인을 다시 로드합니다.
|
|
*
|
|
* @param string $pluginName 플러그인명
|
|
*/
|
|
protected function reloadPlugin(string $pluginName): void
|
|
{
|
|
$pluginDir = $this->pluginsPath.DIRECTORY_SEPARATOR.$pluginName;
|
|
$pluginFile = $pluginDir.DIRECTORY_SEPARATOR.'plugin.php';
|
|
|
|
if (! File::exists($pluginFile)) {
|
|
return;
|
|
}
|
|
|
|
$namespace = $this->convertDirectoryToNamespace($pluginName);
|
|
$pluginClass = "Plugins\\{$namespace}\\Plugin";
|
|
|
|
$plugin = null;
|
|
|
|
if (class_exists($pluginClass, false)) {
|
|
// 클래스가 이미 메모리에 있으면 eval로 새 버전 로드
|
|
$plugin = $this->evalFreshPlugin($pluginFile, $pluginClass, $pluginDir);
|
|
} else {
|
|
require_once $pluginFile;
|
|
if (class_exists($pluginClass)) {
|
|
$plugin = new $pluginClass;
|
|
}
|
|
}
|
|
|
|
if ($plugin instanceof PluginInterface) {
|
|
$this->plugins[$pluginName] = $plugin;
|
|
|
|
// 플러그인 config 파일 로드
|
|
$this->loadPluginConfig($plugin);
|
|
|
|
// 훅 리스너 자동 등록
|
|
$this->registerPluginHookListeners($plugin);
|
|
|
|
// pending/bundled 목록에서 제거
|
|
unset($this->pendingPlugins[$pluginName]);
|
|
unset($this->bundledPlugins[$pluginName]);
|
|
|
|
// 확장 미들웨어 인덱스 무효화 — 재로드된 플러그인의 갱신된 미들웨어 선언 반영.
|
|
ExtensionMiddlewareRegistry::flush();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* eval을 사용하여 플러그인 클래스를 새로 로드합니다.
|
|
*
|
|
* PHP는 동일 프로세스에서 클래스 재정의가 불가하므로,
|
|
* 클래스명을 임시 변경하여 eval로 메모리에서 로드합니다.
|
|
* 파일 I/O는 읽기만 수행합니다.
|
|
*
|
|
* @param string $pluginFile plugin.php 파일 경로
|
|
* @param string $pluginClass 원본 클래스 FQCN
|
|
* @param string $pluginDir 플러그인 디렉토리 경로
|
|
*/
|
|
protected function evalFreshPlugin(string $pluginFile, string $pluginClass, string $pluginDir): ?PluginInterface
|
|
{
|
|
$content = file_get_contents($pluginFile);
|
|
if ($content === false) {
|
|
return null;
|
|
}
|
|
|
|
$uid = '_fresh_'.uniqid();
|
|
|
|
// 클래스명만 변경 (namespace 유지 → use/extends 정상 작동)
|
|
$content = preg_replace('/\bclass\s+Plugin\b/', 'class Plugin'.$uid, $content);
|
|
|
|
// PHP 여는 태그 제거 후 메모리에서 실행
|
|
$content = preg_replace('/^<\?php\s*/', '', $content);
|
|
eval($content);
|
|
|
|
$namespace = substr($pluginClass, 0, strrpos($pluginClass, '\\'));
|
|
$freshClass = $namespace.'\\Plugin'.$uid;
|
|
|
|
if (! class_exists($freshClass)) {
|
|
return null;
|
|
}
|
|
|
|
$plugin = new $freshClass;
|
|
|
|
// pluginPath 수동 설정 (eval 클래스는 ReflectionClass::getFileName()이 비정상)
|
|
$ref = new \ReflectionClass(AbstractPlugin::class);
|
|
$prop = $ref->getProperty('pluginPath');
|
|
$prop->setAccessible(true);
|
|
$prop->setValue($plugin, $pluginDir);
|
|
|
|
return $plugin instanceof PluginInterface ? $plugin : null;
|
|
}
|
|
|
|
/**
|
|
* 단일 플러그인의 업데이트 가능 여부를 확인합니다.
|
|
*
|
|
* 일반 업데이트 우선순위 (GitHub 엄격 우선):
|
|
* 1. GitHub URL 존재 + API 조회 성공 → GitHub 결과만 신뢰
|
|
* a. GitHub 버전 > 현재 → 'github' 소스 반환
|
|
* b. GitHub 버전 ≤ 현재 → "업데이트 없음" 즉시 반환 (bundled 폴백 없음)
|
|
* 2. GitHub URL 없음 OR API 조회 실패 → _bundled 폴백 (안전망)
|
|
*
|
|
* --force 업데이트 우선순위는 resolveForceUpdateSource() 참조 (번들 우선).
|
|
*
|
|
* 참고: _pending 디렉토리는 install 경로에서만 사용되며 update 에서는 참조하지 않음.
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return array{update_available: bool, update_source: string|null, latest_version: string|null, current_version: string|null}
|
|
*/
|
|
public function checkPluginUpdate(string $identifier): array
|
|
{
|
|
$record = $this->pluginRepository->findByIdentifier($identifier);
|
|
if (! $record) {
|
|
return $this->buildPluginUpdateResponse(false, null, null, null, null);
|
|
}
|
|
|
|
$currentVersion = $record->version;
|
|
$plugin = $this->getPlugin($identifier);
|
|
// 활성 manifest 의 g7_version (github 경로 베스트 이펙트 + 폴백)
|
|
$activeRequiredCoreVersion = $plugin ? $plugin->getRequiredCoreVersion() : null;
|
|
|
|
// 1. GitHub URL이 있으면 GitHub에서 최신 버전 확인 (조회 성공 시 GitHub만 신뢰)
|
|
if ($plugin && $plugin->getGithubUrl()) {
|
|
try {
|
|
$latestVersion = $this->fetchLatestVersion($plugin);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('플러그인 GitHub 버전 조회 실패', [
|
|
'plugin' => $identifier,
|
|
'url' => $plugin->getGithubUrl(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$latestVersion = null;
|
|
}
|
|
|
|
if ($latestVersion !== null) {
|
|
// GitHub 조회 성공 → GitHub 결과만 신뢰 (bundled 폴백 없음)
|
|
// GitHub 경로는 release manifest 미다운로드 → 현재 활성 manifest 의 g7_version 으로
|
|
// 베스트 이펙트 호환성 판정. 정확한 판정은 staging 단계 (A.1) 에서 수행됨.
|
|
if (version_compare($latestVersion, $currentVersion, '>')) {
|
|
return $this->buildPluginUpdateResponse(
|
|
true,
|
|
'github',
|
|
$latestVersion,
|
|
$currentVersion,
|
|
$activeRequiredCoreVersion
|
|
);
|
|
}
|
|
|
|
return $this->buildPluginUpdateResponse(
|
|
false,
|
|
null,
|
|
$currentVersion,
|
|
$currentVersion,
|
|
$activeRequiredCoreVersion
|
|
);
|
|
}
|
|
|
|
// GitHub 조회 실패 → _bundled 폴백 안내
|
|
Log::info('플러그인 업데이트 확인: GitHub 조회 실패로 bundled 폴백', [
|
|
'plugin' => $identifier,
|
|
]);
|
|
}
|
|
|
|
// 2. _bundled에서 업데이트 확인 (GitHub URL 없음 OR GitHub 조회 실패)
|
|
if (isset($this->bundledPlugins[$identifier])) {
|
|
$bundledVersion = $this->bundledPlugins[$identifier]['version'] ?? null;
|
|
$bundledRequired = $this->bundledPlugins[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
|
|
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
|
|
return $this->buildPluginUpdateResponse(
|
|
true,
|
|
'bundled',
|
|
$bundledVersion,
|
|
$currentVersion,
|
|
$bundledRequired
|
|
);
|
|
}
|
|
} else {
|
|
$bundledMeta = ExtensionPendingHelper::loadBundledExtensions($this->pluginsPath, 'plugin.json');
|
|
if (isset($bundledMeta[$identifier])) {
|
|
$bundledVersion = $bundledMeta[$identifier]['version'] ?? null;
|
|
$bundledRequired = $bundledMeta[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
|
|
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
|
|
return $this->buildPluginUpdateResponse(
|
|
true,
|
|
'bundled',
|
|
$bundledVersion,
|
|
$currentVersion,
|
|
$bundledRequired
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. 업데이트 없음
|
|
return $this->buildPluginUpdateResponse(
|
|
false,
|
|
null,
|
|
$currentVersion,
|
|
$currentVersion,
|
|
$activeRequiredCoreVersion
|
|
);
|
|
}
|
|
|
|
/**
|
|
* checkPluginUpdate 응답 페이로드 빌더.
|
|
*
|
|
* 호환성 메타 (required_core_version / is_compatible / current_core_version) 을 일관되게 부착합니다.
|
|
*
|
|
* @param bool $updateAvailable 업데이트 가용 여부
|
|
* @param string|null $updateSource 업데이트 소스 (github|bundled|null)
|
|
* @param string|null $latestVersion 최신 버전
|
|
* @param string|null $currentVersion 현재 설치된 버전
|
|
* @param string|null $requiredCoreVersion 요구 코어 버전 제약 (null 이면 호환으로 간주)
|
|
* @return array{update_available: bool, update_source: ?string, latest_version: ?string, current_version: ?string, required_core_version: ?string, is_compatible: bool, current_core_version: string}
|
|
*/
|
|
protected function buildPluginUpdateResponse(
|
|
bool $updateAvailable,
|
|
?string $updateSource,
|
|
?string $latestVersion,
|
|
?string $currentVersion,
|
|
?string $requiredCoreVersion,
|
|
): array {
|
|
return [
|
|
'update_available' => $updateAvailable,
|
|
'update_source' => $updateSource,
|
|
'latest_version' => $latestVersion,
|
|
'current_version' => $currentVersion,
|
|
'required_core_version' => $requiredCoreVersion,
|
|
'is_compatible' => CoreVersionChecker::isCompatible($requiredCoreVersion),
|
|
'current_core_version' => CoreVersionChecker::getCoreVersion(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 설치된 모든 플러그인의 업데이트를 확인하고 DB를 갱신합니다.
|
|
*
|
|
* @return array{updated_count: int, details: array}
|
|
*/
|
|
public function checkAllPluginsForUpdates(): array
|
|
{
|
|
$pluginRecords = $this->pluginRepository->getAllKeyedByIdentifier();
|
|
$details = [];
|
|
$updatedCount = 0;
|
|
$checkedCount = 0;
|
|
|
|
foreach ($pluginRecords as $identifier => $record) {
|
|
$result = $this->checkPluginUpdate($identifier);
|
|
$checkedCount++;
|
|
|
|
// DB 갱신
|
|
$updateData = [
|
|
'update_available' => $result['update_available'],
|
|
'latest_version' => $result['latest_version'],
|
|
'update_source' => $result['update_source'],
|
|
'updated_at' => now(),
|
|
];
|
|
|
|
// GitHub 출처인 경우 changelog URL 갱신
|
|
if ($result['update_source'] === 'github') {
|
|
$plugin = $this->getPlugin($identifier);
|
|
if ($plugin) {
|
|
$updateData['github_changelog_url'] = $this->buildChangelogUrl($plugin->getGithubUrl());
|
|
}
|
|
}
|
|
|
|
$this->pluginRepository->updateByIdentifier($identifier, $updateData);
|
|
|
|
if ($result['update_available']) {
|
|
$updatedCount++;
|
|
$details[] = [
|
|
'identifier' => $identifier,
|
|
'update_available' => true,
|
|
'current_version' => $result['current_version'],
|
|
'latest_version' => $result['latest_version'],
|
|
'update_source' => $result['update_source'],
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'updated_count' => $updatedCount,
|
|
'checked_count' => $checkedCount,
|
|
'details' => $details,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* GitHub에서 플러그인 업데이트를 다운로드하여 _pending 스테이징에 배치합니다.
|
|
*
|
|
* ExtensionManager의 공용 GitHub 다운로드 유틸리티를 사용하여
|
|
* 코어 업데이트와 동일한 폴백 체인(ZipArchive → unzip)을 적용합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
* @param string $version 다운로드할 버전
|
|
* @return string 스테이징 경로
|
|
*
|
|
* @throws \RuntimeException 다운로드 실패 시
|
|
*/
|
|
protected function downloadPluginUpdate(PluginInterface $plugin, string $version): string
|
|
{
|
|
$githubUrl = $plugin->getGithubUrl();
|
|
if (! $githubUrl) {
|
|
throw new \RuntimeException(__('plugins.errors.invalid_github_url', ['plugin' => $plugin->getIdentifier()]));
|
|
}
|
|
|
|
if (! preg_match('#github\.com[/:]([^/]+)/([^/\.]+)#', $githubUrl, $matches)) {
|
|
throw new \RuntimeException(__('plugins.errors.invalid_github_url', ['plugin' => $plugin->getIdentifier()]));
|
|
}
|
|
|
|
$owner = $matches[1];
|
|
$repo = $matches[2];
|
|
|
|
// _pending 스테이징 경로 생성
|
|
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->pluginsPath, $plugin->getIdentifier());
|
|
|
|
// 임시 디렉토리 (다운로드/추출용)
|
|
$tempDir = storage_path('app/temp/plugin_update_'.uniqid());
|
|
|
|
try {
|
|
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
|
|
|
|
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
|
|
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
|
|
$owner, $repo, $version, $tempDir, config('app.update.github_token') ?? ''
|
|
);
|
|
|
|
// 추출된 파일을 _pending 스테이징으로 복사
|
|
ExtensionPendingHelper::stageForUpdate($extractedDir, $stagingPath);
|
|
|
|
Log::info('플러그인 업데이트 다운로드 및 스테이징 완료', [
|
|
'plugin' => $plugin->getIdentifier(),
|
|
'version' => $version,
|
|
'staging_path' => $stagingPath,
|
|
]);
|
|
|
|
return $stagingPath;
|
|
|
|
} catch (\Exception $e) {
|
|
ExtensionPendingHelper::cleanupStaging($stagingPath);
|
|
throw $e;
|
|
} finally {
|
|
// 임시 파일 정리
|
|
if (File::isDirectory($tempDir)) {
|
|
File::deleteDirectory($tempDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 버전별 업그레이드 스텝을 순차 실행합니다.
|
|
*
|
|
* $fromVersion 초과 ~ $toVersion 이하 버전의 스텝을 필터링하여 버전순으로 실행합니다.
|
|
*
|
|
* @param PluginInterface $plugin 플러그인 인스턴스
|
|
* @param string $fromVersion 시작 버전 (이 버전 초과)
|
|
* @param string $toVersion 목표 버전 (이 버전 이하)
|
|
* @param bool $force true 시 fromVersion == toVersion이면 해당 버전 스텝도 포함
|
|
* @param \Closure|null $onStep 각 step 실행 직전에 호출되는 콜백 (인자: 버전 문자열)
|
|
*
|
|
* @throws \Exception 스텝 실행 실패 시
|
|
*/
|
|
protected function runUpgradeSteps(PluginInterface $plugin, string $fromVersion, string $toVersion, bool $force = false, ?\Closure $onStep = null): void
|
|
{
|
|
$allSteps = $plugin->upgrades();
|
|
|
|
if (empty($allSteps)) {
|
|
return;
|
|
}
|
|
|
|
// force + 동일 버전: 해당 버전의 스텝도 포함 (>= 비교)
|
|
$sameVersion = version_compare($fromVersion, $toVersion, '==');
|
|
|
|
// $fromVersion 초과 ~ $toVersion 이하 필터링
|
|
$filteredSteps = [];
|
|
foreach ($allSteps as $stepVersion => $step) {
|
|
$included = $force && $sameVersion
|
|
? version_compare($stepVersion, $toVersion, '==')
|
|
: version_compare($stepVersion, $fromVersion, '>') && version_compare($stepVersion, $toVersion, '<=');
|
|
|
|
if ($included) {
|
|
$filteredSteps[$stepVersion] = $step;
|
|
}
|
|
}
|
|
|
|
if (empty($filteredSteps)) {
|
|
return;
|
|
}
|
|
|
|
// 버전순 정렬
|
|
uksort($filteredSteps, 'version_compare');
|
|
|
|
// 확장 업그레이드는 코어(sudo/root)와 다른 실행 주체(php-fpm/www-data)로 실행되므로
|
|
// 코어 'upgrade' 채널과 로그 파일을 분리한다. 같은 파일 공유 시 root 소유 파일에
|
|
// www-data 가 append 하지 못해 Permission denied 로 실패하던 결함을 원천 차단.
|
|
$context = new UpgradeContext(
|
|
fromVersion: $fromVersion,
|
|
toVersion: $toVersion,
|
|
logChannel: 'extension-upgrade',
|
|
);
|
|
|
|
foreach ($filteredSteps as $stepVersion => $step) {
|
|
$stepContext = $context->withCurrentStep($stepVersion);
|
|
|
|
$onStep?->__invoke((string) $stepVersion);
|
|
|
|
$stepContext->logger->info("Upgrading {$plugin->getIdentifier()} step {$stepVersion}...");
|
|
|
|
if ($step instanceof UpgradeStepInterface) {
|
|
$sinceVersion = ExtensionUpgradeGuardHelper::resolveSinceVersion(
|
|
$plugin->getRequiredCoreVersion(),
|
|
$plugin->getVersion(),
|
|
);
|
|
if ($sinceVersion !== null) {
|
|
ExtensionUpgradeGuardHelper::assertAbstractInheritance(
|
|
(string) $stepVersion,
|
|
$sinceVersion,
|
|
$step,
|
|
'plugin',
|
|
$plugin->getIdentifier(),
|
|
);
|
|
}
|
|
|
|
$step->run($stepContext);
|
|
} elseif (is_callable($step)) {
|
|
$step($stepContext);
|
|
}
|
|
|
|
$stepContext->logger->info("Completed {$plugin->getIdentifier()} step {$stepVersion}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플러그인의 레이아웃 중 사용자가 수정한 것이 있는지 확인합니다.
|
|
*
|
|
* 관리자 UI 업데이트 모달에서 layout_strategy 선택 시 보존 대상 목록 표시에 사용됩니다.
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return array{has_modified_layouts: bool, modified_count: int, modified_layouts: array}
|
|
*/
|
|
public function hasModifiedLayouts(string $identifier): array
|
|
{
|
|
$layouts = $this->layoutRepository->getBySourceIdentifier(
|
|
$identifier,
|
|
LayoutSourceType::Plugin,
|
|
);
|
|
|
|
$modifiedLayouts = $layouts->filter(function ($layout) {
|
|
if (! $layout->original_content_hash) {
|
|
return false;
|
|
}
|
|
|
|
$currentContent = is_string($layout->content)
|
|
? json_decode($layout->content, true)
|
|
: $layout->content;
|
|
$currentHash = $this->computeContentHash($currentContent);
|
|
|
|
return $currentHash !== $layout->original_content_hash;
|
|
});
|
|
|
|
return [
|
|
'has_modified_layouts' => $modifiedLayouts->isNotEmpty(),
|
|
'modified_count' => $modifiedLayouts->count(),
|
|
'modified_layouts' => $modifiedLayouts->map(function ($layout) {
|
|
$currentContent = is_string($layout->content)
|
|
? json_decode($layout->content, true)
|
|
: $layout->content;
|
|
$currentSize = $this->computeContentSize($currentContent);
|
|
$originalSize = $layout->original_content_size ?? $currentSize;
|
|
|
|
return [
|
|
'id' => $layout->id,
|
|
'name' => $layout->name,
|
|
'updated_at' => $layout->updated_at?->format('Y-m-d H:i:s'),
|
|
'size_diff' => $currentSize - $originalSize,
|
|
];
|
|
})->values()->toArray(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 플러그인을 업데이트합니다.
|
|
*
|
|
* 프로세스: 백업 → updating 상태 → 파일 교체 → 마이그레이션 → DB 갱신 →
|
|
* 레이아웃 갱신 → 업그레이드 스텝 실행 → 상태 복원 → 백업 삭제
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @param bool $force 버전 비교 없이 강제 업데이트
|
|
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
|
|
* @param VendorMode $vendorMode vendor 설치 모드
|
|
* @param string $layoutStrategy 레이아웃 전략 ('overwrite' 또는 'keep')
|
|
* @param \Closure|null $onUpgradeStep upgrade step 실행 콜백 (인자: 버전 문자열)
|
|
* @param string|null $sourceOverride 업데이트 소스 강제 지정 (auto|bundled|github)
|
|
* @param string|null $zipPath 사전 다운로드된 zip 파일 경로
|
|
* @return array{success: bool, from_version: string|null, to_version: string|null, message: string}
|
|
*
|
|
* @throws \RuntimeException 업데이트 실패 시
|
|
*/
|
|
public function updatePlugin(
|
|
string $identifier,
|
|
bool $force = false,
|
|
?\Closure $onProgress = null,
|
|
VendorMode $vendorMode = VendorMode::Auto,
|
|
string $layoutStrategy = 'overwrite',
|
|
?\Closure $onUpgradeStep = null,
|
|
?string $sourceOverride = null,
|
|
?string $zipPath = null,
|
|
): array {
|
|
$record = $this->pluginRepository->findByIdentifier($identifier);
|
|
if (! $record) {
|
|
throw new \RuntimeException(__('plugins.not_found', ['plugin' => $identifier]));
|
|
}
|
|
|
|
// 상태 가드
|
|
ExtensionStatusGuard::assertNotInProgress(
|
|
ExtensionStatus::from($record->status),
|
|
$identifier
|
|
);
|
|
|
|
$plugin = $this->getPlugin($identifier);
|
|
if (! $plugin) {
|
|
throw new \RuntimeException(__('plugins.not_found', ['plugin' => $identifier]));
|
|
}
|
|
|
|
$previousStatus = $record->status;
|
|
$fromVersion = $record->version;
|
|
$updateInfo = $this->checkPluginUpdate($identifier);
|
|
|
|
// ZIP 강제 경로: 외부 ZIP 파일을 직접 추출하여 사용. checkPluginUpdate 결과는 무시.
|
|
// zipTempDir / zipExtractedDir 는 staging 단계에서 사용 후 finally 에서 정리.
|
|
$zipTempDir = null;
|
|
$zipExtractedDir = null;
|
|
if ($zipPath !== null) {
|
|
$prepared = $this->extensionManager->prepareZipSource($zipPath, $identifier, 'plugin.json');
|
|
$zipTempDir = $prepared['temp_dir'];
|
|
$zipExtractedDir = $prepared['extracted_dir'];
|
|
$updateSource = 'zip';
|
|
$toVersion = $prepared['to_version'];
|
|
}
|
|
// 번들 강제 경로: 코어 업그레이드 / 일괄 업데이트 컨텍스트에서 GitHub 상태와 무관하게
|
|
// _bundled manifest 버전을 강제 사용.
|
|
elseif ($sourceOverride === 'bundled') {
|
|
$bundled = $this->getBundledVersion($identifier);
|
|
if ($bundled === null) {
|
|
throw new \RuntimeException(
|
|
__('plugins.errors.force_update_no_source', ['plugin' => $identifier])
|
|
);
|
|
}
|
|
$updateSource = 'bundled';
|
|
$toVersion = $bundled;
|
|
} elseif ($sourceOverride === 'github') {
|
|
// GitHub 강제 경로: _bundled 폴백 없이 GitHub 만 시도.
|
|
if (! $plugin->getGithubUrl()) {
|
|
throw new \RuntimeException(
|
|
__('plugins.errors.force_update_no_source', ['plugin' => $identifier])
|
|
);
|
|
}
|
|
$updateSource = 'github';
|
|
$toVersion = ($updateInfo['update_source'] === 'github' ? $updateInfo['latest_version'] : null)
|
|
?? $updateInfo['current_version'];
|
|
} elseif (! $updateInfo['update_available'] && ! $force) {
|
|
return [
|
|
'success' => false,
|
|
'from_version' => $fromVersion,
|
|
'to_version' => $fromVersion,
|
|
'message' => __('plugins.no_update_available'),
|
|
];
|
|
} elseif ($force && ! $updateInfo['update_available']) {
|
|
$updateSource = $this->resolveForceUpdateSource($identifier);
|
|
|
|
if ($updateSource === null) {
|
|
throw new \RuntimeException(
|
|
__('plugins.errors.force_update_no_source', ['plugin' => $identifier])
|
|
);
|
|
}
|
|
|
|
// 번들 재설치는 번들 manifest 버전 기준, github 재설치는 현재 버전 기준
|
|
if ($updateSource === 'bundled') {
|
|
$toVersion = $this->getBundledVersion($identifier) ?? $updateInfo['current_version'];
|
|
} else {
|
|
$toVersion = $updateInfo['current_version'];
|
|
}
|
|
} else {
|
|
$toVersion = $updateInfo['latest_version'];
|
|
$updateSource = $updateInfo['update_source'];
|
|
}
|
|
|
|
// 다운그레이드 차단 — fromVersion > toVersion 인 경우 force=false 면 차단.
|
|
// force=true 는 의도적 다운그레이드 (장애 롤백 등) 허용. lang pack/모듈과 일관.
|
|
if ($fromVersion && version_compare($toVersion, $fromVersion, '<') && ! $force) {
|
|
throw new \RuntimeException(__('plugins.errors.downgrade_blocked', [
|
|
'from' => $fromVersion,
|
|
'to' => $toVersion,
|
|
]));
|
|
}
|
|
|
|
$backupPath = null;
|
|
|
|
try {
|
|
// 1. 백업 생성
|
|
$onProgress?->__invoke('backup', '백업 생성 중...');
|
|
$backupPath = ExtensionBackupHelper::createBackup('plugins', $identifier, $onProgress);
|
|
|
|
// 2. 상태 → updating
|
|
$onProgress?->__invoke('status', '상태 변경 중...');
|
|
$this->pluginRepository->updateByIdentifier($identifier, [
|
|
'status' => ExtensionStatus::Updating->value,
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
// 3. 스테이징 (소스에 따라 분기)
|
|
$onProgress?->__invoke('staging', '스테이징 중...');
|
|
$stagingPath = null;
|
|
|
|
try {
|
|
if ($updateSource === 'github') {
|
|
$stagingPath = $this->downloadPluginUpdate($plugin, $toVersion);
|
|
} elseif ($updateSource === 'bundled') {
|
|
$sourcePath = ExtensionPendingHelper::getBundledPath($this->pluginsPath, $identifier);
|
|
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->pluginsPath, $identifier);
|
|
ExtensionPendingHelper::stageForUpdate($sourcePath, $stagingPath, $onProgress);
|
|
} elseif ($updateSource === 'zip') {
|
|
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->pluginsPath, $identifier);
|
|
ExtensionPendingHelper::stageForUpdate($zipExtractedDir, $stagingPath, $onProgress);
|
|
}
|
|
|
|
// 3.4. 코어 버전 호환성 사전 검증 (staging manifest 기준)
|
|
// - 백업은 이미 생성됐으므로 비호환 시 finally 에서 staging 정리됨
|
|
// - 외부 ZIP / GitHub / _bundled 모두 staging manifest 가 SSoT
|
|
// - force=true 또는 코어 업데이트 spawn 컨텍스트에서는 스킵
|
|
if ($stagingPath && ! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
|
|
$stagedManifest = (new Vendor\VendorIntegrityChecker)->readManifest($stagingPath);
|
|
CoreVersionChecker::validateExtension(
|
|
$stagedManifest['g7_version'] ?? null,
|
|
$identifier,
|
|
'plugin'
|
|
);
|
|
}
|
|
|
|
// 3.5. Vendor 설치 (의존성 있는 경우만, 변경 시에만)
|
|
$resolvedVendorMode = $vendorMode;
|
|
if ($stagingPath && $this->extensionManager->hasComposerDependenciesAt($stagingPath)) {
|
|
$activePath = $this->pluginsPath.DIRECTORY_SEPARATOR.$identifier;
|
|
$previousMode = $this->getPreviousVendorMode($identifier);
|
|
|
|
if ($vendorMode === VendorMode::Auto
|
|
&& $previousMode !== VendorMode::Bundled
|
|
&& $this->extensionManager->isComposerUnchanged($stagingPath, $activePath)
|
|
) {
|
|
$onProgress?->__invoke('composer', 'Composer 의존성 변경 없음 — 스킵');
|
|
Log::info('플러그인 업데이트: composer 변경 없음, 스킵', ['plugin' => $identifier]);
|
|
ExtensionPendingHelper::copyVendorFromActive($activePath, $stagingPath, $onProgress);
|
|
$resolvedVendorMode = $previousMode ?? VendorMode::Auto;
|
|
} else {
|
|
$onProgress?->__invoke('composer', 'Vendor 설치 중...');
|
|
$vendorResult = $this->installVendorViaResolver(
|
|
$identifier,
|
|
$stagingPath,
|
|
$vendorMode,
|
|
'update',
|
|
$previousMode,
|
|
$onProgress,
|
|
);
|
|
$resolvedVendorMode = $vendorResult->mode;
|
|
}
|
|
}
|
|
|
|
// 4. 원자적 적용 (스테이징 → 활성 디렉토리)
|
|
$onProgress?->__invoke('files', '파일 교체 중...');
|
|
if ($stagingPath) {
|
|
$targetPath = $this->pluginsPath.DIRECTORY_SEPARATOR.$identifier;
|
|
ExtensionPendingHelper::copyToActive($stagingPath, $targetPath, $onProgress);
|
|
}
|
|
} finally {
|
|
// 스테이징 정리
|
|
if ($stagingPath) {
|
|
ExtensionPendingHelper::cleanupStaging($stagingPath);
|
|
}
|
|
// ZIP 임시 추출 디렉토리 정리
|
|
if ($zipTempDir && File::isDirectory($zipTempDir)) {
|
|
File::deleteDirectory($zipTempDir);
|
|
}
|
|
}
|
|
|
|
// 플러그인 재로드 (새 파일로)
|
|
$onProgress?->__invoke('reload', '재로드 중...');
|
|
$this->reloadPlugin($identifier);
|
|
$plugin = $this->getPlugin($identifier);
|
|
|
|
// 4. 마이그레이션 실행
|
|
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
|
|
if ($plugin) {
|
|
$this->runMigrations($plugin);
|
|
}
|
|
|
|
// 5. 오토로드 갱신 (DB 트랜잭션 진입 전)
|
|
//
|
|
// 순서 중요: cleanupStalePluginEntries 의 동적 hook(getDynamicPermissionIdentifiers 등)이
|
|
// 플러그인 클래스를 참조할 수 있으므로, 새 파일에 대한 PSR-4 매핑이 현재 프로세스
|
|
// ClassLoader 에 등록되어 있어야 한다. updateComposerAutoload() 가 파일 재기록 +
|
|
// 런타임 ClassLoader 재등록을 동시에 수행한다.
|
|
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
|
|
$this->extensionManager->updateComposerAutoload();
|
|
|
|
// 6. 트랜잭션: DB 정보 갱신
|
|
$onProgress?->__invoke('db', 'DB 갱신 중...');
|
|
DB::beginTransaction();
|
|
try {
|
|
$name = $plugin ? $this->convertToMultilingual($plugin->getName()) : $record->name;
|
|
$description = $plugin ? $this->convertToMultilingual($plugin->getDescription()) : $record->description;
|
|
|
|
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입 (install 경로와 동일)
|
|
if ($plugin) {
|
|
$manifest = HookManager::applyFilters(
|
|
"plugin.{$identifier}.manifest.translations",
|
|
['name' => $name, 'description' => $description]
|
|
);
|
|
$name = $manifest['name'] ?? $name;
|
|
$description = $manifest['description'] ?? $description;
|
|
}
|
|
|
|
$this->pluginRepository->updateByIdentifier($identifier, [
|
|
'version' => $toVersion,
|
|
'latest_version' => $toVersion,
|
|
'name' => $name,
|
|
'description' => $description,
|
|
'update_available' => false,
|
|
'update_source' => null,
|
|
'github_url' => $plugin ? $plugin->getGithubUrl() : $record->github_url,
|
|
'github_changelog_url' => $plugin ? $this->buildChangelogUrl($plugin->getGithubUrl()) : $record->github_changelog_url,
|
|
'metadata' => $plugin ? $plugin->getMetadata() : $record->metadata,
|
|
'vendor_mode' => $resolvedVendorMode->value,
|
|
'updated_by' => Auth::id(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
// Role/Permission 동기화 (있으면 업데이트) + 완전 동기화 (stale cleanup)
|
|
// 선언형 산출물 (역할/권한/IDV/알림) 일괄 동기화 + stale cleanup
|
|
if ($plugin) {
|
|
$this->syncDeclarativeArtifacts($plugin);
|
|
}
|
|
|
|
DB::commit();
|
|
} catch (\Throwable $e) {
|
|
DB::rollBack();
|
|
throw $e;
|
|
}
|
|
|
|
// 7. 업그레이드 스텝 실행
|
|
$onProgress?->__invoke('upgrade', '업그레이드 스텝 실행 중...');
|
|
if ($plugin) {
|
|
$this->runUpgradeSteps($plugin, $fromVersion, $toVersion, $force, $onUpgradeStep);
|
|
}
|
|
|
|
// 8. 상태 복원 (refreshPluginLayouts()가 active 상태를 요구하므로 먼저 복원)
|
|
$onProgress?->__invoke('restore_status', '상태 복원 중...');
|
|
$this->pluginRepository->updateByIdentifier($identifier, [
|
|
'status' => $previousStatus,
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
// 플러그인 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
|
|
// Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
|
|
// updateComposerAutoload() 가 DB 를 재조회해 이 플러그인을 비활성으로 판정하고
|
|
// 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
|
|
// 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
|
|
self::invalidatePluginStatusCache();
|
|
|
|
// 9. 레이아웃 갱신 (이전 상태가 active였으면)
|
|
// refreshPluginLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
|
|
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
|
|
if ($previousStatus === ExtensionStatus::Active->value && $plugin) {
|
|
$preserveModified = ($layoutStrategy === 'keep');
|
|
// registerPluginLayouts() 를 여기서 호출하지 않는다 — 그 메서드는 전략을
|
|
// 모른 채 모든 레이아웃의 content 와 original_content_hash 를 파일 기준으로
|
|
// 덮어써서, 뒤따르는 refreshPluginLayouts($preserveModified) 가 비교할
|
|
// "사용자 수정본" 을 이미 지워버린다(= keep 전략이 항상 무효화).
|
|
// 신규 레이아웃 생성은 refreshPluginLayouts 의 created 분기가 담당한다.
|
|
$this->registerLayoutExtensions($plugin);
|
|
$this->refreshPluginLayouts($identifier, $preserveModified);
|
|
}
|
|
|
|
// 10. 백업 삭제 + 캐시 삭제
|
|
$onProgress?->__invoke('cleanup', '정리 중...');
|
|
ExtensionBackupHelper::deleteBackup($backupPath);
|
|
|
|
// 캐시 무효화
|
|
$this->clearAllTemplateLanguageCaches();
|
|
$this->clearAllTemplateRoutesCaches();
|
|
$this->incrementExtensionCacheVersion();
|
|
RouteCacheHelper::rebuild();
|
|
|
|
// 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
|
|
// 그 시점 이 플러그인이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
|
|
// 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
|
|
// 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
|
|
// updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
|
|
$this->extensionManager->regenerateHookCache();
|
|
|
|
// 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
|
|
HookManager::doAction('core.plugins.updated', $identifier);
|
|
|
|
Log::info('플러그인 업데이트 완료', [
|
|
'plugin' => $identifier,
|
|
'from' => $fromVersion,
|
|
'to' => $toVersion,
|
|
'source' => $updateSource,
|
|
]);
|
|
|
|
return [
|
|
'success' => true,
|
|
'from_version' => $fromVersion,
|
|
'to_version' => $toVersion,
|
|
'message' => __('plugins.update_success', [
|
|
'plugin' => $identifier,
|
|
'version' => $toVersion,
|
|
]),
|
|
];
|
|
|
|
} catch (\Throwable $e) {
|
|
// 실패 시: 백업 복원 + 상태 복원
|
|
Log::error('플러그인 업데이트 실패', [
|
|
'plugin' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
if ($backupPath) {
|
|
try {
|
|
ExtensionBackupHelper::restoreFromBackup('plugins', $identifier, $backupPath, $onProgress);
|
|
ExtensionBackupHelper::deleteBackup($backupPath);
|
|
|
|
// 플러그인 재로드 (복원된 파일로)
|
|
$this->reloadPlugin($identifier);
|
|
} catch (\Throwable $restoreError) {
|
|
Log::error('플러그인 백업 복원 실패', [
|
|
'plugin' => $identifier,
|
|
'error' => $restoreError->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 상태 복원
|
|
$this->pluginRepository->updateByIdentifier($identifier, [
|
|
'status' => $previousStatus,
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
// 상태 캐시 무효화 (성공 경로와 동일) — updating 창에서 누군가 활성 목록을 읽었다면
|
|
// 그 목록에는 이 플러그인이 빠져 있다. 여기서 비우지 않으면 상태를 되돌려 놓고도
|
|
// 캐시 TTL(기본 하루) 동안 이 플러그인의 화면이 계속 404 로 남는다.
|
|
self::invalidatePluginStatusCache();
|
|
|
|
throw new \RuntimeException(
|
|
__('plugins.errors.update_failed', [
|
|
'plugin' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]),
|
|
0,
|
|
$e
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* --force 시 업데이트 소스를 결정합니다.
|
|
*
|
|
* 운영 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
|
|
* (일반 업데이트의 GitHub 우선과 반대 — 개발자가 로컬 번들로 되돌리려는 의도 존중)
|
|
*
|
|
* 우선순위:
|
|
* 1. _bundled (메모리 캐시) → 'bundled'
|
|
* 2. _bundled (디스크 재조회) → 'bundled'
|
|
* 3. GitHub URL 존재 → 'github'
|
|
* 4. 둘 다 없음 → null (업데이트 불가)
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return string|null 'bundled' | 'github' | null
|
|
*/
|
|
private function resolveForceUpdateSource(string $identifier): ?string
|
|
{
|
|
if (isset($this->bundledPlugins[$identifier])) {
|
|
return 'bundled';
|
|
}
|
|
|
|
$bundledMeta = ExtensionPendingHelper::loadBundledExtensions($this->pluginsPath, 'plugin.json');
|
|
if (isset($bundledMeta[$identifier])) {
|
|
return 'bundled';
|
|
}
|
|
|
|
// 번들 없음 → GitHub URL 확인
|
|
$plugin = $this->getPlugin($identifier);
|
|
if ($plugin && $plugin->getGithubUrl()) {
|
|
return 'github';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* _bundled 에 등록된 플러그인의 버전을 반환합니다 (force 업데이트용).
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @return string|null 버전 문자열 또는 null
|
|
*/
|
|
private function getBundledVersion(string $identifier): ?string
|
|
{
|
|
if (isset($this->bundledPlugins[$identifier]['version'])) {
|
|
return $this->bundledPlugins[$identifier]['version'];
|
|
}
|
|
|
|
$meta = ExtensionPendingHelper::loadBundledExtensions($this->pluginsPath, 'plugin.json');
|
|
|
|
return $meta[$identifier]['version'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* VendorResolver 경유로 vendor/ 를 구성합니다 (composer 또는 bundled).
|
|
*
|
|
* @param string $identifier 플러그인 식별자
|
|
* @param string $sourceDir composer.json 및 vendor-bundle.zip 위치
|
|
* @param VendorMode $mode 요청된 모드
|
|
* @param string $operation 'install' | 'update'
|
|
* @param VendorMode|null $previousMode 이전 설치 모드 (update 시 상속용)
|
|
* @param \Closure|null $onProgress 진행 콜백
|
|
*
|
|
* @throws VendorInstallException
|
|
*/
|
|
private function installVendorViaResolver(
|
|
string $identifier,
|
|
string $sourceDir,
|
|
VendorMode $mode,
|
|
string $operation,
|
|
?VendorMode $previousMode,
|
|
?\Closure $onProgress,
|
|
): VendorInstallResult {
|
|
$resolver = app(VendorResolver::class);
|
|
|
|
$context = new VendorInstallContext(
|
|
target: 'plugin',
|
|
identifier: $identifier,
|
|
sourceDir: $sourceDir,
|
|
targetDir: $sourceDir,
|
|
requestedMode: $mode,
|
|
previousMode: $previousMode,
|
|
composerBinaryHint: config('process.composer_binary'),
|
|
operation: $operation,
|
|
);
|
|
|
|
$composerExecutor = function (VendorInstallContext $ctx) use ($onProgress): VendorInstallResult {
|
|
$onProgress?->__invoke('composer', 'Composer install 실행 중...');
|
|
$success = $this->extensionManager->runComposerInstallAt($ctx->sourceDir, true);
|
|
|
|
if (! $success) {
|
|
throw new VendorInstallException(
|
|
errorKey: 'composer_execution_failed',
|
|
context: ['message' => 'runComposerInstallAt returned false'],
|
|
);
|
|
}
|
|
|
|
return new VendorInstallResult(
|
|
mode: VendorMode::Composer,
|
|
strategy: 'composer',
|
|
packageCount: 0,
|
|
details: ['source_dir' => $ctx->sourceDir],
|
|
);
|
|
};
|
|
|
|
return $resolver->install($context, $composerExecutor);
|
|
}
|
|
|
|
/**
|
|
* DB에 기록된 이전 vendor 설치 모드를 조회합니다 (update 시 상속용).
|
|
*/
|
|
private function getPreviousVendorMode(string $identifier): ?VendorMode
|
|
{
|
|
$record = $this->pluginRepository->findByIdentifier($identifier);
|
|
if (! $record) {
|
|
return null;
|
|
}
|
|
|
|
$stored = $record->vendor_mode ?? null;
|
|
if ($stored === null) {
|
|
return null;
|
|
}
|
|
|
|
return VendorMode::tryFrom((string) $stored);
|
|
}
|
|
}
|