Просмотр исходного кода

delete old generated files and import files by scheduler

Alexander Musikhin 12 часов назад
Родитель
Сommit
d2c1274b1d

+ 4 - 0
.env.example

@@ -35,6 +35,10 @@ DB_BACKUP_ENABLED=false
 DB_BACKUP_TIME=02:00
 DB_BACKUP_KEEP=7
 DB_BACKUP_CONNECTION=mysql
+GENERATED_DOCUMENTS_RETENTION_DAYS=14
+IMPORT_FILES_RETENTION_DAYS=14
+GENERATED_DOCUMENTS_CLEANUP_ENABLED=true
+GENERATED_DOCUMENTS_CLEANUP_TIME=03:30
 
 BROADCAST_DRIVER=redis
 CACHE_DRIVER=redis

+ 316 - 0
app/Console/Commands/CleanupGeneratedDocuments.php

@@ -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);
+    }
+}

+ 5 - 0
app/Models/File.php

@@ -16,12 +16,17 @@ class File extends Model
         'mime_type',
         'path',
         'link',
+        'is_generated',
     ];
 
     protected $appends = [
         'name',
     ];
 
+    protected $casts = [
+        'is_generated' => 'boolean',
+    ];
+
     public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);

+ 21 - 6
app/Services/FileService.php

@@ -114,7 +114,7 @@ class FileService
      * @return File
      * @throws Exception
      */
-    public function createZipArchive(string $path, string $archiveName, int $userId = null): File
+    public function createZipArchive(string $path, string $archiveName, ?int $userId = null): File
     {
 
         if (!($tempFile = tempnam(sys_get_temp_dir(), Str::random()))) {
@@ -139,19 +139,34 @@ class FileService
         }
         $zip->close();
 
+        $contents = file_get_contents($tempFile);
+        $archivePath = $this->archivePath($path, $archiveName);
+        Storage::disk('public')->put($archivePath, $contents);
+        unlink($tempFile);
+
         $fileModel = File::query()->updateOrCreate([
-            'link' => url('/storage/') . '/' . Str::replace('/tmp', '', $path) . '/' .$archiveName,
-            'path' => $path . '/' .$archiveName,
+            'link' => url('/storage/') . '/' . $archivePath,
+        ], [
+            'path' => $archivePath,
             'user_id' => $userId ?? auth()->user()->id,
             'original_name' => $archiveName,
             'mime_type' => 'application/zip',
+            'is_generated' => true,
         ]);
-        $contents = file_get_contents($tempFile);
-        Storage::disk('public')->put($path . '/../' .$archiveName, $contents);
-        unlink($tempFile);
 
         return $fileModel;
     }
 
+    private function archivePath(string $path, string $archiveName): string
+    {
+        $normalizedPath = trim($path, '/');
+
+        if (Str::endsWith($normalizedPath, '/tmp')) {
+            $normalizedPath = (string) Str::beforeLast($normalizedPath, '/tmp');
+        }
+
+        return $normalizedPath . '/' . $archiveName;
+    }
+
 
 }

+ 8 - 0
config/documents.php

@@ -0,0 +1,8 @@
+<?php
+
+return [
+    'generated_retention_days' => (int) env('GENERATED_DOCUMENTS_RETENTION_DAYS', 14),
+    'import_retention_days' => (int) env('IMPORT_FILES_RETENTION_DAYS', env('GENERATED_DOCUMENTS_RETENTION_DAYS', 14)),
+    'cleanup_enabled' => filter_var(env('GENERATED_DOCUMENTS_CLEANUP_ENABLED', true), FILTER_VALIDATE_BOOL),
+    'cleanup_time' => env('GENERATED_DOCUMENTS_CLEANUP_TIME', '03:30'),
+];

+ 41 - 0
database/migrations/2026_05_15_000001_add_is_generated_to_files_table.php

@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('files', function (Blueprint $table) {
+            $table->boolean('is_generated')->default(false)->after('link');
+            $table->index(['is_generated', 'created_at']);
+        });
+
+        DB::table('files')
+            ->where(function ($query) {
+                $query
+                    ->where('path', 'like', '%/tmp/%.zip')
+                    ->orWhere('original_name', 'like', 'Монтаж %.zip')
+                    ->orWhere('original_name', 'like', 'Сдача %.zip')
+                    ->orWhere('original_name', 'like', 'Рекламация %.zip')
+                    ->orWhere('original_name', 'like', 'Пакет документов на оплату - %.zip');
+            })
+            ->where(function ($query) {
+                $query
+                    ->where('mime_type', 'application/zip')
+                    ->orWhere('mime_type', 'like', '%zip%');
+            })
+            ->update(['is_generated' => true]);
+    }
+
+    public function down(): void
+    {
+        Schema::table('files', function (Blueprint $table) {
+            $table->dropIndex(['is_generated', 'created_at']);
+            $table->dropColumn('is_generated');
+        });
+    }
+};

+ 5 - 0
routes/console.php

@@ -15,3 +15,8 @@ Schedule::command('db:backup:rotate', [
     ->dailyAt((string) env('DB_BACKUP_TIME', '02:00'))
     ->withoutOverlapping()
     ->when(static fn (): bool => filter_var(env('DB_BACKUP_ENABLED', false), FILTER_VALIDATE_BOOL));
+
+Schedule::command('documents:cleanup-generated')
+    ->dailyAt((string) config('documents.cleanup_time', '03:30'))
+    ->withoutOverlapping()
+    ->when(static fn (): bool => (bool) config('documents.cleanup_enabled', true));

+ 145 - 0
tests/Feature/CleanupGeneratedDocumentsCommandTest.php

@@ -0,0 +1,145 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\File;
+use App\Models\Import;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class CleanupGeneratedDocumentsCommandTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_cleanup_generated_documents_removes_only_old_generated_files(): void
+    {
+        Storage::fake('public');
+
+        $user = User::factory()->create();
+
+        $generated = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'Монтаж тест.zip',
+            'mime_type' => 'application/zip',
+            'path' => 'orders/10/Монтаж тест.zip',
+            'link' => url('/storage/orders/10/Монтаж тест.zip'),
+            'is_generated' => true,
+            'created_at' => now()->subDays(15),
+            'updated_at' => now()->subDays(15),
+        ]);
+
+        $uploaded = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'manual.zip',
+            'mime_type' => 'application/zip',
+            'path' => 'orders/10/document/manual.zip',
+            'link' => url('/storage/orders/10/document/manual.zip'),
+            'is_generated' => false,
+            'created_at' => now()->subDays(30),
+            'updated_at' => now()->subDays(30),
+        ]);
+
+        $freshGenerated = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'Сдача свежий.zip',
+            'mime_type' => 'application/zip',
+            'path' => 'orders/11/Сдача свежий.zip',
+            'link' => url('/storage/orders/11/Сдача свежий.zip'),
+            'is_generated' => true,
+            'created_at' => now()->subDays(2),
+            'updated_at' => now()->subDays(2),
+        ]);
+
+        Storage::disk('public')->put($generated->path, 'generated');
+        Storage::disk('public')->put($uploaded->path, 'uploaded');
+        Storage::disk('public')->put($freshGenerated->path, 'fresh');
+
+        $exitCode = Artisan::call('documents:cleanup-generated', ['--days' => 14]);
+
+        $this->assertSame(0, $exitCode);
+        $this->assertDatabaseMissing('files', ['id' => $generated->id]);
+        $this->assertDatabaseHas('files', ['id' => $uploaded->id]);
+        $this->assertDatabaseHas('files', ['id' => $freshGenerated->id]);
+        Storage::disk('public')->assertMissing($generated->path);
+        Storage::disk('public')->assertExists($uploaded->path);
+        Storage::disk('public')->assertExists($freshGenerated->path);
+    }
+
+    public function test_cleanup_generated_documents_removes_legacy_tmp_archive_path(): void
+    {
+        Storage::fake('public');
+
+        $user = User::factory()->create();
+        $file = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'Рекламация тест.zip',
+            'mime_type' => 'application/zip',
+            'path' => 'reclamations/20/tmp/Рекламация тест.zip',
+            'link' => url('/storage/reclamations/20/Рекламация тест.zip'),
+            'is_generated' => true,
+            'created_at' => now()->subDays(15),
+            'updated_at' => now()->subDays(15),
+        ]);
+
+        Storage::disk('public')->put('reclamations/20/Рекламация тест.zip', 'legacy archive');
+
+        $exitCode = Artisan::call('documents:cleanup-generated', ['--days' => 14]);
+
+        $this->assertSame(0, $exitCode);
+        $this->assertDatabaseMissing('files', ['id' => $file->id]);
+        Storage::disk('public')->assertMissing('reclamations/20/Рекламация тест.zip');
+    }
+
+    public function test_cleanup_generated_documents_removes_old_orphan_generated_archive(): void
+    {
+        Storage::fake('public');
+        Storage::fake('upload');
+
+        Storage::disk('public')->put('orders/15/Монтаж сирота.zip', 'orphan archive');
+        touch(Storage::disk('public')->path('orders/15/Монтаж сирота.zip'), now()->subDays(20)->timestamp);
+
+        Storage::disk('public')->put('orders/15/document/manual.zip', 'manual archive');
+        touch(Storage::disk('public')->path('orders/15/document/manual.zip'), now()->subDays(20)->timestamp);
+
+        $exitCode = Artisan::call('documents:cleanup-generated', ['--days' => 14]);
+
+        $this->assertSame(0, $exitCode);
+        Storage::disk('public')->assertMissing('orders/15/Монтаж сирота.zip');
+        Storage::disk('public')->assertExists('orders/15/document/manual.zip');
+    }
+
+    public function test_cleanup_generated_documents_removes_old_import_files_and_orphans(): void
+    {
+        Storage::fake('public');
+        Storage::fake('upload');
+
+        $oldImport = Import::factory()->create([
+            'filename' => 'ab/old-import.xlsx',
+            'created_at' => now()->subDays(20),
+            'updated_at' => now()->subDays(20),
+        ]);
+        $freshImport = Import::factory()->create([
+            'filename' => 'cd/fresh-import.xlsx',
+            'created_at' => now()->subDays(2),
+            'updated_at' => now()->subDays(2),
+        ]);
+
+        Storage::disk('upload')->put($oldImport->filename, 'old import');
+        Storage::disk('upload')->put($freshImport->filename, 'fresh import');
+        Storage::disk('upload')->put('ef/orphan-import.xlsx', 'orphan import');
+        touch(Storage::disk('upload')->path('ef/orphan-import.xlsx'), now()->subDays(20)->timestamp);
+
+        $exitCode = Artisan::call('documents:cleanup-generated', [
+            '--days' => 14,
+            '--import-days' => 14,
+        ]);
+
+        $this->assertSame(0, $exitCode);
+        Storage::disk('upload')->assertMissing($oldImport->filename);
+        Storage::disk('upload')->assertExists($freshImport->filename);
+        Storage::disk('upload')->assertMissing('ef/orphan-import.xlsx');
+    }
+}

+ 18 - 0
tests/Unit/Services/FileServiceTest.php

@@ -78,4 +78,22 @@ class FileServiceTest extends TestCase
         $this->assertEquals('tests/uploads/file.pdf', $saved->path);
         Storage::disk('public')->assertExists($saved->path);
     }
+
+    public function test_create_zip_archive_marks_file_as_generated_and_stores_archive_outside_tmp(): void
+    {
+        $user = User::factory()->create();
+        $path = 'tests/archive/tmp';
+
+        Storage::disk('public')->makeDirectory($path);
+        Storage::disk('public')->put($path . '/file.txt', 'test content');
+
+        $archive = app(FileService::class)->createZipArchive($path, 'archive.zip', $user->id);
+
+        $this->assertTrue($archive->is_generated);
+        $this->assertEquals('tests/archive/archive.zip', $archive->path);
+        $this->assertEquals(url('/storage/tests/archive/archive.zip'), $archive->link);
+        Storage::disk('public')->assertExists('tests/archive/archive.zip');
+
+        Storage::disk('public')->deleteDirectory('tests/archive');
+    }
 }