- 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>
100 lines
3.6 KiB
PHP
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;
|
|
}
|
|
}
|