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>
94 lines
3.4 KiB
PHP
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;
|
|
}
|
|
}
|