presseportale/app/Services/Archive/ArchivePendingQueue.php
Kevin Adametz 7cd84b2be3 archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)
- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue),
  Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel
  FAILURE; archive_pending_at nur noch Sammelmarker
- O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre
- O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder
  offener Auftrag, nie ein still unvollständiger Nachweis
- A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore,
  archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut
- O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur
  mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab
- A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold-
  Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit
  compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und
  ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export
- 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id

PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock),
N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für
auslieferbare Bilder), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00

266 lines
9.9 KiB
PHP

<?php
namespace App\Services\Archive;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\PressReleaseImage;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Aufträge für ausstehende Beweisarchiv-Einträge (Umsetzungsaudit
* 29.09.2026, A-02/A-04/A-09/A-10).
*
* Ablauf für jeden archivpflichtigen Vorgang:
*
* $job = DB::transaction(function () use (…) {
* // fachliche Änderung …
* return $queue->push($pressReleaseId, ArchiveEvent::LabelCorrected, $correction,
* ArchiveReasonCategory::Correction, actorId: $admin->id);
* });
* $entry = $queue->completeWith($job, fn () => $archiver->labelCorrected(…));
*
* push() gehört in die fachliche Transaktion: Auftrag und Marker
* `press_releases.archive_pending_at` entstehen oder fallen mit dem
* Vorgang. completeWith() läuft nach dem Commit, wirft nie und löscht den
* Auftrag nur zusammen mit dem geschriebenen Eintrag. Scheitert die
* Ablage, bleibt der Auftrag stehen und `archive:retry-pending` holt ihn
* je Ereignistyp nach (PendingArchiveRetry).
*
* Der Marker ist nur noch ein Sammelanzeiger: gesetzt, wenn er NULL ist
* (ein älterer Wert bleibt), gelöscht, wenn für die Meldung kein Auftrag
* mehr offen ist — beides per Query Builder, `updated_at` bleibt
* unberührt. Ein erfolgreicher späterer Vorgang kann einen älteren offenen
* Auftrag damit weder überschreiben noch mitlöschen.
*/
class ArchivePendingQueue
{
/**
* Auftrag anlegen (innerhalb der fachlichen Transaktion aufrufen).
*
* @param Model|null $subject Bezug des späteren Eintrags bzw. des Nachholers:
* Meldung, Redaction-Snapshot, Bild oder Korrekturzeile
* @param array<string, mixed> $payload Was der Nachholer braucht (z. B. `state` bei
* `pm_depublished`, `before`/`after` bei `gdpr_change`)
*/
public function push(
int $pressReleaseId,
ArchiveEvent $event,
?Model $subject = null,
?ArchiveReasonCategory $reason = null,
?int $legalRequestId = null,
?int $actorId = null,
?string $note = null,
array $payload = [],
): ArchivePendingJob {
return $this->create($pressReleaseId, $event->value, $subject, $reason, $legalRequestId, $actorId, $note, $payload);
}
/**
* Sofortlöschung eines nie veröffentlichten Bildes ist gescheitert
* (A-10): der Nachholer wiederholt sie, der Systemstatus zählt den Rest.
*/
public function pushImagePurge(PressReleaseImage $image, ?string $error = null): ArchivePendingJob
{
$job = $this->create((int) $image->press_release_id, ArchivePendingJob::EVENT_IMAGE_PURGE, $image, null, null, null, 'Sofortlöschung fehlgeschlagen', []);
if ($error !== null) {
$this->recordFailure($job, $error);
}
return $job;
}
/**
* Archiveintrag schreiben und den Auftrag im selben Schritt abschließen.
* Wirft nie: ein Fehler oder ein nicht geschriebener Eintrag (fehlender
* Schlüssel) zählt als Versuch, der Auftrag bleibt offen.
*
* @param callable(): ?ArchiveEntry $step
*/
public function completeWith(ArchivePendingJob $job, callable $step): ?ArchiveEntry
{
$alreadyCompleted = false;
try {
$entry = DB::transaction(function () use ($job, $step, &$alreadyCompleted): ?ArchiveEntry {
// Erst beanspruchen, dann archivieren (Umsetzungsaudit
// 02.10.2026, O07): Nachholer und Direktweg dürfen denselben
// Auftrag nicht beide ablegen.
if ($this->claim($job) === null) {
$alreadyCompleted = true;
return null;
}
$entry = $step();
if ($entry !== null) {
$this->complete($job);
}
return $entry;
});
} catch (Throwable $exception) {
$this->recordFailure($job, $exception->getMessage());
Log::error('Beweisarchiv: Eintrag konnte nicht geschrieben werden, Auftrag bleibt offen.', [
'job_id' => $job->getKey(),
'event' => $job->event,
'press_release_id' => $job->press_release_id,
'error' => $exception->getMessage(),
]);
return null;
}
if ($entry === null && ! $alreadyCompleted) {
$this->recordFailure($job, 'Eintrag wurde nicht archiviert (ARCHIVE_KEY fehlt oder ist ungültig).');
}
return $entry;
}
/**
* Auftragszeile unter Sperre neu lesen — innerhalb der Transaktion, die
* den Eintrag schreibt. Wer zweiter ist, wartet auf die Sperre und findet
* den Auftrag danach nicht mehr (null): dann ist nichts mehr zu tun.
*/
public function claim(ArchivePendingJob $job): ?ArchivePendingJob
{
return ArchivePendingJob::query()->whereKey($job->getKey())->lockForUpdate()->first();
}
/**
* Auftrag löschen; den Sammelmarker nur leeren, wenn für die Meldung
* kein weiterer Auftrag offen ist.
*/
public function complete(ArchivePendingJob $job): void
{
ArchivePendingJob::query()->whereKey($job->getKey())->delete();
DB::table('press_releases')
->where('id', $job->press_release_id)
->whereNotNull('archive_pending_at')
->whereNotExists(function ($query): void {
$query->select(DB::raw(1))
->from('archive_pending_jobs')
->whereColumn('archive_pending_jobs.press_release_id', 'press_releases.id');
})
->update(['archive_pending_at' => null]);
}
public function recordFailure(ArchivePendingJob $job, string $error): void
{
ArchivePendingJob::query()->whereKey($job->getKey())->update([
'attempts' => DB::raw('attempts + 1'),
'last_error' => Str::limit($error, 990, ''),
'last_attempted_at' => now(),
]);
}
/**
* Marker aus der Zeit vor den Aufträgen (Deploy 28.09.2026) in je einen
* Auftrag `legacy_marker` überführen, damit sie nicht verloren gehen.
*/
public function adoptLegacyMarkers(): int
{
$adopted = 0;
DB::table('press_releases')
->whereNotNull('archive_pending_at')
->whereNotExists(function ($query): void {
$query->select(DB::raw(1))
->from('archive_pending_jobs')
->whereColumn('archive_pending_jobs.press_release_id', 'press_releases.id');
})
->select(['id'])
->lazyById(200)
->each(function (object $row) use (&$adopted): void {
$this->adoptLegacyMarker((int) $row->id);
$adopted++;
});
return $adopted;
}
/**
* Offene Aufträge plus Altmarker ohne Auftrag (Systemstatus).
*
* @return array{jobs: int, legacy_markers: int, oldest: ?Carbon}
*/
public function status(): array
{
$oldest = ArchivePendingJob::query()->min('created_at');
return [
'jobs' => ArchivePendingJob::query()->count(),
'legacy_markers' => DB::table('press_releases')
->whereNotNull('archive_pending_at')
->whereNotExists(function ($query): void {
$query->select(DB::raw(1))
->from('archive_pending_jobs')
->whereColumn('archive_pending_jobs.press_release_id', 'press_releases.id');
})
->count(),
'oldest' => $oldest !== null ? Carbon::parse($oldest) : null,
];
}
/**
* @param array<string, mixed> $payload
*/
private function create(int $pressReleaseId, string $event, ?Model $subject, ?ArchiveReasonCategory $reason, ?int $legalRequestId, ?int $actorId, ?string $note, array $payload): ArchivePendingJob
{
return DB::transaction(function () use ($pressReleaseId, $event, $subject, $reason, $legalRequestId, $actorId, $note, $payload): ArchivePendingJob {
$this->adoptLegacyMarker($pressReleaseId);
$job = ArchivePendingJob::query()->create([
'press_release_id' => $pressReleaseId,
'event' => $event,
'subject_type' => $subject?->getMorphClass(),
'subject_id' => $subject?->getKey(),
'reason_category' => $reason,
'legal_request_id' => $legalRequestId,
'actor_id' => $actorId,
'note' => $note !== null ? Str::limit($note, 490, '') : null,
'payload' => $payload,
'created_at' => now(),
]);
DB::table('press_releases')
->where('id', $pressReleaseId)
->whereNull('archive_pending_at')
->update(['archive_pending_at' => now()]);
return $job;
});
}
/**
* Steht ein Marker ohne Auftrag, stammt er aus der Zeit vor den
* Aufträgen: als eigener Auftrag festhalten, bevor ein neuer Vorgang
* ihn „übernimmt".
*/
private function adoptLegacyMarker(int $pressReleaseId): void
{
$marker = DB::table('press_releases')->where('id', $pressReleaseId)->value('archive_pending_at');
if ($marker === null || ArchivePendingJob::query()->where('press_release_id', $pressReleaseId)->exists()) {
return;
}
ArchivePendingJob::query()->create([
'press_release_id' => $pressReleaseId,
'event' => ArchivePendingJob::EVENT_LEGACY_MARKER,
'payload' => [],
'created_at' => Carbon::parse($marker),
]);
}
}