v7.0.0-beta.4 release

This commit is contained in:
HeuJung
2026-05-11 11:29:41 +09:00
parent 05db17887d
commit 1db039ff34
1545 changed files with 136783 additions and 11634 deletions
+578 -59
View File
@@ -13,6 +13,7 @@ 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;
@@ -24,6 +25,11 @@ use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\GithubHelper;
use App\Providers\CoreServiceProvider;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
@@ -45,6 +51,7 @@ use Illuminate\Support\Facades\Schema;
class ModuleManager implements ModuleManagerInterface
{
use ResolvesExtensionSharedRecords;
use Traits\CachesModuleStatus;
use Traits\ClearsTemplateCaches;
use Traits\ComputesLayoutContentHash;
@@ -297,6 +304,8 @@ class ModuleManager implements ModuleManagerInterface
*
* @param string $moduleName 설치할 모듈명
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드
* @param bool $force 강제 설치 여부
* @return bool 설치 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없거나 의존성 문제 시
@@ -449,6 +458,15 @@ class ModuleManager implements ModuleManagerInterface
// 관리자 메뉴 자동 생성
$this->createModuleMenus($module);
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncModuleIdentityPolicies($module);
// IDV 메시지 정의/템플릿 자동 동기화 (identity_message_definitions / identity_message_templates)
$this->syncModuleIdentityMessages($module);
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncModuleNotificationDefinitions($module);
DB::commit();
} catch (\Exception $e) {
@@ -517,6 +535,15 @@ class ModuleManager implements ModuleManagerInterface
);
}
// 코어 버전 호환성 사전 검증 (#306 sync 훅보다 앞쪽)
if (! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
CoreVersionChecker::validateExtension(
$module->getRequiredCoreVersion(),
$module->getIdentifier(),
'module'
);
}
// 의존성 검증: 필요한 모듈/플러그인이 활성화되어 있는지 확인
// 중첩 구조 ['modules' => [...], 'plugins' => [...]] 를 순회
$missingModules = [];
@@ -577,6 +604,9 @@ class ModuleManager implements ModuleManagerInterface
if ($result) {
$this->moduleRepository->updateByIdentifier($module->getIdentifier(), [
'status' => ExtensionStatus::Active->value,
'deactivated_reason' => null,
'deactivated_at' => null,
'incompatible_required_version' => null,
'updated_by' => Auth::id(),
'updated_at' => now(),
]);
@@ -646,10 +676,16 @@ class ModuleManager implements ModuleManagerInterface
*
* @param string $moduleName 비활성화할 모듈명
* @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 deactivateModule(string $moduleName, bool $force = false): array
{
public function deactivateModule(
string $moduleName,
bool $force = false,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
): array {
$module = $this->getModule($moduleName);
if (! $module) {
return ['success' => false, 'layouts_deleted' => 0];
@@ -708,6 +744,9 @@ class ModuleManager implements ModuleManagerInterface
if ($result) {
$this->moduleRepository->updateByIdentifier($module->getIdentifier(), [
'status' => ExtensionStatus::Inactive->value,
'deactivated_reason' => $reason,
'deactivated_at' => now(),
'incompatible_required_version' => $incompatibleRequiredVersion,
'updated_by' => Auth::id(),
'updated_at' => now(),
]);
@@ -732,6 +771,9 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
}
return ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
@@ -828,6 +870,45 @@ class ModuleManager implements ModuleManagerInterface
// PO 정책: "동적 권한/메뉴는 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
if ($deleteData) {
$this->removeModulePermissionsAndMenus($module);
// IDV 정책도 data 옵션 선택 시 제거 (동일 정책 — 재설치 시 user_overrides 손실 허용)
if (Schema::hasTable('identity_policies')) {
try {
app(IdentityPolicySyncHelper::class)
->cleanupStalePolicies('module', $module->getIdentifier(), []);
} catch (\Throwable $e) {
Log::warning('IDV 정책 정리 실패 (uninstall)', [
'module' => $module->getIdentifier(),
'error' => $e->getMessage(),
]);
}
}
// IDV 메시지 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
if (Schema::hasTable('identity_message_definitions')) {
try {
app(IdentityMessageSyncHelper::class)
->cleanupStaleDefinitions('module', $module->getIdentifier(), []);
} catch (\Throwable $e) {
Log::warning('IDV 메시지 정리 실패 (uninstall)', [
'module' => $module->getIdentifier(),
'error' => $e->getMessage(),
]);
}
}
// 알림 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
if (Schema::hasTable('notification_definitions')) {
try {
app(NotificationSyncHelper::class)
->cleanupStaleDefinitions('module', $module->getIdentifier(), []);
} catch (\Throwable $e) {
Log::warning('알림 정의 정리 실패 (uninstall)', [
'module' => $module->getIdentifier(),
'error' => $e->getMessage(),
]);
}
}
}
// 모듈 레이아웃 영구 삭제
@@ -970,6 +1051,7 @@ class ModuleManager implements ModuleManagerInterface
'status' => 'uninstalled',
'is_pending' => false,
'is_bundled' => false,
'hidden' => $module->isHidden(),
'assets' => $assets,
];
}
@@ -988,6 +1070,7 @@ class ModuleManager implements ModuleManagerInterface
'status' => 'uninstalled',
'is_pending' => true,
'is_bundled' => false,
'hidden' => (bool) ($metadata['hidden'] ?? false),
'assets' => null,
];
}
@@ -1006,6 +1089,7 @@ class ModuleManager implements ModuleManagerInterface
'status' => 'uninstalled',
'is_pending' => false,
'is_bundled' => true,
'hidden' => (bool) ($metadata['hidden'] ?? false),
'assets' => null,
];
}
@@ -1062,12 +1146,15 @@ class ModuleManager implements ModuleManagerInterface
$latestVersion = $bundledVersion ?? $fileVersion;
}
// 설치된 모듈은 DB row 의 다국어 컬럼 우선 (applyExtensionManifests 가 ja 등 주입).
$nameJson = $record->name ?: $module->getName();
$descriptionJson = $record->description ?: $module->getDescription();
$installedModules[$name] = [
'identifier' => $identifier,
'vendor' => $module->getVendor(),
'name' => $this->getLocalizedValue($module->getName(), $locale),
'name' => $this->getLocalizedValue($nameJson, $locale),
'version' => $record->version,
'description' => $this->getLocalizedValue($module->getDescription(), $locale),
'description' => $this->getLocalizedValue($descriptionJson, $locale),
'dependencies' => $this->enrichDependencies($module->getDependencies()),
'status' => $record->status,
'update_available' => $updateAvailable,
@@ -1076,6 +1163,7 @@ class ModuleManager implements ModuleManagerInterface
'update_source' => $record->update_source ?? null,
'github_url' => $module->getGithubUrl(),
'github_changelog_url' => $record->github_changelog_url ?? null,
'hidden' => $module->isHidden(),
'assets' => $assets,
];
}
@@ -1155,6 +1243,12 @@ class ModuleManager implements ModuleManagerInterface
return $result;
}
/**
* 모듈 상세 정보를 반환합니다.
*
* @param string $moduleName 모듈명
* @return array|null 모듈 정보 배열 또는 null
*/
public function getModuleInfo(string $moduleName): ?array
{
$module = $this->getModule($moduleName);
@@ -1185,12 +1279,17 @@ class ModuleManager implements ModuleManagerInterface
}
}
// 다국어 필드는 DB row(applyExtensionManifests 가 활성 언어팩 ja 등을 주입) 우선,
// 미설치 시 module.json 폴백.
$nameJson = $moduleRecord?->name ?: $module->getName();
$descriptionJson = $moduleRecord?->description ?: $module->getDescription();
return [
'identifier' => $identifier,
'vendor' => $module->getVendor(),
'name' => $this->getLocalizedValue($module->getName(), $locale),
'name' => $this->getLocalizedValue($nameJson, $locale),
'version' => $module->getVersion(),
'description' => $this->getLocalizedValue($module->getDescription(), $locale),
'description' => $this->getLocalizedValue($descriptionJson, $locale),
'github_url' => $module->getGithubUrl(),
'metadata' => $metadata,
'requires_core' => $module->getRequiredCoreVersion(),
@@ -1776,11 +1875,15 @@ class ModuleManager implements ModuleManagerInterface
$totalTableSize = array_sum(array_column($tablesInfo, 'size_bytes'));
$totalStorageSize = array_sum(array_column($storageInfo, 'size_bytes'));
// 8. 코어 공유 테이블에 적재된 이 모듈의 데이터 (deleteData=true 시 정리 대상)
$sharedRecords = $this->resolveExtensionSharedRecords('module', $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,
@@ -1788,6 +1891,7 @@ class ModuleManager implements ModuleManagerInterface
];
}
/**
* 단일 마이그레이션 파일을 롤백합니다.
*
@@ -1867,6 +1971,14 @@ class ModuleManager implements ModuleManagerInterface
}
$roles = $module->getRoles();
// 활성 언어팩이 module 의 roles 다국어 필드(name/description)에 추가 locale 을
// 주입할 수 있도록 필터 훅 적용 (LanguagePackSeedInjector::injectExtensionRoles 결선).
$roles = HookManager::applyFilters(
"module.{$module->getIdentifier()}.roles.translations",
$roles,
);
$syncHelper = $this->getRoleSyncHelper();
foreach ($roles as $role) {
@@ -1895,6 +2007,13 @@ class ModuleManager implements ModuleManagerInterface
$permissionConfig = $module->getPermissions();
$moduleIdentifier = $module->getIdentifier();
// 활성 언어팩이 권한 트리(module/categories/permissions 의 name/description) 에 추가
// locale 을 주입할 수 있도록 필터 훅 적용 (LanguagePackSeedInjector 결선).
$permissionConfig = HookManager::applyFilters(
"module.{$moduleIdentifier}.permissions.translations",
$permissionConfig,
);
// 계층형 구조 여부 확인
if (! isset($permissionConfig['categories'])) {
return;
@@ -1909,10 +2028,10 @@ class ModuleManager implements ModuleManagerInterface
// 이름/설명이 문자열인 경우 배열로 변환 (역호환)
if (is_string($permName)) {
$permName = ['ko' => $permName, 'en' => $permName];
$permName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $permName);
}
if (is_string($permDesc)) {
$permDesc = ['ko' => $permDesc, 'en' => $permDesc];
$permDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $permDesc);
}
$moduleNode = $syncHelper->syncPermission(
@@ -1939,10 +2058,10 @@ class ModuleManager implements ModuleManagerInterface
$catName = $categoryData['name'];
$catDesc = $categoryData['description'];
if (is_string($catName)) {
$catName = ['ko' => $catName, 'en' => $catName];
$catName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $catName);
}
if (is_string($catDesc)) {
$catDesc = ['ko' => $catDesc, 'en' => $catDesc];
$catDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $catDesc);
}
// 2레벨: 카테고리 권한 노드
@@ -1983,10 +2102,10 @@ class ModuleManager implements ModuleManagerInterface
$pName = $permData['name'];
$pDesc = $permData['description'];
if (is_string($pName)) {
$pName = ['ko' => $pName, 'en' => $pName];
$pName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pName);
}
if (is_string($pDesc)) {
$pDesc = ['ko' => $pDesc, 'en' => $pDesc];
$pDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pDesc);
}
// 모듈 정의에서 type을 읽거나, 없으면 admin 기본값
@@ -2087,6 +2206,14 @@ class ModuleManager implements ModuleManagerInterface
}
$menus = $module->getAdminMenus();
// 활성 언어팩이 module 의 admin_menus 다국어 필드(name 등)에 추가 locale 을
// 주입할 수 있도록 필터 훅을 적용한다 (LanguagePackSeedInjector 가 결선).
$menus = HookManager::applyFilters(
"module.{$module->getIdentifier()}.admin_menus.translations",
$menus,
);
$helper = $this->getMenuSyncHelper();
foreach ($menus as $menuData) {
@@ -2099,6 +2226,361 @@ class ModuleManager implements ModuleManagerInterface
}
/**
* 모듈이 선언한 모든 선언형 산출물을 DB 에 동기화합니다.
*
* 동기화 대상 (모듈 manifest 가 정의하는 모든 declarative 영역):
* 1. 역할 (`getRoles`)
* 2. 권한 (`getPermissions`)
* 3. 역할-권한 매핑 (`getRolePermissions`)
* 4. 관리자 메뉴 (`getAdminMenus` / `getMenus`)
* 5. 위 4종에서 stale 항목 제거 (현재 선언에 없는 기존 레코드)
* 6. IDV 정책 (`getIdentityPolicies`)
* 7. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
* 8. 알림 정의/템플릿 (`getNotificationDefinitions`)
*
* `installModule` / `updateModule` 트랜잭션 내부에서 호출되어 모듈 디스크 상태와 DB 를
* 정합 상태로 유지한다. 외부 호출 진입점으로도 노출되어 코어 업그레이드 사후 보정
* (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능.
*
* 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출
* 무해 (멱등).
*
* @param ModuleInterface $module 대상 모듈 인스턴스
*/
public function syncDeclarativeArtifacts(ModuleInterface $module): void
{
$this->createModuleRoles($module);
$this->createModulePermissions($module);
$this->assignPermissionsToRoles($module);
$this->createModuleMenus($module);
$this->cleanupStaleModuleEntries($module);
$this->syncModuleIdentityPolicies($module);
$this->syncModuleIdentityMessages($module);
$this->syncModuleNotificationDefinitions($module);
}
/**
* 활성 모듈 전체를 _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 →
* updateModule → syncDeclarativeArtifacts) 이 자동 처리하므로 본 보정의 추가 호출은
* 무해 (멱등) 하다.
*
* _bundled 에 module.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::getActiveModuleIdentifiers() as $identifier) {
$moduleDir = $this->bundledModulesPath.DIRECTORY_SEPARATOR.$identifier;
$moduleFile = $moduleDir.DIRECTORY_SEPARATOR.'module.php';
// _bundled 에 진입점이 없는 외부 설치 모듈(GitHub 등) 은 skip.
if (! File::exists($moduleFile)) {
$skipped[] = $identifier;
continue;
}
try {
$namespace = $this->convertDirectoryToNamespace($identifier);
$moduleClass = "Modules\\{$namespace}\\Module";
if (class_exists($moduleClass, false)) {
$module = $this->evalFreshModule($moduleFile, $moduleClass, $moduleDir);
} else {
require_once $moduleFile;
$module = class_exists($moduleClass) ? new $moduleClass : null;
}
if (! $module instanceof ModuleInterface) {
$failed[$identifier] = 'fresh-load 실패 (클래스 미생성)';
continue;
}
$this->syncDeclarativeArtifacts($module);
$synced[] = $identifier;
} catch (\Throwable $e) {
$failed[$identifier] = $e->getMessage();
Log::channel('upgrade')->warning('[resyncAllActiveDeclarativeArtifacts] sync 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return ['synced' => $synced, 'skipped' => $skipped, 'failed' => $failed];
}
/**
* 모듈이 선언한 IDV 정책을 `identity_policies` 테이블에 동기화합니다.
*
* `AbstractModule::getIdentityPolicies()` 결과를 순회하며
* `IdentityPolicySyncHelper::syncPolicy()` 로 upsert 하고,
* 현재 선언에 없는 기존 정책은 `cleanupStalePolicies()` 로 제거합니다.
*
* 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
* 는 `user_overrides` JSON 으로 보존됩니다.
*
* @param ModuleInterface $module IDV 정책을 동기화할 모듈 인스턴스
*/
protected function syncModuleIdentityPolicies(ModuleInterface $module): void
{
if (! method_exists($module, 'getIdentityPolicies')) {
return;
}
if (! Schema::hasTable('identity_policies')) {
return; // 마이그레이션 미실행 환경 (예: 초기 설치) 보호
}
$policies = $module->getIdentityPolicies();
// 데이터 손실 방어막 — declaration 이 빈 배열인 경우 cleanup 호출 자체를 차단.
// declaration 은 모듈 코드 계약(getIdentityPolicies) 의 결과이므로 빈 배열 = "이 모듈은
// IDV 정책을 사용하지 않음". 이 경우 첫 install 시점부터 DB row 가 없을 것이라는 전제가
// 성립한다. DB 에 기존 row 가 존재하면 declaration 이 환경 결함(spawn 자식 PSR-4 stale,
// fresh-load 의존성 누락, trait 부분 로드 등) 으로 빈 배열을 반환했을 가능성이 높으므로,
// cleanup 을 건너뛰고 warning 로그만 남긴다 (silent 데이터 손실 차단).
if (empty($policies)) {
$existingCount = DB::table('identity_policies')
->where('source_type', 'module')
->where('source_identifier', $module->getIdentifier())
->count();
if ($existingCount > 0) {
Log::warning('IDV 정책 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
'module' => $module->getIdentifier(),
]);
}
return;
}
$helper = app(IdentityPolicySyncHelper::class);
$definedKeys = [];
foreach ($policies as $data) {
$data['source_type'] = 'module';
$data['source_identifier'] = $module->getIdentifier();
$helper->syncPolicy($data);
$definedKeys[] = $data['key'];
}
$helper->cleanupStalePolicies('module', $module->getIdentifier(), $definedKeys);
}
/**
* 모듈이 선언한 IDV 메시지 정의/템플릿을 동기화합니다.
*
* `AbstractModule::getIdentityMessages()` 결과를 순회하며
* `IdentityMessageSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
* 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 제거합니다.
*
* `extension_type='module'`, `extension_identifier=$module->getIdentifier()` 는 자동 주입.
* 운영자 user_overrides 보존은 helper 내부 trait 가 처리.
*
* @param ModuleInterface $module IDV 메시지를 동기화할 모듈 인스턴스
*/
protected function syncModuleIdentityMessages(ModuleInterface $module): void
{
if (! method_exists($module, 'getIdentityMessages')) {
return;
}
if (! Schema::hasTable('identity_message_definitions')) {
return; // 마이그레이션 미실행 환경 보호
}
$messages = $module->getIdentityMessages();
// 언어팩 시스템: 활성 모듈 언어팩의 seed/identity_messages.json 으로 다국어 키 병합.
$messages = HookManager::applyFilters(
"seed.{$module->getIdentifier()}.identity_messages.translations",
$messages
);
// 데이터 손실 방어막 — declaration 빈 배열 시 cleanup 호출 자체를 차단.
// 자세한 배경은 syncModuleIdentityPolicies 의 동일 가드 주석 참조.
if (empty($messages)) {
if ($this->hasExistingIdentityMessageDefinitions('module', $module->getIdentifier())) {
Log::warning('IDV 메시지 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
'module' => $module->getIdentifier(),
]);
}
return;
}
$helper = app(IdentityMessageSyncHelper::class);
$definedScopes = [];
foreach ($messages as $data) {
$data['extension_type'] = 'module';
$data['extension_identifier'] = $module->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('module', $module->getIdentifier(), $definedScopes);
}
/**
* 모듈이 선언한 알림 정의/템플릿을 `notification_definitions` / `notification_templates` 에 동기화합니다.
*
* `AbstractModule::getNotificationDefinitions()` 결과를 순회하며
* `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
* 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 제거합니다.
*
* `extension_type='module'`, `extension_identifier=$module->getIdentifier()` 는 자동 주입.
* 운영자 user_overrides 보존은 helper 내부 trait 가 처리.
*
* @param ModuleInterface $module 알림 정의를 동기화할 모듈 인스턴스
*/
protected function syncModuleNotificationDefinitions(ModuleInterface $module): void
{
if (! method_exists($module, 'getNotificationDefinitions')) {
return;
}
if (! Schema::hasTable('notification_definitions')) {
return; // 마이그레이션 미실행 환경 보호
}
$definitions = $module->getNotificationDefinitions();
// 언어팩 시스템: 활성 모듈 언어팩의 seed/notifications.json 으로 다국어 키 병합.
$definitions = HookManager::applyFilters(
"seed.{$module->getIdentifier()}.notifications.translations",
$definitions
);
// 데이터 손실 방어막 — declaration 빈 배열 시 cleanup 호출 자체를 차단.
// 자세한 배경은 syncModuleIdentityPolicies 의 동일 가드 주석 참조.
if (empty($definitions)) {
if ($this->hasExistingNotificationDefinitions('module', $module->getIdentifier())) {
Log::warning('알림 정의 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
'module' => $module->getIdentifier(),
]);
}
return;
}
$helper = app(NotificationSyncHelper::class);
$definedTypes = [];
foreach ($definitions as $data) {
$data['extension_type'] = 'module';
$data['extension_identifier'] = $module->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('module', $module->getIdentifier(), $definedTypes);
}
/**
* 해당 source 가 기존에 등록한 IDV 정책이 있는지 확인합니다.
*
* @param string $sourceType
* @param string $sourceIdentifier
* @return bool
*/
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;
}
}
/**
* 해당 모듈이 기존에 등록한 메뉴가 있는지 확인합니다 (데이터 손실 방어용).
*/
protected function hasExistingModuleMenus(string $moduleIdentifier): bool
{
try {
return DB::table('menus')
->where('extension_type', ExtensionOwnerType::Module->value)
->where('extension_identifier', $moduleIdentifier)
->exists();
} catch (\Throwable) {
return false;
}
}
/**
* 모듈 정의 기준으로 stale 권한·메뉴·역할을 정리합니다 (완전 동기화 원칙).
*
@@ -2150,16 +2632,26 @@ class ModuleManager implements ModuleManagerInterface
}
// 2. 메뉴 stale 정리 (정적 + 동적 slug 병합)
// 데이터 손실 방어: declaration 이 빈 배열인데 DB 에 모듈 메뉴 row 가 존재하면 cleanup 차단.
// declaration 빈 배열은 환경 결함(spawn 자식 PSR-4 stale, fresh-load 의존성 누락 등) 으로
// 인한 silent 빈 반환일 가능성이 높다. 모듈이 진짜 메뉴를 비웠다면 첫 install 시점부터
// DB row 가 없을 것이라는 전제가 성립. (역할/권한 cleanup 의 동일 가드와 일관)
if (method_exists($module, 'getAdminMenus')) {
$currentSlugs = $menuSyncHelper->collectSlugsRecursive($module->getAdminMenus());
if (method_exists($module, 'getDynamicMenuSlugs')) {
$currentSlugs = array_merge($currentSlugs, $module->getDynamicMenuSlugs());
}
$menuSyncHelper->cleanupStaleMenus(
ExtensionOwnerType::Module,
$module->getIdentifier(),
$currentSlugs,
);
if (! empty($currentSlugs)) {
$menuSyncHelper->cleanupStaleMenus(
ExtensionOwnerType::Module,
$module->getIdentifier(),
$currentSlugs,
);
} elseif ($this->hasExistingModuleMenus($module->getIdentifier())) {
Log::warning('모듈 메뉴 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
'module' => $module->getIdentifier(),
]);
}
}
// 3. 역할 stale 정리 (정적 getRoles + 동적 역할 병합)
@@ -3292,16 +3784,12 @@ class ModuleManager implements ModuleManagerInterface
{
$record = $this->moduleRepository->findByIdentifier($identifier);
if (! $record) {
return [
'update_available' => false,
'update_source' => null,
'latest_version' => null,
'current_version' => null,
];
return $this->buildModuleUpdateResponse(false, null, null, null, null);
}
$currentVersion = $record->version;
$module = $this->getModule($identifier);
$activeRequiredCoreVersion = $module ? $module->getRequiredCoreVersion() : null;
// 1. GitHub URL이 있으면 GitHub에서 최신 버전 확인 (조회 성공 시 GitHub만 신뢰)
if ($module && $module->getGithubUrl()) {
@@ -3317,62 +3805,75 @@ class ModuleManager implements ModuleManagerInterface
}
if ($latestVersion !== null) {
// GitHub 조회 성공 → GitHub 결과만 신뢰 (bundled 폴백 없음)
if (version_compare($latestVersion, $currentVersion, '>')) {
return [
'update_available' => true,
'update_source' => 'github',
'latest_version' => $latestVersion,
'current_version' => $currentVersion,
];
return $this->buildModuleUpdateResponse(
true, 'github', $latestVersion, $currentVersion, $activeRequiredCoreVersion
);
}
return [
'update_available' => false,
'update_source' => null,
'latest_version' => $currentVersion,
'current_version' => $currentVersion,
];
return $this->buildModuleUpdateResponse(
false, null, $currentVersion, $currentVersion, $activeRequiredCoreVersion
);
}
// GitHub 조회 실패 → _bundled 폴백 안내
Log::info('모듈 업데이트 확인: GitHub 조회 실패로 bundled 폴백', [
'module' => $identifier,
]);
}
// 2. _bundled에서 업데이트 확인 (GitHub URL 없음 OR GitHub 조회 실패)
// 2. _bundled에서 업데이트 확인
if (isset($this->bundledModules[$identifier])) {
$bundledVersion = $this->bundledModules[$identifier]['version'] ?? null;
$bundledRequired = $this->bundledModules[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
return [
'update_available' => true,
'update_source' => 'bundled',
'latest_version' => $bundledVersion,
'current_version' => $currentVersion,
];
return $this->buildModuleUpdateResponse(
true, 'bundled', $bundledVersion, $currentVersion, $bundledRequired
);
}
} else {
$bundledMeta = ExtensionPendingHelper::loadBundledExtensions($this->modulesPath, 'module.json');
if (isset($bundledMeta[$identifier])) {
$bundledVersion = $bundledMeta[$identifier]['version'] ?? null;
$bundledRequired = $bundledMeta[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
return [
'update_available' => true,
'update_source' => 'bundled',
'latest_version' => $bundledVersion,
'current_version' => $currentVersion,
];
return $this->buildModuleUpdateResponse(
true, 'bundled', $bundledVersion, $currentVersion, $bundledRequired
);
}
}
}
// 4. 업데이트 없음
return $this->buildModuleUpdateResponse(
false, null, $currentVersion, $currentVersion, $activeRequiredCoreVersion
);
}
/**
* checkModuleUpdate 응답 페이로드 빌더 (호환성 메타 부착).
*
* @param bool $updateAvailable 업데이트 가용 여부
* @param string|null $updateSource 업데이트 소스 (github|bundled|null)
* @param string|null $latestVersion 최신 버전
* @param string|null $currentVersion 현재 설치된 버전
* @param string|null $requiredCoreVersion 요구 코어 버전 제약
* @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 buildModuleUpdateResponse(
bool $updateAvailable,
?string $updateSource,
?string $latestVersion,
?string $currentVersion,
?string $requiredCoreVersion,
): array {
return [
'update_available' => false,
'update_source' => null,
'latest_version' => $currentVersion,
'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(),
];
}
@@ -3615,6 +4116,8 @@ class ModuleManager implements ModuleManagerInterface
* @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 업데이트 실패 시
@@ -3710,6 +4213,16 @@ class ModuleManager implements ModuleManagerInterface
$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(__('modules.errors.downgrade_blocked', [
'from' => $fromVersion,
'to' => $toVersion,
]));
}
$backupPath = null;
try {
@@ -3740,6 +4253,16 @@ class ModuleManager implements ModuleManagerInterface
ExtensionPendingHelper::stageForUpdate($zipExtractedDir, $stagingPath, $onProgress);
}
// 3.4. 코어 버전 호환성 사전 검증 (staging manifest 기준)
if ($stagingPath && ! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
$stagedManifest = (new Vendor\VendorIntegrityChecker)->readManifest($stagingPath);
CoreVersionChecker::validateExtension(
$stagedManifest['g7_version'] ?? null,
$identifier,
'module'
);
}
// 3.5. Vendor 설치 (의존성 있는 경우만, 변경 시에만)
$resolvedVendorMode = $vendorMode;
if ($stagingPath && $this->extensionManager->hasComposerDependenciesAt($stagingPath)) {
@@ -3828,13 +4351,9 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
// Role/Permission/Menu 동기화 (있으면 업데이트) + 완전 동기화 (stale cleanup)
// 선언형 산출물 (역할/권한/메뉴/IDV/알림) 일괄 동기화 + stale cleanup
if ($module) {
$this->createModuleRoles($module);
$this->createModulePermissions($module);
$this->assignPermissionsToRoles($module);
$this->createModuleMenus($module);
$this->cleanupStaleModuleEntries($module);
$this->syncDeclarativeArtifacts($module);
}
DB::commit();