| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- <?php
- declare(strict_types=1);
- namespace App\Console\Commands;
- use App\Models\File;
- use App\Models\Import;
- use Illuminate\Console\Command;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Storage;
- use Illuminate\Support\Str;
- class CleanupGeneratedDocuments extends Command
- {
- protected $signature = 'documents:cleanup-generated
- {--days= : Удалять сгенерированные документы старше N дней}
- {--import-days= : Удалять загруженные файлы импорта старше N дней}
- {--dry-run : Показать, что будет удалено, без удаления}';
- protected $description = 'Удаляет старые сгенерированные документы, не затрагивая пользовательские загрузки';
- public function handle(): int
- {
- $days = $this->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<string>
- */
- 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<string,true>
- */
- 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);
- }
- }
|