retentionDays(); if ($days < 1) { $this->error('Срок хранения должен быть больше 0 дней.'); return self::FAILURE; } $cutoff = now()->subDays($days); $dryRun = (bool) $this->option('dry-run'); $deletedRows = 0; $deletedFiles = 0; [$fileRows, $storedFiles] = $this->cleanupGeneratedFileRecords($cutoff, $dryRun, $days); $deletedRows += $fileRows; $deletedFiles += $storedFiles; $deletedFiles += $this->cleanupOrphanGeneratedArchives($cutoff, $dryRun); $deletedFiles += $this->cleanupImportFiles($dryRun); if ($dryRun) { $this->info('Проверка завершена. Данные не изменены.'); return self::SUCCESS; } $this->info("Очистка завершена. Удалено записей: {$deletedRows}. Удалено файлов: {$deletedFiles}."); return self::SUCCESS; } /** * @return array{0:int,1:int} */ private function cleanupGeneratedFileRecords($cutoff, bool $dryRun, int $days): array { $deletedRows = 0; $deletedFiles = 0; $query = File::query() ->where('is_generated', true) ->where('created_at', '<', $cutoff) ->orderBy('id'); $total = (clone $query)->count(); if ($total === 0) { $this->info("Сгенерированных документов старше {$days} дн. в БД нет."); return [0, 0]; } $this->info(($dryRun ? 'Проверка' : 'Очистка') . ": найдено {$total} записей сгенерированных файлов старше " . $cutoff->toDateTimeString()); $query->chunkById(100, function ($files) use ($dryRun, &$deletedFiles, &$deletedRows): void { foreach ($files as $file) { $paths = $this->storagePaths($file); $this->line(($dryRun ? 'Будет удалён' : 'Удаляется') . ": #{$file->id} {$file->original_name}"); if ($dryRun) { foreach ($paths as $path) { $this->line(" {$path}"); } continue; } DB::table('order_document')->where('file_id', $file->id)->delete(); DB::table('reclamation_document')->where('file_id', $file->id)->delete(); DB::table('chat_message_file')->where('file_id', $file->id)->delete(); foreach ($paths as $path) { if (Storage::disk('public')->exists($path)) { Storage::disk('public')->delete($path); $deletedFiles++; } } $file->delete(); $deletedRows++; } }); return [$deletedRows, $deletedFiles]; } private function retentionDays(): int { $option = $this->option('days'); if (is_numeric($option)) { return (int) $option; } return (int) config('documents.generated_retention_days', 14); } private function importRetentionDays(): int { $option = $this->option('import-days'); if (is_numeric($option)) { return (int) $option; } return (int) config('documents.import_retention_days', 14); } /** * @return list */ private function storagePaths(File $file): array { $paths = []; foreach ([$file->path, $this->pathFromLink((string) $file->link)] as $path) { if (!is_string($path) || $path === '') { continue; } $paths[] = $this->normalizePublicPath($path); if (str_contains($path, '/tmp/')) { $paths[] = $this->normalizePublicPath(Str::replace('/tmp/', '/', $path)); } } return array_values(array_unique(array_filter($paths))); } private function pathFromLink(string $link): ?string { $path = parse_url($link, PHP_URL_PATH); if (!is_string($path) || $path === '') { return null; } $marker = '/storage/'; $position = strpos($path, $marker); if ($position === false) { return null; } return substr($path, $position + strlen($marker)); } private function normalizePublicPath(string $path): string { $path = str_replace('\\', '/', $path); $marker = 'app/public/'; $position = strpos($path, $marker); if ($position !== false) { $path = substr($path, $position + strlen($marker)); } return ltrim($path, '/'); } private function cleanupOrphanGeneratedArchives($cutoff, bool $dryRun): int { $knownPaths = $this->knownGeneratedPublicPaths(); $deleted = 0; $candidates = array_values(array_filter( Storage::disk('public')->allFiles(), fn (string $path): bool => $this->isGeneratedArchivePath($path) )); foreach ($candidates as $path) { if (isset($knownPaths[$path])) { continue; } if (Storage::disk('public')->lastModified($path) >= $cutoff->timestamp) { continue; } $this->line(($dryRun ? 'Будет удалён сиротский архив' : 'Удаляется сиротский архив') . ": {$path}"); if (!$dryRun) { Storage::disk('public')->delete($path); } $deleted++; } if ($deleted === 0) { $this->info('Сиротских сгенерированных архивов старше срока хранения нет.'); } return $deleted; } /** * @return array */ private function knownGeneratedPublicPaths(): array { $paths = []; File::query() ->where('is_generated', true) ->select(['id', 'path', 'link']) ->chunkById(500, function ($files) use (&$paths): void { foreach ($files as $file) { foreach ($this->storagePaths($file) as $path) { $paths[$path] = true; } } }); return $paths; } private function isGeneratedArchivePath(string $path): bool { if (!Str::endsWith(Str::lower($path), '.zip')) { return false; } return (bool) preg_match('#^(orders/\d+/[^/]+|reclamations/\d+/[^/]+|files/[^/]+/[^/]+|export/(?:orders|order|schedule)/[^/]+)\.zip$#u', $path); } private function cleanupImportFiles(bool $dryRun): int { $days = $this->importRetentionDays(); if ($days < 1) { $this->warn('Срок хранения импортов меньше 1 дня, очистка импортов пропущена.'); return 0; } $cutoff = now()->subDays($days); $knownPaths = []; $deleted = 0; Import::query() ->whereNotNull('filename') ->select(['id', 'filename', 'created_at']) ->orderBy('id') ->chunkById(500, function ($imports) use (&$knownPaths, &$deleted, $cutoff, $dryRun): void { foreach ($imports as $import) { $path = trim((string) $import->filename, '/'); if ($path === '') { continue; } $knownPaths[$path] = true; if ($import->created_at >= $cutoff) { continue; } if (!Storage::disk('upload')->exists($path)) { continue; } $this->line(($dryRun ? 'Будет удалён файл импорта' : 'Удаляется файл импорта') . ": {$path}"); if (!$dryRun) { Storage::disk('upload')->delete($path); } $deleted++; } }); foreach (Storage::disk('upload')->allFiles() as $path) { if (isset($knownPaths[$path])) { continue; } if (!$this->isImportUploadPath($path)) { continue; } if (Storage::disk('upload')->lastModified($path) >= $cutoff->timestamp) { continue; } $this->line(($dryRun ? 'Будет удалён сиротский файл импорта' : 'Удаляется сиротский файл импорта') . ": {$path}"); if (!$dryRun) { Storage::disk('upload')->delete($path); } $deleted++; } if ($deleted === 0) { $this->info("Файлов импорта старше {$days} дн. нет."); } return $deleted; } private function isImportUploadPath(string $path): bool { return (bool) preg_match('#^(?:[A-Za-z0-9]{2}/[^/]+|import/(?:areas|districts|year_data)/[^/]+)$#', $path); } }