web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und Medienpfade sowie die PressRelease-Helfer schreiben jetzt withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein. Der Vorschau-Test prüfte bisher forceDelete statt delete. F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER. Neuer Index contacts_email_idx. F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt- Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache vorher nicht und traf den Fehler nie. F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst (ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung gewinnt die Erstquelle. F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren Vertrag. F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht als verlorenes Logo. Block 4 — KI-Prüfung und Scheduler F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen (ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum aktuellen Text passt, sondern plant eine Prüfung mit Routing ein. F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period). Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der Reset ist idempotent und lässt den laufenden Monat in Ruhe. F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen Fehler-Exit-Code, damit der Failure-Hook greift. F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt. F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
62202a4b13
commit
f235156de9
73 changed files with 1746 additions and 170 deletions
|
|
@ -8,6 +8,7 @@ use App\Models\PressRelease;
|
|||
use App\Models\PressReleaseEvent;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
|
|
@ -40,7 +41,31 @@ class AggregatePressReleaseViews extends Command
|
|||
*/
|
||||
public const WatermarkKey = 'press_release_views.aggregated_through_id';
|
||||
|
||||
/**
|
||||
* Cache-Lock über alle Einstiege (Scheduler UND manueller Aufruf): Zwei
|
||||
* gleichzeitige Läufe lesen dieselbe Marke und verbuchen denselben Chunk
|
||||
* doppelt (Audit F19). withoutOverlapping() schützt nur den Scheduler.
|
||||
*/
|
||||
public const LockKey = 'press-releases:aggregate-views';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$lock = Cache::lock(self::LockKey, 600);
|
||||
|
||||
if (! $lock->get()) {
|
||||
$this->warn('Ein anderer Aggregationslauf ist noch aktiv – dieser Aufruf tut nichts.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->aggregate();
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function aggregate(): int
|
||||
{
|
||||
$chunk = max(100, (int) $this->option('chunk'));
|
||||
$watermark = AppSetting::getValue(self::WatermarkKey);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Models\User;
|
|||
use App\Services\ActivityLog;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\Newsletter\NewsletterSyncService;
|
||||
use App\Support\EmailQuery;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
|
|
@ -128,7 +129,7 @@ class ImportNewsletterBounces extends Command
|
|||
// Groß-/Kleinschreibung ausdrücklich ignorieren, wie in
|
||||
// User::findByEmail(). Bounce-Listen kommen aus dem Mailserver-Log und
|
||||
// geben die Adresse so wieder, wie der Absender sie geschrieben hat.
|
||||
$users = User::query()->whereRaw('LOWER(email) = ?', [$email])->get();
|
||||
$users = EmailQuery::whereEmail(User::query(), $email)->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if (! $user->wantsEmailNotification(EmailNotificationType::Newsletter)) {
|
||||
|
|
@ -169,9 +170,7 @@ class ImportNewsletterBounces extends Command
|
|||
*/
|
||||
private function suppressSubscriptions(string $email, bool $dryRun, int &$unsubscribed, int &$alreadyUnsubscribed): bool
|
||||
{
|
||||
$subscriptions = NewsletterSubscription::query()
|
||||
->withoutGlobalScopes()
|
||||
->whereRaw('LOWER(email) = ?', [$email])
|
||||
$subscriptions = EmailQuery::whereEmail(NewsletterSubscription::query()->withoutGlobalScopes(), $email)
|
||||
->get();
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ClassifyPressRelease;
|
||||
use App\Models\KiAudit;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
|
|
@ -23,6 +25,13 @@ use Throwable;
|
|||
*
|
||||
* 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.
|
||||
* Technische Fehler ergeben einen Fehler-Exit-Code, damit der Failure-Hook
|
||||
* des Schedulers greift (Audit F20); fachliche Ablehnungen nicht.
|
||||
*/
|
||||
class PublishScheduledPressReleases extends Command
|
||||
{
|
||||
|
|
@ -75,6 +84,7 @@ class PublishScheduledPressReleases extends Command
|
|||
$published = 0;
|
||||
$rejected = 0;
|
||||
$failed = 0;
|
||||
$rechecked = 0;
|
||||
|
||||
foreach ($candidates as $pressRelease) {
|
||||
$line = sprintf(
|
||||
|
|
@ -90,6 +100,14 @@ class PublishScheduledPressReleases extends Command
|
|||
continue;
|
||||
}
|
||||
|
||||
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]');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$service->publish($pressRelease, source: 'scheduler');
|
||||
$published++;
|
||||
|
|
@ -107,13 +125,32 @@ class PublishScheduledPressReleases extends Command
|
|||
if (! $dryRun) {
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d fehlgeschlagen.',
|
||||
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d fehlgeschlagen.',
|
||||
$published,
|
||||
$rejected,
|
||||
$rechecked,
|
||||
$failed,
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ use Illuminate\Console\Command;
|
|||
* per Scheduler am Monatsersten (siehe routes/console.php). Bewusste
|
||||
* Vereinfachung: Reset am Kalendermonat, nicht an der individuellen
|
||||
* Abo-Periode ausgerichtet.
|
||||
*
|
||||
* Idempotent und periodengebunden (Audit F18): zurückgesetzt wird nur, was
|
||||
* zu einem früheren Monat gehört (oder noch keinen Monat trägt). Verbrauch
|
||||
* des laufenden Monats bleibt – ein doppelter oder verspäteter Lauf ist
|
||||
* damit harmlos. Die fachliche Wahrheit liegt ohnehin in
|
||||
* User::pressReleaseQuotaUsedThisMonth(), der Befehl räumt nur auf.
|
||||
*/
|
||||
class ResetMonthlyPressReleaseQuota extends Command
|
||||
{
|
||||
|
|
@ -29,9 +35,17 @@ class ResetMonthlyPressReleaseQuota extends Command
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
$period = User::currentQuotaPeriod();
|
||||
|
||||
$affected = User::query()
|
||||
->where('press_release_quota_used_this_month', '>', 0)
|
||||
->update(['press_release_quota_used_this_month' => 0]);
|
||||
->where(fn ($query) => $query
|
||||
->whereNull('press_release_quota_period')
|
||||
->orWhere('press_release_quota_period', '<', $period))
|
||||
->update([
|
||||
'press_release_quota_used_this_month' => 0,
|
||||
'press_release_quota_period' => $period,
|
||||
]);
|
||||
|
||||
$this->info(sprintf('Kontingent-Verbrauch für %d User zurückgesetzt.', $affected));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue