presseportale/app/Services/PressRelease/ReviewCheckService.php
Kevin Adametz 23cbd0bfe2 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>
2026-07-14 13:49:47 +00:00

120 lines
3.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\PressRelease;
use App\Enums\ReviewCheckSource;
use App\Exceptions\ReviewLimitException;
use App\Models\PressRelease;
use App\Models\ReviewCheck;
use App\Models\User;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\CreditWalletService;
use Illuminate\Support\Facades\DB;
/**
* Prüfzähler-Logik laut Decision-Update §4.2/§4.3.
*
* Reihenfolge je Prüfung:
* 1. Tageslimit (Burst-Schutz) – harte Bremse, nicht freikaufbar.
* 2. Monats-Freikontingent (tier-gestaffelt, aggregiert pro Account).
* 3. Overflow – ist das Freikontingent leer, zieht jede weitere Prüfung
* `credits.review.overflow_cost` Credits aus der Wallet.
*/
class ReviewCheckService
{
public function __construct(
private readonly CreditPricingService $pricing,
private readonly CreditWalletService $wallet,
) {}
/**
* Verbrauchte Prüfungen im laufenden Monat (Account-Aggregat).
*/
public function usedThisMonth(User $user): int
{
return $user->reviewChecks()->thisMonth()->count();
}
public function usedToday(User $user): int
{
return $user->reviewChecks()->today()->count();
}
/**
* Verbleibende Frei-Prüfungen diesen Monat.
*/
public function freeRemaining(User $user): int
{
$quota = $this->pricing->reviewFreeQuota($user->currentTier());
return max(0, $quota - $this->usedThisMonth($user));
}
public function dailyLimitReached(User $user): bool
{
return $this->usedToday($user) >= $this->pricing->reviewDailyLimit();
}
/**
* Würde die nächste Prüfung Credits kosten (Freikontingent leer)?
*/
public function nextCheckCosts(User $user): bool
{
return $this->freeRemaining($user) === 0;
}
/**
* Kann der User jetzt eine Prüfung auslösen? Tageslimit nicht erreicht und
* entweder Freikontingent übrig oder genug Guthaben für den Overflow.
*/
public function canCheck(User $user): bool
{
if ($this->dailyLimitReached($user)) {
return false;
}
if ($this->freeRemaining($user) > 0) {
return true;
}
return $this->wallet->canAfford($user, $this->pricing->reviewOverflowCost());
}
/**
* Bucht eine Prüfung. Wirft ReviewLimitException am Tageslimit und
* InsufficientCreditsException, wenn der Overflow nicht gedeckt ist.
*/
public function recordCheck(User $user, ?PressRelease $pressRelease = null): ReviewCheck
{
return DB::transaction(function () use ($user, $pressRelease): ReviewCheck {
$lockedUser = User::query()
->whereKey($user->getKey())
->lockForUpdate()
->firstOrFail();
if ($this->dailyLimitReached($lockedUser)) {
throw new ReviewLimitException($this->pricing->reviewDailyLimit());
}
$useFree = $this->freeRemaining($lockedUser) > 0;
$charged = 0;
if (! $useFree) {
$cost = $this->pricing->reviewOverflowCost();
$this->wallet->debit(
$lockedUser,
$cost,
'Prüfung (Overflow)',
$pressRelease,
);
$charged = $cost;
}
return $lockedUser->reviewChecks()->create([
'press_release_id' => $pressRelease?->id,
'source' => $useFree ? ReviewCheckSource::Free : ReviewCheckSource::Credit,
'charged_credits' => $charged,
]);
});
}
}