billing: Nebenläufigkeit & Idempotenz härten (H4, Stripe, Boost-Slots)
- CreditTopup-Gutschrift, MAN-Rechnung, Proof-PDF und Stripe-Webhook- Spiegelung sperren die betroffene Zeile innerhalb der Transaktion und prüfen die Idempotenz erst danach (verhindert Doppelgutschrift bei at-least-once-Webhooks, H4). - Gemeinsamer LocksModelForUpdate-Trait für die häufige Lock-by-Key-Form (CreditTopup, ManualInvoice). - Unique-Index auf invoices.stripe_invoice_id; Boost-Inventar über boost_slot_locks serialisiert, verwaiste Kategorie-Locks werden beim Löschen der Rubrik entfernt. Deployment: vor migrate prüfen, ob invoices.stripe_invoice_id bereits Duplikate enthält (die Unique-Migration bricht sonst bewusst ab). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3295f47556
commit
aae456954a
14 changed files with 418 additions and 106 deletions
|
|
@ -146,6 +146,8 @@ class BoostService
|
|||
$credits = $this->pricing->boostCredits($days, $placement);
|
||||
|
||||
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits, $placement): Boost {
|
||||
$this->lockSlotInventory($pressRelease, $placement);
|
||||
|
||||
$availability = $this->slotAvailability($pressRelease, $placement);
|
||||
if (! $availability['available']) {
|
||||
throw BoostNotAllowedException::soldOut($placement, $availability['freeAt']);
|
||||
|
|
@ -177,6 +179,36 @@ class BoostService
|
|||
return $boost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
|
||||
*
|
||||
* Eine eigene stabile Lock-Zeile ist nötig: Ein Lock auf vorhandenen
|
||||
* Boost-Zeilen schützt gerade das leere Inventar nicht vor zwei
|
||||
* gleichzeitigen Erstkäufen.
|
||||
*/
|
||||
private function lockSlotInventory(PressRelease $pressRelease, BoostPlacement $placement): void
|
||||
{
|
||||
$lockKeys = collect($this->affectedPortals($pressRelease))
|
||||
->map(fn (string $portal): string => implode(':', [
|
||||
$placement->value,
|
||||
$portal,
|
||||
$pressRelease->language,
|
||||
$placement === BoostPlacement::Category ? (string) $pressRelease->category_id : 'all',
|
||||
]))
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
DB::table('boost_slot_locks')->insertOrIgnore(
|
||||
$lockKeys->map(fn (string $lockKey): array => ['lock_key' => $lockKey])->all(),
|
||||
);
|
||||
|
||||
DB::table('boost_slot_locks')
|
||||
->whereIn('lock_key', $lockKeys)
|
||||
->orderBy('lock_key')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gecachte Start-/Branchenseiten der betroffenen Ausgaben verwerfen,
|
||||
* damit die bezahlte Platzierung sofort sichtbar wird statt erst nach
|
||||
|
|
@ -219,4 +251,17 @@ class BoostService
|
|||
? [Portal::Presseecho->value, Portal::Businessportal24->value]
|
||||
: [$pressRelease->portal->value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt die (bei Kategorie-Boosts angelegten) Slot-Lock-Zeilen einer
|
||||
* gelöschten Rubrik. Die Lock-Keys haben die Form
|
||||
* `category:<portal>:<sprache>:<category_id>` — nach dem Löschen der Rubrik
|
||||
* sind sie verwaist und werden hier aufgeräumt.
|
||||
*/
|
||||
public function forgetSlotLocksForCategory(int $categoryId): void
|
||||
{
|
||||
DB::table('boost_slot_locks')
|
||||
->where('lock_key', 'like', 'category:%:'.$categoryId)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue