Тема
ТЗ — Корзина (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_carts | id, user_id (nullable), session_token, currency_code, updated_at | сессионная или персистентная корзина; индекс по session_token, FK user_id — constrained()->index() |
commerce_cart_items | id, cart_id, variant_id, qty, price_snapshot_minor, lock_version | позиция по variant_id, снапшот цены на момент добавления; FK cart_id/variant_id — constrained()->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, qty | FormRequest: variant_id существует и активен, qty целое 1..остаток, Idempotency-Key |
API PATCH /cart/items/{id} | изменение количества | qty, lock_version | FormRequest: qty целое ≥0 (0 = удалить), lock_version совпадает с текущим (иначе 409) |
API DELETE /cart/items/{id} | удаление позиции | — (только {id} в пути) | принадлежность позиции текущей корзине (session/user) |
API POST /cart/merge | слияние корзин при логине | — (без тела, корзина берётся из сессии+токена авторизации) | пользователь авторизован; Idempotency-Key |
Событие UserLoggedIn | триггер merge | user_id, session_token | payload события ядра, не пользовательский ввод |
Событие UserMerged | слияние дублей аккаунтов | primary, secondary | payload события ядра §16 core |
Событие PriceChanged (commerce-pricing) | инвалидация снапшота | variant_id, new_price_minor | payload чужого модуля, не пользовательский ввод |
Выходы:
| Куда | Формат | Содержимое |
|---|---|---|
GET /cart | {data, meta} | позиции с пересчитанной ценой, meta.diff при расхождении со снапшотом (см. «Крайние случаи») |
Событие CartItemAdded | канал 1 | cart_id, variant_id, qty |
Событие CartMerged | канал 1 | cart_id, user_id, merged_items_count |
| Блок «мини-корзина» | рендер (island, personalized: true) | счётчик, сумма, превью позиций — не в общем page-cache |
| Ошибка мутации | конверт ошибок ядра | {message, code, errors}, code ∈ variant_unavailable, qty_limit_exceeded, version_conflict |
Настройки (группа commerce-cart)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-cart.enabled | bool | true | да | Включение корзины (false — режим лид-каталога) |
commerce-cart.guest_ttl_days | int | 30 | нет | Срок жизни гостевой корзины по session token |
commerce-cart.max_items | int | 100 | нет | Лимит позиций в одной корзине; при достижении — 422 qty_limit_exceeded на добавлении, существующие позиции не режутся |
commerce-cart.max_qty_per_item | int | 999 | нет | Лимит количества одной позиции; защита от опечатки/скрипта |
commerce-cart.merge_on_login_enabled | bool | true | нет | Kill-switch: отключает автослияние корзин при UserLoggedIn/UserMerged без выключения модуля — гостевая корзина остаётся гостевой, пользователь видит только сохранённую |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/cart | public (session token) | Текущее состояние корзины с пересчётом |
| POST | /api/v1/cart/items | public (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/merge | auth | Слияние гостевой корзины с корзиной пользователя при логине |
Мутации с внешним эффектом (добавление/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-pricing | 4. requires, сервис-вызов | исходящее | пересчёт эффективной цены позиции при чтении корзины |
cms/commerce-pricing | 1. событие PriceChanged | входящее | инвалидация снапшота цены позиции |
cms/commerce-promo | 2. FilterBus | исходящее | применение скидок/промокода к сумме корзины |
cms/commerce-stock | 4. requires (suggests), сервис-вызов | исходящее | проверка доступности остатка при добавлении/пересчёте |
cms/commerce-abandoned | 1. событие CartItemAdded/др. | исходящее | сброс пометки брошенности при активности в корзине |
cms/commerce-catalog | 4. requires, сервис-вызов | исходящее | карточка товара (кнопка «в корзину» / режим лида) |
Фоновая работа
Очередь commerce-cart: purge-expired-guests по расписанию через ScheduleRegistrar — удаление гостевых корзин старше guest_ttl_days. Пересчёт снапшота цены при PriceChanged — асинхронно, партиями.
Метрики и алерты: счётчик активных корзин (гостевых/пользовательских), доля version_conflict на мутациях (рост — признак проблемы с фронтом или очень активных вкладок), длительность purge-expired-guests, отставание очереди пересчёта снапшотов. Алерт — отставание очереди пересчёта > 15 мин (цены в корзине расходятся с каталогом дольше разумного).
Ранбук (типовые инциденты):
| Симптом | Что проверить | Команда-лечение |
|---|---|---|
| Корзины гостей не удаляются | статус очереди commerce-cart, лог purge-expired-guests | cms: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 loadvariant+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.view | commerce-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/session →
GET /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запрещены