presseportale/app/Services/Admin/AdminSystemHealth.php
Kevin Adametz 48129e99a0 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>
2026-09-25 14:26:49 +00:00

337 lines
13 KiB
PHP

<?php
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;
use Spatie\Permission\Models\Permission;
use Throwable;
/**
* Betriebsüberwachung fürs Admin-Backend: Zustand der Queue (kritisch, weil
* Magic-Link-Login und fristrelevante Bestätigungs-Mails darüber laufen) sowie
* Deployment-/Konfigurations-Checks (neue Permissions ausgerollt, Debug/Env,
* ausstehende Medien-Privatisierung).
*/
class AdminSystemHealth
{
private const StatusOk = 'ok';
private const StatusWarn = 'warn';
private const StatusCritical = 'critical';
/**
* Sekundenschwellen für den ältesten wartenden Job: darüber gilt der Worker
* als verzögert (warn) bzw. mutmaßlich gestoppt (critical).
*/
private const PendingWarnSeconds = 60;
private const PendingCriticalSeconds = 300;
/**
* @return array{
* generated_at: Carbon,
* overall: string,
* queue: array<string, mixed>,
* checks: list<array{key: string, label: string, status: string, detail: string}>
* }
*/
public function snapshot(): array
{
$queue = $this->queueHealth();
$checks = $this->deploymentChecks($queue);
$statuses = array_merge(
[$queue['status']],
array_map(static fn (array $check): string => $check['status'], $checks),
);
return [
'generated_at' => now(),
'overall' => $this->worstStatus($statuses),
'queue' => $queue,
'checks' => $checks,
];
}
/**
* @return array<string, mixed>
*/
private function queueHealth(): array
{
$connection = (string) config('queue.default');
$usesDatabase = $connection === 'database';
if (! $usesDatabase || ! Schema::hasTable('jobs')) {
return [
'status' => self::StatusWarn,
'driver' => $connection,
'supported' => false,
'pending' => 0,
'delayed' => 0,
'reserved' => 0,
'failed_total' => Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0,
'failed_recent' => 0,
'oldest_pending_seconds' => null,
'worker_running' => null,
'recent_failures' => [],
];
}
$now = now()->getTimestamp();
$pending = DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', $now)->count();
$delayed = DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', $now)->count();
$reserved = DB::table('jobs')->whereNotNull('reserved_at')->count();
$oldestAvailable = DB::table('jobs')
->whereNull('reserved_at')
->where('available_at', '<=', $now)
->min('available_at');
$oldestPendingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : null;
$failedTotal = DB::table('failed_jobs')->count();
$failedRecent = DB::table('failed_jobs')
->where('failed_at', '>=', now()->subDay())
->count();
$recentFailures = DB::table('failed_jobs')
->orderByDesc('failed_at')
->limit(10)
->get(['uuid', 'queue', 'payload', 'exception', 'failed_at'])
->map(fn ($row): array => [
'uuid' => $row->uuid,
'queue' => $row->queue,
'job' => $this->jobDisplayName($row->payload),
'error' => $this->firstExceptionLine($row->exception),
'failed_at' => Carbon::parse($row->failed_at),
])
->all();
// Worker gilt als aktiv, wenn kein wartender Job zu lange unbearbeitet
// liegt. Ein reservierter Job zeigt aktive Verarbeitung an.
$workerRunning = $oldestPendingSeconds === null || $oldestPendingSeconds < self::PendingCriticalSeconds;
$status = self::StatusOk;
if ($oldestPendingSeconds !== null && $oldestPendingSeconds >= self::PendingCriticalSeconds) {
$status = self::StatusCritical;
} elseif (($oldestPendingSeconds !== null && $oldestPendingSeconds >= self::PendingWarnSeconds) || $failedRecent > 0) {
$status = self::StatusWarn;
}
return [
'status' => $status,
'driver' => $connection,
'supported' => true,
'pending' => $pending,
'delayed' => $delayed,
'reserved' => $reserved,
'failed_total' => $failedTotal,
'failed_recent' => $failedRecent,
'oldest_pending_seconds' => $oldestPendingSeconds,
'worker_running' => $workerRunning,
'recent_failures' => $recentFailures,
];
}
/**
* @param array<string, mixed> $queue
* @return list<array{key: string, label: string, status: string, detail: string}>
*/
private function deploymentChecks(array $queue): array
{
$checks = [];
// Queue-Worker als expliziter Check (spiegelt den Queue-Block).
$checks[] = [
'key' => 'queue_worker',
'label' => 'Queue-Worker',
'status' => $queue['status'],
'detail' => $this->queueWorkerDetail($queue),
];
// Neue Permissions ausgerollt?
$expected = PermissionCatalog::adminOnly();
$existing = Permission::query()->whereIn('name', $expected)->pluck('name')->all();
$missing = array_values(array_diff($expected, $existing));
$checks[] = [
'key' => 'permissions',
'label' => 'Berechtigungen ausgerollt',
'status' => $missing === [] ? self::StatusOk : self::StatusCritical,
'detail' => $missing === []
? 'Alle erwarteten Rollen-Permissions sind vorhanden.'
: 'Fehlende Permissions (Seeder ausführen): '.implode(', ', $missing),
];
// APP_DEBUG in nicht-lokalen Umgebungen.
$isLocal = app()->environment('local');
$debug = (bool) config('app.debug');
$checks[] = [
'key' => 'app_debug',
'label' => 'Debug-Modus',
'status' => ($debug && ! $isLocal) ? self::StatusCritical : self::StatusOk,
'detail' => $debug
? ('APP_DEBUG ist aktiv'.($isLocal ? ' (lokal ok).' : ' — im Produktivbetrieb deaktivieren!'))
: 'APP_DEBUG ist deaktiviert.',
];
// Secure Session Cookie außerhalb lokaler Umgebung.
$secureCookie = (bool) config('session.secure');
$checks[] = [
'key' => 'session_secure_cookie',
'label' => 'Session Secure-Cookie',
'status' => (! $secureCookie && ! $isLocal) ? self::StatusWarn : self::StatusOk,
'detail' => $secureCookie
? 'Session-Cookie wird nur über HTTPS gesetzt.'
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
];
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
// nichts, muss aber sichtbar bleiben.
$provenanceFailures = PressReleaseImage::query()
->withTrashed()
->where('provenance_status', 'error')
->where('provenance_checked_at', '>=', now()->subDays(7))
->count();
$checks[] = [
'key' => 'image_provenance',
'label' => 'Herkunftsprüfung Bilder',
'status' => $provenanceFailures === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $provenanceFailures === 0
? 'Keine fehlgeschlagene Herkunftsprüfung in den letzten 7 Tagen.'
: $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[] = [
'key' => 'image_precheck_queue',
'label' => 'Bild-Sichtung',
'status' => $pendingPrechecks === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $pendingPrechecks === 0
? 'Keine Titelbilder warten auf eine Sichtung.'
: $pendingPrechecks.' Titelbild(er) warten auf die Sichtung durch einen Admin (Pressemitteilungen ▸ Bild-Sichtung).',
];
// Ausstehende Medien-Privatisierung.
$publicImages = PressReleaseImage::query()->withTrashed()->where('disk', 'public')->count();
$publicAttachments = PressReleaseAttachment::query()->withTrashed()->where('disk', 'public')->count();
$publicMedia = $publicImages + $publicAttachments;
$checks[] = [
'key' => 'media_privatization',
'label' => 'Medien-Privatisierung',
'status' => $publicMedia > 0 ? self::StatusWarn : self::StatusOk,
'detail' => $publicMedia > 0
? "Noch {$publicMedia} PM-Medien auf der public-Disk — `press-releases:privatize-media` ausführen."
: 'Alle PM-Medien liegen auf der privaten Disk.',
];
return $checks;
}
/**
* @param array<string, mixed> $queue
*/
private function queueWorkerDetail(array $queue): string
{
if (! $queue['supported']) {
return "Queue-Treiber „{$queue['driver']}\" — Job-Metriken hier nicht verfügbar.";
}
if ($queue['oldest_pending_seconds'] === null) {
return 'Keine wartenden Jobs. Worker im Leerlauf oder aktiv.';
}
$age = $this->humanizeSeconds((int) $queue['oldest_pending_seconds']);
if ($queue['status'] === self::StatusCritical) {
return "Ältester wartender Job seit {$age} unbearbeitet — Worker vermutlich gestoppt.";
}
return "Ältester wartender Job seit {$age} in der Warteschlange.";
}
private function jobDisplayName(string $payload): string
{
try {
$data = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
} catch (Throwable) {
return 'Unbekannter Job';
}
if (is_array($data) && is_string($data['displayName'] ?? null)) {
return $data['displayName'];
}
return 'Unbekannter Job';
}
private function firstExceptionLine(string $exception): string
{
$line = trim(strtok($exception, "\n") ?: '');
return $line === '' ? 'Unbekannter Fehler' : mb_strimwidth($line, 0, 240, '…');
}
private function humanizeSeconds(int $seconds): string
{
if ($seconds < 60) {
return "{$seconds}s";
}
if ($seconds < 3600) {
return floor($seconds / 60).'min';
}
return floor($seconds / 3600).'h';
}
/**
* @param list<string> $statuses
*/
private function worstStatus(array $statuses): string
{
if (in_array(self::StatusCritical, $statuses, true)) {
return self::StatusCritical;
}
if (in_array(self::StatusWarn, $statuses, true)) {
return self::StatusWarn;
}
return self::StatusOk;
}
}