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:
Kevin Adametz 2026-06-19 11:05:07 +00:00
parent 9a93903cd9
commit 31d407d3ad
6 changed files with 56 additions and 19 deletions

View file

@ -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

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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;
}
/**

View file

@ -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(),