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:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent 1d417f87f7
commit c2e0b85242
48 changed files with 1676 additions and 134 deletions

View file

@ -22,6 +22,7 @@ class CompanyMergeMove extends Model
'from_company_id',
'field',
'previous_value',
'merged_value',
'dropped',
];

View file

@ -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.