presseportale/app/Console/Commands/PurgeExpiredPressReleaseDrafts.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

120 lines
5.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* 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. Was
* möglicherweise veröffentlicht war, bleibt unangetastet — maßgeblich ist
* PressRelease::neverPublished(): `published_ever_at` und der Altbestand
* `rejected` (im Altsystem auch „offline genommen", Umsetzungsaudit
* 29.09.2026 A-05).
*
* Ein defektes Bild bricht den Lauf nicht ab (A-14): der Fehler wird
* gezählt, die Meldung behält ihren Status für den nächsten Lauf, und der
* Befehl endet mit Fehlerstatus.
*/
class PurgeExpiredPressReleaseDrafts extends Command
{
protected $signature = 'press-releases:purge-drafts
{--days=180 : Entwürfe älter als X Tage archivieren}
{--dry-run : Nur zählen, nichts ändern}';
protected $description = 'Archiviert inaktive PM-Entwürfe und bereinigt Medien abgelehnter Einreichungen (älter als X Tage).';
public function handle(): int
{
$days = (int) $this->option('days');
$isDryRun = $this->option('dry-run');
$cutoff = now()->subDays($days);
$drafts = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Draft->value)
->where('updated_at', '<', $cutoff);
// 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)
->neverPublished()
->whereNull('legacy_id')
->where('updated_at', '<', $cutoff)
->whereHas('images');
$draftCount = $drafts->count();
$rejectedCount = $rejected->count();
if ($isDryRun) {
$this->warn("[DRY-RUN] {$draftCount} Entwürfe würden archiviert, {$rejectedCount} abgelehnte Einreichungen würden bereinigt. Kein tatsächlicher Vorgang.");
return self::SUCCESS;
}
// 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);
$failedPressReleaseIds = [];
$imagesRemoved = $this->removeImages((clone $drafts)->neverPublished(), $remover, $failedPressReleaseIds)
+ $this->removeImages(clone $rejected, $remover, $failedPressReleaseIds);
// Entwürfe mit gescheiterter Bildlöschung behalten ihren Status,
// sonst fielen sie aus dem nächsten Lauf heraus.
$archived = $drafts->when($failedPressReleaseIds !== [], fn (Builder $query) => $query->whereKeyNot($failedPressReleaseIds))
->update(['status' => PressReleaseStatus::Archived->value]);
$failed = count($failedPressReleaseIds);
$this->info("PM-Entwürfe archiviert: {$archived} Einträge, abgelehnte Einreichungen bereinigt: {$rejectedCount}, Bilder gelöscht: {$imagesRemoved}, Meldungen mit fehlgeschlagener Bildlöschung: {$failed}.");
if ($failed > 0) {
$this->error("Bildlöschung fehlgeschlagen: {$failed} Meldung(en) — Details im Log, sie werden beim nächsten Lauf wiederholt.");
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* @param Builder<PressRelease> $query
* @param list<int> $failedPressReleaseIds
*/
private function removeImages(Builder $query, PressReleaseImageRemover $remover, array &$failedPressReleaseIds): int
{
$removed = 0;
$query->with('images')->chunkById(200, function ($pressReleases) use ($remover, &$removed, &$failedPressReleaseIds): void {
foreach ($pressReleases as $pressRelease) {
foreach ($pressRelease->images as $image) {
try {
$remover->remove($image);
$removed++;
} catch (Throwable $exception) {
$failedPressReleaseIds[] = (int) $pressRelease->getKey();
Log::error('purge-drafts: Bild konnte nicht gelöscht werden, wird beim nächsten Lauf wiederholt.', [
'press_release_id' => $pressRelease->getKey(),
'press_release_image_id' => $image->getKey(),
'error' => $exception->getMessage(),
]);
}
}
}
});
$failedPressReleaseIds = array_values(array_unique($failedPressReleaseIds));
return $removed;
}
}