Тема
ТЗ — Опросы/голосования (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_surveys | id, slug, title, starts_at, ends_at, is_anonymous, locale, city_id (nullable), lock_version, external_id (nullable), closed_at (nullable) | сам опрос |
cms_survey_questions | id, survey_id, field_type, question, options (json), sort_order, lock_version | вопрос (движок полей ядра); удалённый вариант — archived: true внутри options, физическое удаление запрещено |
cms_survey_votes | id, question_id, user_id (nullable), fingerprint, answer (json), external_id (nullable), created_at | голос с защитой от накрутки, append-only |
cms_survey_option_counts | question_id, option_key, votes_count | денормализованный агрегат — инкремент в той же транзакции, что и голос; источник для /results без COUNT() |
survey_id/question_id — constrained()->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, fingerprint | FormRequest-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}/export | id опроса (путь) | permission surveys.export, пользовательского входа нет |
| Рендер блока «Опрос» | вызов сервиса модуля из BlockRegistry | survey_id/slug из JSONB блока | без пользовательского входа, читает только опубликованный/активный опрос |
| Legacy-импорт | cms:surveys:import-legacy --source=referendum | дамп/CSV донора (опросы, вопросы, голоса) | маппинг полей + --dry-run, external_id — ключ идемпотентности |
Выходы
| Канал | Формат | Содержимое |
|---|---|---|
Ответ POST /vote | JSON {data} | {accepted: true, results?: […]} — агрегаты попадают в ответ только при results_visible_after_vote=true |
Ответ GET /{slug} | JSON {data} | схема опроса: вопросы, только активные варианты (archived скрыты) |
Ответ GET /{slug}/results | JSON {data} | агрегаты по вопросам (option_key, votes_count, percent) из cms_survey_option_counts |
Событие SurveyVoteCast | EventBus (канал 1) | survey_id, question_id, fingerprint_hash |
Событие SurveyClosed | EventBus (канал 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_fingerprint | bool | true | нет | Один голос на fingerprint+опрос |
surveys.vote_uniqueness_strategy | enum | fingerprint | нет | fingerprint / fingerprint_and_ip / user_id_when_authenticated — какой уровень защиты главный при расхождении (см. «Крайние случаи», п. 1) |
surveys.results_visible_after_vote | bool | true | нет | Показывать результаты после голосования; при false — /results требует факта голосования |
surveys.rate_limit_per_hour | int | 1 | нет | Лимит голосований с одного IP в час, проверяется до записи голоса |
surveys.max_questions_per_survey | int | 20 | нет | Максимум вопросов в одном опросе — защита конструктора |
surveys.voting_enabled | bool | true | нет | Kill-switch: аварийная остановка приёма новых голосов без выключения модуля |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/surveys/{slug} | public | Схема опроса (вопросы, активные варианты) |
| POST | /api/v1/surveys/{slug}/vote | public (rate-limit, fingerprint, kill-switch) | Отправка голоса; voting_enabled=false → 423 |
| GET | /api/v1/surveys/{slug}/results | public (см. results_visible_after_vote) | Агрегаты; без факта голосования при results_visible_after_vote=false → 403 |
| GET/POST/PUT/DELETE | /api/v1/admin/surveys… | admin (surveys.manage) | CRUD опросов и вопросов, lock_version на update → 409 при конфликте |
| GET | /api/v1/admin/surveys/{id}/export | admin (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-contracts | BlockRegistry/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> на каждый вариант).
Крайние случаи и типовые баги
- Повторное голосование, расхождение уровней защиты. Один браузер, но VPN меняет IP между попытками — fingerprint не привязан к IP, второй голос всё равно отклоняется частичным unique-индексом. Другой браузер/инкогнито при том же
user_id(опрос не анонимный) — блокируетuser_id-индекс. Итог: при расхожденииuser_id(если есть) главнееfingerprint,fingerprintглавнее IP-throttle — порядок фиксируетsurveys.vote_uniqueness_strategy; IP-throttle не заменяет ни один из двух уровней. - Изменение вариантов ответа после голосов. Вариант с голосами не удаляется — помечается
archived: trueвoptions(см. «Модель данных»), недоступен для новых голосов, но сохраняется в истории и в агрегатеcms_survey_option_countsс пометкой; Filament блокирует физическое удаление и предлагает архивацию. results_visible_after_vote=falseи публичный/results. ⚠️ Противоречие в исходном ТЗ: не было решено, скрывать ли эндпоинт целиком или отдавать частичный ответ. Разрешение: эндпоинт не скрывается целиком (нужен виджетам агрегатов и админке), но запрос без метки голосования (fingerprintне найден среди голосов вопроса) получает403с телом{message: "голосуйте, чтобы увидеть результаты"}; для Filament/админки ограничение не действует.- Двойной сабмит формы голосования. Идемпотентность на уровне частичного unique-индекса; второй одновременный запрос получает
409с сообщением «голос уже учтён», не создаёт дубль и не роняет обработчик в500. - Опрос без активных вопросов. Блок отдаёт «опрос временно недоступен» вместо пустой формы; Filament не даёт опубликовать опрос без хотя бы одного вопроса.
- Выключение модуля/kill-switch посреди голосования. Голос, чья транзакция уже закоммичена на момент отключения, не теряется и не откатывается; запросы, пришедшие после факта отключения, получают fallback/
423, а не тихую потерю данных. - Отсутствие измерения city/locale.
city_id/localeвcms_surveys— nullable; опрос без города или локали виден на всех сайтах/городах инсталляции; контрактный тест гоняется в обоих режимах (с измерением и без). - Конкурентное редактирование опроса двумя админами.
lock_versionнаcms_surveys/cms_survey_questions; устаревшая версия при сохранении —409с сообщением «опрос изменён другим пользователем, обновите страницу», не молчаливая перезапись. - Пропущенный тик
close-expired. Опрос остаётся открытым дольшеends_atдо следующего тика — это не считается багом голосования. ⚠️ Противоречие в исходном ТЗ: не было решено, засчитывать ли голоса, поданные послеends_at, но до фактического закрытия. Разрешение: засчитываются как валидные (форма доступна — голос легитимен по факту доступности, не по факту дедлайна);SurveyClosed.total_votesфиксирует итог на момент фактического закрытия, включая эти голоса. - Дубли при legacy-импорте. Донор мог не иметь защиты «один голос на fingerprint» — строка, конфликтующая с
unique(question_id, fingerprint)при импорте, не валит весь прогон: пропускается с причиной «дубликат по fingerprint» в отчёте, агрегат пересчитывается по фактически импортированным голосам, не по числу строк донора. - Свободный текстовый ответ со скриптом/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запрещены