audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute (Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden. Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit FAILURE; record() über .tmp + move; Marker archive_pending_at mit archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder; archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks. KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview(); Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht. Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten; Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht; Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run zählt alle Tabellen. Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber. Zwei Migrationen (nullable Spalten, INSTANT). N09 offen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
34fa342c7f
commit
2a2215b3c3
66 changed files with 3092 additions and 368 deletions
|
|
@ -83,7 +83,10 @@ class AggregatePressReleaseViews extends Command
|
|||
$chunk = max(100, (int) $this->option('chunk'));
|
||||
$budget = min(self::LockSeconds - 60, max(0, (int) $this->option('budget')));
|
||||
$startedAt = microtime(true);
|
||||
$watermark = AppSetting::getValue(self::WatermarkKey);
|
||||
// Ungecacht (Audit N21): Die Marke ist Betriebszustand, kein
|
||||
// Admin-Schalter. Ein veralteter Cachewert ließe jeden Lauf an der
|
||||
// Prüfung unter der Sperre scheitern — dauerhaft.
|
||||
$watermark = AppSetting::getValueFresh(self::WatermarkKey);
|
||||
|
||||
// Fehlende Marke heißt: noch nie initialisiert. Sie jetzt als 0 zu
|
||||
// lesen wäre fatal — `press_releases.hits` enthält den Bestand bereits,
|
||||
|
|
@ -174,6 +177,10 @@ class AggregatePressReleaseViews extends Command
|
|||
$current = AppSetting::query()->whereKey(self::WatermarkKey)->lockForUpdate()->first();
|
||||
|
||||
if ($current === null || (int) $current->value !== $watermark) {
|
||||
// Sollte je ein Cache-Leser die Marke veraltet halten: hier
|
||||
// verwerfen, damit der nächste Lauf sauber neu beginnt.
|
||||
AppSetting::forgetCached(self::WatermarkKey);
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'Wasserstandsmarke steht auf %s statt %d – ein anderer Lauf hat übernommen, Durchgang verworfen.',
|
||||
$current === null ? 'null' : (string) $current->value,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Beweisarchiv (Block F.3): Tombstone-Stand täglich auf die Archiv-Disk
|
||||
* exportieren, damit er einen Datenbank-Restore überlebt.
|
||||
* exportieren, damit er einen Datenbank-Restore überlebt. Ein
|
||||
* fehlgeschlagener Export endet mit Fehlerstatus (N10).
|
||||
*/
|
||||
class ArchiveExportTombstones extends Command
|
||||
{
|
||||
|
|
@ -17,7 +19,14 @@ class ArchiveExportTombstones extends Command
|
|||
|
||||
public function handle(EvidenceArchive $archive): int
|
||||
{
|
||||
$count = $archive->exportTombstones();
|
||||
try {
|
||||
$count = $archive->exportTombstones();
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('Tombstone-Export fehlgeschlagen: '.$exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Tombstone-Export geschrieben: {$count} Einträge.");
|
||||
|
||||
return self::SUCCESS;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ use Illuminate\Console\Command;
|
|||
|
||||
/**
|
||||
* Beweisarchiv (Block F.3): Einträge nach Fristablauf ohne legal_hold
|
||||
* löschen — Payload weg, Tombstone bleibt. Läuft täglich.
|
||||
* löschen — Payload weg, Tombstone bleibt. Läuft täglich. Ein Eintrag,
|
||||
* dessen Datei sich nicht löschen ließ, bleibt aktiv und wird beim
|
||||
* nächsten Lauf wiederholt; der Lauf endet dann mit Fehlerstatus (N10).
|
||||
*/
|
||||
class ArchivePurge extends Command
|
||||
{
|
||||
|
|
@ -17,9 +19,21 @@ class ArchivePurge extends Command
|
|||
|
||||
public function handle(EvidenceArchive $archive): int
|
||||
{
|
||||
$count = $archive->purgeDue((bool) $this->option('dry-run'));
|
||||
$result = $archive->purgeDue((bool) $this->option('dry-run'));
|
||||
|
||||
$this->info(($this->option('dry-run') ? 'Fällig (Trockenlauf): ' : 'Gelöscht: ').$count.' Archiveinträge.');
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('Fällig (Trockenlauf): '.$result['due'].' Archiveinträge.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Gelöscht: %d Archiveinträge (fällig: %d, übersprungen: %d, fehlgeschlagen: %d).', $result['purged'], $result['due'], $result['due'] - $result['purged'] - $result['failed'], $result['failed']));
|
||||
|
||||
if ($result['failed'] > 0) {
|
||||
$this->error('Löschung nicht vollständig — Details im Log; die Einträge bleiben für den nächsten Lauf fällig.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,21 +7,21 @@ use Illuminate\Console\Command;
|
|||
|
||||
/**
|
||||
* Beweisarchiv (Block F.3): nach dem Einspielen einer Sicherung alles, was
|
||||
* laut Tombstone-Export bereits gelöscht war, erneut entfernen. Teil des
|
||||
* Restore-Runbooks (dev/betrieb/deploy-runbook.md).
|
||||
* laut Tombstone-Export oder laut Datenbank bereits gelöscht war, erneut
|
||||
* entfernen. Teil des Restore-Runbooks (dev/betrieb/deploy-runbook.md).
|
||||
*/
|
||||
class ArchiveReconcileAfterRestore extends Command
|
||||
{
|
||||
protected $signature = 'archive:reconcile-after-restore';
|
||||
|
||||
protected $description = 'Entfernt nach einem Restore wiederhergestellte Beweisarchiv-Payloads anhand des Tombstone-Exports';
|
||||
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();
|
||||
|
||||
$this->info(sprintf('Abgleich: %d Einträge erneut als gelöscht markiert, %d Dateien entfernt.', $result['re_tombstoned'], $result['files_removed']));
|
||||
$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 self::SUCCESS;
|
||||
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
app/Console/Commands/ArchiveRetryPending.php
Normal file
49
app/Console/Commands/ArchiveRetryPending.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
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.
|
||||
*/
|
||||
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';
|
||||
|
||||
public function handle(PendingArchiveRetry $retry): 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.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$recorded = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query->chunkById(100, function ($pressReleases) use ($retry, &$recorded, &$failed): void {
|
||||
foreach ($pressReleases as $pressRelease) {
|
||||
$result = $retry->retry($pressRelease);
|
||||
$recorded += $result['recorded'];
|
||||
$failed += $result['failed'];
|
||||
}
|
||||
});
|
||||
|
||||
$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));
|
||||
|
||||
return $failed > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
35
app/Console/Commands/ArchiveSweepOrphans.php
Normal file
35
app/Console/Commands/ArchiveSweepOrphans.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
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.
|
||||
*/
|
||||
class ArchiveSweepOrphans extends Command
|
||||
{
|
||||
protected $signature = 'archive:sweep-orphans
|
||||
{--hours=24 : Nur Dateien, die älter als X Stunden sind}
|
||||
{--dry-run : Nur zählen}';
|
||||
|
||||
protected $description = 'Entfernt Beweisarchiv-Payloads ohne zugehörige Archivzeile';
|
||||
|
||||
public function handle(EvidenceArchive $archive): int
|
||||
{
|
||||
$result = $archive->sweepOrphans((bool) $this->option('dry-run'), max(1, (int) $this->option('hours')));
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s: %d Dateien geprüft, %d verwaist, %d entfernt.',
|
||||
$this->option('dry-run') ? 'Trockenlauf' : 'Bereinigung',
|
||||
$result['scanned'],
|
||||
$result['orphans'],
|
||||
$result['removed'],
|
||||
));
|
||||
|
||||
return ! $this->option('dry-run') && $result['removed'] < $result['orphans'] ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -142,8 +142,20 @@ class ExpireGrandfathering extends Command
|
|||
}
|
||||
|
||||
/**
|
||||
* Phase 2: je Kunde genau eine Mail für alle offenen Posten, danach
|
||||
* Marker setzen. Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden].
|
||||
* Phase 2: je Kunde genau eine Mail für alle offenen Posten.
|
||||
* Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden].
|
||||
*
|
||||
* Idempotent (Audit N14): Je Kunde eine Transaktion — offene Posten unter
|
||||
* Zeilensperre erneut lesen, den Marker per bedingtem UPDATE beanspruchen
|
||||
* (0 Zeilen = ein anderer Lauf war schneller, nichts einplanen) und erst
|
||||
* DANACH die Mail in derselben Transaktion einplanen. Scheitert das
|
||||
* Einplanen, rollt der Marker mit zurück; der nächste Lauf holt nach.
|
||||
*
|
||||
* Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION`
|
||||
* (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und
|
||||
* ist damit Teil der Transaktion. Bei einem Wechsel auf Redis o. ä. bräuchte
|
||||
* es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall,
|
||||
* `notified_at` nach Zustellung).
|
||||
*
|
||||
* @param Collection<int, UserPaymentOption> $pending
|
||||
* @return array{0: int, 1: int}
|
||||
|
|
@ -158,26 +170,45 @@ class ExpireGrandfathering extends Command
|
|||
$user = $options->first()?->user;
|
||||
$ids = $options->pluck('id')->all();
|
||||
|
||||
if ($user !== null && ! $suppressions->isSuppressed($user->email)) {
|
||||
$latestEnd = $options->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
|
||||
try {
|
||||
$queued = DB::transaction(function () use ($ids, $user, $suppressions): bool {
|
||||
/** @var Collection<int, UserPaymentOption> $open */
|
||||
$open = $this->pendingNotificationQuery()->whereIn('id', $ids)->lockForUpdate()->get();
|
||||
|
||||
if ($open->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$claimed = $this->pendingNotificationQuery()
|
||||
->whereIn('id', $open->pluck('id')->all())
|
||||
->update(['grandfathering_notified_at' => now()]);
|
||||
|
||||
if ($claimed === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user === null || $suppressions->isSuppressed($user->email)) {
|
||||
// Gesperrte Adresse: ohne Mail als erledigt markiert.
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestEnd = $open->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
|
||||
|
||||
try {
|
||||
Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd));
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage()));
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage()));
|
||||
$failed++;
|
||||
|
||||
$notified++;
|
||||
continue;
|
||||
}
|
||||
|
||||
UserPaymentOption::query()
|
||||
->whereIn('id', $ids)
|
||||
->whereNull('grandfathering_notified_at')
|
||||
->update(['grandfathering_notified_at' => now()]);
|
||||
if ($queued) {
|
||||
$notified++;
|
||||
}
|
||||
}
|
||||
|
||||
return [$notified, $failed];
|
||||
|
|
|
|||
|
|
@ -6,10 +6,18 @@ use App\Enums\PressReleaseStatus;
|
|||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden.
|
||||
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden,
|
||||
* und bereinigt die Medien abgelehnter, nie veröffentlichter Einreichungen
|
||||
* nach derselben Frist (Bildkennzeichnung O1, Umsetzungsaudit N13).
|
||||
* 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.
|
||||
*/
|
||||
class PurgeExpiredPressReleaseDrafts extends Command
|
||||
{
|
||||
|
|
@ -17,7 +25,7 @@ class PurgeExpiredPressReleaseDrafts extends Command
|
|||
{--days=180 : Entwürfe älter als X Tage archivieren}
|
||||
{--dry-run : Nur zählen, nichts ändern}';
|
||||
|
||||
protected $description = 'Archiviert inaktive PM-Entwürfe (älter als X Tage).';
|
||||
protected $description = 'Archiviert inaktive PM-Entwürfe und bereinigt Medien abgelehnter Einreichungen (älter als X Tage).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
|
|
@ -25,14 +33,23 @@ class PurgeExpiredPressReleaseDrafts extends Command
|
|||
$isDryRun = $this->option('dry-run');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$query = PressRelease::withoutGlobalScopes()
|
||||
$drafts = PressRelease::withoutGlobalScopes()
|
||||
->where('status', PressReleaseStatus::Draft->value)
|
||||
->where('updated_at', '<', $cutoff);
|
||||
|
||||
$count = $query->count();
|
||||
// Abgelehnt und nie veröffentlicht: nur Meldungen, an denen noch
|
||||
// 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')
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->whereHas('images');
|
||||
|
||||
$draftCount = $drafts->count();
|
||||
$rejectedCount = $rejected->count();
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn("[DRY-RUN] {$count} Entwürfe würden archiviert. Kein tatsächlicher Vorgang.");
|
||||
$this->warn("[DRY-RUN] {$draftCount} Entwürfe würden archiviert, {$rejectedCount} abgelehnte Einreichungen würden bereinigt. Kein tatsächlicher Vorgang.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
@ -40,21 +57,32 @@ 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 = 0;
|
||||
$imagesRemoved = $this->removeImages((clone $drafts)->whereNull('published_ever_at'), $remover)
|
||||
+ $this->removeImages(clone $rejected, $remover);
|
||||
|
||||
(clone $query)->whereNull('published_ever_at')->with('images')->chunkById(200, function ($drafts) use ($remover, &$imagesRemoved): void {
|
||||
foreach ($drafts as $draft) {
|
||||
foreach ($draft->images as $image) {
|
||||
$drafts->update(['status' => PressReleaseStatus::Archived->value]);
|
||||
|
||||
$this->info("PM-Entwürfe archiviert: {$draftCount} Einträge, abgelehnte Einreichungen bereinigt: {$rejectedCount}, Bilder gelöscht: {$imagesRemoved}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<PressRelease> $query
|
||||
*/
|
||||
private function removeImages(Builder $query, PressReleaseImageRemover $remover): int
|
||||
{
|
||||
$removed = 0;
|
||||
|
||||
$query->with('images')->chunkById(200, function ($pressReleases) use ($remover, &$removed): void {
|
||||
foreach ($pressReleases as $pressRelease) {
|
||||
foreach ($pressRelease->images as $image) {
|
||||
$remover->remove($image);
|
||||
$imagesRemoved++;
|
||||
$removed++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$query->update(['status' => PressReleaseStatus::Archived->value]);
|
||||
|
||||
$this->info("PM-Entwürfe archiviert: {$count} Einträge, Bilder gelöscht: {$imagesRemoved}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
return $removed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ class PurgePressReleaseEvents extends Command
|
|||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$watermark = AppSetting::getValue(AggregatePressReleaseViews::WatermarkKey);
|
||||
// Ungecacht (Audit N21): Dieser Leser darf die Marke nie veraltet in
|
||||
// den Cache zurückschreiben — siehe AggregatePressReleaseViews.
|
||||
$watermark = AppSetting::getValueFresh(AggregatePressReleaseViews::WatermarkKey);
|
||||
|
||||
if ($watermark === null) {
|
||||
$this->error(
|
||||
|
|
@ -73,6 +75,14 @@ class PurgePressReleaseEvents extends Command
|
|||
$cutoff->format('d.m.Y'),
|
||||
$watermark,
|
||||
));
|
||||
// Der echte Lauf räumt auch die beiden Abruf-Protokolle — der
|
||||
// Probelauf muss denselben Umfang nennen.
|
||||
$this->info(sprintf(
|
||||
'[Dry-Run] %d Einträge im Crawler-Abruf-Protokoll und %d im Feed-Abruf-Protokoll älter als %s.',
|
||||
PressReleaseCrawlerHit::query()->where('created_at', '<', $cutoff)->count(),
|
||||
FeedFetch::query()->where('created_at', '<', $cutoff)->count(),
|
||||
$cutoff->format('d.m.Y'),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,7 +163,13 @@ class PressReleaseImageController extends Controller
|
|||
);
|
||||
|
||||
try {
|
||||
$image = $this->uploader->store($file, $pressRelease, $submission, $actor);
|
||||
$image = $this->uploader->store(
|
||||
$file,
|
||||
$pressRelease,
|
||||
$submission,
|
||||
$actor,
|
||||
allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
|
||||
);
|
||||
} catch (ImageUploadException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ class PressReleaseImageMediaController extends Controller
|
|||
$pressRelease = $image->pressRelease;
|
||||
|
||||
abort_unless($pressRelease instanceof PressRelease, 404);
|
||||
$isPublic = $this->pressReleaseMediaIsPublic($pressRelease);
|
||||
|
||||
// Dieselbe Freigaberegel wie Seitenabfragen und OG-Auswahl (Audit
|
||||
// N01): ein zurückgehaltenes oder noch unbestätigtes v2-Bild ist über
|
||||
// die Direktadresse nicht öffentlich, sondern nur mit Signatur oder
|
||||
// `view`-Recht (Vorschau, Konto, Admin) und ohne öffentlichen Cache.
|
||||
$isPublic = $this->pressReleaseMediaIsPublic($pressRelease) && $image->isPubliclyDeliverable();
|
||||
$this->authorizePressReleaseMediaAccess($request, $pressRelease, $isPublic);
|
||||
|
||||
$path = $image->pathForVariant($variant);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use App\Services\PressRelease\QuotaExceededException;
|
|||
use App\Services\PressRelease\StaleClassificationException;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
|
|
@ -99,23 +100,54 @@ class ClassifyPressRelease implements ShouldQueue
|
|||
// Bedingtes UPDATE: schreibt nur, wenn der Inhalt noch die geprüfte
|
||||
// Prüfsumme trägt. Altbestand ohne Summe (null) wird unbedingt
|
||||
// geschrieben. 0 Zeilen = Inhalt inzwischen geändert oder PM weg.
|
||||
//
|
||||
// Ergebnis und Audit in EINER Transaktion (Audit N03): Der Scheduler
|
||||
// liest die Klassifikation und den Audit-Hash getrennt; ein Commit
|
||||
// zwischen beiden ließe eine grüne PM kurz ohne Nachweis dastehen —
|
||||
// und „ohne Nachweis" gilt als auditloser Altbestand, der durchgeht.
|
||||
$classifiedAt = now();
|
||||
$stored = PressRelease::withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressRelease->id)
|
||||
->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash))
|
||||
->update([
|
||||
|
||||
/** @var array{stored: bool, current_hash: string|null} $outcome */
|
||||
$outcome = DB::transaction(function () use ($pressRelease, $result, $analyzedHash, $classifiedAt): array {
|
||||
$stored = PressRelease::withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressRelease->id)
|
||||
->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash))
|
||||
->update([
|
||||
'classification' => $result->classification->value,
|
||||
'classified_at' => $classifiedAt,
|
||||
'updated_at' => $classifiedAt,
|
||||
]);
|
||||
|
||||
if ($stored === 0) {
|
||||
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash');
|
||||
|
||||
if ($currentHash === null) {
|
||||
// Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK).
|
||||
return ['stored' => false, 'current_hash' => null];
|
||||
}
|
||||
|
||||
KiAudit::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'type' => KiAudit::TYPE_CLASSIFICATION,
|
||||
'provider' => $result->provider,
|
||||
'model' => $result->model,
|
||||
'result' => $result->classification->value,
|
||||
'reason' => 'Verworfen: Inhalt wurde während der Prüfung geändert. '.$result->reasonText(),
|
||||
'raw_response' => $result->rawResponse,
|
||||
'content_hash' => $analyzedHash,
|
||||
'discarded_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return ['stored' => false, 'current_hash' => $currentHash];
|
||||
}
|
||||
|
||||
// Modell auf den geschriebenen Stand bringen, ohne ein zweites
|
||||
// (unbedingtes) UPDATE auszulösen.
|
||||
$pressRelease->forceFill([
|
||||
'classification' => $result->classification->value,
|
||||
'classified_at' => $classifiedAt,
|
||||
'updated_at' => $classifiedAt,
|
||||
]);
|
||||
|
||||
if ($stored === 0) {
|
||||
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash');
|
||||
|
||||
if ($currentHash === null) {
|
||||
// Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK).
|
||||
return;
|
||||
}
|
||||
])->syncOriginal();
|
||||
|
||||
KiAudit::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
|
|
@ -123,49 +155,36 @@ class ClassifyPressRelease implements ShouldQueue
|
|||
'provider' => $result->provider,
|
||||
'model' => $result->model,
|
||||
'result' => $result->classification->value,
|
||||
'reason' => 'Verworfen: Inhalt wurde während der Prüfung geändert. '.$result->reasonText(),
|
||||
'reason' => $result->reasonText(),
|
||||
'raw_response' => $result->rawResponse,
|
||||
'content_hash' => $analyzedHash,
|
||||
'discarded_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
self::dispatch($pressRelease->id, $this->route, $this->providerOverride, $currentHash)->onQueue('classification');
|
||||
return ['stored' => true, 'current_hash' => $analyzedHash];
|
||||
});
|
||||
|
||||
if (! $outcome['stored']) {
|
||||
if ($outcome['current_hash'] !== null) {
|
||||
self::dispatch($pressRelease->id, $this->route, $this->providerOverride, $outcome['current_hash'])->onQueue('classification');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Modell auf den geschriebenen Stand bringen, ohne ein zweites
|
||||
// (unbedingtes) UPDATE auszulösen.
|
||||
$pressRelease->forceFill([
|
||||
'classification' => $result->classification->value,
|
||||
'classified_at' => $classifiedAt,
|
||||
])->syncOriginal();
|
||||
|
||||
KiAudit::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'type' => KiAudit::TYPE_CLASSIFICATION,
|
||||
'provider' => $result->provider,
|
||||
'model' => $result->model,
|
||||
'result' => $result->classification->value,
|
||||
'reason' => $result->reasonText(),
|
||||
'raw_response' => $result->rawResponse,
|
||||
'content_hash' => $analyzedHash,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
if (! $this->route && ! $this->redRecheckMustReject($pressRelease, $result)) {
|
||||
if (! $this->route && ! $this->redRecheckMustReject($pressRelease, $result, $analyzedHash)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText());
|
||||
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText(), $analyzedHash);
|
||||
} catch (BlacklistViolationException) {
|
||||
// publish() hat die PM bereits abgelehnt und den Autor benachrichtigt.
|
||||
} catch (StaleClassificationException $exception) {
|
||||
// Inhalt zwischen Audit und Publish-Sperre geändert: publish() hat
|
||||
// bereits eine Prüfung für den aktuellen Stand eingeplant (U02).
|
||||
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [
|
||||
// Inhalt zwischen Audit und Sperre geändert: publish() bzw.
|
||||
// reject()/Hold haben bereits eine Prüfung für den aktuellen
|
||||
// Stand eingeplant (U02, N04).
|
||||
Log::channel('ki')->info('Automatisches Routing übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
} catch (QuotaExceededException $exception) {
|
||||
|
|
@ -182,9 +201,12 @@ class ClassifyPressRelease implements ShouldQueue
|
|||
* Roter Re-Check ohne Routing einer noch wartenden PM (Audit U03): nur
|
||||
* dann greift der Rot-Zweig der Erstprüfung. Status frisch lesen — der
|
||||
* KI-Aufruf kann gedauert haben, und ein Admin hat womöglich inzwischen
|
||||
* entschieden.
|
||||
* entschieden. Ist der Inhalt seit dem Ergebnis schon wieder geändert
|
||||
* (Audit N04), gehört das Rot zu einer alten Fassung: nicht ablehnen —
|
||||
* die Bearbeitung hat bereits einen Re-Check für den neuen Stand
|
||||
* eingeplant. Verbindlich prüft reject() das noch einmal unter der Sperre.
|
||||
*/
|
||||
private function redRecheckMustReject(PressRelease $pressRelease, ClassificationResult $result): bool
|
||||
private function redRecheckMustReject(PressRelease $pressRelease, ClassificationResult $result, ?string $analyzedHash): bool
|
||||
{
|
||||
if ($result->classification !== PressReleaseClassification::Red) {
|
||||
return false;
|
||||
|
|
@ -192,6 +214,14 @@ class ClassifyPressRelease implements ShouldQueue
|
|||
|
||||
$pressRelease->refresh();
|
||||
|
||||
if ($analyzedHash !== null && $pressRelease->content_hash !== null && $pressRelease->content_hash !== $analyzedHash) {
|
||||
Log::channel('ki')->info('Roter Re-Check übersprungen: Inhalt seit dem Ergebnis geändert, neuer Re-Check läuft.', [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $pressRelease->status === PressReleaseStatus::Review
|
||||
&& ! $pressRelease->requiresManualReview();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Services\PressRelease\ContentScore\ContentScoreManager;
|
|||
use App\Services\PressRelease\ContentScore\ContentScoreResult;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
|
|
@ -63,23 +64,45 @@ class ScorePressRelease implements ShouldQueue
|
|||
// Bedingtes UPDATE: schreibt nur, wenn der Inhalt noch die bewertete
|
||||
// Prüfsumme trägt. Altbestand ohne Summe (null) wird unbedingt
|
||||
// geschrieben. 0 Zeilen = Inhalt inzwischen geändert oder PM weg.
|
||||
// Ergebnis und Audit in EINER Transaktion — gleiches Schreibmuster wie
|
||||
// ClassifyPressRelease (Audit N03), damit Bewertung und Nachweis nie
|
||||
// getrennt sichtbar werden.
|
||||
$scoredAt = now();
|
||||
$stored = PressRelease::withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressRelease->id)
|
||||
->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash))
|
||||
->update([
|
||||
'content_score' => $result->score,
|
||||
'content_tier' => $tier->value,
|
||||
'scored_at' => $scoredAt,
|
||||
'updated_at' => $scoredAt,
|
||||
]);
|
||||
|
||||
if ($stored === 0) {
|
||||
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash');
|
||||
/** @var string|null $rescheduleFor */
|
||||
$rescheduleFor = DB::transaction(function () use ($pressRelease, $result, $tier, $analyzedHash, $scoredAt): ?string {
|
||||
$stored = PressRelease::withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressRelease->id)
|
||||
->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash))
|
||||
->update([
|
||||
'content_score' => $result->score,
|
||||
'content_tier' => $tier->value,
|
||||
'scored_at' => $scoredAt,
|
||||
'updated_at' => $scoredAt,
|
||||
]);
|
||||
|
||||
if ($currentHash === null) {
|
||||
// Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK).
|
||||
return;
|
||||
if ($stored === 0) {
|
||||
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash');
|
||||
|
||||
if ($currentHash === null) {
|
||||
// Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK).
|
||||
return null;
|
||||
}
|
||||
|
||||
KiAudit::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'type' => KiAudit::TYPE_CONTENT_SCORE,
|
||||
'provider' => $result->provider,
|
||||
'model' => $result->model,
|
||||
'result' => (string) $result->score,
|
||||
'reason' => 'Verworfen: Inhalt wurde während der Bewertung geändert.',
|
||||
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
|
||||
'content_hash' => $analyzedHash,
|
||||
'discarded_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $currentHash;
|
||||
}
|
||||
|
||||
KiAudit::query()->create([
|
||||
|
|
@ -88,29 +111,18 @@ class ScorePressRelease implements ShouldQueue
|
|||
'provider' => $result->provider,
|
||||
'model' => $result->model,
|
||||
'result' => (string) $result->score,
|
||||
'reason' => 'Verworfen: Inhalt wurde während der Bewertung geändert.',
|
||||
'reason' => $tier->label(),
|
||||
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
|
||||
'content_hash' => $analyzedHash,
|
||||
'discarded_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
self::dispatch($pressRelease->id, $this->providerOverride, $currentHash)->onQueue('classification');
|
||||
return null;
|
||||
});
|
||||
|
||||
return;
|
||||
if ($rescheduleFor !== null) {
|
||||
self::dispatch($pressRelease->id, $this->providerOverride, $rescheduleFor)->onQueue('classification');
|
||||
}
|
||||
|
||||
KiAudit::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'type' => KiAudit::TYPE_CONTENT_SCORE,
|
||||
'provider' => $result->provider,
|
||||
'model' => $result->model,
|
||||
'result' => (string) $result->score,
|
||||
'reason' => $tier->label(),
|
||||
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
|
||||
'content_hash' => $analyzedHash,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Laufzeit-Einstellungen mit Admin-UI (Key-Value, JSON-Wert, gecacht).
|
||||
|
|
@ -40,10 +41,30 @@ class AppSetting extends Model
|
|||
return (bool) static::getValue($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest direkt aus der Datenbank, am Cache vorbei — für operative
|
||||
* Fortschrittsmarken (Wasserstand der Aufruf-Aggregation, Audit N21):
|
||||
* Ein veralteter Cachewert würde dort jeden Lauf gegen die Zeilensperre
|
||||
* scheitern lassen, ohne sich je zu korrigieren.
|
||||
*/
|
||||
public static function getValueFresh(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$value = static::query()->find($key)?->value;
|
||||
|
||||
return $value ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache erst NACH dem Commit verwerfen (Audit N21): Innerhalb einer
|
||||
* Transaktion sähe ein Leser zwischen Forget und Commit noch den alten
|
||||
* DB-Wert und schriebe ihn dauerhaft zurück in den Cache. Ohne offene
|
||||
* Transaktion läuft der Callback sofort.
|
||||
*/
|
||||
public static function setValue(string $key, mixed $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
static::forgetCached($key);
|
||||
|
||||
DB::afterCommit(static fn () => static::forgetCached($key));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class ArchiveEntry extends Model
|
|||
'subject_type',
|
||||
'subject_id',
|
||||
'press_release_id',
|
||||
'legal_request_id',
|
||||
'event',
|
||||
'actor_type',
|
||||
'actor_id',
|
||||
|
|
|
|||
|
|
@ -38,10 +38,29 @@ class ImageDeclarationCorrection extends Model
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Freischein für die Löschung nach Fristende des Beweisarchivs
|
||||
* (Umsetzungsaudit 25.09.2026, 5.1 a). Nie von außen setzen.
|
||||
*/
|
||||
public bool $purgingAfterArchive = false;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(fn () => throw new \LogicException('image_declaration_corrections ist insert-only.'));
|
||||
static::deleting(fn () => throw new \LogicException('image_declaration_corrections wird nicht gelöscht.'));
|
||||
static::deleting(function (self $correction): void {
|
||||
if (! $correction->purgingAfterArchive) {
|
||||
throw new \LogicException('image_declaration_corrections wird nicht gelöscht; nur Kopien nach Fristende des Beweisarchivs (purgeAfterArchive) dürfen verschwinden.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Löschung nach Fristende des Beweisarchivs (5.1 a).
|
||||
*/
|
||||
public function purgeAfterArchive(): void
|
||||
{
|
||||
$this->purgingAfterArchive = true;
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ class ImageRightsConfirmation extends Model
|
|||
*/
|
||||
public bool $purgingUnpublished = false;
|
||||
|
||||
/**
|
||||
* Zweiter Freischein: Löschung der Anwendungskopie, nachdem der
|
||||
* zugehörige Archiveintrag nach Fristablauf getombstont wurde
|
||||
* (Umsetzungsaudit 25.09.2026, 5.1 a). Nie von außen setzen.
|
||||
*/
|
||||
public bool $purgingAfterArchive = false;
|
||||
|
||||
protected $fillable = [
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
|
|
@ -88,6 +95,16 @@ class ImageRightsConfirmation extends Model
|
|||
$this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Löschung nach Fristende des Beweisarchivs (5.1 a): die Kopie im Archiv
|
||||
* ist bereits weg, die Anwendungszeile darf nicht länger leben.
|
||||
*/
|
||||
public function purgeAfterArchive(): void
|
||||
{
|
||||
$this->purgingAfterArchive = true;
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class PressRelease extends Model
|
|||
'published_at',
|
||||
'depublished_at',
|
||||
'published_ever_at',
|
||||
'archive_pending_at',
|
||||
'gone_at',
|
||||
'no_export_before_gone',
|
||||
'scheduled_at',
|
||||
|
|
@ -137,6 +138,7 @@ class PressRelease extends Model
|
|||
'published_at' => 'datetime',
|
||||
'depublished_at' => 'datetime',
|
||||
'published_ever_at' => 'datetime',
|
||||
'archive_pending_at' => 'datetime',
|
||||
'gone_at' => 'datetime',
|
||||
'no_export_before_gone' => 'boolean',
|
||||
'sync_locked_at' => 'datetime',
|
||||
|
|
|
|||
|
|
@ -241,10 +241,6 @@ class PressReleaseImage extends Model
|
|||
return $this->published_ever_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorläufig zurückgehalten (Korrekturweg E.6): öffentlich greift der
|
||||
* Platzhalter, im Panel bleibt das Bild sichtbar.
|
||||
*/
|
||||
/**
|
||||
* Ausgang `queue` des Vorab-Checks (Block K): Bild ist zurückgehalten,
|
||||
* bis ein Admin freigibt oder ablehnt.
|
||||
|
|
@ -267,20 +263,42 @@ class PressReleaseImage extends Model
|
|||
return $this->belongsTo(User::class, 'precheck_reviewed_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorläufig zurückgehalten (Korrekturweg E.6, Vorab-Check K.4):
|
||||
* öffentlich greift der Platzhalter, im Panel bleibt das Bild sichtbar.
|
||||
*/
|
||||
public function isWithheld(): bool
|
||||
{
|
||||
return $this->withheld_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentlich ausspielbare Bilder: nicht zurückgehalten.
|
||||
* Gemeinsamer Freigabezustand für die öffentliche Auslieferung (Audit
|
||||
* 25.09. N01): nicht zurückgehalten und — für Bilder aus Formular v2 —
|
||||
* mit Rechtebestätigung (ein Admin-Upload wartet bis zum Kundenklick,
|
||||
* DU E10). Der Bestand (`rules_version` NULL) hat kein Protokoll und
|
||||
* bleibt ausdrücklich sichtbar. Seitenabfragen, OG-Auswahl und
|
||||
* Medienroute wenden dieselbe Regel an; der Scope ist gleichlautend.
|
||||
*/
|
||||
public function isPubliclyDeliverable(): bool
|
||||
{
|
||||
return $this->withheld_at === null
|
||||
&& ($this->rules_version === null || $this->rights_confirmed_at !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentlich ausspielbare Bilder — gleichlautend mit isPubliclyDeliverable().
|
||||
*
|
||||
* @param Builder<PressReleaseImage> $query
|
||||
* @return Builder<PressReleaseImage>
|
||||
*/
|
||||
public function scopePubliclyVisible(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('withheld_at');
|
||||
return $query
|
||||
->whereNull('withheld_at')
|
||||
->where(fn (Builder $inner) => $inner
|
||||
->whereNull('rules_version')
|
||||
->orWhereNotNull('rights_confirmed_at'));
|
||||
}
|
||||
|
||||
public function url(bool $signed = false): ?string
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ class ImageRightsConfirmationObserver
|
|||
|
||||
public function deleting(ImageRightsConfirmation $confirmation): void
|
||||
{
|
||||
if ($confirmation->purgingUnpublished) {
|
||||
if ($confirmation->purgingUnpublished || $confirmation->purgingAfterArchive) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new LogicException('image_rights_confirmations wird nicht gelöscht; nur nie veröffentlichte Uploads dürfen über purgeForUnpublished() verschwinden.');
|
||||
throw new LogicException('image_rights_confirmations wird nicht gelöscht; nur nie veröffentlichte Uploads (purgeForUnpublished) und Kopien nach Fristende des Beweisarchivs (purgeAfterArchive) dürfen verschwinden.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,14 @@ class PressReleasePolicy
|
|||
* Kurzschluss nur für AKTIVE Super-Admins (Audit 7.2): ein deaktiviertes
|
||||
* Konto fällt auf die regulären Prüfungen zurück, die es ablehnen.
|
||||
*/
|
||||
public function before(User $user): ?bool
|
||||
public function before(User $user, string $ability): ?bool
|
||||
{
|
||||
// Die Kundenbestätigung eines Admin-Uploads ist an die Identität des
|
||||
// Kontoinhabers gebunden (DU E1/E10, Audit N05) — kein Kurzschluss.
|
||||
if ($ability === 'confirmImageRights') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($user->is_super_admin && $user->is_active) ? true : null;
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +81,22 @@ class PressReleasePolicy
|
|||
return $user->is_super_admin === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Bild bestätigen" nach einem Admin-Upload (Bildkennzeichnung B.4,
|
||||
* DU E10): ausschließlich der Kontoinhaber der Meldung — er ist der
|
||||
* Vertragspartner (DU E1) —, auch bei bereits veröffentlichter PM
|
||||
* (Admin-Bildtausch). Nicht von `update` geerbt (Audit N05).
|
||||
*/
|
||||
public function confirmImageRights(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
return $this->isAuthor($user, $pressRelease)
|
||||
&& in_array(
|
||||
$pressRelease->status,
|
||||
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected, PressReleaseStatus::Review, PressReleaseStatus::Published],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
public function publish(User $user, PressRelease $pressRelease): bool
|
||||
{
|
||||
return $user->canAccessAdmin() && $user->can('press-releases:publish');
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
// Deaktivierung geht vor (Audit F09): ein gesperrter Super-Admin fällt
|
||||
// auf die regulären Gates zurück, die `is_active` prüfen – sonst wäre
|
||||
// die Sperre für bestehende Sessions wirkungslos.
|
||||
Gate::before(fn (User $user, string $ability): ?bool => ($user->is_super_admin && $user->is_active) ? true : null);
|
||||
// Ausnahme: die Kundenbestätigung eines Admin-Uploads ist an die
|
||||
// Identität des Kontoinhabers gebunden (PressReleasePolicy, Audit N05).
|
||||
Gate::before(fn (User $user, string $ability): ?bool => $ability !== 'confirmImageRights' && $user->is_super_admin && $user->is_active ? true : null);
|
||||
|
||||
Gate::define('access-admin', fn (User $user): bool => $user->canAccessAdmin());
|
||||
Gate::define('access-customer', fn (User $user): bool => $user->canAccessCustomer());
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@
|
|||
namespace App\Services\Admin;
|
||||
|
||||
use App\Authorization\Permission as PermissionCatalog;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseAttachment;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
@ -209,6 +212,29 @@ class AdminSystemHealth
|
|||
: $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).',
|
||||
];
|
||||
|
||||
// Beweisarchiv: Vorgänge, deren Archiveintrag fehlgeschlagen ist
|
||||
// (archive_pending_at, Umsetzungsaudit N11), und weich gelöschte
|
||||
// v2-Bilder, deren lokale Beweisdateien noch nicht bereinigt sind (5.1 e).
|
||||
$archivePending = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->count();
|
||||
$checks[] = [
|
||||
'key' => 'archive_pending',
|
||||
'label' => 'Beweisarchiv-Nachholer',
|
||||
'status' => $archivePending === 0 ? self::StatusOk : self::StatusWarn,
|
||||
'detail' => $archivePending === 0
|
||||
? 'Alle Archiveinträge wurden beim Vorgang geschrieben.'
|
||||
: $archivePending.' Meldung(en) mit ausstehendem Archiveintrag — `archive:retry-pending` läuft täglich; Log auf ARCHIVE_KEY/Archiv-Disk prüfen.',
|
||||
];
|
||||
|
||||
$evidenceLeftovers = PressReleaseImageRemover::countLocalEvidenceLeftovers();
|
||||
$checks[] = [
|
||||
'key' => 'image_evidence_leftovers',
|
||||
'label' => 'Bildnachweise lokal',
|
||||
'status' => $evidenceLeftovers === 0 ? self::StatusOk : self::StatusWarn,
|
||||
'detail' => $evidenceLeftovers === 0
|
||||
? 'Keine entfernten Bilder mit lokalen Beweisdateien.'
|
||||
: $evidenceLeftovers.' entfernte(s) Bild(er) mit noch vorhandenen lokalen Beweisdateien (Original/Snapshot) — Überführung ausstehend oder Löschung fehlgeschlagen, siehe Log.',
|
||||
];
|
||||
|
||||
// Offene Bild-Sichtungen aus dem KI-Vorab-Check (Block K).
|
||||
$pendingPrechecks = PressReleaseImage::query()->awaitingPrecheckReview()->count();
|
||||
$checks[] = [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ use App\Enums\ArchiveEvent;
|
|||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Models\ArchiveAccessLog;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\ImageDeclarationCorrection;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Support\FileDeletion;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
|
|
@ -15,6 +19,7 @@ use Illuminate\Support\Facades\Log;
|
|||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
|
|
@ -26,7 +31,13 @@ use ZipArchive;
|
|||
* Zugriffsprotokoll. Löschen nur durch purgeDue() nach Frist ohne
|
||||
* legal_hold; die Zeile bleibt als Tombstone. Nach einem Restore aus einer
|
||||
* Sicherung entfernt reconcileAfterRestore() wiederhergestellte Payloads
|
||||
* anhand des täglich exportierten Tombstone-Stands.
|
||||
* anhand des täglich exportierten Tombstone-Stands und der Tombstones in
|
||||
* der Datenbank.
|
||||
*
|
||||
* Dateioperationen werden ausgewertet (Umsetzungsaudit 25.09.2026, N10):
|
||||
* ein Tombstone entsteht erst nach bestätigter Löschung, Purge und
|
||||
* Restore-Abgleich melden Fehlschläge, der Export wird atomar ersetzt.
|
||||
* Hold und Purge koordinieren sich über dieselbe Zeilensperre (N02).
|
||||
*/
|
||||
class EvidenceArchive
|
||||
{
|
||||
|
|
@ -36,6 +47,11 @@ class EvidenceArchive
|
|||
}
|
||||
|
||||
/**
|
||||
* Payload zuerst als .tmp, dann die Zeile in einer Transaktion, dann
|
||||
* die Datei an ihren Platz — schlägt etwas fehl, bleibt weder Zeile
|
||||
* noch .tmp zurück (N11). Eine Datei ohne Zeile (Commit nach dem
|
||||
* Verschieben gescheitert) räumt sweepOrphans() auf.
|
||||
*
|
||||
* @param array<string, mixed> $manifest Fachdaten (werden als manifest.json abgelegt)
|
||||
* @param array<string, string> $files Dateiname → Bytes
|
||||
* @param array<string, mixed> $summary Kleine, nicht personenbezogene Kennzahlen für die Liste
|
||||
|
|
@ -50,6 +66,7 @@ class EvidenceArchive
|
|||
array $manifest,
|
||||
array $files = [],
|
||||
array $summary = [],
|
||||
?int $legalRequestId = null,
|
||||
): ?ArchiveEntry {
|
||||
if (! $this->isConfigured()) {
|
||||
Log::error('Beweisarchiv: ARCHIVE_KEY fehlt, Eintrag konnte nicht abgelegt werden.', [
|
||||
|
|
@ -65,6 +82,7 @@ class EvidenceArchive
|
|||
'subject_type' => $subject->getMorphClass(),
|
||||
'subject_id' => $subject->getKey(),
|
||||
'press_release_id' => $pressReleaseId,
|
||||
'legal_request_id' => $legalRequestId,
|
||||
'reason_category' => $reason->value,
|
||||
'actor_type' => $actorType,
|
||||
'actor_id' => $actorId,
|
||||
|
|
@ -74,26 +92,42 @@ class EvidenceArchive
|
|||
$zip = $this->buildZip($manifest, $files);
|
||||
$hash = hash('sha256', $zip);
|
||||
$path = sprintf('entries/%s/%s.bin', now()->format('Y'), Str::uuid()->toString());
|
||||
$temp = $path.'.tmp';
|
||||
|
||||
if (! $this->disk()->put($path, $this->encrypter()->encrypt($zip, false))) {
|
||||
if (! $this->disk()->put($temp, $this->encrypter()->encrypt($zip, false))) {
|
||||
throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.');
|
||||
}
|
||||
|
||||
return ArchiveEntry::query()->create([
|
||||
'subject_type' => $subject->getMorphClass(),
|
||||
'subject_id' => $subject->getKey(),
|
||||
'press_release_id' => $pressReleaseId,
|
||||
'event' => $event,
|
||||
'actor_type' => $actorType,
|
||||
'actor_id' => $actorId,
|
||||
'reason_category' => $reason,
|
||||
'summary' => $summary ?: null,
|
||||
'payload_path' => $path,
|
||||
'payload_hash' => $hash,
|
||||
'payload_bytes' => strlen($zip),
|
||||
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
try {
|
||||
return DB::transaction(function () use ($subject, $pressReleaseId, $legalRequestId, $event, $actorType, $actorId, $reason, $summary, $path, $temp, $hash, $zip): ArchiveEntry {
|
||||
$entry = ArchiveEntry::query()->create([
|
||||
'subject_type' => $subject->getMorphClass(),
|
||||
'subject_id' => $subject->getKey(),
|
||||
'press_release_id' => $pressReleaseId,
|
||||
'legal_request_id' => $legalRequestId,
|
||||
'event' => $event,
|
||||
'actor_type' => $actorType,
|
||||
'actor_id' => $actorId,
|
||||
'reason_category' => $reason,
|
||||
'summary' => $summary ?: null,
|
||||
'payload_path' => $path,
|
||||
'payload_hash' => $hash,
|
||||
'payload_bytes' => strlen($zip),
|
||||
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
|
||||
throw new RuntimeException('Beweisarchiv: Payload konnte nicht abgelegt werden.');
|
||||
}
|
||||
|
||||
return $entry;
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
$this->disk()->delete($temp);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -148,6 +182,7 @@ class EvidenceArchive
|
|||
'event' => $entry->event->value,
|
||||
'subject' => $entry->subject_type.'#'.$entry->subject_id,
|
||||
'press_release_id' => $entry->press_release_id,
|
||||
'legal_request_id' => $entry->legal_request_id,
|
||||
'reason_category' => $entry->reason_category->value,
|
||||
'created_at' => $entry->created_at?->toIso8601String(),
|
||||
'retain_until' => $entry->retain_until->toDateString(),
|
||||
|
|
@ -161,14 +196,30 @@ class EvidenceArchive
|
|||
], 'metadata.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold setzen unter Zeilensperre: ein gleichzeitig laufender Purge sieht
|
||||
* den Hold bei seiner erneuten Prüfung, oder der Hold wartet, bis der
|
||||
* Purge fertig ist — dann ist der Eintrag Tombstone und der Hold wird
|
||||
* abgelehnt (N02).
|
||||
*/
|
||||
public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void
|
||||
{
|
||||
$entry->forceFill([
|
||||
'legal_hold' => true,
|
||||
'legal_hold_note' => Str::limit($note, 990, ''),
|
||||
'legal_hold_set_by' => $admin->getKey(),
|
||||
'legal_hold_set_at' => now(),
|
||||
])->save();
|
||||
DB::transaction(function () use ($entry, $admin, $note): void {
|
||||
$locked = $this->lockedEntry($entry);
|
||||
|
||||
if ($locked->isTombstoned()) {
|
||||
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone), ein Legal Hold ist nicht mehr möglich.');
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'legal_hold' => true,
|
||||
'legal_hold_note' => Str::limit($note, 990, ''),
|
||||
'legal_hold_set_by' => $admin->getKey(),
|
||||
'legal_hold_set_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
$entry->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,39 +227,64 @@ class EvidenceArchive
|
|||
*/
|
||||
public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void
|
||||
{
|
||||
$entry->forceFill([
|
||||
'legal_hold' => false,
|
||||
'legal_hold_note' => Str::limit($note, 990, ''),
|
||||
'legal_hold_set_by' => $admin->getKey(),
|
||||
'legal_hold_set_at' => now(),
|
||||
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
||||
])->save();
|
||||
DB::transaction(function () use ($entry, $admin, $note): void {
|
||||
$locked = $this->lockedEntry($entry);
|
||||
|
||||
if ($locked->isTombstoned()) {
|
||||
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone).');
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'legal_hold' => false,
|
||||
'legal_hold_note' => Str::limit($note, 990, ''),
|
||||
'legal_hold_set_by' => $admin->getKey(),
|
||||
'legal_hold_set_at' => now(),
|
||||
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
||||
])->save();
|
||||
});
|
||||
|
||||
$entry->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt.
|
||||
* Jeder Eintrag wird unter Zeilensperre erneut geprüft; Fehlschläge
|
||||
* werden gezählt und beim nächsten Lauf wiederholt.
|
||||
*
|
||||
* @return array{due: int, purged: int, failed: int}
|
||||
*/
|
||||
public function purgeDue(bool $dryRun = false): int
|
||||
public function purgeDue(bool $dryRun = false): array
|
||||
{
|
||||
$count = 0;
|
||||
$result = ['due' => 0, 'purged' => 0, 'failed' => 0];
|
||||
|
||||
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$count, $dryRun): void {
|
||||
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$result, $dryRun): void {
|
||||
foreach ($entries as $entry) {
|
||||
$count++;
|
||||
$result['due']++;
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tombstone($entry);
|
||||
try {
|
||||
if ($this->tombstone($entry)) {
|
||||
$result['purged']++;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$result['failed']++;
|
||||
Log::error('Beweisarchiv: Löschung nach Fristablauf fehlgeschlagen, wird wiederholt.', [
|
||||
'entry_id' => $entry->getKey(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombstone-Stand exportieren, damit er einen Restore überlebt.
|
||||
* Tombstone-Stand exportieren, damit er einen Restore überlebt. Wird
|
||||
* atomar ersetzt; ein Fehlschlag wirft, statt eine Anzahl zu melden.
|
||||
*/
|
||||
public function exportTombstones(): int
|
||||
{
|
||||
|
|
@ -222,10 +298,22 @@ class EvidenceArchive
|
|||
->values()
|
||||
->all();
|
||||
|
||||
$this->disk()->put((string) config('archive.tombstone_export_path'), (string) json_encode([
|
||||
$path = (string) config('archive.tombstone_export_path');
|
||||
$temp = $path.'.tmp';
|
||||
$json = (string) json_encode([
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'entries' => $rows,
|
||||
], JSON_PRETTY_PRINT));
|
||||
], JSON_PRETTY_PRINT);
|
||||
|
||||
if (! $this->disk()->put($temp, $json)) {
|
||||
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht geschrieben werden.');
|
||||
}
|
||||
|
||||
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
|
||||
$this->disk()->delete($temp);
|
||||
|
||||
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht abgelegt werden.');
|
||||
}
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
|
@ -233,26 +321,25 @@ class EvidenceArchive
|
|||
/**
|
||||
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
|
||||
* Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile).
|
||||
* Zusätzlich jede Tombstone-Zeile der Datenbank, deren Payload wieder
|
||||
* auf der Disk liegt — deckt den Fall „Disk alt, Datenbank aktuell"
|
||||
* ab (5.1 c).
|
||||
*
|
||||
* @return array{re_tombstoned: int, files_removed: int}
|
||||
* @return array{re_tombstoned: int, files_removed: int, failed: int}
|
||||
*/
|
||||
public function reconcileAfterRestore(): array
|
||||
{
|
||||
$result = ['re_tombstoned' => 0, 'files_removed' => 0];
|
||||
$result = ['re_tombstoned' => 0, 'files_removed' => 0, 'failed' => 0];
|
||||
$raw = $this->disk()->get((string) config('archive.tombstone_export_path'));
|
||||
|
||||
if (! is_string($raw) || $raw === '') {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$rows = (array) (json_decode($raw, true)['entries'] ?? []);
|
||||
$rows = is_string($raw) && $raw !== '' ? (array) (json_decode($raw, true)['entries'] ?? []) : [];
|
||||
$handled = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null;
|
||||
|
||||
if ($path !== null && $this->disk()->exists($path)) {
|
||||
$this->disk()->delete($path);
|
||||
$result['files_removed']++;
|
||||
if ($path !== null && ! isset($handled[$path]) && $this->disk()->exists($path)) {
|
||||
$handled[$path] = true;
|
||||
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
|
||||
}
|
||||
|
||||
$entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
|
||||
|
|
@ -263,22 +350,127 @@ class EvidenceArchive
|
|||
}
|
||||
}
|
||||
|
||||
ArchiveEntry::query()->whereNotNull('deleted_at')->whereNotNull('payload_path')->orderBy('id')
|
||||
->chunkById(500, function ($entries) use (&$result, &$handled): void {
|
||||
foreach ($entries as $entry) {
|
||||
$path = (string) $entry->payload_path;
|
||||
|
||||
if (isset($handled[$path]) || ! $this->disk()->exists($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$handled[$path] = true;
|
||||
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($result['failed'] > 0) {
|
||||
Log::error('Beweisarchiv: Restore-Abgleich konnte Payloads nicht entfernen.', ['failed' => $result['failed']]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function tombstone(ArchiveEntry $entry): void
|
||||
/**
|
||||
* Payloads ohne Archivzeile (abgebrochene Ablage, äußerer Rollback,
|
||||
* liegengebliebene .tmp) nach Karenzzeit entfernen (N11).
|
||||
*
|
||||
* @return array{scanned: int, orphans: int, removed: int}
|
||||
*/
|
||||
public function sweepOrphans(bool $dryRun = false, int $olderThanHours = 24): array
|
||||
{
|
||||
DB::transaction(function () use ($entry): void {
|
||||
if ($entry->payload_path !== null && $this->disk()->exists($entry->payload_path)) {
|
||||
$this->disk()->delete($entry->payload_path);
|
||||
$result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0];
|
||||
$threshold = now()->subHours($olderThanHours)->getTimestamp();
|
||||
|
||||
foreach (array_chunk($this->disk()->allFiles('entries'), 500) as $chunk) {
|
||||
$result['scanned'] += count($chunk);
|
||||
$known = ArchiveEntry::query()->whereIn('payload_path', $chunk)->pluck('payload_path')->all();
|
||||
|
||||
foreach (array_diff($chunk, $known) as $file) {
|
||||
if ($this->disk()->lastModified($file) > $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['orphans']++;
|
||||
|
||||
if (! $dryRun && FileDeletion::delete($this->disk(), $file)) {
|
||||
$result['removed']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unter Zeilensperre erneut prüfen, dann Datei bestätigt löschen, dann
|
||||
* Tombstone. Liefert false, wenn der Eintrag inzwischen nicht mehr
|
||||
* fällig ist (Hold gesetzt, Frist verlängert, schon Tombstone).
|
||||
*/
|
||||
private function tombstone(ArchiveEntry $entry): bool
|
||||
{
|
||||
$done = DB::transaction(function () use ($entry): bool {
|
||||
$locked = $this->lockedEntry($entry);
|
||||
|
||||
if ($locked->isTombstoned() || $locked->legal_hold || $locked->retain_until->gte(today())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($locked->payload_path !== null) {
|
||||
FileDeletion::deleteOrThrow($this->disk(), $locked->payload_path, 'Beweisarchiv');
|
||||
}
|
||||
|
||||
$this->purgeApplicationCopies($locked);
|
||||
|
||||
// Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei
|
||||
// wiederfindet; der Inhalt ist weg.
|
||||
$entry->forceFill(['deleted_at' => now()])->save();
|
||||
$locked->forceFill(['deleted_at' => now()])->save();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
|
||||
if ($done) {
|
||||
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
|
||||
}
|
||||
|
||||
return $done;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mit dem Archiv-Tombstone eines entfernten Bildes verschwinden auch die
|
||||
* Kopien in der Anwendung: Bestätigungen, Korrekturen und die weich
|
||||
* gelöschte Bildzeile (5.1 a). Das Archiv hielt die vollständige Kopie.
|
||||
*/
|
||||
private function purgeApplicationCopies(ArchiveEntry $entry): void
|
||||
{
|
||||
if (! in_array($entry->event, [ArchiveEvent::ImageRemoved, ArchiveEvent::ImageReplaced], true)
|
||||
|| $entry->subject_type !== (new PressReleaseImage)->getMorphClass()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$image = PressReleaseImage::withTrashed()->find($entry->subject_id);
|
||||
|
||||
if ($image !== null && ! $image->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImageRightsConfirmation::query()
|
||||
->where('subject_type', $entry->subject_type)
|
||||
->where('subject_id', $entry->subject_id)
|
||||
->get()
|
||||
->each(fn (ImageRightsConfirmation $confirmation) => $confirmation->purgeAfterArchive());
|
||||
|
||||
ImageDeclarationCorrection::query()
|
||||
->where('press_release_image_id', $entry->subject_id)
|
||||
->get()
|
||||
->each(fn (ImageDeclarationCorrection $correction) => $correction->purgeAfterArchive());
|
||||
|
||||
$image?->forceDelete();
|
||||
}
|
||||
|
||||
private function lockedEntry(ArchiveEntry $entry): ArchiveEntry
|
||||
{
|
||||
return ArchiveEntry::query()->whereKey($entry->getKey())->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
private function logAccess(ArchiveEntry $entry, User $admin, string $action, string $purpose): void
|
||||
|
|
|
|||
176
app/Services/Archive/PendingArchiveRetry.php
Normal file
176
app/Services/Archive/PendingArchiveRetry.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Archive;
|
||||
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Nachholer für Archiveinträge (Umsetzungsaudit 25.09.2026, N11): Der
|
||||
* Geschäftsvorgang setzt `press_releases.archive_pending_at` in seiner
|
||||
* Transaktion und löscht den Marker erst nach erfolgreichem Eintrag.
|
||||
* Bleibt er stehen, holt retry() alles nach, was seit dem Marker fehlt:
|
||||
*
|
||||
* - `pm_depublished`, wenn die Meldung nicht mehr veröffentlicht ist;
|
||||
* - `gdpr_change` je Redaction-Snapshot ohne Eintrag (Vorher-Stand aus dem
|
||||
* Snapshot, Nachher-Stand aus der Meldung);
|
||||
* - `image_removed` je weich gelöschtem v2-Bild ohne Eintrag.
|
||||
*/
|
||||
class PendingArchiveRetry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PressReleaseArchiver $archiver,
|
||||
private readonly PressReleaseImageRemover $remover,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{recorded: int, failed: int}
|
||||
*/
|
||||
public function retry(PressRelease $pressRelease): array
|
||||
{
|
||||
$result = ['recorded' => 0, 'failed' => 0];
|
||||
$since = $pressRelease->archive_pending_at;
|
||||
|
||||
if ($since === null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$snapshots = PressReleaseRedactionSnapshot::query()
|
||||
->where('press_release_id', $pressRelease->getKey())
|
||||
->where('created_at', '>=', $since->copy()->subMinute())
|
||||
->with('legalRequest')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$this->attempt($result, fn () => $this->retryDepublished($pressRelease, $since));
|
||||
|
||||
foreach ($snapshots as $snapshot) {
|
||||
$this->attempt($result, fn () => $this->retryGdprChange($pressRelease, $snapshot));
|
||||
}
|
||||
|
||||
$latestRequest = $snapshots->last()?->legalRequest;
|
||||
|
||||
foreach ($this->trashedImagesWithoutEntry($pressRelease) as $image) {
|
||||
$this->attempt($result, fn () => $this->remover->archiveTrashed(
|
||||
$image,
|
||||
null,
|
||||
ArchiveEvent::ImageRemoved,
|
||||
$latestRequest !== null ? ArchiveReasonCategory::Gdpr : ArchiveReasonCategory::OwnDecision,
|
||||
$latestRequest,
|
||||
'Nachgeholt durch archive:retry-pending',
|
||||
));
|
||||
}
|
||||
|
||||
if ($result['failed'] === 0) {
|
||||
$pressRelease->forceFill(['archive_pending_at' => null])->save();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{recorded: int, failed: int} $result
|
||||
* @param callable(): ?ArchiveEntry $step
|
||||
*/
|
||||
private function attempt(array &$result, callable $step): void
|
||||
{
|
||||
try {
|
||||
$entry = $step();
|
||||
} catch (Throwable $exception) {
|
||||
$result['failed']++;
|
||||
Log::error('Beweisarchiv: Nachholen eines Eintrags fehlgeschlagen.', ['error' => $exception->getMessage()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($entry === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result['recorded']++;
|
||||
}
|
||||
|
||||
private function retryDepublished(PressRelease $pressRelease, \DateTimeInterface $since): ?ArchiveEntry
|
||||
{
|
||||
if ($pressRelease->status === PressReleaseStatus::Published || $pressRelease->published_ever_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$exists = ArchiveEntry::query()
|
||||
->where('press_release_id', $pressRelease->getKey())
|
||||
->where('event', ArchiveEvent::PressReleaseDepublished->value)
|
||||
->where('created_at', '>=', $since)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pressRelease->loadMissing(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)]);
|
||||
|
||||
return $this->archiver->pressReleaseDepublished(
|
||||
$pressRelease,
|
||||
$pressRelease->depublished_at !== null ? ArchiveReasonCategory::CustomerRequest : ArchiveReasonCategory::OwnDecision,
|
||||
null,
|
||||
'Nachgeholt durch archive:retry-pending',
|
||||
);
|
||||
}
|
||||
|
||||
private function retryGdprChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot): ?ArchiveEntry
|
||||
{
|
||||
$exists = ArchiveEntry::query()
|
||||
->where('press_release_id', $pressRelease->getKey())
|
||||
->where('event', ArchiveEvent::GdprChange->value)
|
||||
->get(['id', 'summary'])
|
||||
->contains(fn (ArchiveEntry $entry): bool => (int) ($entry->summary['redaction_snapshot_id'] ?? 0) === $snapshot->getKey());
|
||||
|
||||
if ($exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = (array) ($snapshot->encrypted_before['fields'] ?? []);
|
||||
$legalRequest = $snapshot->legalRequest;
|
||||
|
||||
return $this->archiver->gdprChange(
|
||||
$pressRelease,
|
||||
$before,
|
||||
$pressRelease->only(array_keys($before)),
|
||||
$legalRequest,
|
||||
null,
|
||||
$legalRequest?->type === LegalRequestType::Correction ? ArchiveReasonCategory::Correction : ArchiveReasonCategory::Gdpr,
|
||||
'Nachgeholt durch archive:retry-pending',
|
||||
$snapshot->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PressReleaseImage>
|
||||
*/
|
||||
private function trashedImagesWithoutEntry(PressRelease $pressRelease): Collection
|
||||
{
|
||||
$morph = (new PressReleaseImage)->getMorphClass();
|
||||
|
||||
return PressReleaseImage::withTrashed()
|
||||
->where('press_release_id', $pressRelease->getKey())
|
||||
->whereNotNull('deleted_at')
|
||||
->whereNotNull('published_ever_at')
|
||||
->whereNotNull('rules_version')
|
||||
->get()
|
||||
->reject(fn (PressReleaseImage $image): bool => ArchiveEntry::query()
|
||||
->where('subject_type', $morph)
|
||||
->where('subject_id', $image->getKey())
|
||||
->whereIn('event', [ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value])
|
||||
->exists());
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,8 @@ use Illuminate\Support\Facades\Storage;
|
|||
* Baut die Archiv-Payloads für die Ereignisse aus Decision-Update 4.1 und
|
||||
* ruft das Beweisarchiv. Fachdaten liegen im Manifest, Dateien (Original,
|
||||
* veröffentlichte Fassung) im ZIP. Fehlt der Schlüssel, protokolliert das
|
||||
* Archiv den Ausfall; die Geschäftsvorgänge laufen trotzdem weiter.
|
||||
* Archiv den Ausfall; die Geschäftsvorgänge laufen trotzdem weiter. Der
|
||||
* Bezug zum Vorgang im Meldeverfahren liegt als Spalte am Eintrag (5.1 d).
|
||||
*/
|
||||
class PressReleaseArchiver
|
||||
{
|
||||
|
|
@ -28,7 +29,7 @@ class PressReleaseArchiver
|
|||
* Veröffentlichtes Bild entfernt oder ersetzt: Original, veröffentlichte
|
||||
* Fassung, Bestätigungen und Korrekturen wandern ins Archiv.
|
||||
*/
|
||||
public function imageRemoved(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest): ?ArchiveEntry
|
||||
public function imageRemoved(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?string $note = null): ?ArchiveEntry
|
||||
{
|
||||
$files = [];
|
||||
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
|
|
@ -61,15 +62,18 @@ class PressReleaseArchiver
|
|||
'license_url', 'source_url', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'ai_declaration_locked',
|
||||
'provenance_status', 'provenance', 'original_sha256', 'published_snapshot_sha256', 'rules_version',
|
||||
'published_ever_at', 'withheld_at', 'precheck', 'precheck_outcome', 'precheck_review_required_at',
|
||||
'precheck_reviewed_at', 'precheck_reviewed_by', 'precheck_review_note', 'created_at',
|
||||
'precheck_reviewed_at', 'precheck_reviewed_by', 'precheck_review_note', 'created_at', 'deleted_at',
|
||||
]),
|
||||
'credit_line' => $pressRelease ? ImageCredit::for($image, $pressRelease->company)->line() : null,
|
||||
'confirmations' => $image->rightsConfirmations()->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(),
|
||||
'corrections' => ImageDeclarationCorrection::query()->where('press_release_image_id', $image->getKey())->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(),
|
||||
'press_release' => $pressRelease ? $this->pressReleaseHeader($pressRelease) : null,
|
||||
'legal_request' => $this->legalRequestBlock($legalRequest),
|
||||
'note' => $note,
|
||||
],
|
||||
$files,
|
||||
['image_id' => $image->getKey(), 'files' => count($files)],
|
||||
['image_id' => $image->getKey(), 'files' => count($files), 'legal_request_id' => $legalRequest?->getKey()],
|
||||
$legalRequest?->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -130,12 +134,13 @@ class PressReleaseArchiver
|
|||
|
||||
/**
|
||||
* DSGVO-Änderung (Schwärzung, Korrektur): Diff alt/neu mit Verweis auf
|
||||
* den Antrag.
|
||||
* den Antrag. Der Vorher-Stand kommt aus dem Redaction-Snapshot, damit
|
||||
* eine Wiederholung denselben Nachweis liefert (N11).
|
||||
*
|
||||
* @param array<string, mixed> $before
|
||||
* @param array<string, mixed> $after
|
||||
*/
|
||||
public function gdprChange(PressRelease $pressRelease, array $before, array $after, ?LegalRequest $legalRequest, ?User $actor = null, ArchiveReasonCategory $reason = ArchiveReasonCategory::Gdpr, ?string $note = null): ?ArchiveEntry
|
||||
public function gdprChange(PressRelease $pressRelease, array $before, array $after, ?LegalRequest $legalRequest, ?User $actor = null, ArchiveReasonCategory $reason = ArchiveReasonCategory::Gdpr, ?string $note = null, ?int $redactionSnapshotId = null): ?ArchiveEntry
|
||||
{
|
||||
return $this->archive->record(
|
||||
ArchiveEvent::GdprChange,
|
||||
|
|
@ -148,16 +153,13 @@ class PressReleaseArchiver
|
|||
'press_release' => $this->pressReleaseHeader($pressRelease),
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'legal_request' => $legalRequest ? [
|
||||
'id' => $legalRequest->getKey(),
|
||||
'reference' => $legalRequest->reference(),
|
||||
'type' => $legalRequest->type->value,
|
||||
'requester_email' => $legalRequest->requester_email,
|
||||
] : null,
|
||||
'legal_request' => $this->legalRequestBlock($legalRequest),
|
||||
'redaction_snapshot_id' => $redactionSnapshotId,
|
||||
'note' => $note,
|
||||
],
|
||||
[],
|
||||
['fields' => count(array_keys($after)), 'legal_request_id' => $legalRequest?->getKey()],
|
||||
['fields' => count(array_keys($after)), 'legal_request_id' => $legalRequest?->getKey(), 'redaction_snapshot_id' => $redactionSnapshotId],
|
||||
$legalRequest?->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +184,23 @@ class PressReleaseArchiver
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function legalRequestBlock(?LegalRequest $legalRequest): ?array
|
||||
{
|
||||
if ($legalRequest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $legalRequest->getKey(),
|
||||
'reference' => $legalRequest->reference(),
|
||||
'type' => $legalRequest->type->value,
|
||||
'requester_email' => $legalRequest->requester_email,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ class OpenAiImagePrecheckDriver implements ImagePrecheckDriver
|
|||
throw new RuntimeException('OpenAI-Bildsichtung lieferte ein unvollständiges Schema.');
|
||||
}
|
||||
|
||||
$this->assertSchema($decoded);
|
||||
|
||||
return ImagePrecheckResult::ok($decoded, 'openai', $model, null, [
|
||||
'prompt_version' => (string) config('images.precheck.prompt_version'),
|
||||
'sent_size' => [$prepared['width'], $prepared['height']],
|
||||
|
|
@ -95,6 +97,41 @@ class OpenAiImagePrecheckDriver implements ImagePrecheckDriver
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strikte Prüfung der Wertstruktur (Audit N20): Ein Schlüssel allein
|
||||
* reicht nicht — alle Kategorien auf `null`, leere Objekte, String-
|
||||
* Booleans („false" ist in PHP wahr) oder Konfidenzen außerhalb 0…1
|
||||
* ergäben sonst ein scheinbar belastbares `ok` ohne Treffer. Formal
|
||||
* unbrauchbare Antworten gehen als Ausnahme in den bestehenden
|
||||
* `error`-Pfad (Block K.1: durchlassen mit Vermerk, nie blockieren).
|
||||
*
|
||||
* @param array<string, mixed> $decoded
|
||||
*/
|
||||
private function assertSchema(array $decoded): void
|
||||
{
|
||||
foreach (ImagePrecheckCategory::cases() as $category) {
|
||||
$entry = $decoded[$category->value];
|
||||
|
||||
if (! is_array($entry)) {
|
||||
throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value} ist kein Objekt.");
|
||||
}
|
||||
|
||||
if (! array_key_exists('hit', $entry) || ! is_bool($entry['hit'])) {
|
||||
throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.hit ist kein Boolean.");
|
||||
}
|
||||
|
||||
$confidence = $entry['confidence'] ?? null;
|
||||
|
||||
if (! (is_int($confidence) || is_float($confidence)) || ! is_finite($confidence) || $confidence < 0 || $confidence > 1) {
|
||||
throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.confidence liegt nicht in 0…1.");
|
||||
}
|
||||
|
||||
if (array_key_exists('note', $entry) && $entry['note'] !== null && ! is_string($entry['note'])) {
|
||||
throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.note ist kein Text.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function systemPrompt(): string
|
||||
{
|
||||
$keys = implode(', ', array_map(fn (ImagePrecheckCategory $c): string => $c->value, ImagePrecheckCategory::cases()));
|
||||
|
|
|
|||
|
|
@ -124,7 +124,9 @@ final readonly class ImagePrecheckResult
|
|||
}
|
||||
|
||||
$confidence = isset($entry['confidence']) && is_numeric($entry['confidence']) ? (float) $entry['confidence'] : 0.0;
|
||||
$hit = (bool) ($entry['hit'] ?? false);
|
||||
// Nur ein echtes `true` zählt (Audit N20): der String "false" wäre
|
||||
// per Cast wahr. Die strikte Schemaprüfung sitzt im Treiber.
|
||||
$hit = ($entry['hit'] ?? false) === true;
|
||||
$note = isset($entry['note']) && is_string($entry['note']) && trim($entry['note']) !== '' ? mb_substr(trim($entry['note']), 0, 200) : null;
|
||||
|
||||
$normalized[$category->value] = [
|
||||
|
|
|
|||
|
|
@ -3,16 +3,23 @@
|
|||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Archive\PressReleaseArchiver;
|
||||
use App\Services\Image\ImageService;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Schwärzung personenbezogener Daten in einer Pressemitteilung
|
||||
|
|
@ -23,7 +30,9 @@ use Illuminate\Support\Facades\Storage;
|
|||
* und sichert die Vorher-Werte verschlüsselt (Reversibilität, nie Klartext
|
||||
* in Logs). Bewusst OHNE KI-Gate und OHNE Credit-Bezug — der DSGVO-Pfad
|
||||
* ist Compliance-Pflicht (Art. 12 Abs. 5) und darf an keinem Freigabelauf
|
||||
* oder Bezahlschritt hängen.
|
||||
* oder Bezahlschritt hängen. Deshalb blockiert auch das Beweisarchiv
|
||||
* nicht: Ein fehlgeschlagener Eintrag lässt `archive_pending_at` stehen
|
||||
* und wird nachgeholt (N11).
|
||||
*/
|
||||
class AnonymizationService
|
||||
{
|
||||
|
|
@ -32,7 +41,12 @@ class AnonymizationService
|
|||
|
||||
public const DEFAULT_REPLACEMENT = '[entfernt]';
|
||||
|
||||
public function __construct(private readonly ActivityLog $activityLog) {}
|
||||
public function __construct(
|
||||
private readonly ActivityLog $activityLog,
|
||||
private readonly PressReleaseImageRemover $imageRemover,
|
||||
private readonly PressReleaseArchiver $archiver,
|
||||
private readonly ImageService $imageService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Pflicht-Vorschau: Was würde ersetzt? Verändert die PM nicht.
|
||||
|
|
@ -65,7 +79,8 @@ class AnonymizationService
|
|||
|
||||
/**
|
||||
* Führt die Schwärzung aus: Snapshot → Text-Replace → Kontakte lösen →
|
||||
* Bilder/Anhänge entfernen → Re-Sync-Sperre → Cache-Invalidierung →
|
||||
* Anhänge entfernen → Re-Sync-Sperre → Commit → Bilder über den
|
||||
* Remover (Beweisarchiv, N12) → Archiveintrag → Cache-Invalidierung →
|
||||
* Verlaufs-Eintrag ohne Klartext-Altwerte.
|
||||
*
|
||||
* @param list<string> $terms
|
||||
|
|
@ -82,10 +97,11 @@ class AnonymizationService
|
|||
?LegalRequest $legalRequest = null,
|
||||
): PressReleaseRedactionSnapshot {
|
||||
$totalHits = 0;
|
||||
$images = collect();
|
||||
|
||||
$snapshot = DB::transaction(function () use (
|
||||
$pressRelease, $terms, $replacement, $detachContacts,
|
||||
$removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits,
|
||||
$removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits, &$images,
|
||||
): PressReleaseRedactionSnapshot {
|
||||
$contactIds = $detachContacts
|
||||
? $pressRelease->contacts()->pluck('contacts.id')->all()
|
||||
|
|
@ -103,8 +119,6 @@ class AnonymizationService
|
|||
'replacement' => $replacement,
|
||||
], $legalRequest);
|
||||
|
||||
$changedFields = [];
|
||||
|
||||
foreach (self::REDACTABLE_FIELDS as $field) {
|
||||
$value = (string) ($pressRelease->getAttribute($field) ?? '');
|
||||
|
||||
|
|
@ -116,30 +130,18 @@ class AnonymizationService
|
|||
|
||||
if ($hits > 0) {
|
||||
$pressRelease->setAttribute($field, $after);
|
||||
$changedFields[] = $field;
|
||||
$totalHits += $hits;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changedFields !== []) {
|
||||
$pressRelease->save();
|
||||
}
|
||||
// Marker „Archivierung steht aus" in derselben Transaktion (N11).
|
||||
$pressRelease->setAttribute('archive_pending_at', now());
|
||||
$pressRelease->save();
|
||||
|
||||
if ($contactIds !== []) {
|
||||
$pressRelease->contacts()->detach($contactIds);
|
||||
}
|
||||
|
||||
// Klarname in Pressebild/PDF: Zeile soft-löschen (raus aus der
|
||||
// Auslieferung); auf der public-Disk zusätzlich die Dateien
|
||||
// entfernen, weil sie sonst per Direkt-URL erreichbar blieben.
|
||||
foreach ($images as $image) {
|
||||
$this->deletePublicFiles($image->disk, array_filter([
|
||||
$image->path,
|
||||
...array_values((array) ($image->variants ?? [])),
|
||||
]));
|
||||
$image->delete();
|
||||
}
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
$this->deletePublicFiles($attachment->disk, array_filter([$attachment->path]));
|
||||
$attachment->delete();
|
||||
|
|
@ -152,20 +154,24 @@ class AnonymizationService
|
|||
return $snapshot;
|
||||
});
|
||||
|
||||
// Klarname in Pressebild/PDF: über den gemeinsamen Lebenszyklus
|
||||
// (Beweisarchiv bei veröffentlichten Bildern, Sofortlöschung sonst).
|
||||
$archiveFailed = false;
|
||||
|
||||
foreach ($images as $image) {
|
||||
$archiveFailed = ! $this->removeImage($image, $legalRequest) || $archiveFailed;
|
||||
}
|
||||
|
||||
$this->forgetSitemapCaches();
|
||||
|
||||
// Beweisarchiv (Block F.4): Diff alt/neu mit Antrag — dort dürfen die
|
||||
// Altwerte liegen (verschlüsselt, zugriffsprotokolliert), im
|
||||
// Aktivitätsverlauf nicht.
|
||||
app(PressReleaseArchiver::class)->gdprChange(
|
||||
$pressRelease,
|
||||
(array) ($snapshot->encrypted_before['fields'] ?? []),
|
||||
$pressRelease->fresh()?->only(self::REDACTABLE_FIELDS) ?? [],
|
||||
$legalRequest,
|
||||
Auth::user(),
|
||||
ArchiveReasonCategory::Gdpr,
|
||||
'Anonymisierung: '.count($terms).' Begriff(e), '.count($removeImageIds).' Bild(er), '.count($contactIdsForLog = $snapshot->encrypted_before['contact_ids'] ?? []).' Kontakt(e)',
|
||||
);
|
||||
// Aktivitätsverlauf nicht. Vorher-Stand aus dem Snapshot (N11).
|
||||
$entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $terms, $removeImageIds);
|
||||
|
||||
if ($entry !== null && ! $archiveFailed) {
|
||||
$pressRelease->forceFill(['archive_pending_at' => null])->save();
|
||||
}
|
||||
|
||||
// Bewusst ohne Alt-Werte und ohne Schwärzbegriffe — sonst leben die
|
||||
// personenbezogenen Daten im Verlauf weiter.
|
||||
|
|
@ -181,12 +187,67 @@ class AnonymizationService
|
|||
),
|
||||
user: Auth::user(),
|
||||
subject: $pressRelease,
|
||||
meta: ['legal_request_id' => $legalRequest?->getKey(), 'snapshot_id' => $snapshot->getKey()],
|
||||
meta: ['legal_request_id' => $legalRequest?->getKey(), 'snapshot_id' => $snapshot->getKey(), 'archive_entry_id' => $entry?->getKey()],
|
||||
);
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bild über den Remover entfernen (N12). Scheitert das Archiv, muss der
|
||||
* Klarname trotzdem sofort aus der Auslieferung: Derivate weg, Soft
|
||||
* Delete; der Nachholer archiviert das weich gelöschte Bild später.
|
||||
*/
|
||||
private function removeImage(PressReleaseImage $image, ?LegalRequest $legalRequest): bool
|
||||
{
|
||||
try {
|
||||
$this->imageRemover->remove($image, Auth::user(), ArchiveEvent::ImageRemoved, ArchiveReasonCategory::Gdpr, $legalRequest);
|
||||
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Beweisarchiv: Bild aus Anonymisierung konnte nicht überführt werden, wird nachgeholt.', [
|
||||
'press_release_image_id' => $image->getKey(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
|
||||
if (! $image->trashed()) {
|
||||
$image->delete();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $terms
|
||||
* @param list<int> $removeImageIds
|
||||
*/
|
||||
private function archiveChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, ?LegalRequest $legalRequest, array $terms, array $removeImageIds): ?ArchiveEntry
|
||||
{
|
||||
try {
|
||||
return $this->archiver->gdprChange(
|
||||
$pressRelease,
|
||||
(array) ($snapshot->encrypted_before['fields'] ?? []),
|
||||
$pressRelease->fresh()?->only(self::REDACTABLE_FIELDS) ?? [],
|
||||
$legalRequest,
|
||||
Auth::user(),
|
||||
ArchiveReasonCategory::Gdpr,
|
||||
'Anonymisierung: '.count($terms).' Begriff(e), '.count($removeImageIds).' Bild(er), '.count($snapshot->encrypted_before['contact_ids'] ?? []).' Kontakt(e)',
|
||||
$snapshot->getKey(),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Beweisarchiv: Anonymisierung konnte nicht archiviert werden, wird nachgeholt.', [
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'snapshot_id' => $snapshot->getKey(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzung über Wortgrenzen: Lookarounds auf Unicode-Buchstaben/Ziffern
|
||||
* statt \b, damit Umlaute korrekt begrenzen und weder Teiltreffer
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ class CanonicalAssigner
|
|||
}
|
||||
|
||||
if ($pressRelease->canonical_press_release_id !== null) {
|
||||
$canonical = PressRelease::query()->withoutGlobalScopes()->find($pressRelease->canonical_press_release_id);
|
||||
// Nur der PortalScope entfällt — eine soft-gelöschte Erstquelle
|
||||
// liefert 404 und darf kein Canonical-Ziel sein.
|
||||
$canonical = PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressRelease->canonical_press_release_id);
|
||||
|
||||
if ($canonical !== null && $canonical->isVisibleInArchive()) {
|
||||
return $this->publicUrl($canonical, $canonical->portal === Portal::Both ? ($this->preferredPortal($canonical) ?? $viewedOn) : $canonical->portal);
|
||||
|
|
@ -131,6 +133,31 @@ class CanonicalAssigner
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenstücke einer Erstquelle neu bewerten, nachdem diese aus der
|
||||
* öffentlichen Ansicht verschwunden ist (Depublikation, Gone, Löschung,
|
||||
* Statuswechsel). Jede Kopie verliert ihren Verweis und wird — falls ein
|
||||
* anderes veröffentlichtes Gegenstück existiert — neu zugeordnet; sonst
|
||||
* ist sie fortan selbst Erstquelle und gehört wieder in die Sitemap.
|
||||
*/
|
||||
public function reassignCopiesOf(PressRelease $former): void
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$copies = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->where('canonical_press_release_id', $former->getKey())
|
||||
->get();
|
||||
|
||||
foreach ($copies as $copy) {
|
||||
$this->write($copy->getKey(), null);
|
||||
$copy->canonical_press_release_id = null;
|
||||
$this->assignFor($copy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Paare im Bestand zuordnen.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Enums\ArchiveReasonCategory;
|
|||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\LegalRequestVerificationMethod;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
|
|
@ -18,6 +19,7 @@ use App\Services\PressRelease\Classification\ClassificationResult;
|
|||
use App\Support\WebCaches;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Kleine Korrektur (Meldeverfahren v2, Spur A — Phase 4): Selbstbedienungs-
|
||||
|
|
@ -60,7 +62,7 @@ class CorrectionService
|
|||
|
||||
try {
|
||||
return $this->classification->driver()->classify($proposed);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('ki')->warning('KI-Vorprüfung (Spur D) fehlgeschlagen — deterministischer Fallback.', [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'error' => $e->getMessage(),
|
||||
|
|
@ -150,9 +152,7 @@ class CorrectionService
|
|||
|
||||
$this->assertNotRateLimited($pressRelease);
|
||||
|
||||
$before = $pressRelease->only(self::CORRECTABLE_FIELDS);
|
||||
|
||||
$legalRequest = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification): LegalRequest {
|
||||
[$legalRequest, $snapshot] = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification): array {
|
||||
$legalRequest = LegalRequest::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'portal' => $pressRelease->portal?->value,
|
||||
|
|
@ -170,27 +170,20 @@ class CorrectionService
|
|||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
PressReleaseRedactionSnapshot::capture($pressRelease, [
|
||||
$snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [
|
||||
'fields' => $pressRelease->only(self::CORRECTABLE_FIELDS),
|
||||
], $legalRequest);
|
||||
|
||||
$this->writeChangedFields($pressRelease, $classification['fields'], $newValues);
|
||||
$pressRelease->forceFill(['archive_pending_at' => now()])->save();
|
||||
$pressRelease->lockSync('Kleine Korrektur '.$legalRequest->reference());
|
||||
|
||||
return $legalRequest;
|
||||
return [$legalRequest, $snapshot];
|
||||
});
|
||||
|
||||
WebCaches::forgetSitemaps();
|
||||
|
||||
app(PressReleaseArchiver::class)->gdprChange(
|
||||
$pressRelease,
|
||||
$before,
|
||||
$pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [],
|
||||
$legalRequest,
|
||||
$actor,
|
||||
ArchiveReasonCategory::Correction,
|
||||
'Kleine Korrektur (Selbstbedienung)',
|
||||
);
|
||||
$entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $actor, 'Kleine Korrektur (Selbstbedienung)');
|
||||
|
||||
$changedFields = collect($classification['fields'])
|
||||
->filter(fn (array $meta): bool => $meta['changed'])
|
||||
|
|
@ -207,7 +200,7 @@ class CorrectionService
|
|||
),
|
||||
user: $actor,
|
||||
subject: $pressRelease,
|
||||
meta: ['legal_request_id' => $legalRequest->id],
|
||||
meta: ['legal_request_id' => $legalRequest->id, 'archive_entry_id' => $entry?->getKey()],
|
||||
);
|
||||
|
||||
// Re-Check ohne Status-Wirkung — konsistent mit dem bestehenden
|
||||
|
|
@ -245,9 +238,7 @@ class CorrectionService
|
|||
return false;
|
||||
}
|
||||
|
||||
$before = $pressRelease->only(self::CORRECTABLE_FIELDS);
|
||||
|
||||
DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues): void {
|
||||
$snapshot = DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues): PressReleaseRedactionSnapshot {
|
||||
$legalRequest->forceFill([
|
||||
'status' => LegalRequestStatus::Resolved->value,
|
||||
'payload' => array_merge($legalRequest->payload ?? [], ['fields' => $classification['fields']]),
|
||||
|
|
@ -255,25 +246,20 @@ class CorrectionService
|
|||
'executed_at' => now(),
|
||||
])->save();
|
||||
|
||||
PressReleaseRedactionSnapshot::capture($pressRelease, [
|
||||
$snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [
|
||||
'fields' => $pressRelease->only(self::CORRECTABLE_FIELDS),
|
||||
], $legalRequest);
|
||||
|
||||
$this->writeChangedFields($pressRelease, $classification['fields'], $newValues);
|
||||
$pressRelease->forceFill(['archive_pending_at' => now()])->save();
|
||||
$pressRelease->lockSync('Kostenpflichtige Änderung '.$legalRequest->reference());
|
||||
|
||||
return $snapshot;
|
||||
});
|
||||
|
||||
WebCaches::forgetSitemaps();
|
||||
|
||||
app(PressReleaseArchiver::class)->gdprChange(
|
||||
$pressRelease,
|
||||
$before,
|
||||
$pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [],
|
||||
$legalRequest,
|
||||
$actor,
|
||||
ArchiveReasonCategory::Correction,
|
||||
'Kostenpflichtige Änderung',
|
||||
);
|
||||
$entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $actor, 'Kostenpflichtige Änderung');
|
||||
|
||||
$changedFields = collect($classification['fields'])
|
||||
->filter(fn (array $meta): bool => $meta['changed'])
|
||||
|
|
@ -290,7 +276,7 @@ class CorrectionService
|
|||
),
|
||||
user: $actor,
|
||||
subject: $pressRelease,
|
||||
meta: ['legal_request_id' => $legalRequest->id],
|
||||
meta: ['legal_request_id' => $legalRequest->id, 'archive_entry_id' => $entry?->getKey()],
|
||||
);
|
||||
|
||||
$this->pressReleaseService->reprocessAfterContentChange($pressRelease);
|
||||
|
|
@ -304,6 +290,41 @@ class CorrectionService
|
|||
* @param array<string, array{distance_percent: float, changed: bool}> $classifiedFields
|
||||
* @param array<string, string|null> $newValues
|
||||
*/
|
||||
/**
|
||||
* Beweisarchiv (F.4): Vorher-Stand aus dem Snapshot, Nachher-Stand aus
|
||||
* der Meldung. Scheitert die Ablage, bleibt `archive_pending_at`
|
||||
* stehen und `archive:retry-pending` holt den Eintrag nach (N11).
|
||||
*/
|
||||
private function archiveChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor, string $note): ?ArchiveEntry
|
||||
{
|
||||
try {
|
||||
$entry = app(PressReleaseArchiver::class)->gdprChange(
|
||||
$pressRelease,
|
||||
(array) ($snapshot->encrypted_before['fields'] ?? []),
|
||||
$pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [],
|
||||
$legalRequest,
|
||||
$actor,
|
||||
ArchiveReasonCategory::Correction,
|
||||
$note,
|
||||
$snapshot->getKey(),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Beweisarchiv: Korrektur konnte nicht archiviert werden, wird nachgeholt.', [
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'legal_request_id' => $legalRequest->getKey(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($entry !== null) {
|
||||
$pressRelease->forceFill(['archive_pending_at' => null])->save();
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
private function writeChangedFields(PressRelease $pressRelease, array $classifiedFields, array $newValues): void
|
||||
{
|
||||
foreach ($classifiedFields as $field => $meta) {
|
||||
|
|
|
|||
|
|
@ -31,11 +31,13 @@ class FeedFetchReport
|
|||
// letzter Abruf); die Verdichtung je Reader passiert in PHP — das
|
||||
// bleibt auf MySQL und SQLite gleich. Abonnenten: je Feed das Maximum,
|
||||
// dann summiert — ein Dienst holt einen Feed viele Male, die Zahl darf
|
||||
// nicht mitwachsen.
|
||||
// nicht mitwachsen. Ein Feed ist Art + Schlüssel + Portal + Sprache,
|
||||
// wie in topFeeds() und fetchesFor(): der Portalfeed von presseecho
|
||||
// und der von businessportal24 sind zwei Feeds mit zwei Abonnentenzahlen.
|
||||
$rows = FeedFetch::query()
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->selectRaw('reader, feed, feed_key, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at')
|
||||
->groupBy('reader', 'feed', 'feed_key')
|
||||
->selectRaw('reader, feed, feed_key, portal, language, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at')
|
||||
->groupBy('reader', 'feed', 'feed_key', 'portal', 'language')
|
||||
->toBase()
|
||||
->get()
|
||||
->groupBy(fn (object $row): string => (string) $row->reader);
|
||||
|
|
|
|||
|
|
@ -104,13 +104,16 @@ class ImageCorrectionService
|
|||
$this->refreshOgVariant($image->fresh());
|
||||
}
|
||||
|
||||
// Nie veröffentlicht: kein Archiveintrag (DU 4.1, Umsetzungsaudit
|
||||
// 5.1 b) — die Korrekturzeile dokumentiert den Vorgang; wird das Bild
|
||||
// später veröffentlicht und entfernt, wandern alle Korrekturen mit
|
||||
// dem image_removed-Eintrag ins Archiv.
|
||||
if ($image->wasEverPublished()) {
|
||||
$this->snapshots->capture($image->fresh());
|
||||
WebCaches::forgetPublicPages();
|
||||
$this->archiver->labelCorrected($image->fresh(), $correction, $admin);
|
||||
}
|
||||
|
||||
$this->archiver->labelCorrected($image->fresh(), $correction, $admin);
|
||||
|
||||
return $correction;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@ namespace App\Services\PressRelease\Images;
|
|||
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Services\Archive\PressReleaseArchiver;
|
||||
use App\Services\Image\ImageService;
|
||||
use App\Support\FileDeletion;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
|
|
@ -16,10 +20,11 @@ use Illuminate\Support\Facades\Storage;
|
|||
* Entfernt ein Titelbild (Bildkennzeichnung E.3, Decision-Update E4):
|
||||
*
|
||||
* - nie veröffentlicht: Derivate, Original, Snapshot und Bestätigung sofort
|
||||
* und vollständig weg, die Zeile wird hart gelöscht;
|
||||
* - veröffentlicht: Derivate weg, Zeile weich gelöscht; Original, Snapshot
|
||||
* und Bestätigung bleiben für das Beweisarchiv (Block F übernimmt die
|
||||
* Überführung und die Frist).
|
||||
* und vollständig weg, die Zeile wird hart gelöscht — schlägt eine
|
||||
* Dateilöschung fehl, bleibt die Zeile für den nächsten Versuch (5.1 e);
|
||||
* - veröffentlicht: Original, Snapshot und Bestätigung ins Beweisarchiv
|
||||
* (Block F), danach die lokalen Beweisdateien weg und die Pfade an der
|
||||
* Zeile geleert; Derivate weg, Zeile weich gelöscht.
|
||||
*
|
||||
* Bestand ohne `rules_version` hat weder Original noch Protokoll und wird
|
||||
* wie bisher behandelt (Derivate weg, Soft Delete).
|
||||
|
|
@ -31,21 +36,16 @@ class PressReleaseImageRemover
|
|||
private readonly PressReleaseArchiver $archiver,
|
||||
) {}
|
||||
|
||||
public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest): void
|
||||
public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null): void
|
||||
{
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
|
||||
if ($image->wasEverPublished() || $image->isLegacyRules()) {
|
||||
// Veröffentlicht: Original, Snapshot und Bestätigungen ins
|
||||
// Beweisarchiv (Block F), danach die lokalen Beweisdateien weg.
|
||||
// Veröffentlicht: erst der Archiveintrag, dann verschwinden die
|
||||
// Derivate — ein Archivfehler lässt das Bild unangetastet.
|
||||
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
|
||||
$entry = $this->archiver->imageRemoved($image, $actor, $event, $reason);
|
||||
|
||||
if ($entry !== null) {
|
||||
$this->deleteEvidenceFiles($image);
|
||||
}
|
||||
$this->archiveTrashed($image, $actor, $event, $reason, $legalRequest);
|
||||
}
|
||||
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
$image->delete();
|
||||
WebCaches::forgetPublicPages();
|
||||
|
||||
|
|
@ -57,32 +57,79 @@ class PressReleaseImageRemover
|
|||
$confirmation->purgeForUnpublished();
|
||||
}
|
||||
|
||||
if ($image->original_path !== null) {
|
||||
Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->original_path);
|
||||
}
|
||||
$disk = $this->evidenceDisk($image);
|
||||
|
||||
if ($image->published_snapshot_path !== null) {
|
||||
Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->published_snapshot_path);
|
||||
foreach (array_filter([$image->original_path, $image->published_snapshot_path]) as $path) {
|
||||
FileDeletion::deleteOrThrow($disk, $path, 'Sofortlöschung Bild #'.$image->getKey());
|
||||
}
|
||||
|
||||
$image->forceDelete();
|
||||
});
|
||||
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archiveintrag für ein veröffentlichtes v2-Bild anlegen und danach die
|
||||
* lokalen Beweisdateien entfernen. Auch für den Nachholer
|
||||
* (archive:retry-pending), wenn ein Bild bereits weich gelöscht ist,
|
||||
* aber kein Eintrag existiert.
|
||||
*/
|
||||
public function archiveTrashed(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?string $note = null): ?ArchiveEntry
|
||||
{
|
||||
$entry = $this->archiver->imageRemoved($image, $actor, $event, $reason, $legalRequest, $note);
|
||||
|
||||
if ($entry !== null) {
|
||||
$this->deleteEvidenceFiles($image);
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lokale Beweisdateien nach erfolgreicher Überführung entfernen. Ein
|
||||
* Fehlschlag betrifft nur die Datenminimierung — er wird protokolliert,
|
||||
* die Pfade bleiben stehen, der Systemstatus zählt sie (5.1 e).
|
||||
*/
|
||||
private function deleteEvidenceFiles(PressReleaseImage $image): void
|
||||
{
|
||||
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
|
||||
foreach (array_filter([$image->original_path, $image->published_snapshot_path]) as $path) {
|
||||
$disk->delete($path);
|
||||
}
|
||||
$disk = $this->evidenceDisk($image);
|
||||
$paths = array_values(array_filter([$image->original_path, $image->published_snapshot_path]));
|
||||
|
||||
if ($image->published_snapshot_path !== null) {
|
||||
$sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $image->published_snapshot_path);
|
||||
|
||||
if (is_string($sidecar)) {
|
||||
$disk->delete($sidecar);
|
||||
$paths[] = $sidecar;
|
||||
}
|
||||
}
|
||||
|
||||
$remaining = FileDeletion::deleteOrLog($disk, $paths, 'Beweisdateien Bild #'.$image->getKey());
|
||||
|
||||
// Pfade an der Zeile leeren (nur die gelöschten) — über die Query,
|
||||
// damit die übergebene Instanz für den Aufrufer unverändert bleibt.
|
||||
PressReleaseImage::withTrashed()->whereKey($image->getKey())->update([
|
||||
'original_path' => in_array($image->original_path, $remaining, true) ? $image->original_path : null,
|
||||
'published_snapshot_path' => in_array($image->published_snapshot_path, $remaining, true) ? $image->published_snapshot_path : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernte v2-Bilder, deren lokale Beweisdateien noch nicht bereinigt
|
||||
* sind (Überführung ausstehend oder Löschung fehlgeschlagen) — für den
|
||||
* Admin-Systemstatus (5.1 e). Die Pfade bleiben hier gekapselt (E.4).
|
||||
*/
|
||||
public static function countLocalEvidenceLeftovers(): int
|
||||
{
|
||||
return PressReleaseImage::query()->onlyTrashed()
|
||||
->whereNotNull('rules_version')
|
||||
->whereNotNull('published_ever_at')
|
||||
->where(fn ($q) => $q->whereNotNull('original_path')->orWhereNotNull('published_snapshot_path'))
|
||||
->count();
|
||||
}
|
||||
|
||||
private function evidenceDisk(PressReleaseImage $image): Filesystem
|
||||
{
|
||||
return Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Enums\AiDeclarationSource;
|
|||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\CreditSource;
|
||||
use App\Enums\ImagePrecheckOutcome;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\LegalText;
|
||||
use App\Models\PressRelease;
|
||||
|
|
@ -38,9 +39,11 @@ use Throwable;
|
|||
* 4. Derivate über ImageService; bei Fehler alles zurückrollen.
|
||||
* 5. Effektive Kennzeichnung bestimmen: Kundenangabe und geprüfter Befund
|
||||
* getrennt; in Phase 1 belegt ein ungesicherter Befund nur vor.
|
||||
* 6. Transaktion mit Sperre auf der PM: ein Titelbild, Bildzeile,
|
||||
* Bestätigung (außer Admin-Upload), Regelversion.
|
||||
* 7. DB-Fehler → Dateien wieder löschen.
|
||||
* 6. Transaktion mit Sperre auf der PM: erlaubter Status (Audit N07),
|
||||
* ein Titelbild, Bildzeile, Bestätigung (außer Admin-Upload),
|
||||
* Regelversion.
|
||||
* 7. DB-Fehler → Dateien wieder löschen. Nachgelagerte Schritte (Cache)
|
||||
* laufen erst nach dem Commit und außerhalb der Bereinigung (Audit N08).
|
||||
*
|
||||
* Für Newsroom-Bilder (Block J) wird das Subjekt verallgemeinert; Phase 1
|
||||
* kennt nur PressRelease.
|
||||
|
|
@ -52,6 +55,7 @@ class PressReleaseImageUploader
|
|||
private readonly ImageProvenanceInspector $inspector,
|
||||
private readonly ImageRightsTextRenderer $textRenderer,
|
||||
private readonly ImagePrecheckService $precheck,
|
||||
private readonly PublishedImageSnapshot $snapshot,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -85,6 +89,15 @@ class PressReleaseImageUploader
|
|||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* `$allowedStatuses` ist die Status-Regel des Aufrufers (Kunde: Draft/
|
||||
* Rejected, Admin: alles außer Archived). Sie wird unter der Sperre
|
||||
* erneut geprüft, damit ein Statuswechsel während Vorab-Check und
|
||||
* Variantenerzeugung den Upload nicht überholt (Audit N07). Null = keine
|
||||
* Regel (nur für Aufrufer, die den Status selbst sperren).
|
||||
*
|
||||
* @param list<PressReleaseStatus>|null $allowedStatuses
|
||||
*/
|
||||
public function store(
|
||||
UploadedFile $upload,
|
||||
PressRelease $pressRelease,
|
||||
|
|
@ -93,6 +106,7 @@ class PressReleaseImageUploader
|
|||
?string $expectedSha256 = null,
|
||||
?string $agbUrl = null,
|
||||
?ImagePrecheckResult $precheck = null,
|
||||
?array $allowedStatuses = null,
|
||||
): PressReleaseImage {
|
||||
$this->assertWithinLimits($upload);
|
||||
|
||||
|
|
@ -150,13 +164,17 @@ class PressReleaseImageUploader
|
|||
ogCaption: ImageCredit::compose($submission->copyright, $effective['declaration']?->publicLabel()),
|
||||
);
|
||||
|
||||
$image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective, $precheck, $precheckEvaluation): PressReleaseImage {
|
||||
$image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective, $precheck, $precheckEvaluation, $allowedStatuses): PressReleaseImage {
|
||||
$locked = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressRelease->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($allowedStatuses !== null && ! in_array($locked->status, $allowedStatuses, true)) {
|
||||
throw new ImageUploadException(__('Der Status der Meldung hat sich während des Uploads geändert. Bitte laden Sie die Seite neu.'));
|
||||
}
|
||||
|
||||
if ($locked->images()->exists()) {
|
||||
throw new ImageUploadException(__('Bitte löschen Sie zuerst das vorhandene Titelbild.'));
|
||||
}
|
||||
|
|
@ -210,17 +228,11 @@ class PressReleaseImageUploader
|
|||
|
||||
return $image;
|
||||
});
|
||||
|
||||
// Öffentliche Seiten-Caches nur verwerfen, wenn die PM schon
|
||||
// öffentlich ist (Admin-Bildtausch, D.5) — Entwürfe sind nicht sichtbar.
|
||||
if ($pressRelease->published_at !== null) {
|
||||
WebCaches::forgetPublicPages();
|
||||
}
|
||||
|
||||
return $image;
|
||||
} catch (Throwable $exception) {
|
||||
// Kein Rollback für Dateien: Was geschrieben wurde, wird
|
||||
// ausdrücklich wieder entfernt (Feedback F09).
|
||||
// ausdrücklich wieder entfernt (Feedback F09). Nur Fehler VOR
|
||||
// bzw. IM Commit landen hier — danach gehören die Dateien zur
|
||||
// gespeicherten Bildzeile (Audit N08).
|
||||
if ($stored !== null) {
|
||||
$this->imageService->deletePressReleaseImage($stored['disk'], $stored['path'], $stored['variants']);
|
||||
}
|
||||
|
|
@ -229,19 +241,44 @@ class PressReleaseImageUploader
|
|||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
// Öffentliche Seiten-Caches nur verwerfen, wenn die PM schon
|
||||
// öffentlich ist (Admin-Bildtausch, D.5) — Entwürfe sind nicht sichtbar.
|
||||
if ($pressRelease->published_at !== null) {
|
||||
$this->forgetPublicPagesQuietly();
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachträgliche Bestätigung eines bestehenden Bildes durch den
|
||||
* Kontoinhaber (Admin-Upload, Decision-Update E10) — dieselbe Prüfung
|
||||
* der Textkennung, dasselbe Protokoll, gebunden an den aktuellen Hash.
|
||||
* Zusätzlich zur Policy prüft der Service selbst, dass der Bestätiger
|
||||
* der Kontoinhaber ist und eine Bestätigung aussteht (Audit N05).
|
||||
*/
|
||||
public function confirmExisting(PressReleaseImage $image, ImageUploadActor $actor, ?string $legalTextKey, ?string $agbUrl = null): ImageRightsConfirmation
|
||||
{
|
||||
if (! $actor->confirmsImmediately()) {
|
||||
$pressRelease = $image->pressRelease;
|
||||
|
||||
if (
|
||||
! $actor->confirmsImmediately()
|
||||
|| $pressRelease === null
|
||||
|| $actor->user === null
|
||||
|| (int) $pressRelease->user_id !== (int) $actor->user->getKey()
|
||||
) {
|
||||
throw new ImageUploadException(__('Die Bestätigung muss vom Kontoinhaber abgegeben werden.'), 'rights_confirmed');
|
||||
}
|
||||
|
||||
// Frisch prüfen, nicht aus einer eventuell veralteten Relation:
|
||||
// keine Doppelbestätigung, kein Bestätigen eines ersetzten Bildes.
|
||||
$image->unsetRelation('latestRightsConfirmation');
|
||||
|
||||
if (! $image->awaitsCustomerConfirmation()) {
|
||||
throw new ImageUploadException(__('Für dieses Bild steht keine Bestätigung aus.'), 'rights_confirmed');
|
||||
}
|
||||
|
||||
$legalText = $this->resolveLegalText(
|
||||
new ImageSubmission(copyright: (string) $image->copyright, aiDeclaration: $image->ai_declaration, rightsConfirmed: true, legalTextKey: $legalTextKey),
|
||||
$actor,
|
||||
|
|
@ -251,20 +288,47 @@ class PressReleaseImageUploader
|
|||
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
|
||||
}
|
||||
|
||||
$confirmation = DB::transaction(function () use ($image, $actor, $legalText, $agbUrl): ImageRightsConfirmation {
|
||||
$confirmation = $this->recordConfirmation($image, $image->pressRelease, $actor, $legalText, $agbUrl);
|
||||
$confirmation = DB::transaction(function () use ($image, $pressRelease, $actor, $legalText, $agbUrl): ImageRightsConfirmation {
|
||||
$confirmation = $this->recordConfirmation($image, $pressRelease, $actor, $legalText, $agbUrl);
|
||||
$image->forceFill(['rights_confirmed_at' => now()])->save();
|
||||
|
||||
return $confirmation;
|
||||
});
|
||||
|
||||
if ($image->pressRelease?->published_at !== null) {
|
||||
WebCaches::forgetPublicPages();
|
||||
// Admin-Bildtausch an einer veröffentlichten PM: Mit der Bestätigung
|
||||
// wird das Bild öffentlich — Veröffentlichungssnapshot (E.2) und
|
||||
// Seiten-Caches folgen hier, nicht beim Upload.
|
||||
if ($pressRelease->published_at !== null) {
|
||||
$this->captureSnapshotQuietly($image, $pressRelease);
|
||||
$this->forgetPublicPagesQuietly();
|
||||
}
|
||||
|
||||
return $confirmation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachgelagerte Schritte nach erfolgreichem Commit dürfen die gespeicherte
|
||||
* Bildzeile nicht mehr gefährden: Fehler werden gemeldet, nicht geworfen
|
||||
* (Audit N08). Der Cache verfällt spätestens mit seiner TTL.
|
||||
*/
|
||||
private function forgetPublicPagesQuietly(): void
|
||||
{
|
||||
try {
|
||||
WebCaches::forgetPublicPages();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function captureSnapshotQuietly(PressReleaseImage $image, PressRelease $pressRelease): void
|
||||
{
|
||||
try {
|
||||
$this->snapshot->capture($image, $pressRelease);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt
|
||||
* die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ use App\Support\WebCaches;
|
|||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
|
|
@ -202,8 +203,15 @@ class PressReleaseService
|
|||
*
|
||||
* Greift nur, solange die PM noch im Status `review` steht; manuelle
|
||||
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
|
||||
*
|
||||
* `$expectedHash` ist die Prüfsumme des klassifizierten Inhalts (Audit
|
||||
* N04): Rot/Hold werden nur für genau diese Fassung wirksam; hat ein
|
||||
* Editor inzwischen korrigiert, greift unter der Sperre die gleiche
|
||||
* Regel wie beim Publish (StaleClassificationException, Re-Check).
|
||||
*
|
||||
* @throws StaleClassificationException
|
||||
*/
|
||||
public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void
|
||||
public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null, ?string $expectedHash = null): void
|
||||
{
|
||||
if ($pressRelease->status !== PressReleaseStatus::Review) {
|
||||
return;
|
||||
|
|
@ -221,12 +229,12 @@ class PressReleaseService
|
|||
$pressRelease->auto_check_attempts,
|
||||
$max,
|
||||
$reason ?: 'Rot ohne Begründung.',
|
||||
), automationExhausted: true);
|
||||
), automationExhausted: true, expectedHash: $expectedHash);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki');
|
||||
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki', $expectedHash);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -234,7 +242,7 @@ class PressReleaseService
|
|||
// Gelb geht durch (Entscheidung 16.09.2026) — außer der Schalter für
|
||||
// die manuelle Queue ist bewusst eingeschaltet.
|
||||
if ($classification === PressReleaseClassification::Yellow && config('scoring.classification.yellow_to_manual_queue')) {
|
||||
$this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.', automationExhausted: false);
|
||||
$this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.', automationExhausted: false, expectedHash: $expectedHash);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -247,11 +255,45 @@ class PressReleaseService
|
|||
* wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch. Der Autor
|
||||
* bekommt eine kurze Mail „ein Mensch prüft jetzt" — ohne sie sähe er
|
||||
* nach dem letzten Rot nur Stillstand statt einer Ablehnung.
|
||||
*
|
||||
* Unter Zeilensperre (Audit N04): Nur wenn die PM noch in `review` steht
|
||||
* und – bei übergebener Prüfsumme – noch den geprüften Inhalt trägt.
|
||||
*
|
||||
* @throws StaleClassificationException
|
||||
*/
|
||||
private function holdForManualReview(PressRelease $pressRelease, string $reason, bool $automationExhausted): void
|
||||
private function holdForManualReview(PressRelease $pressRelease, string $reason, bool $automationExhausted, ?string $expectedHash = null): void
|
||||
{
|
||||
$pressRelease->update(['manual_review_required_at' => now()]);
|
||||
$this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki');
|
||||
/** @var array{stale?: string, skipped?: true}|null $outcome */
|
||||
$outcome = DB::transaction(function () use ($pressRelease, $reason, $expectedHash): ?array {
|
||||
$locked = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressRelease->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($locked->status !== PressReleaseStatus::Review) {
|
||||
return ['skipped' => true];
|
||||
}
|
||||
|
||||
if ($this->classificationIsStale($locked, $expectedHash)) {
|
||||
return ['stale' => (string) $locked->content_hash];
|
||||
}
|
||||
|
||||
$locked->update(['manual_review_required_at' => now()]);
|
||||
$this->logStatusChange($locked, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki');
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
if (isset($outcome['stale'])) {
|
||||
$this->rescheduleForCurrentContent($pressRelease, $expectedHash, $outcome['stale']);
|
||||
}
|
||||
|
||||
if ($outcome !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::PressReleaseStatus,
|
||||
|
|
@ -360,9 +402,7 @@ class PressReleaseService
|
|||
}
|
||||
|
||||
if (isset($outcome['stale'])) {
|
||||
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $outcome['stale']['current'])->onQueue('classification');
|
||||
|
||||
throw new StaleClassificationException($pressRelease->id, $outcome['stale']['analyzed'], $outcome['stale']['current']);
|
||||
$this->rescheduleForCurrentContent($pressRelease, $outcome['stale']['analyzed'], $outcome['stale']['current']);
|
||||
}
|
||||
|
||||
if (isset($outcome['blacklist'])) {
|
||||
|
|
@ -374,16 +414,37 @@ class PressReleaseService
|
|||
);
|
||||
}
|
||||
|
||||
// Doppelveröffentlichung (Anhang 2, 1c.8): liegt derselbe Text der
|
||||
// Firma schon auf dem anderen Portal, bestimmt jetzt das Portal die
|
||||
// Erstquelle. Nebensache — darf die Veröffentlichung nie brechen.
|
||||
$this->assignCanonical($pressRelease);
|
||||
|
||||
$this->notifyAuthor($pressRelease, 'published');
|
||||
}
|
||||
|
||||
/**
|
||||
* Doppelveröffentlichung (Anhang 2, 1c.8): liegt derselbe Text der Firma
|
||||
* schon auf dem anderen Portal, bestimmt jetzt das Portal die Erstquelle.
|
||||
* Nebensache — darf die Veröffentlichung nie brechen.
|
||||
*/
|
||||
private function assignCanonical(PressRelease $pressRelease): void
|
||||
{
|
||||
try {
|
||||
app(CanonicalAssigner::class)->assignFor($pressRelease);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
$this->notifyAuthor($pressRelease, 'published');
|
||||
/**
|
||||
* Gegenstück zu assignCanonical(): Verschwindet eine Erstquelle aus der
|
||||
* öffentlichen Ansicht, bekommen ihre Kopien den Verweis nicht als Leiche
|
||||
* mit — sie werden neu zugeordnet oder selbst Erstquelle (Sitemap).
|
||||
*/
|
||||
private function releaseCanonicalCopies(PressRelease $pressRelease): void
|
||||
{
|
||||
try {
|
||||
app(CanonicalAssigner::class)->reassignCopiesOf($pressRelease);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -412,6 +473,21 @@ class PressReleaseService
|
|||
&& $analyzedHash !== $pressRelease->content_hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Ausgang von publish(), reject() und Hold, wenn die
|
||||
* Klassifikation unter der Sperre nicht mehr zum Inhalt passt (U02, N04):
|
||||
* Prüfung mit Routing für den aktuellen Stand einplanen und dem Aufrufer
|
||||
* die Abweichung melden. Nichts an der PM geändert.
|
||||
*
|
||||
* @throws StaleClassificationException
|
||||
*/
|
||||
private function rescheduleForCurrentContent(PressRelease $pressRelease, ?string $analyzedHash, string $currentHash): never
|
||||
{
|
||||
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $currentHash)->onQueue('classification');
|
||||
|
||||
throw new StaleClassificationException($pressRelease->id, $analyzedHash, $currentHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers
|
||||
* (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte
|
||||
|
|
@ -516,9 +592,20 @@ class PressReleaseService
|
|||
return $base;
|
||||
}
|
||||
|
||||
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void
|
||||
/**
|
||||
* @param string|null $expectedHash Prüfsumme des Inhalts, für den die
|
||||
* Ablehnung gilt (KI-Rot, Audit N04).
|
||||
* Trägt die gesperrte Zeile eine
|
||||
* andere, wird nicht abgelehnt,
|
||||
* sondern neu geprüft. Admin-Ablehnung
|
||||
* übergibt nichts.
|
||||
*
|
||||
* @throws StaleClassificationException
|
||||
*/
|
||||
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin', ?string $expectedHash = null): void
|
||||
{
|
||||
DB::transaction(function () use ($pressRelease, $reason, $source): void {
|
||||
/** @var string|null $staleCurrentHash */
|
||||
$staleCurrentHash = DB::transaction(function () use ($pressRelease, $reason, $source, $expectedHash): ?string {
|
||||
$locked = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressRelease->getKey())
|
||||
|
|
@ -528,11 +615,22 @@ class PressReleaseService
|
|||
$this->assertStatus($locked, [PressReleaseStatus::Review]);
|
||||
$previous = $locked->status;
|
||||
|
||||
if ($this->classificationIsStale($locked, $expectedHash)) {
|
||||
return (string) $locked->content_hash;
|
||||
}
|
||||
|
||||
$locked->update(['status' => PressReleaseStatus::Rejected->value]);
|
||||
$this->logStatusChange($locked, $previous, PressReleaseStatus::Rejected, $reason, $source);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
if ($staleCurrentHash !== null) {
|
||||
$this->rescheduleForCurrentContent($pressRelease, $expectedHash, $staleCurrentHash);
|
||||
}
|
||||
|
||||
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
||||
}
|
||||
|
||||
|
|
@ -552,7 +650,7 @@ class PressReleaseService
|
|||
|
||||
$previous = $pressRelease->status;
|
||||
|
||||
$pressRelease->update(['status' => PressReleaseStatus::Archived->value]);
|
||||
$pressRelease->update(['status' => PressReleaseStatus::Archived->value, 'archive_pending_at' => now()]);
|
||||
// Sichtbarkeitsregel (Roadmap 1b.1/1b.5): Ein Newsroom ist öffentlich
|
||||
// nur mit mindestens einer veröffentlichten Meldung. Fällt die letzte
|
||||
// weg, muss auch seine Seite sofort weg — nicht erst nach Ablauf des
|
||||
|
|
@ -585,8 +683,10 @@ class PressReleaseService
|
|||
$pressRelease->update([
|
||||
'status' => PressReleaseStatus::Archived->value,
|
||||
'depublished_at' => now(),
|
||||
'archive_pending_at' => now(),
|
||||
]);
|
||||
$pressRelease->lockSync($syncLockReason ?? 'Depublikation auf Wunsch des Absenders');
|
||||
$this->releaseCanonicalCopies($pressRelease);
|
||||
WebCaches::forgetPublicPages();
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, 'Depublikation auf Wunsch des Absenders (kostenpflichtige Serviceleistung)', 'depublication');
|
||||
$this->archiveDepublished(
|
||||
|
|
@ -621,6 +721,10 @@ class PressReleaseService
|
|||
'published_ever_at' => $status === PressReleaseStatus::Published
|
||||
? ($locked->published_ever_at ?? now())
|
||||
: $locked->published_ever_at,
|
||||
// Archivierung steht aus, bis der Eintrag geschrieben ist (N11).
|
||||
'archive_pending_at' => $previous === PressReleaseStatus::Published && $status !== PressReleaseStatus::Published
|
||||
? now()
|
||||
: $locked->archive_pending_at,
|
||||
]);
|
||||
|
||||
if ($status === PressReleaseStatus::Published) {
|
||||
|
|
@ -640,7 +744,12 @@ class PressReleaseService
|
|||
|
||||
if ($status === PressReleaseStatus::Published) {
|
||||
$this->capturePublishedImages($pressRelease);
|
||||
|
||||
if ($previous !== PressReleaseStatus::Published) {
|
||||
$this->assignCanonical($pressRelease);
|
||||
}
|
||||
} elseif ($previous === PressReleaseStatus::Published) {
|
||||
$this->releaseCanonicalCopies($pressRelease);
|
||||
$this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, $reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -649,7 +758,8 @@ class PressReleaseService
|
|||
{
|
||||
if ($pressRelease->status === PressReleaseStatus::Published) {
|
||||
// Letzter veröffentlichter Stand VOR dem Überschreiben ins Archiv.
|
||||
$this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, 'Entfernung durch den Admin');
|
||||
// Bewusst blockierend: ohne Archiveintrag kein Überschreiben.
|
||||
app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, Auth::user(), 'Entfernung durch den Admin');
|
||||
|
||||
$pressRelease->update([
|
||||
'status' => PressReleaseStatus::Archived->value,
|
||||
|
|
@ -661,6 +771,7 @@ class PressReleaseService
|
|||
'backlink_url' => null,
|
||||
'no_export' => true,
|
||||
]);
|
||||
$this->releaseCanonicalCopies($pressRelease);
|
||||
WebCaches::forgetPublicPages();
|
||||
|
||||
return;
|
||||
|
|
@ -679,11 +790,29 @@ class PressReleaseService
|
|||
|
||||
/**
|
||||
* Beweisarchiv (Block F.4): letzter veröffentlichter Stand, sobald eine
|
||||
* Meldung das öffentliche Angebot verlässt.
|
||||
* Meldung das öffentliche Angebot verlässt. Der Marker
|
||||
* `archive_pending_at` wurde in der fachlichen Transaktion gesetzt und
|
||||
* fällt erst mit dem Eintrag; scheitert die Ablage, bleibt er stehen
|
||||
* und `archive:retry-pending` holt den Eintrag nach (N11). Nur
|
||||
* deleteFromAdmin() archiviert vor dem Überschreiben und bleibt
|
||||
* blockierend.
|
||||
*/
|
||||
private function archiveDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?string $note = null): void
|
||||
{
|
||||
app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, $reason, Auth::user(), $note);
|
||||
try {
|
||||
$entry = app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, $reason, Auth::user(), $note);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('Beweisarchiv: Depublikation konnte nicht archiviert werden, wird nachgeholt.', [
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($entry !== null && $pressRelease->archive_pending_at !== null) {
|
||||
$pressRelease->forceFill(['archive_pending_at' => null])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -757,6 +886,7 @@ class PressReleaseService
|
|||
]);
|
||||
|
||||
$pressRelease->lockSync($reason ?? 'Entfernung mit 410 (Tombstone)');
|
||||
$this->releaseCanonicalCopies($pressRelease);
|
||||
|
||||
if ($flushPublicPages) {
|
||||
WebCaches::forgetPublicPages();
|
||||
|
|
|
|||
|
|
@ -130,14 +130,20 @@ class ViewDistributionReport
|
|||
|
||||
// Älter heißt: früher veröffentlicht. Die Altersgrenzen drehen sich
|
||||
// deshalb zu Datumsgrenzen um — und bleiben im Fenster from..to.
|
||||
// Alter [lower, upper) Tage: die Untergrenze des Datums ist
|
||||
// exklusiv, damit die Kohorten lückenlos aneinanderschließen und
|
||||
// keine Meldung in zwei fällt. Klemmt das Fenster (`$from`) oder
|
||||
// ist es die älteste Kohorte, bleibt der Fensterbeginn inklusiv.
|
||||
$newest = $reference->copy()->subDays($lowerDays)->min($to);
|
||||
$oldest = ($upperDays === null ? $from : $reference->copy()->subDays($upperDays - 1))->max($from);
|
||||
$bound = $upperDays === null ? null : $reference->copy()->subDays($upperDays);
|
||||
$lowerExclusive = $bound !== null && $bound->greaterThanOrEqualTo($from);
|
||||
$oldest = $bound === null ? $from->copy() : $bound->max($from);
|
||||
|
||||
if ($oldest->greaterThan($newest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hits = $this->hits($portal, $oldest, $newest, true);
|
||||
$hits = $this->hits($portal, $oldest, $newest, true, $lowerExclusive);
|
||||
$count = count($hits);
|
||||
|
||||
$result[] = [
|
||||
|
|
@ -157,16 +163,18 @@ class ViewDistributionReport
|
|||
* Eine Meldung mit `portal = both` erscheint auf beiden Portalen und zählt
|
||||
* deshalb bei beiden mit — der Zähler ist einer, die Sichtbarkeit doppelt.
|
||||
*
|
||||
* @param bool $fromExclusive Untergrenze ausschließen (Kohortengrenzen), sonst inklusiv
|
||||
* @return list<int>
|
||||
*/
|
||||
private function hits(Portal $portal, Carbon $from, Carbon $to, ?bool $onlyNew): array
|
||||
private function hits(Portal $portal, Carbon $from, Carbon $to, ?bool $onlyNew, bool $fromExclusive = false): array
|
||||
{
|
||||
return PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('portal', [$portal->value, Portal::Both->value])
|
||||
->where('status', PressReleaseStatus::Published->value)
|
||||
->whereNull('gone_at')
|
||||
->whereBetween('published_at', [$from, $to])
|
||||
->where('published_at', $fromExclusive ? '>' : '>=', $from)
|
||||
->where('published_at', '<=', $to)
|
||||
->when($onlyNew === true, fn (Builder $query) => $query->whereNull('legacy_portal'))
|
||||
->when($onlyNew === false, fn (Builder $query) => $query->whereNotNull('legacy_portal'))
|
||||
->orderBy('hits')
|
||||
|
|
|
|||
|
|
@ -136,7 +136,13 @@ class VisibilityStats
|
|||
/**
|
||||
* Einzelwerte einer Meldung für das Panel „Auslieferung".
|
||||
*
|
||||
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null, feeds: list<array{label: string, url: string, fetches_30d: int}>}
|
||||
* Sitemap (1c.8): Nur die Erstquelle steht in der Sitemap eines Portals —
|
||||
* `sitemap_role` sagt, ob diese Meldung dort Erstquelle (`source`),
|
||||
* Kopie mit rel=canonical auf die andere Fassung (`copy`) oder gar nicht
|
||||
* öffentlich (`none`) ist; `sitemap_portals` nennt die Portale, deren
|
||||
* Sitemap sie tatsächlich führt.
|
||||
*
|
||||
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, sitemap_role: string, sitemap_portals: list<string>, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null, feeds: list<array{label: string, url: string, fetches_30d: int}>}
|
||||
*/
|
||||
public function releaseSummary(PressRelease $pressRelease): array
|
||||
{
|
||||
|
|
@ -158,16 +164,26 @@ class VisibilityStats
|
|||
|
||||
$links = $pressRelease->isVisibleInArchive() ? $pressRelease->publicPortalLinks() : [];
|
||||
|
||||
// Dieselbe Regel wie SitemapController/NewsSitemapController: je
|
||||
// Portal nur die Erstquelle (CanonicalAssigner::isCanonicalOn).
|
||||
$assigner = app(CanonicalAssigner::class);
|
||||
$sitemapPortals = array_values(array_map(
|
||||
static fn (array $link): string => $link['label'],
|
||||
array_filter($links, static fn (array $link): bool => $assigner->isCanonicalOn($pressRelease, $link['portal'])),
|
||||
));
|
||||
|
||||
return [
|
||||
'hits' => (int) $pressRelease->hits,
|
||||
'first_view_at' => $views?->first_view ? Carbon::parse($views->first_view) : null,
|
||||
'last_view_at' => $views?->last_view ? Carbon::parse($views->last_view) : null,
|
||||
'shares' => $shares->all(),
|
||||
'shares_total' => (int) $shares->sum(),
|
||||
// Die Sitemap listet jede im Archiv sichtbare Meldung — ab dem
|
||||
// Die Sitemap listet jede im Archiv sichtbare Erstquelle — ab dem
|
||||
// Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht
|
||||
// über Google.
|
||||
'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
|
||||
'in_sitemap_since' => $sitemapPortals !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
|
||||
'sitemap_role' => $links === [] ? 'none' : ($sitemapPortals === [] ? 'copy' : 'source'),
|
||||
'sitemap_portals' => $sitemapPortals,
|
||||
'crawlers' => $this->crawlerFetches($pressRelease),
|
||||
'crawlers_since' => $this->crawlerHitsSince(),
|
||||
'feeds' => $this->feedLinks($pressRelease, $links),
|
||||
|
|
|
|||
|
|
@ -70,10 +70,7 @@ class IndexStatusReport
|
|||
// lehnt den korrelierten Ausdruck direkt im GROUP BY ab.
|
||||
$visible = DB::query()
|
||||
->fromSub(
|
||||
$this->apiJoin(PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->visibleInArchive()
|
||||
->forPortalValues([$portal->value, Portal::Both->value]))
|
||||
$this->apiJoin($this->visibleOn($portal))
|
||||
->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'),
|
||||
'r'
|
||||
)
|
||||
|
|
@ -81,11 +78,15 @@ class IndexStatusReport
|
|||
->groupBy('origin', 'role')
|
||||
->get();
|
||||
|
||||
// Dieselbe Grundgesamtheit wie `total`: nur Checks von Meldungen,
|
||||
// die auf diesem Portal gerade sichtbar sind — sonst zählt eine
|
||||
// inzwischen depublizierte oder entfernte Meldung als „indexed",
|
||||
// ohne im Nenner zu stehen.
|
||||
$checked = DB::query()
|
||||
->fromSub(
|
||||
$this->apiJoin($this->latestChecks($portal)
|
||||
->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id')
|
||||
->whereNull('press_releases.deleted_at'))
|
||||
->whereIn('press_releases.id', $this->visibleOn($portal)->select('press_releases.id')))
|
||||
->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'),
|
||||
'c'
|
||||
)
|
||||
|
|
@ -208,21 +209,36 @@ class IndexStatusReport
|
|||
}
|
||||
|
||||
/**
|
||||
* Jeweils jüngste Prüfung je Meldung auf einem Portal.
|
||||
* Auf dem Portal gerade sichtbare Meldungen — die Grundgesamtheit von
|
||||
* `total` und der Zustandszählung.
|
||||
*
|
||||
* @return Builder<PressRelease>
|
||||
*/
|
||||
private function visibleOn(Portal $portal): Builder
|
||||
{
|
||||
return PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->visibleInArchive()
|
||||
->forPortalValues([$portal->value, Portal::Both->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeweils jüngste Prüfung je Meldung auf einem Portal. Bestimmt über die
|
||||
* höchste ID, nicht über `max(checked_at)`: zwei Prüfungen in derselben
|
||||
* Sekunde hätten sonst beide gezählt.
|
||||
*
|
||||
* @return Builder<PressReleaseIndexCheck>
|
||||
*/
|
||||
private function latestChecks(Portal $portal): Builder
|
||||
{
|
||||
$latest = PressReleaseIndexCheck::query()
|
||||
->select('press_release_id', DB::raw('max(checked_at) as last_checked_at'))
|
||||
->select('press_release_id', DB::raw('max(id) as last_id'))
|
||||
->where('portal', $portal->value)
|
||||
->groupBy('press_release_id');
|
||||
|
||||
return PressReleaseIndexCheck::query()
|
||||
->joinSub($latest, 'latest', fn ($join) => $join
|
||||
->on('latest.press_release_id', '=', 'press_release_index_checks.press_release_id')
|
||||
->on('latest.last_checked_at', '=', 'press_release_index_checks.checked_at'))
|
||||
->on('latest.last_id', '=', 'press_release_index_checks.id'))
|
||||
->where('press_release_index_checks.portal', $portal->value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
app/Support/FileDeletion.php
Normal file
68
app/Support/FileDeletion.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Dateilöschung mit ausgewertetem Ergebnis (Umsetzungsaudit 25.09.2026,
|
||||
* N10 und 5.1 e). Die Disks laufen mit `throw => false`; ein stilles
|
||||
* `false` darf nirgends als Erfolg durchgehen, wo danach ein Tombstone
|
||||
* gesetzt oder eine Zeile hart gelöscht wird.
|
||||
*/
|
||||
class FileDeletion
|
||||
{
|
||||
/**
|
||||
* Löscht und bestätigt, dass die Datei danach nicht mehr existiert.
|
||||
* Eine bereits fehlende Datei gilt als gelöscht.
|
||||
*/
|
||||
public static function delete(Filesystem $disk, string $path): bool
|
||||
{
|
||||
// Eine fehlende Datei meldet der lokale Treiber als gelöscht (true);
|
||||
// ein stilles false (throw => false) heißt: Datei ist noch da.
|
||||
return $disk->delete($path) && self::isGone($disk, $path);
|
||||
}
|
||||
|
||||
private static function isGone(Filesystem $disk, string $path): bool
|
||||
{
|
||||
return ! $disk->exists($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie delete(), wirft aber bei Misserfolg — für Pfade, auf denen die
|
||||
* Löschung Voraussetzung für den nächsten Schritt ist.
|
||||
*/
|
||||
public static function deleteOrThrow(Filesystem $disk, string $path, string $context): void
|
||||
{
|
||||
if (! self::delete($disk, $path)) {
|
||||
throw new RuntimeException($context.': Datei konnte nicht gelöscht werden ('.$path.').');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht mehrere Pfade, protokolliert Fehlschläge und liefert die
|
||||
* verbliebenen Pfade — für Pfade, deren Verbleib nur Datenminimierung
|
||||
* betrifft.
|
||||
*
|
||||
* @param list<string> $paths
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function deleteOrLog(Filesystem $disk, array $paths, string $context): array
|
||||
{
|
||||
$remaining = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (! self::delete($disk, $path)) {
|
||||
$remaining[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
if ($remaining !== []) {
|
||||
Log::error($context.': Datei(en) konnten nicht gelöscht werden.', ['paths' => $remaining]);
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue