GET /slots/availability, POST /slots/{id}/hold, POST /holds/{id}/confirm
and DELETE /holds/{id} on Laravel 12 and MySQL 8.
- availability cached for 10s behind a single-flight lock with a stale
copy, invalidated after commit on confirm and cancel
- oversell prevented by a conditional decrement, verified by the number
of affected rows rather than a preceding select
- idempotency enforced by a unique index on holds.idempotency_key
- active holds counted with an expires_at filter, so no background
cleanup is required for correctness
- 27 feature tests against MySQL, service documentation, Postman collection
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.4 KiB
Slot Booking API
API бронирования слотов: горячий кеш доступности, временные холды с идемпотентностью, защита от оверсела.
Стек: Laravel 12 (PHP 8.2+), MySQL 8+. Внешних сервисов не требуется — кеш и блокировки хранятся в той же базе.
Описание устройства сервиса — docs/README.md. Коллекция Postman — docs/postman.
Быстрый старт
docker run -d --name stl-mysql \
-e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=slots \
-p 3307:3306 mysql:8
cp .env.example .env && composer install && php artisan key:generate
php artisan migrate --seed
php artisan serve --port=8001
Сидер создаёт три слота с вместимостью 10, 5 и 1. Слот с одним местом позволяет воспроизвести конфликт оверсела двумя запросами.
Тесты
Тесты выполняются против MySQL (см. phpunit.xml), поэтому нужна отдельная база:
docker exec stl-mysql mysql -uroot -psecret -e "CREATE DATABASE IF NOT EXISTS slots_test"
php artisan test
27 тестов, 90 проверок: кеш и его инвалидация, поведение при потере гонки за пересчёт, идемпотентность, истечение холдов, защита от оверсела при конкурентном подтверждении.
Эндпоинты
| Метод | Путь | Ответ |
|---|---|---|
GET |
/slots/availability |
200 — массив слотов |
POST |
/slots/{id}/hold |
201 создан / 200 повтор по тому же ключу / 409 мест нет / 422 некорректный Idempotency-Key / 404 |
POST |
/holds/{id}/confirm |
200 / 409 мест нет либо холд неактивен / 404 |
DELETE |
/holds/{id} |
200 / 404 |
Ошибки приходят в виде {"message": "...", "reason": "<код>"}. Коды: capacity_exhausted, hold_cancelled, hold_expired, idempotency_key_reuse, not_found.
Примеры запросов
Прогон против запущенного сервера. Слот 3 имеет capacity = 1.
Доступность
curl -s http://127.0.0.1:8001/slots/availability
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":1}]
Создание холда
curl -si -X POST http://127.0.0.1:8001/slots/3/hold -H 'Idempotency-Key: 6e517e8e-1470-4a78-bdbd-e2b50776a6a0'
HTTP/1.1 201 Created
{"hold_id":1,"slot_id":3,"status":"held","expires_at":"2026-07-29T18:19:56+00:00","confirmed_at":null,"cancelled_at":null}
Повтор с тем же ключом — тот же холд, 200 вместо 201
curl -si -X POST http://127.0.0.1:8001/slots/3/hold -H 'Idempotency-Key: 6e517e8e-1470-4a78-bdbd-e2b50776a6a0'
HTTP/1.1 200 OK
{"hold_id":1,"slot_id":3,"status":"held","expires_at":"2026-07-29T18:19:56+00:00","confirmed_at":null,"cancelled_at":null}
Второй строки в holds не появляется.
Конфликт при оверселе
curl -si -X POST http://127.0.0.1:8001/slots/3/hold -H 'Idempotency-Key: bab7c4be-3184-40d6-ab90-b4118c40bcb3'
HTTP/1.1 409 Conflict
{"message":"Slot 3 has no remaining capacity.","reason":"capacity_exhausted"}
Переиспользование ключа для другого слота
curl -si -X POST http://127.0.0.1:8001/slots/1/hold -H 'Idempotency-Key: 6e517e8e-1470-4a78-bdbd-e2b50776a6a0'
HTTP/1.1 422 Unprocessable Content
{"message":"Idempotency-Key 6e517e8e-... was already used for a different slot than 1.","reason":"idempotency_key_reuse"}
Подтверждение
curl -si -X POST http://127.0.0.1:8001/holds/1/confirm
HTTP/1.1 200 OK
{"hold_id":1,"slot_id":3,"status":"confirmed","expires_at":"2026-07-29T18:19:56+00:00","confirmed_at":"2026-07-29T18:14:57+00:00","cancelled_at":null}
Кеш инвалидирован, остаток слота 3 обновился сразу:
curl -s http://127.0.0.1:8001/slots/availability
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":0}]
Повторный confirm того же холда возвращает 200 и тот же результат — место списывается один раз.
Отмена
curl -si -X DELETE http://127.0.0.1:8001/holds/1
HTTP/1.1 200 OK
{"hold_id":1,"slot_id":3,"status":"cancelled","expires_at":"2026-07-29T18:19:56+00:00","confirmed_at":"2026-07-29T18:14:57+00:00","cancelled_at":"2026-07-29T18:14:57+00:00"}
curl -s http://127.0.0.1:8001/slots/availability
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":1}]
Подтвердить отменённый холд уже нельзя:
curl -si -X POST http://127.0.0.1:8001/holds/1/confirm
HTTP/1.1 409 Conflict
{"message":"Hold 1 is no longer active and cannot be confirmed.","reason":"hold_cancelled"}
Структура
app/Enums/HoldStatus.php held | confirmed | cancelled
app/Exceptions/SlotBookingException.php доменные отказы и их HTTP-статус
app/Http/Controllers/AvailabilityController.php
app/Http/Controllers/HoldController.php
app/Http/Requests/StoreHoldRequest.php валидация заголовка Idempotency-Key
app/Http/Resources/HoldResource.php
app/Models/Slot.php
app/Models/Hold.php scopeActive() — определение активного холда
app/Services/SlotService.php транзакции, кеш, идемпотентность
config/slots.php TTL кеша и TTL холда
routes/api.php
docs/README.md устройство сервиса
docs/postman/ коллекция Postman