presseportale/app/Console/Commands/ResumeNewsletterCampaign.php
Kevin Adametz eeb1cde48d newsletter: jeden Empfänger genau einmal, atomarer Start, Wiederanlauf (Audit Block 2, Deploy B)
F05: chunkById hinter der created_at-Sortierung der Vorschau las ab der
zweiten Seite Empfänger doppelt und ließ die übrigen aus. Auf der Dev-
Kopie erreichte eine Kampagne an 59.705 Nutzer 500 Adressen, davon 480
doppelt. Die Sortierung wird vor dem ID-Chunking verworfen (E4: die
Versandreihenfolge hat keine fachliche Bedeutung), ein Unique-Index auf
(Kampagne, User) fängt jede weitere Doppelung ab; die Migration räumt
vorhandene Doppelungen vorher weg (auf der Dev-Kopie 470 Zeilen).

F14: Der Start übernimmt den Entwurf per bedingtem UPDATE draft→sending;
nur der Gewinner friert Empfänger ein (insertOrIgnore) und plant Jobs
aus dem Protokoll ein. Abbruch beim Einfrieren gibt den Entwurf frei.

F15: Der Versandjob beansprucht die Zeile pending→sending, schreibt
Empfängerstatus, Zähler und Kampagnenabschluss in einer Transaktion und
gibt den Claim bei Versandfehler zurück. Tries und Backoff am Job.
Zusatzbefund: Adressen auf der Sperrliste galten als „gesendet“, weil der
Rückgabewert von Mail::send nicht ausgewertet wurde — jetzt Status
„suppressed“ ohne Zähler.

Neu: php artisan newsletter:resume {kampagne} [--failed]
[--stuck-minutes=30] plant ausstehende Empfänger einer laufenden
Kampagne neu ein; der Circuit-Breaker-Hinweis nennt ihn statt
queue:retry, das Newsletter-Empfänger nicht nachholt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:19:28 +00:00

94 lines
3.4 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
{
$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;
}
}