Перейти к основному содержимому
Мобилка38ГЛАВА 38Корзина и заказ: сервер без проверокNike Air Max · ×3Air Force 1 · ×1итого 41 300 ₽сервер хранит только id и countсумму считаем сами

Корзина и заказ: сервер, который ничего не проверяет

Прочитано0%
Квизы0/4
Уровень 1 · Новичок

Зачем это нужно​

Корзина кажется простой: положил, поменял количество, оформил. На сервере задания она действительно простая — настолько, что это становится проблемой.

Запись корзины состоит из трёх полей: чей это ряд, какой товар и сколько штук. Ни цены, ни названия, ни суммы. И — что важнее — сервер не проверяет ничего: он примет отрицательное количество, дубль одного товара и идентификатор товара, которого не существует.

Значит, вся логика магазина живёт в приложении. Эта глава — про то, как её написать так, чтобы она не разваливалась на демонстрации.

Как выглядит корзина на сервере​

Кладём товар:

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": "ned6nzkcrtqip6s""product_id": "8ezt4dmrek7plpz""count": 3три поля — и всёстрока на экранеNike Air Max 270картинка · 10 500 ₽ × 3= 31 500 ₽названия, цены и суммы на сервере нетитог корзины считает приложение — сервер про деньги не знает вообще
Три поля на сервере против того, что нужно нарисовать на экране

Имена полей — со знаком подчёркивания: 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 — сервер дубли не склеивает
СЕРВЕР ПРИНЯЛ ВСЁ ЭТО С КОДОМ 200тот же товар положили дваждыдве отдельные строки, дубль не склеилсяcount = 0сохранено как 0count = −5сохранено как −5product_id = "net-takogo-tovara"сохранено, товара такого нетпроверки — забота приложения: сервер задания это хранилище строк, а не магазин
Четыре заведомо неверных запроса, и все приняты с кодом 200

Разберём, что это значит для приложения.

Дубли. Повторное нажатие «В корзину» создаёт вторую строку на тот же товар. На экране получится две одинаковые карточки, а сумма посчитается дважды. Лечится так: перед добавлением посмотреть, есть ли уже строка с этим 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, молча — без ошибки, которая подсказала бы, что вы не туда смотрите.

Отвечено вопросов: 0 из 3
Пользователь дважды нажал «В корзину» на одном товаре. Что будет на сервере?
Почему ?expand=product_id не подтягивает товар в корзину?
Отправили count = -5. Что ответит сервер?
Отвечено верно: 0 из 3

Собрать корзину с ценами​

Экрану нужны название, картинка и цена, а в корзине их нет. Напрашивается очевидное решение: пройти по строкам и на каждую сделать запрос товара. Так делать не надо.

ДВА ЗАПРОСА ВМЕСТО N+1GET /cart/records3 строки, только id товаровGET /products/records?filter=(id='a'||id='b'||id='c')все нужные товары одним запросомproducts.associateBy { it.id } → словарьrows.mapNotNull { r -> products[r.productId]?.let { CartLine(...) } }expand здесь не поможет: product_id — обычная строка, а не связь между коллекциямизапрос на каждую строку корзины — это N+1: три товара, четыре похода в сеть
Один запрос за всеми товарами сразу вместо запроса на каждую строку

Три строки в корзине — это четыре запроса вместо двух. Десять строк — одиннадцать. Такое поведение называют «задача 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 на ровном месте. Но молчать об этом не стоит: в журнал такое лучше записать.

Отвечено вопросов: 0 из 3
Корзина из трёх строк. Сколько запросов нужно, чтобы показать её с ценами?
Зачем в сборке строк используется mapNotNull, а не map?
Что делает associateBy { it.id } со списком товаров?
Отвечено верно: 0 из 3

Изменить и удалить​

Количество меняется тем же 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 строк
Важно

Здесь прячется настоящая опасность. Три записи создаются тремя отдельными запросами, и сервер не умеет откатывать их вместе. Если на третьем пропадёт сеть, первые два товара уже заказаны, а корзина частично очищена — состояние, из которого приложение само не выберется.

Полностью защититься нельзя: сервер задания транзакций не предлагает. Но уменьшить вред просто — сначала создать все заказы, и только потом чистить корзину. Тогда при обрыве корзина останется целой, и пользователь повторит оформление, а не будет гадать, что заказалось.

Ещё одна деталь: цену заказа сервер не хранит. Заказ помнит только товар и количество — значит, если цена в каталоге изменится, история заказов покажет новую. Для учебного задания это нормально, но знать об этом полезно: в настоящем магазине цену всегда фиксируют в момент заказа.

Отвечено вопросов: 0 из 3
Заказ из трёх товаров — это…
Почему корзину чистят после создания всех заказов, а не по ходу?
Метод удаления объявили как возвращающий CartResponse. Что произойдёт?
Отвечено верно: 0 из 3

Где живёт корзина​

Отдельный вопрос, который эксперт проверяет почти наверняка: что происходит с корзиной при выходе из приложения и при выходе из аккаунта.

  • Корзина на сервере — та, что мы описали выше. Переживает перезапуск, видна с другого устройства, требует токена.
  • Корзина локальная — в базе устройства. Работает без сети, но не переносится и теряется при переустановке.

Задание требует серверную: запросы корзины есть в спецификации. Но это не значит, что локальная не нужна вовсе — она хорошо работает как кэш: корзина открывается мгновенно из базы, а сетевой ответ её уточняет. Эту схему мы разбирали в главе про кэш.

Совет

При выходе из аккаунта локальную копию надо чистить. Иначе следующий вошедший увидит чужие товары — и это не мелочь, а именно то, что эксперт замечает сразу: вышли, вошли другим пользователем, а корзина прежняя.

Типичные ошибки​

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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоEasy Retrofit 2 Tutorial in Android Studio using Kotlin & CoroutinesIndently
ВидеоRetrofit Android Tutorial - Make API CallsAhmed Guedmioui
ВидеоPagination from Remote API & Local Cache Using Paging3, Compose and Clean ArchitecturePhilipp Lackner
ВидеоRetrofit with Kotlin- the ULTIMATE GUIDECodersee- Kotlin on the backend
ВидеоAndroid Studio (Kotlin) Simple MVVM Retrofit Pagination 3Ferer Atlus

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.