presseportale/app/Console/Commands/PublishScheduledPressReleases.php
Kevin Adametz c82f279fef 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>
2026-10-02 13:09:00 +00:00

308 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
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;
/**
* Veröffentlicht Pressemitteilungen mit Status `review`, der KI-Klassifikation
* `green` und einem `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
*
* Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent:
* berührt nur grüne PRs in Review-Status — bereits publishte werden ignoriert.
* Gelb eingestufte PMs bleiben bewusst in der manuellen Admin-Queue, auch wenn
* ihr Termin fällig ist.
*
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
* Mail-Benachrichtigung des Autors.
*
* Inhaltsbindung (Audit F07): Passt die jüngste Klassifikation nicht mehr
* zum aktuellen Text (Admin- oder Korrektur-Bearbeitung nach der Prüfung),
* wird nicht veröffentlicht, sondern eine Prüfung mit Routing eingeplant –
* Grün geht dann zum fälligen Termin live, Rot lehnt ab und benachrichtigt.
* Die Vorprüfung hier ist nur der schnelle Filter; verbindlich prüft
* PressReleaseService::publish() unter der Zeilensperre noch einmal
* (Audit U02) und meldet eine Abweichung als StaleClassificationException –
* die zählt hier ebenfalls als Re-Check, nicht als Fehler; publish() hat die
* 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
{
/**
* @var string
*/
protected $signature = 'press-releases:publish-scheduled
{--dry-run : Nur anzeigen, was publiziert würde, ohne DB zu ändern}
{--limit=200 : Maximale Anzahl pro Lauf}';
/**
* @var string
*/
protected $description = 'Veröffentlicht fällige geplante Pressemitteilungen (Status review + scheduled_at <= now).';
public function handle(PressReleaseService $service, ActivityLog $activityLog): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$now = now();
$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,
PressReleaseClassification::Yellow->value,
])
->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')
->orderBy('id')
->limit(max(1, $size))
->get();
}
/**
* 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 'rechecked';
}
try {
$service->publish($pressRelease, source: 'scheduler');
$this->line($line.' [OK]');
return 'published';
} catch (BlacklistViolationException $e) {
$this->warn($line.' [REJECT: '.$e->word.']');
return 'rejected';
} catch (StaleClassificationException) {
$this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]');
return 'rechecked';
} catch (ManualReviewRequiredException) {
$this->warn($line.' [BLOCKIERT: wartet auf manuelle Prüfung]');
return 'blocked';
} catch (QuotaExceededException) {
$this->warn($line.' [BLOCKIERT: Kontingent aufgebraucht]');
$this->recordBlockedOnce($activityLog, $pressRelease, 'quota', 'Kontingent aufgebraucht');
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');
return 'blocked';
} catch (Throwable $e) {
$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;
}
Log::warning('Geplante Veröffentlichung blockiert.', ['press_release_id' => $pressRelease->id, 'reason' => $reason]);
$activityLog->record(
ActivityType::PressReleaseStatus,
$description,
subject: $pressRelease,
meta: ['publish_blocked' => $reason],
actorLabel: 'scheduler',
);
}
/**
* Jüngste gültige Klassifikation mit Prüfsumme, die nicht zum aktuellen
* Inhalt passt. Altbestand ohne Prüfsumme (vor der Bindung) gilt weiter.
*/
private function classificationIsStale(PressRelease $pressRelease): bool
{
$latestHash = KiAudit::query()
->where('press_release_id', $pressRelease->id)
->where('type', KiAudit::TYPE_CLASSIFICATION)
->whereNull('discarded_at')
->orderByDesc('id')
->value('content_hash');
return $latestHash !== null
&& $pressRelease->content_hash !== null
&& $latestHash !== $pressRelease->content_hash;
}
}