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