ОБЕСПЕЧЕНИЕ СОГЛАСОВАННОСТИ БРОНИРОВАНИЙ В P2P-СИСТЕМЕ АРЕНДЫ СПОРТИВНОГО ИНВЕНТАРЯ: КЛИЕНТ-СЕРВЕРНАЯ РЕАЛИЗАЦИЯ
Конференция: CCCLIV Студенческая международная научно-практическая конференция «Молодежный научный форум»
Секция: Технические науки

CCCLIV Студенческая международная научно-практическая конференция «Молодежный научный форум»
ОБЕСПЕЧЕНИЕ СОГЛАСОВАННОСТИ БРОНИРОВАНИЙ В P2P-СИСТЕМЕ АРЕНДЫ СПОРТИВНОГО ИНВЕНТАРЯ: КЛИЕНТ-СЕРВЕРНАЯ РЕАЛИЗАЦИЯ
ВВЕДЕНИЕ
В клиент-серверной системе аренды бронирование связывает арендатора, позицию каталога, конкретный физический экземпляр, временной интервал и стоимость. Для P2P-платформы корректность этой операции критична: один и тот же объект одновременно просматривается несколькими пользователями, а информация на экране Android-приложения может устареть между загрузкой карточки и отправкой запроса. Поэтому мобильный клиент выполняет только предварительную проверку, тогда как окончательное решение о доступности принимает сервер.
Цель статьи заключается в технической конкретизации механизма согласованного бронирования для Android-клиента на Kotlin и серверной части на Spring Boot. Рассматриваются структура REST-запроса, транзакционная обработка, выбор свободного экземпляра в PostgreSQL, защита от пересечения интервалов и преобразование серверного конфликта в состояние пользовательского интерфейса. Временной период задаётся полуинтервалом [s, e): начало включается, окончание не включается. Для нового периода [sₙ, eₙ) и существующего [sₐ, eₐ) пересечение существует при sₙ < eₐ и eₙ > sₐ.
АРХИТЕКТУРА ОПЕРАЦИИ БРОНИРОВАНИЯ
На Android последовательность компонентов имеет вид Jetpack Compose → BookingViewModel → CreateBookingUseCase → BookingRepository → Retrofit. Экран передаёт во ViewModel выбранные дату и время; ViewModel проверяет, что начало предшествует окончанию, переводит интерфейс в состояние загрузки и вызывает доменный сценарий. Репозиторий формирует POST-запрос к ресурсу /api/bookings, а OkHttp-интерцептор добавляет заголовок Authorization: Bearer <token>. Идентификатор пользователя в теле запроса не передаётся: сервер получает его из проверенного JSON Web Token, что исключает подмену арендатора.
Запрос содержит идентификатор позиции инвентаря, начало и окончание аренды и requestId — UUID, создаваемый один раз при нажатии кнопки. Повторная отправка того же requestId после сетевого сбоя не должна создавать вторую запись. На сервере обработка проходит через BookingController, BookingService и репозитории Spring Data JPA. Контроллер выполняет валидацию формата и возвращает HTTP-ответ, сервис содержит бизнес-правила, а репозитории работают с сущностями inventory_items, inventory_units и bookings. Разделение карточки позиции и экземпляра позволяет серверу назначить один из нескольких одинаковых объектов, реально свободный в заданный период.
Интерфейс Retrofit объявляет suspend-метод @POST("api/bookings") с телом CreateBookingRequest и заголовком Idempotency-Key. Ответ обрабатывается как Response<BookingResponse>, чтобы репозиторий мог различать 201, 400, 401 и 409 без передачи HTTP-деталей в пользовательский интерфейс.
РЕАЛИЗАЦИЯ НА СЕРВЕРНОЙ СТОРОНЕ
Метод BookingService.createBooking помечается @Transactional. Внутри одной транзакции выполняются: проверка startTime < endTime; получение активной позиции каталога и её цены; выбор свободного экземпляра; расчёт стоимости на сервере; сохранение Booking со статусом CREATED. Цена из Android-запроса не принимается, поскольку клиентские данные не являются доверенными. Для дат используется единый формат ISO 8601, а на уровне PostgreSQL — тип timestamp with time zone, что исключает неоднозначность при сравнении периодов.
Листинг 1. Транзакционный метод сервисного слоя
@Transactional
fun createBooking(userId: Long, r: CreateBookingRequest): BookingDto {
validateInterval(r)
bookingRepo.findByRequest(userId, r.requestId)?.let { return it.toDto() }
val unitId = unitRepo.lockFreeUnit(r) ?: throw BookingConflictException()
val booking = Booking(userId, unitId, r, priceService.calculate(r), CREATED)
return bookingRepo.save(booking).toDto()
}
Свободный экземпляр выбирается нативным запросом с блокировкой строк. Условие NOT EXISTS исключает единицы, для которых имеется бронирование в блокирующем статусе CREATED, PAID или ISSUED и выполняется условие пересечения. Конструкция FOR UPDATE SKIP LOCKED не позволяет параллельной транзакции выбрать уже захваченный экземпляр; ORDER BY задаёт одинаковый порядок блокировок и снижает вероятность взаимной блокировки. Если строка не найдена, сервис возбуждает BookingConflictException.
Листинг 2. Выбор и блокировка свободного экземпляра
SELECT iu.id FROM inventory_units iu
WHERE iu.item_id = :itemId AND iu.status = 'AVAILABLE'
AND NOT EXISTS (SELECT 1 FROM bookings b
WHERE b.inventory_unit_id = iu.id
AND b.status IN ('CREATED','PAID','ISSUED')
AND b.start_time < :endTime AND b.end_time > :startTime)
ORDER BY iu.id FOR UPDATE SKIP LOCKED LIMIT 1;
При успешном сохранении контроллер возвращает 201 Created и объект {bookingId, status, unitId, totalPrice}. Исключение об отсутствии экземпляра преобразуется классом @RestControllerAdvice в 409 Conflict с кодом BOOKING_INTERVAL_CONFLICT. Нарушение уникальности requestId обрабатывается как повтор запроса: сервер находит ранее созданную операцию по паре renter_id и request_id и возвращает её результат. Это обеспечивает идемпотентность при тайм-ауте, когда ответ потерян, но транзакция уже зафиксирована.
Прикладная проверка дополняется инвариантом базы данных. В миграции PostgreSQL создаётся GiST-ограничение, запрещающее пересечение диапазонов для одной inventory_unit_id. Оно является последним уровнем защиты: даже при ошибке в сервисном коде противоречивые записи не фиксируются. Возникшее DataIntegrityViolationException также преобразуется в 409 Conflict [3–5].
Листинг 3. Ограничение пересекающихся периодов в PostgreSQL
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (inventory_unit_id WITH =,
tstzrange(start_time, end_time, '[)') WITH &&)
WHERE (status IN ('CREATED', 'PAID', 'ISSUED'));
ОБРАБОТКА РЕЗУЛЬТАТА В ANDROID-ПРИЛОЖЕНИИ
BookingViewModel хранит экранное состояние в StateFlow. Используются состояния Editing, Loading, Success, Conflict и Error. Jetpack Compose подписывается на поток через collectAsStateWithLifecycle; при Loading кнопка бронирования блокируется и отображается индикатор. Запрос запускается в viewModelScope, поэтому сетевая операция не блокирует главный поток, а состояние сохраняется при пересоздании Activity [1], [2].
BookingRepositoryImpl вызывает Retrofit API и преобразует ответ в доменный результат. Код 201 формирует BookingResult.Success, 409 — BookingResult.Conflict, 401 — Unauthorized, остальные ошибки — Failure. Благодаря этому ViewModel не зависит от Retrofit и может тестироваться с fake-репозиторием.
Листинг 4. Изменение состояния экрана во ViewModel
fun submit(draft: BookingDraft) = viewModelScope.launch {
uiState.value = Loading(draft)
when (val result = createBookingUseCase(draft)) {
is Success -> uiState.value = Success(result.booking)
is Conflict -> { availabilityRepository.refresh(draft.inventoryId);
uiState.value = Conflict(draft) }
is Unauthorized -> events.emit(OpenLogin)
is Failure -> uiState.value = Error(draft)
}
}
В состоянии Conflict приложение показывает сообщение «Интервал уже занят», повторно загружает доступность и сохраняет выбранные параметры, чтобы пользователь изменил только время. При 401 выполняется обновление токена либо переход к авторизации; при сетевой ошибке запрос повторяется с тем же requestId. Локальный кэш Room используется для каталога и истории, но не является источником истины при подтверждении доступности.
ПРОВЕРКА КОНКУРЕНТНЫХ СЦЕНАРИЕВ
Проверка включает граничные интервалы и параллельные обращения. Периоды 10:00–12:00 и 12:00–14:00 допустимы, поскольку общая граница не образует пересечения; запросы 10:00–12:00 и 11:00–13:00 конфликтуют. В интеграционном сценарии два потока одновременно отправляют одинаковые POST-запросы для позиции с одним экземпляром. После снятия синхронизирующего барьера один запрос фиксирует Booking и получает 201, второй не находит свободную строку либо нарушает исключающее ограничение и получает 409. Проверка базы данных подтверждает наличие одной активной записи. Для корзины блокировки выполняются по возрастанию идентификаторов, а отсутствие хотя бы одной единицы приводит к откату всего заказа.
ЗАКЛЮЧЕНИЕ
Согласованность бронирований обеспечивается тремя уровнями: Android-клиент валидирует ввод и отображает конфликт; Spring Boot выполняет авторизацию, выбор экземпляра, расчёт стоимости и сохранение в одной транзакции; PostgreSQL блокирует конкурентный выбор и запрещает пересечение диапазонов. Решение позволяет предсказуемо обрабатывать одновременные запросы и не зависит от состояния мобильного интерфейса.





