Корзина и заказ: сервер, который ничего не проверяет
Зачем это нужно
Корзина кажется простой: положил, поменял количество, оформил. На сервере задания она действительно простая — настолько, что это становится проблемой.
Запись корзины состоит из трёх полей: чей это ряд, какой товар и сколько штук. Ни цены, ни названия, ни суммы. И — что важнее — сервер не проверяет ничего: он примет отрицательное количество, дубль одного товара и идентификатор товара, которого не существует.
Значит, вся логика магазина живёт в приложении. Эта глава — про то, как её написать так, чтобы она не разваливалась на демонстрации.
Как выглядит корзина на сервере
Кладём товар:
POST /api/collections/cart/records
Authorization: eyJhbGci…
{"user_id":"ned6nzkcrtqip6s","product_id":"8ezt4dmrek7plpz","count":1}
Ответ:
{
"collectionId": "pbc_749661959",
"collectionName": "cart",
"count": 1,
"id": "6o3thgw5kb6tpdm",
"product_id": "8ezt4dmrek7plpz",
"user_id": "ned6nzkcrtqip6s"
}
Имена полей — со знаком подчёркивания: user_id, product_id. В Kotlin так писать не принято, поэтому имена связывают аннотацией:
@Serializable
data class CartRequest(
@SerialName("user_id") val userId: String,
@SerialName("product_id") val productId: String,
val count: Int,
)
user_id приходится передавать самому, хотя сервер и так знает, кто мы, — по токену. Правила стенда это проверяют: запрос с чужим user_id отклоняется. Значит, идентификатор нужно где-то хранить — он приходит в ответе на вход, в поле record.id, и кладётся в хранилище рядом с токеном.
Чего сервер не проверяет
Вот живой прогон, где мы нарочно отправляли ерунду. Все четыре запроса прошли:
── что сервер НЕ проверяет ──
count=0 принят: 0
count=-5 принят: -5
несуществующий product_id принят: net-takogo-tovara
── тот же товар ещё раз ──
создана ВТОРАЯ строка p5qmpbp3zswpefk на тот же product_id — сервер дубли не склеивает
Разберём, что это значит для приложения.
Дубли. Повторное нажатие «В корзину» создаёт вторую строку на тот же товар. На экране получится две одинаковые карточки, а сумма посчитается дважды. Лечится так: перед добавлением посмотреть, есть ли уже строка с этим product_id, и если есть — не создавать новую, а увеличить count у существующей.
suspend fun addToCart(productId: String) {
val existing = api.cart().items.firstOrNull { it.productId == productId }
if (existing == null) {
api.addToCart(CartRequest(userId, productId, 1))
} else {
api.changeCart(existing.id, CountRequest(existing.count + 1))
}
}
Нулевое и отрицательное количество. Кнопка «минус» без нижней границы доведёт count до нуля и дальше в минус, а сумма корзины станет отрицательной. Граница ставится на клиенте:
val next = (row.count - 1).coerceAtLeast(1)
Либо — что честнее — при попытке уйти ниже единицы предлагать удалить строку целиком.
Фантомный товар. product_id в коллекции корзины — это обычная текстовая строка, а не связь между коллекциями. Сервер не сверяет её с каталогом. Поэтому в корзине может оказаться строка, для которой товара уже нет — например, его убрали из каталога.
Из «product_id — просто строка» следует ещё одно: параметр ?expand=product_id не работает. Он подтягивает связанные записи только для настоящих полей-связей, а здесь связи нет. Ответ приходит без ключа expand, молча — без ошибки, которая подсказала бы, что вы не туда смотрите.
Собрать корзину с ценами
Экрану нужны название, картинка и цена, а в корзине их нет. Напрашивается очевидное решение: пройти по строкам и на каждую сделать запрос товара. Так делать не надо.
Три строки в корзине — это четыре запроса вместо двух. Десять строк — одиннадцать. Такое поведение называют «задача N+1», и на слабой сети оно превращает открытие корзины в несколько секунд ожидания.
Правильно — забрать все нужные товары одним запросом, собрав фильтр из идентификаторов:
val rows = api.cart().items
val filter = rows.map { it.productId }.distinct().joinToString(" || ") { "id='$it'" }
val products = api.catalog(perPage = 200, filter = "($filter)").items.associateBy { it.id }
Дальше соединяем строки корзины с товарами. Это можно проверить прямо здесь — данные в примере те, что реально пришли со стенда:
data class ProductDto(val id: String, val title: String, val price: Int)
data class CartRow(val id: String, val productId: String, val count: Int)
data class CartLine(val rowId: String, val product: ProductDto, val count: Int) {
val sum: Int get() = product.price * count
}
// то, что реально пришло со стенда
val rows = listOf(
CartRow("eg6e0ltcmdzzrd3", "8ezt4dmrek7plpz", 3),
CartRow("bgfq7b2bp2vti6n", "auzbdtixz85k04y", 1),
CartRow("q7fm6be7umq2ogq", "s2q9xhzswvq1e8v", 1),
CartRow("4kp9y96b1p2kq9v", "net-takogo-tovara", 1),
)
val products = listOf(
ProductDto("8ezt4dmrek7plpz", "Nike Air Max 270", 10500),
ProductDto("auzbdtixz85k04y", "Nike Air Force 1", 9800),
ProductDto("s2q9xhzswvq1e8v", "Adidas Superstar", 8700),
).associateBy { it.id }
fun main() {
val lines = rows.mapNotNull { r ->
products[r.productId]?.let { CartLine(r.id, it, r.count) }
}
lines.forEach { println("${it.product.title} × ${it.count} = ${it.sum} ₽") }
println()
println("строк в корзине: ${rows.size}, показано: ${lines.size}")
println("потеряно строк с неизвестным товаром: ${rows.size - lines.size}")
println("итого: ${lines.sumOf { it.sum }} ₽ за ${lines.sumOf { it.count }} шт.")
println()
val filter = rows.map { it.productId }.distinct().joinToString(" || ") { "id='$it'" }
println("один запрос вместо ${rows.size}:")
println(" ?filter=($filter)")
}
Настоящий вывод:
Nike Air Max 270 × 3 = 31500 ₽
Nike Air Force 1 × 1 = 9800 ₽
Adidas Superstar × 1 = 8700 ₽
строк в корзине: 4, показано: 3
потеряно строк с неизвестным товаром: 1
итого: 50000 ₽ за 5 шт.
Обратите внимание на mapNotNull и на строку «потеряно строк: 1». Фантомная строка не роняет экран — она просто не показывается. Это сознательное решение: лучше показать корзину без одной позиции, чем упасть с NullPointerException на ровном месте. Но молчать об этом не стоит: в журнал такое лучше записать.
Изменить и удалить
Количество меняется тем же PATCH, что и профиль, — приходит вся обновлённая запись:
@PATCH("collections/cart/records/{id}")
suspend fun changeCart(@Path("id") id: String, @Body body: CountRequest): CartResponse
Удаления из корзины в спецификации задания нет — среди четырнадцати запросов его не значится. Но приложению оно нужно: иначе товар из корзины не убрать, а после оформления заказа она останется полной. PocketBase удаление поддерживает, и устроено оно иначе прочих — ответ приходит пустым.
@DELETE("collections/cart/records/{id}")
suspend fun removeFromCart(@Path("id") id: String)
Сервер отвечает на удаление кодом 204 No Content и пустым телом. Если объявить метод как возвращающий что-нибудь — скажем, : CartResponse — разбор упадёт на пустой строке, хотя удаление на самом деле прошло. Для таких ответов результат либо Unit, либо Response<Unit>, если нужен код.
Живой прогон изменения и удаления:
── меняем количество ──
Nike Air Max 270: было 1, стало 3
── тот же товар ещё раз ──
создана ВТОРАЯ строка p5qmpbp3zswpefk на тот же product_id — сервер дубли не склеивает
удалили её: DELETE вернул 204 без тела
Оформление заказа
Коллекция orders устроена ровно так же, как корзина: user_id, product_id, count. То есть заказ из трёх товаров — это три записи, а не одна запись со списком.
val rows = api.cart().items
rows.forEach { row ->
api.createOrder(OrderRequest(userId, row.productId, row.count))
api.removeFromCart(row.id)
}
Живой прогон:
── оформляем заказ ──
заказ w4opi2roqlc3xvv: product_id=8ezt4dmrek7plpz, count=3
заказ 6arb5s5t5na4m8e: product_id=auzbdtixz85k04y, count=1
заказ 9cc9ztswhkz8kc3: product_id=s2q9xhzswvq1e8v, count=1
корзина после оформления: 0 строк
Здесь прячется настоящая опасность. Три записи создаются тремя отдельными запросами, и сервер не умеет откатывать их вместе. Если на третьем пропадёт сеть, первые два товара уже заказаны, а корзина частично очищена — состояние, из которого приложение само не выберется.
Полностью защититься нельзя: сервер задания транзакций не предлагает. Но уменьшить вред просто — сначала создать все заказы, и только потом чистить корзину. Тогда при обрыве корзина останется целой, и пользователь повторит оформление, а не будет гадать, что заказалось.
Ещё одна деталь: цену заказа сервер не хранит. Заказ помнит только товар и количество — значит, если цена в каталоге изменится, история заказов покажет новую. Для учебного задания это нормально, но знать об этом полезно: в настоящем магазине цену всегда фиксируют в момент заказа.
Где живёт корзина
Отдельный вопрос, который эксперт проверяет почти наверняка: что происходит с корзиной при выходе из приложения и при выходе из аккаунта.
- Корзина на сервере — та, что мы описали выше. Переживает перезапуск, видна с другого устройства, требует токена.
- Корзина локальная — в базе устройства. Работает без сети, но не переносится и теряется при переустановке.
Задание требует серверную: запросы корзины есть в спецификации. Но это не значит, что локальная не нужна вовсе — она хорошо работает как кэш: корзина открывается мгновенно из базы, а сетевой ответ её уточняет. Эту схему мы разбирали в главе про кэш.
При выходе из аккаунта локальную копию надо чистить. Иначе следующий вошедший увидит чужие товары — и это не мелочь, а именно то, что эксперт замечает сразу: вышли, вошли другим пользователем, а корзина прежняя.
Типичные ошибки
1. Не передали user_id. Сервер знает нас по токену, но поле всё равно обязательно, и правила стенда сверяют его с владельцем токена. Чужой user_id даёт 400 «Failed to create record.».
2. Запрос товара на каждую строку корзины. Работает и даже выглядит правильно — пока строк три. Правильный приём — один запрос с фильтром по всем идентификаторам.
3. ?expand=product_id. Не сработает и не пожалуется: product_id не связь, а строка.
4. Сумма считается по approximateCost. Это строка "10 500 ₽" с неразрывным пробелом — умножать её на количество нельзя. Для арифметики есть числовое price.
5. Метод удаления объявлен с типом результата. Ответ пустой, разбор упадёт после успешного удаления — и приложение покажет ошибку там, где всё получилось.
6. Корзина чистится по ходу оформления. Обрыв связи посередине оставит половину заказанного и половину корзины.
7. Заказ без токена. Даёт 400 «Failed to create record.», а не 401 — ветка «нужно войти» по коду ответа снова не сработает.
Что засчитывает эксперт
В этой главе закрыты три запроса из спецификации: создание корзины, изменение количества и создание заказа. Проверяют так:
- товар добавляется, и счётчик на значке корзины меняется;
- повторное добавление не создаёт вторую такую же карточку;
- плюс и минус меняют количество, сумма пересчитывается;
- ниже единицы количество не уходит;
- заказ оформляется, корзина после этого пуста;
- после перезапуска приложения корзина на месте.
Счётчик на значке корзины — частая потеря балла не из-за сети, а из-за состояния: его забывают обновить после добавления с экрана товара. Экран корзины при этом работает правильно. Лечится тем же приёмом, что и всё остальное в приложении: счётчик читается из одного источника, на который подписаны оба экрана.
Куда углубиться
- Оптимистичное обновление: менять количество на экране сразу, а запрос отправлять следом — и откатывать, если он не прошёл.
- Гашение частых нажатий (
debounce): десять нажатий «плюс» подряд не должны дать десять запросов. Roomкак кэш корзины и подписка экрана на базу вместо ответа сети.- Фиксация цены в момент заказа — как это делают настоящие магазины и почему.
- Пакетные запросы PocketBase (
/api/batch) — способ отправить несколько изменений одним обращением.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- PocketBase — создание и изменение записей — тела запросов, коды ответов и поведение DELETE.
- Синтаксис фильтров PocketBase — операторы
||,&&и правила кавычек. - Retrofit — аннотации запросов —
@DELETE,@Body,@Pathи пустые ответы. - Kotlin — коллекции —
associateBy,mapNotNull,sumOfи почему словарь быстрее перебора.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.