- 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>
386 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|