|
|
@@ -0,0 +1,316 @@
|
|
|
+<?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);
|
|
|
+ }
|
|
|
+}
|