Тема
ТЗ — 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_messages | id, phone, template, body, status, provider_message_id, cost, sent_at | журнал отправленных SMS |
cms_sms_templates | id, 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_length | FormRequest: 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) | phone | phone уже провалидирован вызывающей стороной; 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.provider | string | — | нет | Активный провайдер (через cms/transport-providers) |
sms.otp_length | int | 6 | нет | Длина OTP-кода |
sms.otp_ttl_seconds | int | 300 | нет | Срок действия OTP-кода |
sms.monthly_budget_limit | int | 0 | нет | Лимит расходов в месяц (0 — без лимита) |
sms.daily_budget_limit | int | 0 | нет | Лимит расходов в сутки (0 — без лимита); защита от резкого всплеска трат за один день |
sms.budget_alert_threshold_percent | int | 80 | нет | Порог % от лимита, при котором SmsBudgetThresholdExceeded шлётся заранее, до исчерпания |
sms.kill_switch_enabled | bool | false | нет | Аварийная остановка ВСЕХ SMS-рассылок — ручной рубильник на инцидент (например «бот шлёт спам»), не связан с бюджетом и не выключает модуль целиком |
sms.otp_rate_limit_per_phone | int | 3 | нет | Макс. запросов OTP на один номер за otp_rate_limit_window_seconds |
sms.otp_rate_limit_per_ip | int | 10 | нет | Макс. запросов OTP с одного IP за то же окно — отдельный лимит от лимита по номеру |
sms.otp_rate_limit_window_seconds | int | 600 | нет | Окно времени для обоих лимитов OTP |
sms.otp_captcha_after_attempts | int | 3 | нет | После скольких неудачных/повторных попыток показывать капчу перед следующим 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-bus | provides-контракт (3) notification-channel sms | bus → 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-factor | two-factor → sms | запрос отправки OTP-кода; sms применяет свой rate-limit по phone/IP независимо от вызывающей стороны |
| Вебхук провайдера | сервис-вызов через cms/webhooks-in | провайдер → sms | обновление status/cost существующей записи по DLR |
Событие SmsBudgetThresholdExceeded | канал 1 | sms → 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отклоняется при сохранении (не обрезается молча); - [ ] Пересчёт длины после подстановки параметров шаблона — сегментация, не тихая обрезка;
- [ ] Отправка по несогласованному шаблону (
status≠approved) блокируется с понятной ошибкой; - [ ] Два админа правят один шаблон одновременно → второй
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запрещены.