Files
Gnuboard7/app/Providers/PluginRouteServiceProvider.php
T
HeuJung 03cbb99196 fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는
DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가
invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고,
라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이
아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다.
update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로
판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다.

플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속
응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다.

실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던
문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out
파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를
추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와
파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다.
언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를
일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를
거쳐 디버그 모드에서 도달한다.
2026-08-21 17:09:30 +09:00

148 lines
5.5 KiB
PHP

<?php
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;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
class PluginRouteServiceProvider extends ServiceProvider
{
use CachesPluginStatus;
/**
* The path to the "home" route for your application.
*
* Typically, users are redirected here after authentication.
*
* @var string
*/
public const HOME = '/home';
/**
* 라우트 모델 바인딩, 패턴 필터 및 기타 라우트 구성을 정의합니다.
*/
public function boot(): void
{
$this->routes(function () {
$this->loadPluginRoutes();
});
}
/**
* 플러그인의 라우트 파일들을 로드합니다.
*
* 활성화된 플러그인만 라우트를 등록합니다.
*/
protected function loadPluginRoutes(): void
{
// .env 파일이 없으면 스킵 (인스톨러 실행 전)
if (! File::exists(base_path('.env'))) {
return;
}
$pluginsPath = base_path('plugins');
if (! File::exists($pluginsPath)) {
return;
}
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 쿼리 제거).
// 인스톨러 이전 환경에서는 기존 체크 경로로 폴백.
//
// 단, 마이그레이션 계열 명령 실행 중에는 INSTALLER_COMPLETED=true 라도 테이블이
// 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
// 무력화하고 hasTable 검증 경로로 진입한다. 빈 DB 면 여기서 early return 하여
// 이후 플러그인 부팅/상태 조회가 table not found 로 부팅을 깨뜨리는 것을 막는다.
if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) {
try {
if (! Schema::hasTable('plugins')) {
return;
}
} catch (\Exception) {
// DB 연결 실패 시 조용히 스킵 (설정 오류, 마이그레이션 전 등)
return;
}
}
// 활성화된 플러그인 identifier 목록 가져오기.
// 같은 목록을 PluginManager·PluginServiceProvider 가 이미 캐시(TTL 기본 하루)해 두므로
// 여기서 다시 조회하지 않고 그 캐시를 공유한다. 상태 변경 시 무효화도 같이 따라온다.
$activePluginIdentifiers = self::getActivePluginIdentifiers();
$plugins = File::directories($pluginsPath);
$allowlistActive = ExtensionTestAllowlist::isActive();
foreach ($plugins as $plugin) {
$pluginName = basename($plugin);
$pluginFile = $plugin.'/plugin.php';
// 테스트 환경 확장 격리: allowlist 밖 플러그인의 라우트 등록 차단
if ($allowlistActive && ! ExtensionTestAllowlist::isAllowed('plugin', $pluginName)) {
continue;
}
// 활성화된 플러그인만 라우트 로드 (모듈과 동일 기준).
// 이 게이트가 없으면 비활성 플러그인의 API 가 계속 호출 가능해, 화면·메뉴만
// 사라지고 기능은 살아 있는 상태가 된다.
if (! in_array($pluginName, $activePluginIdentifiers)) {
continue;
}
// 플러그인 파일이 존재하는지 확인
if (! File::exists($pluginFile)) {
continue;
}
// vendor-plugin 형식을 네임스페이스로 변환
$namespace = $this->convertDirectoryToNamespace($pluginName);
$pluginClass = "Plugins\\{$namespace}\\Plugin";
// 클래스가 아직 로드되지 않은 경우에만 require
// (_bundled에서 이미 로드된 경우 중복 선언 방지)
if (! class_exists($pluginClass, false)) {
require_once $pluginFile;
}
if (! class_exists($pluginClass)) {
continue;
}
$pluginInstance = new $pluginClass;
$routes = $pluginInstance->getRoutes();
// API 라우트 로드
if (isset($routes['api']) && File::exists($routes['api'])) {
Route::prefix('api/plugins/'.$pluginName)
->name('api.plugins.'.$pluginName.'.')
->middleware('api')
->group($routes['api']);
}
// 웹 라우트 로드
if (isset($routes['web']) && File::exists($routes['web'])) {
Route::prefix('plugins/'.$pluginName)
->name('web.plugins.'.$pluginName.'.')
->middleware('web')
->group($routes['web']);
}
}
}
/**
* 디렉토리명(vendor-plugin)을 네임스페이스(Vendor\Plugin)로 변환합니다.
*
* @param string $directoryName 디렉토리명 (예: sirsoft-tosspayments)
* @return string 네임스페이스 (예: Sirsoft\Tosspayments)
*/
protected function convertDirectoryToNamespace(string $directoryName): string
{
return ExtensionManager::directoryToNamespace($directoryName);
}
}