Тема
ТЗ — Отзывы/рейтинги (cms/reviews)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: catalog, er Статус: ТЗ к разработке
🔄 Ревизия (корпоративный MVP, 15.07.2026, решение №8 сессии): отзывы — пресет типа контента с модерацией, приоритет P1, на движке типов контента с Schema.org Review/AggregateRating. Связь с товаром/услугой — типизированная связь движка. Разделы ниже — свойства/связи/шаблоны пресета.
✅ Решено 15.07.2026 (пресет vs бесспок — закрыто): отзыв — пресет движка (
cms_content_entries, типreviews),subject— типизированная связьrelationс whitelisttarget. Тяжёлое выносится в тонкую надстройку модуля рядом с движком, а не в движок: денормализованный агрегат рейтинга — собственной лёгкой таблицей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_reviews | id, reviewable_type, reviewable_id, user_id (nullable), rating, body, status, locale, external_id (nullable), lock_version | сам отзыв |
cms_review_photos | review_id, media_id, sort_order | фото к отзыву (медиатека) |
cms_review_replies | id, review_id, author_user_id, body | ответ владельца, один на отзыв |
cms_review_aggregates | reviewable_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) | body | FormRequest whitelist, санитайзер rich-text (сохранение+вывод) |
Модерация (PUT /admin/reviews/{id}) | status (approved/rejected), lock_version | FormRequest 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, Review | JSON-LD в разметке страницы |
Шина событий: ReviewSubmitted/ReviewApproved/ReviewReplied | см. «События и обмен» | канал 1, после коммита |
| Filament (экспорт) | список отзывов с фильтрами | CSV/JSON, ручное скачивание админом |
cms:reviews:recalc-aggregates --json | отчёт пересчёта | stdout JSON (диагностика/CI) |
Настройки (группа reviews)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
reviews.premoderation_enabled | bool | true | нет | Требовать одобрение перед публикацией |
reviews.owner_reply_enabled | bool | true | да | Разрешить ответ владельца на отзыв |
reviews.photos_max_count | int | 5 | нет | Максимум фото на отзыв (лимит, явная настройка) |
reviews.min_rating/max_rating | int | 1/5 | да | Границы шкалы рейтинга |
reviews.body_max_length | int | 2000 | нет | Максимум символов текста отзыва/ответа (лимит) |
reviews.submissions_per_hour_per_user | int | 3 | нет | Квота отправки отзывов на пользователя/устройство в час |
reviews.aggregate_recalc_batch_size | int | 500 | нет | Размер чанка батч-пересчёта агрегатов |
reviews.public_submission_enabled | bool | true | нет | Kill-switch: аварийно выключить публичную отправку отзывов (например, при волне спама), не выключая модуль целиком — витрина и рейтинги продолжают работать |
Достижение лимитов (photos_max_count, body_max_length, квота отправки) отдаёт 422 с понятным сообщением и метрикой, не 500 и не тихое обрезание.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/reviews | public | Одобренные отзывы сущности (пагинация, сортировка по дате/рейтингу) |
| POST | /api/v1/reviews | public (rate-limit, антифрод-крючья, Idempotency-Key) | Отправка отзыва (в премодерацию) |
| GET/PUT | /api/v1/admin/reviews… | admin (reviews.manage) | Модерация: одобрение/отклонение |
| POST | /api/v1/admin/reviews/{id}/reply | admin (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-контрактов не реализует.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро: MediaService | requires (сервис-вызов) | reviews → ядро | Загрузка/привязка фото отзыва |
Ядро: RequestContext | requires | reviews → ядро | locale/site_id/city_id при сохранении отзыва |
Ядро: CacheTags | requires | reviews → ядро | Объявление тега reviews, инвалидация по событиям |
Ядро: EventBus | канал 1 | reviews → все подписчики | Издание ReviewSubmitted/ReviewApproved/ReviewReplied |
cms/antifraud | provides spam-filter (резолв через DI, suggests) | reviews → antifraud | Проверка публичной отправки на дубли/накрутку; при отсутствии — no-op деградация |
cms/commerce-orders | событие OrderCompleted (канал 1, suggests) | commerce-orders → reviews | Тонкий слушатель кладёт job AskForReview (канал 5) → приглашение через NotificationDispatch (провайдер контракта notification-channel) |
cms/moderation | provides 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 jobs | queue: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.reply — reply отделено от 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запрещены