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>
This commit is contained in:
Kevin Adametz 2026-07-14 13:49:47 +00:00
parent 1afcc364ac
commit 22975e762e
18 changed files with 419 additions and 39 deletions

View file

@ -2,9 +2,12 @@
namespace App\Services\Web;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Kursdaten für den Ad-Hoc-Ticker der Web-Frontends.
@ -78,12 +81,19 @@ class MarketDataService
{
try {
$response = Http::timeout(10)
->connectTimeout(3)
->retry(
[150, 400],
when: static fn (Throwable $exception): bool => $exception instanceof ConnectionException
|| ($exception instanceof RequestException && in_array($exception->response->status(), [429, 500, 502, 503, 504], true)),
throw: false,
)
->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; PressekontoTicker/1.0)'])
->get(config('market.endpoint').rawurlencode($symbol), [
'range' => '1d',
'interval' => '1d',
]);
} catch (\Throwable $exception) {
} catch (Throwable $exception) {
Log::warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'error' => $exception->getMessage()]);
return null;

View file

@ -6,8 +6,10 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Carbon;
/**
@ -22,6 +24,31 @@ class NewsroomDetailPageData
* @return array<string, mixed>
*/
public function build(WebPortalContext $context, string $slug): array
{
// Seitennummer exakt so auflösen wie der Paginator (Paginator::
// resolveCurrentPage validiert und fällt bei ungültigen Werten auf 1
// zurück). Sonst landet Seite-1-Inhalt unter einem anderen Seiten-Key
// (Cache-Poisoning über z. B. ?page=2.5).
$page = Paginator::resolveCurrentPage();
$cacheKey = WebPageCache::key('newsroom', [
implode('-', $context->portalValues),
$context->language,
$slug,
'page',
$page,
]);
return WebPageCache::remember(
$cacheKey,
fn (): array => $this->buildUncached($context, $slug),
);
}
/**
* @return array<string, mixed>
*/
private function buildUncached(WebPortalContext $context, string $slug): array
{
$portalValues = $context->portalValues;
$language = $context->language;
@ -66,12 +93,14 @@ class NewsroomDetailPageData
$leadRelease = $base()->with($with)->orderByDesc('published_at')->first();
// Nur den Seiten-Parameter an die Pagination-Links hängen (kein
// ->withQueryString()): sonst backt der erste Besucher seine beliebigen
// Query-Parameter (utm_* o. Ä.) in die für alle gecachten Links.
$releases = $base()
->with($with)
->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey()))
->orderByDesc('published_at')
->paginate(10)
->withQueryString();
->paginate(10);
$mostReadReleases = $base()
->orderByDesc('hits')

View file

@ -6,8 +6,11 @@ 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
@ -18,15 +21,51 @@ 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);
$term = trim((string) request('q', ''));
$published = static function (Builder $query) use ($portalValues, $language): void {
$query
->forPortalValues($portalValues)
@ -53,8 +92,11 @@ class NewsroomsPageData
->orderByDesc('recent_count')
->orderByDesc('releases_count')
->orderBy('name')
->paginate(24)
->withQueryString();
->paginate(24, ['*'], 'page', $page);
if ($term !== '') {
$newsrooms->appends(['q' => $term]);
}
$archiveTotal = PressRelease::query()
->withoutGlobalScope(PortalScope::class)

View file

@ -2,12 +2,13 @@
namespace App\Services\Web;
use App\Enums\PressReleaseEventType;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use function Illuminate\Support\defer;
/**
* Datenaufbereitung der Detailseite einer Pressemitteilung je Ausgabe inkl.
* Hit-Zähler und verwandten Meldungen (Firma, Kategorie). Unbekannter oder nicht
@ -61,13 +62,11 @@ class ReleaseDetailPageData
abort(404);
}
PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($release->getKey())
->update(['hits' => ($release->hits ?? 0) + 1]);
$releaseId = $release->getKey();
defer(static fn () => PressRelease::recordDeferredView($releaseId));
// Zusätzlich zum Legacy-Zähler als Einzel-Ereignis loggen (ohne
// Personenbezug); Anzeige auf der Seite inkl. des aktuellen Aufrufs.
$release->events()->create(['type' => PressReleaseEventType::View]);
// Die Antwort zeigt den aktuellen Aufruf bereits an; die atomaren
// Schreibzugriffe erfolgen nach dem Senden der Response.
$release->hits = ($release->hits ?? 0) + 1;
$with = [

View file

@ -4,6 +4,7 @@ namespace App\Services\Web;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
/**
@ -17,20 +18,28 @@ class SuchePageData
*/
public function build(WebPortalContext $context): array
{
$mostReadReleases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($context->portalValues)
->forLanguage($context->language)
->visibleInArchive()
->orderByDesc('hits')
->orderByDesc('published_at')
->limit(5)
->get(['id', 'slug', 'title', 'hits', 'portal', 'language']);
$cacheKey = WebPageCache::key('search', [
'most-read',
implode('-', $context->portalValues),
$context->language,
]);
return [
'portal' => $context->portal->value,
'language' => $context->language,
'mostReadReleases' => $mostReadReleases,
];
return WebPageCache::remember($cacheKey, static function () use ($context): array {
$mostReadReleases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($context->portalValues)
->forLanguage($context->language)
->visibleInArchive()
->orderByDesc('hits')
->orderByDesc('published_at')
->limit(5)
->get(['id', 'slug', 'title', 'hits', 'portal', 'language']);
return [
'portal' => $context->portal->value,
'language' => $context->language,
'mostReadReleases' => $mostReadReleases,
];
});
}
}