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:
Kevin Adametz 2026-09-25 14:26:49 +00:00
parent 34fa342c7f
commit 2a2215b3c3
66 changed files with 3092 additions and 368 deletions

View file

@ -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,

View file

@ -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;

View file

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

View file

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

View 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;
}
}

View 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;
}
}

View file

@ -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];

View file

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

View file

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

View file

@ -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(),

View file

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

View file

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

View file

@ -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(),
]);
}
/**

View file

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

View file

@ -28,6 +28,7 @@ class ArchiveEntry extends Model
'subject_type',
'subject_id',
'press_release_id',
'legal_request_id',
'event',
'actor_type',
'actor_id',

View file

@ -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();
}
/**

View file

@ -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>
*/

View file

@ -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',

View file

@ -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

View file

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

View file

@ -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');

View file

@ -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());

View file

@ -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[] = [

View file

@ -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

View 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());
}
}

View file

@ -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>
*/

View file

@ -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()));

View file

@ -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] = [

View file

@ -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

View file

@ -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.
*

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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.

View file

@ -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();

View file

@ -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')

View file

@ -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),

View file

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

View 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;
}
}