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
1ccb4a8479
commit
d6e49c11c3
48 changed files with 1676 additions and 134 deletions
|
|
@ -16,6 +16,7 @@ use App\Models\PressReleaseAiDeclaration;
|
|||
use App\Scopes\PortalScope;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Services\PressRelease\CanonicalAssigner;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
|
@ -162,7 +163,7 @@ class CompanyMergeService
|
|||
}
|
||||
|
||||
if (! $survivor->is_active && collect($plan->losers)->contains(fn (Company $loser): bool => (bool) $loser->is_active)) {
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $plan->losers[0], 'is_active', '0');
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $plan->losers[0], 'is_active', '0', merged: '1');
|
||||
$survivorChanges['is_active'] = true;
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +184,8 @@ class CompanyMergeService
|
|||
return $merge;
|
||||
});
|
||||
|
||||
$this->reassignCanonicals($merge);
|
||||
|
||||
if ($flushCaches) {
|
||||
$this->flushCaches();
|
||||
}
|
||||
|
|
@ -225,11 +228,44 @@ class CompanyMergeService
|
|||
);
|
||||
});
|
||||
|
||||
$this->reassignCanonicals($merge);
|
||||
|
||||
if ($flushCaches) {
|
||||
$this->flushCaches();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical-Zuordnung der verschobenen Meldungen neu bewerten (Umsetzungs-
|
||||
* audit 29.09.2026, R-06): Nach dem Merge gehören Fassungen gleichen
|
||||
* Inhalts auf beiden Portalen derselben Firma und werden zum Paar; nach
|
||||
* dem Unmerge zeigt eine Kopie sonst auf die Meldung einer fremden Firma.
|
||||
* Bewusst nach dem Commit — die Zuordnung ist ableitbar und lässt sich
|
||||
* mit `press-releases:assign-canonicals` jederzeit nachziehen.
|
||||
*/
|
||||
private function reassignCanonicals(CompanyMerge $merge): void
|
||||
{
|
||||
$assigner = app(CanonicalAssigner::class);
|
||||
|
||||
if (! $assigner->enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$moves = $merge->moves()->where('subject', CompanyMergeSubject::PressRelease->value);
|
||||
$companyIds = $merge->moves()->distinct()->pluck('from_company_id')->push($merge->survivor_company_id)->unique()->values()->all();
|
||||
|
||||
$moves->select(['id', 'subject_id'])->chunkById(500, function ($chunk) use ($assigner, $companyIds): void {
|
||||
$hashes = PressRelease::query()->withoutGlobalScopes()
|
||||
->whereIn('id', $chunk->pluck('subject_id')->all())
|
||||
->whereNotNull('content_hash')
|
||||
->distinct()
|
||||
->pluck('content_hash')
|
||||
->all();
|
||||
|
||||
$assigner->reassignWithin($hashes, $companyIds);
|
||||
});
|
||||
}
|
||||
|
||||
public function flushCaches(): void
|
||||
{
|
||||
WebCaches::forgetPublicPages();
|
||||
|
|
@ -341,11 +377,16 @@ class CompanyMergeService
|
|||
], $chunk));
|
||||
}
|
||||
|
||||
$query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]);
|
||||
|
||||
if ($subject === CompanyMergeSubject::PressRelease) {
|
||||
// toBase(): ohne updated_at — der Inhalt der Meldung ändert
|
||||
// sich nicht (Umsetzungsaudit 29.09.2026, R-02).
|
||||
$query->whereIn('id', $ids)->toBase()->update(['company_id' => $survivor->getKey()]);
|
||||
$moved = count($ids);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]);
|
||||
}
|
||||
|
||||
return $moved;
|
||||
|
|
@ -400,14 +441,14 @@ class CompanyMergeService
|
|||
continue;
|
||||
}
|
||||
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $loser, $field, $survivor->getRawOriginal($field));
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $loser, $field, $survivor->getRawOriginal($field), merged: (string) $loser->getRawOriginal($field));
|
||||
$fills[$field] = $loser->getRawOriginal($field);
|
||||
}
|
||||
|
||||
return $fills;
|
||||
}
|
||||
|
||||
private function record(CompanyMerge $merge, CompanyMergeSubject $subject, ?int $subjectId, Company $from, ?string $field = null, ?string $previous = null, bool $dropped = false): void
|
||||
private function record(CompanyMerge $merge, CompanyMergeSubject $subject, ?int $subjectId, Company $from, ?string $field = null, ?string $previous = null, bool $dropped = false, ?string $merged = null): void
|
||||
{
|
||||
CompanyMergeMove::query()->create([
|
||||
'company_merge_id' => $merge->getKey(),
|
||||
|
|
@ -416,6 +457,7 @@ class CompanyMergeService
|
|||
'from_company_id' => $from->getKey(),
|
||||
'field' => $field,
|
||||
'previous_value' => $previous,
|
||||
'merged_value' => $merged,
|
||||
'dropped' => $dropped,
|
||||
]);
|
||||
}
|
||||
|
|
@ -425,17 +467,58 @@ class CompanyMergeService
|
|||
$from = $move->from_company_id;
|
||||
|
||||
match ($move->subject) {
|
||||
CompanyMergeSubject::PressRelease => PressRelease::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::PressRelease => PressRelease::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->toBase()->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::Contact => Contact::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::AiDeclaration => PressReleaseAiDeclaration::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::ConversionEvent => ConversionEvent::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::CompanyUser => $this->undoPivot('company_user', 'user_id', $move, $survivorId, ['role' => $move->previous_value]),
|
||||
CompanyMergeSubject::PaymentOption => $this->undoPivot('user_payment_option_company', 'user_payment_option_id', $move, $survivorId, ['is_active' => (bool) $move->previous_value]),
|
||||
CompanyMergeSubject::Field => Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([(string) $move->field => $move->previous_value]),
|
||||
CompanyMergeSubject::Field => $this->undoField($move, $survivorId),
|
||||
CompanyMergeSubject::Company => Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($from)->update(['deleted_at' => null]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein aufgefülltes Feld nur zurücksetzen, wenn es noch den beim Merge
|
||||
* eingefüllten Wert trägt — hat der Kunde es inzwischen geändert (etwa
|
||||
* nach dem Beanspruchen des Newsrooms), bleibt seine Fassung stehen
|
||||
* (Umsetzungsaudit 29.09.2026, R-05). Zeilen von vor der Spalte
|
||||
* `merged_value` vergleichen gegen die Herkunftsfirma: Die wurde beim
|
||||
* Merge nur soft-gelöscht, ihr Wert ist der eingefüllte.
|
||||
*/
|
||||
private function undoField(CompanyMergeMove $move, int $survivorId): void
|
||||
{
|
||||
$field = (string) $move->field;
|
||||
$filled = $move->merged_value;
|
||||
|
||||
if ($filled === null) {
|
||||
$filled = $field === 'is_active'
|
||||
? '1'
|
||||
: Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($move->from_company_id)->toBase()->value($field);
|
||||
}
|
||||
|
||||
$current = Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->toBase()->value($field);
|
||||
|
||||
if (! $this->sameRawValue($current, $filled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([$field => $move->previous_value]);
|
||||
}
|
||||
|
||||
private function sameRawValue(mixed $current, mixed $filled): bool
|
||||
{
|
||||
if (is_bool($current)) {
|
||||
$current = (int) $current;
|
||||
}
|
||||
|
||||
if (is_bool($filled)) {
|
||||
$filled = (int) $filled;
|
||||
}
|
||||
|
||||
return $current !== null && $filled !== null && (string) $current === (string) $filled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Builder;
|
|||
*/
|
||||
class MergedNewsroomLookup
|
||||
{
|
||||
private const MaxChainLength = 10;
|
||||
|
||||
/**
|
||||
* Der Überlebende zu einem aufgegangenen Slug, oder null, wenn der Slug
|
||||
* nie aufgegangen ist oder die Zusammenführung rückabgewickelt wurde.
|
||||
|
|
@ -34,9 +36,40 @@ class MergedNewsroomLookup
|
|||
return null;
|
||||
}
|
||||
|
||||
$survivorId = $this->survivorIdOf($mergedIds->all());
|
||||
$visited = [];
|
||||
|
||||
// Merge-Ketten (A → B, später B → C): Ist der Überlebende selbst
|
||||
// aufgegangen, weiter bis zur lebenden Firma. Zyklenschutz über die
|
||||
// besuchten IDs und eine feste Obergrenze (Umsetzungsaudit
|
||||
// 29.09.2026, R-11).
|
||||
while ($survivorId !== null && ! isset($visited[$survivorId]) && count($visited) < self::MaxChainLength) {
|
||||
$visited[$survivorId] = true;
|
||||
|
||||
$survivor = Company::withTrashed()->withoutGlobalScope(PortalScope::class)->find($survivorId);
|
||||
|
||||
if ($survivor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $survivor->trashed()) {
|
||||
return $survivor;
|
||||
}
|
||||
|
||||
$survivorId = $this->survivorIdOf([$survivorId]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $companyIds
|
||||
*/
|
||||
private function survivorIdOf(array $companyIds): ?int
|
||||
{
|
||||
$survivorId = CompanyMergeMove::query()
|
||||
->where('subject', CompanyMergeSubject::Company->value)
|
||||
->whereIn('subject_id', $mergedIds)
|
||||
->whereIn('subject_id', $companyIds)
|
||||
->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at'))
|
||||
->with('merge:id,survivor_company_id')
|
||||
->latest('id')
|
||||
|
|
@ -44,10 +77,6 @@ class MergedNewsroomLookup
|
|||
?->merge
|
||||
?->survivor_company_id;
|
||||
|
||||
if ($survivorId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Company::query()->withoutGlobalScope(PortalScope::class)->find($survivorId);
|
||||
return $survivorId !== null ? (int) $survivorId : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class BounceMailProcessor
|
|||
private readonly DsnParser $parser,
|
||||
private readonly EmailSuppressionService $suppressions,
|
||||
private readonly ActivityLog $activityLog,
|
||||
private readonly BounceReference $references,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -69,6 +70,16 @@ class BounceMailProcessor
|
|||
$kind = $this->reclassify($recipient['status'], $recipient['diagnostic']);
|
||||
}
|
||||
|
||||
$reference = $this->references->plain($report->reference);
|
||||
$verified = $this->references->verify($report->reference, $recipient['email']);
|
||||
|
||||
// Jede Art, die auf eine Sperre zählt — hart wie weich —, braucht
|
||||
// den signierten Nachweis für genau diesen Empfänger
|
||||
// (Umsetzungsaudit 02.10.2026, O01: der weiche Weg lief vorbei).
|
||||
if ($kind !== EmailBounceKind::Policy && ! $verified) {
|
||||
[$kind, $reference] = $this->unverified($report->reference, $reference);
|
||||
}
|
||||
|
||||
$bounces++;
|
||||
|
||||
if ($dryRun) {
|
||||
|
|
@ -84,7 +95,7 @@ class BounceMailProcessor
|
|||
'action' => $recipient['action'],
|
||||
'status' => $recipient['status'],
|
||||
'diagnostic' => $recipient['diagnostic'],
|
||||
'reference' => $report->reference,
|
||||
'reference' => $reference,
|
||||
'received_at' => $report->receivedAt,
|
||||
],
|
||||
);
|
||||
|
|
@ -93,6 +104,42 @@ class BounceMailProcessor
|
|||
return ['folder' => $bounces > 0 ? self::Processed : self::Ignored, 'bounces' => $bounces, 'subject' => $subject];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce ohne passende Signatur (Umsetzungsaudit 29.09.2026, R-04, und
|
||||
* 02.10.2026, O01): Jeder kann ein `multipart/report` ans Return-Path-
|
||||
* Postfach schicken. Auf eine Sperre zählen darf — hart wie weich — nur
|
||||
* ein Bericht, dessen `X-Pk-Ref` wir für genau diesen Empfänger signiert
|
||||
* haben.
|
||||
*
|
||||
* - Signatur vorhanden, aber falsch (verändert, oder die echte Signatur
|
||||
* einer Mail an eine andere Adresse): Richtlinie — nur protokolliert,
|
||||
* zur Sichtung im Admin, zählt auf keinen Schwellwert.
|
||||
* - Gar keine Signatur (vor der Umstellung versendet, Freitext-Bericht
|
||||
* ohne Original-Header): `bounces.unsigned_kind`, Standard ebenfalls
|
||||
* Richtlinie. `soft` zählt bewusst auf den Schwellwert und ist damit
|
||||
* fälschbar; hart wird ein unsignierter Bericht nie.
|
||||
*
|
||||
* @return array{0: EmailBounceKind, 1: string|null}
|
||||
*/
|
||||
private function unverified(?string $header, ?string $reference): array
|
||||
{
|
||||
if ($this->references->isSigned($header)) {
|
||||
return [EmailBounceKind::Policy, $this->annotate($reference, 'Signatur ungültig')];
|
||||
}
|
||||
|
||||
$kind = EmailBounceKind::tryFrom((string) config('bounces.unsigned_kind', 'policy'));
|
||||
|
||||
return [
|
||||
$kind === EmailBounceKind::Soft ? EmailBounceKind::Soft : EmailBounceKind::Policy,
|
||||
$this->annotate($reference, 'unsigniert'),
|
||||
];
|
||||
}
|
||||
|
||||
private function annotate(?string $reference, string $note): string
|
||||
{
|
||||
return $reference === null ? '('.$note.')' : mb_substr($reference, 0, 100).' ('.$note.')';
|
||||
}
|
||||
|
||||
/**
|
||||
* `failed` heißt nicht immer „Adresse existiert nicht". Drei Gruppen:
|
||||
*
|
||||
|
|
|
|||
124
app/Services/Mail/BounceReference.php
Normal file
124
app/Services/Mail/BounceReference.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
/**
|
||||
* Signierte Bounce-Referenz (Umsetzungsaudit 29.09.2026, R-04).
|
||||
*
|
||||
* Jede ausgehende Mail trägt `X-Pk-Ref: <referenz>; s=<sig>[,<sig>…]` — je
|
||||
* Empfänger eine HMAC über Referenz und Empfängeradresse. Ein Zustellbericht
|
||||
* bringt die Original-Header zurück; nur wenn die Signatur zur gemeldeten
|
||||
* Adresse passt, stammt der Bericht nachweislich von einer Mail, die wir an
|
||||
* genau diese Adresse geschickt haben. Erst dann darf ein harter Bounce
|
||||
* sperren — sonst könnte jeder mit einer Mail ans Return-Path-Postfach
|
||||
* beliebige Adressen sperren und damit Magic Links, Resets und Rechnungen
|
||||
* abschneiden.
|
||||
*
|
||||
* Schlüssel: `bounces.signing_key` (BOUNCE_SIGNING_KEY), sonst aus APP_KEY
|
||||
* abgeleitet. Ein Schlüsselwechsel entwertet nur die Signaturen bereits
|
||||
* versendeter Mails — deren Rückläufer zählen dann weich.
|
||||
*/
|
||||
class BounceReference
|
||||
{
|
||||
/** Ohne fachliche Referenz der Mailable. */
|
||||
public const DefaultReference = 'mail';
|
||||
|
||||
/** Mehr Signaturen schreibt kein Header; weitere Empfänger bleiben unsigniert. */
|
||||
public const MaxRecipients = 20;
|
||||
|
||||
private const SignatureLength = 32;
|
||||
|
||||
/**
|
||||
* @param list<string> $recipients
|
||||
*/
|
||||
public function sign(?string $reference, array $recipients): string
|
||||
{
|
||||
$reference = $this->plain($reference) ?? self::DefaultReference;
|
||||
|
||||
$signatures = collect($recipients)
|
||||
->map(fn (string $email): ?string => EmailSuppressionService::normalise($email))
|
||||
->filter()
|
||||
->unique()
|
||||
->take(self::MaxRecipients)
|
||||
->map(fn (string $email): string => $this->signature($reference, $email))
|
||||
->implode(',');
|
||||
|
||||
return $signatures === '' ? $reference : $reference.'; s='.$signatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob der Header eine gültige Signatur für genau diesen Empfänger trägt.
|
||||
*/
|
||||
public function verify(?string $header, string $recipient): bool
|
||||
{
|
||||
$reference = $this->plain($header);
|
||||
$email = EmailSuppressionService::normalise($recipient);
|
||||
|
||||
if ($reference === null || $email === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected = $this->signature($reference, $email);
|
||||
|
||||
foreach ($this->signatures($header) as $signature) {
|
||||
if (hash_equals($expected, $signature)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob der Header überhaupt Signaturen trägt — unterscheidet „vor der
|
||||
* Umstellung versendet" (keine) von „gefälscht oder verändert" (falsche).
|
||||
*/
|
||||
public function isSigned(?string $header): bool
|
||||
{
|
||||
return $this->signatures($header) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die fachliche Referenz ohne Signaturteil (für Protokoll und Anzeige).
|
||||
*/
|
||||
public function plain(?string $header): ?string
|
||||
{
|
||||
if ($header === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reference = trim((string) preg_replace('/;\s*s=.*$/s', '', $header));
|
||||
|
||||
return $reference === '' ? null : $reference;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function signatures(?string $header): array
|
||||
{
|
||||
if ($header === null || preg_match('/;\s*s=(.+)$/s', $header, $m) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = (string) preg_replace('/\s+/', '', $m[1]);
|
||||
|
||||
return array_values(array_filter(explode(',', $list), fn (string $sig): bool => preg_match('/^[0-9a-f]{'.self::SignatureLength.'}$/', $sig) === 1));
|
||||
}
|
||||
|
||||
private function signature(string $reference, string $email): string
|
||||
{
|
||||
return substr(hash_hmac('sha256', $reference."\n".$email, $this->key()), 0, self::SignatureLength);
|
||||
}
|
||||
|
||||
private function key(): string
|
||||
{
|
||||
$configured = (string) config('bounces.signing_key');
|
||||
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', 'pk-bounce-reference', (string) config('app.key'));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
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;
|
||||
|
|
@ -26,8 +27,8 @@ use Illuminate\Support\Facades\DB;
|
|||
*/
|
||||
class CanonicalAssigner
|
||||
{
|
||||
/** @var array<int, string|null> Kategorie-ID → DE-Slug der Hauptkategorie */
|
||||
private array $categorySlugs = [];
|
||||
/** @var array<int, array{root: int|null, slug: string|null}> Kategorie-ID → Hauptkategorie (ID und DE-Slug) */
|
||||
private array $categoryRoots = [];
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
|
|
@ -44,12 +45,41 @@ class CanonicalAssigner
|
|||
return null;
|
||||
}
|
||||
|
||||
$slug = $this->categorySlug((int) $pressRelease->category_id);
|
||||
$portal = $slug !== null ? config('canonical.portal_by_category.'.$slug) : 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
|
||||
|
|
@ -159,15 +189,102 @@ class CanonicalAssigner
|
|||
}
|
||||
|
||||
/**
|
||||
* Alle Paare im Bestand zuordnen.
|
||||
* 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}
|
||||
* @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();
|
||||
|
|
@ -201,7 +318,31 @@ class CanonicalAssigner
|
|||
}
|
||||
});
|
||||
|
||||
return ['pairs' => $pairs, 'changed' => $changed];
|
||||
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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -300,16 +441,22 @@ class CanonicalAssigner
|
|||
return sprintf('%s/%s/press-release/%s', $base, $edition, $pressRelease->slug);
|
||||
}
|
||||
|
||||
private function categorySlug(int $categoryId): ?string
|
||||
/**
|
||||
* @return array{root: int|null, slug: string|null}
|
||||
*/
|
||||
private function categoryRoot(int $categoryId): array
|
||||
{
|
||||
if (array_key_exists($categoryId, $this->categorySlugs)) {
|
||||
return $this->categorySlugs[$categoryId];
|
||||
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->categorySlugs[$categoryId] = is_string($slug) ? $slug : null;
|
||||
return $this->categoryRoots[$categoryId] = [
|
||||
'root' => $root?->getKey(),
|
||||
'slug' => is_string($slug) ? $slug : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,26 +68,17 @@ class FeedFetchReport
|
|||
*/
|
||||
public function topFeeds(int $days = 30, int $limit = 20): array
|
||||
{
|
||||
// Gruppiert und begrenzt in SQL (Umsetzungsaudit 29.09.2026, OPT-1).
|
||||
// Reader ohne Kennung zählen wie bisher als ein eigener Reader.
|
||||
$rows = FeedFetch::query()
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->selectRaw('feed, feed_key, portal, language, reader, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at')
|
||||
->groupBy('feed', 'feed_key', 'portal', 'language', 'reader')
|
||||
->selectRaw("feed, feed_key, portal, language, count(*) as fetches, count(distinct coalesce(reader, '')) as readers, max(subscribers) as subscribers, max(created_at) as last_at")
|
||||
->groupBy('feed', 'feed_key', 'portal', 'language')
|
||||
->orderByDesc('fetches')
|
||||
->orderByDesc('last_at')
|
||||
->limit($limit)
|
||||
->toBase()
|
||||
->get()
|
||||
->groupBy(fn (object $row): string => implode('|', [(string) $row->feed, (string) $row->feed_key, (string) $row->portal, (string) $row->language]))
|
||||
->map(fn ($readers): object => (object) [
|
||||
'feed' => (string) $readers->first()->feed,
|
||||
'feed_key' => $readers->first()->feed_key,
|
||||
'portal' => (string) $readers->first()->portal,
|
||||
'language' => (string) $readers->first()->language,
|
||||
'fetches' => (int) $readers->sum('fetches'),
|
||||
'readers' => $readers->count(),
|
||||
'subscribers' => $readers->whereNotNull('subscribers')->max('subscribers'),
|
||||
'last_at' => $readers->max('last_at'),
|
||||
])
|
||||
->sortByDesc('fetches')
|
||||
->take($limit)
|
||||
->values();
|
||||
->get();
|
||||
|
||||
$categoryNames = $this->categoryNames($rows->where('feed', FeedFetch::FEED_CATEGORY)->pluck('feed_key')->filter()->unique()->values()->all());
|
||||
$companyNames = $this->companyNames($rows->where('feed', FeedFetch::FEED_NEWSROOM)->pluck('feed_key')->filter()->unique()->values()->all());
|
||||
|
|
@ -152,19 +143,32 @@ class FeedFetchReport
|
|||
return [];
|
||||
}
|
||||
|
||||
$since = now()->subDays($days);
|
||||
$key = static fn (string $feed, ?string $feedKey, string $portal, string $language): string => implode(':', [$feed, (string) $feedKey, $portal, $language]);
|
||||
|
||||
// Eine Abfrage für alle Feeds statt einer je Feed (OPT-1).
|
||||
$found = FeedFetch::query()
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->where(function ($query) use ($feeds): void {
|
||||
foreach ($feeds as $feed) {
|
||||
$query->orWhere(fn ($match) => $match
|
||||
->where('feed', $feed['feed'])
|
||||
->when($feed['feed_key'] === null, fn ($q) => $q->whereNull('feed_key'), fn ($q) => $q->where('feed_key', $feed['feed_key']))
|
||||
->where('portal', $feed['portal'])
|
||||
->where('language', $feed['language']));
|
||||
}
|
||||
})
|
||||
->selectRaw('feed, feed_key, portal, language, count(*) as fetches')
|
||||
->groupBy('feed', 'feed_key', 'portal', 'language')
|
||||
->toBase()
|
||||
->get()
|
||||
->mapWithKeys(fn (object $row): array => [
|
||||
$key((string) $row->feed, $row->feed_key === null ? null : (string) $row->feed_key, (string) $row->portal, (string) $row->language) => (int) $row->fetches,
|
||||
]);
|
||||
|
||||
$counts = [];
|
||||
|
||||
foreach ($feeds as $feed) {
|
||||
$key = implode(':', [$feed['feed'], (string) $feed['feed_key'], $feed['portal'], $feed['language']]);
|
||||
|
||||
$counts[$key] = FeedFetch::query()
|
||||
->where('feed', $feed['feed'])
|
||||
->when($feed['feed_key'] === null, fn ($query) => $query->whereNull('feed_key'), fn ($query) => $query->where('feed_key', $feed['feed_key']))
|
||||
->where('portal', $feed['portal'])
|
||||
->where('language', $feed['language'])
|
||||
->where('created_at', '>=', $since)
|
||||
->count();
|
||||
$counts[$key($feed['feed'], $feed['feed_key'], $feed['portal'], $feed['language'])] = (int) ($found[$key($feed['feed'], $feed['feed_key'], $feed['portal'], $feed['language'])] ?? 0);
|
||||
}
|
||||
|
||||
return $counts;
|
||||
|
|
|
|||
|
|
@ -180,8 +180,13 @@ class VisibilityStats
|
|||
'shares_total' => (int) $shares->sum(),
|
||||
// Die Sitemap listet jede im Archiv sichtbare Erstquelle — ab dem
|
||||
// Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht
|
||||
// über Google.
|
||||
'in_sitemap_since' => $sitemapPortals !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
|
||||
// über Google. Gibt es eine Fassung auf dem anderen Portal, kann
|
||||
// die Rolle gewechselt haben (Kopie → Erstquelle); einen Zeitpunkt
|
||||
// dafür speichern wir nicht — dann kein Datum statt eines falschen
|
||||
// (Umsetzungsaudit 29.09.2026, N17-Rest).
|
||||
'in_sitemap_since' => $sitemapPortals !== [] && $pressRelease->published_at !== null && ! $this->hasCounterpart($pressRelease)
|
||||
? Carbon::instance($pressRelease->published_at)
|
||||
: null,
|
||||
'sitemap_role' => $links === [] ? 'none' : ($sitemapPortals === [] ? 'copy' : 'source'),
|
||||
'sitemap_portals' => $sitemapPortals,
|
||||
'crawlers' => $this->crawlerFetches($pressRelease),
|
||||
|
|
@ -190,6 +195,26 @@ class VisibilityStats
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob es dieselbe Meldung (Prüfsumme, Firma) auf einem anderen Portal gibt
|
||||
* oder gab — dann ist „gelistet seit Veröffentlichung" nicht belegbar.
|
||||
*/
|
||||
private function hasCounterpart(PressRelease $pressRelease): bool
|
||||
{
|
||||
if ($pressRelease->content_hash === null || ! app(CanonicalAssigner::class)->enabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->withTrashed()
|
||||
->where('content_hash', $pressRelease->content_hash)
|
||||
->where('company_id', $pressRelease->company_id)
|
||||
->whereKeyNot($pressRelease->getKey())
|
||||
->where('portal', '!=', $pressRelease->portal->value)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* RSS-Feeds, in denen die Meldung erscheint (Roadmap 3.1): je Portal der
|
||||
* Feed der Branche, der des Newsrooms und der Portal-Feed. Beide führen die jüngsten
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Enums\Portal;
|
|||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseIndexCheck;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\PressRelease\CanonicalAssigner;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
|
@ -32,13 +33,6 @@ class IndexStatusReport
|
|||
|
||||
private const int CACHE_SECONDS = 900;
|
||||
|
||||
/**
|
||||
* Rolle einer Meldung in der kanonischen Zuordnung (1c.8): Erstquelle
|
||||
* oder Kopie mit rel=canonical auf die andere Fassung. Für eine Kopie ist
|
||||
* „ausgeschlossen" (Google folgt dem Verweis) der erwartete Befund.
|
||||
*/
|
||||
private const string ROLE_SQL = "case when press_releases.canonical_press_release_id is null then 'source' else 'copy' end";
|
||||
|
||||
/** @var list<string> */
|
||||
public const array ORIGINS = ['direct', 'api'];
|
||||
|
||||
|
|
@ -65,13 +59,15 @@ class IndexStatusReport
|
|||
$rows = [];
|
||||
|
||||
foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) {
|
||||
$roleSql = $this->roleSql($portal);
|
||||
|
||||
// Herkunft und Rolle je Meldung erst in einer abgeleiteten Tabelle
|
||||
// bestimmen, dann darüber gruppieren — MySQL (only_full_group_by)
|
||||
// lehnt den korrelierten Ausdruck direkt im GROUP BY ab.
|
||||
$visible = DB::query()
|
||||
->fromSub(
|
||||
$this->apiJoin($this->visibleOn($portal))
|
||||
->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'),
|
||||
->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin, '.$roleSql.' as role'),
|
||||
'r'
|
||||
)
|
||||
->selectRaw('origin, role, count(*) as total')
|
||||
|
|
@ -87,7 +83,7 @@ class IndexStatusReport
|
|||
$this->apiJoin($this->latestChecks($portal)
|
||||
->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id')
|
||||
->whereIn('press_releases.id', $this->visibleOn($portal)->select('press_releases.id')))
|
||||
->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'),
|
||||
->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin, '.$roleSql.' as role'),
|
||||
'c'
|
||||
)
|
||||
->selectRaw('origin, role, state, count(*) as total')
|
||||
|
|
@ -198,6 +194,33 @@ class IndexStatusReport
|
|||
* @param Builder<TModel> $query
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
/**
|
||||
* Rolle einer Meldung auf `$portal` in der kanonischen Zuordnung (1c.8):
|
||||
* Erstquelle oder Kopie mit rel=canonical auf die andere Fassung. Für
|
||||
* eine Kopie ist „ausgeschlossen" (Google folgt dem Verweis) der
|
||||
* erwartete Befund. Dieselbe Regel wie CanonicalAssigner::isCanonicalOn()
|
||||
* und damit die Sitemap: Ohne Freigabe ist alles Erstquelle; eine
|
||||
* Meldung mit portal=both ist nur auf dem Portal ihrer Kategorie
|
||||
* Erstquelle, auf dem anderen Kopie (Umsetzungsaudit 29.09.2026,
|
||||
* N18-Rest).
|
||||
*/
|
||||
private function roleSql(Portal $portal): string
|
||||
{
|
||||
$assigner = app(CanonicalAssigner::class);
|
||||
|
||||
if (! $assigner->enabled()) {
|
||||
return "'source'";
|
||||
}
|
||||
|
||||
$other = $portal === Portal::Presseecho ? Portal::Businessportal24 : Portal::Presseecho;
|
||||
$foreignCategories = array_map('intval', $assigner->categoryIdsPreferring($other));
|
||||
$bothOnOtherPortal = $foreignCategories === []
|
||||
? ''
|
||||
: " when press_releases.portal = '".Portal::Both->value."' and press_releases.category_id in (".implode(',', $foreignCategories).") then 'copy'";
|
||||
|
||||
return "case when press_releases.canonical_press_release_id is not null then 'copy'".$bothOnOtherPortal." else 'source' end";
|
||||
}
|
||||
|
||||
private function apiJoin(Builder $query): Builder
|
||||
{
|
||||
$api = DB::table('press_release_status_logs')
|
||||
|
|
|
|||
|
|
@ -89,20 +89,23 @@ class ReleaseDetailPageData
|
|||
}
|
||||
|
||||
$releaseId = $release->getKey();
|
||||
defer(static fn () => PressRelease::recordDeferredView($releaseId));
|
||||
|
||||
// Crawler-Abruf-Protokoll (Roadmap 1c.0): nur bekannte Such- und
|
||||
// KI-Dienste, erkannt am User-Agent, ohne IP.
|
||||
// KI-Dienste, erkannt am User-Agent, ohne IP. Ein erkannter Crawler
|
||||
// ist kein Aufruf (Umsetzungsaudit 29.09.2026, R-07) — er landet nur
|
||||
// im Crawler-Protokoll, nicht im Aufrufzähler.
|
||||
$userAgent = (string) request()->userAgent();
|
||||
$crawler = CrawlerIdentifier::identify($userAgent);
|
||||
|
||||
if ($crawler !== null) {
|
||||
defer(static fn () => PressRelease::recordDeferredCrawlerHit($releaseId, $crawler, $userAgent));
|
||||
}
|
||||
} else {
|
||||
defer(static fn () => PressRelease::recordDeferredView($releaseId));
|
||||
|
||||
// Die Antwort zeigt den aktuellen Aufruf bereits an; die atomaren
|
||||
// Schreibzugriffe erfolgen nach dem Senden der Response.
|
||||
$release->hits = ($release->hits ?? 0) + 1;
|
||||
// Die Antwort zeigt den aktuellen Aufruf bereits an; die atomaren
|
||||
// Schreibzugriffe erfolgen nach dem Senden der Response.
|
||||
$release->hits = ($release->hits ?? 0) + 1;
|
||||
}
|
||||
|
||||
$with = [
|
||||
'company',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue