Тема
ТЗ — 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_items | id, user_id (nullable), session_token, product_id, added_at | избранное, гость/пользователь по аналогии с корзиной; FK user_id/product_id — constrained()->index(), индекс по session_token, уникальный составной индекс (COALESCE(user_id), session_token, product_id) — дедуп на уровне БД |
commerce_comparison_items | id, user_id (nullable), session_token, product_id, added_at | список сравнения, ограничен одной категорией; та же схема индексации |
commerce_wishlist_shares | id, 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_id | FormRequest whitelist, существование и публикация товара, max_wishlist_items (лимит) |
Добавление в сравнение (POST /api/v1/wishlist/compare/items) | product_id | FormRequest 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.enabled | bool | true | да | Включение избранного и сравнения (kill-switch модуля целиком) |
commerce-wishlist.max_compare_items | int | 4 | нет | Лимит товаров в списке сравнения |
commerce-wishlist.max_wishlist_items | int | 200 | нет | Лимит товаров в избранном на гостя/пользователя (защита от абьюза гостевой сессии без аутентификации) |
commerce-wishlist.guest_ttl_days | int | 90 | нет | TTL гостевых записей избранного/сравнения без активности |
commerce-wishlist.sharing_enabled | bool | true | нет | Kill-switch: аварийно отключить публичный шаринг списков (например, при абьюзе), не выключая избранное/сравнение целиком |
commerce-wishlist.max_active_shares_per_owner | int | 10 | нет | Лимит активных ссылок-шарингов на пользователя/сессию |
Достижение лимитов (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/wishlist | public (session token) | Текущий список избранного |
| POST | /api/v1/wishlist/items | public (session token) | Добавление товара в избранное |
| DELETE | /api/v1/wishlist/items/{id} | public (session token) | Удаление товара из избранного |
| GET | /api/v1/wishlist/compare | public (session token) | Таблица сравнения по характеристикам |
| POST | /api/v1/wishlist/compare/items | public (session token) | Добавление товара в сравнение |
| POST | /api/v1/wishlist/merge | auth | Ручной вызов слияния гостевого избранного с пользовательским (основной путь — автоматически по UserLoggedIn, см. «События и обмен») |
| POST | /api/v1/wishlist/share | public (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\* напрямую. Механизм склейки:
UserLoggedIn(user_id, ...)→ тонкий слушатель кладёт jobMergeGuestWishlist(канал 5) сsession_tokenтекущего запроса иuser_id.- Job находит гостевые записи
commerce_wishlist_items/commerce_comparison_itemsпоsession_token, переносит их наuser_id(UPDATE ... SET user_id = ?, session_token = NULL), дедуп поproduct_id— при конфликте уникального индекса гостевая запись отбрасывается (пользовательская уже содержит товар), не создаёт дубль и не падает. - По завершении job издаётся
WishlistMerged(user_id, merged_count)(канал 1). UserMerged(primary, secondary)(слияние аккаунтов — гостевой → зарегистрированный, дубли) обрабатывается тем же job-обработчиком: записиsecondaryпереносятся наprimaryс тем же правилом дедупа поproduct_id, затемWishlistMerged(primary, merged_count).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-catalog | requires (сервис-вызов) + событие ProductDeleted/ProductDeactivated | оба направления | Проверка существования/публикации товара при добавлении; пометка «недоступен» при удалении/деактивации |
cms/commerce-attributes | provides suggest-provider-подобный сервис-вызов (suggests) | commerce-wishlist → commerce-attributes | Чтение атрибутов с флагом is_comparable для таблицы сравнения; при отсутствии модуля — деградация к списку без сравнения по характеристикам |
Ядро: CacheTags | requires | commerce-wishlist → ядро | Объявление тегов commerce-wishlist:*, инвалидация по событиям |
Ядро: EventBus | канал 1 | commerce-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, расписание в ScheduleRegistrar | cms: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запрещены