diff --git a/AGENTS.md b/AGENTS.md index 8dd1a4df..327f801c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -584,10 +584,15 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통 | typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 | | 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 | | 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 | +| 치환 자리(`:error`)를 가진 키를 파라미터 없이 호출 | 넷째 인자 `messageParams` 로 채운다 — 비워 두면 번역기가 자리표시자를 **그대로 둔 문장**을 돌려줘 운영자 화면에 `:error` 가 노출된다 (실패했을 때만 드러나 정상 흐름 테스트로는 안 잡힌다) | +| 사유를 모른다고 치환 자리를 비워 두기 | 알 수 없으면 일반 문구(`errors.unknown_error`)로 채운다 | +| 원문을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 **치환 자리 자체를 없앤다** — 자리를 남기면 나중에 예외 원문으로 채우는 회귀를 부른다 | +| 하위 계층이 `false`/`null` 만 돌려주고 실패 사유를 버림 | 사유를 반환 경로에 실어 올린다 (배열 키 `reason` 또는 **뒤에 붙인 선택적 out 파라미터**) — 기존 호출부를 깨지 않는다 | +| 확장 수명주기 훅이 사유 없이 `false` 반환 | `AbstractModule`/`AbstractPlugin` 의 `failWith(__('...'))` — 코어가 그 사유를 원인 자리에 싣는다 | `message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다. -상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. +상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. 치환 자리 축은 `tests/Feature/Http/ErrorMessageParamSubstitutionTest.php` 가 고정한다 — 호출부를 열거하지 않고 `->error(...)` 전수를 괄호 균형으로 잘라, 키를 실제로 번역해 `:error` 를 요구하는지 판정한다. 같은 판정기가 `new *OperationException(...)` 생성자 축도 덮는다(파라미터 배열이 넷째가 아니라 둘째 인자다). 이 축이 없으면 키를 들고 다니는 예외로 던지는 경로가 통째로 사각이 된다. ### Listener 데이터 접근 @@ -1051,6 +1056,8 @@ BaseApiController (최상위) 필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행 필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지 필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시") +필수: 확장 라우트는 활성 상태인 확장의 것만 등록한다 — 모듈·플러그인 두 라우트 프로바이더가 같은 기준을 쓴다. 게이트가 한쪽에만 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만 나타난다: 비활성화해도 화면·메뉴·에셋만 사라지고 API 는 계속 호출 가능하며, 컨트롤러가 정상 처리하므로 오류도 로그도 남지 않는다 +필수: 그 rebuild() 는 확장 상태 캐시 무효화(invalidate*StatusCache()) 뒤에 온다 — route:cache 는 새 앱을 부팅해 라우트를 수집하는데 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록(TTL 기본 1일)을 읽으므로, 먼저 구우면 방금 바뀐 상태가 빠진 채 박제되고 자가 회복되지 않는다 (활성화 → 그 확장 API 전량 404 / 비활성화 → 끈 확장 API 가 계속 호출 가능 / 업데이트 → 404 + 훅 리스너 누락). 무효화는 굽기 직전이 아니라 DB 상태 쓰기 직후에 둔다 — 같은 목록을 읽는 굽기가 라우트 캐시 말고도 있다 (오토로드 갱신 안의 훅 매핑 캐시). update 경로만 예외: Updating 전이 직후에는 비우지 않고 (비우면 그 창의 오토로드 갱신이 그 확장을 비활성으로 판정해 훅 리스너를 떨군다) 상태 복원 직후에 비운 뒤 ExtensionManager::regenerateHookCache() 로 훅 캐시를 다시 굽는다. 훅 캐시 폴백은 파일 부재·손상에만 작동해 내용이 stale 한 경우는 조용히 통과한다 필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용 필수: 모든 확장 작업은 Artisan 커맨드로 수행 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c94ea9..9fa8c1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ - 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다. - 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다. +### Changed + +- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.** +- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다. + +### Fixed + +- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다. +- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다. + ## [7.0.7] - 2026-08-19 ### Security diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php index 5bdfde59..cf4f9033 100644 --- a/app/Extension/AbstractModule.php +++ b/app/Extension/AbstractModule.php @@ -9,6 +9,7 @@ use App\Contracts\Extension\StorageInterface; use App\Contracts\Extension\UpgradeStepInterface; use App\Extension\Cache\ModuleCacheDriver; use App\Extension\Storage\ModuleStorageDriver; +use App\Extension\Traits\ReportsLifecycleFailure; use Illuminate\Database\Seeder; use ReflectionClass; @@ -21,6 +22,8 @@ use ReflectionClass; */ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface { + use ReportsLifecycleFailure; + /** * 모듈 디렉토리 경로 (캐시) */ diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 380ef150..c6fc3b00 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -9,6 +9,7 @@ use App\Contracts\Extension\StorageInterface; use App\Contracts\Extension\UpgradeStepInterface; use App\Extension\Cache\PluginCacheDriver; use App\Extension\Storage\PluginStorageDriver; +use App\Extension\Traits\ReportsLifecycleFailure; use Illuminate\Database\Seeder; use ReflectionClass; @@ -24,6 +25,8 @@ use ReflectionClass; */ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface { + use ReportsLifecycleFailure; + /** * 플러그인 디렉토리 경로 (캐시) */ diff --git a/app/Extension/ExtensionManager.php b/app/Extension/ExtensionManager.php index 8facbd27..a53a25e8 100644 --- a/app/Extension/ExtensionManager.php +++ b/app/Extension/ExtensionManager.php @@ -74,8 +74,12 @@ class ExtensionManager * * 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다. * 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다. + * + * 확장 수명주기에서 상태를 되돌린 뒤 다시 부를 수 있도록 public 이다 — + * Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너가 빠진 채 남고, + * 훅 캐시 폴백은 파일 부재/손상에만 작동해 stale 한 내용은 조용히 통과하기 때문이다. */ - protected function regenerateHookCache(): void + public function regenerateHookCache(): void { try { $moduleManager = app(ModuleManager::class); diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index f3b4ce63..df68fb30 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -311,6 +311,7 @@ class ModuleManager implements ModuleManagerInterface * @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 모듈을 찾을 수 없거나 의존성 문제 시 @@ -320,7 +321,10 @@ class ModuleManager implements ModuleManagerInterface ?\Closure $onProgress = null, VendorMode $vendorMode = VendorMode::Auto, bool $force = false, + ?string &$failureReason = null, ): bool { + $failureReason = null; + // identifier 형식 검증 (내부 호출 방어) ExtensionManager::validateIdentifierFormat($moduleName); @@ -404,9 +408,12 @@ class ModuleManager implements ModuleManagerInterface $this->validateSeoVariables($module, 'module'); // 모듈 설치 실행 + $module->clearLifecycleFailureReason(); $result = $module->install(); if (! $result) { + $failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'); + return false; } @@ -540,9 +547,15 @@ class ModuleManager implements ModuleManagerInterface { $module = $this->getModule($moduleName); if (! $module) { - return ['success' => false, 'layouts_registered' => 0]; + return [ + 'success' => false, + 'layouts_registered' => 0, + 'reason' => __('modules.errors.not_found', ['module' => $moduleName]), + ]; } + $module->clearLifecycleFailureReason(); + // 상태 가드: 진행 중 상태 체크 $record = $this->moduleRepository->findByIdentifier($module->getIdentifier()); if ($record) { @@ -628,6 +641,13 @@ class ModuleManager implements ModuleManagerInterface 'updated_at' => now(), ]); + // 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다. + // 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는 + // 새 애플리케이션을 부팅해 "캐시된" 활성 모듈 목록을 읽는다. 여기서 비우지 않으면 + // 방금 활성으로 바뀐 이 모듈이 목록에서 빠진 채 라우트가 박제되고, + // 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다. + self::invalidateModuleStatusCache(); + // soft deleted된 모듈 레이아웃 복원 (재활성화 시) $this->restoreModuleLayouts($module->getIdentifier()); @@ -650,9 +670,6 @@ class ModuleManager implements ModuleManagerInterface $this->incrementExtensionCacheVersion(); RouteCacheHelper::rebuild(); - // 모듈 상태 캐시 무효화 - self::invalidateModuleStatusCache(); - // 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이 // 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가). IdentityPolicy::flushRouteScopeCache(); @@ -667,7 +684,18 @@ class ModuleManager implements ModuleManagerInterface HookManager::doAction('core.modules.activated', $moduleName); } - return ['success' => $result, 'layouts_registered' => $layoutsRegistered]; + if (! $result) { + // 모듈이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고, + // 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면 + // 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다. + return [ + 'success' => false, + 'layouts_registered' => $layoutsRegistered, + 'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'), + ]; + } + + return ['success' => true, 'layouts_registered' => $layoutsRegistered]; } /** @@ -714,9 +742,15 @@ class ModuleManager implements ModuleManagerInterface ): array { $module = $this->getModule($moduleName); if (! $module) { - return ['success' => false, 'layouts_deleted' => 0]; + return [ + 'success' => false, + 'layouts_deleted' => 0, + 'reason' => __('modules.errors.not_found', ['module' => $moduleName]), + ]; } + $module->clearLifecycleFailureReason(); + // 상태 가드: 진행 중 상태 체크 $record = $this->moduleRepository->findByIdentifier($module->getIdentifier()); if ($record) { @@ -777,6 +811,12 @@ class ModuleManager implements ModuleManagerInterface 'updated_at' => now(), ]); + // 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다. + // 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 모듈 목록을 읽으므로, + // 여기서 비우지 않으면 방금 비활성으로 바꾼 모듈의 라우트가 그대로 박제되어 + // 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다. + self::invalidateModuleStatusCache(); + // 모듈 레이아웃 soft delete $layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier()); @@ -796,9 +836,6 @@ class ModuleManager implements ModuleManagerInterface // 모듈 자체 캐시 전체 정리 $this->flushModuleCache($module); - // 모듈 상태 캐시 무효화 - self::invalidateModuleStatusCache(); - // 본인인증 route scope 캐시 무효화 — 비활성 모듈이 선언한 정책이 enforce 대상에서 // 즉시 제외되도록 한다. 정책 행 자체는 변경하지 않으므로(enabled 운영자 설정 보존) // IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다. @@ -811,7 +848,15 @@ class ModuleManager implements ModuleManagerInterface HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier()); } - return ['success' => $result, 'layouts_deleted' => $layoutsDeleted]; + if (! $result) { + return [ + 'success' => false, + 'layouts_deleted' => $layoutsDeleted, + 'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'), + ]; + } + + return ['success' => true, 'layouts_deleted' => $layoutsDeleted]; } /** @@ -859,12 +904,19 @@ class ModuleManager implements ModuleManagerInterface * @param string $moduleName 제거할 모듈명 * @param bool $deleteData 모듈 데이터(테이블) 삭제 여부 * @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message) + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return bool 제거 성공 여부 * * @throws \Exception 모듈을 찾을 수 없을 때 */ - public function uninstallModule(string $moduleName, bool $deleteData = false, ?\Closure $onProgress = null): bool - { + public function uninstallModule( + string $moduleName, + bool $deleteData = false, + ?\Closure $onProgress = null, + ?string &$failureReason = null, + ): bool { + $failureReason = null; + // 상태 가드: 진행 중 상태 체크 $existingRecord = $this->moduleRepository->findByIdentifier($moduleName); if ($existingRecord) { @@ -897,8 +949,13 @@ class ModuleManager implements ModuleManagerInterface DB::beginTransaction(); // 모듈 제거 실행 + $module->clearLifecycleFailureReason(); $result = $module->uninstall(); + if (! $result) { + $failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'); + } + if ($result) { // 권한·메뉴·역할은 $deleteData=true 시에만 삭제. // false 시 보존하여 재설치 시 기존 역할 할당/커스터마이징이 복원 가능하도록 한다. @@ -960,6 +1017,12 @@ class ModuleManager implements ModuleManagerInterface // 오토로드 병합 실행 (트랜잭션 외부에서 실행) if ($result) { + // 모듈 상태 캐시 무효화 — DB 에서 모듈 행을 지운 직후(커밋 직후)에 둔다. + // 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가 + // 캐시된 활성 모듈 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된 모듈이 + // 목록에 남은 채로 라우트·훅이 박제된다. + self::invalidateModuleStatusCache(); + $onProgress?->__invoke('autoload', '오토로드 갱신 중...'); $this->extensionManager->updateComposerAutoload(); @@ -977,9 +1040,6 @@ class ModuleManager implements ModuleManagerInterface // 모듈 자체 캐시 전체 정리 $this->flushModuleCache($module); - // 모듈 상태 캐시 무효화 - self::invalidateModuleStatusCache(); - // 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외. ExtensionMiddlewareRegistry::flush(); @@ -4495,6 +4555,13 @@ class ModuleManager implements ModuleManagerInterface 'updated_at' => now(), ]); + // 모듈 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다. + // Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의 + // updateComposerAutoload() 가 DB 를 재조회해 이 모듈을 비활성으로 판정하고 + // 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다). + // 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다. + self::invalidateModuleStatusCache(); + // 9. 레이아웃 갱신 (이전 상태가 active였으면) // refreshModuleLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함 $onProgress?->__invoke('layout', '레이아웃 갱신 중...'); @@ -4518,7 +4585,13 @@ class ModuleManager implements ModuleManagerInterface $this->clearAllTemplateRoutesCaches(); $this->incrementExtensionCacheVersion(); RouteCacheHelper::rebuild(); - self::invalidateModuleStatusCache(); + + // 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는 + // 그 시점 이 모듈이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다. + // 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다. + // 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다. + // updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다. + $this->extensionManager->regenerateHookCache(); // 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거) HookManager::doAction('core.modules.updated', $identifier); diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 3c53cf25..dc016663 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -296,6 +296,7 @@ class PluginManager implements PluginManagerInterface * @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 플러그인을 찾을 수 없거나 의존성 문제 시 @@ -305,7 +306,10 @@ class PluginManager implements PluginManagerInterface ?\Closure $onProgress = null, VendorMode $vendorMode = VendorMode::Auto, bool $force = false, + ?string &$failureReason = null, ): bool { + $failureReason = null; + // identifier 형식 검증 (내부 호출 방어) ExtensionManager::validateIdentifierFormat($pluginName); @@ -386,8 +390,13 @@ class PluginManager implements PluginManagerInterface // 플러그인 설치 실행 $onProgress?->__invoke('validate', '검증 중...'); + $plugin->clearLifecycleFailureReason(); $result = $plugin->install(); + if (! $result) { + $failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'); + } + if (! $result) { return false; } @@ -522,9 +531,15 @@ class PluginManager implements PluginManagerInterface { $plugin = $this->getPlugin($pluginName); if (! $plugin) { - return ['success' => false, 'layouts_registered' => 0]; + return [ + 'success' => false, + 'layouts_registered' => 0, + 'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]), + ]; } + $plugin->clearLifecycleFailureReason(); + // 상태 가드: 진행 중 상태 체크 $record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier()); if ($record) { @@ -613,6 +628,13 @@ class PluginManager implements PluginManagerInterface 'updated_at' => now(), ]); + // 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다. + // 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는 + // 새 애플리케이션을 부팅해 "캐시된" 활성 플러그인 목록을 읽는다. 여기서 비우지 않으면 + // 방금 활성으로 바뀐 이 플러그인이 목록에서 빠진 채 라우트가 박제되고, + // 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다. + self::invalidatePluginStatusCache(); + // soft deleted된 플러그인 레이아웃 복원 (재활성화 시) $this->restorePluginLayouts($plugin->getIdentifier()); @@ -635,9 +657,6 @@ class PluginManager implements PluginManagerInterface $this->incrementExtensionCacheVersion(); RouteCacheHelper::rebuild(); - // 플러그인 상태 캐시 무효화 - self::invalidatePluginStatusCache(); - // 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이 // 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가). IdentityPolicy::flushRouteScopeCache(); @@ -652,7 +671,18 @@ class PluginManager implements PluginManagerInterface HookManager::doAction('core.plugins.activated', $pluginName); } - return ['success' => $result, 'layouts_registered' => $layoutsRegistered]; + if (! $result) { + // 플러그인이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고, + // 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면 + // 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다. + return [ + 'success' => false, + 'layouts_registered' => $layoutsRegistered, + 'reason' => $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'), + ]; + } + + return ['success' => true, 'layouts_registered' => $layoutsRegistered]; } /** @@ -699,9 +729,15 @@ class PluginManager implements PluginManagerInterface ): array { $plugin = $this->getPlugin($pluginName); if (! $plugin) { - return ['success' => false, 'layouts_deleted' => 0]; + return [ + 'success' => false, + 'layouts_deleted' => 0, + 'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]), + ]; } + $plugin->clearLifecycleFailureReason(); + // 상태 가드: 진행 중 상태 체크 $record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier()); if ($record) { @@ -765,6 +801,12 @@ class PluginManager implements PluginManagerInterface 'updated_at' => now(), ]); + // 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다. + // 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 플러그인 목록을 읽으므로, + // 여기서 비우지 않으면 방금 비활성으로 바꾼 플러그인의 라우트가 그대로 박제되어 + // 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다. + self::invalidatePluginStatusCache(); + // 플러그인 레이아웃 soft delete $layoutsDeleted = $this->softDeletePluginLayouts($plugin->getIdentifier()); @@ -784,9 +826,6 @@ class PluginManager implements PluginManagerInterface // 플러그인 자체 캐시 전체 정리 $this->flushPluginCache($plugin); - // 플러그인 상태 캐시 무효화 - self::invalidatePluginStatusCache(); - // 본인인증 route scope 캐시 무효화 — 비활성 플러그인이 선언한 정책이 enforce // 대상에서 즉시 제외되도록 한다. 정책 행은 변경하지 않으므로(enabled 보존) // IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다. @@ -801,6 +840,10 @@ class PluginManager implements PluginManagerInterface $response = ['success' => $result, 'layouts_deleted' => $layoutsDeleted]; + if (! $result) { + $response['reason'] = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'); + } + if (! empty($driverWarnings)) { $response['driver_warnings'] = $driverWarnings; } @@ -888,12 +931,19 @@ class PluginManager implements PluginManagerInterface * @param string $pluginName 제거할 플러그인명 * @param bool $deleteData 플러그인 데이터(테이블) 삭제 여부 * @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message) + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return bool 제거 성공 여부 * * @throws \Exception 플러그인을 찾을 수 없을 때 */ - public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?\Closure $onProgress = null): bool - { + public function uninstallPlugin( + string $pluginName, + bool $deleteData = false, + ?\Closure $onProgress = null, + ?string &$failureReason = null, + ): bool { + $failureReason = null; + // 상태 가드: 진행 중 상태 체크 $existingRecord = $this->pluginRepository->findByIdentifier($pluginName); if ($existingRecord) { @@ -922,8 +972,13 @@ class PluginManager implements PluginManagerInterface DB::beginTransaction(); // 플러그인 제거 실행 + $plugin->clearLifecycleFailureReason(); $result = $plugin->uninstall(); + if (! $result) { + $failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'); + } + if ($result) { // 권한/역할은 $deleteData=true 시에만 삭제. // 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제" @@ -984,6 +1039,12 @@ class PluginManager implements PluginManagerInterface // 트랜잭션 외부에서 실행 if ($result) { + // 플러그인 상태 캐시 무효화 — DB 에서 플러그인 행을 지운 직후(커밋 직후)에 둔다. + // 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가 + // 캐시된 활성 플러그인 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된 + // 플러그인이 목록에 남은 채로 라우트·훅이 박제된다. + self::invalidatePluginStatusCache(); + // 플러그인 설정 디렉토리 삭제 (deleteData 옵션이 true인 경우) if ($deleteData) { $this->deletePluginSettingsDirectory($plugin); @@ -1009,9 +1070,6 @@ class PluginManager implements PluginManagerInterface // 플러그인 자체 캐시 전체 정리 $this->flushPluginCache($plugin); - // 플러그인 상태 캐시 무효화 - self::invalidatePluginStatusCache(); - // 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외. ExtensionMiddlewareRegistry::flush(); @@ -4725,6 +4783,13 @@ class PluginManager implements PluginManagerInterface 'updated_at' => now(), ]); + // 플러그인 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다. + // Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의 + // updateComposerAutoload() 가 DB 를 재조회해 이 플러그인을 비활성으로 판정하고 + // 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다). + // 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다. + self::invalidatePluginStatusCache(); + // 9. 레이아웃 갱신 (이전 상태가 active였으면) // refreshPluginLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함 $onProgress?->__invoke('layout', '레이아웃 갱신 중...'); @@ -4748,7 +4813,13 @@ class PluginManager implements PluginManagerInterface $this->clearAllTemplateRoutesCaches(); $this->incrementExtensionCacheVersion(); RouteCacheHelper::rebuild(); - self::invalidatePluginStatusCache(); + + // 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는 + // 그 시점 이 플러그인이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다. + // 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다. + // 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다. + // updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다. + $this->extensionManager->regenerateHookCache(); // 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거) HookManager::doAction('core.plugins.updated', $identifier); diff --git a/app/Extension/TemplateManager.php b/app/Extension/TemplateManager.php index 6f13c8c1..9fd3fbc2 100644 --- a/app/Extension/TemplateManager.php +++ b/app/Extension/TemplateManager.php @@ -672,15 +672,21 @@ class TemplateManager implements TemplateManagerInterface * @param string $templateName 비활성화할 템플릿명 (identifier) * @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core) * @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약 + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return bool 비활성화 성공 여부 */ public function deactivateTemplate( string $templateName, string $reason = DeactivationReason::Manual->value, ?string $incompatibleRequiredVersion = null, + ?string &$failureReason = null, ): bool { + $failureReason = null; + $template = $this->getTemplate($templateName); if (! $template) { + $failureReason = __('templates.errors.not_found', ['template' => $templateName]); + return false; } diff --git a/app/Extension/Traits/ReportsLifecycleFailure.php b/app/Extension/Traits/ReportsLifecycleFailure.php new file mode 100644 index 00000000..06662cfc --- /dev/null +++ b/app/Extension/Traits/ReportsLifecycleFailure.php @@ -0,0 +1,58 @@ +lifecycleFailureReason; + } + + /** + * 실패 사유를 남기고 false 를 반환합니다. + * + * 수명주기 훅에서 `return $this->failWith(__('...'));` 형태로 사용합니다. + * + * @param string $reason 운영자에게 보일 실패 사유 (번역된 문장) + * @return false 언제나 false + */ + protected function failWith(string $reason): bool + { + $this->lifecycleFailureReason = $reason; + + return false; + } + + /** + * 직전 실패 사유를 지웁니다. + * + * 같은 확장 인스턴스로 수명주기 훅을 다시 부르기 전에 코어가 호출합니다. + * 지우지 않으면 이전 실패의 사유가 다음 성공/실패에 그대로 따라붙는다. + */ + public function clearLifecycleFailureReason(): void + { + $this->lifecycleFailureReason = null; + } +} diff --git a/app/Http/Controllers/Api/Admin/ModuleController.php b/app/Http/Controllers/Api/Admin/ModuleController.php index 74ffb478..ce2c228f 100644 --- a/app/Http/Controllers/Api/Admin/ModuleController.php +++ b/app/Http/Controllers/Api/Admin/ModuleController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Admin; use App\Enums\LanguagePackScope; +use App\Exceptions\ModuleOperationException; use App\Extension\Vendor\VendorMode; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks; @@ -199,7 +200,7 @@ class ModuleController extends AdminBaseController // cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort) $this->installSelectedDependencies($validated['dependencies'] ?? []); - $module = $this->moduleService->installModule($moduleName, $vendorMode); + $module = $this->moduleService->installModule($moduleName, $vendorMode, false, $installFailureReason); if ($module) { // cascade 2단계: 동반 번들 언어팩 best-effort 설치 @@ -210,7 +211,9 @@ class ModuleController extends AdminBaseController return $this->success('module.install_success', $payload, 201); } else { - return $this->error('module.install_failed'); + return $this->error('module.install_failed', 400, null, [ + 'error' => $installFailureReason ?? __('modules.errors.unknown_error'), + ]); } } catch (ValidationException $e) { // Service에서 이미 번역된 메시지를 errors에 포함하므로 @@ -271,10 +274,12 @@ class ModuleController extends AdminBaseController 'pending_language_packs' => $pendingLanguagePacks, ])); } else { - return $this->error('module.activate_failed'); + return $this->error('module.activate_failed', 400, null, [ + 'error' => $result['reason'] ?? __('modules.errors.unknown_error'), + ]); } } catch (ValidationException $e) { - return $this->error('module.activate_failed', 422, $e->errors()); + return $this->error('module.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('module.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -320,10 +325,12 @@ class ModuleController extends AdminBaseController return $this->success('module.deactivate_success', $result); } else { - return $this->error('module.deactivate_failed'); + return $this->error('module.deactivate_failed', 400, null, [ + 'error' => $result['reason'] ?? __('modules.errors.unknown_error'), + ]); } } catch (ValidationException $e) { - return $this->error('module.deactivate_failed', 422, $e->errors()); + return $this->error('module.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('module.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -383,15 +390,17 @@ class ModuleController extends AdminBaseController $moduleName = $validated['module_name']; $deleteData = $validated['delete_data'] ?? false; - $result = $this->moduleService->uninstallModule($moduleName, $deleteData); + $result = $this->moduleService->uninstallModule($moduleName, $deleteData, $uninstallFailureReason); if ($result) { return $this->success('module.uninstall_success'); } else { - return $this->error('module.uninstall_failed'); + return $this->error('module.uninstall_failed', 400, null, [ + 'error' => $uninstallFailureReason ?? __('modules.errors.unknown_error'), + ]); } } catch (ValidationException $e) { - return $this->error('module.uninstall_failed', 422, $e->errors()); + return $this->error('module.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('module.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -433,8 +442,10 @@ class ModuleController extends AdminBaseController new ModuleResource($module), 201 ); - } catch (\RuntimeException $e) { - return $this->error($e->getMessage(), 422); + } catch (ModuleOperationException $e) { + // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에 + // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지). + return $this->error($e->errorKey, 422, null, $e->params); } catch (\Exception $e) { return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]); } @@ -457,8 +468,10 @@ class ModuleController extends AdminBaseController new ModuleResource($module), 201 ); - } catch (\RuntimeException $e) { - return $this->error($e->getMessage(), 422); + } catch (ModuleOperationException $e) { + // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에 + // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지). + return $this->error($e->errorKey, 422, null, $e->params); } catch (\Exception $e) { return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]); } @@ -476,7 +489,7 @@ class ModuleController extends AdminBaseController return $this->success('modules.check_updates_success', $result); } catch (ValidationException $e) { - return $this->error('modules.check_updates_failed', 422, $e->errors()); + return $this->error('modules.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('modules.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -505,7 +518,7 @@ class ModuleController extends AdminBaseController return $this->success('modules.check_modified_layouts_success', $result); } catch (ValidationException $e) { - return $this->error('modules.check_modified_layouts_failed', 422, $e->errors()); + return $this->error('modules.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -596,10 +609,12 @@ class ModuleController extends AdminBaseController new ModuleResource($module) ); } else { - return $this->error('module.refresh_layouts_failed'); + return $this->error('module.refresh_layouts_failed', 400, null, [ + 'error' => __('modules.errors.unknown_error'), + ]); } } catch (ValidationException $e) { - return $this->error('module.refresh_layouts_failed', 422, $e->errors()); + return $this->error('module.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('module.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } diff --git a/app/Http/Controllers/Api/Admin/PluginController.php b/app/Http/Controllers/Api/Admin/PluginController.php index 0fcc883e..7f85fb81 100644 --- a/app/Http/Controllers/Api/Admin/PluginController.php +++ b/app/Http/Controllers/Api/Admin/PluginController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Admin; use App\Enums\LanguagePackScope; +use App\Exceptions\PluginOperationException; use App\Extension\Vendor\VendorMode; use App\Helpers\PermissionHelper; use App\Http\Controllers\Api\Base\AdminBaseController; @@ -188,7 +189,7 @@ class PluginController extends AdminBaseController // cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort) $this->installSelectedDependencies($validated['dependencies'] ?? []); - $pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode); + $pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode, false, $installFailureReason); if ($pluginInfo) { // cascade 2단계: 동반 번들 언어팩 best-effort 설치 @@ -199,7 +200,9 @@ class PluginController extends AdminBaseController return $this->success('plugins.install_success', $payload); } else { - return $this->error('plugins.install_failed'); + return $this->error('plugins.install_failed', 400, null, [ + 'error' => $installFailureReason ?? __('plugins.errors.unknown_error'), + ]); } } catch (ValidationException $e) { // Service에서 이미 번역된 메시지를 errors에 포함하므로 @@ -260,13 +263,16 @@ class PluginController extends AdminBaseController 'pending_language_packs' => $pendingLanguagePacks, ])); } else { - return $this->error('plugins.activate_failed'); + return $this->error('plugins.activate_failed', 400, null, [ + 'error' => $result['reason'] ?? __('plugins.errors.unknown_error'), + ]); } } catch (ValidationException $e) { return $this->error( 'plugins.activate_validation_failed', 422, - $e->errors() + $e->errors(), + ['error' => $e->getMessage()] ); } catch (\Exception $e) { return $this->error( @@ -317,13 +323,16 @@ class PluginController extends AdminBaseController return $this->success('plugins.deactivate_success', $result); } else { - return $this->error('plugins.deactivate_failed'); + return $this->error('plugins.deactivate_failed', 400, null, [ + 'error' => $result['reason'] ?? __('plugins.errors.unknown_error'), + ]); } } catch (ValidationException $e) { return $this->error( 'plugins.deactivate_validation_failed', 422, - $e->errors() + $e->errors(), + ['error' => $e->getMessage()] ); } catch (\Exception $e) { return $this->error( @@ -388,18 +397,21 @@ class PluginController extends AdminBaseController $pluginName = $validated['plugin_name']; $deleteData = $validated['delete_data'] ?? false; - $result = $this->pluginService->uninstallPlugin($pluginName, $deleteData); + $result = $this->pluginService->uninstallPlugin($pluginName, $deleteData, $uninstallFailureReason); if ($result) { return $this->success('plugins.uninstall_success'); } else { - return $this->error('plugins.uninstall_failed'); + return $this->error('plugins.uninstall_failed', 400, null, [ + 'error' => $uninstallFailureReason ?? __('plugins.errors.unknown_error'), + ]); } } catch (ValidationException $e) { return $this->error( 'plugins.uninstall_validation_failed', 422, - $e->errors() + $e->errors(), + ['error' => $e->getMessage()] ); } catch (\Exception $e) { return $this->error( @@ -444,8 +456,10 @@ class PluginController extends AdminBaseController new PluginResource($plugin), 201 ); - } catch (\RuntimeException $e) { - return $this->error($e->getMessage(), 422); + } catch (PluginOperationException $e) { + // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에 + // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지). + return $this->error($e->errorKey, 422, null, $e->params); } catch (\Exception $e) { return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]); } @@ -468,8 +482,10 @@ class PluginController extends AdminBaseController new PluginResource($plugin), 201 ); - } catch (\RuntimeException $e) { - return $this->error($e->getMessage(), 422); + } catch (PluginOperationException $e) { + // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에 + // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지). + return $this->error($e->errorKey, 422, null, $e->params); } catch (\Exception $e) { return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]); } @@ -487,7 +503,7 @@ class PluginController extends AdminBaseController return $this->success('plugins.check_updates_success', $result); } catch (ValidationException $e) { - return $this->error('plugins.check_updates_failed', 422, $e->errors()); + return $this->error('plugins.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('plugins.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -512,7 +528,7 @@ class PluginController extends AdminBaseController return $this->success('plugins.check_modified_layouts_success', $result); } catch (ValidationException $e) { - return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors()); + return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -608,13 +624,16 @@ class PluginController extends AdminBaseController 'unchanged' => $result['unchanged'], ]); } else { - return $this->error('plugins.refresh_layouts_failed'); + return $this->error('plugins.refresh_layouts_failed', 400, null, [ + 'error' => __('plugins.errors.unknown_error'), + ]); } } catch (ValidationException $e) { return $this->error( 'plugins.refresh_layouts_validation_failed', 422, - $e->errors() + $e->errors(), + ['error' => $e->getMessage()] ); } catch (\Exception $e) { return $this->error( diff --git a/app/Http/Controllers/Api/Admin/PluginSettingsController.php b/app/Http/Controllers/Api/Admin/PluginSettingsController.php index a4b1a1e0..7387440c 100644 --- a/app/Http/Controllers/Api/Admin/PluginSettingsController.php +++ b/app/Http/Controllers/Api/Admin/PluginSettingsController.php @@ -113,10 +113,12 @@ class PluginSettingsController extends AdminBaseController // 그대로 설정 파일에 병합되는 경로로만 동작했다 (mass-assignment). $settings = $request->validated(); - $result = $this->pluginSettingsService->save($identifier, $settings); + $result = $this->pluginSettingsService->save($identifier, $settings, $failureReason); if (! $result) { - return $this->error('plugins.settings.update_failed', 500); + return $this->error('plugins.settings.update_failed', 500, null, [ + 'error' => $failureReason ?? __('plugins.errors.unknown_error'), + ]); } // 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로 diff --git a/app/Http/Controllers/Api/Admin/TemplateController.php b/app/Http/Controllers/Api/Admin/TemplateController.php index 8eeabc3a..5bbef459 100644 --- a/app/Http/Controllers/Api/Admin/TemplateController.php +++ b/app/Http/Controllers/Api/Admin/TemplateController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Admin; use App\Enums\LanguagePackScope; +use App\Exceptions\TemplateOperationException; use App\Helpers\PermissionHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks; @@ -169,7 +170,9 @@ class TemplateController extends AdminBaseController return $this->success('templates.install_success', $payload, 201); } else { - return $this->error('templates.install_failed'); + return $this->error('templates.install_failed', 400, null, [ + 'error' => __('templates.errors.unknown_error'), + ]); } } catch (ValidationException $e) { // Service에서 이미 번역된 메시지를 errors에 포함하므로 @@ -230,10 +233,12 @@ class TemplateController extends AdminBaseController 'pending_language_packs' => $pendingLanguagePacks, ])); } else { - return $this->error('templates.activate_failed'); + return $this->error('templates.activate_failed', 400, null, [ + 'error' => $result['reason'] ?? __('templates.errors.unknown_error'), + ]); } } catch (ValidationException $e) { - return $this->error('templates.activate_failed', 422, $e->errors()); + return $this->error('templates.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('templates.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -249,7 +254,7 @@ class TemplateController extends AdminBaseController { try { $templateName = $request->validated()['template_name']; - $template = $this->templateService->deactivateTemplate($templateName); + $template = $this->templateService->deactivateTemplate($templateName, $deactivateFailureReason); if ($template) { return $this->successWithResource( @@ -257,10 +262,12 @@ class TemplateController extends AdminBaseController new TemplateResource($template) ); } else { - return $this->error('templates.deactivate_failed'); + return $this->error('templates.deactivate_failed', 400, null, [ + 'error' => $deactivateFailureReason ?? __('templates.errors.unknown_error'), + ]); } } catch (ValidationException $e) { - return $this->error('templates.deactivate_failed', 422, $e->errors()); + return $this->error('templates.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('templates.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -284,10 +291,12 @@ class TemplateController extends AdminBaseController if ($result) { return $this->success('templates.uninstall_success'); } else { - return $this->error('templates.uninstall_failed'); + return $this->error('templates.uninstall_failed', 400, null, [ + 'error' => __('templates.errors.unknown_error'), + ]); } } catch (ValidationException $e) { - return $this->error('templates.uninstall_failed', 422, $e->errors()); + return $this->error('templates.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('templates.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -348,8 +357,10 @@ class TemplateController extends AdminBaseController new TemplateResource($template), 201 ); - } catch (\RuntimeException $e) { - return $this->error($e->getMessage(), 422); + } catch (TemplateOperationException $e) { + // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에 + // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지). + return $this->error($e->errorKey, 422, null, $e->params); } catch (\Exception $e) { return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]); } @@ -372,8 +383,10 @@ class TemplateController extends AdminBaseController new TemplateResource($template), 201 ); - } catch (\RuntimeException $e) { - return $this->error($e->getMessage(), 422); + } catch (TemplateOperationException $e) { + // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에 + // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지). + return $this->error($e->errorKey, 422, null, $e->params); } catch (\Exception $e) { return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]); } @@ -397,10 +410,12 @@ class TemplateController extends AdminBaseController new TemplateResource($template) ); } else { - return $this->error('templates.refresh_layouts_failed'); + return $this->error('templates.refresh_layouts_failed', 400, null, [ + 'error' => __('templates.errors.unknown_error'), + ]); } } catch (ValidationException $e) { - return $this->error('templates.refresh_layouts_failed', 422, $e->errors()); + return $this->error('templates.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -418,7 +433,7 @@ class TemplateController extends AdminBaseController return $this->success('templates.check_updates_success', $result); } catch (ValidationException $e) { - return $this->error('templates.check_updates_failed', 422, $e->errors()); + return $this->error('templates.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('templates.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } @@ -446,7 +461,7 @@ class TemplateController extends AdminBaseController return $this->success('templates.check_modified_layouts_success', $result); } catch (ValidationException $e) { - return $this->error('templates.check_modified_layouts_failed', 422, $e->errors()); + return $this->error('templates.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]); } catch (\Exception $e) { return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]); } diff --git a/app/Providers/PluginRouteServiceProvider.php b/app/Providers/PluginRouteServiceProvider.php index 0798c3a3..8737078d 100644 --- a/app/Providers/PluginRouteServiceProvider.php +++ b/app/Providers/PluginRouteServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Extension\ExtensionManager; use App\Extension\Testing\ExtensionTestAllowlist; +use App\Extension\Traits\CachesPluginStatus; use App\Support\InstallerContext; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\File; @@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Schema; class PluginRouteServiceProvider extends ServiceProvider { + use CachesPluginStatus; + /** * The path to the "home" route for your application. * @@ -33,6 +36,8 @@ class PluginRouteServiceProvider extends ServiceProvider /** * 플러그인의 라우트 파일들을 로드합니다. + * + * 활성화된 플러그인만 라우트를 등록합니다. */ protected function loadPluginRoutes(): void { @@ -65,6 +70,11 @@ class PluginRouteServiceProvider extends ServiceProvider } } + // 활성화된 플러그인 identifier 목록 가져오기. + // 같은 목록을 PluginManager·PluginServiceProvider 가 이미 캐시(TTL 기본 하루)해 두므로 + // 여기서 다시 조회하지 않고 그 캐시를 공유한다. 상태 변경 시 무효화도 같이 따라온다. + $activePluginIdentifiers = self::getActivePluginIdentifiers(); + $plugins = File::directories($pluginsPath); $allowlistActive = ExtensionTestAllowlist::isActive(); @@ -77,6 +87,13 @@ class PluginRouteServiceProvider extends ServiceProvider continue; } + // 활성화된 플러그인만 라우트 로드 (모듈과 동일 기준). + // 이 게이트가 없으면 비활성 플러그인의 API 가 계속 호출 가능해, 화면·메뉴만 + // 사라지고 기능은 살아 있는 상태가 된다. + if (! in_array($pluginName, $activePluginIdentifiers)) { + continue; + } + // 플러그인 파일이 존재하는지 확인 if (! File::exists($pluginFile)) { continue; diff --git a/app/Services/LanguagePackService.php b/app/Services/LanguagePackService.php index 75e453ba..36c613f4 100644 --- a/app/Services/LanguagePackService.php +++ b/app/Services/LanguagePackService.php @@ -763,7 +763,12 @@ class LanguagePackService $response = Http::timeout(120)->get($url); if (! $response->successful()) { - throw new LanguagePackOperationException('language_packs.errors.download_failed', ['url' => $url]); + // 응답 상태를 사유로 싣는다 — 비우면 치환 자리가 남아 관리자 화면에 + // 리터럴 ':error' 가 그대로 노출된다. + throw new LanguagePackOperationException('language_packs.errors.download_failed', [ + 'url' => $url, + 'error' => 'HTTP '.$response->status(), + ]); } File::put($zipPath, $response->body()); diff --git a/app/Services/ModuleService.php b/app/Services/ModuleService.php index 569ec88d..28ad15d1 100644 --- a/app/Services/ModuleService.php +++ b/app/Services/ModuleService.php @@ -242,6 +242,7 @@ class ModuleService * @param string $moduleName 설치할 모듈명 * @param VendorMode $vendorMode Vendor 설치 모드 * @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부 + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return array|null 설치된 모듈 정보 또는 null * * @throws ValidationException 모듈 설치 실패 시 @@ -250,12 +251,15 @@ class ModuleService string $moduleName, VendorMode $vendorMode = VendorMode::Auto, bool $force = false, + ?string &$failureReason = null, ): ?array { + $failureReason = null; + HookManager::doAction('core.modules.before_install', $moduleName); try { $this->moduleManager->loadModules(); - $result = $this->moduleManager->installModule($moduleName, null, $vendorMode, $force); + $result = $this->moduleManager->installModule($moduleName, null, $vendorMode, $force, $failureReason); if ($result) { // 설치 후 모듈 정보 반환 @@ -307,7 +311,9 @@ class ModuleService ]; } - return ['success' => false]; + // 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의 + // 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다. + return $result; } catch (\Exception $e) { throw ValidationException::withMessages([ 'module_name' => [__('modules.activation_failed', ['error' => $e->getMessage()])], @@ -359,12 +365,15 @@ class ModuleService * * @param string $moduleName 제거할 모듈명 * @param bool $deleteData 모듈 데이터(테이블) 삭제 여부 + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return bool 제거 성공 여부 * * @throws ValidationException 모듈 제거 실패 시 */ - public function uninstallModule(string $moduleName, bool $deleteData = false): bool + public function uninstallModule(string $moduleName, bool $deleteData = false, ?string &$failureReason = null): bool { + $failureReason = null; + HookManager::doAction('core.modules.before_uninstall', $moduleName, $deleteData); try { @@ -372,7 +381,7 @@ class ModuleService $this->moduleManager->loadModules(); $moduleInfo = $this->moduleManager->getModuleInfo($moduleName); - $result = $this->moduleManager->uninstallModule($moduleName, $deleteData); + $result = $this->moduleManager->uninstallModule($moduleName, $deleteData, null, $failureReason); if ($result) { $module = $this->moduleRepository->findByName($moduleName); @@ -867,10 +876,14 @@ class ModuleService private function executeModuleInstall(string $identifier): array { $this->moduleManager->loadModules(); - $result = $this->moduleManager->installModule($identifier); + $result = $this->moduleManager->installModule($identifier, null, VendorMode::Auto, false, $failureReason); if (! $result) { - throw new ModuleOperationException('modules.errors.install_failed'); + // 사유를 실어 올리지 않으면 'modules.errors.install_failed' 의 치환 자리가 + // 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다. + throw new ModuleOperationException('modules.errors.install_failed', [ + 'error' => $failureReason ?? __('modules.errors.unknown_error'), + ]); } return $this->moduleManager->getModuleInfo($identifier); diff --git a/app/Services/PluginService.php b/app/Services/PluginService.php index 2139aa24..2af946db 100644 --- a/app/Services/PluginService.php +++ b/app/Services/PluginService.php @@ -115,6 +115,7 @@ class PluginService * @param string $pluginName 플러그인 식별자 * @param VendorMode $vendorMode vendor 설치 모드 (Auto/Composer/Bundled) * @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부 + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return array|null 설치된 플러그인 정보 또는 설치 실패 시 null * * @throws ValidationException 플러그인 설치 실패 시 @@ -123,12 +124,15 @@ class PluginService string $pluginName, VendorMode $vendorMode = VendorMode::Auto, bool $force = false, + ?string &$failureReason = null, ): ?array { + $failureReason = null; + HookManager::doAction('core.plugins.before_install', $pluginName); try { $this->pluginManager->loadPlugins(); - $result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force); + $result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force, $failureReason); if ($result) { // 설치 후 플러그인 정보 반환 @@ -182,7 +186,9 @@ class PluginService ]; } - return ['success' => false]; + // 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의 + // 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다. + return $result; } catch (\Exception $e) { throw ValidationException::withMessages([ 'plugin_name' => [__('plugins.activation_failed', ['error' => $e->getMessage()])], @@ -236,18 +242,21 @@ class PluginService * * @param string $pluginName 플러그인 식별자 * @param bool $deleteData 플러그인이 생성한 DB 데이터/스토리지 디렉토리까지 삭제 여부 + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return bool 제거 성공 여부 * * @throws ValidationException 제거 실패 시 */ - public function uninstallPlugin(string $pluginName, bool $deleteData = false): bool + public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?string &$failureReason = null): bool { + $failureReason = null; + HookManager::doAction('core.plugins.before_uninstall', $pluginName, $deleteData); try { $this->pluginManager->loadPlugins(); - $result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData); + $result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData, null, $failureReason); HookManager::doAction('core.plugins.after_uninstall', $pluginName, $deleteData, $result); @@ -983,10 +992,14 @@ class PluginService private function executePluginInstall(string $identifier): array { $this->pluginManager->loadPlugins(); - $result = $this->pluginManager->installPlugin($identifier); + $result = $this->pluginManager->installPlugin($identifier, null, VendorMode::Auto, false, $failureReason); if (! $result) { - throw new PluginOperationException('plugins.errors.install_failed'); + // 사유를 실어 올리지 않으면 'plugins.errors.install_failed' 의 치환 자리가 + // 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다. + throw new PluginOperationException('plugins.errors.install_failed', [ + 'error' => $failureReason ?? __('plugins.errors.unknown_error'), + ]); } return $this->pluginManager->getPluginInfo($identifier); diff --git a/app/Services/PluginSettingsService.php b/app/Services/PluginSettingsService.php index 3043333c..436dae55 100644 --- a/app/Services/PluginSettingsService.php +++ b/app/Services/PluginSettingsService.php @@ -151,10 +151,13 @@ class PluginSettingsService * * @param string $identifier 플러그인 식별자 * @param array $settings 저장할 설정 (검증 통과분) + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return bool 저장 성공 여부 */ - public function save(string $identifier, array $settings): bool + public function save(string $identifier, array $settings, ?string &$failureReason = null): bool { + $failureReason = null; + // Before 훅 HookManager::doAction('core.plugin_settings.before_save', $identifier, $settings); @@ -164,6 +167,8 @@ class PluginSettingsService // 플러그인 인스턴스 확인 $pluginInstance = $this->pluginManager->getPlugin($identifier); if (! $pluginInstance) { + $failureReason = __('plugins.errors.not_found', ['plugin' => $identifier]); + return false; } @@ -187,6 +192,11 @@ class PluginSettingsService // 파일에 저장 $result = $this->saveSettingsToFile($identifier, $mergedSettings); + if (! $result && $failureReason === null) { + // 파일 쓰기 실패 — 스토리지 드라이버가 사유를 돌려주지 않으므로 일반 문구로 대체한다. + $failureReason = __('plugins.errors.unknown_error'); + } + // 캐시 초기화 if ($result) { unset($this->settingsCache[$identifier]); diff --git a/app/Services/TemplateService.php b/app/Services/TemplateService.php index 658c913d..37cc899f 100644 --- a/app/Services/TemplateService.php +++ b/app/Services/TemplateService.php @@ -7,6 +7,7 @@ use App\Contracts\Extension\PluginManagerInterface; use App\Contracts\Extension\TemplateManagerInterface; use App\Contracts\Repositories\LayoutVersionRepositoryInterface; use App\Contracts\Repositories\TemplateRepositoryInterface; +use App\Enums\DeactivationReason; use App\Enums\ExtensionStatus; use App\Exceptions\TemplateNotFoundException; use App\Exceptions\TemplateOperationException; @@ -476,12 +477,15 @@ class TemplateService * 템플릿을 비활성화합니다. * * @param int|string $idOrIdentifier 템플릿 ID 또는 식별자 + * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null) * @return array|null 비활성화된 템플릿 정보 또는 null * * @throws ValidationException 비활성화 실패 시 */ - public function deactivateTemplate(int|string $idOrIdentifier): ?array + public function deactivateTemplate(int|string $idOrIdentifier, ?string &$failureReason = null): ?array { + $failureReason = null; + // ID 또는 identifier로 템플릿 조회 $template = is_int($idOrIdentifier) ? $this->templateRepository->findById($idOrIdentifier) @@ -496,7 +500,14 @@ class TemplateService HookManager::doAction('core.templates.before_deactivate', $template->identifier); try { - $result = $this->templateManager->deactivateTemplate($template->identifier); + // 위치 인자로 넘긴다 — 이 의존성은 인터페이스 타입이고 테스트가 그 인터페이스를 + // mock 하므로, 이름 붙인 인자는 mock 의 __call 에 닿아 "Unknown named parameter" 가 된다. + $result = $this->templateManager->deactivateTemplate( + $template->identifier, + DeactivationReason::Manual->value, + null, + $failureReason + ); if ($result) { // 템플릿 매니저에서 업데이트된 정보 조회 @@ -2021,7 +2032,11 @@ class TemplateService $result = $this->templateManager->installTemplate($identifier); if (! $result) { - throw new TemplateOperationException('templates.errors.install_failed'); + // installTemplate 은 사유 out 파라미터를 갖지 않으므로 일반 문구로 채운다. + // 비워 두면 치환 자리가 남아 관리자 화면에 리터럴 ':error' 가 노출된다. + throw new TemplateOperationException('templates.errors.install_failed', [ + 'error' => __('templates.errors.unknown_error'), + ]); } return $this->templateManager->getTemplateInfo($identifier); diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 75eaf33f..72652dfb 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -120,6 +120,10 @@ Authorization: Bearer {YOUR_TOKEN} | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`errors` 에 필드별 메시지) | | 428 | Precondition Required | 본인인증(IDV)이 선행되어야 하는 경우 | +확장(모듈·플러그인)이 제공하는 엔드포인트(`/api/modules/{id}/…`, `/api/plugins/{id}/…`)는 그 확장이 +**활성 상태일 때만** 존재합니다. 비활성화·제거된 확장의 엔드포인트는 404 를 반환하며, 이는 권한 +문제가 아니라 라우트가 등록되지 않은 상태입니다. 확장을 업데이트하는 동안에도 잠시 같은 상태가 됩니다. + 428 응답은 `error_code: "identity_verification_required"` 와 함께 `verification` 객체를 반환합니다. 클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다. diff --git a/docs/backend/api/language-packs.md b/docs/backend/api/language-packs.md index abc9a9f3..5c1375b0 100644 --- a/docs/backend/api/language-packs.md +++ b/docs/backend/api/language-packs.md @@ -371,7 +371,7 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 (`업데이트 확인에 실패했습니다: :error` — `language_packs.check_updates_failed`) | +| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 (`업데이트 확인에 실패했습니다.` — `language_packs.check_updates_failed`) | @@ -633,7 +633,7 @@ HTTP/1.1 201 } ``` -> manifest 검증 실패 시 422 (`language-pack.json 검증에 실패했습니다.`), 그 외 설치 실패 시 500 (`언어팩 설치에 실패했습니다: :error`) 으로 응답합니다. +> manifest 검증 실패 시 422 (`language-pack.json 검증에 실패했습니다.`), 그 외 설치 실패 시 500 (`언어팩 설치에 실패했습니다.`) 으로 응답합니다. **에러 응답** @@ -1664,7 +1664,7 @@ HTTP/1.1 200 } ``` -> 업데이트 소스 정보가 없거나(`업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).`) 이미 최신 버전이면(`이미 최신 버전입니다.`) 500 (`언어팩 업데이트에 실패했습니다: :error`) 으로 응답합니다. +> 업데이트 소스 정보가 없거나(`업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).`) 이미 최신 버전이면(`이미 최신 버전입니다.`) 500 (`언어팩 업데이트에 실패했습니다.`) 으로 응답합니다. **에러 응답** diff --git a/docs/backend/exceptions.md b/docs/backend/exceptions.md index b0cf9f89..20aa1314 100644 --- a/docs/backend/exceptions.md +++ b/docs/backend/exceptions.md @@ -164,6 +164,27 @@ class MaxDepthExceededException extends Exception } ``` +### 치환 자리는 비워 둘 수 없다 + +`:error` 같은 치환 자리를 가진 키를 파라미터 없이 부르면, 번역기는 그 자리를 **그대로 둔 문장**을 +돌려준다. 그래서 운영자 화면에 `모듈 활성화에 실패했습니다: :error` 처럼 내부 자리표시자가 노출된다. +예외도 로그도 남지 않고 실패했을 때만 드러나므로, 정상 흐름만 보는 테스트로는 잡히지 않는다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| `error('x.activate_failed')` — 치환 자리를 가진 키를 파라미터 없이 | `error('x.activate_failed', 400, null, ['error' => $reason])` | +| 사유를 모른다고 자리를 비워 두기 | 사유를 알 수 없으면 일반 문구로 채운다 (`errors.unknown_error`) | +| 원인을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 치환 자리 자체를 없앤다 | +| 하위 계층이 `false` 만 돌려주고 사유를 버리기 | 사유를 반환 경로에 실어 올린다 (배열 키 또는 선택적 out 파라미터) | + +셋째 인자 `errors` 페이로드와 넷째 인자 `messageParams` 는 **다른 통로**다. `errors` 에 예외를 넘기는 +것은 진단 정보이고(노출 폭은 `ResponseHelper` 가 `app.debug` 로 정한다), 문구의 치환 자리를 채우는 +것은 `messageParams` 뿐이다. 한쪽만 채우면 자리표시자는 그대로 남는다. + +응답 문구에 예외 원문을 싣지 않기로 정한 화면(언어팩 관리 등)은 **파라미터를 채우는 대신 키에서 +치환 자리를 없앤다.** 자리를 남긴 채 일반 문구로 채우면 "…실패했습니다: 알 수 없는 오류" 처럼 +의미 없는 꼬리가 붙고, 나중에 누군가 그 자리를 예외 원문으로 채우는 회귀를 부른다. + --- ## 예외 → 응답 매핑 diff --git a/docs/backend/routing.md b/docs/backend/routing.md index e65153b5..669aee69 100644 --- a/docs/backend/routing.md +++ b/docs/backend/routing.md @@ -343,8 +343,9 @@ Route::prefix('products')->group(function () { | `RouteCacheHelper::rebuild()` | 비운 뒤 즉시 재생성. 테스트 환경·설치 미완료는 비우기까지만 | | `RouteCacheHelper::clear()` | 재생성 없이 비우기만 — 재생성이 부적절한 흐름 중간용 | -`rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 새 애플리케이션을 부팅해 라우트를 -수집하므로 방금 설치·활성화한 확장의 라우트도 함께 잡힌다. 재생성이 실패하면(직렬화 +`rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 **새 애플리케이션을 부팅해** 라우트를 +수집한다. 방금 설치·활성화한 확장의 라우트가 함께 잡히려면 그 새 부팅이 바뀐 상태를 읽어야 +하므로, 굽기 전에 상태 캐시를 비워야 한다(아래 절). 재생성이 실패하면(직렬화 불가한 클로저 라우트 등) 비운 상태로 둔다 — 비어 있으면 느릴 뿐 정확하지만, 낡은 캐시는 방금 설치한 확장을 통째로 없는 것으로 만든다. @@ -362,6 +363,53 @@ Route::prefix('products')->group(function () { 서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는 `api/modules/{identifier}` 로 식별자에 고정). +### 확장 라우트는 활성 상태로 게이트된다 + +모듈·플러그인 라우트는 **활성 상태인 확장의 것만** 등록한다. 비활성화하면 화면·메뉴·프론트엔드 +에셋은 사라지지만, 라우트 등록을 게이트하지 않으면 그 확장의 API 는 계속 호출 가능한 상태로 남는다. +컨트롤러 파일이 그대로 있으므로 요청은 정상 처리되고 오류도 로그도 남지 않아, 화면만 보고는 +꺼진 기능이 여전히 동작한다는 사실을 알 수 없다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| 라우트 프로바이더가 디렉토리에 있는 확장 전부를 등록 | 활성 식별자 목록으로 걸러 등록 | +| 한쪽(모듈)만 게이트하고 다른 쪽(플러그인)은 무게이트 | 두 경로가 같은 기준을 공유 | +| 게이트를 개별 컨트롤러·미들웨어에 흩어 놓기 | 라우트 등록 지점 한 곳에서 판정 | + +활성 목록은 상태 캐시를 공유하므로, 상태를 바꾼 쪽이 그 캐시를 비워야 다음 부팅이 새 상태를 +읽는다(아래 절). + +### 굽기는 상태 캐시 무효화 뒤에 온다 + +`route:cache` 가 부팅하는 새 애플리케이션에서 확장 라우트 프로바이더는 DB 가 아니라 +**캐시된 활성 확장 목록**을 읽는다(TTL 기본 1일). 그래서 확장의 상태를 DB 에 쓴 뒤 +그 상태 캐시를 비우기 **전에** 구우면, 새 부팅이 낡은 목록을 읽어 방금 바뀐 상태가 +반영되지 않은 라우트가 박제된다. + +```text +DB 상태 쓰기 → 상태 캐시 무효화 → 굽기(rebuild / 훅 캐시 재생성) +``` + +순서가 뒤집혔을 때의 결과는 방향마다 다르고, 어느 쪽도 스스로 회복되지 않는다. + +| 수명주기 | 낡은 목록의 내용 | 결과 | +|---------|----------------|------| +| 활성화 | 그 확장이 없음 | 방금 켠 확장의 API 전량 404 | +| 비활성화 | 그 확장이 남아 있음 | 끈 확장의 API 가 계속 호출 가능 | +| 업데이트 | `Updating`(=비활성)으로 판정 | 업데이트 후 API 404 + 훅 리스너 누락 | + +상태 캐시 무효화는 **DB 상태 쓰기 직후**에 둔다. 굽기 직전으로 옮기는 것으로는 부족하다 — +같은 목록을 읽는 굽기가 라우트 캐시 말고도 있기 때문이다(훅 매핑 캐시가 오토로드 갱신 +안에서 구워진다). + +업데이트 경로만 예외가 하나 있다. `Updating` 전이 직후에는 비우지 않는다 — 비우면 그 창 +안에서 도는 오토로드 갱신이 DB 를 재조회해 그 확장을 비활성으로 판정하고 훅 캐시에서 +리스너를 떨군다. 대신 **상태 복원 직후**에 비우고, 그 뒤 훅 캐시를 다시 굽는다. 훅 캐시 +폴백은 파일 부재·손상에만 작동하므로 내용이 낡은 경우는 조용히 통과하기 때문이다. + +이 순서는 정적 검사로 강제된다 — `rebuild()` 를 호출하는 수명주기 메서드를 리플렉션으로 +도출해(개별 열거 금지) 각각에서 무효화가 앞서는지 확인한다. + ### 캐시 안전한 라우트 작성 캐시가 걸리면 `RouteServiceProvider::boot()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가 diff --git a/docs/extension/module-basics.md b/docs/extension/module-basics.md index d9429367..428c9888 100644 --- a/docs/extension/module-basics.md +++ b/docs/extension/module-basics.md @@ -143,6 +143,28 @@ | `getMetadata()` | `[]` | 메타데이터 | | `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | | `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) | + +#### 수명주기 훅이 실패를 알리는 방법 + +`install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로, 그냥 `false` 를 +반환하면 **왜 거부했는지가 코어에 전달되지 않는다.** 그 결과 운영자는 원인이 빠진 실패 문구만 본다. + +사유를 남기려면 `failWith()` 로 반환한다. 코어가 그 사유를 응답 문구의 원인 자리에 싣는다. + +```php +public function activate(): bool +{ + if (! extension_loaded('gd')) { + return $this->failWith(__('my-module::messages.gd_required')); + } + + return true; +} +``` + +- 사유는 **이미 번역된 문장**이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다. +- 사유를 남기지 않고 `false` 만 돌려주면 코어가 일반 문구로 대체한다(동작은 그대로). +- 같은 규칙이 플러그인(`AbstractPlugin`)에도 동일하게 적용된다. | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw | #### 동적 권한/역할/메뉴 보존 규칙 diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 92e813c7..ca13d9d8 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -11,6 +11,10 @@ - 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`). - 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`). +### Changed + +- 언어팩 관리(조회·설치·활성화·비활성화·제거·업데이트 확인·업데이트·캐시 갱신·manifest 미리보기) 실패 안내에서 오류 원문 노출(`:error`)이 제거된 것에 맞춰, 해당 실패 문구의 일본어 번역을 갱신했습니다 (`language_packs.*_failed`). + ## [1.0.6] - 2026-08-19 ### Changed diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php b/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php index cfa7e138..ec9c8cdb 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php @@ -2,25 +2,25 @@ return [ 'fetch_success' => '言語パックリストを取得しました。', - 'fetch_failed' => '言語パックリストを読み込めませんでした: :error', + 'fetch_failed' => '言語パックリストを読み込めませんでした。', 'not_found' => '言語パックが見つかりません。', 'install_success' => '言語パックをインストールしました。', - 'install_failed' => '言語パックのインストールに失敗しました: :error', + 'install_failed' => '言語パックのインストールに失敗しました。', 'activate_success' => '言語パックを有効化しました。', - 'activate_failed' => '言語パックの有効化に失敗しました: :error', + 'activate_failed' => '言語パックの有効化に失敗しました。', 'deactivate_success' => '言語パックを無効化しました。', - 'deactivate_failed' => '言語パックの無効化に失敗しました: :error', + 'deactivate_failed' => '言語パックの無効化に失敗しました。', 'uninstall_success' => '言語パックを削除しました。', - 'uninstall_failed' => '言語パックの削除に失敗しました: :error', + 'uninstall_failed' => '言語パックの削除に失敗しました。', 'manifest_invalid' => 'language-pack.json の検証に失敗しました。', 'check_updates_success' => 'アップデート確認が完了しました。', - 'check_updates_failed' => 'アップデート確認に失敗しました: :error', + 'check_updates_failed' => 'アップデート確認に失敗しました。', 'update_success' => '言語パックをアップデートしました。', - 'update_failed' => '言語パックのアップデートに失敗しました: :error', + 'update_failed' => '言語パックのアップデートに失敗しました。', 'refresh_cache_success' => '言語パックキャッシュを更新しました。', - 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました: :error', + 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました。', 'preview_success' => 'manifest プレビューが完了しました。', - 'preview_failed' => 'manifest プレビューに失敗しました: :error', + 'preview_failed' => 'manifest プレビューに失敗しました。', 'errors' => [ 'manifest_not_found' => 'ZIP内に language-pack.json ファイルが見つかりません。', 'manifest_invalid_json' => 'language-pack.json の JSON 形式が正しくありません。', diff --git a/lang/en/language_packs.php b/lang/en/language_packs.php index 674f1dff..c7ae49e7 100644 --- a/lang/en/language_packs.php +++ b/lang/en/language_packs.php @@ -2,25 +2,25 @@ return [ 'fetch_success' => 'Language pack list retrieved.', - 'fetch_failed' => 'Failed to load language pack list: :error', + 'fetch_failed' => 'Failed to load language pack list.', 'not_found' => 'Language pack not found.', 'install_success' => 'Language pack installed.', - 'install_failed' => 'Failed to install language pack: :error', + 'install_failed' => 'Failed to install language pack.', 'activate_success' => 'Language pack activated.', - 'activate_failed' => 'Failed to activate language pack: :error', + 'activate_failed' => 'Failed to activate language pack.', 'deactivate_success' => 'Language pack deactivated.', - 'deactivate_failed' => 'Failed to deactivate language pack: :error', + 'deactivate_failed' => 'Failed to deactivate language pack.', 'uninstall_success' => 'Language pack removed.', - 'uninstall_failed' => 'Failed to remove language pack: :error', + 'uninstall_failed' => 'Failed to remove language pack.', 'manifest_invalid' => 'language-pack.json validation failed.', 'check_updates_success' => 'Update check completed.', - 'check_updates_failed' => 'Failed to check updates: :error', + 'check_updates_failed' => 'Failed to check updates.', 'update_success' => 'Language pack updated.', - 'update_failed' => 'Failed to update language pack: :error', + 'update_failed' => 'Failed to update language pack.', 'refresh_cache_success' => 'Language pack cache refreshed.', - 'refresh_cache_failed' => 'Failed to refresh language pack cache: :error', + 'refresh_cache_failed' => 'Failed to refresh language pack cache.', 'preview_success' => 'Manifest preview completed.', - 'preview_failed' => 'Failed to preview manifest: :error', + 'preview_failed' => 'Failed to preview manifest.', 'errors' => [ 'manifest_not_found' => 'language-pack.json file not found in archive.', @@ -36,7 +36,7 @@ return [ 'target_version_too_old' => 'Target :scope (":target") version does not satisfy the required constraint (:constraint).', 'downgrade_blocked' => 'Downgrade blocked (:from → :to).', 'protected_pack' => 'Protected language packs cannot be deactivated or removed.', - 'download_failed' => 'Failed to download from URL: :url', + 'download_failed' => 'Failed to download the language pack from URL (:url): :error', 'download_url_not_public' => 'Language packs cannot be downloaded from internal network addresses (private IPs, localhost, etc.). Use a publicly reachable https address.', 'checksum_mismatch' => 'Checksum mismatch.', 'update_no_source' => 'No update source available (only GitHub-sourced packs can be updated).', diff --git a/lang/ko/language_packs.php b/lang/ko/language_packs.php index d5654726..2f815335 100644 --- a/lang/ko/language_packs.php +++ b/lang/ko/language_packs.php @@ -2,25 +2,25 @@ return [ 'fetch_success' => '언어팩 목록을 조회했습니다.', - 'fetch_failed' => '언어팩 목록을 불러오지 못했습니다: :error', + 'fetch_failed' => '언어팩 목록을 불러오지 못했습니다.', 'not_found' => '언어팩을 찾을 수 없습니다.', 'install_success' => '언어팩을 설치했습니다.', - 'install_failed' => '언어팩 설치에 실패했습니다: :error', + 'install_failed' => '언어팩 설치에 실패했습니다.', 'activate_success' => '언어팩을 활성화했습니다.', - 'activate_failed' => '언어팩 활성화에 실패했습니다: :error', + 'activate_failed' => '언어팩 활성화에 실패했습니다.', 'deactivate_success' => '언어팩을 비활성화했습니다.', - 'deactivate_failed' => '언어팩 비활성화에 실패했습니다: :error', + 'deactivate_failed' => '언어팩 비활성화에 실패했습니다.', 'uninstall_success' => '언어팩을 제거했습니다.', - 'uninstall_failed' => '언어팩 제거에 실패했습니다: :error', + 'uninstall_failed' => '언어팩 제거에 실패했습니다.', 'manifest_invalid' => 'language-pack.json 검증에 실패했습니다.', 'check_updates_success' => '업데이트 확인을 완료했습니다.', - 'check_updates_failed' => '업데이트 확인에 실패했습니다: :error', + 'check_updates_failed' => '업데이트 확인에 실패했습니다.', 'update_success' => '언어팩을 업데이트했습니다.', - 'update_failed' => '언어팩 업데이트에 실패했습니다: :error', + 'update_failed' => '언어팩 업데이트에 실패했습니다.', 'refresh_cache_success' => '언어팩 캐시를 갱신했습니다.', - 'refresh_cache_failed' => '언어팩 캐시 갱신에 실패했습니다: :error', + 'refresh_cache_failed' => '언어팩 캐시 갱신에 실패했습니다.', 'preview_success' => 'manifest 미리보기를 완료했습니다.', - 'preview_failed' => 'manifest 미리보기에 실패했습니다: :error', + 'preview_failed' => 'manifest 미리보기에 실패했습니다.', 'errors' => [ 'manifest_not_found' => 'ZIP 안에서 language-pack.json 파일을 찾을 수 없습니다.', diff --git a/tests/Feature/Api/Admin/ModuleControllerTest.php b/tests/Feature/Api/Admin/ModuleControllerTest.php index c4179478..114c214c 100644 --- a/tests/Feature/Api/Admin/ModuleControllerTest.php +++ b/tests/Feature/Api/Admin/ModuleControllerTest.php @@ -3,11 +3,15 @@ namespace Tests\Feature\Api\Admin; use App\Enums\ExtensionOwnerType; +use App\Enums\PermissionType; +use App\Exceptions\ModuleOperationException; use App\Models\Permission; use App\Models\Role; use App\Models\User; +use App\Services\ModuleService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; use Tests\TestCase; @@ -54,7 +58,7 @@ class ModuleControllerTest extends TestCase 'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', - 'type' => \App\Enums\PermissionType::Admin, + 'type' => PermissionType::Admin, ] ); $permissionIds[] = $permission->id; @@ -498,7 +502,7 @@ class ModuleControllerTest extends TestCase */ public function test_install_from_file_succeeds_with_valid_zip(): void { - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('installFromZipFile') ->once() ->andReturn([ @@ -506,7 +510,7 @@ class ModuleControllerTest extends TestCase 'name' => 'Test Module', 'version' => '1.0.0', ]); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); $file = UploadedFile::fake()->create('module.zip', 100, 'application/zip'); @@ -520,15 +524,20 @@ class ModuleControllerTest extends TestCase } /** - * ModuleService에서 RuntimeException 발생 시 422 반환 + * ModuleService에서 도메인 예외 발생 시 422 반환 + 키가 해석된 문구 + * + * 종전에는 raw `\RuntimeException` 이 이미 번역된 문장을 들고 오는 것을 컨트롤러가 + * 메시지 **키** 자리로 넘겨 그대로 내보냈다. 키 해석에 실패한 원문이 나가는 형태라 + * 계약 테스트가 이 지점을 위반으로 등재해 두고 있었다. 이제 도메인 실패는 키와 + * 파라미터를 들고 다니는 예외로 오고, 컨트롤러가 그 키를 해석해 내보낸다. */ - public function test_install_from_file_returns_422_on_runtime_exception(): void + public function test_install_from_file_returns_422_on_domain_exception(): void { - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('installFromZipFile') ->once() - ->andThrow(new \RuntimeException('module.json을 찾을 수 없습니다.')); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + ->andThrow(new ModuleOperationException('modules.errors.module_json_not_found')); + $this->app->instance(ModuleService::class, $moduleServiceMock); $file = UploadedFile::fake()->create('module.zip', 100, 'application/zip'); @@ -537,7 +546,27 @@ class ModuleControllerTest extends TestCase ]); $response->assertStatus(422); - $response->assertJsonPath('message', 'module.json을 찾을 수 없습니다.'); + $response->assertJsonPath('message', __('modules.errors.module_json_not_found')); + } + + /** + * 도메인 예외가 아닌 raw RuntimeException 은 500 (인프라 장애를 입력 오류로 위장하지 않는다) + */ + public function test_install_from_file_returns_500_on_raw_runtime_exception(): void + { + $moduleServiceMock = \Mockery::mock(ModuleService::class); + $moduleServiceMock->shouldReceive('installFromZipFile') + ->once() + ->andThrow(new \RuntimeException('disk full')); + $this->app->instance(ModuleService::class, $moduleServiceMock); + + $file = UploadedFile::fake()->create('module.zip', 100, 'application/zip'); + + $response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [ + 'file' => $file, + ]); + + $response->assertStatus(500); } /** @@ -545,11 +574,11 @@ class ModuleControllerTest extends TestCase */ public function test_install_from_file_returns_500_on_general_exception(): void { - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('installFromZipFile') ->once() ->andThrow(new \Exception('예상치 못한 오류')); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); $file = UploadedFile::fake()->create('module.zip', 100, 'application/zip'); @@ -569,7 +598,7 @@ class ModuleControllerTest extends TestCase */ public function test_install_from_github_calls_service_with_valid_url(): void { - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('installFromGithub') ->once() ->with('https://github.com/sirsoft/sample-module') @@ -578,7 +607,7 @@ class ModuleControllerTest extends TestCase 'name' => 'Sample Module', 'version' => '1.0.0', ]); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); $response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [ 'github_url' => 'https://github.com/sirsoft/sample-module', @@ -592,20 +621,20 @@ class ModuleControllerTest extends TestCase /** * ModuleService에서 RuntimeException 발생 시 422 반환 (GitHub) */ - public function test_install_from_github_returns_422_on_runtime_exception(): void + public function test_install_from_github_returns_422_on_domain_exception(): void { - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('installFromGithub') ->once() - ->andThrow(new \RuntimeException('GitHub 저장소를 찾을 수 없습니다.')); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + ->andThrow(new ModuleOperationException('modules.errors.github_repo_not_found')); + $this->app->instance(ModuleService::class, $moduleServiceMock); $response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [ 'github_url' => 'https://github.com/sirsoft/sample-module', ]); $response->assertStatus(422); - $response->assertJsonPath('message', 'GitHub 저장소를 찾을 수 없습니다.'); + $response->assertJsonPath('message', __('modules.errors.github_repo_not_found')); } /** @@ -613,11 +642,11 @@ class ModuleControllerTest extends TestCase */ public function test_install_from_github_returns_500_on_general_exception(): void { - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('installFromGithub') ->once() ->andThrow(new \Exception('예상치 못한 오류')); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); $response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [ 'github_url' => 'https://github.com/sirsoft/sample-module', @@ -682,7 +711,7 @@ class ModuleControllerTest extends TestCase public function test_activate_returns_409_when_dependencies_not_met(): void { // Arrange: ModuleService를 Mock - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('activateModule') ->with('test-module', false) ->andReturn([ @@ -696,7 +725,7 @@ class ModuleControllerTest extends TestCase ], 'message' => '이 모듈을 활성화하려면 필요한 의존성이 충족되어야 합니다.', ]); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/modules/activate', [ @@ -731,7 +760,7 @@ class ModuleControllerTest extends TestCase public function test_activate_with_force_bypasses_dependency_check(): void { // Arrange: ModuleService를 Mock - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('activateModule') ->with('test-module', true) ->andReturn([ @@ -742,7 +771,7 @@ class ModuleControllerTest extends TestCase 'status' => 'active', ], ]); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/modules/activate', [ @@ -761,7 +790,7 @@ class ModuleControllerTest extends TestCase public function test_deactivate_returns_409_when_dependents_exist(): void { // Arrange: ModuleService를 Mock - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('deactivateModule') ->with('test-module', false) ->andReturn([ @@ -774,7 +803,7 @@ class ModuleControllerTest extends TestCase 'dependent_plugins' => [], 'message' => '이 모듈에 의존하는 활성화된 확장이 있습니다.', ]); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [ @@ -801,7 +830,7 @@ class ModuleControllerTest extends TestCase public function test_deactivate_with_force_bypasses_dependent_check(): void { // Arrange: ModuleService를 Mock - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('deactivateModule') ->with('test-module', true) ->andReturn([ @@ -812,7 +841,7 @@ class ModuleControllerTest extends TestCase 'status' => 'inactive', ], ]); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [ @@ -835,7 +864,7 @@ class ModuleControllerTest extends TestCase public function test_activate_response_includes_assets_when_module_has_assets(): void { // Arrange: ModuleService를 Mock - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('activateModule') ->with('test-module', false) ->andReturn([ @@ -851,7 +880,7 @@ class ModuleControllerTest extends TestCase ], ], ]); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/modules/activate', [ @@ -872,7 +901,7 @@ class ModuleControllerTest extends TestCase public function test_deactivate_response_includes_assets_when_module_has_assets(): void { // Arrange: ModuleService를 Mock - $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class); + $moduleServiceMock = \Mockery::mock(ModuleService::class); $moduleServiceMock->shouldReceive('deactivateModule') ->with('test-module', false) ->andReturn([ @@ -888,7 +917,7 @@ class ModuleControllerTest extends TestCase ], ], ]); - $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock); + $this->app->instance(ModuleService::class, $moduleServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [ @@ -913,8 +942,8 @@ class ModuleControllerTest extends TestCase { // Arrange: 테스트용 CHANGELOG.md 생성 $modulePath = base_path('modules/test-changelog-mod'); - \Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath); - \Illuminate\Support\Facades\File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 새 기능\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n"); + File::ensureDirectoryExists($modulePath); + File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 새 기능\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n"); try { $response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-mod/changelog'); @@ -924,7 +953,7 @@ class ModuleControllerTest extends TestCase ->assertJsonPath('data.changelog.0.categories.0.name', 'Added') ->assertJsonPath('data.changelog.1.version', '0.1.0'); } finally { - \Illuminate\Support\Facades\File::deleteDirectory($modulePath); + File::deleteDirectory($modulePath); } } @@ -945,8 +974,8 @@ class ModuleControllerTest extends TestCase public function test_changelog_with_version_range(): void { $modulePath = base_path('modules/test-changelog-range'); - \Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath); - \Illuminate\Support\Facades\File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.2] - 2026-02-28\n\n### Added\n- 기능 C\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 기능 B\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n"); + File::ensureDirectoryExists($modulePath); + File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.2] - 2026-02-28\n\n### Added\n- 기능 C\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 기능 B\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n"); try { $response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-range/changelog?from_version=0.1.0&to_version=0.1.2'); @@ -957,7 +986,7 @@ class ModuleControllerTest extends TestCase $this->assertSame('0.1.2', $changelog[0]['version']); $this->assertSame('0.1.1', $changelog[1]['version']); } finally { - \Illuminate\Support\Facades\File::deleteDirectory($modulePath); + File::deleteDirectory($modulePath); } } @@ -980,8 +1009,8 @@ class ModuleControllerTest extends TestCase public function test_license_returns_content(): void { $modulePath = base_path('modules/test-license-mod'); - \Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath); - \Illuminate\Support\Facades\File::put($modulePath.'/LICENSE', 'MIT License - Test'); + File::ensureDirectoryExists($modulePath); + File::put($modulePath.'/LICENSE', 'MIT License - Test'); try { $response = $this->authRequest()->getJson('/api/admin/modules/test-license-mod/license'); @@ -989,7 +1018,7 @@ class ModuleControllerTest extends TestCase $response->assertStatus(200) ->assertJsonPath('data.content', 'MIT License - Test'); } finally { - \Illuminate\Support\Facades\File::deleteDirectory($modulePath); + File::deleteDirectory($modulePath); } } diff --git a/tests/Feature/Api/Admin/PluginControllerTest.php b/tests/Feature/Api/Admin/PluginControllerTest.php index 4aeeece4..ed9dc95a 100644 --- a/tests/Feature/Api/Admin/PluginControllerTest.php +++ b/tests/Feature/Api/Admin/PluginControllerTest.php @@ -3,11 +3,15 @@ namespace Tests\Feature\Api\Admin; use App\Enums\ExtensionOwnerType; +use App\Enums\PermissionType; +use App\Exceptions\PluginOperationException; use App\Models\Permission; use App\Models\Role; use App\Models\User; +use App\Services\PluginService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; use Tests\TestCase; @@ -51,7 +55,7 @@ class PluginControllerTest extends TestCase 'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']), 'extension_type' => ExtensionOwnerType::Core, 'extension_identifier' => 'core', - 'type' => \App\Enums\PermissionType::Admin, + 'type' => PermissionType::Admin, ] ); $permissionIds[] = $permission->id; @@ -423,7 +427,7 @@ class PluginControllerTest extends TestCase public function test_activate_returns_409_when_dependencies_not_met(): void { // Arrange: PluginService를 Mock - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('activatePlugin') ->with('test-plugin', false) ->andReturn([ @@ -437,7 +441,7 @@ class PluginControllerTest extends TestCase ], 'message' => '플러그인 활성화를 위해 필요한 의존성이 충족되지 않았습니다.', ]); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/plugins/activate', [ @@ -472,7 +476,7 @@ class PluginControllerTest extends TestCase public function test_activate_with_force_bypasses_dependency_check(): void { // Arrange: PluginService를 Mock - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('activatePlugin') ->with('test-plugin', true) ->andReturn([ @@ -483,7 +487,7 @@ class PluginControllerTest extends TestCase 'status' => 'active', ], ]); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/plugins/activate', [ @@ -502,7 +506,7 @@ class PluginControllerTest extends TestCase public function test_deactivate_returns_409_when_dependents_exist(): void { // Arrange: PluginService를 Mock - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('deactivatePlugin') ->with('test-plugin', false) ->andReturn([ @@ -517,7 +521,7 @@ class PluginControllerTest extends TestCase ], 'message' => '이 플러그인에 의존하는 활성화된 확장이 있습니다.', ]); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [ @@ -545,7 +549,7 @@ class PluginControllerTest extends TestCase public function test_deactivate_with_force_bypasses_dependent_check(): void { // Arrange: PluginService를 Mock - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('deactivatePlugin') ->with('test-plugin', true) ->andReturn([ @@ -556,7 +560,7 @@ class PluginControllerTest extends TestCase 'status' => 'inactive', ], ]); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [ @@ -579,7 +583,7 @@ class PluginControllerTest extends TestCase public function test_activate_response_includes_assets_when_plugin_has_assets(): void { // Arrange: PluginService를 Mock - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('activatePlugin') ->with('test-plugin', false) ->andReturn([ @@ -595,7 +599,7 @@ class PluginControllerTest extends TestCase ], ], ]); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/plugins/activate', [ @@ -616,7 +620,7 @@ class PluginControllerTest extends TestCase public function test_deactivate_response_includes_assets_when_plugin_has_assets(): void { // Arrange: PluginService를 Mock - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('deactivatePlugin') ->with('test-plugin', false) ->andReturn([ @@ -632,7 +636,7 @@ class PluginControllerTest extends TestCase ], ], ]); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); // Act $response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [ @@ -656,8 +660,8 @@ class PluginControllerTest extends TestCase public function test_changelog_returns_parsed_entries(): void { $pluginPath = base_path('plugins/test-changelog-plg'); - \Illuminate\Support\Facades\File::ensureDirectoryExists($pluginPath); - \Illuminate\Support\Facades\File::put($pluginPath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Fixed\n- 버그 수정\n"); + File::ensureDirectoryExists($pluginPath); + File::put($pluginPath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Fixed\n- 버그 수정\n"); try { $response = $this->authRequest()->getJson('/api/admin/plugins/test-changelog-plg/changelog'); @@ -666,7 +670,7 @@ class PluginControllerTest extends TestCase ->assertJsonPath('data.changelog.0.version', '0.1.1') ->assertJsonPath('data.changelog.0.categories.0.name', 'Fixed'); } finally { - \Illuminate\Support\Facades\File::deleteDirectory($pluginPath); + File::deleteDirectory($pluginPath); } } @@ -817,7 +821,7 @@ class PluginControllerTest extends TestCase */ public function test_install_from_file_calls_service_with_valid_zip(): void { - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('installFromZipFile') ->once() ->andReturn([ @@ -825,7 +829,7 @@ class PluginControllerTest extends TestCase 'name' => 'Test Plugin', 'version' => '1.0.0', ]); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); $file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip'); @@ -841,13 +845,13 @@ class PluginControllerTest extends TestCase /** * PluginService에서 RuntimeException 발생 시 422 반환 */ - public function test_install_from_file_returns_422_on_runtime_exception(): void + public function test_install_from_file_returns_422_on_domain_exception(): void { - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('installFromZipFile') ->once() - ->andThrow(new \RuntimeException('plugin.json을 찾을 수 없습니다.')); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + ->andThrow(new PluginOperationException('plugins.errors.plugin_json_not_found')); + $this->app->instance(PluginService::class, $pluginServiceMock); $file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip'); @@ -856,7 +860,7 @@ class PluginControllerTest extends TestCase ]); $response->assertStatus(422); - $response->assertJsonPath('message', 'plugin.json을 찾을 수 없습니다.'); + $response->assertJsonPath('message', __('plugins.errors.plugin_json_not_found')); } /** @@ -864,11 +868,11 @@ class PluginControllerTest extends TestCase */ public function test_install_from_file_returns_500_on_general_exception(): void { - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('installFromZipFile') ->once() ->andThrow(new \Exception('예상치 못한 오류')); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); $file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip'); @@ -970,7 +974,7 @@ class PluginControllerTest extends TestCase */ public function test_install_from_github_calls_service_with_valid_url(): void { - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('installFromGithub') ->once() ->with('https://github.com/sirsoft/sample-plugin') @@ -979,7 +983,7 @@ class PluginControllerTest extends TestCase 'name' => 'Sample Plugin', 'version' => '1.0.0', ]); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); $response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [ 'github_url' => 'https://github.com/sirsoft/sample-plugin', @@ -993,20 +997,20 @@ class PluginControllerTest extends TestCase /** * PluginService에서 RuntimeException 발생 시 422 반환 */ - public function test_install_from_github_returns_422_on_runtime_exception(): void + public function test_install_from_github_returns_422_on_domain_exception(): void { - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('installFromGithub') ->once() - ->andThrow(new \RuntimeException('GitHub 저장소를 다운로드할 수 없습니다.')); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + ->andThrow(new PluginOperationException('plugins.errors.github_repo_not_found')); + $this->app->instance(PluginService::class, $pluginServiceMock); $response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [ 'github_url' => 'https://github.com/sirsoft/sample-plugin', ]); $response->assertStatus(422); - $response->assertJsonPath('message', 'GitHub 저장소를 다운로드할 수 없습니다.'); + $response->assertJsonPath('message', __('plugins.errors.github_repo_not_found')); } /** @@ -1014,11 +1018,11 @@ class PluginControllerTest extends TestCase */ public function test_install_from_github_returns_500_on_general_exception(): void { - $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class); + $pluginServiceMock = \Mockery::mock(PluginService::class); $pluginServiceMock->shouldReceive('installFromGithub') ->once() ->andThrow(new \Exception('예상치 못한 오류')); - $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock); + $this->app->instance(PluginService::class, $pluginServiceMock); $response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [ 'github_url' => 'https://github.com/sirsoft/sample-plugin', diff --git a/tests/Feature/Api/Admin/TemplateControllerTest.php b/tests/Feature/Api/Admin/TemplateControllerTest.php index a18dcce2..b9d9ee47 100644 --- a/tests/Feature/Api/Admin/TemplateControllerTest.php +++ b/tests/Feature/Api/Admin/TemplateControllerTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Api\Admin; use App\Contracts\Extension\TemplateManagerInterface; use App\Enums\ExtensionOwnerType; +use App\Exceptions\TemplateOperationException; use App\Models\Permission; use App\Models\Role; use App\Models\Template; @@ -1024,12 +1025,12 @@ class TemplateControllerTest extends TestCase /** * TemplateService에서 RuntimeException 발생 시 422 반환 */ - public function test_install_from_file_returns_422_on_runtime_exception(): void + public function test_install_from_file_returns_422_on_domain_exception(): void { $templateServiceMock = Mockery::mock(TemplateService::class); $templateServiceMock->shouldReceive('installFromZipFile') ->once() - ->andThrow(new \RuntimeException('template.json을 찾을 수 없습니다.')); + ->andThrow(new TemplateOperationException('templates.errors.template_json_not_found')); $this->app->instance(TemplateService::class, $templateServiceMock); $file = UploadedFile::fake()->create('template.zip', 100, 'application/zip'); @@ -1039,7 +1040,7 @@ class TemplateControllerTest extends TestCase ]); $response->assertStatus(422); - $response->assertJsonPath('message', 'template.json을 찾을 수 없습니다.'); + $response->assertJsonPath('message', __('templates.errors.template_json_not_found')); } /** @@ -1094,12 +1095,12 @@ class TemplateControllerTest extends TestCase /** * TemplateService에서 RuntimeException 발생 시 422 반환 (GitHub) */ - public function test_install_from_github_returns_422_on_runtime_exception(): void + public function test_install_from_github_returns_422_on_domain_exception(): void { $templateServiceMock = Mockery::mock(TemplateService::class); $templateServiceMock->shouldReceive('installFromGithub') ->once() - ->andThrow(new \RuntimeException('GitHub 저장소를 찾을 수 없습니다.')); + ->andThrow(new TemplateOperationException('templates.errors.github_repo_not_found')); $this->app->instance(TemplateService::class, $templateServiceMock); $response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [ @@ -1107,7 +1108,7 @@ class TemplateControllerTest extends TestCase ]); $response->assertStatus(422); - $response->assertJsonPath('message', 'GitHub 저장소를 찾을 수 없습니다.'); + $response->assertJsonPath('message', __('templates.errors.github_repo_not_found')); } /** diff --git a/tests/Feature/Extension/ExtensionRouteActiveGateTest.php b/tests/Feature/Extension/ExtensionRouteActiveGateTest.php new file mode 100644 index 00000000..784b638a --- /dev/null +++ b/tests/Feature/Extension/ExtensionRouteActiveGateTest.php @@ -0,0 +1,256 @@ + 이 테스트가 라우트 등록까지 확인할 확장 */ + protected array $requiredExtensions = ['plugins/sirsoft-gdpr', 'modules/sirsoft-page']; + + /** @var Router|null 교체 전 원본 라우터 */ + private ?Router $originalRouter = null; + + protected function tearDown(): void + { + $this->restoreRouter(); + + parent::tearDown(); + } + + #[Test] + public function 비활성_플러그인의_라우트는_등록되지_않는다(): void + { + $this->seedPlugin('sirsoft-gdpr', ExtensionStatus::Active); + PluginManager::invalidatePluginStatusCache(); + + $active = $this->registerPluginRoutes(); + + $this->assertNotEmpty( + $this->urisFor($active, 'api/plugins/sirsoft-gdpr'), + '활성 플러그인의 라우트가 등록되지 않았다 — 판정기 모집단이 비어 아래 단언이 무의미해진다' + ); + + $this->seedPlugin('sirsoft-gdpr', ExtensionStatus::Inactive); + PluginManager::invalidatePluginStatusCache(); + + $inactive = $this->registerPluginRoutes(); + + $this->assertSame( + [], + $this->urisFor($inactive, 'api/plugins/sirsoft-gdpr'), + '비활성 플러그인의 라우트가 등록됐다 — 화면·메뉴만 사라지고 API 는 계속 호출 가능한 상태가 된다' + ); + } + + #[Test] + public function 비활성_모듈의_라우트는_등록되지_않는다(): void + { + $this->seedModule('sirsoft-page', ExtensionStatus::Active); + ModuleManager::invalidateModuleStatusCache(); + + $active = $this->registerModuleRoutes(); + + $this->assertNotEmpty( + $this->urisFor($active, 'api/modules/sirsoft-page'), + '활성 모듈의 라우트가 등록되지 않았다 — 판정기 모집단이 비어 아래 단언이 무의미해진다' + ); + + $this->seedModule('sirsoft-page', ExtensionStatus::Inactive); + ModuleManager::invalidateModuleStatusCache(); + + $inactive = $this->registerModuleRoutes(); + + $this->assertSame( + [], + $this->urisFor($inactive, 'api/modules/sirsoft-page'), + '비활성 모듈의 라우트가 등록됐다' + ); + } + + #[Test] + public function 두_라우트_프로바이더가_같은_활성_게이트를_쓴다(): void + { + // 한쪽에만 게이트가 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만 + // 나타난다. 소스에서 게이트 존재를 함께 확인해, 한쪽만 제거되는 회귀를 막는다. + $cases = [ + ModuleRouteServiceProvider::class => 'getActiveModuleIdentifiers', + PluginRouteServiceProvider::class => 'getActivePluginIdentifiers', + ]; + + foreach ($cases as $class => $resolver) { + $source = file_get_contents((new ReflectionClass($class))->getFileName()); + + $this->assertStringContainsString( + 'self::'.$resolver.'()', + $source, + $class.' 가 활성 확장 목록을 조회하지 않는다' + ); + + $this->assertMatchesRegularExpression( + '/if \(! in_array\(\$\w+, \$active\w+\)\) \{\s*continue;/', + $source, + $class.' 가 활성 목록으로 라우트 등록을 게이트하지 않는다 — ' + .'비활성 확장의 API 가 계속 호출 가능해진다' + ); + } + } + + /** + * 플러그인 라우트를 격리된 라우터에 등록하고 그 라우터를 반환합니다. + * + * @return Router 등록 결과가 담긴 라우터 + */ + private function registerPluginRoutes(): Router + { + return $this->registerRoutesWith(PluginRouteServiceProvider::class, 'loadPluginRoutes'); + } + + /** + * 모듈 라우트를 격리된 라우터에 등록하고 그 라우터를 반환합니다. + * + * @return Router 등록 결과가 담긴 라우터 + */ + private function registerModuleRoutes(): Router + { + return $this->registerRoutesWith(ModuleRouteServiceProvider::class, 'loadModuleRoutes'); + } + + /** + * 프로바이더의 라우트 로더를 격리된 라우터 위에서 실행합니다. + * + * 애플리케이션 라우터를 그대로 쓰면 이미 부팅 시 등록된 라우트와 섞여 "이번 호출이 + * 등록한 것" 을 분리할 수 없다. 빈 라우터로 교체해 이번 호출의 결과만 관측한다. + * + * @param string $providerClass 라우트 프로바이더 클래스 + * @param string $loader 라우트 로더 메서드명 + * @return Router 등록 결과가 담긴 라우터 + */ + private function registerRoutesWith(string $providerClass, string $loader): Router + { + $this->restoreRouter(); + $this->originalRouter = app('router'); + + $fresh = new Router(app('events'), app()); + app()->instance('router', $fresh); + Facade::clearResolvedInstance('router'); + + $provider = new $providerClass(app()); + (new ReflectionClass($provider))->getMethod($loader)->invoke($provider); + + $this->restoreRouter(); + + return $fresh; + } + + /** + * 교체했던 애플리케이션 라우터를 원복합니다. + */ + private function restoreRouter(): void + { + if ($this->originalRouter === null) { + return; + } + + app()->instance('router', $this->originalRouter); + Facade::clearResolvedInstance('router'); + $this->originalRouter = null; + } + + /** + * 라우터에 등록된 URI 중 주어진 접두사로 시작하는 것을 반환합니다. + * + * @param Router $router 대상 라우터 + * @param string $prefix URI 접두사 + * @return array 매칭된 URI 목록 + */ + private function urisFor(Router $router, string $prefix): array + { + $uris = []; + + foreach ($router->getRoutes() as $route) { + if (str_starts_with($route->uri(), $prefix)) { + $uris[] = $route->uri(); + } + } + + return array_values(array_unique($uris)); + } + + /** + * plugins 테이블에 지정한 상태로 행을 준비합니다. + * + * @param string $identifier 플러그인 식별자 + * @param ExtensionStatus $status 적용할 상태 + */ + private function seedPlugin(string $identifier, ExtensionStatus $status): void + { + $this->seedExtensionRow('plugins', $identifier, $status); + } + + /** + * modules 테이블에 지정한 상태로 행을 준비합니다. + * + * @param string $identifier 모듈 식별자 + * @param ExtensionStatus $status 적용할 상태 + */ + private function seedModule(string $identifier, ExtensionStatus $status): void + { + $this->seedExtensionRow('modules', $identifier, $status); + } + + /** + * 확장 테이블에 식별자/상태 행을 upsert 합니다. + * + * @param string $table 대상 테이블 (modules|plugins) + * @param string $identifier 확장 식별자 + * @param ExtensionStatus $status 적용할 상태 + */ + private function seedExtensionRow(string $table, string $identifier, ExtensionStatus $status): void + { + $existing = DB::table($table)->where('identifier', $identifier)->first(); + + if ($existing !== null) { + DB::table($table)->where('identifier', $identifier)->update([ + 'status' => $status->value, + 'updated_at' => now(), + ]); + + return; + } + + DB::table($table)->insert([ + 'identifier' => $identifier, + 'vendor' => explode('-', $identifier)[0], + 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'version' => '1.0.0', + 'status' => $status->value, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Extension/ExtensionRouteCacheInvalidationTest.php b/tests/Feature/Extension/ExtensionRouteCacheInvalidationTest.php index 558af3fd..dd68e24f 100644 --- a/tests/Feature/Extension/ExtensionRouteCacheInvalidationTest.php +++ b/tests/Feature/Extension/ExtensionRouteCacheInvalidationTest.php @@ -71,7 +71,9 @@ class ExtensionRouteCacheInvalidationTest extends TestCase foreach ($methods as $method) { $this->assertStringContainsString( 'RouteCacheHelper::rebuild()', - $this->methodSource($class, $method), + // 주석을 걷어낸 뒤 본다 — 설명 주석의 언급만으로 통과하면 + // 호출이 사라져도 green 이 된다. + $this->stripComments($this->methodSource($class, $method)), $class.'::'.$method.' 이 라우트 캐시를 갱신하지 않는다 — ' .'캐시가 걸린 사이트에서 이 조작 뒤 확장 라우트가 404 가 된다' ); @@ -105,6 +107,77 @@ class ExtensionRouteCacheInvalidationTest extends TestCase } } + #[Test] + public function 라우트_캐시_재생성은_상태_캐시_무효화_뒤에_온다(): void + { + $targets = [ + ModuleManager::class => 'invalidateModuleStatusCache', + PluginManager::class => 'invalidatePluginStatusCache', + ]; + + foreach ($targets as $class => $invalidator) { + $found = 0; + + $reflection = new ReflectionClass($class); + + foreach ($reflection->getMethods() as $method) { + // trait 메서드는 getDeclaringClass() 가 사용 클래스를 가리키므로 이 필터만으로는 + // 걸러지지 않는다. methodSource() 는 클래스 파일을 줄 번호로 자르므로, + // 다른 파일에 정의된 메서드가 섞이면 엉뚱한 구간을 읽고 판정이 오염된다. + if ($method->getFileName() !== $reflection->getFileName()) { + continue; + } + + // 주석을 걷어낸 뒤 판정한다 — 주석 안의 호출 언급을 실제 호출로 오인하면 + // 순서 판정이 통째로 뒤집힌다(설명 주석이 호출보다 앞서는 것이 보통이다). + $source = $this->stripComments($this->methodSource($class, $method->getName())); + $rebuildAt = strpos($source, 'RouteCacheHelper::rebuild()'); + + if ($rebuildAt === false) { + continue; + } + + $found++; + $invalidateAt = strpos($source, $invalidator.'()'); + + $this->assertNotFalse( + $invalidateAt, + $class.'::'.$method->getName().' 이 라우트 캐시를 구우면서 상태 캐시를 무효화하지 않는다' + ); + + // route:cache 는 새 앱을 부팅해 캐시된 활성 확장 목록을 읽는다. + // 무효화가 뒤에 오면 방금 바뀐 상태가 반영되지 않은 채 라우트가 박제된다. + $this->assertLessThan( + $rebuildAt, + $invalidateAt, + $class.'::'.$method->getName().' 이 상태 캐시 무효화보다 먼저 라우트 캐시를 굽는다 — ' + .'그 확장의 라우트가 빠진 채 박제되어 오류 없이 404 가 된다 (#519 회귀)' + ); + } + + // 판정기 자신이 모집단을 잃는 것을 막는 가드 — 리플렉션 필터가 잘못되면 + // 0건을 순회하고도 green 이 된다. + $this->assertGreaterThanOrEqual(5, $found, $class.' 에서 굽기 지점을 도출하지 못했다 — 판정기 모집단이 비었다'); + } + } + + #[Test] + public function 업데이트는_상태_복원_뒤에_훅_캐시를_다시_굽는다(): void + { + foreach ([[ModuleManager::class, 'updateModule'], [PluginManager::class, 'updatePlugin']] as [$class, $method]) { + // 주석을 걷어낸 뒤 판정한다 — 설명 주석의 언급만으로 통과하면 안 된다. + $source = $this->stripComments($this->methodSource($class, $method)); + + // Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너를 누락한 채 남는다. + // 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 stale 은 조용히 통과한다. + $this->assertStringContainsString( + 'regenerateHookCache()', + $source, + $class.'::'.$method.' 이 상태 복원 뒤 훅 캐시를 재생성하지 않는다' + ); + } + } + /** * 지정한 메서드의 소스 본문을 반환합니다. * @@ -124,4 +197,34 @@ class ExtensionRouteCacheInvalidationTest extends TestCase $target->getEndLine() - $target->getStartLine() + 1 )); } + + /** + * 소스에서 주석을 제거합니다. + * + * 호출 순서를 문자열 위치로 판정하므로, 그 호출을 설명하는 주석이 실제 호출로 + * 오인되면 판정이 뒤집힌다. 어휘 분석으로 주석 토큰만 걷어낸다. + * + * @param string $source 대상 소스 + * @return string 주석이 제거된 소스 + */ + private function stripComments(string $source): string + { + $stripped = ''; + + foreach (token_get_all('phpFilesIn(app_path()) as $file) { + $source = File::get($file); + + foreach ($this->errorCallsIn($source) as [$offset, $call]) { + if (! preg_match("/^\(\s*'([^']+)'/", $call, $m)) { + continue; + } + + // 키를 실제로 해석해 판정한다. lang 파일을 직접 파싱하면 중첩 키 + // (`modules.errors.install_failed`)의 경로를 잃어 오탐·누락이 함께 생긴다. + // 존재하지 않는 키는 번역기가 키 자체를 돌려주므로 자연히 걸러진다. + if (! str_contains(trans($m[1], [], 'ko'), ':error')) { + continue; + } + + $checked++; + + // messageParams(넷째 인자)에 'error' 키가 실렸는지 본다. + // 셋째 인자 errors 페이로드는 다른 통로이므로 치환에 쓰이지 않는다. + // 배열의 첫 키가 아닐 수 있으므로(`['module' => ..., 'error' => ...]`) + // 인자를 최상위 쉼표 기준으로 갈라 넷째 인자만 본다. + $params = $this->topLevelArguments($call)[3] ?? ''; + + if (preg_match("/'error'\s*=>/", $params) === 1) { + continue; + } + + $line = substr_count(substr($source, 0, $offset), "\n") + 1; + $missing[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file).':'.$line.' '.$m[1]; + } + } + + $this->assertGreaterThan( + 50, + $checked, + '검사한 호출부가 너무 적다 — 호출부 탐지 정규식이 모집단을 잃었다' + ); + + $this->assertSame( + [], + $missing, + '다음 호출부가 :error 치환 파라미터 없이 오류 문구를 반환한다 — ' + ."운영자 화면에 자리표시자 \":error\" 가 그대로 노출된다:\n " + .implode("\n ", $missing) + ); + } + + #[Test] + public function error_치환_자리를_가진_키는_예외_생성자에서도_파라미터와_함께_전달된다(): void + { + $missing = []; + $checked = 0; + + foreach ($this->phpFilesIn(app_path()) as $file) { + $source = File::get($file); + + foreach ($this->exceptionConstructorsIn($source) as [$offset, $call]) { + $args = $this->topLevelArguments($call); + + if (! preg_match("/^\s*'([^']+)'/", $args[0] ?? '', $m)) { + continue; + } + + if (! str_contains(trans($m[1], [], 'ko'), ':error')) { + continue; + } + + $checked++; + + // 예외 생성자는 `->error()` 와 인자 배치가 다르다 — + // 파라미터 배열이 넷째가 아니라 **둘째** 인자다. + if (preg_match("/'error'\s*=>/", $args[1] ?? '') === 1) { + continue; + } + + $line = substr_count(substr($source, 0, $offset), "\n") + 1; + $missing[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file).':'.$line.' '.$m[1]; + } + } + + $this->assertGreaterThan( + 0, + $checked, + '검사한 예외 생성자가 0건이다 — 탐지 정규식이 모집단을 잃었다' + ); + + $this->assertSame( + [], + $missing, + '다음 예외 생성자가 :error 치환 파라미터 없이 던져진다 — 그 메시지가 응답으로 ' + ."나가면 운영자 화면에 자리표시자 \":error\" 가 그대로 노출된다:\n " + .implode("\n ", $missing) + ); + } + + /** + * 소스에서 `new *OperationException(...)` 생성자 호출 전체를 잘라 반환합니다. + * + * `->error()` 스캐너는 첫 인자가 문자열 리터럴이 아니면 건너뛰므로, 키를 들고 다니는 + * 예외로 던지는 경로는 그 판정에 걸리지 않는다. 그 축을 이 스캐너가 덮는다. + * + * @param string $source 대상 소스 + * @return array [호출 시작 오프셋, 호출 전체 문자열] + */ + private function exceptionConstructorsIn(string $source): array + { + $calls = []; + + if (! preg_match_all('/new\s+\\\\?(\w*OperationException)\(/', $source, $m, PREG_OFFSET_CAPTURE)) { + return $calls; + } + + foreach ($m[0] as [$match, $at]) { + foreach ($this->callsIn(substr($source, $at), $match) as [$rel, $call]) { + if ($rel === 0) { + $calls[] = [$at, $call]; + break; + } + } + } + + return $calls; + } + + /** + * 소스에서 `->error(...)` 호출 전체를 괄호 균형으로 잘라 반환합니다. + * + * 한 줄만 보면 여러 줄에 걸친 호출의 뒷부분(치환 파라미터)을 놓쳐 오탐이 된다. + * + * @param string $source 대상 소스 + * @return array [호출 시작 오프셋, 호출 전체 문자열] + */ + private function errorCallsIn(string $source): array + { + return $this->callsIn($source, '->error('); + } + + /** + * 소스에서 지정한 여는 토큰으로 시작하는 호출 전체를 괄호 균형으로 잘라 반환합니다. + * + * @param string $source 대상 소스 + * @param string $token 호출 시작 토큰 (여는 괄호 포함, 예: `->error(`) + * @return array [호출 시작 오프셋, 호출 전체 문자열] + */ + private function callsIn(string $source, string $token): array + { + $calls = []; + $offset = 0; + + while (($at = strpos($source, $token, $offset)) !== false) { + $open = $at + strlen($token) - 1; + $depth = 0; + $end = null; + + for ($i = $open, $len = strlen($source); $i < $len; $i++) { + if ($source[$i] === '(') { + $depth++; + } elseif ($source[$i] === ')') { + $depth--; + + if ($depth === 0) { + $end = $i; + break; + } + } + } + + if ($end === null) { + break; + } + + $calls[] = [$at, substr($source, $open, $end - $open + 1)]; + $offset = $at + 1; + } + + return $calls; + } + + /** + * 호출 문자열 `( ... )` 을 최상위 쉼표 기준으로 갈라 인자 목록을 반환합니다. + * + * 중첩 괄호·대괄호와 따옴표 안의 쉼표는 경계로 보지 않는다. + * + * @param string $call 괄호를 포함한 호출 문자열 + * @return array 인자 문자열 목록 + */ + private function topLevelArguments(string $call): array + { + $inner = substr($call, 1, -1); + $args = []; + $buffer = ''; + $depth = 0; + $quote = null; + + for ($i = 0, $len = strlen($inner); $i < $len; $i++) { + $char = $inner[$i]; + + if ($quote !== null) { + $buffer .= $char; + + if ($char === '\\') { + $buffer .= $inner[++$i] ?? ''; + } elseif ($char === $quote) { + $quote = null; + } + + continue; + } + + if ($char === "'" || $char === '"') { + $quote = $char; + $buffer .= $char; + + continue; + } + + if ($char === '(' || $char === '[') { + $depth++; + } elseif ($char === ')' || $char === ']') { + $depth--; + } elseif ($char === ',' && $depth === 0) { + $args[] = trim($buffer); + $buffer = ''; + + continue; + } + + $buffer .= $char; + } + + if (trim($buffer) !== '') { + $args[] = trim($buffer); + } + + return $args; + } + + /** + * 주어진 디렉토리 아래의 PHP 파일 경로를 모두 반환합니다. + * + * @param string $directory 대상 디렉토리 + * @return array PHP 파일 경로 목록 + */ + private function phpFilesIn(string $directory): array + { + $files = []; + + foreach (File::allFiles($directory) as $file) { + if ($file->getExtension() === 'php') { + $files[] = $file->getPathname(); + } + } + + return $files; + } +} diff --git a/tests/Feature/Http/GenericCatchStatusCodeContractTest.php b/tests/Feature/Http/GenericCatchStatusCodeContractTest.php index 5c7474a3..846d6ba2 100644 --- a/tests/Feature/Http/GenericCatchStatusCodeContractTest.php +++ b/tests/Feature/Http/GenericCatchStatusCodeContractTest.php @@ -38,40 +38,29 @@ class GenericCatchStatusCodeContractTest extends TestCase /** * 예외 원문을 메시지 키로 넘기는 것이 아직 남아 있는 지점. * - * 코어 확장 설치 경로는 `\RuntimeException(__('key', [...]))` 형태로 서비스·헬퍼 - * 40여 곳에서 던져진다. 키를 들고 다니는 예외로 승격하려면 설치/업데이트 경로 전체를 - * 건드려야 하므로 별도 작업으로 분리한다 — 여기 남겨 두는 이유는 "판정기가 못 봐서 - * 통과한 것" 과 "알고 남긴 것" 을 구분하기 위해서다. 목록이 늘어나면 실패한다. + * 확장 설치 경로 6곳(모듈·플러그인·템플릿 × from-file/from-github)이 여기 있었다. + * 도메인 실패가 이미 `*OperationException`(errorKey + params)으로 승격되어 있었는데 + * catch 만 부모 `\RuntimeException` 으로 남아, 이미 번역된 문장을 키 자리로 넘기고 + * 있었다. typed catch 로 좁히고 원본 키·파라미터를 넘기도록 바꿔 전부 해소했다. + * + * 비운 채로 둔다 — 새 위반이 생기면 그 자리에서 실패해야 한다. * * @var array */ - private const KNOWN_EXCEPTION_TEXT_AS_KEY = [ - 'app/Http/Controllers/Api/Admin/ModuleController.php:437', - 'app/Http/Controllers/Api/Admin/ModuleController.php:461', - 'app/Http/Controllers/Api/Admin/PluginController.php:448', - 'app/Http/Controllers/Api/Admin/PluginController.php:472', - 'app/Http/Controllers/Api/Admin/TemplateController.php:352', - 'app/Http/Controllers/Api/Admin/TemplateController.php:376', - ]; + private const KNOWN_EXCEPTION_TEXT_AS_KEY = []; /** * 광역 `\RuntimeException` catch 가 4xx 를 반환하는 것이 아직 남아 있는 지점. * * `\RuntimeException` 은 도메인 예외의 부모가 되기 쉬워, 도메인 실패를 typed 로 * 승격한 뒤에도 이 catch 를 남겨 두면 남는 것은 인프라 예외뿐인데 그것까지 4xx 로 - * 뭉갠다. 위 `KNOWN_EXCEPTION_TEXT_AS_KEY` 와 같은 줄이며, 같은 설치 경로 개편에서 - * 함께 해소된다. + * 뭉갠다. 위 `KNOWN_EXCEPTION_TEXT_AS_KEY` 와 같은 줄이었고 함께 해소되었다. + * + * 비운 채로 둔다 — 새 위반이 생기면 그 자리에서 실패해야 한다. * * @var array */ - private const KNOWN_BROAD_RUNTIME_CATCH_4XX = [ - 'app/Http/Controllers/Api/Admin/ModuleController.php::installFromFile' => '설치 경로가 도메인 실패를 키 없는 RuntimeException 으로 던진다 — 예외 승격과 함께 해소', - 'app/Http/Controllers/Api/Admin/ModuleController.php::installFromGithub' => '동일', - 'app/Http/Controllers/Api/Admin/PluginController.php::installFromFile' => '동일', - 'app/Http/Controllers/Api/Admin/PluginController.php::installFromGithub' => '동일', - 'app/Http/Controllers/Api/Admin/TemplateController.php::installFromFile' => '동일', - 'app/Http/Controllers/Api/Admin/TemplateController.php::installFromGithub' => '동일', - ]; + private const KNOWN_BROAD_RUNTIME_CATCH_4XX = []; /** * 스캔 대상 컨트롤러 루트 목록 (코어 + 번들 모듈/플러그인).