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:
parent
1afcc364ac
commit
22975e762e
18 changed files with 419 additions and 39 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue