presseportale/app/Services/Web/NewsroomsPageData.php
Kevin Adametz 22975e762e performance: Caching, deferred Tracking & externe Timeouts (H7, M12, M13)
- Navigations-Chrome (CategoryNavigation), Suche- und Newsroom-Seitendaten
  300s gecacht; gemeinsamer WebPageCache-Helper (eine TTL/Key-Konvention).
- Cache-Poisoning über ?page= geschlossen (Key an Paginator ausgerichtet,
  kein withQueryString im Cache); Newsrooms cachet nur den ungefilterten
  Fall und die ersten Seiten (kein unbegrenzter Key-Raum).
- Detailseiten schreiben Hits/View-Events deferred nach der Response
  (M12); OpenAI-/Markt-/USt-ID-Aufrufe mit Connect-Timeouts und
  begrenzten Retries; Admin-Memo-Caches konsistent invalidiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:49:47 +00:00

120 lines
4.4 KiB
PHP

<?php
namespace App\Services\Web;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\EditorialClock;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Str;
/**
* Datenaufbereitung des Newsrooms-Verzeichnisses je Ausgabe: alle aktiven
* Unternehmen mit veröffentlichten Meldungen (optional gefiltert per `q`).
*/
class NewsroomsPageData
{
/**
* @return array<string, mixed>
*/
/**
* Nur die ersten Seiten des ungefilterten Verzeichnisses werden gecacht.
*/
private const MaxCachedPage = 20;
public function build(WebPortalContext $context): array
{
$portalValues = $context->portalValues;
$language = $context->language;
$requestedTerm = request()->query('q', '');
$term = is_string($requestedTerm) ? Str::of($requestedTerm)->squish()->toString() : '';
$normalizedTerm = mb_strtolower($term);
// Seite exakt wie der Paginator auflösen (verhindert Key/Inhalt-Divergenz).
$page = Paginator::resolveCurrentPage();
$build = fn (): array => $this->query(
portalValues: $portalValues,
language: $language,
term: $term,
page: $page,
);
// Gefilterte Suchen und tiefe Seiten NICHT cachen: sonst kann ein
// Crawler/Angreifer über beliebige ?q= / ?page= unbegrenzt Cache-
// Einträge (je mit serialisiertem Paginator) erzeugen, die nie
// wiederverwendet werden. Der häufige Fall (ungefiltertes Verzeichnis,
// erste Seiten) bleibt gecacht.
if ($normalizedTerm !== '' || $page > self::MaxCachedPage) {
return $build();
}
$cacheKey = WebPageCache::key('newsrooms', [implode(',', $portalValues), $language, 'page', $page]);
return WebPageCache::remember($cacheKey, $build);
}
/**
* @param list<string> $portalValues
* @return array<string, mixed>
*/
private function query(array $portalValues, string $language, string $term, int $page): array
{
$referenceNow = EditorialClock::reference($portalValues, $language);
$recentSince = $referenceNow->copy()->subDays(30);
$published = static function (Builder $query) use ($portalValues, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive();
};
$activeCompanies = static fn (): Builder => Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $portalValues)
->where('is_active', true)
->whereHas('pressReleases', fn (Builder $query) => $published($query));
$newsrooms = $activeCompanies()
->when($term !== '', fn (Builder $query) => $query->whereLike('name', '%'.$term.'%'))
->withCount([
'pressReleases as releases_count' => fn (Builder $query) => $published($query),
'pressReleases as recent_count' => function (Builder $query) use ($published, $recentSince): void {
$published($query);
$query->where('published_at', '>=', $recentSince);
},
])
->withMax(['pressReleases as latest_published_at' => fn (Builder $query) => $published($query)], 'published_at')
->orderByDesc('recent_count')
->orderByDesc('releases_count')
->orderBy('name')
->paginate(24, ['*'], 'page', $page);
if ($term !== '') {
$newsrooms->appends(['q' => $term]);
}
$archiveTotal = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->tap(fn (Builder $query) => $published($query))
->count();
return [
'newsrooms' => $newsrooms,
'term' => $term,
'referenceNow' => $referenceNow,
'totalNewsrooms' => (clone $activeCompanies())->count(),
'activeNewsroomsCount' => $activeCompanies()
->whereHas('pressReleases', function (Builder $query) use ($published, $recentSince): void {
$published($query);
$query->where('published_at', '>=', $recentSince);
})
->count(),
'archiveTotal' => $archiveTotal,
];
}
}