From 8d21cfb983677008d853551fe61d74b04cab9fd2 Mon Sep 17 00:00:00 2001 From: rinsvent Date: Sun, 17 May 2026 20:58:40 +0700 Subject: [PATCH] feat: add service logic --- app/Exceptions/HoldNotAvailableException.php | 9 ++ .../SlotCapacityExceededException.php | 9 ++ app/Services/SlotService.php | 137 ++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 app/Exceptions/HoldNotAvailableException.php create mode 100644 app/Exceptions/SlotCapacityExceededException.php diff --git a/app/Exceptions/HoldNotAvailableException.php b/app/Exceptions/HoldNotAvailableException.php new file mode 100644 index 0000000..c6418fa --- /dev/null +++ b/app/Exceptions/HoldNotAvailableException.php @@ -0,0 +1,9 @@ + + */ + 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(); + } }