- 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>
462 lines
17 KiB
PHP
462 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services\PressRelease;
|
|
|
|
use App\Enums\Portal;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Http\Middleware\SetEdition;
|
|
use App\Models\Category;
|
|
use App\Models\PressRelease;
|
|
use App\Scopes\PortalScope;
|
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Kanonische Zuordnung bei Doppelveröffentlichung (Anhang 2, 1c.8).
|
|
*
|
|
* Liegt derselbe Text (content_hash) derselben Firma auf beiden Portalen,
|
|
* bestimmt das Portal die Erstquelle statt Google: die zweite Fassung
|
|
* bekommt `canonical_press_release_id` auf die erste und verweist per
|
|
* rel=canonical dorthin. Welche Fassung Erstquelle ist, folgt der Kategorie
|
|
* (config/canonical.php, E9); ohne Eintrag gewinnt die zuerst
|
|
* veröffentlichte. Meldungen mit portal=both (eine Zeile, zwei Adressen)
|
|
* folgen derselben Kategorie-Zuordnung beim Rendern.
|
|
*
|
|
* Schreibt über den Query-Builder, damit weder Suchindex noch Aktivitäts-
|
|
* log anspringen — es ändert sich nur die Zuordnung, kein Inhalt.
|
|
*/
|
|
class CanonicalAssigner
|
|
{
|
|
/** @var array<int, array{root: int|null, slug: string|null}> Kategorie-ID → Hauptkategorie (ID und DE-Slug) */
|
|
private array $categoryRoots = [];
|
|
|
|
public function enabled(): bool
|
|
{
|
|
return (bool) config('canonical.enabled');
|
|
}
|
|
|
|
/**
|
|
* Portal, das für die Kategorie der Meldung die Erstquelle führt — null
|
|
* ohne Eintrag in der Zuordnung.
|
|
*/
|
|
public function preferredPortal(PressRelease $pressRelease): ?Portal
|
|
{
|
|
if (! $pressRelease->category_id) {
|
|
return null;
|
|
}
|
|
|
|
return $this->preferredPortalForCategory((int) $pressRelease->category_id);
|
|
}
|
|
|
|
/**
|
|
* Zuordnung über die ID der Hauptkategorie (`portal_by_category_id`),
|
|
* rückwärtskompatibel über den DE-Slug (`portal_by_category`) — eine
|
|
* Slug-Änderung schaltet die Zuordnung damit nicht mehr still ab
|
|
* (Umsetzungsaudit 29.09.2026, R-11).
|
|
*/
|
|
public function preferredPortalForCategory(int $categoryId): ?Portal
|
|
{
|
|
['root' => $rootId, 'slug' => $slug] = $this->categoryRoot($categoryId);
|
|
|
|
$byId = (array) config('canonical.portal_by_category_id', []);
|
|
$portal = $rootId !== null ? ($byId[$rootId] ?? null) : null;
|
|
$portal ??= $slug !== null ? config('canonical.portal_by_category.'.$slug) : null;
|
|
|
|
return is_string($portal) ? Portal::tryFrom($portal) : null;
|
|
}
|
|
|
|
/**
|
|
* IDs aller Kategorien (Haupt- und Unterkategorien), deren Erstquelle
|
|
* `$portal` führt — für SQL-Auswertungen, die isCanonicalOn() für
|
|
* portal=both nachbilden (IndexStatusReport).
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
public function categoryIdsPreferring(Portal $portal): array
|
|
{
|
|
return Category::query()->withoutGlobalScopes()->pluck('id')
|
|
->filter(fn (int $id): bool => $this->preferredPortalForCategory($id) === $portal)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Öffentliche Adresse, auf die rel=canonical der Meldung zeigt, wenn sie
|
|
* auf `$viewedOn` ausgeliefert wird. Ohne Freigabe (config) oder ohne
|
|
* Zuordnung: die eigene Adresse auf dem aufgerufenen Portal.
|
|
*/
|
|
public function canonicalUrlFor(PressRelease $pressRelease, Portal $viewedOn): string
|
|
{
|
|
$self = $this->publicUrl($pressRelease, $viewedOn);
|
|
|
|
if (! $this->enabled()) {
|
|
return $self;
|
|
}
|
|
|
|
if ($pressRelease->canonical_press_release_id !== null) {
|
|
// Nur der PortalScope entfällt — eine soft-gelöschte Erstquelle
|
|
// liefert 404 und darf kein Canonical-Ziel sein.
|
|
$canonical = PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressRelease->canonical_press_release_id);
|
|
|
|
if ($canonical !== null && $canonical->isVisibleInArchive()) {
|
|
return $this->publicUrl($canonical, $canonical->portal === Portal::Both ? ($this->preferredPortal($canonical) ?? $viewedOn) : $canonical->portal);
|
|
}
|
|
|
|
return $self;
|
|
}
|
|
|
|
if ($pressRelease->portal === Portal::Both) {
|
|
return $this->publicUrl($pressRelease, $this->preferredPortal($pressRelease) ?? $viewedOn);
|
|
}
|
|
|
|
return $self;
|
|
}
|
|
|
|
/**
|
|
* Ob die Meldung auf `$portal` als Erstquelle gilt — nur die gehört in
|
|
* Sitemap und News-Sitemap dieses Portals.
|
|
*/
|
|
public function isCanonicalOn(PressRelease $pressRelease, Portal $portal): bool
|
|
{
|
|
if (! $this->enabled()) {
|
|
return true;
|
|
}
|
|
|
|
if ($pressRelease->canonical_press_release_id !== null) {
|
|
return false;
|
|
}
|
|
|
|
if ($pressRelease->portal === Portal::Both) {
|
|
return ($this->preferredPortal($pressRelease) ?? $portal) === $portal;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Zuordnung für eine Meldung und ihre Gegenstücke auf dem anderen Portal
|
|
* neu bestimmen — nach jeder Veröffentlichung.
|
|
*/
|
|
public function assignFor(PressRelease $pressRelease): void
|
|
{
|
|
if (! $this->enabled() || $pressRelease->content_hash === null || $pressRelease->portal === Portal::Both) {
|
|
return;
|
|
}
|
|
|
|
$counterparts = PressRelease::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->published()
|
|
->where('content_hash', $pressRelease->content_hash)
|
|
->where('company_id', $pressRelease->company_id)
|
|
->whereKeyNot($pressRelease->getKey())
|
|
->where('portal', '!=', $pressRelease->portal->value)
|
|
->get();
|
|
|
|
if ($counterparts->isEmpty()) {
|
|
$this->write($pressRelease->getKey(), null);
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($counterparts as $counterpart) {
|
|
$this->assignPair($pressRelease, $counterpart);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gegenstücke einer Erstquelle neu bewerten, nachdem diese aus der
|
|
* öffentlichen Ansicht verschwunden ist (Depublikation, Gone, Löschung,
|
|
* Statuswechsel). Jede Kopie verliert ihren Verweis und wird — falls ein
|
|
* anderes veröffentlichtes Gegenstück existiert — neu zugeordnet; sonst
|
|
* ist sie fortan selbst Erstquelle und gehört wieder in die Sitemap.
|
|
*/
|
|
public function reassignCopiesOf(PressRelease $former): void
|
|
{
|
|
if (! $this->enabled()) {
|
|
return;
|
|
}
|
|
|
|
$copies = PressRelease::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->where('canonical_press_release_id', $former->getKey())
|
|
->get();
|
|
|
|
foreach ($copies as $copy) {
|
|
$this->write($copy->getKey(), null);
|
|
$copy->canonical_press_release_id = null;
|
|
$this->assignFor($copy);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zuordnung aller Meldungen mit diesen Prüfsummen innerhalb dieser Firmen
|
|
* neu bestimmen — nach Firmen-Merge und -Unmerge, die `company_id` am
|
|
* Query-Builder vorbei umhängen (Umsetzungsaudit 29.09.2026, R-06).
|
|
* Verweise, die kein gültiges Paar mehr bilden (andere Firma, anderer
|
|
* Inhalt, Ziel nicht mehr veröffentlicht), fallen; danach wird jede
|
|
* veröffentlichte Meldung wie nach einer Veröffentlichung zugeordnet.
|
|
*
|
|
* @param list<string> $hashes
|
|
* @param list<int> $companyIds
|
|
*/
|
|
public function reassignWithin(array $hashes, array $companyIds): void
|
|
{
|
|
if (! $this->enabled() || $hashes === [] || $companyIds === []) {
|
|
return;
|
|
}
|
|
|
|
foreach (array_chunk(array_values(array_unique($hashes)), 200) as $chunk) {
|
|
$releases = PressRelease::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->whereIn('content_hash', $chunk)
|
|
->whereIn('company_id', $companyIds)
|
|
->get();
|
|
|
|
foreach ($releases as $release) {
|
|
if ($release->canonical_press_release_id === null) {
|
|
continue;
|
|
}
|
|
|
|
$target = $releases->firstWhere('id', $release->canonical_press_release_id);
|
|
|
|
if ($target === null || ! $this->formsPair($release, $target)) {
|
|
$this->write($release->getKey(), null);
|
|
$release->canonical_press_release_id = null;
|
|
}
|
|
}
|
|
|
|
foreach ($releases as $release) {
|
|
if ($this->isPairable($release)) {
|
|
$this->assignFor($release);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ob `$copy` zu Recht auf `$target` verweist — dieselben Bedingungen wie
|
|
* pairsQuery(), nur für ein einzelnes Paar.
|
|
*/
|
|
private function formsPair(PressRelease $copy, PressRelease $target): bool
|
|
{
|
|
return $target->company_id === $copy->company_id
|
|
&& $target->content_hash !== null
|
|
&& $target->content_hash === $copy->content_hash
|
|
&& $target->portal !== $copy->portal
|
|
&& $target->portal !== Portal::Both
|
|
&& $copy->portal !== Portal::Both
|
|
&& $this->isPairable($target);
|
|
}
|
|
|
|
/**
|
|
* Wie `published()` plus die Filter aus pairsQuery().
|
|
*/
|
|
private function isPairable(PressRelease $pressRelease): bool
|
|
{
|
|
return $pressRelease->status === PressReleaseStatus::Published
|
|
&& $pressRelease->published_at !== null
|
|
&& $pressRelease->gone_at === null
|
|
&& ! $pressRelease->trashed();
|
|
}
|
|
|
|
/**
|
|
* Alle Paare im Bestand zuordnen. Vorher fallen Verweise, die kein Paar
|
|
* aus pairsQuery() mehr bilden (Kopie zeigt auf eine gelöschte oder
|
|
* nicht mehr veröffentlichte Meldung, auf eine andere Firma, einen
|
|
* anderen Inhalt oder dasselbe Portal) — Umsetzungsaudit 29.09.2026, R-06.
|
|
*
|
|
* @param callable(int $canonicalId, int $duplicateId, bool $changed): void|null $each
|
|
* @return array{pairs: int, changed: int, reset: int}
|
|
*/
|
|
public function assignAll(bool $dryRun = false, ?callable $each = null): array
|
|
{
|
|
$pairs = 0;
|
|
$changed = 0;
|
|
$reset = 0;
|
|
|
|
// chunkById: Die Schreibvorgänge nehmen Zeilen aus der Ergebnismenge,
|
|
// eine Offset-Paginierung würde dabei Zeilen überspringen.
|
|
$this->staleReferencesQuery()->chunkById(500, function ($rows) use ($dryRun, &$reset): void {
|
|
foreach ($rows as $row) {
|
|
$reset++;
|
|
|
|
if (! $dryRun) {
|
|
$this->write((int) $row->id, null);
|
|
}
|
|
}
|
|
}, 'p.id', 'id');
|
|
|
|
$this->pairsQuery()->orderBy('a.id')->chunk(500, function ($rows) use ($dryRun, $each, &$pairs, &$changed): void {
|
|
$ids = $rows->flatMap(fn (object $row): array => [(int) $row->a_id, (int) $row->b_id])->unique()->all();
|
|
$releases = PressRelease::query()->withoutGlobalScopes()->findMany($ids)->keyBy('id');
|
|
|
|
foreach ($rows as $row) {
|
|
$a = $releases->get((int) $row->a_id);
|
|
$b = $releases->get((int) $row->b_id);
|
|
|
|
if (! $a || ! $b) {
|
|
continue;
|
|
}
|
|
|
|
$pairs++;
|
|
$canonical = $this->resolveCanonical($a, $b);
|
|
$duplicate = $canonical->is($a) ? $b : $a;
|
|
$wouldChange = $duplicate->canonical_press_release_id !== $canonical->getKey() || $canonical->canonical_press_release_id !== null;
|
|
|
|
if ($wouldChange) {
|
|
$changed++;
|
|
|
|
if (! $dryRun) {
|
|
$this->write($duplicate->getKey(), $canonical->getKey());
|
|
$this->write($canonical->getKey(), null);
|
|
}
|
|
}
|
|
|
|
if ($each !== null) {
|
|
$each($canonical->getKey(), $duplicate->getKey(), $wouldChange);
|
|
}
|
|
}
|
|
});
|
|
|
|
return ['pairs' => $pairs, 'changed' => $changed, 'reset' => $reset];
|
|
}
|
|
|
|
/**
|
|
* Meldungen mit einem Verweis, der kein gültiges Paar (mehr) ist.
|
|
*/
|
|
public function staleReferencesQuery(): QueryBuilder
|
|
{
|
|
return DB::table('press_releases as p')
|
|
->leftJoin('press_releases as c', 'c.id', '=', 'p.canonical_press_release_id')
|
|
->whereNotNull('p.canonical_press_release_id')
|
|
->where(fn (QueryBuilder $query) => $query
|
|
->whereNull('c.id')
|
|
->orWhereNotNull('c.deleted_at')
|
|
->orWhere('c.status', '!=', 'published')
|
|
->orWhereNotNull('c.gone_at')
|
|
->orWhere('c.portal', 'both')
|
|
->orWhere('p.portal', 'both')
|
|
->orWhereColumn('c.portal', '=', 'p.portal')
|
|
->orWhereNull('c.company_id')
|
|
->orWhereColumn('c.company_id', '!=', 'p.company_id')
|
|
->orWhereNull('c.content_hash')
|
|
->orWhereNull('p.content_hash')
|
|
->orWhereColumn('c.content_hash', '!=', 'p.content_hash'))
|
|
->select(['p.id']);
|
|
}
|
|
|
|
/**
|
|
* Veröffentlichte Paare gleichen Inhalts derselben Firma auf verschiedenen
|
|
* Portalen (a = kleinere ID).
|
|
*/
|
|
public function pairsQuery(): QueryBuilder
|
|
{
|
|
return DB::table('press_releases as a')
|
|
->join('press_releases as b', function ($join): void {
|
|
$join->on('a.content_hash', '=', 'b.content_hash')
|
|
->on('a.company_id', '=', 'b.company_id')
|
|
->whereColumn('a.id', '<', 'b.id')
|
|
->whereColumn('a.portal', '!=', 'b.portal');
|
|
})
|
|
->whereNotNull('a.content_hash')
|
|
->whereNull('a.deleted_at')->whereNull('b.deleted_at')
|
|
->where('a.status', 'published')->where('b.status', 'published')
|
|
->whereNull('a.gone_at')->whereNull('b.gone_at')
|
|
->whereNotIn('a.portal', ['both'])->whereNotIn('b.portal', ['both'])
|
|
->select(['a.id as a_id', 'b.id as b_id', 'a.category_id', 'a.company_id', 'a.published_at as a_published_at', 'b.published_at as b_published_at']);
|
|
}
|
|
|
|
/**
|
|
* Welche der beiden Fassungen Erstquelle ist: die auf dem Kategorie-
|
|
* Portal, sonst die zuerst veröffentlichte (bei Gleichstand die ältere ID).
|
|
*/
|
|
public function resolveCanonical(PressRelease $a, PressRelease $b): PressRelease
|
|
{
|
|
$preferred = $this->preferredPortal($a);
|
|
|
|
if ($preferred !== null) {
|
|
if ($a->portal === $preferred && $b->portal !== $preferred) {
|
|
return $a;
|
|
}
|
|
|
|
if ($b->portal === $preferred && $a->portal !== $preferred) {
|
|
return $b;
|
|
}
|
|
}
|
|
|
|
$fallback = (string) config('canonical.fallback', 'first');
|
|
$fallbackPortal = Portal::tryFrom($fallback);
|
|
|
|
if ($fallbackPortal !== null) {
|
|
if ($a->portal === $fallbackPortal) {
|
|
return $a;
|
|
}
|
|
|
|
if ($b->portal === $fallbackPortal) {
|
|
return $b;
|
|
}
|
|
}
|
|
|
|
$aAt = $a->published_at?->getTimestamp() ?? PHP_INT_MAX;
|
|
$bAt = $b->published_at?->getTimestamp() ?? PHP_INT_MAX;
|
|
|
|
if ($aAt !== $bAt) {
|
|
return $aAt < $bAt ? $a : $b;
|
|
}
|
|
|
|
return $a->getKey() < $b->getKey() ? $a : $b;
|
|
}
|
|
|
|
private function assignPair(PressRelease $a, PressRelease $b): void
|
|
{
|
|
$canonical = $this->resolveCanonical($a, $b);
|
|
$duplicate = $canonical->is($a) ? $b : $a;
|
|
|
|
$this->write($duplicate->getKey(), $canonical->getKey());
|
|
$this->write($canonical->getKey(), null);
|
|
}
|
|
|
|
private function write(int $id, ?int $canonicalId): void
|
|
{
|
|
DB::table('press_releases')
|
|
->where('id', $id)
|
|
->where(fn ($query) => $canonicalId === null
|
|
? $query->whereNotNull('canonical_press_release_id')
|
|
: $query->where('canonical_press_release_id', '!=', $canonicalId)->orWhereNull('canonical_press_release_id'))
|
|
->update(['canonical_press_release_id' => $canonicalId]);
|
|
}
|
|
|
|
private function publicUrl(PressRelease $pressRelease, Portal $portal): string
|
|
{
|
|
if ($portal === Portal::Both) {
|
|
$portal = Portal::Presseecho;
|
|
}
|
|
|
|
$edition = in_array($pressRelease->language, SetEdition::EDITIONS, true) ? $pressRelease->language : 'de';
|
|
$base = rtrim((string) match ($portal) {
|
|
Portal::Presseecho => config('domains.domain_presseecho_url'),
|
|
default => config('domains.domain_businessportal_url'),
|
|
}, '/');
|
|
|
|
return sprintf('%s/%s/press-release/%s', $base, $edition, $pressRelease->slug);
|
|
}
|
|
|
|
/**
|
|
* @return array{root: int|null, slug: string|null}
|
|
*/
|
|
private function categoryRoot(int $categoryId): array
|
|
{
|
|
if (array_key_exists($categoryId, $this->categoryRoots)) {
|
|
return $this->categoryRoots[$categoryId];
|
|
}
|
|
|
|
$category = Category::query()->withoutGlobalScopes()->with(['parent.translations', 'translations'])->find($categoryId);
|
|
$root = $category !== null && $category->parent !== null ? $category->parent : $category;
|
|
$slug = $root?->translations->firstWhere('locale', 'de')?->slug;
|
|
|
|
return $this->categoryRoots[$categoryId] = [
|
|
'root' => $root?->getKey(),
|
|
'slug' => is_string($slug) ? $slug : null,
|
|
];
|
|
}
|
|
}
|