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>
This commit is contained in:
parent
d6e49c11c3
commit
c82f279fef
20 changed files with 927 additions and 89 deletions
|
|
@ -50,6 +50,14 @@ class ExpireGrandfathering extends Command
|
|||
|
||||
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
|
||||
{
|
||||
// E18 als Zusicherung statt nur als Kommentar (OPT-9): vor jeder
|
||||
// Änderung, damit ein Lauf auf falscher Queue gar nichts anfasst.
|
||||
if (($violation = $this->queueViolation()) !== null) {
|
||||
$this->error($violation);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$candidates = $this->expiredQuery()->with('user')->get();
|
||||
$pending = $this->pendingNotificationQuery()->with('user')->get();
|
||||
|
||||
|
|
@ -153,7 +161,8 @@ class ExpireGrandfathering extends Command
|
|||
*
|
||||
* Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION`
|
||||
* (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und
|
||||
* ist damit Teil der Transaktion. Bei einem Wechsel auf Redis o. ä. bräuchte
|
||||
* ist damit Teil der Transaktion. handle() sichert das zu (OPT-9,
|
||||
* queueViolation()). Bei einem Wechsel auf Redis o. ä. bräuchte
|
||||
* es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall,
|
||||
* `notified_at` nach Zustellung).
|
||||
*
|
||||
|
|
@ -214,6 +223,36 @@ class ExpireGrandfathering extends Command
|
|||
return [$notified, $failed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Annahme aus notifyPending() (E18): Der Mail-Job wird in
|
||||
* derselben DB-Transaktion eingereiht wie der Marker. Das gilt nur für
|
||||
* die Datenbank-Queue auf der Standard-Verbindung — oder für `sync`
|
||||
* (Tests, lokale Läufe), wo der Versand innerhalb der Transaktion läuft
|
||||
* und ein Fehler den Marker mit zurückrollt. Jede andere Queue würde den
|
||||
* Job unabhängig vom Commit einreihen: dann lieber FAILURE (Alarm) als
|
||||
* stille Doppel- oder Nicht-Zustellung.
|
||||
*/
|
||||
private function queueViolation(): ?string
|
||||
{
|
||||
$default = (string) config('queue.default');
|
||||
|
||||
if ($default === 'sync') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($default !== 'database') {
|
||||
return sprintf('E18 verletzt: QUEUE_CONNECTION ist „%s“, die Benachrichtigung ist nur mit der Datenbank-Queue idempotent. Nichts geändert.', $default);
|
||||
}
|
||||
|
||||
$queueConnection = config('queue.connections.database.connection');
|
||||
|
||||
if ($queueConnection !== null && $queueConnection !== '' && $queueConnection !== config('database.default')) {
|
||||
return sprintf('E18 verletzt: DB_QUEUE_CONNECTION „%s“ weicht von der Standard-Verbindung „%s“ ab, der Job-Insert läge außerhalb der Transaktion. Nichts geändert.', $queueConnection, config('database.default'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl,
|
||||
* Sperre und UPDATE identisch (U11).
|
||||
|
|
|
|||
|
|
@ -2,16 +2,26 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ClassifyPressRelease;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\KiAudit;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||
use App\Services\PressRelease\ManualReviewRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use App\Services\PressRelease\StaleClassificationException;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
|
|
@ -38,6 +48,19 @@ use Throwable;
|
|||
* Prüfung dann schon eingeplant.
|
||||
* Technische Fehler ergeben einen Fehler-Exit-Code, damit der Failure-Hook
|
||||
* des Schedulers greift (Audit F20); fachliche Ablehnungen nicht.
|
||||
*
|
||||
* Menschliche Entscheidung (Umsetzungsaudit 29.09.2026, K-01): Meldungen mit
|
||||
* `manual_review_required_at` zieht der Scheduler nie; publish() prüft die
|
||||
* Marke für automatische Quellen zusätzlich unter der Sperre.
|
||||
*
|
||||
* Fachlich blockiert (K-02): Ist das Kontingent aufgebraucht oder wartet ein
|
||||
* Titelbild noch auf die Bestätigung des Kontoinhabers, bleibt die Meldung in
|
||||
* `review` — das ist kein Betriebsfehler (sonst alle 5 Minuten Exit 1 und
|
||||
* Alarm-Mail). Der Lauf endet erfolgreich; die Sperre steht einmal je
|
||||
* Sperrgrund im Aktivitäts-Verlauf der Meldung (und im Log), bis sich ihr
|
||||
* Status ändert. Eine eigene Autorenmail gibt es bewusst nicht: für das
|
||||
* Kontingent existiert keine passende Vorlage, und die Bildbestätigung hat
|
||||
* der Kunde beim Admin-Upload bereits per Mail angefordert bekommen.
|
||||
*/
|
||||
class PublishScheduledPressReleases extends Command
|
||||
{
|
||||
|
|
@ -53,17 +76,116 @@ class PublishScheduledPressReleases extends Command
|
|||
*/
|
||||
protected $description = 'Veröffentlicht fällige geplante Pressemitteilungen (Status review + scheduled_at <= now).';
|
||||
|
||||
public function handle(PressReleaseService $service): int
|
||||
public function handle(PressReleaseService $service, ActivityLog $activityLog): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
$now = now();
|
||||
|
||||
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
|
||||
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
|
||||
// Fallback in der manuellen Queue.
|
||||
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
|
||||
$published = 0;
|
||||
$rejected = 0;
|
||||
$failed = 0;
|
||||
$rechecked = 0;
|
||||
$blocked = 0;
|
||||
|
||||
// Fairer Fortschritt (Umsetzungsaudit 02.10.2026, O06): Fachlich
|
||||
// blockierte Meldungen bleiben fällig und stünden sonst bei jedem
|
||||
// Lauf vorn — eine volle Seite davon hielte jede jüngere Meldung auf.
|
||||
// Deshalb seitenweise per Cursor über (scheduled_at, id); das Limit
|
||||
// gilt für bearbeitete Meldungen, Blockaden zählen nur auf ein
|
||||
// Scan-Budget, das den Lauf begrenzt.
|
||||
$scanBudget = $limit * 10;
|
||||
$scanned = 0;
|
||||
$handled = 0;
|
||||
$cursor = null;
|
||||
|
||||
while ($handled < $limit && $scanned < $scanBudget) {
|
||||
$page = $this->duePage($now, $cursor, min($limit, $scanBudget - $scanned));
|
||||
|
||||
if ($page->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($page as $pressRelease) {
|
||||
$scanned++;
|
||||
$cursor = [$pressRelease->scheduled_at, $pressRelease->id];
|
||||
|
||||
$line = sprintf(
|
||||
' #%d scheduled_at=%s title="%s"',
|
||||
$pressRelease->id,
|
||||
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
|
||||
Str::limit($pressRelease->title, 60),
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line($line.' [DRY]');
|
||||
$handled++;
|
||||
|
||||
if ($handled >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$outcome = $this->processCandidate($service, $activityLog, $pressRelease, $line);
|
||||
|
||||
match ($outcome) {
|
||||
'published' => $published++,
|
||||
'rejected' => $rejected++,
|
||||
'rechecked' => $rechecked++,
|
||||
'blocked' => $blocked++,
|
||||
default => $failed++,
|
||||
};
|
||||
|
||||
if ($outcome !== 'blocked') {
|
||||
$handled++;
|
||||
}
|
||||
|
||||
if ($handled >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($scanned === 0) {
|
||||
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info(sprintf('%d fällige Pressemitteilung(en) gefunden. (Dry-Run)', $scanned));
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d blockiert, %d fehlgeschlagen.',
|
||||
$published,
|
||||
$rejected,
|
||||
$rechecked,
|
||||
$blocked,
|
||||
$failed,
|
||||
));
|
||||
}
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nächste Seite fälliger Kandidaten nach dem Cursor (scheduled_at, id).
|
||||
* Gelb und Grün gehen zum Termin automatisch live (Decision-Update §5.0);
|
||||
* nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als Fallback in
|
||||
* der manuellen Queue.
|
||||
*
|
||||
* @param array{0: mixed, 1: int}|null $cursor
|
||||
* @return Collection<int, PressRelease>
|
||||
*/
|
||||
private function duePage(CarbonInterface $now, ?array $cursor, int $size): Collection
|
||||
{
|
||||
return PressRelease::withoutGlobalScope(PortalScope::class)
|
||||
->where('status', PressReleaseStatus::Review->value)
|
||||
->whereIn('classification', [
|
||||
PressReleaseClassification::Green->value,
|
||||
|
|
@ -71,78 +193,99 @@ class PublishScheduledPressReleases extends Command
|
|||
])
|
||||
->whereNotNull('scheduled_at')
|
||||
->where('scheduled_at', '<=', $now)
|
||||
->whereNull('manual_review_required_at')
|
||||
->when($cursor !== null, fn ($query) => $query->where(fn ($query) => $query
|
||||
->where('scheduled_at', '>', $cursor[0])
|
||||
->orWhere(fn ($query) => $query->where('scheduled_at', $cursor[0])->where('id', '>', $cursor[1]))))
|
||||
->orderBy('scheduled_at')
|
||||
->limit($limit)
|
||||
->orderBy('id')
|
||||
->limit(max(1, $size))
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
|
||||
/**
|
||||
* Eine fällige Meldung veröffentlichen oder fachlich einordnen.
|
||||
*
|
||||
* @return 'published'|'rejected'|'rechecked'|'blocked'|'failed'
|
||||
*/
|
||||
private function processCandidate(PressReleaseService $service, ActivityLog $activityLog, PressRelease $pressRelease, string $line): string
|
||||
{
|
||||
if ($this->classificationIsStale($pressRelease)) {
|
||||
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $pressRelease->content_hash)->onQueue('classification');
|
||||
$this->warn($line.' [RECHECK: Inhalt nach der Prüfung geändert]');
|
||||
|
||||
return self::SUCCESS;
|
||||
return 'rechecked';
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%d fällige Pressemitteilung(en) gefunden.%s',
|
||||
$candidates->count(),
|
||||
$dryRun ? ' (Dry-Run)' : '',
|
||||
));
|
||||
try {
|
||||
$service->publish($pressRelease, source: 'scheduler');
|
||||
$this->line($line.' [OK]');
|
||||
|
||||
$published = 0;
|
||||
$rejected = 0;
|
||||
$failed = 0;
|
||||
$rechecked = 0;
|
||||
return 'published';
|
||||
} catch (BlacklistViolationException $e) {
|
||||
$this->warn($line.' [REJECT: '.$e->word.']');
|
||||
|
||||
foreach ($candidates as $pressRelease) {
|
||||
$line = sprintf(
|
||||
' #%d scheduled_at=%s title="%s"',
|
||||
$pressRelease->id,
|
||||
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
|
||||
Str::limit($pressRelease->title, 60),
|
||||
);
|
||||
return 'rejected';
|
||||
} catch (StaleClassificationException) {
|
||||
$this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line($line.' [DRY]');
|
||||
return 'rechecked';
|
||||
} catch (ManualReviewRequiredException) {
|
||||
$this->warn($line.' [BLOCKIERT: wartet auf manuelle Prüfung]');
|
||||
|
||||
continue;
|
||||
}
|
||||
return 'blocked';
|
||||
} catch (QuotaExceededException) {
|
||||
$this->warn($line.' [BLOCKIERT: Kontingent aufgebraucht]');
|
||||
$this->recordBlockedOnce($activityLog, $pressRelease, 'quota', 'Kontingent aufgebraucht');
|
||||
|
||||
if ($this->classificationIsStale($pressRelease)) {
|
||||
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $pressRelease->content_hash)->onQueue('classification');
|
||||
$rechecked++;
|
||||
$this->warn($line.' [RECHECK: Inhalt nach der Prüfung geändert]');
|
||||
return 'blocked';
|
||||
} catch (ImageRightsConfirmationRequiredException) {
|
||||
$this->warn($line.' [BLOCKIERT: Titelbild nicht bestätigt]');
|
||||
$this->recordBlockedOnce($activityLog, $pressRelease, 'image_rights', 'Titelbild vom Kontoinhaber noch nicht bestätigt');
|
||||
|
||||
continue;
|
||||
}
|
||||
return 'blocked';
|
||||
} catch (Throwable $e) {
|
||||
$this->error($line.' [FAIL: '.$e->getMessage().']');
|
||||
report($e);
|
||||
|
||||
try {
|
||||
$service->publish($pressRelease, source: 'scheduler');
|
||||
$published++;
|
||||
$this->line($line.' [OK]');
|
||||
} catch (BlacklistViolationException $e) {
|
||||
$rejected++;
|
||||
$this->warn($line.' [REJECT: '.$e->word.']');
|
||||
} catch (StaleClassificationException) {
|
||||
$rechecked++;
|
||||
$this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]');
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
$this->error($line.' [FAIL: '.$e->getMessage().']');
|
||||
report($e);
|
||||
}
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fachliche Sperre einmal je Sperrgrund festhalten (K-02): Ein Eintrag im
|
||||
* Aktivitäts-Verlauf der Meldung (für Admins sichtbar) und eine
|
||||
* Log-Warnung — nicht bei jedem 5-Minuten-Lauf erneut. Ein Statuswechsel
|
||||
* der Meldung beginnt eine neue Episode.
|
||||
*/
|
||||
private function recordBlockedOnce(ActivityLog $activityLog, PressRelease $pressRelease, string $reason, string $label): void
|
||||
{
|
||||
$description = sprintf('PM #%d: Termin erreicht, aber nicht veröffentlicht — %s.', $pressRelease->id, $label);
|
||||
|
||||
$since = PressReleaseStatusLog::query()
|
||||
->where('press_release_id', $pressRelease->id)
|
||||
->max('created_at');
|
||||
|
||||
$alreadyRecorded = ActivityEvent::query()
|
||||
->where('subject_type', $pressRelease->getMorphClass())
|
||||
->where('subject_id', $pressRelease->id)
|
||||
->where('description', $description)
|
||||
->when($since !== null, fn ($query) => $query->where('occurred_at', '>=', $since))
|
||||
->exists();
|
||||
|
||||
if ($alreadyRecorded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d fehlgeschlagen.',
|
||||
$published,
|
||||
$rejected,
|
||||
$rechecked,
|
||||
$failed,
|
||||
));
|
||||
}
|
||||
Log::warning('Geplante Veröffentlichung blockiert.', ['press_release_id' => $pressRelease->id, 'reason' => $reason]);
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
$activityLog->record(
|
||||
ActivityType::PressReleaseStatus,
|
||||
$description,
|
||||
subject: $pressRelease,
|
||||
meta: ['publish_blocked' => $reason],
|
||||
actorLabel: 'scheduler',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ class ResumeNewsletterCampaign extends Command
|
|||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ use App\Scopes\PortalScope;
|
|||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\Classification\ClassificationManager;
|
||||
use App\Services\PressRelease\Classification\ClassificationResult;
|
||||
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||
use App\Services\PressRelease\ManualReviewRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use App\Services\PressRelease\StaleClassificationException;
|
||||
|
|
@ -17,6 +19,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Klassifiziert eine Pressemitteilung asynchron (Red Flag, Konzept §15.1).
|
||||
|
|
@ -67,6 +70,17 @@ class ClassifyPressRelease implements ShouldQueue
|
|||
*/
|
||||
public int $timeout = 120;
|
||||
|
||||
/**
|
||||
* Wartezeit vor dem 2. und 3. Versuch (OPT-8): Ein 429 bzw. eine kurze
|
||||
* Störung des KI-Anbieters soll nicht sofort zweimal nachgeschossen werden.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 120];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $pressReleaseId Zu prüfende PM
|
||||
* @param bool $route Ob das Ergebnis den Status steuert (Rot→reject,
|
||||
|
|
@ -176,6 +190,11 @@ class ClassifyPressRelease implements ShouldQueue
|
|||
return;
|
||||
}
|
||||
|
||||
// Status frisch lesen (Audit K-05): Der KI-Aufruf kann gedauert haben;
|
||||
// hat ein Admin inzwischen entschieden, greift routeByClassification()
|
||||
// gar nicht erst, statt an einer Statusprüfung zu scheitern.
|
||||
$pressRelease->refresh();
|
||||
|
||||
try {
|
||||
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText(), $analyzedHash);
|
||||
} catch (BlacklistViolationException) {
|
||||
|
|
@ -194,6 +213,26 @@ class ClassifyPressRelease implements ShouldQueue
|
|||
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
} catch (ImageRightsConfirmationRequiredException) {
|
||||
// Titelbild wartet auf die Bestätigung des Kontoinhabers (E10,
|
||||
// Audit K-02): fachlicher Ausgang, kein Retry. Die PM bleibt in
|
||||
// Review; die Bestätigung hat der Kunde per Mail bekommen.
|
||||
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: Titelbild noch nicht bestätigt, PM bleibt in Review.', [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
} catch (ManualReviewRequiredException) {
|
||||
// Wartet auf einen Menschen (E3, Audit K-01): keine Automatik.
|
||||
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM wartet auf manuelle Prüfung.', [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
} catch (LogicException $exception) {
|
||||
// Status zwischen refresh() und Zeilensperre überholt (Audit K-05):
|
||||
// die Entscheidung ist gefallen, ein Retry würde nur einen zweiten
|
||||
// KI-Aufruf und ein doppeltes Audit erzeugen.
|
||||
Log::channel('ki')->info('Routing überholt: Status wurde während der Prüfung geändert.', [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,17 @@ class ScorePressRelease implements ShouldQueue
|
|||
*/
|
||||
public int $timeout = 120;
|
||||
|
||||
/**
|
||||
* Wartezeit vor dem 2. und 3. Versuch (OPT-8): Ein 429 bzw. eine kurze
|
||||
* Störung des KI-Anbieters soll nicht sofort zweimal nachgeschossen werden.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 120];
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
public readonly int $pressReleaseId,
|
||||
public readonly ?string $providerOverride = null,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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;
|
||||
|
|
@ -52,6 +53,13 @@ class SendNewsletterCampaignMail implements ShouldQueue
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,11 +30,26 @@ class CampaignSender
|
|||
{
|
||||
public function __construct(private readonly CampaignRecipients $recipients) {}
|
||||
|
||||
/**
|
||||
* Versand per Konfiguration freigegeben (E9-Schalter).
|
||||
*/
|
||||
public static function sendingEnabled(): bool
|
||||
{
|
||||
return (bool) config('newsletter.sending_enabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Anzahl der eingeplanten Empfänger.
|
||||
*
|
||||
* @throws NewsletterSendingDisabledException Versand gesperrt; der Entwurf
|
||||
* bleibt unverändert.
|
||||
*/
|
||||
public function start(NewsletterCampaign $campaign): int
|
||||
{
|
||||
if (! self::sendingEnabled()) {
|
||||
throw new NewsletterSendingDisabledException;
|
||||
}
|
||||
|
||||
if (! $this->claimDraft($campaign)) {
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Newsletter;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Kampagnen-Versand ist per Konfiguration gesperrt (`newsletter.sending_enabled`,
|
||||
* E9-Schalter, Umsetzungsaudit 29.09.2026) — bis U05–U07 umgesetzt sind.
|
||||
*/
|
||||
class NewsletterSendingDisabledException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(__('Der Newsletter-Versand ist derzeit gesperrt (NEWSLETTER_SENDING_ENABLED). Entwürfe und Test-Mails bleiben möglich.'));
|
||||
}
|
||||
}
|
||||
19
app/Services/PressRelease/ManualReviewRequiredException.php
Normal file
19
app/Services/PressRelease/ManualReviewRequiredException.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Automatische Veröffentlichung (`ki`, `scheduler`) einer Meldung, die auf
|
||||
* eine menschliche Entscheidung wartet (E3, `manual_review_required_at`) —
|
||||
* Umsetzungsaudit 29.09.2026, K-01. Fachlicher Ausgang, kein Fehler: Die
|
||||
* Meldung bleibt unverändert in `review`, nur ein Admin gibt sie frei.
|
||||
*/
|
||||
class ManualReviewRequiredException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly int $pressReleaseId)
|
||||
{
|
||||
parent::__construct(sprintf('PM #%d wartet auf eine manuelle Prüfung und wird nicht automatisch veröffentlicht.', $pressReleaseId));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue