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) <noreply@anthropic.com>
This commit is contained in:
parent
9a93903cd9
commit
31d407d3ad
6 changed files with 56 additions and 19 deletions
|
|
@ -10,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class LogApiUsage
|
class LogApiUsage
|
||||||
{
|
{
|
||||||
|
private const StartedAtKey = 'api_usage_started_at';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
|
|
@ -17,12 +19,22 @@ class LogApiUsage
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$startedAt = microtime(true);
|
$request->attributes->set(self::StartedAtKey, microtime(true));
|
||||||
$response = $next($request);
|
|
||||||
|
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);
|
$this->writeLog($request, $response, $startedAt);
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function writeLog(Request $request, Response $response, float $startedAt): void
|
private function writeLog(Request $request, Response $response, float $startedAt): void
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||||
|
|
||||||
private ?bool $cachedHasUnlimitedQuota = null;
|
private ?bool $cachedHasUnlimitedQuota = null;
|
||||||
|
|
||||||
|
private ?int $cachedOpenSinglePurchaseCount = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
|
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
|
||||||
* gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.
|
* 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,
|
($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)
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->singlePurchases()->grantingSubmission()->exists()) {
|
if ($this->openSinglePurchaseCount() > 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,15 +42,13 @@ class LegacySubscriptionService
|
||||||
*/
|
*/
|
||||||
public function cancelBundleFor(User $user): int
|
public function cancelBundleFor(User $user): int
|
||||||
{
|
{
|
||||||
$posts = $this->grandfatheredFor($user);
|
return UserPaymentOption::query()
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
foreach ($posts as $post) {
|
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||||
$post->update([
|
->whereNull('cancelled_at')
|
||||||
|
->update([
|
||||||
'status' => UserPaymentOptionStatus::Cancelled->value,
|
'status' => UserPaymentOptionStatus::Cancelled->value,
|
||||||
'cancelled_at' => now(),
|
'cancelled_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
return $posts->count();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,22 @@ class CustomerCompanyContext
|
||||||
|
|
||||||
public function selectedCompany(User $user): ?Company
|
public function selectedCompany(User $user): ?Company
|
||||||
{
|
{
|
||||||
$companyId = $this->selectedCompanyId($user);
|
$companyId = session(self::SessionKey);
|
||||||
|
|
||||||
if ($companyId === null) {
|
if (! is_numeric($companyId)) {
|
||||||
return null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class CustomerStatusSummary
|
||||||
hasActiveBooking: $user->hasActiveBooking(),
|
hasActiveBooking: $user->hasActiveBooking(),
|
||||||
hasSubscription: $hasSubscription,
|
hasSubscription: $hasSubscription,
|
||||||
hasGrandfathered: $hasGrandfathered,
|
hasGrandfathered: $hasGrandfathered,
|
||||||
openSinglePurchases: $user->singlePurchases()->grantingSubmission()->count(),
|
openSinglePurchases: $user->openSinglePurchaseCount(),
|
||||||
unlimitedQuota: $user->hasUnlimitedPressReleaseQuota(),
|
unlimitedQuota: $user->hasUnlimitedPressReleaseQuota(),
|
||||||
quotaTotal: $user->pressReleaseQuotaTotal(),
|
quotaTotal: $user->pressReleaseQuotaTotal(),
|
||||||
quotaRemaining: $user->pressReleaseQuotaRemaining(),
|
quotaRemaining: $user->pressReleaseQuotaRemaining(),
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.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.
|
- [ ] **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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue