.log` 기록 * * @permanent — 7.0.0-beta.5 이전 사용자의 잔존 결함 회복을 위해 영구 보존. 향후 운영자가 * 과거 롤백 흔적을 점검할 수 있도록 유지하며, deprecation 예정 없음. */ class RollbackStaleFilesCommand extends Command { use HasUnifiedConfirm; protected $signature = 'hotfix:rollback-stale-files {--backup= : 특정 백업 디렉토리 경로 지정 (미지정 시 가장 최근 백업 자동 선택)} {--prune : 진단만이 아닌 실제 정리 수행 (확인 프롬프트 동반)}'; protected $description = '[hotfix/beta.6 신설] 코어 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리합니다.'; public function handle(): int { $backupPath = $this->resolveBackupPath(); if ($backupPath === null) { $this->info('사용 가능한 백업이 없습니다 (`storage/app/core_backups/` 비어 있음).'); return Command::SUCCESS; } $this->info("백업 디렉토리: {$backupPath}"); $manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST; $manifestAvailable = File::exists($manifestPath); if (! $manifestAvailable) { $this->warn('⚠ _new_files_manifest.json 부재 — 보수적 진단 모드로 진행합니다.'); $this->line(' manifest 가 없으면 신 파일 vs 사용자 추가 파일을 정확히 구분할 수 없습니다.'); $this->line(' beta.5 이전 사용자: beta.6 업그레이드 스텝이 manifest 를 사후 작성하므로'); $this->line(' 먼저 `php artisan core:execute-upgrade-steps --from=<버전> --to=7.0.0-beta.6 --force`'); $this->line(' 를 실행한 뒤 본 커맨드를 재실행하는 것을 권장합니다.'); $this->newLine(); } $candidates = $this->collectCandidates($backupPath, $manifestAvailable); if ($candidates['files'] === [] && $candidates['dirs'] === []) { $this->info('잔존 후보 없음 — 활성 디렉토리가 정상 상태입니다.'); return Command::SUCCESS; } $this->newLine(); $this->info(sprintf( '잔존 후보: 파일 %d개, 디렉토리 %d개', count($candidates['files']), count($candidates['dirs']), )); foreach (array_slice($candidates['files'], 0, 20) as $f) { $this->line(" · 파일: {$f}"); } if (count($candidates['files']) > 20) { $this->line(sprintf(' ... (총 %d건, 상위 20건만 표시)', count($candidates['files']))); } foreach (array_slice($candidates['dirs'], 0, 10) as $d) { $this->line(" · 디렉토리: {$d}"); } if (count($candidates['dirs']) > 10) { $this->line(sprintf(' ... (총 %d건, 상위 10건만 표시)', count($candidates['dirs']))); } if (! $this->option('prune')) { $this->newLine(); $this->info('진단 모드: 실제 삭제하지 않았습니다. 정리하려면 `--prune` 옵션을 추가하세요.'); return Command::SUCCESS; } if (! $manifestAvailable) { $this->newLine(); $this->error('manifest 부재 상태에서는 자동 prune 을 수행하지 않습니다 (사용자 추가 파일 손실 위험).'); $this->line('beta.6 업그레이드 스텝을 먼저 실행하여 manifest 를 사후 작성한 뒤 재시도하세요.'); return Command::FAILURE; } $this->newLine(); if (! $this->unifiedConfirm('위 후보를 활성 디렉토리에서 정리하시겠습니까?', false)) { $this->info('취소되었습니다.'); return Command::SUCCESS; } $protectedPaths = (array) config('app.update.protected_paths', []); $result = CoreBackupHelper::pruneNewFiles($backupPath, base_path(), $protectedPaths); $this->newLine(); $this->info(sprintf( '정리 완료: 파일 %d개, 디렉토리 %d개 제거. 보호 %d건, symlink skip %d건, 실패 %d건.', $result['removed_files'], $result['removed_dirs'], $result['protected_count'], $result['symlink_skipped'], $result['failed_count'], )); $logPath = storage_path(sprintf( 'logs/hotfix_rollback_stale_files_%s.log', date('Ymd_His'), )); @file_put_contents($logPath, $this->buildLogEntry($backupPath, $result, $candidates)); $this->line("로그: {$logPath}"); Log::info('hotfix:rollback-stale-files 정리 완료', [ 'backup_path' => $backupPath, 'result' => $result, ]); return Command::SUCCESS; } /** * `--backup` 옵션 또는 가장 최근 백업 디렉토리 경로를 반환. */ private function resolveBackupPath(): ?string { $explicit = $this->option('backup'); if ($explicit !== null && $explicit !== '') { $real = realpath($explicit); if ($real === false || ! is_dir($real)) { $this->error("지정된 백업 디렉토리가 존재하지 않습니다: {$explicit}"); return null; } return $real; } $backups = CoreBackupHelper::listBackups(); if ($backups === []) { return null; } // listBackups 는 정렬 보장이 약하므로 mtime 기준으로 다시 정렬 usort($backups, fn ($a, $b) => filemtime($b['path']) <=> filemtime($a['path'])); return $backups[0]['path'] ?? null; } /** * 잔존 후보 식별 — manifest 가 있으면 그 기반, 부재 시 빈 배열 반환 (운영자 수동 검토 안내). * * @return array{files:array, dirs:array} */ private function collectCandidates(string $backupPath, bool $manifestAvailable): array { if (! $manifestAvailable) { return ['files' => [], 'dirs' => []]; } $manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST; $raw = @file_get_contents($manifestPath); if ($raw === false) { return ['files' => [], 'dirs' => []]; } $manifest = json_decode($raw, true); if (! is_array($manifest)) { return ['files' => [], 'dirs' => []]; } $files = []; foreach ((array) ($manifest['new_files'] ?? []) as $rel) { if (! is_string($rel) || $rel === '') { continue; } $absolute = base_path($rel); if (file_exists($absolute) || is_link($absolute)) { $files[] = $rel; } } $dirs = []; foreach ((array) ($manifest['new_dirs'] ?? []) as $rel) { if (! is_string($rel) || $rel === '') { continue; } $absolute = base_path($rel); if (is_dir($absolute) && ! is_link($absolute)) { $dirs[] = $rel; } } return ['files' => $files, 'dirs' => $dirs]; } /** * 진단/정리 결과를 로그 텍스트로 빌드. */ private function buildLogEntry(string $backupPath, array $result, array $candidates): string { $lines = [ '=== hotfix:rollback-stale-files 실행 로그 ===', '날짜: '.date('Y-m-d H:i:s'), "백업 디렉토리: {$backupPath}", '', '결과:', sprintf(' 파일 제거: %d', $result['removed_files']), sprintf(' 디렉토리 제거: %d', $result['removed_dirs']), sprintf(' 보호 경로 skip: %d', $result['protected_count']), sprintf(' symlink skip: %d', $result['symlink_skipped']), sprintf(' 실패: %d', $result['failed_count']), '', '후보 목록 (실행 직전 스냅샷):', ]; foreach ($candidates['files'] as $f) { $lines[] = " · 파일: {$f}"; } foreach ($candidates['dirs'] as $d) { $lines[] = " · 디렉토리: {$d}"; } return implode("\n", $lines)."\n"; } }