feat: add service logic
This commit is contained in:
parent
dfaec2d948
commit
8d21cfb983
9
app/Exceptions/HoldNotAvailableException.php
Normal file
9
app/Exceptions/HoldNotAvailableException.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class HoldNotAvailableException extends RuntimeException
|
||||
{
|
||||
}
|
||||
9
app/Exceptions/SlotCapacityExceededException.php
Normal file
9
app/Exceptions/SlotCapacityExceededException.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SlotCapacityExceededException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@ -2,7 +2,144 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\HoldStatus;
|
||||
use App\Exceptions\HoldNotAvailableException;
|
||||
use App\Exceptions\SlotCapacityExceededException;
|
||||
use App\Models\Hold;
|
||||
use App\Models\Slot;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SlotService
|
||||
{
|
||||
private const string AVAILABILITY_CACHE_KEY = 'slots:availability';
|
||||
|
||||
/**
|
||||
* @return list<array{slot_id: int, capacity: int, remaining: int}>
|
||||
*/
|
||||
public function getAvailability(): array
|
||||
{
|
||||
$fresh = config('slots.availability_cache_fresh_seconds');
|
||||
$ttl = config('slots.availability_cache_ttl_seconds');
|
||||
|
||||
return Cache::flexible(self::AVAILABILITY_CACHE_KEY, [$fresh, $ttl], function (): array {
|
||||
return $this->buildAvailabilityPayload();
|
||||
});
|
||||
}
|
||||
|
||||
private function buildAvailabilityPayload(): array
|
||||
{
|
||||
return Slot::query()
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (Slot $slot): array => [
|
||||
'slot_id' => $slot->id,
|
||||
'capacity' => $slot->capacity,
|
||||
'remaining' => $this->availableRemaining($slot),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function createHold(Slot $slot, string $idempotencyKey): Hold
|
||||
{
|
||||
$existing = $this->findHoldByIdempotencyKey($idempotencyKey);
|
||||
if ($existing !== null) {
|
||||
return $existing->load('slot');
|
||||
}
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($slot, $idempotencyKey): Hold {
|
||||
$lockedSlot = Slot::query()->lockForUpdate()->findOrFail($slot->id);
|
||||
|
||||
if ($this->availableRemaining($lockedSlot) <= 0) {
|
||||
throw new SlotCapacityExceededException('Slot capacity is exhausted.');
|
||||
}
|
||||
|
||||
return Hold::query()->create([
|
||||
'slot_id' => $lockedSlot->id,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'status' => HoldStatus::Held,
|
||||
'expires_at' => now()->addMinutes(config('slots.hold_ttl_minutes')),
|
||||
])->load('slot');
|
||||
});
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$existing = $this->findHoldByIdempotencyKey($idempotencyKey);
|
||||
|
||||
if ($existing === null) {
|
||||
throw new SlotCapacityExceededException('Slot capacity is exhausted.');
|
||||
}
|
||||
|
||||
return $existing->load('slot');
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmHold(Hold $hold): Hold
|
||||
{
|
||||
return DB::transaction(function () use ($hold) {
|
||||
$lockedHold = Hold::query()->lockForUpdate()->findOrFail($hold->id);
|
||||
|
||||
if ($lockedHold->status === HoldStatus::Confirmed) {
|
||||
return $lockedHold->load('slot');
|
||||
}
|
||||
|
||||
if ($lockedHold->status !== HoldStatus::Held || $lockedHold->isExpired()) {
|
||||
throw new HoldNotAvailableException('Hold cannot be confirmed');
|
||||
}
|
||||
|
||||
$slot = Slot::query()->lockForUpdate()->findOrFail($lockedHold->slot_id);
|
||||
|
||||
if ($slot->remaining < 1) {
|
||||
throw new SlotCapacityExceededException('Slot capacity is exhausted.');
|
||||
}
|
||||
|
||||
$slot->remaining--;
|
||||
$slot->save();
|
||||
|
||||
$lockedHold->status = HoldStatus::Confirmed;
|
||||
$lockedHold->save();
|
||||
|
||||
return $lockedHold->load('slot');
|
||||
});
|
||||
}
|
||||
|
||||
public function cancelHold(Hold $hold): Hold
|
||||
{
|
||||
return DB::transaction(function () use ($hold) {
|
||||
$lockedHold = Hold::query()->lockForUpdate()->findOrFail($hold->id);
|
||||
|
||||
if ($lockedHold->status === HoldStatus::Cancelled) {
|
||||
return $lockedHold->load('slot');
|
||||
}
|
||||
|
||||
if ($lockedHold->status === HoldStatus::Confirmed) {
|
||||
$slot = Slot::query()->lockForUpdate()->findOrFail($lockedHold->slot_id);
|
||||
$slot->remaining = min($slot->capacity, $slot->remaining + 1);
|
||||
$slot->save();
|
||||
}
|
||||
|
||||
$lockedHold->status = HoldStatus::Cancelled;
|
||||
$lockedHold->save();
|
||||
|
||||
return $lockedHold->load('slot');
|
||||
});
|
||||
}
|
||||
|
||||
private function availableRemaining(Slot $slot): int
|
||||
{
|
||||
$activeHolds = Hold::query()
|
||||
->where('slot_id', $slot->id)
|
||||
->where('status', HoldStatus::Held)
|
||||
->where('expires_at', '>', now())
|
||||
->count();
|
||||
|
||||
return max(0, $slot->remaining - $activeHolds);
|
||||
}
|
||||
|
||||
private function findHoldByIdempotencyKey(string $idempotencyKey): Hold
|
||||
{
|
||||
return Hold::query()
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user