Skip to content

ТЗ — Опросы/голосования (cms/surveys)

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

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

Опросы с несколькими типами вопросов на движке полей ядра, голосование с защитой от накрутки, результаты с диаграммами и экспорт. Отдельный модуль от cms/wizard — здесь фокус на голосовании и агрегации результатов, не на многошаговом сборе данных для лида.

  • Типы вопросов: одиночный/множественный выбор, шкала, свободный текст (движок полей ядра)
  • Голосование анонимное или с привязкой к пользователю (конфигурируется per-опрос)
  • Защита от накрутки: rate-limit по IP, browser fingerprint, один голос на fingerprint+опрос, опционально — на аккаунт (user_id) для неанонимных опросов
  • Результаты в реальном времени с лёгкими диаграммами (по типу вопроса — bar/pie/среднее по шкале), без тяжёлых чарт-библиотек на горячем пути
  • Период проведения опроса (starts_at/ends_at), автозакрытие
  • Экспорт результатов (CSV) для внешнего анализа
  • Промежуточные результаты видны участнику после голосования (опционально)
  • Аварийное отключение приёма новых голосов (kill-switch) без выключения модуля — на случай инцидента с накруткой

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

requires: ядро · suggests: cms/attack-monitor (публикация событий накрутки/rate-limit, если модуль включён)

Поведение при выключении: активные опросы скрываются (fallback — заглушка), сбор новых голосов останавливается, накопленные результаты и голоса сохраняются. Голос, чья транзакция уже закоммичена на момент отключения, не откатывается и не теряется (детали гонки — «Крайние случаи», п. 6).

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

ТаблицаКлючевые поляПримечание
cms_surveysid, slug, title, starts_at, ends_at, is_anonymous, locale, city_id (nullable), lock_version, external_id (nullable), closed_at (nullable)сам опрос
cms_survey_questionsid, survey_id, field_type, question, options (json), sort_order, lock_versionвопрос (движок полей ядра); удалённый вариант — archived: true внутри options, физическое удаление запрещено
cms_survey_votesid, question_id, user_id (nullable), fingerprint, answer (json), external_id (nullable), created_atголос с защитой от накрутки, append-only
cms_survey_option_countsquestion_id, option_key, votes_countденормализованный агрегат — инкремент в той же транзакции, что и голос; источник для /results без COUNT()

survey_id/question_idconstrained()->cascadeOnDelete()->index(); на cms_survey_votes — два частичных уникальных индекса: unique(question_id, fingerprint) where fingerprint is not null и unique(question_id, user_id) where user_id is not null (два независимых уровня защиты «один голос», см. настройку surveys.vote_uniqueness_strategy и «Крайние случаи»); unique(question_id, option_key) на cms_survey_option_counts; field_type — PHP Enum; cms_survey_votes — append-only журнал, BRIN-индекс по created_at при объёме от нескольких сотен тысяч строк (specs: миграции); lock_version — optimistic lock на опросе и вопросе (§4 стандарта). external_id — идемпотентность legacy-импорта.

ПДн-паспорт (кратко): fingerprint и user_id в cms_survey_votes — потенциально идентифицирующие данные; полный паспорт (срок хранения, «забыть по запросу») — в разделе «Безопасность».

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

Входы

ИсточникКаналПоляВалидация
Посетитель — форма голосованияPOST /api/v1/surveys/{slug}/vote (публичный)question_id, answer, fingerprintFormRequest-whitelist: answer строго по option_key активных (не archived) вариантов вопроса; rate-limit и уникальность проверяются до записи
Админ — CRUD опроса/вопросаPOST/PUT/DELETE /api/v1/admin/surveys…title, starts_at, ends_at, is_anonymous, city_id, questions[]FormRequest, lock_version на обновлении, лимит surveys.max_questions_per_survey
Админ — экспорт результатовGET /api/v1/admin/surveys/{id}/exportid опроса (путь)permission surveys.export, пользовательского входа нет
Рендер блока «Опрос»вызов сервиса модуля из BlockRegistrysurvey_id/slug из JSONB блокабез пользовательского входа, читает только опубликованный/активный опрос
Legacy-импортcms:surveys:import-legacy --source=referendumдамп/CSV донора (опросы, вопросы, голоса)маппинг полей + --dry-run, external_id — ключ идемпотентности

Выходы

КаналФорматСодержимое
Ответ POST /voteJSON {data}{accepted: true, results?: […]} — агрегаты попадают в ответ только при results_visible_after_vote=true
Ответ GET /{slug}JSON {data}схема опроса: вопросы, только активные варианты (archived скрыты)
Ответ GET /{slug}/resultsJSON {data}агрегаты по вопросам (option_key, votes_count, percent) из cms_survey_option_counts
Событие SurveyVoteCastEventBus (канал 1)survey_id, question_id, fingerprint_hash
Событие SurveyClosedEventBus (канал 1)survey_id, total_votes
Экспорт CSVфайлголоса построчно; fingerprint — только хеш, user_id — если опрос не анонимный
Рендер блока «Опрос»props компонентасхема + агрегаты (с учётом results_visible_after_vote)

Всё, что не перечислено как вход, модуль обязан отвергать (whitelist-принцип §11 стандарта) — в частности, answer вне списка option_key активного вопроса.

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

КлючТипДефолтaffectsPageCacheОписание
surveys.one_vote_per_fingerprintbooltrueнетОдин голос на fingerprint+опрос
surveys.vote_uniqueness_strategyenumfingerprintнетfingerprint / fingerprint_and_ip / user_id_when_authenticated — какой уровень защиты главный при расхождении (см. «Крайние случаи», п. 1)
surveys.results_visible_after_votebooltrueнетПоказывать результаты после голосования; при false/results требует факта голосования
surveys.rate_limit_per_hourint1нетЛимит голосований с одного IP в час, проверяется до записи голоса
surveys.max_questions_per_surveyint20нетМаксимум вопросов в одном опросе — защита конструктора
surveys.voting_enabledbooltrueнетKill-switch: аварийная остановка приёма новых голосов без выключения модуля

API

МетодПутьДоступНазначение
GET/api/v1/surveys/{slug}publicСхема опроса (вопросы, активные варианты)
POST/api/v1/surveys/{slug}/votepublic (rate-limit, fingerprint, kill-switch)Отправка голоса; voting_enabled=false423
GET/api/v1/surveys/{slug}/resultspublic (см. results_visible_after_vote)Агрегаты; без факта голосования при results_visible_after_vote=false403
GET/POST/PUT/DELETE/api/v1/admin/surveys…admin (surveys.manage)CRUD опросов и вопросов, lock_version на update → 409 при конфликте
GET/api/v1/admin/surveys/{id}/exportadmin (surveys.export)Экспорт голосов в CSV (без сырого fingerprint)

Компоненты

Блоки (BlockRegistry): «Опрос» (вопросы + форма голосования + диаграмма результатов) — диаграммы лёгкие (SVG/CSS, без синхронных чарт-библиотек), lazy-load после голосования, резерв места под диаграмму (без CLS), варианты ответа доступны с клавиатуры (radio/ checkbox с <label>, видимый :focus). Filament: конструктор опроса (вопросы drag&drop) с предупреждением при правке вопроса с существующими голосами, дашборд результатов с диаграммами (человекочитаемый вид по умолчанию, таблица сырых чисел — опция). Демо-сидер: опрос с 2 вопросами (одиночный выбор + шкала) и синтетическими голосами — обязателен для /_gallery и playground. Команды: cms:surveys:close-expired --json, cms:surveys:import-legacy --source=<профиль> --dry-run --json.

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

СобытиеКогдаPayload
SurveyVoteCastголос принятsurvey_id, question_id, fingerprint_hash
SurveyClosedопрос завершён (по ends_at или вручную)survey_id, total_votes

Слушает: —. Provides-контрактов не реализует, FilterBus не использует. Не использует LeadService — голос не создаёт заявку; это архитектурное отличие от cms/wizard (многошаговый сбор данных для лида).

Взаимодействия

Сущность/модульКаналНаправлениеЧто происходит
cms/core (ScheduleRegistrar)очереди/расписание (канал 5)ядро → модультик планировщика запускает джобу close-expired
cms/attack-monitor (suggests)шина событий (канал 1)модуль → attack-monitorпревышение rate-limit/подозрение на накрутку публикуется как событие безопасности, если модуль включён
cms/wizard (сосед)нет каналаархитектурно не связаны: голос не проходит через LeadService, не создаёт лид
cms/core-contractsBlockRegistry/CacheTags/SettingsStore/ScheduleRegistrarмодуль → ядрорегистрация блока, тегов кеша, чтение группы настроек surveys, расписание автозакрытия

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

Джоба cms:surveys:close-expired (очередь surveys) — расписание через ScheduleRegistrar ядра, автозакрытие опросов по ends_at, идемпотентна (повторный запуск не закрывает уже закрытый опрос дважды). Пропущенный тик не считается багом голосования: опрос закрывается со следующим тиком, голоса между просроченным ends_at и фактическим закрытием принимаются как валидные (форма скрывается сразу после факта закрытия — см. «Крайние случаи», п. 9). При выключении модуля/срабатывании kill-switch голос, уже принятый в рамках закоммиченной транзакции запроса, не откатывается — деградация действует только на новые запросы после факта отключения.

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

Объёмы: десятки активных опросов одновременно, от сотен до нескольких тысяч голосов на популярный опрос (legacy-импорт исторически — до сотен тысяч строк).

Горячие пути:

  • отправка голоса (POST /vote) — запись строки в cms_survey_votes + инкремент cms_survey_option_counts.votes_count одной транзакцией; полного пересчёта по всем голосам вопроса на каждый запрос нет;
  • чтение результатов (GET /results) — агрегат берётся из cms_survey_option_counts (кеш тега surveys:results:{question_id}), не COUNT()/GROUP BY по cms_survey_votes на каждый рендер.

Индексы: частичные unique(question_id, fingerprint) where fingerprint is not null и unique(question_id, user_id) where user_id is not null — на горячем пути записи; BRIN на created_at в cms_survey_votes при объёме от нескольких сотен тысяч строк (журнальная таблица, §4 стандарта).

Теги кеша: surveys — на схеме опроса; surveys:results:{question_id} — на агрегате результатов, инвалидация точечная по SurveyVoteCast (не полный flush группы) и surveys:{slug} целиком по SurveyClosed. Настройки группы surveys читаются из кеша группы — 0 запросов на горячем пути (§6 стандарта). Страница с активным опросом при results_visible_after_vote=true отдаётся по-разному до/после факта голоса — это сегментация публичного рендера, а не общий page-cache (§10 стандарта).

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

Границы входа: FormRequest-whitelist на отправку голоса (answer — строго по option_key активных вариантов вопроса, archived-варианты недоступны для голосования) и на конструктор опроса/вопроса (админка, lock_version на update). Rate-limit по IP применяется до записи голоса (в authorize()/middleware, не постфактум) — накрутка не долетает до транзакции. Уровни защиты от накрутки складываются, не заменяют друг друга: IP+rate-limit (surveys.rate_limit_per_hour) — заградительный уровень от массовых атак; fingerprint (частичный unique) — «один голос с браузера»; user_id (частичный unique, только is_anonymous=false) — «один голос с аккаунта». Какой уровень главный при расхождении между ними — конфигурируется surveys.vote_uniqueness_strategy (детали — «Крайние случаи», п. 1).

Fingerprint хранится и экспортируется только в виде SHA-256 хеша с солью ядра, не в сыром browser-fingerprint значении — ни в БД, ни в логах, ни в экспорте. Экспорт CSV — доступ только surveys.export (отдельно от surveys.manage, чтобы не выдавать fingerprint-данные редактору по умолчанию — матрица ролей ниже).

ПДн-паспорт. fingerprint (технический отпечаток браузера, псевдоним) и user_id (для неанонимных опросов) в cms_survey_votes — потенциально идентифицирующие данные. Хранение: append-only, без автоматического TTL (дефолт — бессрочно; ретеншн — решение клиента/студии, фиксируется в docs/module.md при вводе в эксплуатацию конкретного сайта). Участие в 152-ФЗ хуках ядра: «выгрузить всё по субъекту» — отдаёт список голосов по user_id; «забыть по запросу» — обезличивание: user_id → null, fingerprint → необратимый пере-хеш без исходной соли; сам факт голоса (answer) и агрегат cms_survey_option_counts сохраняются — обезличивание не искажает статистику.

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

РольПросмотр результатовCRUD опросаЭкспорт CSVУдаление опроса с голосами
Посетительпубличные (по настройке)нетнетнет
Редактордаданетнет
Менеджердадада (surveys.export)нет
Adminдададада, с подтверждением
Studioдададада, включая обязательные модули парка

Права: surveys.view, surveys.manage, surveys.export.

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

Админ. Пустое состояние без опросов — заглушка с CTA «создать опрос» вместо пустой таблицы. Дашборд результатов — диаграммы человекочитаемого вида по умолчанию (bar/pie/ среднее по шкале), таблица сырых чисел — опция, не дефолт. Попытка изменить варианты ответа у вопроса с существующими голосами — предупреждение «в опросе уже N голосов — изменение вариантов исказит статистику» с явным выбором «архивировать вариант» / «отменить» (физическое удаление недоступно из UI). Закрытие или удаление опроса с голосами — подтверждение с числом голосов и подсказкой «экспортируйте перед удалением».

Посетитель. Повторная попытка голосования — понятное сообщение «вы уже голосовали» вместо формы или молчаливой ошибки; если результаты открыты — сразу показываются вместо формы. Результат голосования виден сразу после отправки без перезагрузки страницы (ответ POST /vote инлайн подменяет форму диаграммой). Свободный текстовый ответ при ошибке валидации не теряется (введённое значение остаётся в поле). Варианты ответа доступны с клавиатуры (Tab/Space/Enter, видимый :focus, <label> на каждый вариант).

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

  1. Повторное голосование, расхождение уровней защиты. Один браузер, но VPN меняет IP между попытками — fingerprint не привязан к IP, второй голос всё равно отклоняется частичным unique-индексом. Другой браузер/инкогнито при том же user_id (опрос не анонимный) — блокирует user_id-индекс. Итог: при расхождении user_id (если есть) главнее fingerprint, fingerprint главнее IP-throttle — порядок фиксирует surveys.vote_uniqueness_strategy; IP-throttle не заменяет ни один из двух уровней.
  2. Изменение вариантов ответа после голосов. Вариант с голосами не удаляется — помечается archived: true в options (см. «Модель данных»), недоступен для новых голосов, но сохраняется в истории и в агрегате cms_survey_option_counts с пометкой; Filament блокирует физическое удаление и предлагает архивацию.
  3. results_visible_after_vote=false и публичный /results. ⚠️ Противоречие в исходном ТЗ: не было решено, скрывать ли эндпоинт целиком или отдавать частичный ответ. Разрешение: эндпоинт не скрывается целиком (нужен виджетам агрегатов и админке), но запрос без метки голосования (fingerprint не найден среди голосов вопроса) получает 403 с телом {message: "голосуйте, чтобы увидеть результаты"}; для Filament/админки ограничение не действует.
  4. Двойной сабмит формы голосования. Идемпотентность на уровне частичного unique-индекса; второй одновременный запрос получает 409 с сообщением «голос уже учтён», не создаёт дубль и не роняет обработчик в 500.
  5. Опрос без активных вопросов. Блок отдаёт «опрос временно недоступен» вместо пустой формы; Filament не даёт опубликовать опрос без хотя бы одного вопроса.
  6. Выключение модуля/kill-switch посреди голосования. Голос, чья транзакция уже закоммичена на момент отключения, не теряется и не откатывается; запросы, пришедшие после факта отключения, получают fallback/423, а не тихую потерю данных.
  7. Отсутствие измерения city/locale. city_id/locale в cms_surveys — nullable; опрос без города или локали виден на всех сайтах/городах инсталляции; контрактный тест гоняется в обоих режимах (с измерением и без).
  8. Конкурентное редактирование опроса двумя админами. lock_version на cms_surveys/cms_survey_questions; устаревшая версия при сохранении — 409 с сообщением «опрос изменён другим пользователем, обновите страницу», не молчаливая перезапись.
  9. Пропущенный тик close-expired. Опрос остаётся открытым дольше ends_at до следующего тика — это не считается багом голосования. ⚠️ Противоречие в исходном ТЗ: не было решено, засчитывать ли голоса, поданные после ends_at, но до фактического закрытия. Разрешение: засчитываются как валидные (форма доступна — голос легитимен по факту доступности, не по факту дедлайна); SurveyClosed.total_votes фиксирует итог на момент фактического закрытия, включая эти голоса.
  10. Дубли при legacy-импорте. Донор мог не иметь защиты «один голос на fingerprint» — строка, конфликтующая с unique(question_id, fingerprint) при импорте, не валит весь прогон: пропускается с причиной «дубликат по fingerprint» в отчёте, агрегат пересчитывается по фактически импортированным голосам, не по числу строк донора.
  11. Свободный текстовый ответ со скриптом/HTML. Санитизация двойным барьером: хранится как plain text (без интерпретации разметки), экранируется при выводе в списке ответов админки — rich-text для этого типа вопроса не разрешён.

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

Что взятьПуть
Логика опросов, защита от накруткиreferendum/app/ (Services)

Legacy-импорт

Команда cms:surveys:import-legacy --source=referendum [--dry-run] --json — маппинг таблиц опросов/вопросов/голосов донора referendum на cms_surveys/ cms_survey_questions/cms_survey_votes; идемпотентность по external_id (повторный прогон обновляет, не дублирует опрос/вопрос). Голоса донора без собственной защиты от накрутки, конфликтующие с unique(question_id, fingerprint) при импорте, не валят прогон целиком — пропускаются с причиной в отчёте (см. «Крайние случаи», п. 10). --dry-run показывает diff (сколько будет создано/обновлено/пропущено и почему) без записи. Прогон на копии донорских данных — часть приёмки модуля.

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

  • [ ] Контрактный тест: повторный голос с тем же fingerprint отклоняется
  • [ ] При выключении модуля блок опроса отдаёт fallback без 500, голосование недоступно
  • [ ] Rate-limit и fingerprint-проверка реально блокируют накрутку в нагрузочном тесте
  • [ ] Права surveys.manage/surveys.export разграничены от публичного голосования и друг от друга (экспорт недоступен редактору)
  • [ ] Нет N+1 при агрегации результатов по вопросам (чтение из cms_survey_option_counts, не COUNT()/GROUP BY по голосам на каждый запрос)
  • [ ] Экспорт CSV не хранит fingerprint в явном виде (хеш, не сырое значение)
  • [ ] Автозакрытие по ends_at работает через планировщик, не требует ручного вмешательства; пропущенный тик закрывает опрос со следующим прогоном
  • [ ] Изменение вариантов вопроса с существующими голосами архивирует вариант (archived: true), не удаляет физически — покрыто тестом
  • [ ] results_visible_after_vote=false: GET /results отдаёт 403 до факта голосования и 200 после — тест обоих состояний
  • [ ] Двойной сабмит формы голосования не создаёт дубль голоса (409 на второй запрос)
  • [ ] Конкурентное редактирование опроса — 409 при устаревшем lock_version
  • [ ] Kill-switch surveys.voting_enabled=false останавливает приём голосов без выключения модуля, чтение результатов и админка продолжают работать
  • [ ] Legacy-импорт (cms:surveys:import-legacy --dry-run) идемпотентен по external_id, прогнан на копии донорских данных, дубли донора не валят прогон
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (схема, голос, результаты, admin CRUD, экспорт)
  • [ ] Тестовая БД только surveys_test; migrate:fresh/refresh/reset/db:wipe запрещены

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