presseportale/app/Console/Commands/ResumeNewsletterCampaign.php
Kevin Adametz c82f279fef press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)
- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft
  für ki/scheduler unter der Sperre (ManualReviewRequiredException)
- K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger
  Verlaufseintrag); KI-Job ohne Retry bei Bildsperre
- O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete
  Meldungen, Blockaden nur ein Scan-Budget
- K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff
- K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500
- K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50,
  neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45)
- E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job
- OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18

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

100 lines
3.6 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Enums\NewsletterCampaignStatus;
use App\Enums\NewsletterRecipientStatus;
use App\Models\NewsletterCampaign;
use App\Services\Newsletter\CampaignSender;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Wiederanlauf einer laufenden Newsletter-Kampagne (Audit F14/F15): plant
* alle ausstehenden Empfänger neu ein — nach abgebrochenem Start, nach
* einer Pause des Circuit Breakers oder nach einem Worker-Absturz.
*
* `--failed` setzt fehlgeschlagene Empfänger zurück (z. B. nach behobener
* Relay-Störung). Empfänger, die länger als `--stuck-minutes` in „sending“
* hängen, gelten als Absturz-Reste: Ob die Mail dort schon raus war, ist
* unklar — der Aufrufer entscheidet mit der Frist, ob er das Risiko eines
* Doppelversands eingeht.
*/
class ResumeNewsletterCampaign extends Command
{
protected $signature = 'newsletter:resume
{campaign : ID der Kampagne im Status „sending“}
{--failed : Fehlgeschlagene Empfänger zurücksetzen und erneut einplanen}
{--stuck-minutes=30 : Empfänger, die länger als X Minuten in „sending“ hängen, zurücksetzen (0 = keine)}';
protected $description = 'Plant die ausstehenden Empfänger einer laufenden Newsletter-Kampagne erneut ein.';
public function handle(): int
{
if (! CampaignSender::sendingEnabled()) {
$this->error('Der Newsletter-Versand ist gesperrt (NEWSLETTER_SENDING_ENABLED=false) — nichts eingeplant.');
return self::FAILURE;
}
$campaign = NewsletterCampaign::query()->find((int) $this->argument('campaign'));
if ($campaign === null) {
$this->error('Kampagne nicht gefunden.');
return self::FAILURE;
}
if ($campaign->status !== NewsletterCampaignStatus::Sending) {
$this->error(sprintf('Kampagne #%d ist „%s“, nicht „%s“ — nichts zu tun.', $campaign->id, $campaign->status->label(), NewsletterCampaignStatus::Sending->label()));
return self::FAILURE;
}
$stuckMinutes = max(0, (int) $this->option('stuck-minutes'));
$stuck = 0;
if ($stuckMinutes > 0) {
$stuck = $campaign->recipients()
->where('status', NewsletterRecipientStatus::Sending->value)
->where('updated_at', '<', now()->subMinutes($stuckMinutes))
->update([
'status' => NewsletterRecipientStatus::Pending->value,
'error' => null,
'updated_at' => now(),
]);
}
$failed = 0;
if ($this->option('failed')) {
$failed = DB::transaction(function () use ($campaign): int {
$reset = $campaign->recipients()
->where('status', NewsletterRecipientStatus::Failed->value)
->update([
'status' => NewsletterRecipientStatus::Pending->value,
'error' => null,
'updated_at' => now(),
]);
if ($reset > 0) {
NewsletterCampaign::query()->whereKey($campaign->id)->decrement('failed_count', $reset);
}
return $reset;
});
}
$dispatched = CampaignSender::dispatchPending($campaign);
$this->info(sprintf(
'Kampagne #%d: %d Empfänger neu eingeplant (%d hängende zurückgesetzt, %d fehlgeschlagene zurückgesetzt).',
$campaign->id,
$dispatched,
$stuck,
$failed,
));
return self::SUCCESS;
}
}