Skip to content

ТЗ — Отзывы/рейтинги (cms/reviews)

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

🔄 Ревизия (корпоративный MVP, 15.07.2026, решение №8 сессии): отзывы — пресет типа контента с модерацией, приоритет P1, на движке типов контента с Schema.org Review/AggregateRating. Связь с товаром/услугой — типизированная связь движка. Разделы ниже — свойства/связи/шаблоны пресета.

Решено 15.07.2026 (пресет vs бесспок — закрыто): отзыв — пресет движка (cms_content_entries, тип reviews), subject — типизированная связь relation с whitelist target. Тяжёлое выносится в тонкую надстройку модуля рядом с движком, а не в движок: денормализованный агрегат рейтинга — собственной лёгкой таблицей cms_reviews_aggregates (атомарный инкремент, чтобы карточки с тысячами отзывов не считали AVG на лету), антифрод — крючья модуля, модерация — через suggests: cms/moderation. Тем самым видение «всё на типах» сохраняется, а узкое место не давит на общую JSONB-таблицу. Снимает кандидатуру reviews на исключение из движка (content-types-engine §16).

⚠️ Статус тела ТЗ: раздел «Модель данных» и всё тело ТЗ ниже (модель cms_reviews*, роуты /api/v1/reviews, Filament-ресурс, права reviews.manage, тесты) — исторический слой, написанный до пивота 15.07.2026: описывают целевые правила через историческую реализацию собственными таблицами; если модуль остаётся пресетом — хранение переезжает на cms_content_entries, свойства/связи — по карте пресетов (engine §13), админку/API даёт движок (ContentEntryResource, /api/v1/content/{slug}). Полная ревизия ТЗ — отдельная задача непосредственно перед реализацией пресета (content-types-engine §16).

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

Отзывы к любой сущности (полиморфно) с рейтингом, денормализованным агрегатом, модерацией, ответом владельца и фото. Стандартный модуль социального доказательства для каталогов, услуг и товаров.

  • Полиморфная привязка отзыва к сущности (reviewable_type/reviewable_id)
  • Рейтинг по шкале (1-5), денормализованный агрегат (avg_rating, reviews_count) на сущности, пересчёт по событию, не на каждом чтении
  • Модерация: pending → approved/rejected, премодерация опциональна
  • Ответ владельца сущности на отзыв (публичный, один ответ на отзыв)
  • Фото к отзыву через медиатеку ядра
  • Антифрод-крючья через cms/antifraud (защита от накрутки, дубли с одного устройства)
  • Приглашение к отзыву после покупки/заказа (при наличии cms/commerce-orders)
  • Schema.org AggregateRating + Review на карточке сущности
  • Агрегаты рейтинга кешируются, инвалидация по событию пересчёта

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

requires: ядро · suggests: cms/antifraud, cms/commerce-orders (приглашение к отзыву после OrderCompleted)

Поведение при выключении: блок отзывов и звёзды рейтинга скрываются на карточках (fallback — пусто), денормализованный агрегат перестаёт обновляться, но не удаляется; существующие отзывы сохраняются и возвращаются при повторном включении.

Модуль не объявляет suggests: cms/moderation и не реализует provides-контракт moderation-workflow — модерация построена на собственной простой статусной машине. Это осознанное расхождение с cms/moderation, разобрано со ссылкой на решение в Крайние случаи.

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

ТаблицаКлючевые поляПримечание
cms_reviewsid, reviewable_type, reviewable_id, user_id (nullable), rating, body, status, locale, external_id (nullable), lock_versionсам отзыв
cms_review_photosreview_id, media_id, sort_orderфото к отзыву (медиатека)
cms_review_repliesid, review_id, author_user_id, bodyответ владельца, один на отзыв
cms_review_aggregatesreviewable_type, reviewable_id, avg_rating, reviews_count, updated_atденормализованный агрегат для быстрого чтения

status — PHP Enum (pending/approved/rejected); reviewable_type+reviewable_id — составной индекс на cms_reviews и cms_review_aggregates; review_id на дочерних таблицах — constrained()->cascadeOnDelete(); external_id — уникальный per-source ключ для legacy-импорта (nullable, unique-индекс); lock_version — optimistic lock на модерации (см. «Конкурентное редактирование» ниже).

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

Входы:

ИсточникПоляЧем валидируется
Форма отправки отзыва (POST /api/v1/reviews)rating, body, reviewable_type, reviewable_id, photos[] (media_id)FormRequest whitelist, reviews.min_rating/max_rating, reviews.photos_max_count, whitelist зарегистрированных reviewable_type, антифрод-крючья при наличии cms/antifraud
Ответ владельца (POST /admin/reviews/{id}/reply)bodyFormRequest whitelist, санитайзер rich-text (сохранение+вывод)
Модерация (PUT /admin/reviews/{id})status (approved/rejected), lock_versionFormRequest whitelist, enum, optimistic lock (409 при рассинхроне)
Событие OrderCompleted (ядро/cms/commerce-orders, опционально)order_id, customer_user_id, reviewable_type/id из позиций заказаслушатель тонкий — только валидирует факт, кладёт job AskForReview
Legacy-импорт (cms:reviews:import-legacy)строки донорских таблиц (catalog/er)маппинг полей, external_id для идемпотентности

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

Выходы:

ПотребительДанныеФормат
Публичный блок «Отзывы»список одобренных отзывов сущности + агрегатконверт {data, meta}, keyset-пагинация
Карточка сущности (Schema.org)AggregateRating, ReviewJSON-LD в разметке страницы
Шина событий: ReviewSubmitted/ReviewApproved/ReviewRepliedсм. «События и обмен»канал 1, после коммита
Filament (экспорт)список отзывов с фильтрамиCSV/JSON, ручное скачивание админом
cms:reviews:recalc-aggregates --jsonотчёт пересчётаstdout JSON (диагностика/CI)

Настройки (группа reviews)

КлючТипДефолтaffectsPageCacheОписание
reviews.premoderation_enabledbooltrueнетТребовать одобрение перед публикацией
reviews.owner_reply_enabledbooltrueдаРазрешить ответ владельца на отзыв
reviews.photos_max_countint5нетМаксимум фото на отзыв (лимит, явная настройка)
reviews.min_rating/max_ratingint1/5даГраницы шкалы рейтинга
reviews.body_max_lengthint2000нетМаксимум символов текста отзыва/ответа (лимит)
reviews.submissions_per_hour_per_userint3нетКвота отправки отзывов на пользователя/устройство в час
reviews.aggregate_recalc_batch_sizeint500нетРазмер чанка батч-пересчёта агрегатов
reviews.public_submission_enabledbooltrueнетKill-switch: аварийно выключить публичную отправку отзывов (например, при волне спама), не выключая модуль целиком — витрина и рейтинги продолжают работать

Достижение лимитов (photos_max_count, body_max_length, квота отправки) отдаёт 422 с понятным сообщением и метрикой, не 500 и не тихое обрезание.

API

МетодПутьДоступНазначение
GET/api/v1/reviewspublicОдобренные отзывы сущности (пагинация, сортировка по дате/рейтингу)
POST/api/v1/reviewspublic (rate-limit, антифрод-крючья, Idempotency-Key)Отправка отзыва (в премодерацию)
GET/PUT/api/v1/admin/reviews…admin (reviews.manage)Модерация: одобрение/отклонение
POST/api/v1/admin/reviews/{id}/replyadmin (reviews.reply)Ответ владельца на отзыв

Список отзывов — keyset-пагинация по (created_at, id) или (rating, id), не OFFSET. POST /api/v1/reviews несёт Idempotency-Key — повторная отправка с тем же ключом возвращает ранее созданный отзыв, не дублирует запись.

Компоненты

Блоки (BlockRegistry): «Отзывы» (список + форма), «Рейтинг-бейдж». Fallback при выключении модуля — блок не рендерится (пустая заглушка), без 500. Filament: очередь модерации отзывов с фильтром по статусу и рейтингу, RelationManager ответов. Команды: cms:reviews:recalc-aggregates --json, cms:reviews:import-legacy --json.

Фронтенд-бюджет: блок «Отзывы» — собственные JS/CSS через пайплайн темы, загрузка фото — lazy-load по видимости, резерв высоты формы и списка (без CLS при подгрузке). Форма отзыва: звёзды рейтинга доступны с клавиатуры (стрелки/Tab), поля с label, ошибки валидации озвучиваются скринридером (aria-live).

Демо-контент: сидер ReviewsDemoSeeder — 10-15 отзывов с разным рейтингом, фото и одним ответом владельца на демо-сущности, для галереи блоков /_gallery и playground.

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

СобытиеКогдаPayload
ReviewSubmittedновый отзыв отправленreview_id, reviewable_type, reviewable_id, status
ReviewApprovedотзыв одобрен, агрегат пересчитанreviewable_type, reviewable_id, avg_rating, reviews_count
ReviewRepliedвладелец ответил на отзывreview_id

Использует антифрод-крючья cms/antifraud при наличии модуля (защита от накрутки, дубли устройства/IP). Provides-контрактов не реализует.

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

Сущность/модульКаналНаправлениеЧто происходит
Ядро: MediaServicerequires (сервис-вызов)reviews → ядроЗагрузка/привязка фото отзыва
Ядро: RequestContextrequiresreviews → ядроlocale/site_id/city_id при сохранении отзыва
Ядро: CacheTagsrequiresreviews → ядроОбъявление тега reviews, инвалидация по событиям
Ядро: EventBusканал 1reviews → все подписчикиИздание ReviewSubmitted/ReviewApproved/ReviewReplied
cms/antifraudprovides spam-filter (резолв через DI, suggests)reviews → antifraudПроверка публичной отправки на дубли/накрутку; при отсутствии — no-op деградация
cms/commerce-ordersсобытие OrderCompleted (канал 1, suggests)commerce-orders → reviewsТонкий слушатель кладёт job AskForReview (канал 5) → приглашение через NotificationDispatch (провайдер контракта notification-channel)
cms/moderationprovides moderation-workflowне реализован этим модулемОтзывы модерируются собственной статусной машиной, не движком; см. ⚠️ ниже
Filament / кабинетвнешний канал (не из 5)admin → reviewsМодерация, RelationManager ответов, экспорт

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

Джоба cms:reviews:recalc-aggregates (очередь reviews) — пересчёт avg_rating/ reviews_count по событию ReviewApproved, идемпотентна при повторном запуске, батч reviews.aggregate_recalc_batch_size для полного пересчёта. Джоба AskForReview (очередь reviews-low) — приглашение к отзыву после OrderCompleted, с задержкой (настройка отправки не входит в MVP-объём, срок — конфиг очереди).

Эксплуатация (ранбук). Метрики: reviews_submitted_total, reviews_pending_queue_size, reviews_recalc_duration_ms, reviews_antifraud_block_rate. Алерты: очередь pending растёт быстрее, чем разбирается модерацией; джоба пересчёта падает/ретраится больше 3 раз подряд.

СимптомПроверитьКоманда
Агрегат разошёлся с фактическим count отзывовпоследние ReviewApproved без пересчётаcms:reviews:recalc-aggregates --dry-run, затем без флага
Очередь модерации не двигаетсяворкер очереди reviews жив, failed jobsqueue:failed, cms:doctor --json
Отзывы дублируются после сбоя клиентаIdempotency-Key/antifraud-логи за окноручной аудит cms_reviews по user_id+reviewable_id

Бэкап/рестор: в бэкап попадают cms_reviews, cms_review_photos, cms_review_replies, cms_review_aggregates и файлы фото (медиатека). После рестора агрегаты — не пересоздаются автоматически: обязательный шаг — cms:reviews:recalc-aggregates для проверки консистентности (рестор без этого шага не считается завершённым, §15 стандарта).

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

  • Ожидаемые объёмы: тысячи–десятки тысяч отзывов на каталог, до сотен на одну сущность у популярных товаров/услуг.
  • Горячие пути: рендер блока «Отзывы» на карточке (высокочастотный, публичный), рейтинг-бейдж на листингах (N карточек на странице — агрегат не должен читаться отдельным запросом на каждую).
  • Бюджет запросов: рендер карточки с отзывами — ≤ 2 запроса (список keyset + агрегат из кеша тега); листинг с бейджами — 0 доп. запросов на элемент (агрегаты подгружаются пакетно/из кеша, не по одному).
  • Критичные индексы: составной (reviewable_type, reviewable_id) на cms_reviews и cms_review_aggregates; (reviewable_type, reviewable_id, status) для публичной выборки approved; (created_at, id)/(rating, id) под keyset-сортировки; review_id на дочерних таблицах.
  • Теги кеша: reviews:<reviewable_type>:<reviewable_id> на агрегате и списке отзывов сущности; инвалидация по ReviewApproved. Влияет на page-cache карточек с рейтинг-бейджем (affectsPageCache у связанных настроек шкалы).
  • Массовые операции: групповое одобрение в очереди модерации — батчами с батчевой инвалидацией тегов (один flush на партию сущностей), не событие на каждый отзыв.

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

Границы входа: форма отправки отзыва — FormRequest-whitelist (rating, body, reviewable_type, reviewable_id), rate-limit + антифрод-крючья на публичной отправке. Rich-текст отзыва и ответа владельца — санитизация двойным барьером (сохранение + вывод). Фото — только через медиатеку ядра, не прямая загрузка файлов в контроллере.

Векторы: подмена reviewable_type на непубличный/чужой домен (whitelist зарегистрированных типов на FormRequest) · накрутка рейтинга ботами при отсутствии cms/antifraud (компенсация — премодерация по умолчанию включена) · XSS через тело отзыва/ответа (санитайзер ядра, не собственный) · перебор review_id/reviewable_id (публичные идентификаторы — slug/UUID, не автоинкремент, в API-ответах и URL).

Права: reviews.view, reviews.manage, reviews.replyreply отделено от manage (ответ владельца сущности vs модерация отзывов).

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

PermissionАдминМенеджерРедакторStudio
reviews.view
reviews.manage (модерация)
reviews.reply (ответ владельца)✅ (по своим сущностям)

ПДн-паспорт. Хранит: user_id (nullable — анонимные отзывы допустимы), текст отзыва (может содержать личные данные автора), фото (потенциально лица людей на снимках). Ретеншн: отзывы хранятся бессрочно как контент сайта (аналог публикации), но ПДн-часть (привязка к user_id, фото) обрабатывается отдельно — хук ядра «выгрузить всё по субъекту» отдаёт список отзывов/фото пользователя; хук «забыть по запросу» обезличивает user_id → null и удаляет фото из медиатеки, сам текст отзыва и агрегат остаются (контентная, не персональная часть) — модуль не хранит ПДн в самом тексте отзыва по умолчанию (санитайзер не вырезает случайно указанные читателем данные — это ответственность автора, но экспорт/забвение обязаны сработать по user_id/фото).

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

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

Посетитель: форма отправки отзыва сохраняет введённый текст/рейтинг/выбранные фото при ошибке валидации (не сбрасывается); список отзывов и агрегат отдаются из кеша без ощутимой задержки, отправка — мгновенный отклик «отправлено, ожидает модерации»; звёзды рейтинга управляются с клавиатуры и объявляются скринридером (aria-label="Оценка N из 5"), загрузка фото имеет текстовую альтернативу и прогресс.

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

  • Двойная отправка отзыва (тот же пользователь/устройство дважды подряд на одну сущность) → публичный POST несёт Idempotency-Key; сервис дополнительно проверяет уникальность по (user_id или device-fingerprint, reviewable_type, reviewable_id) за окно reviews.submissions_per_hour_per_user — повторный сабмит возвращает уже созданный отзыв (200), не создаёт дубль.
  • Гонка при параллельном одобрении нескольких отзывов одной сущности (два админа жмут «одобрить» почти одновременно) → пересчёт агрегата — не «прочитать в PHP, прибавить, записать», а атомарный UPDATE cms_review_aggregates SET reviews_count = reviews_count + 1, avg_rating = (avg_rating * reviews_count + :rating) / (reviews_count + 1) внутри транзакции с SELECT … FOR UPDATE по строке агрегата (блокировка на reviewable_type+reviewable_id); джоба cms:reviews:recalc-aggregates идемпотентна и может перезапускаться как самокоррекция при подозрении на рассинхрон.
  • Отзыв на сущность, которую потом удалили (reviewable_id больше не существует) → блок «Отзывы» на публичной карточке недостижим (страница 404 у самой сущности), отзыв и агрегат не удаляются каскадом (журнал остаётся), в списке модерации/Filament запись помечается «сущность удалена» вместо попытки построить битую ссылку.
  • Schema.org AggregateRating должен строго соответствовать агрегату — гонка рендера страницы во время пересчёта: JSON-LD генерируется из того же чтения агрегата, что и рейтинг-бейдж (один SELECT/кеш-хит на рендер), а не из отдельного запроса в другой момент времени; устаревание закрывается инвалидацией тега по ReviewApproved, не повторным чтением «на всякий случай».
  • Фото отзыва превышает photos_max_count при одновременной загрузке нескольких файлов → проверка count(существующие + входящие) выполняется атомарно в транзакции сохранения отзыва (не по одной проверке на файл), лишние отклоняются 422 с указанием лимита, а не тихо обрезаются до допустимого числа.
  • Антифрод-крючья отсутствуют (cms/antifraud не установлен — suggests, деградация) → публичная отправка работает на общем rate-limit ядра без device-fingerprint/детекции дублей; премодерация остаётся включённой по умолчанию как компенсирующий контроль, факт отсутствия модуля виден в health-чеке.
  • Ответ владельца на уже удалённый/отклонённый отзыв → API отдаёт 404 (отзыв не найден) или 409 (отзыв не в статусе approved); Filament показывает кнопку ответа только на карточках отзывов в статусе approved.
  • Конкурентное редактирование модерации двумя админами (оба открывают один и тот же pending-отзыв) → lock_version (optimistic lock) на cms_reviews; второй save получает 409 и сообщение «отзыв уже обработан кем-то другим, обновите страницу», не молчаливый «последний победил».
  • reviewable_type не входит в whitelist зарегистрированных типов сущностей → POST /api/v1/reviews отклоняет запрос 422 на этапе FormRequest, не создавая запись «в никуда» (защита от произвольной полиморфной привязки).
  • ⚠️ Противоречие: cms/reviews не использует provides-контракт moderation-workflow. docs/modules/moderation.md перечисляет cms/reviews как штатного потребителя общего движка модерации («Общий движок, которым пользуются cms/ugc, cms/comments, cms/reviews»), но манифест reviews не объявляет suggests: cms/moderation и не регистрирует свою сущность через контракт — модуль ведёт собственную статусную машину pending/approved/rejected без истории переходов, назначения ревьюеров и SLA. Разрешение: добавить suggests: cms/moderation в манифест reviews; при включённом cms/moderation регистрировать Review как moderatable_type через контракт (делегируя статус, историю и SLA движку, status на cms_reviews становится проекцией cms_moderation_items.status); при выключенном модуле — деградация к текущей собственной статусной машине (уже описано в «Зависимости и выключение»). До реализации разрешения оба ТЗ фиксируют его явно здесь, дальнейшее решение — за приоритизацией разработки (см. также открытые вопросы, если пункт не будет закрыт до старта).

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

Что взятьПуть
Полиморфные отзывы каталога, агрегаты рейтингаcatalog (пути не выданы — только имя проекта)
Отзывы услуг с ответом владельцаer (пути не выданы — только имя проекта)

Legacy-импорт. cms:reviews:import-legacy --source=catalog|er --dry-run — маппинг донорских таблиц отзывов (catalog: полиморфные отзывы товаров/разделов; er: отзывы услуг с ответом владельца) на cms_reviews/cms_review_replies/cms_review_photos; идемпотентна по external_id (повторный прогон обновляет существующие записи, не дублирует); шкала рейтинга донора приводится к reviews.min_rating/max_rating, если отличается; --dry-run печатает отчёт расхождений без записи. Прогон на копии донорских данных — часть приёмки модуля (§16 стандарта).

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

  • [ ] Контрактный тест: одобрение отзыва пересчитывает avg_rating/reviews_count корректно
  • [ ] Контрактный тест: параллельное одобрение двух отзывов одной сущности не теряет инкремент (гонка FOR UPDATE/атомарный UPDATE, не read-modify-write в PHP)
  • [ ] Повторная отправка отзыва с тем же Idempotency-Key/device не создаёт дубль
  • [ ] Загрузка фото сверх photos_max_count при параллельных запросах отклоняется 422, не превышает лимит в БД
  • [ ] Модерация одного и того же отзыва двумя запросами параллельно — второй получает 409 (lock_version)
  • [ ] При выключении модуля карточки сущностей рендерятся без блока отзывов, без 500
  • [ ] Антифрод-крючья блокируют повторную отправку с того же устройства/IP за окно времени; при отсутствии cms/antifraud форма работает на общем rate-limit без падения
  • [ ] Права reviews.reply отделены от reviews.manage (ответ владельца vs модерация)
  • [ ] Нет N+1 при выводе списка отзывов с фото (->with('photos', 'reply'))
  • [ ] Агрегат рейтинга кешируется и инвалидируется по тегу при ReviewApproved
  • [ ] Schema.org AggregateRating соответствует значению денормализованного агрегата в том же рендере, что и бейдж (нет расхождения при устаревшем кеше)
  • [ ] Ответ владельца на неодобренный/удалённый отзыв отклоняется (404/409)
  • [ ] Хуки «выгрузить всё по субъекту» и «забыть по запросу» покрывают user_id и фото
  • [ ] cms:reviews:import-legacy --dry-run прогнан на копии донорских данных, отчёт полн
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (список, отправка, модерация, ответ владельца)
  • [ ] Тестовая БД только reviews_test; migrate:fresh/refresh/reset/db:wipe запрещены

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