archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)
- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue), Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel FAILURE; archive_pending_at nur noch Sammelmarker - O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre - O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder offener Auftrag, nie ein still unvollständiger Nachweis - A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore, archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut - O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab - A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold- Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export - 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock), N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für auslieferbare Bilder), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f0eeef0144
commit
7cd84b2be3
29 changed files with 2918 additions and 412 deletions
|
|
@ -8,20 +8,36 @@ use Illuminate\Console\Command;
|
|||
/**
|
||||
* Beweisarchiv (Block F.3): nach dem Einspielen einer Sicherung alles, was
|
||||
* laut Tombstone-Export oder laut Datenbank bereits gelöscht war, erneut
|
||||
* entfernen. Teil des Restore-Runbooks (dev/betrieb/deploy-runbook.md).
|
||||
* entfernen — samt Kopien in der Anwendung (A-06). Setzt den Orphan-Sweep
|
||||
* aus (A-03); danach `archive:reregister`, dann `archive:sweep-orphans
|
||||
* --resume`. Teil des Restore-Runbooks (dev/betrieb/deploy-runbook.md).
|
||||
*/
|
||||
class ArchiveReconcileAfterRestore extends Command
|
||||
{
|
||||
protected $signature = 'archive:reconcile-after-restore';
|
||||
protected $signature = 'archive:reconcile-after-restore
|
||||
{--accept-unsigned : Unsignierten Tombstone-Export verwenden (nur Exporte vor dem 29.09.2026)}';
|
||||
|
||||
protected $description = 'Entfernt nach einem Restore wiederhergestellte Beweisarchiv-Payloads anhand des Tombstone-Exports und der Datenbank-Tombstones';
|
||||
|
||||
public function handle(EvidenceArchive $archive): int
|
||||
{
|
||||
$result = $archive->reconcileAfterRestore();
|
||||
$result = $archive->reconcileAfterRestore((bool) $this->option('accept-unsigned'));
|
||||
|
||||
$this->line('Tombstone-Export: '.match ($result['export_signature']) {
|
||||
'valid' => 'Signatur gültig.',
|
||||
'unsigned' => 'ohne Signatur — auf Anweisung (--accept-unsigned) verwendet.',
|
||||
'unsigned_refused' => 'ohne Signatur — verworfen. Nur für einen Export von vor dem 29.09.2026 mit --accept-unsigned wiederholen.',
|
||||
'invalid' => 'Signatur ungültig — Export verworfen, nur Datenbank-Tombstones abgeglichen.',
|
||||
default => 'nicht vorhanden — nur Datenbank-Tombstones abgeglichen.',
|
||||
});
|
||||
$this->info(sprintf('Abgleich: %d Einträge erneut als gelöscht markiert, %d Dateien entfernt, %d fehlgeschlagen.', $result['re_tombstoned'], $result['files_removed'], $result['failed']));
|
||||
|
||||
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
if ($result['conflicts'] > 0) {
|
||||
$this->error(sprintf('%d Export-Zeile(n) passen nicht zur Datenbank (gleiche ID, anderer Payload) — nicht angefasst, bitte im Log prüfen.', $result['conflicts']));
|
||||
}
|
||||
|
||||
$this->warn('Orphan-Sweep ausgesetzt: jetzt archive:reregister ausführen, danach archive:sweep-orphans --resume.');
|
||||
|
||||
return $result['failed'] > 0 || $result['conflicts'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
app/Console/Commands/ArchiveReregister.php
Normal file
62
app/Console/Commands/ArchiveReregister.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Beweisarchiv (Umsetzungsaudit 29.09.2026, A-03): nach einem DB-Restore
|
||||
* die Zeilen für Payloads ohne Zeile aus deren manifest.json wieder
|
||||
* anlegen — aus `entries/` und aus der Quarantäne. Laut Tombstone-Export
|
||||
* gelöschte Payloads bleiben außen vor. Legal Hold kennt das Manifest
|
||||
* nicht: wiederhergestellte Einträge im Admin prüfen.
|
||||
*/
|
||||
class ArchiveReregister extends Command
|
||||
{
|
||||
protected $signature = 'archive:reregister
|
||||
{--dry-run : Nur zählen}
|
||||
{--accept-unsigned : Unsignierten Tombstone-Export verwenden (nur Exporte vor dem 29.09.2026)}
|
||||
{--without-export : Ohne Tombstone-Export registrieren (gelöschte Einträge können zurückkehren)}';
|
||||
|
||||
protected $description = 'Registriert Beweisarchiv-Payloads ohne Zeile anhand ihres Manifests neu (nach DB-Restore)';
|
||||
|
||||
public function handle(EvidenceArchive $archive): int
|
||||
{
|
||||
if (! $archive->isConfigured()) {
|
||||
$this->error('ARCHIVE_KEY fehlt oder ist ungültig — Payloads lassen sich nicht lesen.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$result = $archive->reregister(
|
||||
(bool) $this->option('dry-run'),
|
||||
(bool) $this->option('accept-unsigned'),
|
||||
(bool) $this->option('without-export'),
|
||||
);
|
||||
|
||||
if ($result['aborted']) {
|
||||
$this->error(match ($result['export_signature']) {
|
||||
'invalid' => 'Tombstone-Export mit ungültiger Signatur — abgebrochen, nichts registriert. Ist ARCHIVE_PREVIOUS_KEYS vollständig?',
|
||||
'unsigned_refused' => 'Tombstone-Export ohne Signatur — abgebrochen. Nur für einen Export von vor dem 29.09.2026 mit --accept-unsigned wiederholen.',
|
||||
default => 'Kein Tombstone-Export — abgebrochen. Ohne ihn können gelöschte Einträge zurückkehren; bewusst mit --without-export.',
|
||||
});
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%d Payload(s) ohne Zeile, %d wieder registriert, %d laut Tombstone-Export gelöscht (übergangen), %d fehlgeschlagen.',
|
||||
$result['candidates'],
|
||||
$result['registered'],
|
||||
$result['skipped_tombstoned'],
|
||||
$result['failed'],
|
||||
));
|
||||
|
||||
if ($result['registered'] > 0) {
|
||||
$this->warn('Legal Hold und Kennzahlen stehen nicht im Manifest — wiederhergestellte Einträge im Admin prüfen.');
|
||||
}
|
||||
|
||||
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,48 +2,60 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Models\ArchivePendingJob;
|
||||
use App\Services\Archive\ArchivePendingQueue;
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use App\Services\Archive\PendingArchiveRetry;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Beweisarchiv (Umsetzungsaudit 25.09.2026, N11): Meldungen mit gesetztem
|
||||
* `archive_pending_at` — ihr Archiveintrag ist beim Vorgang fehlgeschlagen
|
||||
* — erneut archivieren. Läuft täglich; bleibt etwas offen, endet der Lauf
|
||||
* mit Fehlerstatus, und der Systemstatus zeigt die Zahl.
|
||||
* Beweisarchiv (N11, Umsetzungsaudit 29.09.2026 A-01/A-02): offene
|
||||
* Archivaufträge (archive_pending_jobs) je Ereignistyp nachholen. Läuft
|
||||
* täglich. Ohne gültigen ARCHIVE_KEY bricht der Lauf ab, ohne einen
|
||||
* Auftrag anzufassen. Bleibt etwas offen, endet er mit Fehlerstatus, und
|
||||
* der Systemstatus zeigt die Zahl.
|
||||
*/
|
||||
class ArchiveRetryPending extends Command
|
||||
{
|
||||
protected $signature = 'archive:retry-pending {--dry-run : Nur zählen}';
|
||||
|
||||
protected $description = 'Holt fehlgeschlagene Beweisarchiv-Einträge (archive_pending_at) nach';
|
||||
protected $description = 'Holt ausstehende Beweisarchiv-Einträge (archive_pending_jobs) nach';
|
||||
|
||||
public function handle(PendingArchiveRetry $retry): int
|
||||
public function handle(PendingArchiveRetry $retry, ArchivePendingQueue $queue, EvidenceArchive $archive): int
|
||||
{
|
||||
$query = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->orderBy('id');
|
||||
$pending = $query->count();
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info("Offen (Trockenlauf): {$pending} Meldungen mit ausstehender Archivierung.");
|
||||
$status = $queue->status();
|
||||
$this->info("Offen (Trockenlauf): {$status['jobs']} Archivaufträge, {$status['legacy_markers']} Altmarker ohne Auftrag.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$recorded = 0;
|
||||
$failed = 0;
|
||||
if (! $archive->isConfigured()) {
|
||||
$this->error('ARCHIVE_KEY fehlt oder ist ungültig — nichts nachgeholt, alle Aufträge bleiben offen.');
|
||||
|
||||
$query->chunkById(100, function ($pressReleases) use ($retry, &$recorded, &$failed): void {
|
||||
foreach ($pressReleases as $pressRelease) {
|
||||
$result = $retry->retry($pressRelease);
|
||||
$recorded += $result['recorded'];
|
||||
$failed += $result['failed'];
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$queue->adoptLegacyMarkers();
|
||||
$pending = ArchivePendingJob::query()->count();
|
||||
$totals = ['recorded' => 0, 'skipped' => 0, 'failed' => 0];
|
||||
|
||||
ArchivePendingJob::query()->lazyById(100)->each(function (ArchivePendingJob $job) use ($retry, &$totals): void {
|
||||
foreach ($retry->retry($job) as $key => $count) {
|
||||
$totals[$key] += $count;
|
||||
}
|
||||
});
|
||||
|
||||
$remaining = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->count();
|
||||
$this->info(sprintf('Nachgeholt: %d Archiveinträge für %d Meldungen; fehlgeschlagen: %d; weiterhin offen: %d.', $recorded, $pending, $failed, $remaining));
|
||||
$remaining = ArchivePendingJob::query()->count();
|
||||
$this->info(sprintf(
|
||||
'Nachgeholt: %d Archiveinträge aus %d Aufträgen; übersprungen: %d; fehlgeschlagen: %d; weiterhin offen: %d.',
|
||||
$totals['recorded'],
|
||||
$pending,
|
||||
$totals['skipped'],
|
||||
$totals['failed'],
|
||||
$remaining,
|
||||
));
|
||||
|
||||
return $failed > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS;
|
||||
return $totals['failed'] > 0 || $totals['skipped'] > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,30 +6,57 @@ use App\Services\Archive\EvidenceArchive;
|
|||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Beweisarchiv (Umsetzungsaudit 25.09.2026, N11): Payload-Dateien ohne
|
||||
* Archivzeile (abgebrochene Ablage, äußerer Rollback, liegengebliebene
|
||||
* .tmp) nach Karenzzeit entfernen. Läuft täglich nach dem Purge.
|
||||
* Beweisarchiv (N11, Umsetzungsaudit 29.09.2026 A-03): Payload-Dateien ohne
|
||||
* Archivzeile nach Karenzzeit in die Quarantäne verschieben (nicht löschen
|
||||
* — nach einem DB-Restore fehlen echten Einträgen die Zeilen), liegen-
|
||||
* gebliebene .tmp löschen, abgelaufene Quarantäne-Tage entfernen. Läuft
|
||||
* täglich nach dem Purge. `--pause`/`--resume` schalten den Sweep aus und
|
||||
* ein; der Restore-Abgleich setzt ihn automatisch aus.
|
||||
*/
|
||||
class ArchiveSweepOrphans extends Command
|
||||
{
|
||||
protected $signature = 'archive:sweep-orphans
|
||||
{--hours=24 : Nur Dateien, die älter als X Stunden sind}
|
||||
{--dry-run : Nur zählen}';
|
||||
{--dry-run : Nur zählen}
|
||||
{--pause : Sweep aussetzen (z. B. nach einem Restore)}
|
||||
{--resume : Ausgesetzten Sweep wieder aufnehmen}';
|
||||
|
||||
protected $description = 'Entfernt Beweisarchiv-Payloads ohne zugehörige Archivzeile';
|
||||
protected $description = 'Verschiebt Beweisarchiv-Payloads ohne zugehörige Archivzeile in die Quarantäne';
|
||||
|
||||
public function handle(EvidenceArchive $archive): int
|
||||
{
|
||||
if ($this->option('pause')) {
|
||||
$archive->pauseSweep();
|
||||
$this->info('Orphan-Sweep ausgesetzt. Fortsetzen mit archive:sweep-orphans --resume (nach archive:reregister).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('resume')) {
|
||||
$archive->resumeSweep();
|
||||
$this->info('Orphan-Sweep wieder aktiv'.(config('archive.sweep_paused') ? ' — ARCHIVE_SWEEP_PAUSED hält ihn weiterhin an.' : '.'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$result = $archive->sweepOrphans((bool) $this->option('dry-run'), max(1, (int) $this->option('hours')));
|
||||
|
||||
if ($result['paused']) {
|
||||
$this->warn('Orphan-Sweep ausgesetzt (Restore-Abgleich oder ARCHIVE_SWEEP_PAUSED) — nichts verschoben. Fortsetzen mit --resume.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s: %d Dateien geprüft, %d verwaist, %d entfernt.',
|
||||
'%s: %d Dateien geprüft, %d verwaist, %d in Quarantäne, %d entfernt; Quarantäne abgelaufen: %d.',
|
||||
$this->option('dry-run') ? 'Trockenlauf' : 'Bereinigung',
|
||||
$result['scanned'],
|
||||
$result['orphans'],
|
||||
$result['quarantined'],
|
||||
$result['removed'],
|
||||
$result['expired'],
|
||||
));
|
||||
|
||||
return ! $this->option('dry-run') && $result['removed'] < $result['orphans'] ? self::FAILURE : self::SUCCESS;
|
||||
return ! $this->option('dry-run') && $result['orphans'] > $result['quarantined'] + $result['removed'] ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
app/Console/Commands/ArchiveVerifyExport.php
Normal file
54
app/Console/Commands/ArchiveVerifyExport.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use Illuminate\Console\Command;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Beweisarchiv (Umsetzungsaudit 29.09.2026, A-15): Signatur eines
|
||||
* Anwaltsexports prüfen — neue Exporte (abgeleiteter Schlüssel) und
|
||||
* Exporte vor der Umstellung (Archivschlüssel) gegen alle bekannten
|
||||
* Schlüssel, dazu den Hash von payload.zip.
|
||||
*/
|
||||
class ArchiveVerifyExport extends Command
|
||||
{
|
||||
protected $signature = 'archive:verify-export {path : Pfad zur exportierten ZIP-Datei}';
|
||||
|
||||
protected $description = 'Prüft Hash und HMAC-Signatur eines Beweisarchiv-Exports';
|
||||
|
||||
public function handle(EvidenceArchive $archive): int
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
|
||||
if ($zip->open((string) $this->argument('path')) !== true) {
|
||||
$this->error('Datei ist kein lesbares ZIP.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = $zip->getFromName('payload.zip');
|
||||
$signature = $zip->getFromName('signature.txt');
|
||||
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true);
|
||||
$zip->close();
|
||||
|
||||
$hash = is_array($metadata) ? (string) ($metadata['payload_sha256'] ?? '') : '';
|
||||
|
||||
if (! is_string($payload) || ! is_string($signature) || $hash === '' || ! hash_equals($hash, hash('sha256', $payload))) {
|
||||
$this->error('Export ungültig: payload.zip passt nicht zum Hash in metadata.json.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $archive->verifyExportSignature($hash, $signature)) {
|
||||
$this->error('Signatur ungültig oder mit keinem bekannten Schlüssel prüfbar.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Export gültig: Hash und Signatur stimmen.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ use App\Models\PressRelease;
|
|||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden,
|
||||
|
|
@ -15,9 +17,15 @@ use Illuminate\Database\Eloquent\Builder;
|
|||
* Schützt vor "Zombie-Drafts" in der DB – optional, konfigurierbar.
|
||||
*
|
||||
* Abgelehnte Meldungen behalten ihren Status (Ablehnungsdatensatz bleibt
|
||||
* als Vertragsdokumentation, DU 4.1); nur die Medien fallen. Altbestand
|
||||
* mit `published_ever_at` (im Altsystem „offline genommen") bleibt
|
||||
* unangetastet.
|
||||
* als Vertragsdokumentation, DU 4.1); nur die Medien fallen. Was
|
||||
* möglicherweise veröffentlicht war, bleibt unangetastet — maßgeblich ist
|
||||
* PressRelease::neverPublished(): `published_ever_at` und der Altbestand
|
||||
* `rejected` (im Altsystem auch „offline genommen", Umsetzungsaudit
|
||||
* 29.09.2026 A-05).
|
||||
*
|
||||
* Ein defektes Bild bricht den Lauf nicht ab (A-14): der Fehler wird
|
||||
* gezählt, die Meldung behält ihren Status für den nächsten Lauf, und der
|
||||
* Befehl endet mit Fehlerstatus.
|
||||
*/
|
||||
class PurgeExpiredPressReleaseDrafts extends Command
|
||||
{
|
||||
|
|
@ -41,7 +49,8 @@ class PurgeExpiredPressReleaseDrafts extends Command
|
|||
// Bilder hängen — sonst wäre der Lauf jede Woche derselbe Zähler.
|
||||
$rejected = PressRelease::withoutGlobalScopes()
|
||||
->where('status', PressReleaseStatus::Rejected->value)
|
||||
->whereNull('published_ever_at')
|
||||
->neverPublished()
|
||||
->whereNull('legacy_id')
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->whereHas('images');
|
||||
|
||||
|
|
@ -57,32 +66,55 @@ class PurgeExpiredPressReleaseDrafts extends Command
|
|||
// Bilder nie veröffentlichter Entwürfe werden mit dem Purge sofort
|
||||
// und vollständig gelöscht (Bildkennzeichnung E.3, Decision-Update E4).
|
||||
$remover = app(PressReleaseImageRemover::class);
|
||||
$imagesRemoved = $this->removeImages((clone $drafts)->whereNull('published_ever_at'), $remover)
|
||||
+ $this->removeImages(clone $rejected, $remover);
|
||||
$failedPressReleaseIds = [];
|
||||
$imagesRemoved = $this->removeImages((clone $drafts)->neverPublished(), $remover, $failedPressReleaseIds)
|
||||
+ $this->removeImages(clone $rejected, $remover, $failedPressReleaseIds);
|
||||
|
||||
$drafts->update(['status' => PressReleaseStatus::Archived->value]);
|
||||
// Entwürfe mit gescheiterter Bildlöschung behalten ihren Status,
|
||||
// sonst fielen sie aus dem nächsten Lauf heraus.
|
||||
$archived = $drafts->when($failedPressReleaseIds !== [], fn (Builder $query) => $query->whereKeyNot($failedPressReleaseIds))
|
||||
->update(['status' => PressReleaseStatus::Archived->value]);
|
||||
|
||||
$this->info("PM-Entwürfe archiviert: {$draftCount} Einträge, abgelehnte Einreichungen bereinigt: {$rejectedCount}, Bilder gelöscht: {$imagesRemoved}.");
|
||||
$failed = count($failedPressReleaseIds);
|
||||
$this->info("PM-Entwürfe archiviert: {$archived} Einträge, abgelehnte Einreichungen bereinigt: {$rejectedCount}, Bilder gelöscht: {$imagesRemoved}, Meldungen mit fehlgeschlagener Bildlöschung: {$failed}.");
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->error("Bildlöschung fehlgeschlagen: {$failed} Meldung(en) — Details im Log, sie werden beim nächsten Lauf wiederholt.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<PressRelease> $query
|
||||
* @param list<int> $failedPressReleaseIds
|
||||
*/
|
||||
private function removeImages(Builder $query, PressReleaseImageRemover $remover): int
|
||||
private function removeImages(Builder $query, PressReleaseImageRemover $remover, array &$failedPressReleaseIds): int
|
||||
{
|
||||
$removed = 0;
|
||||
|
||||
$query->with('images')->chunkById(200, function ($pressReleases) use ($remover, &$removed): void {
|
||||
$query->with('images')->chunkById(200, function ($pressReleases) use ($remover, &$removed, &$failedPressReleaseIds): void {
|
||||
foreach ($pressReleases as $pressRelease) {
|
||||
foreach ($pressRelease->images as $image) {
|
||||
$remover->remove($image);
|
||||
$removed++;
|
||||
try {
|
||||
$remover->remove($image);
|
||||
$removed++;
|
||||
} catch (Throwable $exception) {
|
||||
$failedPressReleaseIds[] = (int) $pressRelease->getKey();
|
||||
Log::error('purge-drafts: Bild konnte nicht gelöscht werden, wird beim nächsten Lauf wiederholt.', [
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'press_release_image_id' => $image->getKey(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$failedPressReleaseIds = array_values(array_unique($failedPressReleaseIds));
|
||||
|
||||
return $removed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,20 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ArchivePendingJob;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Löscht verschlüsselte Vorher-Snapshots des Meldeverfahrens nach Ablauf
|
||||
* ihrer Löschfrist (purge_after, gesetzt aus legal.snapshot_retention_days).
|
||||
* Danach ist eine Schwärzung endgültig — genau so gewollt: Alt-Werte dürfen
|
||||
* nicht unbegrenzt weiterleben. Läuft täglich via Scheduler.
|
||||
*
|
||||
* Ausgenommen sind Meldungen mit offenem Archivauftrag oder Marker
|
||||
* (Umsetzungsaudit 29.09.2026, A-13): der Nachholer braucht den
|
||||
* Vorher-Stand noch; der Snapshot fällt im ersten Lauf danach.
|
||||
*/
|
||||
class PurgeRedactionSnapshots extends Command
|
||||
{
|
||||
|
|
@ -21,6 +27,8 @@ class PurgeRedactionSnapshots extends Command
|
|||
{
|
||||
$deleted = PressReleaseRedactionSnapshot::query()
|
||||
->dueForPurge()
|
||||
->whereNotIn('press_release_id', ArchivePendingJob::query()->select('press_release_id'))
|
||||
->whereNotIn('press_release_id', DB::table('press_releases')->whereNotNull('archive_pending_at')->select('id'))
|
||||
->delete();
|
||||
|
||||
$this->info("Schwärzungs-Snapshots bereinigt: {$deleted} Einträge gelöscht.");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue