web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1d417f87f7
commit
c2e0b85242
48 changed files with 1676 additions and 134 deletions
|
|
@ -22,6 +22,7 @@ class CompanyMergeMove extends Model
|
|||
'from_company_id',
|
||||
'field',
|
||||
'previous_value',
|
||||
'merged_value',
|
||||
'dropped',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use App\Services\ActivityLog;
|
|||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||
use App\Support\ContentFingerprint;
|
||||
use App\Support\ImageCredit;
|
||||
use Carbon\CarbonInterface;
|
||||
use Closure;
|
||||
use Database\Factories\PressReleaseFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
|
@ -64,10 +65,19 @@ class PressRelease extends Model
|
|||
});
|
||||
|
||||
// Prüfsumme über den Inhalt (Anhang 2, 1c.7) — bei jeder Änderung an
|
||||
// Titel oder Text, und nachträglich für Zeilen ohne Summe.
|
||||
// Titel oder Text, und nachträglich für Zeilen ohne Summe. Ändert
|
||||
// sich die Summe an einer bestehenden Zeile, ist das die letzte
|
||||
// inhaltliche Änderung (content_changed_at, Umsetzungsaudit
|
||||
// 29.09.2026 R-02) — Grundlage für lastmod/dateModified.
|
||||
static::saving(function (self $pressRelease): void {
|
||||
if ($pressRelease->content_hash === null || $pressRelease->isDirty(['title', 'text'])) {
|
||||
$pressRelease->content_hash = ContentFingerprint::hash($pressRelease->title, $pressRelease->text);
|
||||
$hash = ContentFingerprint::hash($pressRelease->title, $pressRelease->text);
|
||||
|
||||
if ($pressRelease->exists && $pressRelease->isDirty(['title', 'text']) && $hash !== $pressRelease->getOriginal('content_hash')) {
|
||||
$pressRelease->content_changed_at = now();
|
||||
}
|
||||
|
||||
$pressRelease->content_hash = $hash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -84,6 +94,7 @@ class PressRelease extends Model
|
|||
'slug',
|
||||
'text',
|
||||
'content_hash',
|
||||
'content_changed_at',
|
||||
'canonical_press_release_id',
|
||||
'boilerplate_override',
|
||||
'placeholder_variant',
|
||||
|
|
@ -131,6 +142,7 @@ class PressRelease extends Model
|
|||
'content_score' => 'integer',
|
||||
'content_tier' => PressReleaseContentTier::class,
|
||||
'scored_at' => 'datetime',
|
||||
'content_changed_at' => 'datetime',
|
||||
'hits' => 'integer',
|
||||
'teaser_begin' => 'integer',
|
||||
'teaser_end' => 'integer',
|
||||
|
|
@ -149,6 +161,25 @@ class PressRelease extends Model
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzte inhaltliche Änderung für lastmod, dateModified und
|
||||
* article:modified_time (Umsetzungsaudit 29.09.2026, R-02): bewusst nicht
|
||||
* `updated_at`, das auch Aufrufzähler und Firmen-Merge bewegt haben.
|
||||
* Ohne Änderung nach der Veröffentlichung gilt `published_at` — eine
|
||||
* Änderung am Entwurf vor der Veröffentlichung liegt davor und zählt nicht.
|
||||
*/
|
||||
public function contentModifiedAt(): ?CarbonInterface
|
||||
{
|
||||
$changedAt = $this->content_changed_at;
|
||||
$publishedAt = $this->published_at;
|
||||
|
||||
if ($changedAt === null || ($publishedAt !== null && $changedAt->lessThanOrEqualTo($publishedAt))) {
|
||||
return $publishedAt;
|
||||
}
|
||||
|
||||
return $changedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geplanter Veröffentlichungstermin. Die Anwendung läuft durchgängig in
|
||||
* Europe/Berlin (config app.timezone), daher ist keine Umrechnung nötig.
|
||||
|
|
@ -262,36 +293,52 @@ class PressRelease extends Model
|
|||
}
|
||||
|
||||
/**
|
||||
* Bild-URL für Kartenansichten: card-Variante des ersten Bildes,
|
||||
* Fallback Original. Null bei bildlosen PMs (dann greift
|
||||
* chosenPlaceholderUrl() bzw. der generische Fallback der Komponente).
|
||||
* Titelbild für Teaser und Listen: das erste öffentlich auslieferbare
|
||||
* Bild (Umsetzungsaudit 29.09.2026, B-01). Vorgeladene Bilder werden
|
||||
* gefiltert, auch wenn sie ungefiltert geladen wurden; ohne Vorladen
|
||||
* genau eine gefilterte Abfrage statt Lazy Loading aller Bilder.
|
||||
*/
|
||||
private function cardImage(): ?PressReleaseImage
|
||||
{
|
||||
$images = $this->relationLoaded('images')
|
||||
? $this->images
|
||||
: $this->images()->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1)->get();
|
||||
|
||||
return $images->first(fn (PressReleaseImage $image): bool => $image->isPubliclyDeliverable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachweiszeile des Titelbilds für Teaser (Bildkennzeichnung H.2):
|
||||
* „Foto: …" bzw. „Quelle: …" plus KI-Zusatz, null ohne Bild.
|
||||
*/
|
||||
public function cardImageCredit(): ?string
|
||||
{
|
||||
$image = $this->relationLoaded('images') ? $this->images->first() : null;
|
||||
$image = $this->cardImage();
|
||||
|
||||
return $image ? ImageCredit::for($image, $this->company)->line() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bild-URL für Kartenansichten: card-Variante des Titelbildes,
|
||||
* Fallback Original. Null ohne öffentlich auslieferbares Bild (dann
|
||||
* greift chosenPlaceholderUrl() bzw. der generische Fallback).
|
||||
*/
|
||||
public function cardImageUrl(): ?string
|
||||
{
|
||||
$image = $this->images?->first();
|
||||
$image = $this->cardImage();
|
||||
|
||||
return $image?->variantUrl('card') ?? $image?->url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bild-URL für Listen-Thumbnails: thumb-Variante des Titelbildes,
|
||||
* Fallback card-Variante bzw. Original. Null bei bildlosen PMs
|
||||
* (dann greift chosenPlaceholderUrl() bzw. der generische Fallback).
|
||||
* Fallback card-Variante bzw. Original. Null ohne öffentlich
|
||||
* auslieferbares Bild (dann greift chosenPlaceholderUrl() bzw. der
|
||||
* generische Fallback).
|
||||
*/
|
||||
public function thumbImageUrl(): ?string
|
||||
{
|
||||
$image = $this->images?->first();
|
||||
$image = $this->cardImage();
|
||||
|
||||
return $image?->variantUrl('thumb') ?? $image?->variantUrl('card') ?? $image?->url();
|
||||
}
|
||||
|
|
@ -616,6 +663,30 @@ class PressRelease extends Model
|
|||
return $this->gone_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* War die Meldung (möglicherweise) je öffentlich? Maßgeblich für alles,
|
||||
* was „nie veröffentlicht" sofort löscht (Umsetzungsaudit 29.09.2026,
|
||||
* A-05): `published_ever_at` — und im Altbestand `rejected`, weil das
|
||||
* Altsystem damit auch „offline genommen" abbildete; der Backfill kann
|
||||
* das nicht unterscheiden (Dev-Kopie: 126.383 solche Meldungen ohne
|
||||
* `published_ever_at`).
|
||||
*/
|
||||
public function wasEverPublished(): bool
|
||||
{
|
||||
return $this->published_ever_at !== null
|
||||
|| ($this->legacy_id !== null && $this->status === PressReleaseStatus::Rejected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenstück zu wasEverPublished() für Abfragen.
|
||||
*/
|
||||
public function scopeNeverPublished(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull($query->qualifyColumn('published_ever_at'))
|
||||
->where(fn (Builder $q) => $q->whereNull($q->qualifyColumn('legacy_id'))
|
||||
->orWhere($q->qualifyColumn('status'), '!=', PressReleaseStatus::Rejected->value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatik erschöpft (E3): Für diese Meldung entscheidet nur noch ein
|
||||
* Mensch — jede Freigabe geht ohne KI-Lauf in die Admin-Queue.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue