Skip to content

ТЗ — Корзина (cms/commerce-cart)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: masha Статус: ТЗ к разработке

Назначение и возможности

Сессионная (гость) и персистентная (пользователь) корзина с merge при логине. Позиции всегда по variant_id (см. /cms-v2/commerce-model). Пересчёт стоимости идёт через резолвер цены и фильтр скидок, с проверкой доступности остатка.

  • Сессионная корзина гостя (session token) + персистентная корзина авторизованного пользователя
  • Merge корзин при логине (слияние позиций гостя и сохранённой корзины пользователя)
  • Позиции корзины строго по variant_id, не по product_id
  • Пересчёт цены позиции через резолвер cms/commerce-pricing
  • Применение скидок фильтром через rule-engine cms/commerce-promo
  • Резерв-проверка остатков при добавлении/пересчёте (без резервирования — только проверка доступности)
  • Мини-корзина виджетом (счётчик, сумма, превью позиций)
  • Публичное API корзины без авторизации (session token, rate-limit)

Зависимости и выключение

requires: cms/commerce-catalog, cms/commerce-pricing · suggests: cms/commerce-promo, cms/commerce-stock

Поведение при выключении: витрина каталога работает в режиме «без покупки» (лид-форма вместо корзины) — карточка товара скрывает кнопку «в корзину», деградация до universal-модели без поломки каталога.

Модель данных

ТаблицаКлючевые поляПримечание
commerce_cartsid, user_id (nullable), session_token, currency_code, updated_atсессионная или персистентная корзина; индекс по session_token, FK user_idconstrained()->index()
commerce_cart_itemsid, cart_id, variant_id, qty, price_snapshot_minor, lock_versionпозиция по variant_id, снапшот цены на момент добавления; FK cart_id/variant_idconstrained()->index()

commerce_carts.lock_version — optimistic lock (§4 стандарта) для мутаций из нескольких вкладок/устройств. Индексы: commerce_carts(session_token) (uniq), commerce_carts(user_id), commerce_cart_items(cart_id).

ПДн-паспорт. Корзина сама по себе не хранит email/имя — только user_id/session_token (обезличенный токен) и состав позиций. Персональные данные подтягиваются на чтении из cms/core (профиль пользователя). Срок хранения: гостевая корзина — guest_ttl_days (purge-джоба), пользовательская — пока жив аккаунт. Участие в «выгрузить всё по субъекту» — да (состав активной корзины по user_id); в «забыть по запросу» — да, каскадное удаление commerce_carts/commerce_cart_items по UserDeleted (см. «События и обмен»).

Входные и выходные данные

Входы (whitelist — всё не перечисленное отклоняется 422):

ОткудаЧто приходитПоляВалидация
API POST /cart/itemsдобавление позицииvariant_id, qtyFormRequest: variant_id существует и активен, qty целое 1..остаток, Idempotency-Key
API PATCH /cart/items/{id}изменение количестваqty, lock_versionFormRequest: qty целое ≥0 (0 = удалить), lock_version совпадает с текущим (иначе 409)
API DELETE /cart/items/{id}удаление позиции— (только {id} в пути)принадлежность позиции текущей корзине (session/user)
API POST /cart/mergeслияние корзин при логине— (без тела, корзина берётся из сессии+токена авторизации)пользователь авторизован; Idempotency-Key
Событие UserLoggedInтриггер mergeuser_id, session_tokenpayload события ядра, не пользовательский ввод
Событие UserMergedслияние дублей аккаунтовprimary, secondarypayload события ядра §16 core
Событие PriceChanged (commerce-pricing)инвалидация снапшотаvariant_id, new_price_minorpayload чужого модуля, не пользовательский ввод

Выходы:

КудаФорматСодержимое
GET /cart{data, meta}позиции с пересчитанной ценой, meta.diff при расхождении со снапшотом (см. «Крайние случаи»)
Событие CartItemAddedканал 1cart_id, variant_id, qty
Событие CartMergedканал 1cart_id, user_id, merged_items_count
Блок «мини-корзина»рендер (island, personalized: true)счётчик, сумма, превью позиций — не в общем page-cache
Ошибка мутацииконверт ошибок ядра{message, code, errors}, codevariant_unavailable, qty_limit_exceeded, version_conflict

Настройки (группа commerce-cart)

КлючТипДефолтaffectsPageCacheОписание
commerce-cart.enabledbooltrueдаВключение корзины (false — режим лид-каталога)
commerce-cart.guest_ttl_daysint30нетСрок жизни гостевой корзины по session token
commerce-cart.max_itemsint100нетЛимит позиций в одной корзине; при достижении — 422 qty_limit_exceeded на добавлении, существующие позиции не режутся
commerce-cart.max_qty_per_itemint999нетЛимит количества одной позиции; защита от опечатки/скрипта
commerce-cart.merge_on_login_enabledbooltrueнетKill-switch: отключает автослияние корзин при UserLoggedIn/UserMerged без выключения модуля — гостевая корзина остаётся гостевой, пользователь видит только сохранённую

API

МетодПутьДоступНазначение
GET/api/v1/cartpublic (session token)Текущее состояние корзины с пересчётом
POST/api/v1/cart/itemspublic (session token)Добавление позиции по variant_id
PATCH/api/v1/cart/items/{id}public (session token)Изменение количества позиции
DELETE/api/v1/cart/items/{id}public (session token)Удаление позиции
POST/api/v1/cart/mergeauthСлияние гостевой корзины с корзиной пользователя при логине

Мутации с внешним эффектом (добавление/merge) — Idempotency-Key. PATCH /cart/items/{id} принимает lock_version — расхождение с текущим значением отдаёт 409 version_conflict.

Компоненты

Блоки (BlockRegistry): мини-корзина виджетом (personalized: true — см. «Производительность и кеш»), страница корзины. Команды: cms:commerce-cart:purge-expired-guests --json. Демо-сидер: наполненная demo-корзина (2-3 позиции с разными вариантами) для галереи блоков и playground — без реального пользователя.

События и обмен

СобытиеКогдаPayload
CartItemAddedпозиция добавлена в корзинуcart_id, variant_id, qty
CartMergedгостевая корзина слита с пользовательской при логинеcart_id, user_id, merged_items_count

Слушает: PriceChanged из cms/commerce-pricing для инвалидации снапшота цены позиции; UserLoggedIn (событие аутентификации ядра) — запускает merge гостевой корзины в пользовательскую; UserMerged(primary, secondary) — переносит позиции корзины со вторичного аккаунта на первичный тем же алгоритмом слияния; UserDeleted — каскадно удаляет корзину пользователя (ПДн-каскад «забыть по запросу»).

Provides-контракты: не предоставляет. FilterBus: пересчёт стоимости корзины проходит через фильтр скидок cms/commerce-promo (rule-engine), не хардкодом в сервисе корзины.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/core (auth)1. событие UserLoggedInвходящеетриггер merge гостевой корзины в пользовательскую
cms/core (auth)1. событие UserMergedвходящееперенос позиций со вторичного аккаунта на первичный
cms/core (auth)1. событие UserDeletedвходящеекаскадное удаление корзины пользователя
cms/commerce-pricing4. requires, сервис-вызовисходящеепересчёт эффективной цены позиции при чтении корзины
cms/commerce-pricing1. событие PriceChangedвходящееинвалидация снапшота цены позиции
cms/commerce-promo2. FilterBusисходящееприменение скидок/промокода к сумме корзины
cms/commerce-stock4. requires (suggests), сервис-вызовисходящеепроверка доступности остатка при добавлении/пересчёте
cms/commerce-abandoned1. событие CartItemAdded/др.исходящеесброс пометки брошенности при активности в корзине
cms/commerce-catalog4. requires, сервис-вызовисходящеекарточка товара (кнопка «в корзину» / режим лида)

Фоновая работа

Очередь commerce-cart: purge-expired-guests по расписанию через ScheduleRegistrar — удаление гостевых корзин старше guest_ttl_days. Пересчёт снапшота цены при PriceChanged — асинхронно, партиями.

Метрики и алерты: счётчик активных корзин (гостевых/пользовательских), доля version_conflict на мутациях (рост — признак проблемы с фронтом или очень активных вкладок), длительность purge-expired-guests, отставание очереди пересчёта снапшотов. Алерт — отставание очереди пересчёта > 15 мин (цены в корзине расходятся с каталогом дольше разумного).

Ранбук (типовые инциденты):

СимптомЧто проверитьКоманда-лечение
Корзины гостей не удаляютсястатус очереди commerce-cart, лог purge-expired-guestscms:commerce-cart:purge-expired-guests --dry-run → без ошибок → без --dry-run
Массовые 409 version_conflictне сломан ли клиент (шлёт устаревший lock_version без ретрая)не бэкенд-инцидент — фикс на стороне фронтенда (retry по 409); бэкенд не деградирует
Цена в корзине не совпадает с каталогомотставание очереди пересчёта снапшотов, случайные сбои PriceChanged-слушателяручной пересчёт cms:commerce-cart:recount-prices --json (batched, идемпотентно)
Merge при логине не сработалвключён ли commerce-cart.merge_on_login_enabled, дошло ли UserLoggedInпроверить cms/health слушателей auth-событий; повторный логин безопасен (идемпотентно)

Бэкап/рестор: таблицы commerce_carts/commerce_cart_items — в общем бэкапе БД (данные не пересоздаются автоматически, это активные корзины покупателей). После рестора ничего пересчитывать не нужно — снапшоты цен валидны на момент бэкапа, актуализируются на первом открытии корзины.

Производительность и кеш

  • Ожидаемые объёмы: на среднем магазине — тысячи активных гостевых корзин одновременно, десятки тысяч в базе (до purge); горячий путь — GET /cart и мини-корзина в шапке на каждой странице сайта.
  • Персональные данные, не общий page-cache. Корзина — персонализированные данные; мини-корзина в шапке — блок с флагом personalized: true (BlockRegistry, ревизия ядра 14.07.2026): каркас страницы кешируется общим page-cache, фрагмент корзины рендерится отдельно (остров на клиенте или сегментированный фрагмент-кеш ядра) — самодельная сегментация запрещена (§10 стандарта).
  • Тег commerce-cart:cart:{id}. Инвалидация — событиями CartItemAdded, CartMerged, PriceChanged (снапшот).
  • Бюджет запросов: GET /cart с превью позиций — без N+1 (eager load variant+product одним ->with()); настройки группы — 0 запросов (чтение из кеша группы).
  • Индексы под горячие WHERE: commerce_carts(session_token) (поиск гостевой корзины), commerce_carts(user_id) (поиск пользовательской), commerce_cart_items(cart_id) (построение списка позиций).

Безопасность

Публичное API — ограничено rate-limit, без авторизации для гостя (session token валидируется на границе); мутации — Idempotency-Key; max_items/max_qty_per_item — защита от переполнения корзины. Права: commerce-cart.view, commerce-cart.manage.

IDOR. session_token — не автоинкремент и не предсказуем (UUID/ULID, генерируется сервером); попытка подобрать чужой токен не должна давать статистически значимого преимущества (rate-limit на GET /cart по IP). Позиция ({id} в путях PATCH/DELETE) проверяется на принадлежность текущей корзине (session/user), а не только на существование — иначе можно менять чужие позиции по угаданному id.

Подмена суммы. Сумма корзины и цена каждой позиции пересчитываются на сервере при каждом чтении (GET /cart) через cms/commerce-pricing — клиентский снапшот из POST/PATCH не доверяется и не используется для итоговой суммы; price_snapshot_minor в БД — только последняя известная серверная цена, не источник истины для checkout.

Матрица ролей:

Рольcommerce-cart.viewcommerce-cart.manage
Администратор
Менеджер✓ (правка корзины клиента по обращению)
Редактор
Studio

Гость/покупатель работает не через permissions, а через собственный session_token/auth — матрица выше только про админку (просмотр/ручная правка чужих корзин поддержкой).

UX-требования

Покупатель: добавление в корзину и checkout без обязательной регистрации (гостевая корзина); при ошибке валидации (например, qty_limit_exceeded) количество в поле не сбрасывается — форма показывает, что введено, и понятную причину отказа; при расхождении цены/наличия между добавлением и открытием корзины — явный блок «изменилось с момента добавления» (было/стало), а не молчаливая замена суммы; удаление позиции — с возможностью отменить (undo-тост), не только диалогом подтверждения.

Админ: пустая корзина в списке поддержки — состояние «нет активных корзин» с пояснением, а не пустая таблица; массовое действие — экспорт/просмотр брошенных корзин для менеджера поддержки; ошибки на человеческом языке («корзина клиента изменилась, обновите страницу», не 409 version_conflict); ручное удаление позиции из чужой корзины — с подтверждением (необратимо для клиента).

Крайние случаи и типовые баги

  • Слияние гостевой корзины при логине, дубли позиций → одинаковый variant_id в обеих корзинах суммирует qty (не дублирует строку); итоговое qty ограничивается max_qty_per_item; итоговое число позиций после merge ограничивается max_items — избыточные позиции остаются в гостевой корзине с пометкой «не перенесено, лимит».
  • UserMerged (слияние дублей аккаунтов) → тот же алгоритм merge применяется к корзине вторичного аккаунта; после переноса корзина вторичного аккаунта помечается пустой/удаляется, повторный UserMerged для того же события — идемпотентен (не задвоит позиции).
  • Цена/наличие изменились между добавлением и checkout → пересчёт на открытии корзины и повторно перед checkout; если что-то изменилось — ответ несёт meta.diff (позиция, было, стало), checkout блокируется до подтверждения покупателем нового состояния.
  • Несколько вкладок одновременно → каждая мутация несёт lock_version; расхождение — 409 version_conflict с понятным сообщением, клиент делает GET /cart и повторяет действие (мягкая деградация, не потеря данных).
  • Двойной сабмит «добавить в корзину» (двойной клик/повтор запроса) → Idempotency-Key на POST /cart/items гарантирует, что повтор не добавит позицию дважды.
  • Товар/вариант удалён из каталога, пока лежит в корзине → позиция помечается недоступной (variant_unavailable), не исчезает молча и не блокирует остальную корзину.
  • Остаток закончился между добавлением и checkout → то же поведение: позиция недоступна, покупатель решает — убрать или оставить «ждать поступления» (если каталог это поддерживает).
  • Пустая корзина у гостя без cookies/sessionGET /cart отдаёт пустой валидный ответ (не 404/500), новая корзина создаётся лениво при первом добавлении.
  • max_items достигнут ровно на merge → см. первый пункт; лимит не превышается ни при каком сценарии слияния, отказ по лимиту логируется, не тихо режется до лимита.
  • ⚠️ Противоречие: суммы в снапшоте vs. правило «деньги — integer minor units» (§4 стандарта). price_snapshot_minor уже integer — противоречия нет, но ТЗ явно фиксирует: снапшот не участвует в фискализации/чеке (это делает commerce-orders на оформлении), корзина — черновик, не финансовый документ, append-only журнал к ней не применяется.

Донорский код

Что взятьПуть
Логика сессионной/персистентной корзины и merge при логинеmasha (путь не выдан)

Миграция legacy-данных: не применимо — гостевые и пользовательские корзины не переносятся между инсталляциями (короткоживущие данные, guest_ttl_days); при переезде клиента со старой платформы корзины покупателей просто не мигрируют, это ожидаемо и не требует cms:commerce-cart:import-legacy.

Тесты и приёмка

  • [ ] Контрактный тест: позиция корзины хранит и оперирует только variant_id
  • [ ] Merge при логине не теряет позиции ни гостевой, ни пользовательской корзины (объединение количества)
  • [ ] Merge при UserMerged переносит позиции вторичного аккаунта и идемпотентен при повторе события
  • [ ] Merge уважает max_items/max_qty_per_item, не создаёт позиций сверх лимита
  • [ ] Пересчёт цены идёт через резолвер cms/commerce-pricing, не хардкод цены в корзине
  • [ ] Расхождение цены/наличия между добавлением и checkout отдаёт meta.diff, не тихую замену суммы
  • [ ] Параллельная мутация из двух вкладок с устаревшим lock_version отдаёт 409 version_conflict
  • [ ] Двойной сабмит с одним Idempotency-Key не создаёт вторую позицию
  • [ ] При недоступном остатке позиция помечается недоступной, не удаляется молча
  • [ ] session_token/{id} позиции не дают доступа к чужой корзине (IDOR-тест)
  • [ ] Публичное API корзины ограничено rate-limit, не требует авторизации для гостя
  • [ ] UserDeleted каскадно удаляет корзину пользователя (ПДн-каскад)
  • [ ] При выключении модуля карточка товара работает в режиме лида без ошибок
  • [ ] Нет N+1 при построении мини-корзины с превью позиций; мини-корзина не ломает page-cache каркаса
  • [ ] Контрактный набор cms-testing и testbench-изоляция зелёные, feature-тест на каждый роут
  • [ ] Тестовая БД только commerce-cart_test; migrate:fresh/refresh/reset запрещены

Внутренняя база знаний студии