Skip to content

ТЗ — SMS-канал (cms/sms)

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

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

Канал SMS для шины cms/notifications-bus: транспорт через провайдеров, шаблоны с лимитом длины, OTP-сообщения для 2FA, статусы доставки и учёт стоимости.

  • provides: notification-channel sms для шины уведомлений;
  • транспорт через cms/transport-providers (конкретный SMS-шлюз конфигурируется отдельно);
  • шаблоны сообщений с проверкой лимита длины (сегментация SMS, предупреждение при превышении) и статусом согласования с оператором связи — для РФ-операторов несогласованный шаблон рискует блокировкой номера отправителя (аналог модерации шаблонов WhatsApp, см. cms_messenger_templates.status в cms/messengers);
  • OTP-сообщения: короткий код для cms/two-factor по SMS как альтернатива TOTP;
  • статусы доставки (отправлено/доставлено/ошибка) от провайдера через вебхук;
  • учёт стоимости отправленных SMS по периоду (для контроля бюджета клиента).

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

requires: cms/notifications-bus, cms/transport-providers · provides: notification-channel sms · suggests: cms/attack-monitor (rate-limit/капча по OTP — защита от SMS-бомбера), cms/audit (согласование шаблонов, включение kill-switch).

Поведение при выключении: шина уведомлений пропускает канал sms при рассылке (событие логируется как недоставленное по каналу, без ошибки для остальных каналов); OTP по SMS для 2FA становится недоступен — остаётся TOTP/email.

Стоимость внешних API — критично для этого модуля. Каждое отправленное SMS стоит денег провайдеру (в отличие от большинства уведомительных каналов). Лимиты, алертинг и аварийная остановка расхода — см. «Настройки» и «Крайние случаи».

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

ТаблицаКлючевые поляПримечание
cms_sms_messagesid, phone, template, body, status, provider_message_id, cost, sent_atжурнал отправленных SMS
cms_sms_templatesid, key, body, max_length, status, approved_at, lock_versionшаблоны с лимитом длины и согласованием оператора

cms_sms_messages — append-only журнал, BRIN-индекс по sent_at; индекс на provider_message_id под сопоставление вебхуков статусов доставки; status — PHP Enum. key в cms_sms_templates — уникальный индекс; status (PHP Enum: draft/pending_approval/approved/rejected) и approved_at — по аналогии с cms_messenger_templates.status: отправка блокируется, пока шаблон не в статусе approved (см. «Крайние случаи»).

Конкурентное редактирование: cms_sms_templates редактируется в Filament → несёт lock_version (optimistic lock, §4 стандарта): два админа правят один шаблон одновременно → второй save() получает 409 и понятное сообщение, а не молчаливую перезапись.

ПДн-паспорт. phone в cms_sms_messages — персональные данные (152-ФЗ); body шаблонизированного сообщения может содержать код OTP или бизнес-данные (не хранить дольше, чем нужно для журнала доставки). Ретеншн: журнал append-only, хранится согласно политике клиента (рекомендация студии — не более N месяцев, джоба ретеншна анонимизирует phone в устаревших записях, не удаляет строку — сохраняется факт отправки для отчётности по расходу). Хуки ядра: «выгрузить всё по субъекту» — все сообщения по phone субъекта; «забыть по запросу» — анонимизация phone (замена хешем) в найденных записях, а не физическое удаление (нарушило бы append-only и отчёты по расходу).

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

Входы (whitelist-принцип: всё, что не перечислено ниже, модуль обязан отвергать):

ИсточникПоляЧем валидируется
Filament: создание/правка шаблонаkey, body, max_lengthFormRequest: key — required|unique|slug-формат, body — required, плейсхолдеры проверяются ReDoS-валидатором ядра, max_length — required|int|1..N
Filament: смена status шаблонаstatus (переход draft → pending_approval → approved/rejected)permission sms.manage, допустимые переходы — whitelist состояний Enum, не произвольная строка
Запрос отправки от cms/notifications-bus (канал 3, provides)phone, template_key, params для подстановкитипизированный DTO контракта notification-channel sms
Запрос OTP от cms/two-factor (канал 4, инициатор — two-factor)phonephone уже провалидирован вызывающей стороной; sms дополнительно применяет свой rate-limit по phone/IP (см. «Безопасность») независимо от вызывающего модуля
Вебхук DLR от провайдера (через cms/webhooks-in)provider_message_id, status, delivered_atподпись вебхука (cms/webhooks-in), сопоставление строго по индексу provider_message_id, неизвестный provider_message_id → отклонить, не создавать запись

Выходы:

ПотребительДанныеФормат
SMS-провайдер (внешний, из очереди)текст сообщения (после подстановки параметров), номерHTTP API провайдера через cms/transport-providers
cms/notifications-busстатус отправки по каналу smsвозврат контракта notification-channel
cms/two-factorрезультат отправки OTP (успех/провал)синхронный ответ с таймаутом ≤2 с + graceful fallback
События SmsSent/SmsDeliveryFailed/SmsBudgetThresholdExceededсм. «События и обмен»JSON payload, канал 1
Filament журнал/отчётстатусы доставки, расход за период, статус согласования шаблоновтаблица админки

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

КлючТипДефолтaffectsPageCacheОписание
sms.providerstringнетАктивный провайдер (через cms/transport-providers)
sms.otp_lengthint6нетДлина OTP-кода
sms.otp_ttl_secondsint300нетСрок действия OTP-кода
sms.monthly_budget_limitint0нетЛимит расходов в месяц (0 — без лимита)
sms.daily_budget_limitint0нетЛимит расходов в сутки (0 — без лимита); защита от резкого всплеска трат за один день
sms.budget_alert_threshold_percentint80нетПорог % от лимита, при котором SmsBudgetThresholdExceeded шлётся заранее, до исчерпания
sms.kill_switch_enabledboolfalseнетАварийная остановка ВСЕХ SMS-рассылок — ручной рубильник на инцидент (например «бот шлёт спам»), не связан с бюджетом и не выключает модуль целиком
sms.otp_rate_limit_per_phoneint3нетМакс. запросов OTP на один номер за otp_rate_limit_window_seconds
sms.otp_rate_limit_per_ipint10нетМакс. запросов OTP с одного IP за то же окно — отдельный лимит от лимита по номеру
sms.otp_rate_limit_window_secondsint600нетОкно времени для обоих лимитов OTP
sms.otp_captcha_after_attemptsint3нетПосле скольких неудачных/повторных попыток показывать капчу перед следующим OTP-запросом

API

Отдельного публичного API у модуля нет — админ-CRUD через Filament, отправка инициируется шиной cms/notifications-bus или запросом OTP от cms/two-factor. Rate-limit/капча из «Настроек» применяются модулем независимо от того, где именно находится публичная точка входа (например форма восстановления пароля в cms/two-factor) — sms не доверяет чужой валидации входа целиком.

Компоненты

Filament: журнал отправленных SMS со статусами доставки, редактор шаблонов с проверкой длины и workflow согласования (draft → pending_approval → approved/ rejected), отчёт по расходу за период (дневной/месячный, % от лимита). Демо-контент: блоков/виджетов у модуля нет (frontend-бюджет — не применимо), но для галереи Filament-ресурсов полезны демо-шаблоны (сидер с парой примеров: OTP-шаблон, транзакционное уведомление) — показывают редактор без ручного ввода.

Команды (все с --json): cms:sms:sync-delivery-status (синхронизация статусов по вебхуку/поллингу), cms:sms:doctor.

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

СобытиеКогдаPayload
SmsSentсообщение отправлено провайдеруmessage_id, phone, template
SmsDeliveryFailedпровайдер вернул ошибку доставкиmessage_id, reason
SmsBudgetThresholdExceededпревышен budget_alert_threshold_percent или сам лимитspent, limit, period

Таблица взаимодействий (сущность/модуль → канал → направление → что происходит):

Сущность/модульКаналНаправлениеЧто происходит
cms/notifications-busprovides-контракт (3) notification-channel smsbus → smsшина резолвит канал sms через DI и вызывает send() при рассылке уведомления
cms/notifications-busсервис-вызов requires (4)sms → busзапись факта отправки в общий журнал уведомлений шины
cms/transport-providersсервис-вызов requires (4)sms → transport-providersполучение конкретной реализации SMS-шлюза (провайдер сконфигурирован отдельно от sms)
cms/two-factorсервис-вызов requires (4), инициатор — two-factortwo-factor → smsзапрос отправки OTP-кода; sms применяет свой rate-limit по phone/IP независимо от вызывающей стороны
Вебхук провайдерасервис-вызов через cms/webhooks-inпровайдер → smsобновление status/cost существующей записи по DLR
Событие SmsBudgetThresholdExceededканал 1sms → cms/health, cms/audit (suggests)алерт о приближении/превышении дневного или месячного бюджета
cms/attack-monitor (suggests, если включён)канал 1 (событие безопасности)sms → attack-monitorсрабатывание rate-limit/капчи по OTP фиксируется как событие атаки (SMS-бомбер)
SettingsStore / CacheTags / ScheduleRegistrar (ядро)вверх, реестр (bootstrap)sms → ядродекларация группы sms, тега sms:templates, расписания sync-delivery-status
Очередь sms (канал 5)job → провайдерsms → провайдеротправка сообщения через HTTP-API провайдера

Слушает: запросы канала sms от cms/notifications-bus, запрос OTP от cms/two-factor. Provides-контракт notification-channel sms — потребляется шиной уведомлений наравне с push/webpush/telegram/messengers.

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

Именованная очередь sms для отправки провайдеру (внешний HTTP-вызов — только из очереди) и для cms:sms:sync-delivery-status (синхронизация статусов по вебхуку/ поллингу); джобы идемпотентны. sync-delivery-status дополнительно регистрируется в ScheduleRegistrar — периодический поллинг там, где провайдер не шлёт вебхуки активно, а не только по требованию.

Мини-ранбук (эксплуатация):

СимптомЧто проверитьКоманда
SMS не отправляютсяsms.provider настроен, kill_switch_enabled не включён, бюджет не исчерпанcms:sms:doctor --json
Статусы доставки не обновляютсяПриходят ли вебхуки от провайдера, отставание поллингаcms:sms:sync-delivery-status --json --dry-run
Резкий рост расходовbudget_alert_threshold_percent сработал, кто инициировал волну (OTP-бомбер vs легитимная кампания)отчёт по расходу в Filament + cms:sms:doctor --json

Метрики и алерты: доля недоставленных SMS за период, текущий % от daily_budget_limit/monthly_budget_limit, отставание очереди sms, число сработавших rate-limit/капча на OTP (индикатор атаки).

Бэкап/рестор: cms_sms_messages и cms_sms_templates — в бэкапе целиком (журнал и шаблоны с историей согласования); после рестора ничего не пересоздаётся отдельной командой (нет производных индексов сверх стандартных).

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

Ожидаемые объёмы: от десятков до нескольких тысяч SMS в день — сильно зависит от клиента: транзакционные OTP дают стабильный фоновый поток, маркетинговые рассылки — пики в момент кампании.

Горячие пути: отправка SMS не лежит на публичном горячем пути страницы (инициируется из очереди/сервиса, не из рендера). Чтение активного провайдера и лимитов — из кеша группы настроек, 0 запросов на путь постановки сообщения в очередь.

Бюджет запросов: 0 запросов на чтение настроек провайдера/лимитов (кеш группы).

Критичные индексы (см. «Модель данных»): provider_message_id в cms_sms_messages — без индекса каждый вебхук DLR триггерит full scan журнала; BRIN по sent_at — под отчёты по расходу за период без деградации на больших объёмах; уникальный key в cms_sms_templates — быстрый резолв шаблона по коду.

Теги и инвалидация: sms:templates — инвалидируется при изменении шаблона (afterSave() в Filament, включая смену status согласования).

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

Шаблон сверх max_length отклоняется при сохранении (не обрезается молча); длина пересчитывается и после подстановки параметров в рантайме (плейсхолдеры переменной длины могут вывести итоговый текст за лимит уже после max_length-проверки на сохранении шаблона — см. «Крайние случаи»). Номер телефона и текст сообщения не логируются в открытом виде сверх необходимого для журнала. OTP-код действителен только в пределах otp_ttl_seconds, повторно не проверяется после использования.

Rate-limit и защита от SMS-бомбера: запрос OTP ограничен ОДНОВРЕМЕННО по номеру телефона (otp_rate_limit_per_phone за otp_rate_limit_window_seconds) И по IP (otp_rate_limit_per_ip, то же окно) — измерений два, потому что боты меняют номер или используют один IP для перебора номеров, а легитимные пользователи за общим NAT дают один IP на многих; после otp_captcha_after_attempts — капча перед следующей попыткой (провайдер капчи — captcha-provider из канонического реестра ядра).

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

PermissionАдминМенеджерРедакторStudio
sms.view (журнал, отчёт по расходу)
sms.manage (редактирование шаблонов, лимиты)
смена status шаблона на approved (согласование с оператором)
sms.kill_switch_enabled (аварийная остановка)

ПДн: см. «Модель данных» (ретеншн журнала, хук «забыть по запросу» через анонимизацию phone).

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

Для получателя (пользователь): понятное сообщение при срабатывании rate-limit/ капчи («слишком много попыток, попробуйте через N минут»), а не generic-ошибка; воспринимаемая скорость доставки OTP важна — таймаут ожидания на стороне вызывающей формы должен соответствовать SLA провайдера; повторный запрос кода не продлевает TTL предыдущего неявно — новый код явно инвалидирует старый.

Для админа: пустое состояние журнала — «пока нет отправленных SMS, проверьте настройки провайдера» со ссылкой на sms.provider. Массовые действия: повторная постановка в очередь неудачных отправок за период, экспорт отчёта по расходу. Человеческие ошибки: провал провайдера отражается понятным текстом причины (SmsDeliveryFailed.reason), не кодом исключения. Подтверждение необратимых операций: включение kill_switch_enabled требует подтверждения (останавливает все рассылки, включая OTP для 2FA — критичное последствие); попытка отправить по неподтверждённому (pending_approval/rejected) шаблону — заблокирована с понятной причиной, не тихая отправка. Видимость расхода в реальном времени: прогресс-бар daily_budget_limit/monthly_budget_limit с текущим % на дашборде модуля — обязательное требование именно для sms (в отличие от бесплатных каналов).

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

  • SMS-бомбер через форму без аутентификации (например запрос OTP на странице входа/восстановления пароля) → otp_rate_limit_per_phone И otp_rate_limit_per_ip срабатывают независимо — превышение любого из двух блокирует следующий запрос; после otp_captcha_after_attempts — обязательная капча перед новой попыткой.
  • Повторный DLR-вебхук по уже финальному статусу (delivered/failed) → игнорируется идемпотентно по provider_message_id, не переоткрывает статус и не создаёт вторую запись.
  • Провайдер недоступен/таймаут при отправке → job уходит в retry с backoff, после исчерпания — failed job + алерт cms/health; сообщение остаётся в статусе ошибки, не «зависает» неопределённо.
  • kill_switch_enabled включён посреди уже поставленных в очередь джобов → джоб перед вызовом провайдера обязан заново проверить флаг и мягко отменить отправку (со статусом «отменено kill-switch»), не тратя бюджет по инерции.
  • Бюджет исчерпан (daily_budget_limit/monthly_budget_limit)SmsBudgetThresholdExceeded уже сработал заранее на budget_alert_threshold_percent; при достижении 100% новые немаркированные как критичные SMS блокируются с понятной ошибкой в логе рассылки — OTP для 2FA по умолчанию не блокируется бюджетом (иначе клиент рискует потерять возможность входа), это явно декларируется как отдельная категория в docs/module.md.
  • cms/transport-providers недоступен/провайдер не настроен → sms деградирует: шина уведомлений пропускает канал (как и при выключении модуля), OTP для 2FA недоступен — остаются TOTP/email; в админке понятная ошибка «провайдер не настроен», не 500.
  • Шаблон превышает max_length после подстановки параметров (переменная длина плейсхолдеров, не выявляется при сохранении шаблона) → пересчёт итоговой длины в рантайме, сегментация SMS на несколько частей вместо тихой обрезки текста.
  • Шаблон не согласован с оператором (status = pending_approval/rejected) → отправка блокируется до перехода в approved; попытка использовать несогласованный шаблон — понятная ошибка админу при постановке в очередь, не тихая отправка с риском блокировки номера отправителя оператором связи.
  • Ловушка измерений locale/city: номер телефона сам по себе не несёт измерений locale/city_id/site_id, а текст шаблона — да (язык сообщения зависит от locale получателя); при отсутствии перевода шаблона для локали — fallback на дефолтную локаль сайта, не пустое сообщение.
  • ⚠️ Противоречие: cms_sms_messages объявлена как append-only журнал по §4 стандарта («финансовые/журнальные таблицы — append-only, исправление — сторно»), но sync-delivery-status/вебхук DLR обновляет поле status (и иногда cost) уже существующей записи — формально нарушает append-only. Разрешение: append-only в этом модуле трактуется как неизменяемость факта отправки (phone, body, template, sent_at); status/cost/provider_message_id остаются служебными изменяемыми полями до перехода в терминальный статус (delivered/failed), после которого дальнейшие правки запрещаются на уровне сервиса, а не только конвенцией.
  • ⚠️ Противоречие: исходное ТЗ использовало sms.monthly_budget_limit как единственный тормоз расходов через алерт-событие, но §6 стандарта требует отдельный kill-switch для рискованной внешней функциональности — это разные механизмы (бюджет — плановый экономический лимит с ожидаемым исчерпанием, kill-switch — ручной рубильник на инцидент вроде «бот шлёт спам», не связанный с деньгами напрямую). Разрешение: добавлена независимая настройка sms.kill_switch_enabled (см. «Настройки»).

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

Донор: — (новая разработка). §16 стандарта («Миграция legacy-данных») — не применимо: донора с боевыми данными нет, легаси-импортёр не требуется.

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

  • [ ] Контрактный тест: шаблон сверх max_length отклоняется при сохранении (не обрезается молча);
  • [ ] Пересчёт длины после подстановки параметров шаблона — сегментация, не тихая обрезка;
  • [ ] Отправка по несогласованному шаблону (statusapproved) блокируется с понятной ошибкой;
  • [ ] Два админа правят один шаблон одновременно → второй save() получает 409 (lock_version);
  • [ ] OTP-код действителен только в пределах otp_ttl_seconds, повторно не проверяется после использования;
  • [ ] Rate-limit по номеру И по IP срабатывают независимо; капча — после otp_captcha_after_attempts;
  • [ ] Статусы доставки обновляются по вебхуку провайдера, повторный DLR по тому же provider_message_id идемпотентен;
  • [ ] Превышение daily_budget_limit/monthly_budget_limit даёт алерт заранее на budget_alert_threshold_percent, не блокирует критичные OTP-сообщения без явной настройки;
  • [ ] kill_switch_enabled останавливает уже поставленные в очередь джобы перед вызовом провайдера;
  • [ ] Деградация при выключении модуля/недоступности провайдера не ломает остальные каналы шины уведомлений;
  • [ ] Номер телефона и текст сообщения не логируются в открытом виде сверх необходимого для журнала;
  • [ ] Хук «забыть по запросу» анонимизирует phone в найденных записях журнала, не удаляет строку;
  • [ ] Контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] Тестовая БД только sms_test; migrate:fresh/refresh/reset, db:wipe запрещены.

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