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>
185 lines
6.4 KiB
Markdown
185 lines
6.4 KiB
Markdown
# Slot Booking API
|
||
|
||
API бронирования слотов: горячий кеш доступности, временные холды с идемпотентностью, защита от оверсела.
|
||
|
||
**Стек:** Laravel 12 (PHP 8.2+), MySQL 8+. Внешних сервисов не требуется — кеш и блокировки хранятся в той же базе.
|
||
|
||
Описание устройства сервиса — [docs/README.md](docs/README.md). Коллекция Postman — [docs/postman](docs/postman/slot-booking-api.postman_collection.json).
|
||
|
||
## Быстрый старт
|
||
|
||
```bash
|
||
docker run -d --name stl-mysql \
|
||
-e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=slots \
|
||
-p 3307:3306 mysql:8
|
||
```
|
||
|
||
```bash
|
||
cp .env.example .env && composer install && php artisan key:generate
|
||
```
|
||
|
||
```bash
|
||
php artisan migrate --seed
|
||
```
|
||
|
||
```bash
|
||
php artisan serve --port=8001
|
||
```
|
||
|
||
Сидер создаёт три слота с вместимостью 10, 5 и 1. Слот с одним местом позволяет воспроизвести конфликт оверсела двумя запросами.
|
||
|
||
## Тесты
|
||
|
||
Тесты выполняются против MySQL (см. `phpunit.xml`), поэтому нужна отдельная база:
|
||
|
||
```bash
|
||
docker exec stl-mysql mysql -uroot -psecret -e "CREATE DATABASE IF NOT EXISTS slots_test"
|
||
```
|
||
|
||
```bash
|
||
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`.
|
||
|
||
### Доступность
|
||
|
||
```bash
|
||
curl -s http://127.0.0.1:8001/slots/availability
|
||
```
|
||
|
||
```json
|
||
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":1}]
|
||
```
|
||
|
||
### Создание холда
|
||
|
||
```bash
|
||
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`
|
||
|
||
```bash
|
||
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` не появляется.
|
||
|
||
### Конфликт при оверселе
|
||
|
||
```bash
|
||
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"}
|
||
```
|
||
|
||
### Переиспользование ключа для другого слота
|
||
|
||
```bash
|
||
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"}
|
||
```
|
||
|
||
### Подтверждение
|
||
|
||
```bash
|
||
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` обновился сразу:
|
||
|
||
```bash
|
||
curl -s http://127.0.0.1:8001/slots/availability
|
||
```
|
||
|
||
```json
|
||
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":0}]
|
||
```
|
||
|
||
Повторный `confirm` того же холда возвращает `200` и тот же результат — место списывается один раз.
|
||
|
||
### Отмена
|
||
|
||
```bash
|
||
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"}
|
||
```
|
||
|
||
```bash
|
||
curl -s http://127.0.0.1:8001/slots/availability
|
||
```
|
||
|
||
```json
|
||
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5},{"slot_id":3,"capacity":1,"remaining":1}]
|
||
```
|
||
|
||
Подтвердить отменённый холд уже нельзя:
|
||
|
||
```bash
|
||
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
|
||
```
|