From 4c73ba0a89bb1ea0857f4e9dda151d6146085126 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 20 Jul 2026 10:50:31 +0000 Subject: [PATCH] =?UTF-8?q?legacy:=20privatize-media=20=C3=BCberspringt=20?= =?UTF-8?q?unmigrierte=20Legacy-Bildzeilen,=20--prune-missing=20l=C3=B6sch?= =?UTF-8?q?t=20sie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Legacy-Import legt pro Alt-Bild eine Zeile mit rohem Legacy-Dateinamen als path an; legacy:migrate-media biegt sie auf die echte Datei um. Zeilen, deren Datei im Alt-System verloren ging (Live-Befund: ~22k bei Presseecho), blieben als disk=public-Leichen liegen und ließen den Privatizer mit RuntimeException abbrechen. Jetzt: Skip + Warnhinweis statt Abbruch; --prune-missing löscht solche Zeilen endgültig, wenn ihre Datei auf keiner Disk existiert. Der Integritäts-Assert für migrierte Pfade bleibt. Co-Authored-By: Claude Fable 5 --- .../Commands/PrivatizePressReleaseMedia.php | 19 ++++++- .../PressReleaseMediaPrivatizer.php | 48 +++++++++++++++-- .../PressReleaseMediaPrivatizerTest.php | 54 +++++++++++++++++++ 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/app/Console/Commands/PrivatizePressReleaseMedia.php b/app/Console/Commands/PrivatizePressReleaseMedia.php index d0e0bfa5..533c0d47 100644 --- a/app/Console/Commands/PrivatizePressReleaseMedia.php +++ b/app/Console/Commands/PrivatizePressReleaseMedia.php @@ -12,7 +12,8 @@ class PrivatizePressReleaseMedia extends Command * * @var string */ - protected $signature = 'press-releases:privatize-media'; + protected $signature = 'press-releases:privatize-media + {--prune-missing : Unmigrierte Legacy-Bildzeilen ohne Datei endgültig löschen}'; /** * The console command description. @@ -26,7 +27,7 @@ class PrivatizePressReleaseMedia extends Command */ public function handle(PressReleaseMediaPrivatizer $privatizer): int { - $counts = $privatizer->privatizeAll(); + $counts = $privatizer->privatizeAll((bool) $this->option('prune-missing')); $this->info(sprintf( '%d Bilder und %d Anhänge wurden auf den privaten Datenträger migriert.', @@ -34,6 +35,20 @@ class PrivatizePressReleaseMedia extends Command $counts['attachments'], )); + if ($counts['legacy_pruned'] > 0) { + $this->info(sprintf( + '%d unmigrierte Legacy-Bildzeilen ohne Datei wurden gelöscht.', + $counts['legacy_pruned'], + )); + } + + if ($counts['legacy_skipped'] > 0) { + $this->warn(sprintf( + '%d unmigrierte Legacy-Bildzeilen übersprungen — erst `legacy:migrate-media` ausführen, danach Verbliebene ohne Datei mit `--prune-missing` löschen.', + $counts['legacy_skipped'], + )); + } + return self::SUCCESS; } } diff --git a/app/Services/PressRelease/PressReleaseMediaPrivatizer.php b/app/Services/PressRelease/PressReleaseMediaPrivatizer.php index ee7f58f6..87aa5b0e 100644 --- a/app/Services/PressRelease/PressReleaseMediaPrivatizer.php +++ b/app/Services/PressRelease/PressReleaseMediaPrivatizer.php @@ -5,26 +5,43 @@ namespace App\Services\PressRelease; use App\Models\PressReleaseAttachment; use App\Models\PressReleaseImage; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use RuntimeException; class PressReleaseMediaPrivatizer { /** - * @return array{images:int,attachments:int} + * Der Legacy-Import legt Bildzeilen mit dem rohen Alt-Dateinamen als `path` + * an; erst `legacy:migrate-media` biegt sie auf `press-releases/…` um. + * Zeilen, die danach noch den Alt-Pfad tragen, haben keine Quelldatei mehr — + * sie werden hier übersprungen bzw. mit `$pruneMissing` endgültig gelöscht. + * + * @return array{images:int,attachments:int,legacy_skipped:int,legacy_pruned:int} */ - public function privatizeAll(): array + public function privatizeAll(bool $pruneMissing = false): array { - $counts = ['images' => 0, 'attachments' => 0]; + $counts = ['images' => 0, 'attachments' => 0, 'legacy_skipped' => 0, 'legacy_pruned' => 0]; PressReleaseImage::query() ->withTrashed() ->where('disk', 'public') - ->chunkById(100, function ($images) use (&$counts): void { + ->chunkById(100, function ($images) use (&$counts, $pruneMissing): void { foreach ($images as $image) { $paths = collect([$image->path, ...array_values($image->variants ?? [])]) ->filter(fn ($path): bool => is_string($path) && filled($path)) ->unique(); + if ($this->isUnmigratedLegacyImage($image)) { + if ($pruneMissing && ! $this->anyPathExists($paths->all())) { + $image->forceDelete(); + $counts['legacy_pruned']++; + } else { + $counts['legacy_skipped']++; + } + + continue; + } + $this->assertPathsAvailable($paths->all()); foreach ($paths as $path) { @@ -51,6 +68,29 @@ class PressReleaseMediaPrivatizer return $counts; } + private function isUnmigratedLegacyImage(PressReleaseImage $image): bool + { + return $image->legacy_id !== null + && ! Str::startsWith((string) $image->path, 'press-releases/'); + } + + /** + * @param list $paths + */ + private function anyPathExists(array $paths): bool + { + $public = Storage::disk('public'); + $private = Storage::disk('local'); + + foreach ($paths as $path) { + if ($public->exists($path) || $private->exists($path)) { + return true; + } + } + + return false; + } + /** * @param list $paths */ diff --git a/tests/Feature/PressReleaseMediaPrivatizerTest.php b/tests/Feature/PressReleaseMediaPrivatizerTest.php index 191afb8c..13d8dd52 100644 --- a/tests/Feature/PressReleaseMediaPrivatizerTest.php +++ b/tests/Feature/PressReleaseMediaPrivatizerTest.php @@ -1,6 +1,7 @@ and($attachment->fresh()->disk)->toBe('local'); }); +test('unmigrated legacy image rows are skipped instead of aborting the run', function () { + Storage::fake('public'); + Storage::fake('local'); + $pressRelease = PressRelease::factory()->create(); + $legacyImage = $pressRelease->images()->create([ + 'disk' => 'public', + 'path' => 'b0cde51ff6799356bfaa8ec4c80e8921.jpg', + 'legacy_portal' => 'presseecho', + 'legacy_id' => 33683, + ]); + $migratedImage = $pressRelease->images()->create([ + 'disk' => 'public', + 'path' => "press-releases/{$pressRelease->id}/images/cover.jpg", + ]); + Storage::disk('public')->put($migratedImage->path, 'content'); + + $this->artisan('press-releases:privatize-media') + ->expectsOutputToContain('1 unmigrierte Legacy-Bildzeilen übersprungen') + ->assertSuccessful(); + + expect($legacyImage->fresh())->not->toBeNull() + ->and($legacyImage->fresh()->disk)->toBe('public') + ->and($migratedImage->fresh()->disk)->toBe('local'); +}); + +test('prune-missing deletes unmigrated legacy image rows only when no file exists', function () { + Storage::fake('public'); + Storage::fake('local'); + $pressRelease = PressRelease::factory()->create(); + $lostImage = $pressRelease->images()->create([ + 'disk' => 'public', + 'path' => 'b0cde51ff6799356bfaa8ec4c80e8921.jpg', + 'legacy_portal' => 'presseecho', + 'legacy_id' => 33683, + ]); + $recoverableImage = $pressRelease->images()->create([ + 'disk' => 'public', + 'path' => 'noch-vorhanden.jpg', + 'legacy_portal' => 'presseecho', + 'legacy_id' => 33684, + ]); + Storage::disk('public')->put($recoverableImage->path, 'content'); + + $this->artisan('press-releases:privatize-media', ['--prune-missing' => true]) + ->expectsOutputToContain('1 unmigrierte Legacy-Bildzeilen ohne Datei wurden gelöscht.') + ->expectsOutputToContain('1 unmigrierte Legacy-Bildzeilen übersprungen') + ->assertSuccessful(); + + expect(PressReleaseImage::withTrashed()->find($lostImage->id))->toBeNull() + ->and($recoverableImage->fresh())->not->toBeNull() + ->and($recoverableImage->fresh()->disk)->toBe('public'); +}); + test('a missing source file never flips its database record to the private disk', function () { Storage::fake('public'); Storage::fake('local');