Skip to content

ТЗ — Wishlist/сравнение (cms/commerce-wishlist)

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

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

Избранное и сравнение товаров по характеристикам, с работой и для гостя (localStorage), и для авторизованного пользователя (БД) с merge при логине.

  • Избранное: гость — localStorage, пользователь — БД, слияние списков при логине
  • Сравнение товаров по характеристикам — таблица строится из cms/commerce-attributes (флаг is_comparable на атрибуте)
  • Сравнение ограничено товарами одной категории (набор атрибутов сопоставим)
  • Счётчики-виджеты избранного и сравнения в шапке сайта
  • Удаление позиции из избранного/сравнения без подтверждения (мгновенное действие)
  • Публичное API для гостя по session token, как в cms/commerce-cart
  • Товар, ставший недоступным (удалён/деактивирован), не исчезает из списка тихо — отображается как «товар недоступен» (см. «Крайние случаи»)
  • Публичная ссылка-шаринг на список избранного по ULID (расширение назначения модуля — см. «Крайние случаи» и API)

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

requires: cms/commerce-catalog · suggests: cms/commerce-attributes

Поведение при выключении: кнопки «в избранное»/«сравнить» скрываются на карточке и листинге — каталог и покупка продолжают работать без функции избранного/сравнения.

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

ТаблицаКлючевые поляПримечание
commerce_wishlist_itemsid, user_id (nullable), session_token, product_id, added_atизбранное, гость/пользователь по аналогии с корзиной; FK user_id/product_idconstrained()->index(), индекс по session_token, уникальный составной индекс (COALESCE(user_id), session_token, product_id) — дедуп на уровне БД
commerce_comparison_itemsid, user_id (nullable), session_token, product_id, added_atсписок сравнения, ограничен одной категорией; та же схема индексации
commerce_wishlist_sharesid, ulid, user_id (nullable), session_token, title (nullable), created_at, expires_at (nullable)публичная ссылка-шаринг на снапшот списка избранного; ulid — публичный идентификатор, уникальный индекс

ulid на commerce_wishlist_shares — публичный идентификатор в URL шаринга (автоинкрементный id в публичном URL запрещён, §4 стандарта). Дедуп по product_id — уникальный индекс, а не только проверка на уровне сервиса (защита от гонки двойного клика/двойного запроса).

ПДн-паспорт. Хранит: user_id (nullable), session_token (гостевой идентификатор — минимальный ПДн-след, привязан к устройству/браузеру, не к личности). Ретеншн: записи авторизованных пользователей — бессрочно (пока пользователь не удалит или не удалит аккаунт — см. UserDeleted ниже); гостевые записи по session_token без активности — TTL commerce-wishlist.guest_ttl_days (дефолт 90 дней), чистит purge-expired-guests. Хук «выгрузить всё по субъекту» отдаёт список избранного/сравнения пользователя по user_id; хук «забыть по запросу» удаляет записи и активные commerce_wishlist_shares пользователя.

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

Входы:

ИсточникПоляЧем валидируется
Добавление в избранное (POST /api/v1/wishlist/items)product_idFormRequest whitelist, существование и публикация товара, max_wishlist_items (лимит)
Добавление в сравнение (POST /api/v1/wishlist/compare/items)product_idFormRequest whitelist, товар той же категории, что уже в списке, max_compare_items
Удаление позиции (DELETE /api/v1/wishlist/items/{id})idпринадлежность записи текущему session_token/user_id
Создание ссылки-шаринга (POST /api/v1/wishlist/share)title (nullable)FormRequest whitelist, лимит активных ссылок на пользователя/сессию
Событие UserLoggedIn (ядро)user_id, session_token (из контекста запроса)тонкий слушатель — кладёт job merge, не мержит синхронно в обработчике события
Событие UserMerged (ядро)primary_user_id, secondary_user_idтонкий слушатель — переносит записи избранного/сравнения со вторичного аккаунта на первичный
Событие UserDeleted (ядро)user_idудаление/обезличивание записей пользователя (ПДн-хук «забыть по запросу»)
Событие ProductDeleted/ProductDeactivated (cms/commerce-catalog)product_idпомечает позиции избранного/сравнения как «товар недоступен», не удаляет запись

Всё, что не перечислено — отвергается (whitelist-принцип): произвольные поля в теле запроса, product_id не из каталога, товар другой категории в сравнении.

Выходы:

ПотребительДанныеФормат
GET /api/v1/wishlistсписок избранного текущего гостя/пользователя, недоступные товары помеченыконверт {data, meta}, data[].unavailable: bool
GET /api/v1/wishlist/compareтаблица сравнения по характеристикамконверт {data, meta}, data.attributes[], data.products[]
GET /api/v1/wishlist/shared/{ulid}публичный снапшот списка на момент шарингаконверт {data, meta}, без данных владельца сверх title
Счётчики-виджеты в шапкеколичество позиций избранного/сравненияisland-фрагмент, не в общем page-cache (personalized: true)
Шина событий: WishlistItemAdded/WishlistMerged/WishlistSharedсм. «События и обмен»канал 1, после коммита
cms:commerce-wishlist:purge-expired-guests --jsonотчёт очисткиstdout JSON (диагностика/CI)

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

КлючТипДефолтaffectsPageCacheОписание
commerce-wishlist.enabledbooltrueдаВключение избранного и сравнения (kill-switch модуля целиком)
commerce-wishlist.max_compare_itemsint4нетЛимит товаров в списке сравнения
commerce-wishlist.max_wishlist_itemsint200нетЛимит товаров в избранном на гостя/пользователя (защита от абьюза гостевой сессии без аутентификации)
commerce-wishlist.guest_ttl_daysint90нетTTL гостевых записей избранного/сравнения без активности
commerce-wishlist.sharing_enabledbooltrueнетKill-switch: аварийно отключить публичный шаринг списков (например, при абьюзе), не выключая избранное/сравнение целиком
commerce-wishlist.max_active_shares_per_ownerint10нетЛимит активных ссылок-шарингов на пользователя/сессию

Достижение лимитов (max_compare_items, max_wishlist_items, max_active_shares_per_owner) отдаёт 422 с кодом compare_limit_reached/wishlist_limit_reached/share_limit_reached и понятным сообщением, не 500 и не тихое обрезание списка.

API

МетодПутьДоступНазначение
GET/api/v1/wishlistpublic (session token)Текущий список избранного
POST/api/v1/wishlist/itemspublic (session token)Добавление товара в избранное
DELETE/api/v1/wishlist/items/{id}public (session token)Удаление товара из избранного
GET/api/v1/wishlist/comparepublic (session token)Таблица сравнения по характеристикам
POST/api/v1/wishlist/compare/itemspublic (session token)Добавление товара в сравнение
POST/api/v1/wishlist/mergeauthРучной вызов слияния гостевого избранного с пользовательским (основной путь — автоматически по UserLoggedIn, см. «События и обмен»)
POST/api/v1/wishlist/sharepublic (session token) / authСоздание публичной ссылки-шаринга на список избранного (ULID)
GET/api/v1/wishlist/shared/{ulid}public, без токенаПросмотр расшаренного списка избранного

Список избранного/сравнения — не более max_wishlist_items/max_compare_items позиций, без пагинации (лимит достаточно мал). POST /wishlist/share несёт Idempotency-Key — повторный вызов с тем же ключом возвращает уже созданную ссылку, не плодит дубли.

Компоненты

Блоки (BlockRegistry): счётчик-виджет избранного/сравнения в шапке, страница сравнения, публичная страница расшаренного списка (/wishlist/shared/{ulid}). Fallback при выключении модуля — виджеты не рендерятся, без 500. Команды: cms:commerce-wishlist:purge-expired-guests --json.

Фронтенд-бюджет: счётчик в шапке и страница сравнения — собственные JS/CSS через пайплайн темы; таблица сравнения — резерв высоты под колонки характеристик (без CLS при подгрузке атрибутов); удаление позиции — оптимистичное обновление UI с откатом при ошибке сети. Кнопки избранного/сравнения на карточке доступны с клавиатуры, с aria-label («Добавить в избранное»/«Убрать из избранного»).

Демо-контент: сидер WishlistDemoSeeder — избранное и список сравнения на 4-6 товарах демо-каталога (в одной категории для сравнения) для галереи блоков /_gallery и playground.

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

СобытиеКогдаPayload
WishlistItemAddedтовар добавлен в избранноеproduct_id, user_id (nullable)
WishlistMergedгостевое избранное слито с пользовательским при логинеuser_id, merged_count
WishlistSharedсоздана публичная ссылка-шаринг спискаulid, user_id (nullable), items_count

Слушает UserLoggedIn, UserMerged, UserDeleted ядра (ревизия контрактов ядра 14.07.2026, core.md) — не перехватывает Illuminate\Auth\Events\* напрямую. Механизм склейки:

  1. UserLoggedIn(user_id, ...) → тонкий слушатель кладёт job MergeGuestWishlist (канал 5) с session_token текущего запроса и user_id.
  2. Job находит гостевые записи commerce_wishlist_items/commerce_comparison_items по session_token, переносит их на user_id (UPDATE ... SET user_id = ?, session_token = NULL), дедуп по product_id — при конфликте уникального индекса гостевая запись отбрасывается (пользовательская уже содержит товар), не создаёт дубль и не падает.
  3. По завершении job издаётся WishlistMerged(user_id, merged_count) (канал 1).
  4. UserMerged(primary, secondary) (слияние аккаунтов — гостевой → зарегистрированный, дубли) обрабатывается тем же job-обработчиком: записи secondary переносятся на primary с тем же правилом дедупа по product_id, затем WishlistMerged(primary, merged_count).
  5. UserDeleted(user_id) — удаляет записи избранного/сравнения и активные commerce_wishlist_shares пользователя (ПДн-хук «забыть по запросу»).

Provides-контракты: не предоставляет. FilterBus: не использует; таблица сравнения читает атрибуты «сравнимо» (is_comparable) из cms/commerce-attributes сервис-вызовом по requires/suggests.

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

Сущность/модульКаналНаправлениеЧто происходит
Ядро: UserLoggedInканал 1 (событие)ядро → commerce-wishlistТриггер слияния гостевого избранного с пользовательским
Ядро: UserMergedканал 1 (событие)ядро → commerce-wishlistПеренос записей со вторичного аккаунта на первичный
Ядро: UserDeletedканал 1 (событие)ядро → commerce-wishlistУдаление/обезличивание записей пользователя (ПДн)
cms/commerce-catalogrequires (сервис-вызов) + событие ProductDeleted/ProductDeactivatedоба направленияПроверка существования/публикации товара при добавлении; пометка «недоступен» при удалении/деактивации
cms/commerce-attributesprovides suggest-provider-подобный сервис-вызов (suggests)commerce-wishlist → commerce-attributesЧтение атрибутов с флагом is_comparable для таблицы сравнения; при отсутствии модуля — деградация к списку без сравнения по характеристикам
Ядро: CacheTagsrequirescommerce-wishlist → ядроОбъявление тегов commerce-wishlist:*, инвалидация по событиям
Ядро: EventBusканал 1commerce-wishlist → все подписчикиИздание WishlistItemAdded/WishlistMerged/WishlistShared

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

Очередь commerce-wishlist: MergeGuestWishlist — слияние по UserLoggedIn/UserMerged, идемпотентна (дедуп по уникальному индексу product_id, повторный прогон не создаёт дублей и не теряет позиции). Очередь commerce-wishlist-low: purge-expired-guests по расписанию через ScheduleRegistrar — удаление гостевых записей избранного/сравнения по guest_ttl_days (аналогично commerce-cart), включая связанные commerce_wishlist_shares с истёкшим сроком (expires_at).

Эксплуатация (ранбук). Метрики: commerce_wishlist_items_total, commerce_wishlist_merge_duration_ms, commerce_wishlist_merge_conflicts_total (дедуп сработал), commerce_wishlist_shares_active_total. Алерты: MergeGuestWishlist падает/ ретраится больше 3 раз подряд; purge-expired-guests не выполнялась дольше расписания (гостевые записи копятся).

СимптомПроверитьКоманда
После логина избранное не подтянулосьjob MergeGuestWishlist в очереди commerce-wishlist, session_token запросаqueue:failed, ручной повтор POST /wishlist/merge
Гостевые записи не удаляютсяворкер очереди commerce-wishlist-low, расписание в ScheduleRegistrarcms:commerce-wishlist:purge-expired-guests --dry-run
Расшаренная ссылка отдаёт 404 у живого владельцаexpires_at/sharing_enabledсверка commerce_wishlist_shares по ulid

Бэкап/рестор: в бэкап попадают commerce_wishlist_items, commerce_comparison_items, commerce_wishlist_shares. После рестора ничего не пересоздаётся отдельной командой — счётчики в шапке читаются напрямую из таблиц; кеш-теги протухают и перестраиваются по первому запросу.

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

  • Ожидаемые объёмы: единицы–десятки позиций на пользователя в избранном (ограничено max_wishlist_items), не более max_compare_items в сравнении — горячий путь лёгкий по объёму данных, тяжёлый по частоте (счётчик в шапке на каждой странице).
  • Горячие пути: счётчик-виджет избранного/сравнения (рендерится на каждой странице сайта), кнопка «в избранное» на карточке/листинге (проверка «уже добавлено» на N карточек листинга).
  • Бюджет запросов: счётчик в шапке — 0 запросов (кеш по session_token/user_id); листинг с отметками «уже в избранном» — 1 запрос на всю страницу (множество product_id пользователя из кеша, сверка в PHP), не по одному на карточку.
  • Критичные индексы: уникальный (user_id/session_token, product_id) на обеих таблицах позиций (дедуп + быстрая проверка «уже добавлено»); session_token отдельно для очистки гостевых; ulid уникальный на commerce_wishlist_shares; expires_at для выборки истёкших ссылок.
  • Теги кеша: commerce-wishlist:user:{id} / commerce-wishlist:session:{token}. Инвалидация — событиями WishlistItemAdded, WishlistMerged. Персональные данные не попадают в общий page-cache — блоки-виджеты помечены personalized: true (core.md, флаг блока).
  • Массовые операции: merge при логине — один батч-UPDATE по session_token, не построчный перенос; purge-expired-guests — чанками по TTL, батчевая инвалидация тегов затронутых сессий одним flush по завершении команды.

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

Публичное API — session token + rate-limit, без авторизации для гостя; max_compare_items и max_wishlist_items — защита от переполнения и абьюза гостевой сессии без аутентификации; мутации merge и создание ссылки-шаринга — Idempotency-Key.

Границы входа: FormRequest-whitelist на product_id (только существующий опубликованный товар), на title ссылки-шаринга (санитизация plain-text, без rich-text). Публичная страница GET /wishlist/shared/{ulid} не раскрывает user_id/session_token владельца — только title и список товаров.

Векторы: перебор ulid ссылок-шаринга (ULID непредсказуем, не инкремент — §4 стандарта) · подбор чужого session_token (session token генерируется ядром с достаточной энтропией, как в commerce-cart) · переполнение избранного скриптом (rate-limit + max_wishlist_items отдают 422, не позволяют раздуть таблицу) · шаринг списка с чужими товарами через подмену product_id в момент создания ссылки (снапшот на момент шаринга, не live-ссылка на текущее избранное владельца).

Права: commerce-wishlist.view, commerce-wishlist.manage.

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

PermissionАдминМенеджерРедакторStudio
commerce-wishlist.view (просмотр списков в Filament)
commerce-wishlist.manage (лимиты, sharing_enabled, ручная очистка)

Публичное избранное/сравнение — без permissions, доступно любому посетителю по session token; permissions относятся только к административному доступу.

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

Админ: пустое состояние Filament-списка избранного — «Пока никто ничего не добавил»; массовые действия — ручная очистка гостевых записей выбранной сессии, отзыв ссылки-шаринга списком; человеческие ошибки — «Нельзя создать ссылку — достигнут лимит активных ссылок» вместо 500; подтверждение необратимых операций — отзыв опубликованной ссылки-шаринга (она станет недоступна тем, кому уже отправлена) запрашивает «да».

Посетитель: добавление/удаление из избранного — мгновенный оптимистичный отклик без перезагрузки страницы, с откатом и понятным сообщением при сбое сети; попытка добавить пятый товар сверх max_compare_items — сообщение «Сравнить можно не более 4 товаров, уберите один» вместо тихого игнора клика; кнопки избранного/сравнения доступны с клавиатуры, с aria-pressed для отражения состояния «уже добавлено».

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

  • Гостевой вишлист → логин: склейка через UserLoggedIn/UserMerged. Точный механизм — см. «События и обмен»: session_token находит гостевые записи, job переносит их на user_id, дедуп по product_id через уникальный индекс (не ручная проверка в PHP — гонка двойного логина в двух вкладках не создаёт дублей). Слушаются события ядра UserLoggedIn/UserMerged, не Illuminate\Auth\Events\Login напрямую — самодельный перехват аутентификации запрещён (ревизия контрактов ядра 14.07.2026).
  • Товар удалён или деактивирован, но лежит в чьём-то избранном/сравнении → показ «товар недоступен» в списке (изображение-заглушка, название сохранено на момент добавления, кнопка «перейти» неактивна), а не тихое исчезновение из списка — пользователь должен видеть, что он что-то выбирал. Позиция не удаляется автоматически, удаление — только явным действием пользователя.
  • Лимит избранного (max_wishlist_items) при абьюзе гостевой сессии без аутентификации (скрипт добавляет тысячи товаров) → 422 с кодом wishlist_limit_reached при достижении лимита, rate-limit на POST /wishlist/items не даёт исчерпать лимит за секунды.
  • Двойной клик «добавить в избранное» (гонка двух параллельных запросов с одним session_token/product_id) → уникальный составной индекс на таблице позиций отклоняет вторую вставку на уровне БД, сервис перехватывает конфликт как «уже добавлено» (идемпотентный успех, не 500).
  • Сравнение товаров из разных категорийPOST /wishlist/compare/items отклоняет добавление товара другой категории, если список сравнения не пуст, с кодом category_mismatch; первый добавленный товар определяет категорию списка на сессию.
  • cms/commerce-attributes выключен (suggests, деградация) → таблица сравнения показывает только базовые поля (название, цена, изображение) без характеристик, флаг is_comparable не резолвится — не 500, факт отсутствия виден в health-чеке.
  • Публичная ссылка-шаринг ведёт себя не как live-избранное, а как снапшот — товар, добавленный в избранное после создания ссылки, не появляется на расшаренной странице; товар, ставший недоступным после шаринга, на расшаренной странице тоже помечается «недоступен» (тот же принцип, что и для собственного списка владельца).
  • Истёкшая или отозванная ссылка-шаринг (expires_at в прошлом либо sharing_enabled = false) → GET /wishlist/shared/{ulid} отдаёт 404 с понятным сообщением, не 500 и не пустой список с кодом 200 (не путать «удалено» с «пусто»).
  • Огромный список избранного у одного аккаунта (близко к max_wishlist_items) на листинге с отметками «уже в избранном» → сверка «товар в избранном» на листинге не запрашивает БД на каждую карточку — множество product_id пользователя читается одним запросом из кеша и сверяется в PHP (см. «Производительность и кеш»).
  • Измерение site_id/мультисайт: избранное и сравнение — per-session_token/ user_id, не привязаны к site_id/city_id напрямую, но product_id валиден только в рамках каталога текущего сайта — добавление товара с чужого сайта мультисайтовой инсталляции отклоняется на этапе проверки существования/публикации товара (cms/commerce-catalog резолвит по текущему RequestContext).
  • ⚠️ Противоречие: ссылка-шаринг избранного — новая фича сверх исходного назначения модуля. Текущее ТЗ модуля описывало только личное избранное/сравнение без функции публичного шаринга; требование ULID для публичных идентификаторов (§4 стандарта) добавлено этой ревизией вместе с самой возможностью шаринга (commerce_wishlist_shares, POST /wishlist/share, GET /wishlist/shared/{ulid}). Разрешение: фича принята в объём модуля как явное расширение (ссылки на список — типовая ожидаемая возможность e-commerce избранного), зафиксирована в модели данных/API/событиях этой версии ТЗ; отдельного открытого вопроса не заводится, так как решение принято в рамках этой доработки.

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

Донор: — (новая разработка). Готового вишлиста/сравнения с прежних проектов студии для маппинга нет — модуль без донора, cms:commerce-wishlist:import-legacy не требуется на старте. Если в будущем появится проект-донор с избранным/списками сравнения (например, при переносе клиента с внешней CMS), команда cms:commerce-wishlist:import-legacy --source=<профиль> заводится по общей схеме §16 стандарта: маппинг пользователь/товар на commerce_wishlist_items, идемпотентность по external_id, --dry-run с отчётом расхождений.

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

  • [ ] Контрактный тест: merge избранного при логине не создаёт дублей одного товара (гонка двух параллельных UserLoggedIn не размножает записи — уникальный индекс)
  • [ ] Контрактный тест: UserMerged(primary, secondary) переносит записи вторичного аккаунта на первичный с тем же дедупом по product_id
  • [ ] Список сравнения ограничен max_compare_items, добавление сверх лимита отклоняется с 422 (compare_limit_reached)
  • [ ] Список избранного ограничен max_wishlist_items, достижение лимита отклоняется с 422 (wishlist_limit_reached)
  • [ ] Таблица сравнения строится из атрибутов cms/commerce-attributes с флагом is_comparable; при выключенном модуле деградирует до базовых полей без 500
  • [ ] Удалённый/деактивированный товар в избранном/сравнении показывается как «товар недоступен», не исчезает из списка
  • [ ] Двойной клик «добавить в избранное» (гонка) не создаёт дубль позиции (уникальный индекс на БД)
  • [ ] Ссылка-шаринг: GET /wishlist/shared/{ulid} отдаёт снапшот на момент создания, не live-список; истёкшая/отозванная ссылка отдаёт 404
  • [ ] Публичный URL шаринга использует ULID, не автоинкрементный id (перебор невозможен)
  • [ ] При выключении модуля кнопки избранного/сравнения не рендерятся, каталог работает штатно
  • [ ] Хуки «выгрузить всё по субъекту»/«забыть по запросу» покрывают user_id, session_token, активные ссылки-шаринги (UserDeleted)
  • [ ] Гостевые записи старше guest_ttl_days удаляются purge-expired-guests, идемпотентно
  • [ ] Права commerce-wishlist.view/.manage разграничивают пользовательский доступ и админскую настройку лимитов
  • [ ] Нет N+1 при проверке «товар уже в избранном» на листинге (один запрос множества product_id на страницу)
  • [ ] Контрактный набор cms-testing и testbench-изоляция зелёные, feature-тест на каждый роут
  • [ ] Тестовая БД только commerce-wishlist_test; migrate:fresh/refresh/reset запрещены

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