presseportale/app/Services/Archive/PendingArchiveRetry.php
Kevin Adametz 60650baeb8 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

386 lines
14 KiB
PHP

<?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\ArchivePendingJob;
use App\Models\ImageDeclarationCorrection;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseRedactionSnapshot;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Nachholer für Archiveinträge (N11, Umsetzungsaudit 29.09.2026 A-01/A-02/
* A-04/A-09/A-10). Arbeitet je Auftrag (ArchivePendingJob) und je
* Ereignistyp:
*
* - `pm_depublished`: aus dem am Auftrag festgehaltenen Stand zum Zeitpunkt
* der Depublikation — nie aus dem aktuellen Text;
* - `gdpr_change`: Vorher- und Nachher-Stand aus dem Auftrag;
* - `image_removed`/`image_replaced`: weich gelöschtes v2-Bild ins Archiv;
* - `label_corrected`: Korrekturzeile ins Archiv (Auftrags-API für den
* Korrekturweg, siehe ArchivePendingQueue);
* - `image_purge`: gescheiterte Sofortlöschung eines nie veröffentlichten
* Bildes wiederholen (kein Archiveintrag);
* - `legacy_marker`: Marker ohne Auftrag aus der Zeit vor den Aufträgen,
* einmalig mit der bisherigen Heuristik.
*
* Ein Auftrag fällt nur zusammen mit seinem Eintrag (eine Transaktion).
* Existiert der Eintrag schon, gilt der Auftrag als erledigt; wird nichts
* archiviert (Schlüssel fehlt), bleibt er offen und zählt als übersprungen.
*/
class PendingArchiveRetry
{
private const NOTE = 'Nachgeholt durch archive:retry-pending';
public function __construct(
private readonly PressReleaseArchiver $archiver,
private readonly PressReleaseImageRemover $remover,
private readonly ArchivePendingQueue $queue,
) {}
/**
* @return array{recorded: int, skipped: int, failed: int}
*/
public function retry(ArchivePendingJob $job): array
{
$alreadyCompleted = false;
try {
$recorded = DB::transaction(function () use ($job, &$alreadyCompleted): ?int {
// Beanspruchen wie der Direktweg (O07); mit der frisch
// gelesenen Zeile weiterarbeiten.
$claimed = $this->queue->claim($job);
if ($claimed === null) {
$alreadyCompleted = true;
return null;
}
$recorded = $this->handle($claimed);
if ($recorded !== null) {
$this->queue->complete($claimed);
}
return $recorded;
});
} catch (Throwable $exception) {
$this->queue->recordFailure($job, $exception->getMessage());
Log::error('Beweisarchiv: Nachholen eines Auftrags fehlgeschlagen.', [
'job_id' => $job->getKey(),
'event' => $job->event,
'press_release_id' => $job->press_release_id,
'error' => $exception->getMessage(),
]);
return ['recorded' => 0, 'skipped' => 0, 'failed' => 1];
}
if ($alreadyCompleted) {
return ['recorded' => 0, 'skipped' => 0, 'failed' => 0];
}
if ($recorded === null) {
$this->queue->recordFailure($job, 'Eintrag wurde nicht archiviert (ARCHIVE_KEY fehlt oder ist ungültig).');
return ['recorded' => 0, 'skipped' => 1, 'failed' => 0];
}
return ['recorded' => $recorded, 'skipped' => 0, 'failed' => 0];
}
/**
* Anzahl geschriebener Einträge; null = nicht archiviert (Auftrag bleibt).
*/
private function handle(ArchivePendingJob $job): ?int
{
return match ($job->event) {
ArchiveEvent::PressReleaseDepublished->value => $this->counted($this->depublished($job)),
ArchiveEvent::GdprChange->value => $this->counted($this->gdprChange($job)),
ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value => $this->imageRemoved($job),
ArchiveEvent::LabelCorrected->value => $this->labelCorrected($job),
ArchivePendingJob::EVENT_IMAGE_PURGE => $this->imagePurge($job),
ArchivePendingJob::EVENT_LEGACY_MARKER => $this->legacyMarker($job),
default => throw new RuntimeException('Beweisarchiv: unbekannte Auftragsart '.$job->event.'.'),
};
}
private function depublished(ArchivePendingJob $job): ?ArchiveEntry
{
$pressRelease = $this->pressRelease($job);
$state = $job->payload['state'] ?? null;
if (! is_array($state) || $state === []) {
throw new RuntimeException('Beweisarchiv: Auftrag ohne festgehaltenen Stand der Depublikation.');
}
return $this->archiver->recordDepublished(
$pressRelease,
$state,
$job->reason_category ?? ArchiveReasonCategory::OwnDecision,
$this->actor($job),
$this->note($job),
$this->legalRequest($job),
);
}
private function gdprChange(ArchivePendingJob $job): ?ArchiveEntry
{
$payload = (array) $job->payload;
$snapshotId = $job->subject_type === (new PressReleaseRedactionSnapshot)->getMorphClass() ? $job->subject_id : null;
return $this->archiver->gdprChange(
$this->pressRelease($job),
(array) ($payload['before'] ?? []),
(array) ($payload['after'] ?? []),
$this->legalRequest($job),
$this->actor($job),
$job->reason_category ?? ArchiveReasonCategory::Gdpr,
$this->note($job),
$snapshotId,
);
}
private function imageRemoved(ArchivePendingJob $job): ?int
{
$image = PressReleaseImage::withTrashed()->find($job->subject_id);
if ($image === null || $this->imageEntryExists($image)) {
return 0;
}
return $this->counted($this->remover->archiveTrashed(
$image,
$this->actor($job),
ArchiveEvent::from($job->event),
$job->reason_category ?? ArchiveReasonCategory::OwnDecision,
$this->legalRequest($job),
$this->note($job),
));
}
private function labelCorrected(ArchivePendingJob $job): ?int
{
$correction = ImageDeclarationCorrection::query()->find($job->subject_id);
$image = $correction !== null ? PressReleaseImage::withTrashed()->find($correction->press_release_image_id) : null;
if ($correction === null || $image === null) {
return 0;
}
$exists = ArchiveEntry::query()
->where('subject_type', $image->getMorphClass())
->where('subject_id', $image->getKey())
->where('event', ArchiveEvent::LabelCorrected->value)
->get(['id', 'summary'])
->contains(fn (ArchiveEntry $entry): bool => (int) ($entry->summary['correction_id'] ?? 0) === $correction->getKey());
if ($exists) {
return 0;
}
return $this->counted($this->archiver->labelCorrected($image, $correction, $this->actor($job)));
}
/**
* Gescheiterte Sofortlöschung wiederholen (A-10). Der Remover arbeitet
* nur auf nicht gelöschten Zeilen; die weich gelöschte Zeile wird dafür
* innerhalb der Transaktion zurückgeholt — scheitert die Löschung
* erneut, rollt das mit zurück und das Bild bleibt ausgeblendet.
*/
private function imagePurge(ArchivePendingJob $job): ?int
{
$image = PressReleaseImage::withTrashed()->find($job->subject_id);
if ($image === null) {
return 0;
}
// Inzwischen veröffentlicht gewesen: nie sofort löschen, archivieren.
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
if ($this->imageEntryExists($image)) {
return 0;
}
return $this->counted($this->remover->archiveTrashed($image, $this->actor($job), ArchiveEvent::ImageRemoved, ArchiveReasonCategory::OwnDecision, null, $this->note($job)));
}
if ($image->trashed()) {
$image->restoreQuietly();
}
$this->remover->remove($image);
if (PressReleaseImage::withTrashed()->whereKey($image->getKey())->exists()) {
throw new RuntimeException('Beweisarchiv: Sofortlöschung von Bild #'.$image->getKey().' erneut nicht abgeschlossen.');
}
return 0;
}
/**
* Marker aus der Zeit vor den Aufträgen: bisherige Heuristik, einmalig.
* Vorher-Stände kommen aus den Redaction-Snapshots, die
* `snapshots:purge` bei offenem Auftrag stehen lässt (A-13).
*/
private function legacyMarker(ArchivePendingJob $job): ?int
{
$pressRelease = $this->pressRelease($job);
$since = $job->created_at ?? now();
$recorded = 0;
if ($pressRelease->status !== PressReleaseStatus::Published && $pressRelease->published_ever_at !== null) {
$exists = ArchiveEntry::query()
->where('press_release_id', $pressRelease->getKey())
->where('event', ArchiveEvent::PressReleaseDepublished->value)
->where('created_at', '>=', $since)
->exists();
if (! $exists) {
$entry = $this->archiver->pressReleaseDepublished(
$pressRelease,
$pressRelease->depublished_at !== null ? ArchiveReasonCategory::CustomerRequest : ArchiveReasonCategory::OwnDecision,
null,
self::NOTE.' (Altmarker, Stand beim Nachholen)',
);
if ($entry === null) {
return null;
}
$recorded++;
}
}
$snapshots = PressReleaseRedactionSnapshot::query()
->where('press_release_id', $pressRelease->getKey())
->where('created_at', '>=', $since->copy()->subMinute())
->with('legalRequest')
->orderBy('id')
->get();
foreach ($snapshots as $snapshot) {
$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) {
continue;
}
$before = (array) ($snapshot->encrypted_before['fields'] ?? []);
$legalRequest = $snapshot->legalRequest;
$entry = $this->archiver->gdprChange(
$pressRelease,
$before,
$pressRelease->only(array_keys($before)),
$legalRequest,
null,
$legalRequest?->type === LegalRequestType::Correction ? ArchiveReasonCategory::Correction : ArchiveReasonCategory::Gdpr,
self::NOTE.' (Altmarker)',
$snapshot->getKey(),
);
if ($entry === null) {
return null;
}
$recorded++;
}
$latestRequest = $snapshots->last()?->legalRequest;
foreach ($this->trashedImagesWithoutEntry($pressRelease) as $image) {
$entry = $this->remover->archiveTrashed(
$image,
null,
ArchiveEvent::ImageRemoved,
$latestRequest !== null ? ArchiveReasonCategory::Gdpr : ArchiveReasonCategory::OwnDecision,
$latestRequest,
self::NOTE.' (Altmarker)',
);
if ($entry === null) {
return null;
}
$recorded++;
}
return $recorded;
}
/**
* @return Collection<int, PressReleaseImage>
*/
private function trashedImagesWithoutEntry(PressRelease $pressRelease): Collection
{
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 => $this->imageEntryExists($image));
}
private function imageEntryExists(PressReleaseImage $image): bool
{
return ArchiveEntry::query()
->where('subject_type', $image->getMorphClass())
->where('subject_id', $image->getKey())
->whereIn('event', [ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value])
->exists();
}
private function pressRelease(ArchivePendingJob $job): PressRelease
{
$pressRelease = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->withTrashed()
->find($job->press_release_id);
if (! $pressRelease instanceof PressRelease) {
throw new RuntimeException('Beweisarchiv: Meldung #'.$job->press_release_id.' des Auftrags existiert nicht mehr.');
}
return $pressRelease;
}
private function legalRequest(ArchivePendingJob $job): ?LegalRequest
{
return $job->legal_request_id !== null ? LegalRequest::query()->find($job->legal_request_id) : null;
}
private function actor(ArchivePendingJob $job): ?User
{
return $job->actor_id !== null ? User::query()->find($job->actor_id) : null;
}
private function note(ArchivePendingJob $job): string
{
return $job->note !== null && $job->note !== '' ? $job->note.' — '.self::NOTE : self::NOTE;
}
private function counted(?ArchiveEntry $entry): ?int
{
return $entry !== null ? 1 : null;
}
}