Backend для тестового задания сервиса продажи билетов в кино.
Сервис показывает список киносеансов с количеством свободных мест, создаёт временную бронь на время оплаты, подтверждает оплату и освобождает место при отмене или истечении срока брони.
- список будущих киносеансов с актуальным количеством свободных мест
- временная бронь места на 2 минуты
- имитация успешной оплаты
- отмена временной брони при закрытии формы
- автоматическое истечение неиспользованных броней
- защита от конкурентного бронирования последнего места
- API без авторизации
- Docker-окружение с PostgreSQL, Redis, Nginx и PHP-FPM
- PHP 8.3
- Laravel 12
- PostgreSQL 16
- Redis 7
- Nginx
- Docker Compose
- Pest
- Laravel Pint
- Docker Desktop с включённой интеграцией WSL
- Docker Compose
cp .env.example .envПроверь, что в .env указаны настройки:
APP_NAME="Cinema Ticket API"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8080
FRONTEND_URL=http://localhost:3000
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=cinema_ticket
DB_USERNAME=cinema
DB_PASSWORD=cinema_secret
CACHE_STORE=redis
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379docker compose up -d --builddocker compose exec app php artisan migrate:fresh --seedAPI будет доступно по адресу:
http://localhost:8080/api/v1
Проверка работоспособности:
curl http://localhost:8080/api/v1/healthОжидаемый ответ:
{
"status": "ok"
}| Сервис | Назначение |
|---|---|
nginx |
принимает HTTP-запросы на localhost:8080 |
app |
Laravel и PHP-FPM |
scheduler |
запускает Laravel Scheduler |
postgres |
основная PostgreSQL-база |
redis |
кэш и distributed locks для scheduler |
Проверить состояние:
docker compose psПросмотреть логи:
docker compose logs -f app
docker compose logs -f nginx
docker compose logs -f schedulerОстановить окружение:
docker compose downПолностью удалить контейнеры, volumes и данные PostgreSQL:
docker compose down -vДля Feature-тестов используется отдельная база cinema_ticket_test.
Создать её один раз:
docker compose exec postgres psql -U cinema -d postgres \
-c "CREATE DATABASE cinema_ticket_test OWNER cinema;"Если база уже существует, PostgreSQL сообщит об этом. Это нормально.
Запуск всех тестов:
docker compose exec app ./vendor/bin/pestТолько unit-тесты:
docker compose exec app ./vendor/bin/pest --testsuite=UnitПроверка форматирования:
docker compose exec app ./vendor/bin/pint --testАвтоматическое исправление форматирования:
docker compose exec app ./vendor/bin/pintВсе ответы возвращаются в JSON. Все даты передаются в ISO 8601 и UTC.
Базовый URL:
http://localhost:8080/api/v1
GET /screeningsПример ответа:
[
{
"id": 1,
"title": "Интерстеллар",
"startsAt": "2026-06-26T16:00:00Z",
"totalSeats": 10,
"availableSeats": 8
}
]availableSeats рассчитывается на backend и учитывает:
- оплаченные брони
- активные временные брони
- истёкшие и отменённые брони не занимают места
Вызывается в момент нажатия пользователем на кнопку оформления билета.
POST /screenings/{screeningId}/reservations
Accept: application/jsonТело запроса не требуется.
Успешный ответ 201 Created:
{
"id": "56f96ec8-8bd9-41dc-9c34-a785dfdb9f6e",
"reservationToken": "секретный-токен-из-64-символов",
"expiresAt": "2026-06-26T16:02:00Z"
}Возможные ошибки:
| Код | Причина |
|---|---|
404 |
сеанс не найден |
409 |
сеанс уже начался |
409 |
свободных мест не осталось |
Реальный эквайринг в рамках тестового задания не подключён. Запрос имитирует успешную оплату.
POST /reservations/{reservationId}/pay
Accept: application/json
Content-Type: application/json
X-Reservation-Token: {reservationToken}Тело:
{
"name": "Иван Иванов",
"email": "ivan@example.com"
}Успешный ответ 200 OK:
{
"id": "56f96ec8-8bd9-41dc-9c34-a785dfdb9f6e",
"status": "paid",
"paidAt": "2026-06-26T16:01:15Z"
}Ограничения валидации:
- имя от 2 до 80 символов
- email в корректном формате, максимум 254 символа
X-Reservation-Tokenобязателен и должен содержать 64 hex-символа
Вызывается при закрытии формы до оплаты.
DELETE /reservations/{reservationId}
Accept: application/json
X-Reservation-Token: {reservationToken}Успешный ответ 200 OK:
{
"id": "56f96ec8-8bd9-41dc-9c34-a785dfdb9f6e",
"status": "cancelled"
}Если пользователь закрыл вкладку и запрос не успел выполниться, место автоматически освободится после истечения срока брони.
Ошибки валидации Laravel возвращаются в формате:
{
"message": "The given data was invalid.",
"errors": {
"name": [
"The name field is required."
]
}
}Ошибки бизнес-правил:
{
"message": "Свободных мест на сеанс не осталось."
}Проект разделён на четыре слоя:
app/
├── Domain/
├── Application/
├── Infrastructure/
└── Presentation/
Содержит чистые бизнес-правила и не зависит от Laravel.
Примеры:
- статусы брони
pending,paid,expired,cancelled - проверка срока действия брони
- переход брони в статус оплаты или отмены
- проверка доступности мест
- запрет бронирования прошедшего сеанса
Содержит сценарии системы и контракты, через которые сценарии обращаются к внешнему миру.
Примеры:
- создание временной брони
- оплата брони
- отмена брони
- истечение брони
- список сеансов
Application не знает о Eloquent, HTTP-контроллерах и конкретной базе данных.
Содержит технические реализации контрактов Application.
Примеры:
- Eloquent-модели
- PostgreSQL-запросы
SELECT ... FOR UPDATE- генерация UUID
- генерация токенов
- Redis и Laravel Scheduler
- транзакции Laravel
Содержит внешние точки входа.
Примеры:
- HTTP-контроллеры
- Form Request
- JSON Resource
- Artisan-команда очистки броней
Направление зависимостей:
Presentation → Application → Domain
Infrastructure → Application
Eloquent-модели намеренно находятся только в Infrastructure, потому что они являются техническим представлением данных в PostgreSQL, а не бизнес-моделью.
Документация схемы находится в файле:
docs/database.dbml
Его можно вставить в dbdiagram.io.
В базе есть две основные таблицы:
screenings
reservations
screenings хранит название сеанса, время начала и общий лимит мест.
reservations хранит бронь, её статус, срок действия, данные покупателя и SHA-256 хэш токена управления бронью.
Токен возвращается клиенту только при создании брони. В базе хранится только его хэш.
Ключевая задача проекта - не допустить, чтобы несколько пользователей заняли одно последнее место.
При создании брони выполняется следующий сценарий внутри одной транзакции:
начать транзакцию
→ заблокировать строку сеанса через SELECT FOR UPDATE
→ пометить истёкшие pending-брони как expired
→ посчитать занятые места
→ проверить лимит мест
→ создать новую pending-бронь
→ завершить транзакцию
Все операции, влияющие на бронь, используют одинаковый порядок блокировки:
screening → reservation
Это исключает ситуацию, когда два параллельных запроса одновременно увидят последнее свободное место.
Проверка доступности не хранится в отдельном поле available_seats. Значение всегда рассчитывается из общего лимита сеанса и активных броней, поэтому не может рассинхронизироваться.
Временная бронь действует 2 минуты.
Даже если фоновая очистка временно не сработала, бронь перестаёт занимать место сразу после expires_at.
Laravel Scheduler раз в минуту запускает команду:
php artisan reservations:expireОна переводит все просроченные pending-брони в статус expired.
Проверить расписание:
docker compose exec app php artisan schedule:listЗапустить очистку вручную:
docker compose exec app php artisan reservations:expireВ .env.local frontend необходимо указать:
NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1Frontend должен работать по следующему сценарию:
Нажатие «Оформить билет»
→ POST /screenings/{id}/reservations
→ получить reservationToken
→ открыть форму и показать таймер
Нажатие «Оплатить»
→ POST /reservations/{id}/pay
→ показать успешный результат
→ не отправлять DELETE
Закрытие формы до оплаты
→ DELETE /reservations/{id}
→ обновить список сеансов
В рамках тестового задания не добавлялись:
- авторизация пользователей
- реальная интеграция с эквайрингом
- выбор конкретного кресла
- личный кабинет
- email-уведомления
- сложный дизайн интерфейса
Эти ограничения соответствуют требованиям тестового задания и не влияют на основной сценарий безопасного временного бронирования.
