fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가 invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고, 라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이 아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다. update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로 판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다. 플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속 응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다. 실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던 문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out 파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를 추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와 파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다. 언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를 일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를 거쳐 디버그 모드에서 도달한다.
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