목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친 감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다. 검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다. 옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던 공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글 목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가 되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는 접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다. ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로 로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에 사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다. 하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서 통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가 요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다. 정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다. 탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
195 lines
8.4 KiB
PHP
195 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands\Core;
|
|
|
|
use App\Extension\ExtensionManager;
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Extension\TemplateManager;
|
|
use App\Extension\Vendor\VendorMode;
|
|
use App\Search\SearchIndexMaintenanceManager;
|
|
use App\Services\LanguagePackService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 번들 확장(모듈/플러그인/템플릿/언어팩) 일괄 업데이트를 별도 PHP 프로세스에서 실행합니다.
|
|
*
|
|
* `core:update` 의 `BundledExtensionUpdatePrompt::executeBulkUpdate` 가 사용자 선택을
|
|
* 매니페스트로 직렬화한 후 본 커맨드를 `proc_open` 으로 spawn 한다. 자식은 fresh PHP
|
|
* 프로세스이므로 디스크에 막 적용된 신버전 코어 코드(특히 `ModuleManager` /
|
|
* `PluginManager` / `TemplateManager` / `LanguagePackService`) 를 메모리에 로드한 상태
|
|
* 에서 update 메서드를 호출한다.
|
|
*
|
|
* 부모 프로세스의 stale memory 가 신규 sync 메서드(`syncDeclarativeArtifacts` 등) 를
|
|
* 호출하지 못하던 결함의 영구 차단 (beta.4 의 `Upgrade_7_0_0_beta_4` 사후 보정과는 별개의
|
|
* 구조적 fix — 향후 모든 코어 업그레이드에서 동일 패턴 회귀를 차단).
|
|
*
|
|
* 매니페스트 형식:
|
|
* ```json
|
|
* {
|
|
* "modules": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
|
* "plugins": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
|
* "templates": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
|
* "lang_packs": [{"identifier": "..."}, ...]
|
|
* }
|
|
* ```
|
|
*
|
|
* 출력:
|
|
* - stdout 에 진행 라인을 그대로 출력 (부모 콘솔이 forwarding)
|
|
* - 종료 직전 `[BUNDLED-RESULT] {json}` 표식으로 최종 카운트 페이로드를 1회 출력 →
|
|
* 부모가 stdout 파싱해 결과 복원.
|
|
*/
|
|
class ExecuteBundledUpdatesCommand extends Command
|
|
{
|
|
public const RESULT_PREFIX = '[BUNDLED-RESULT] ';
|
|
|
|
protected $signature = 'core:execute-bundled-updates
|
|
{--manifest= : 업데이트 매니페스트 JSON 파일 경로 (필수)}
|
|
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}
|
|
{--rebuild-search-index : 일괄 업데이트 후 검색 인덱스 재생성 (매니페스트의 선택보다 우선)}';
|
|
|
|
protected $description = '번들 확장 일괄 업데이트를 실행합니다 (CoreUpdateCommand 내부용 — fresh PHP 프로세스에서 호출)';
|
|
|
|
public function handle(): int
|
|
{
|
|
$manifestPath = (string) $this->option('manifest');
|
|
if ($manifestPath === '' || ! File::isFile($manifestPath)) {
|
|
$this->error('--manifest 옵션이 필수이며 존재하는 파일이어야 합니다.');
|
|
|
|
return self::INVALID;
|
|
}
|
|
|
|
// spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록 — 자세한 배경은
|
|
// ExecuteUpgradeStepsCommand::handle 의 동일 호출 주석 참조.
|
|
// (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
|
|
// output buffer 를 덮어쓰는 Laravel 동작 회피)
|
|
try {
|
|
app(ExtensionManager::class)->updateComposerAutoload();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
$manifest = json_decode(File::get($manifestPath), true);
|
|
if (! is_array($manifest)) {
|
|
$this->error('매니페스트 JSON 파싱 실패: '.$manifestPath);
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
// 부모(core:update)가 운영자 선택을 매니페스트에 실어 보낸다 — 자식이 임의로 정하지 않는다
|
|
$rebuildSearchIndex = (bool) $this->option('rebuild-search-index') || (bool) ($manifest['rebuild_search_index'] ?? false);
|
|
|
|
$modules = $manifest['modules'] ?? [];
|
|
$plugins = $manifest['plugins'] ?? [];
|
|
$templates = $manifest['templates'] ?? [];
|
|
$langPacks = $manifest['lang_packs'] ?? [];
|
|
|
|
$moduleManager = app(ModuleManager::class);
|
|
$pluginManager = app(PluginManager::class);
|
|
$templateManager = app(TemplateManager::class);
|
|
$langPackService = app(LanguagePackService::class);
|
|
|
|
$success = 0;
|
|
$failed = 0;
|
|
|
|
$this->info('── 번들 일괄 업데이트 실행 (spawn child) ──');
|
|
|
|
foreach ($modules as $entry) {
|
|
$id = (string) ($entry['identifier'] ?? '');
|
|
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
|
if ($id === '') {
|
|
continue;
|
|
}
|
|
$this->line("→ [모듈] {$id} ({$strategy})");
|
|
try {
|
|
$moduleManager->updateModule($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
|
$success++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" 실패: {$e->getMessage()}");
|
|
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'module', 'id' => $id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
foreach ($plugins as $entry) {
|
|
$id = (string) ($entry['identifier'] ?? '');
|
|
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
|
if ($id === '') {
|
|
continue;
|
|
}
|
|
$this->line("→ [플러그인] {$id} ({$strategy})");
|
|
try {
|
|
$pluginManager->updatePlugin($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
|
$success++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" 실패: {$e->getMessage()}");
|
|
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'plugin', 'id' => $id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
foreach ($templates as $entry) {
|
|
$id = (string) ($entry['identifier'] ?? '');
|
|
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
|
if ($id === '') {
|
|
continue;
|
|
}
|
|
$this->line("→ [템플릿] {$id} ({$strategy})");
|
|
try {
|
|
$templateManager->updateTemplate($id, true, null, $strategy, 'bundled');
|
|
$success++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" 실패: {$e->getMessage()}");
|
|
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'template', 'id' => $id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
foreach ($langPacks as $entry) {
|
|
$id = (string) ($entry['identifier'] ?? '');
|
|
if ($id === '') {
|
|
continue;
|
|
}
|
|
$this->line("→ [언어팩] {$id}");
|
|
try {
|
|
$pack = $langPackService->findByIdentifier($id);
|
|
if (! $pack) {
|
|
throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
|
|
}
|
|
$langPackService->performUpdate($pack, true);
|
|
$success++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" 실패: {$e->getMessage()}");
|
|
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
|
|
|
|
// 검색 인덱스 재생성 — 운영자가 부모 단계에서 선택했을 때만 수행 (인덱스 잠금·재색인 비용)
|
|
if ($rebuildSearchIndex) {
|
|
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
|
|
$this->newLine();
|
|
$this->info('검색 인덱스: '.$report->summary());
|
|
|
|
foreach ($report->failed as $identifier => $message) {
|
|
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
|
|
}
|
|
}
|
|
|
|
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
|
|
$this->line(self::RESULT_PREFIX.json_encode([
|
|
'success' => $success,
|
|
'failed' => $failed,
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
}
|