route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가 invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고, 라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이 아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다. update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로 판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다. 플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속 응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다. 실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던 문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out 파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를 추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와 파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다. 언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를 일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를 거쳐 디버그 모드에서 도달한다.
670 lines
28 KiB
PHP
670 lines
28 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
|
|
use App\Http\Controllers\Concerns\RebuildsSearchIndexOnDemand;
|
|
use App\Http\Requests\Extension\ChangelogRequest;
|
|
use App\Http\Requests\Module\ActivateModuleRequest;
|
|
use App\Http\Requests\Module\DeactivateModuleRequest;
|
|
use App\Http\Requests\Module\IndexModuleRequest;
|
|
use App\Http\Requests\Module\InstallModuleFromFileRequest;
|
|
use App\Http\Requests\Module\InstallModuleFromGithubRequest;
|
|
use App\Http\Requests\Module\InstallModuleRequest;
|
|
use App\Http\Requests\Module\PerformModuleUpdateRequest;
|
|
use App\Http\Requests\Module\PreviewModuleManifestRequest;
|
|
use App\Http\Requests\Module\RefreshModuleLayoutsRequest;
|
|
use App\Http\Requests\Module\UninstallModuleRequest;
|
|
use App\Http\Resources\ModuleCollection;
|
|
use App\Http\Resources\ModuleResource;
|
|
use App\Services\Extension\ExtensionInstallPreviewBuilder;
|
|
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
|
|
use App\Services\LicenseService;
|
|
use App\Services\ModuleService;
|
|
use App\Services\TemplateService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/**
|
|
* 관리자용 모듈 관리 컨트롤러
|
|
*
|
|
* 관리자가 시스템 모듈을 설치, 활성화, 비활성화, 제거할 수 있는 기능을 제공합니다.
|
|
*/
|
|
class ModuleController extends AdminBaseController
|
|
{
|
|
use InjectsExtensionLanguagePacks;
|
|
use OrchestratesCascadeInstall;
|
|
use RebuildsSearchIndexOnDemand;
|
|
|
|
public function __construct(
|
|
private ModuleService $moduleService,
|
|
private TemplateService $templateService,
|
|
private LicenseService $licenseService
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
/**
|
|
* 모든 모듈 목록을 조회합니다 (설치된 모듈과 미설치 모듈 포함).
|
|
*
|
|
* 페이지네이션 및 다중 검색 조건을 지원합니다.
|
|
* - search: 단일 검색어 (이름, 식별자, 설명, 벤더 OR 검색)
|
|
* - filters: 다중 검색 조건 (AND 조건)
|
|
* - with[]: 추가 데이터 포함 (예: custom_menus)
|
|
*
|
|
* @param IndexModuleRequest $request 모듈 목록 조회 요청
|
|
* @return JsonResponse 모듈 목록을 포함한 JSON 응답
|
|
*/
|
|
public function index(IndexModuleRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$responseData = $this->moduleService->getIndexData(
|
|
$request->validated(),
|
|
$request->hasWithOption('custom_menus')
|
|
);
|
|
|
|
// ModuleCollection 변환 (전체 모듈 목록 반환 시에만)
|
|
if (! empty($responseData['data'])) {
|
|
$collection = new ModuleCollection(collect($responseData['data']));
|
|
$responseData['data'] = $collection->toArray($request)['data'];
|
|
$responseData['meta'] = $collection->with($request)['meta'];
|
|
$responseData['abilities'] = $collection->resolveCollectionAbilities($request);
|
|
}
|
|
|
|
return $this->success('module.fetch_success', $responseData);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 설치된 모듈만 조회합니다.
|
|
*
|
|
* @param Request $request HTTP 요청 객체
|
|
* @return JsonResponse 설치된 모듈 목록을 포함한 JSON 응답
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 목록 조회. ModuleCollection::toArray($request)/with($request) 전달용
|
|
public function installed(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$modules = $this->moduleService->getInstalledModulesOnly();
|
|
|
|
$collection = new ModuleCollection(collect($modules));
|
|
|
|
return $this->success('module.fetch_success', [
|
|
'data' => $collection->toArray($request)['data'],
|
|
'meta' => $collection->with($request)['meta'],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미설치 모듈만 조회합니다.
|
|
*
|
|
* @param Request $request HTTP 요청 객체
|
|
* @return JsonResponse 미설치 모듈 목록을 포함한 JSON 응답
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 목록 조회. ModuleCollection::toArray($request)/with($request) 전달용
|
|
public function uninstalled(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$modules = $this->moduleService->getUninstalledModulesOnly();
|
|
|
|
$collection = new ModuleCollection(collect($modules));
|
|
|
|
return $this->success('module.fetch_success', [
|
|
'data' => $collection->toArray($request)['data'],
|
|
'meta' => $collection->with($request)['meta'],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 모듈의 상세 정보를 조회합니다.
|
|
*
|
|
* @param Request $request HTTP 요청 (attachLanguagePacks 의 Request 인자 전달용)
|
|
* @param string $moduleName 모듈 식별자
|
|
* @return JsonResponse 모듈 정보를 포함한 JSON 응답
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 상세 조회. attachLanguagePacks($detail, scope, name, $request) 전달용
|
|
public function show(Request $request, string $moduleName): JsonResponse
|
|
{
|
|
try {
|
|
$moduleInfo = $this->moduleService->getModuleInfo($moduleName);
|
|
|
|
if (! $moduleInfo) {
|
|
return $this->error('module.not_found', 404, null, ['module' => $moduleName]);
|
|
}
|
|
|
|
// 상세 정보는 toDetailArray() 메서드 사용 + 지원 언어팩 주입
|
|
$resource = new ModuleResource($moduleInfo);
|
|
$detail = $this->attachLanguagePacks(
|
|
$resource->toDetailArray(),
|
|
LanguagePackScope::Module,
|
|
$moduleName,
|
|
$request,
|
|
);
|
|
|
|
return $this->success('module.fetch_success', $detail);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈 설치 cascade 프리뷰를 반환합니다 (의존 확장 + 동반 가능 번들 언어팩).
|
|
*
|
|
* 인스톨 모달 오픈 시 호출되어 사용자가 선택할 cascade 후보 트리를 노출합니다.
|
|
* `manifest-preview` (POST + ZIP 업로드) 와는 다른 목적의 GET 식별자 기반 API.
|
|
*
|
|
* @param string $moduleName 모듈 식별자
|
|
* @param ExtensionInstallPreviewBuilder $builder 프리뷰 빌더
|
|
* @return JsonResponse cascade 프리뷰 응답
|
|
*/
|
|
public function installPreview(string $moduleName, ExtensionInstallPreviewBuilder $builder): JsonResponse
|
|
{
|
|
try {
|
|
$preview = $builder->build(LanguagePackScope::Module, $moduleName);
|
|
|
|
return $this->success('module.fetch_success', $preview);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈을 시스템에 설치합니다.
|
|
*
|
|
* @param InstallModuleRequest $request 모듈 설치 요청 데이터
|
|
* @return JsonResponse 설치된 모듈 정보를 포함한 JSON 응답
|
|
*/
|
|
public function install(InstallModuleRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$moduleName = $validated['module_name'];
|
|
$vendorMode = VendorMode::fromStringOrAuto(
|
|
$validated['vendor_mode'] ?? null
|
|
);
|
|
|
|
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
|
|
$this->installSelectedDependencies($validated['dependencies'] ?? []);
|
|
|
|
$module = $this->moduleService->installModule($moduleName, $vendorMode, false, $installFailureReason);
|
|
|
|
if ($module) {
|
|
// cascade 2단계: 동반 번들 언어팩 best-effort 설치
|
|
$lpFailures = $this->installSelectedLanguagePacks($validated['language_packs'] ?? []);
|
|
|
|
$payload = (new ModuleResource($module))->toArray($request);
|
|
$payload['language_pack_failures'] = $lpFailures;
|
|
|
|
return $this->success('module.install_success', $payload, 201);
|
|
} else {
|
|
return $this->error('module.install_failed', 400, null, [
|
|
'error' => $installFailureReason ?? __('modules.errors.unknown_error'),
|
|
]);
|
|
}
|
|
} catch (ValidationException $e) {
|
|
// Service에서 이미 번역된 메시지를 errors에 포함하므로
|
|
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
|
|
$firstError = collect($e->errors())->flatten()->first()
|
|
?? __('module.install_failed');
|
|
|
|
return $this->validationError($e->errors(), $firstError);
|
|
} catch (\Exception $e) {
|
|
return $this->error('modules.installation_failed', 500, $e->getMessage(), [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈을 활성화합니다.
|
|
*
|
|
* force 파라미터가 없고 필요한 의존성이 충족되지 않은 경우 경고를 반환합니다.
|
|
*
|
|
* @param ActivateModuleRequest $request 모듈 활성화 요청 데이터
|
|
* @return JsonResponse 활성화된 모듈 정보를 포함한 JSON 응답
|
|
*/
|
|
public function activate(ActivateModuleRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$moduleName = $validated['module_name'];
|
|
$force = $validated['force'] ?? false;
|
|
|
|
$result = $this->moduleService->activateModule($moduleName, $force);
|
|
|
|
// 경고 응답인 경우 (필요 의존성 미충족) - 활성화 실패로 처리
|
|
if (isset($result['warning']) && $result['warning'] === true) {
|
|
return $this->error('modules.activate_warning', 409, [
|
|
'warning' => true,
|
|
'missing_modules' => $result['missing_modules'] ?? [],
|
|
'missing_plugins' => $result['missing_plugins'] ?? [],
|
|
'message' => $result['message'],
|
|
]);
|
|
}
|
|
|
|
if ($result['success']) {
|
|
$moduleInfo = $result['module_info'] ?? null;
|
|
|
|
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함 (요구사항 #8: 빈 배열이면 모달 표시 안 함)
|
|
$pendingLanguagePacks = app(LanguagePackBundledRegistrar::class)
|
|
->getPendingForReactivation('module', $moduleName);
|
|
|
|
if ($moduleInfo) {
|
|
return $this->success('module.activate_success', [
|
|
'module' => (new ModuleResource($moduleInfo))->resolve(),
|
|
'pending_language_packs' => $pendingLanguagePacks,
|
|
]);
|
|
}
|
|
|
|
return $this->success('module.activate_success', array_merge($result, [
|
|
'pending_language_packs' => $pendingLanguagePacks,
|
|
]));
|
|
} else {
|
|
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(), ['error' => $e->getMessage()]);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈을 비활성화합니다.
|
|
*
|
|
* force 파라미터가 없고 의존하는 확장이 있는 경우 경고를 반환합니다.
|
|
*
|
|
* @param DeactivateModuleRequest $request 모듈 비활성화 요청 데이터
|
|
* @return JsonResponse 비활성화된 모듈 정보를 포함한 JSON 응답
|
|
*/
|
|
public function deactivate(DeactivateModuleRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$moduleName = $validated['module_name'];
|
|
$force = $validated['force'] ?? false;
|
|
|
|
$result = $this->moduleService->deactivateModule($moduleName, $force);
|
|
|
|
// 경고 응답인 경우 (의존 확장 존재) - 비활성화 실패로 처리
|
|
if (isset($result['warning']) && $result['warning'] === true) {
|
|
return $this->error('modules.deactivate_warning', 409, [
|
|
'warning' => true,
|
|
'dependent_templates' => $result['dependent_templates'] ?? [],
|
|
'dependent_modules' => $result['dependent_modules'] ?? [],
|
|
'dependent_plugins' => $result['dependent_plugins'] ?? [],
|
|
'message' => $result['message'],
|
|
]);
|
|
}
|
|
|
|
if ($result['success']) {
|
|
$moduleInfo = $result['module_info'] ?? null;
|
|
|
|
if ($moduleInfo) {
|
|
return $this->successWithResource(
|
|
'module.deactivate_success',
|
|
new ModuleResource($moduleInfo)
|
|
);
|
|
}
|
|
|
|
return $this->success('module.deactivate_success', $result);
|
|
} else {
|
|
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(), ['error' => $e->getMessage()]);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈에 의존하는 템플릿 목록을 조회합니다.
|
|
*
|
|
* @param string $identifier 모듈 식별자
|
|
* @return JsonResponse 의존 템플릿 목록을 포함한 JSON 응답
|
|
*/
|
|
public function dependentTemplates(string $identifier): JsonResponse
|
|
{
|
|
try {
|
|
$dependentTemplates = $this->templateService->getTemplatesDependingOnModule($identifier);
|
|
|
|
return $this->success('module.dependent_templates_success', [
|
|
'data' => $dependentTemplates,
|
|
'total' => count($dependentTemplates),
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.dependent_templates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈 삭제 시 삭제될 데이터 정보를 조회합니다.
|
|
*
|
|
* @param string $moduleName 모듈명
|
|
* @return JsonResponse 삭제 정보를 포함한 JSON 응답
|
|
*/
|
|
public function uninstallInfo(string $moduleName): JsonResponse
|
|
{
|
|
try {
|
|
$uninstallInfo = $this->moduleService->getModuleUninstallInfo($moduleName);
|
|
|
|
if (! $uninstallInfo) {
|
|
return $this->error('module.not_found', 404, null, ['module' => $moduleName]);
|
|
}
|
|
|
|
return $this->success('module.uninstall_info_success', $uninstallInfo);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.uninstall_info_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈을 시스템에서 제거합니다.
|
|
*
|
|
* @param UninstallModuleRequest $request 모듈 제거 요청 데이터
|
|
* @return JsonResponse 제거 결과 JSON 응답
|
|
*/
|
|
public function uninstall(UninstallModuleRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$moduleName = $validated['module_name'];
|
|
$deleteData = $validated['delete_data'] ?? false;
|
|
|
|
$result = $this->moduleService->uninstallModule($moduleName, $deleteData, $uninstallFailureReason);
|
|
|
|
if ($result) {
|
|
return $this->success('module.uninstall_success');
|
|
} else {
|
|
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(), ['error' => $e->getMessage()]);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X).
|
|
*
|
|
* 사용자가 모듈 설치 전 module.json 검증 실패 사유를 미리 확인할 수 있도록 합니다.
|
|
*
|
|
* @param PreviewModuleManifestRequest $request 미리보기 요청
|
|
* @return JsonResponse manifest + validation 결과
|
|
*/
|
|
public function manifestPreview(PreviewModuleManifestRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$result = $this->moduleService->previewManifest($request->file('file'));
|
|
|
|
return $this->success('module.preview_success', $result);
|
|
} catch (\Throwable $e) {
|
|
return $this->error('module.preview_failed', 422, null, ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ZIP 파일에서 모듈을 설치합니다.
|
|
*
|
|
* @param InstallModuleFromFileRequest $request 파일 설치 요청 데이터
|
|
* @return JsonResponse 설치된 모듈 정보를 포함한 JSON 응답
|
|
*/
|
|
public function installFromFile(InstallModuleFromFileRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$file = $request->file('file');
|
|
$module = $this->moduleService->installFromZipFile($file);
|
|
|
|
return $this->successWithResource(
|
|
'module.install_success',
|
|
new ModuleResource($module),
|
|
201
|
|
);
|
|
} 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()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub 저장소에서 모듈을 설치합니다.
|
|
*
|
|
* @param InstallModuleFromGithubRequest $request GitHub 설치 요청 데이터
|
|
* @return JsonResponse 설치된 모듈 정보를 포함한 JSON 응답
|
|
*/
|
|
public function installFromGithub(InstallModuleFromGithubRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$githubUrl = $request->validated()['github_url'];
|
|
$module = $this->moduleService->installFromGithub($githubUrl);
|
|
|
|
return $this->successWithResource(
|
|
'module.install_success',
|
|
new ModuleResource($module),
|
|
201
|
|
);
|
|
} 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()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 설치된 모든 모듈의 업데이트를 확인합니다.
|
|
*
|
|
* @return JsonResponse 업데이트 확인 결과 JSON 응답
|
|
*/
|
|
public function checkUpdates(): JsonResponse
|
|
{
|
|
try {
|
|
$result = $this->moduleService->checkForUpdates();
|
|
|
|
return $this->success('modules.check_updates_success', $result);
|
|
} catch (ValidationException $e) {
|
|
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()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 모듈의 수정된 레이아웃을 확인합니다.
|
|
*
|
|
* 업데이트 전 사용자가 수정한 레이아웃이 있는지 확인하여
|
|
* 레이아웃 전략(overwrite/keep) 선택에 참고할 수 있도록 합니다.
|
|
*
|
|
* @param string $moduleName 모듈 식별자
|
|
* @return JsonResponse 수정된 레이아웃 정보를 포함한 JSON 응답
|
|
*/
|
|
public function checkModifiedLayouts(string $moduleName): JsonResponse
|
|
{
|
|
try {
|
|
// 미존재 식별자는 404 로 구분한다. 존재 확인 없이 조회하면 레이아웃 0건과
|
|
// 모듈 부재가 똑같이 "수정된 레이아웃 없음" 으로 보고되어, 오타·제거된 모듈이
|
|
// 조용히 "수정 없음" 으로 통과한다 (show/uninstall-info 와 동일 규약).
|
|
if (! $this->moduleService->getModuleInfo($moduleName)) {
|
|
return $this->error('module.not_found', 404, null, ['module' => $moduleName]);
|
|
}
|
|
|
|
$result = $this->moduleService->checkModifiedLayouts($moduleName);
|
|
|
|
return $this->success('modules.check_modified_layouts_success', $result);
|
|
} catch (ValidationException $e) {
|
|
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()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 모듈을 업데이트합니다.
|
|
*
|
|
* layout_strategy 파라미터로 레이아웃 처리 방식을 결정합니다:
|
|
* - overwrite: 모든 레이아웃을 새 버전으로 교체
|
|
* - keep: 사용자가 수정한 레이아웃을 유지
|
|
*
|
|
* @param PerformModuleUpdateRequest $request 업데이트 요청 데이터
|
|
* @param string $moduleName 업데이트할 모듈 identifier
|
|
* @return JsonResponse 업데이트 결과 JSON 응답
|
|
*/
|
|
public function performUpdate(PerformModuleUpdateRequest $request, string $moduleName): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$vendorMode = VendorMode::fromStringOrAuto(
|
|
$validated['vendor_mode'] ?? null
|
|
);
|
|
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
|
|
$force = (bool) ($validated['force'] ?? false);
|
|
$result = $this->moduleService->updateModule($moduleName, $vendorMode, $layoutStrategy, $force);
|
|
|
|
// 검색 인덱스 재생성은 운영자가 체크했을 때만 수행한다 — 인덱스 잠금·재색인 비용이
|
|
// 있어 운영 중인 사이트에서 업데이트만으로 발생해서는 안 된다.
|
|
$searchIndex = $this->rebuildSearchIndexIfRequested(
|
|
(bool) ($validated['rebuild_search_index'] ?? false)
|
|
);
|
|
|
|
$moduleInfo = $result['module_info'] ?? null;
|
|
|
|
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":module"/":version"
|
|
// 플레이스홀더가 그대로 사용자에게 노출된다.
|
|
$messageParams = [
|
|
'module' => $moduleName,
|
|
'version' => (string) ($result['to_version'] ?? data_get($moduleInfo, 'version') ?? ''),
|
|
];
|
|
|
|
if ($moduleInfo) {
|
|
return $this->successWithResource(
|
|
'modules.update_success',
|
|
(new ModuleResource($moduleInfo))->additional(['search_index' => $searchIndex]),
|
|
200,
|
|
$messageParams
|
|
);
|
|
}
|
|
|
|
return $this->success(
|
|
'modules.update_success',
|
|
$result + ['search_index' => $searchIndex],
|
|
200,
|
|
$messageParams
|
|
);
|
|
} catch (ValidationException $e) {
|
|
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
|
|
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
|
|
$firstError = collect($e->errors())->flatten()->first()
|
|
?? __('modules.errors.update_failed', ['module' => $moduleName, 'error' => '']);
|
|
|
|
return $this->validationError($e->errors(), $firstError);
|
|
} catch (\Exception $e) {
|
|
return $this->error('modules.errors.update_failed', 500, $e->getMessage(), [
|
|
'module' => $moduleName,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈의 레이아웃을 파일에서 다시 읽어 갱신합니다.
|
|
*
|
|
* @param RefreshModuleLayoutsRequest $request 레이아웃 갱신 요청 데이터
|
|
* @return JsonResponse 갱신된 모듈 정보를 포함한 JSON 응답
|
|
*/
|
|
public function refreshLayouts(RefreshModuleLayoutsRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$moduleName = $request->validated()['module_name'];
|
|
$module = $this->moduleService->refreshModuleLayouts($moduleName);
|
|
|
|
if ($module) {
|
|
return $this->successWithResource(
|
|
'module.refresh_layouts_success',
|
|
new ModuleResource($module)
|
|
);
|
|
} else {
|
|
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(), ['error' => $e->getMessage()]);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 모듈의 변경 내역(changelog)을 조회합니다.
|
|
*
|
|
* @param ChangelogRequest $request 검증된 요청
|
|
* @param string $identifier 모듈 식별자
|
|
* @return JsonResponse 변경 내역을 포함한 JSON 응답
|
|
*/
|
|
public function changelog(ChangelogRequest $request, string $identifier): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$changelog = $this->moduleService->getModuleChangelog(
|
|
$identifier,
|
|
$validated['source'] ?? null,
|
|
$validated['from_version'] ?? null,
|
|
$validated['to_version'] ?? null,
|
|
);
|
|
|
|
return $this->success('module.fetch_success', ['changelog' => $changelog]);
|
|
} catch (\Exception $e) {
|
|
return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈의 라이선스 파일 내용을 반환합니다.
|
|
*
|
|
* @param string $identifier 모듈 식별자
|
|
* @return JsonResponse
|
|
*/
|
|
public function license(string $identifier): JsonResponse
|
|
{
|
|
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $identifier)) {
|
|
return $this->error('module.license_not_found', 404);
|
|
}
|
|
|
|
$content = $this->licenseService->getExtensionLicense('modules', $identifier);
|
|
|
|
if ($content === null) {
|
|
return $this->error('module.license_not_found', 404);
|
|
}
|
|
|
|
return $this->success('module.fetch_success', [
|
|
'content' => $content,
|
|
]);
|
|
}
|
|
}
|