- 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>
204 lines
7.1 KiB
PHP
204 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Enums\EmailNotificationType;
|
|
use App\Enums\NewsletterCampaignStatus;
|
|
use App\Enums\NewsletterRecipientStatus;
|
|
use App\Mail\NewsletterCampaignMail;
|
|
use App\Models\NewsletterCampaign;
|
|
use App\Models\NewsletterCampaignRecipient;
|
|
use App\Services\Mail\EmailSuppressionService;
|
|
use App\Services\Newsletter\CampaignSender;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Queue\Queueable;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Versendet eine Newsletter-Kampagne an genau einen Empfänger (Protokoll-
|
|
* Zeile). Pro Empfänger ein Job, damit Fehler einzeln nachvollziehbar
|
|
* bleiben und Retries keinen Doppelversand an die übrige Liste auslösen.
|
|
*
|
|
* Der Opt-out wird hier — zum tatsächlichen Versandzeitpunkt — erneut
|
|
* geprüft: Wer sich nach Versandstart abmeldet, wird übersprungen.
|
|
*
|
|
* Gegen Doppelversand und schiefe Zähler (Audit F15): Der Job übernimmt
|
|
* die Zeile per bedingtem UPDATE pending → sending; verliert er den Claim,
|
|
* ist ein anderer Worker dran. Empfängerstatus, Kampagnenzähler und
|
|
* Kampagnenabschluss werden in einer Transaktion geschrieben. Schlägt der
|
|
* Mailversand fehl, geht der Claim an „pending“ zurück und der Retry darf
|
|
* neu zugreifen. Bleibt eine Zeile in „sending“, ist der Worker nach der
|
|
* Mailserver-Übergabe abgestürzt: Zustellung unklar, kein automatischer
|
|
* Neuversand — `newsletter:resume --stuck-minutes` entscheidet das bewusst.
|
|
*/
|
|
class SendNewsletterCampaignMail implements ShouldQueue
|
|
{
|
|
use Queueable;
|
|
|
|
public int $tries = 3;
|
|
|
|
public function __construct(public readonly int $recipientId) {}
|
|
|
|
/**
|
|
* Wartezeit in Sekunden vor dem zweiten und dritten Versuch.
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
public function backoff(): array
|
|
{
|
|
return [30, 120];
|
|
}
|
|
|
|
public function handle(): void
|
|
{
|
|
// Versand gesperrt (E9-Schalter): nichts beanspruchen, der Empfänger
|
|
// bleibt `pending` und kann nach dem Freischalten per
|
|
// `newsletter:resume` erneut eingeplant werden.
|
|
if (! CampaignSender::sendingEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (! $this->claim()) {
|
|
return;
|
|
}
|
|
|
|
$recipient = NewsletterCampaignRecipient::query()
|
|
->with(['campaign', 'user'])
|
|
->find($this->recipientId);
|
|
|
|
if ($recipient === null || $recipient->campaign === null) {
|
|
return;
|
|
}
|
|
|
|
$campaign = $recipient->campaign;
|
|
$user = $recipient->user;
|
|
|
|
if ($user === null || ! $user->is_active) {
|
|
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', 'Benutzer gelöscht oder deaktiviert.');
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $user->wantsEmailNotification(EmailNotificationType::Newsletter)) {
|
|
$this->conclude($recipient, NewsletterRecipientStatus::SkippedOptOut, 'skipped_count');
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$sent = Mail::to($recipient->email)->send(new NewsletterCampaignMail($campaign, $user));
|
|
} catch (\Throwable $exception) {
|
|
$this->release();
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
// Ohne SentMessage hat das Versand-Gate die Mail gestrichen. Die
|
|
// Sperrliste bestätigt das — der Rückgabewert allein trägt nicht,
|
|
// die Mail-Attrappe der Tests liefert ebenfalls null.
|
|
if ($sent === null && app(EmailSuppressionService::class)->isSuppressed($recipient->email)) {
|
|
$this->conclude($recipient, NewsletterRecipientStatus::Suppressed, null, 'Adresse auf der Sperrliste.');
|
|
|
|
return;
|
|
}
|
|
|
|
$this->conclude($recipient, NewsletterRecipientStatus::Sent, 'sent_count');
|
|
}
|
|
|
|
/**
|
|
* Endgültiger Fehlschlag nach allen Versuchen des Workers.
|
|
*/
|
|
public function failed(\Throwable $exception): void
|
|
{
|
|
$recipient = NewsletterCampaignRecipient::query()->find($this->recipientId);
|
|
|
|
if ($recipient === null || ! in_array($recipient->status->value, NewsletterRecipientStatus::openValues(), true)) {
|
|
return;
|
|
}
|
|
|
|
$error = $recipient->status === NewsletterRecipientStatus::Sending
|
|
? 'Zustellung unklar (Fehler nach Übergabe an den Mailserver): '.$exception->getMessage()
|
|
: $exception->getMessage();
|
|
|
|
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', $error);
|
|
}
|
|
|
|
/**
|
|
* Bedingter Claim pending → sending. Genau ein Worker gewinnt.
|
|
*/
|
|
private function claim(): bool
|
|
{
|
|
return NewsletterCampaignRecipient::query()
|
|
->whereKey($this->recipientId)
|
|
->where('status', NewsletterRecipientStatus::Pending->value)
|
|
->update([
|
|
'status' => NewsletterRecipientStatus::Sending->value,
|
|
'updated_at' => now(),
|
|
]) === 1;
|
|
}
|
|
|
|
/**
|
|
* Claim zurückgeben, wenn der Versand nicht stattgefunden hat.
|
|
*/
|
|
private function release(): void
|
|
{
|
|
NewsletterCampaignRecipient::query()
|
|
->whereKey($this->recipientId)
|
|
->where('status', NewsletterRecipientStatus::Sending->value)
|
|
->update([
|
|
'status' => NewsletterRecipientStatus::Pending->value,
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Abschluss in einer Transaktion: Empfängerstatus, Kampagnenzähler und
|
|
* — wenn keine offene Zeile mehr da ist — Kampagnenabschluss. Die
|
|
* Kampagnenzeile wird zuerst gesperrt, damit zwei gleichzeitig
|
|
* endende Jobs den Abschluss nicht gegenseitig übersehen.
|
|
*/
|
|
private function conclude(
|
|
NewsletterCampaignRecipient $recipient,
|
|
NewsletterRecipientStatus $status,
|
|
?string $campaignCounter,
|
|
?string $error = null,
|
|
): void {
|
|
DB::transaction(function () use ($recipient, $status, $campaignCounter, $error): void {
|
|
$campaign = NewsletterCampaign::query()
|
|
->whereKey($recipient->newsletter_campaign_id)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
$recipient->update([
|
|
'status' => $status->value,
|
|
'error' => $error !== null ? Str::limit($error, 480) : null,
|
|
'sent_at' => $status === NewsletterRecipientStatus::Sent ? now() : null,
|
|
]);
|
|
|
|
if ($campaign === null) {
|
|
return;
|
|
}
|
|
|
|
if ($campaignCounter !== null) {
|
|
NewsletterCampaign::query()->whereKey($campaign->id)->increment($campaignCounter);
|
|
}
|
|
|
|
$hasOpen = $campaign->recipients()
|
|
->whereIn('status', NewsletterRecipientStatus::openValues())
|
|
->exists();
|
|
|
|
if (! $hasOpen) {
|
|
NewsletterCampaign::query()
|
|
->whereKey($campaign->id)
|
|
->where('status', NewsletterCampaignStatus::Sending->value)
|
|
->update([
|
|
'status' => NewsletterCampaignStatus::Sent->value,
|
|
'sent_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
});
|
|
}
|
|
}
|