Merge pull request from gnuboard:HeuJung/issue603
fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
This commit is contained in:
@@ -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 커맨드로 수행
|
||||
```
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
|
||||
- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
|
||||
- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
|
||||
- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
|
||||
|
||||
## [7.0.7] - 2026-08-19
|
||||
|
||||
### Security
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 모듈 디렉토리 경로 (캐시)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 플러그인 디렉토리 경로 (캐시)
|
||||
*/
|
||||
|
||||
@@ -74,8 +74,12 @@ class ExtensionManager
|
||||
*
|
||||
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
|
||||
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
|
||||
*
|
||||
* 확장 수명주기에서 상태를 되돌린 뒤 다시 부를 수 있도록 public 이다 —
|
||||
* Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너가 빠진 채 남고,
|
||||
* 훅 캐시 폴백은 파일 부재/손상에만 작동해 stale 한 내용은 조용히 통과하기 때문이다.
|
||||
*/
|
||||
protected function regenerateHookCache(): void
|
||||
public function regenerateHookCache(): void
|
||||
{
|
||||
try {
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
/**
|
||||
* 확장이 수명주기 훅에서 실패 사유를 알리는 통로.
|
||||
*
|
||||
* `install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로,
|
||||
* 확장이 "왜" 거부했는지가 호출자에게 전달되지 않는다. 그 결과 관리자 화면에는
|
||||
* 원인 자리가 빈 실패 문구만 남는다.
|
||||
*
|
||||
* 확장은 `failWith()` 로 사유를 남기며 false 를 돌려주고, 코어(Manager)는
|
||||
* `getLifecycleFailureReason()` 으로 그 사유를 읽어 응답에 싣는다.
|
||||
* 사유를 남기지 않은 확장은 null 이며, 이 경우 코어가 일반 문구로 대체한다.
|
||||
*
|
||||
* 사유는 이미 번역된 문장이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다.
|
||||
*/
|
||||
trait ReportsLifecycleFailure
|
||||
{
|
||||
/** @var string|null 마지막 수명주기 실패 사유 (번역된 문장) */
|
||||
protected ?string $lifecycleFailureReason = null;
|
||||
|
||||
/**
|
||||
* 마지막 수명주기 훅이 남긴 실패 사유를 반환합니다.
|
||||
*
|
||||
* @return string|null 실패 사유. 남기지 않았으면 null
|
||||
*/
|
||||
public function getLifecycleFailureReason(): ?string
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
// 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -151,10 +151,13 @@ class PluginSettingsService
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @param array<string, mixed> $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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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` 객체를 반환합니다.
|
||||
클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다.
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -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 (`언어팩 업데이트에 실패했습니다.`) 으로 응답합니다.
|
||||
|
||||
**에러 응답**
|
||||
|
||||
|
||||
@@ -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` 뿐이다. 한쪽만 채우면 자리표시자는 그대로 남는다.
|
||||
|
||||
응답 문구에 예외 원문을 싣지 않기로 정한 화면(언어팩 관리 등)은 **파라미터를 채우는 대신 키에서
|
||||
치환 자리를 없앤다.** 자리를 남긴 채 일반 문구로 채우면 "…실패했습니다: 알 수 없는 오류" 처럼
|
||||
의미 없는 꼬리가 붙고, 나중에 누군가 그 자리를 예외 원문으로 채우는 회귀를 부른다.
|
||||
|
||||
---
|
||||
|
||||
## 예외 → 응답 매핑
|
||||
|
||||
+50
-2
@@ -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()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가
|
||||
|
||||
@@ -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 |
|
||||
|
||||
#### 동적 권한/역할/메뉴 보존 규칙
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 形式が正しくありません。',
|
||||
|
||||
+10
-10
@@ -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).',
|
||||
|
||||
@@ -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 파일을 찾을 수 없습니다.',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Extension;
|
||||
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Providers\ModuleRouteServiceProvider;
|
||||
use App\Providers\PluginRouteServiceProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Router;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장 라우트가 "활성 상태" 로 게이트되는지에 대한 회귀 테스트.
|
||||
*
|
||||
* 확장을 비활성화하면 화면·메뉴·프론트엔드 에셋은 사라지지만, 라우트 등록이 게이트되지
|
||||
* 않으면 그 확장의 API 는 계속 호출 가능한 상태로 남는다. 컨트롤러 파일이 그대로 있으므로
|
||||
* 요청은 정상 처리되고, 오류도 로그도 남지 않는다 — 화면만 보고는 알 수 없다.
|
||||
*
|
||||
* 모듈 쪽에는 이 게이트가 있었으나 플러그인 쪽에는 처음부터 없었다(도입된 적 없음).
|
||||
* 두 경로가 같은 기준을 쓰는지 실제 라우트 등록 결과로 확인한다.
|
||||
*/
|
||||
class ExtensionRouteActiveGateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @var array<int, string> 이 테스트가 라우트 등록까지 확인할 확장 */
|
||||
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<int, string> 매칭된 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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('<?php '.$source) as $token) {
|
||||
if (is_array($token)) {
|
||||
if ($token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stripped .= $token[1];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$stripped .= $token;
|
||||
}
|
||||
|
||||
return $stripped;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Http;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 오류 문구의 치환 자리가 비어 노출되는 것을 막는 회귀 테스트.
|
||||
*
|
||||
* `:error` 같은 치환 자리를 가진 다국어 키를 파라미터 없이 부르면, Laravel 은 그 자리를
|
||||
* 그대로 둔 문장을 돌려준다. 그래서 운영자 화면에는 "모듈 활성화에 실패했습니다: :error"
|
||||
* 처럼 내부 자리표시자가 그대로 보인다. 예외도 로그도 남지 않고, 실패했을 때만 드러나므로
|
||||
* 정상 흐름 테스트로는 잡히지 않는다.
|
||||
*
|
||||
* 호출부를 손으로 열거하지 않는다 — 새 실패 분기가 생겼을 때 그 지점만 조용히 빠진다.
|
||||
* lang 파일에서 `:error` 를 요구하는 키를 도출하고, 그 키를 쓰는 호출부 전수를 검사한다.
|
||||
*/
|
||||
class ErrorMessageParamSubstitutionTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function error_치환_자리를_가진_키는_언제나_파라미터와_함께_호출된다(): void
|
||||
{
|
||||
$missing = [];
|
||||
$checked = 0;
|
||||
|
||||
foreach ($this->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<int, array{0: int, 1: string}> [호출 시작 오프셋, 호출 전체 문자열]
|
||||
*/
|
||||
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<int, array{0: int, 1: string}> [호출 시작 오프셋, 호출 전체 문자열]
|
||||
*/
|
||||
private function errorCallsIn(string $source): array
|
||||
{
|
||||
return $this->callsIn($source, '->error(');
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스에서 지정한 여는 토큰으로 시작하는 호출 전체를 괄호 균형으로 잘라 반환합니다.
|
||||
*
|
||||
* @param string $source 대상 소스
|
||||
* @param string $token 호출 시작 토큰 (여는 괄호 포함, 예: `->error(`)
|
||||
* @return array<int, array{0: int, 1: string}> [호출 시작 오프셋, 호출 전체 문자열]
|
||||
*/
|
||||
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<int, string> 인자 문자열 목록
|
||||
*/
|
||||
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<int, string> PHP 파일 경로 목록
|
||||
*/
|
||||
private function phpFilesIn(string $directory): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach (File::allFiles($directory) as $file) {
|
||||
if ($file->getExtension() === 'php') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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 = [];
|
||||
|
||||
/**
|
||||
* 스캔 대상 컨트롤러 루트 목록 (코어 + 번들 모듈/플러그인).
|
||||
|
||||
Reference in New Issue
Block a user