From 31d407d3adb607b978ff4770ac9e0816a9118ef8 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 19 Jun 2026 11:05:07 +0000 Subject: [PATCH] Optimierung 3.4: Performance (Logging terminable, Single-Purchase memoisiert) - LogApiUsage als terminable Middleware: der ApiUsageLog-INSERT laeuft nach gesendeter Antwort und verlaengert nicht mehr die Request-Dauer. Startzeit wird ueber ein Request-Attribut uebergeben (terminate() laeuft auf frischer Instanz). - User::openSinglePurchaseCount() memoisiert die offenen Einmalkaeufe pro Request. Vorher wurde derselbe grantingSubmission-Count im Status-Widget mehrfach (Quota-Total, Quota-Remaining, openSinglePurchases) plus ein exists() in hasActiveBooking abgesetzt -> jetzt eine Query. - CustomerCompanyContext::selectedCompany() nutzt eine einzige first()-Query statt exists()+first(); das Verwerfen nicht (mehr) erreichbarer Auswahlen bleibt erhalten. - LegacySubscriptionService::cancelBundleFor() ist ein einziges Bulk-Update statt Update-pro-Iteration (UserPaymentOption hat keine Observer/Events). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Http/Middleware/LogApiUsage.php | 20 ++++++++++++---- app/Models/User.php | 24 ++++++++++++++++--- .../Billing/LegacySubscriptionService.php | 12 ++++------ .../Customer/CustomerCompanyContext.php | 15 +++++++++--- .../Customer/CustomerStatusSummary.php | 2 +- dev/optimize/entwicklungsplan.md | 2 +- 6 files changed, 56 insertions(+), 19 deletions(-) diff --git a/app/Http/Middleware/LogApiUsage.php b/app/Http/Middleware/LogApiUsage.php index 739b5b52..6d1831bc 100644 --- a/app/Http/Middleware/LogApiUsage.php +++ b/app/Http/Middleware/LogApiUsage.php @@ -10,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response; class LogApiUsage { + private const StartedAtKey = 'api_usage_started_at'; + /** * Handle an incoming request. * @@ -17,12 +19,22 @@ class LogApiUsage */ public function handle(Request $request, Closure $next): Response { - $startedAt = microtime(true); - $response = $next($request); + $request->attributes->set(self::StartedAtKey, microtime(true)); + + return $next($request); + } + + /** + * Der INSERT läuft erst nach gesendeter Antwort (terminable Middleware) und + * verlängert damit nicht mehr die wahrgenommene Request-Dauer. Der Startwert + * wird über die Request-Attribute übergeben, da `terminate()` auf einer + * frischen Middleware-Instanz aufgerufen wird. + */ + public function terminate(Request $request, Response $response): void + { + $startedAt = (float) $request->attributes->get(self::StartedAtKey, microtime(true)); $this->writeLog($request, $response, $startedAt); - - return $response; } private function writeLog(Request $request, Response $response, float $startedAt): void diff --git a/app/Models/User.php b/app/Models/User.php index 404dc150..5d2cae67 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -130,6 +130,8 @@ class User extends Authenticatable implements MustVerifyEmail private ?bool $cachedHasUnlimitedQuota = null; + private ?int $cachedOpenSinglePurchaseCount = null; + /** * Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans` * gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo. @@ -216,7 +218,23 @@ class User extends Authenticatable implements MustVerifyEmail ($this->currentPlan()?->press_release_quota ?? 0) - (int) $this->press_release_quota_used_this_month, ); - return $planRemaining + $this->singlePurchases()->grantingSubmission()->count(); + return $planRemaining + $this->openSinglePurchaseCount(); + } + + /** + * Bezahlte, noch nicht eingelöste Einmalkäufe (Einzel-/Extra-PM), die zur + * Einreichung berechtigen. Per-Request memoisiert, weil das Status-Widget + * den Wert je Render mehrfach abfragt (Kontingent, Buchungslabel, Gate). + */ + public function openSinglePurchaseCount(): int + { + if ($this->cachedOpenSinglePurchaseCount !== null) { + return $this->cachedOpenSinglePurchaseCount; + } + + return $this->cachedOpenSinglePurchaseCount = $this->singlePurchases() + ->grantingSubmission() + ->count(); } /** @@ -230,7 +248,7 @@ class User extends Authenticatable implements MustVerifyEmail } return ($this->currentPlan()?->press_release_quota ?? 0) - + $this->singlePurchases()->grantingSubmission()->count(); + + $this->openSinglePurchaseCount(); } /** @@ -253,7 +271,7 @@ class User extends Authenticatable implements MustVerifyEmail return true; } - if ($this->singlePurchases()->grantingSubmission()->exists()) { + if ($this->openSinglePurchaseCount() > 0) { return true; } diff --git a/app/Services/Billing/LegacySubscriptionService.php b/app/Services/Billing/LegacySubscriptionService.php index cd64b95f..3a7d4d99 100644 --- a/app/Services/Billing/LegacySubscriptionService.php +++ b/app/Services/Billing/LegacySubscriptionService.php @@ -42,15 +42,13 @@ class LegacySubscriptionService */ public function cancelBundleFor(User $user): int { - $posts = $this->grandfatheredFor($user); - - foreach ($posts as $post) { - $post->update([ + return UserPaymentOption::query() + ->where('user_id', $user->getKey()) + ->where('status', UserPaymentOptionStatus::Grandfathered->value) + ->whereNull('cancelled_at') + ->update([ 'status' => UserPaymentOptionStatus::Cancelled->value, 'cancelled_at' => now(), ]); - } - - return $posts->count(); } } diff --git a/app/Services/Customer/CustomerCompanyContext.php b/app/Services/Customer/CustomerCompanyContext.php index 248c9cd8..ddee96b7 100644 --- a/app/Services/Customer/CustomerCompanyContext.php +++ b/app/Services/Customer/CustomerCompanyContext.php @@ -57,13 +57,22 @@ class CustomerCompanyContext public function selectedCompany(User $user): ?Company { - $companyId = $this->selectedCompanyId($user); + $companyId = session(self::SessionKey); - if ($companyId === null) { + if (! is_numeric($companyId)) { return null; } - return $this->findFor($user, $companyId); + // Eine einzige Abfrage statt exists()+first(): findFor prüft den Zugriff + // bereits über accessibleBy. Ist die Firma nicht (mehr) erreichbar, wird + // die Auswahl wie in selectedCompanyId verworfen. + $company = $this->findFor($user, (int) $companyId); + + if ($company === null) { + $this->clear(); + } + + return $company; } /** diff --git a/app/Services/Customer/CustomerStatusSummary.php b/app/Services/Customer/CustomerStatusSummary.php index 8a4a8948..13dd321f 100644 --- a/app/Services/Customer/CustomerStatusSummary.php +++ b/app/Services/Customer/CustomerStatusSummary.php @@ -54,7 +54,7 @@ class CustomerStatusSummary hasActiveBooking: $user->hasActiveBooking(), hasSubscription: $hasSubscription, hasGrandfathered: $hasGrandfathered, - openSinglePurchases: $user->singlePurchases()->grantingSubmission()->count(), + openSinglePurchases: $user->openSinglePurchaseCount(), unlimitedQuota: $user->hasUnlimitedPressReleaseQuota(), quotaTotal: $user->pressReleaseQuotaTotal(), quotaRemaining: $user->pressReleaseQuotaRemaining(), diff --git a/dev/optimize/entwicklungsplan.md b/dev/optimize/entwicklungsplan.md index 94915bec..993bd238 100644 --- a/dev/optimize/entwicklungsplan.md +++ b/dev/optimize/entwicklungsplan.md @@ -60,7 +60,7 @@ Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten. - [ ] **3.1 `Company::scopeAccessibleBy()` / `CompanyPolicy`** — identische `owner_user_id == me OR whereHas('users', me)`-Closure in 4 Controllern + Überlappung mit `User::accessibleCompanyIds()` und `CustomerCompanyContext::accessibleCompanyQuery()`. Policy wird in `ContactAccessService:229` bereits referenziert. → konsequent über Gate/Policy. - [ ] **3.2 API-PressRelease-Zugriff + Eager-Loads zentralisieren** — `findOwnedPressRelease` doppelt (PressRelease/Image-Controller); Relation-Liste (`company:…`, `category.translations`, `images`) mehrfach. → expliziter Scope (`ownedByApiUser`) + `withApiResourceRelations()`. Unique-Slug-Reimplementierung (`PressReleaseController:201-220`) durch bestehendes `HasUniqueSlug` ersetzen. - [ ] **3.3 `DomainContext`-Service** — Host→Config-Auflösung 4× (`ThemeServiceProvider::boot`, `ThemeMiddleware`, `SetDomainUrl`, `SetCurrentPortal`). **Vorher prüfen, ob `SetDomainUrl` toter Code ist** (scheint nicht in `bootstrap/app.php` registriert). `CurrentPortalContext` mutable static → request-scoped Binding. -- [ ] **3.4 Performance** — `LogApiUsage` synchroner INSERT/Request → terminable/queued. `CustomerStatusSummary:35-66` Query-Sturm (always-on im Header) memoisieren; offene Single-Purchase 3× → einmal berechnen. `CustomerCompanyContext::selectedCompany()` doppelte Query → einmal `first()`. `LegacySubscriptionService::cancelBundleFor` Update-pro-Iteration → Bulk/Transaction. +- [x] **3.4 Performance** — `LogApiUsage` ist jetzt terminable (INSERT nach gesendeter Antwort, kein Queue-Worker nötig; Startzeit über Request-Attribut). Offene Single-Purchase über `User::openSinglePurchaseCount()` per-Request memoisiert (vorher 3× Count + 1× exists pro Status-Widget-Render → jetzt 1× Count), genutzt von Quota-Total/-Remaining, `hasActiveBooking` und `CustomerStatusSummary`. `CustomerCompanyContext::selectedCompany()` nutzt eine einzige `first()`-Query statt exists()+first() (Verwerfen bei Nichterreichbarkeit bleibt). `LegacySubscriptionService::cancelBundleFor` ist ein einziges Bulk-Update (UserPaymentOption ohne Observer → sicher). - [ ] **3.5 Kleinere Zentralisierungen** — `VatResolver::normalize/isEuCountry` (3×), `User::findByEmail()` statt `LOWER()`-Queries (defeated Index, 4×), `config('billing.default_currency')` statt `'EUR'`/`'eur'`-Literale, gemeinsame Account-Provisioning-Methode (`ContactAccessService` ≈ `SocialAuthService`). - [ ] **3.6 Auth-Gates** — `EnsureUserIsAdmin/Customer`, `EnsureApiUserIsActive` von Hand-Rolling → `Gate::define('access-admin'|'access-customer')` + `can:`-Middleware. Impersonation-Redirect aus dem Admin-Gate in eigene Middleware.