Skip to content

ТЗ — Мобильный API + push (cms/mobile-api)

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

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

Расширение API ядра для мобильных приложений: регистрация устройств, push-канал через шину уведомлений, версионирование контрактов и deep-links. Донор — внутренний проект студии push-server (путь в парке не выдан, использовать как референс подхода).

  • Регистрация устройства с FCM/APNS-токеном, привязка к пользователю;
  • push-уведомления через cms/notifications-bus (канал push, provides: notification-channel push);
  • версионирование мобильных контрактов API (заголовок версии, обратная совместимость N-1);
  • deep-links: генерация и разбор ссылок на конкретные экраны приложения;
  • rate-limit per-устройство поверх общего лимита API;
  • отзыв токена устройства при логауте/удалении приложения;
  • мягкая депрекация: версия приложения выше min_supported_version, но старше актуальной, продолжает работать — эндпоинт/поле помечены заголовком Deprecation (конвенция API ядра), без резкого breaking для парка устройств, которые физически не обновляются мгновенно (типичная картина мобильного продакшена — заметная доля установок отстаёт от актуальной версии месяцами);
  • принудительное обновление: запрос с версией ниже min_supported_version получает структурированный ответ «обновите приложение» со ссылкой на стор, а не голый код ошибки без объяснения — клиент показывает экран блокировки с понятной причиной;
  • джоба чистки мёртвых push-токенов по двум независимым критериям: возраст last_seen_at и число подряд неудачных доставок (см. «Настройки», «Фоновая работа»);
  • идемпотентность мутаций через Idempotency-Key (конвенция API ядра) — офлайн-очередь мобильного клиента при восстановлении связи нередко повторяет один и тот же запрос.

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

requires: cms/notifications-bus · provides: notification-channel push

Поведение при выключении: мобильные приложения теряют push-канал и версионированные эндпоинты (fallback на базовый API ядра без мобильной специфики); ранее зарегистрированные устройства сохраняются в БД для восстановления при включении. Если выключен cms/notifications-bus (requires) — регистрация устройств продолжает работать (это независимая функция мобильного API), но push технически некому диспетчеризировать: NotificationDispatch ядра уходит в log-fallback, устройство не получает уведомлений без 500 (см. «Крайние случаи»).

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

ТаблицаКлючевые поляПримечание
cms_mobile_devicesid, user_id, platform, push_token, app_version, last_seen_at, failed_deliveries_countзарегистрированные устройства
cms_mobile_deeplinksid, screen, params (json), slugкарта deep-link → экран приложения

FK user_idconstrained()->index(); platform — PHP Enum (ios/android). paramsjson()->nullable() + cast array; slug в cms_mobile_deeplinks — уникальный индекс под разбор ссылки. Уникальный составной индекс (platform, push_token) в cms_mobile_devices — повторная регистрация того же токена (переустановка приложения, повторный логин на том же устройстве) обновляет существующую строку (user_id, app_version, last_seen_at, сброс failed_deliveries_count), не создаёт дубль. Индекс (last_seen_at) и (failed_deliveries_count) — под выборку кандидатов на удаление джобой очистки.

ПДн-паспорт. Хранит user_id (ссылка), push_token (псевдонимный идентификатор устройства — не ПДн само по себе, но связан с конкретным человеком через user_id), platform/app_version (техническая телеметрия, не ПДн). Ретеншн: строка живёт, пока устройство активно (обновляет last_seen_at) либо до срабатывания критериев очистки («Настройки»). Участвует в «выгрузить всё по субъекту» (список устройств пользователя) и «забыть по запросу» (удаление строк устройств при удалении пользователя ядром — токен отзывается на стороне провайдера push тем же циклом, что штатный логаут).

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

Whitelist-принцип: заголовок/поле вне перечня ниже — отвергается (422), не игнорируется молча.

Входы

ИсточникДанные/поляЧем валидируется
POST /api/v1/mobile/devicesplatform, push_token, app_versionFormRequest-whitelist, platform — PHP Enum, Idempotency-Key обязателен (мутация с внешним эффектом — регистрация в push-провайдере)
DELETE /api/v1/mobile/devices/{id}— (только id в пути)Policy владения (mobile-api.manage.own); повторный вызов на уже удалённом id — идемпотентно 204, не 404-ошибка клиенту
GET /api/v1/mobile/deeplinks/{slug}slug в путипоиск по уникальному индексу cms_mobile_deeplinks.slug; нет совпадения → 404 в конверте
Заголовок X-App-Version на каждом запросе мобильного неймспейсаверсия приложения (semver)middleware модуля сверяет с min_supported_version/актуальной версией; отсутствие заголовка трактуется как версия ниже минимальной (см. «Крайние случаи»)
provides: notification-channel (вызов от cms/notifications-bus)DTO канала: recipient, subject, body, variablesконтракт канала (notifications-bus.md); модуль отвечает результатом доставки
cms:mobile-api:prune-dead-tokens, cms:mobile-api:doctor (CLI)--dry-run, без обязательных аргументовwhitelist аргументов команды

Выходы

ПотребительДанныеФормат
cms/notifications-bus (канал 3, notification-channel)результат доставки (success/fail/error, код ответа FCM/APNS)DTO результата доставки по контракту канала
Мобильный клиент (любой эндпоинт мобильного неймспейса при устаревшей версии)код ошибки, человеческое сообщение, ссылка на сторструктурированный ответ форсированного обновления (см. «API»)
GET /api/v1/mobile/deeplinks/{slug}screen, paramsконверт {data, meta}
Filament (список устройств, статистика по версиям)устройства пользователя, распределение app_version/platformтаблицы
Подписчики шины событийMobileDeviceRegistered, MobileDeviceRevoked, MobilePushDeliveryFailed, MobileDeviceTokenExpiredpayload события

Настройки (группа mobile-api)

КлючТипДефолтaffectsPageCacheОписание
mobile-api.min_supported_versionstring1.0.0нетМинимальная поддерживаемая версия приложения — ниже неё принудительное обновление
mobile-api.rate_limit_per_deviceint120нетЗапросов в минуту на устройство
mobile-api.push_batch_sizeint500нетРазмер пачки при массовой рассылке push
mobile-api.dead_token_max_age_daysint60нетКритерий устаревания токена по возрасту: last_seen_at старше — кандидат на удаление
mobile-api.dead_token_max_failed_deliveriesint5нетКритерий устаревания токена по числу подряд неудачных доставок
mobile-api.api_version_deprecation_window_daysint90нетСколько дней версия API помечена Deprecation, прежде чем требования к ней ужесточаются до min_supported_version
mobile-api.app_store_url / mobile-api.play_store_urlstringнетСсылки на сторы для экрана принудительного обновления
mobile-api.push_kill_switchboolfalseнетKill-switch: аварийная остановка отправки push (FCM/APNS) без выключения модуля — регистрация устройств и deep-links продолжают работать

Достижение rate_limit_per_device — 429 с Retry-After (конвенция API ядра), не тихое отбрасывание запроса.

API

МетодПутьДоступНазначение
POST/api/v1/mobile/devicesauthРегистрация устройства и push-токена (Idempotency-Key)
DELETE/api/v1/mobile/devices/{id}auth (владелец)Отзыв токена устройства (логаут), идемпотентно
GET/api/v1/mobile/deeplinks/{slug}publicРазбор deep-link в параметры экрана

Все эндпоинты мобильного неймспейса читают заголовок X-App-Version: версия ниже min_supported_version → 426 с телом канонического конверта ошибок ядра {"error": {"type": "app_update_required", "code": "app_update_required", "message": "…", "fields": {"store_url": "…"}, "doc_url": null}} (не голый код без объяснения); версия в окне депрекации → ответ штатный + заголовок Deprecation (конвенция ядра, core.md), без блокировки функциональности.

Компоненты

Filament: список зарегистрированных устройств пользователя, статистика по версиям приложения (доля устройств ниже min_supported_version — сигнал для планирования депрекации). Команды: cms:mobile-api:prune-dead-tokens --json, cms:mobile-api:doctor --json.

Блоки/виджеты (BlockRegistry/WidgetRegistry): не применимо — модуль обслуживает нативное приложение через REST, не рендерит веб-страницы; демо-сидер и фронтенд-бюджет блоков (матрица v2.2) не применимы по той же причине. Playground демонстрируется через Filament-раздел устройств на тестовых записях фабрики, без публичных блоков.

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

СобытиеКогдаPayload
MobileDeviceRegisteredзарегистрировано новое устройство (или обновлён существующий токен)user_id, device_id, platform
MobileDeviceRevokedустройство отозвано (логаут/удаление приложения)user_id, device_id
MobilePushDeliveryFailedpush не доставлен (ошибка FCM/APNS)device_id, reason
MobileDeviceTokenExpiredтокен удалён джобой очистки по критериям устареванияdevice_id, reason (age или failed_deliveries)

Таблица взаимодействий

Сущность/модульКаналНаправлениеЧто происходит
cms/notifications-bus (requires)provides-контракт notification-channel (канал 3)inшина резолвит mobile-api как реализацию push-канала через DI, вызывает send()
FCM/APNS (внешний провайдер)внешний HTTP только из очереди mobile-api (§9 стандарта, не входит в 5 внутренних каналов)outфактическая отправка; отказ → MobilePushDeliveryFailed
Ядро: RequestContextрезолв контекста (сервис-вызов ядра)inопределяет пользователя при регистрации устройства
Ядро: API-слой (Idempotency-Key, Deprecation)конвенция API ядра (не отдельный из 5 каналов)inфорсирует заголовки на мутациях и депрекации мобильного неймспейса
Очередь mobile-apiочередь (канал 5)массовая рассылка push (push_batch_size, Bus::batch), prune-dead-tokens

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

Именованная очередь mobile-api для массовой рассылки push (push_batch_size — размер пачки, Bus::batch с прогрессом) и для cms:mobile-api:prune-dead-tokens; джобы идемпотентны, внешние вызовы к FCM/APNS — только из очереди.

Критерии устаревания токена (prune-dead-tokens): удаляется устройство, если last_seen_at старше dead_token_max_age_days ИЛИ failed_deliveries_count достиг dead_token_max_failed_deliveries — любой из двух критериев независимо достаточен, чтобы не копить токены, которые провайдер push уже считает мёртвыми, но клиент формально «недавно заходил» (или наоборот).

Эксплуатация (runbook). Метрики: лаг очереди mobile-api, доля неудачных доставок push по платформе за час, число удалённых токенов за прогон prune-dead-tokens, доля запросов с версией ниже min_supported_version (индикатор качества rollout принудительного обновления), 404-rate по deeplinks/{slug}. Алерты: резкий рост MobilePushDeliveryFailed на платформе (провайдер лёг или массово протухли токены после смены сертификата APNS/ключа FCM); доля версий ниже min_supported_version не падает после релиза «принудительного обновления» — сигнал, что экран блокировки не доходит до части парка.

СимптомЧто проверитьКоманда
Push не доходит на iOS/Android массовоhealth FCM/APNS в cms/health, сертификат/ключ провайдераcms:doctor --json
Токены не чистятся, база устройств растётлаг очереди mobile-api, расписаниеcms:mobile-api:prune-dead-tokens --dry-run --json
Пользователи жалуются на «обновите приложение» при актуальной версиизначение mobile-api.min_supported_version в settings-storecms:cache:inspect --group=mobile-api
Deep-link не открывает экранналичие slug в cms_mobile_deeplinks, опечатка в карте— (Filament: карта deep-links)
Дубли устройств на один токенуникальный индекс (platform, push_token)cms:mobile-api:doctor --json

Бэкап/рестор: cms_mobile_devices и cms_mobile_deeplinks — в общем бэкапе БД. После рестора ничего не пересчитывается (не денормализованные данные); при потере части устройств между бэкапом и инцидентом — они переустановятся сами при следующем открытии приложения (MobileDeviceRegistered идемпотентен по (platform, push_token)), явного восстановительного шага не требуется.

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

Ожидаемые объёмы: от тысяч до сотен тысяч зарегистрированных устройств на проекте с популярным приложением; push-рассылка — от единиц до десятков тысяч получателей за кампанию (батчами по push_batch_size).

Горячие пути и бюджет запросов: регистрация устройства — upsert по (platform, push_token), 1 запрос; проверка версии из X-App-Version — сравнение с настройкой min_supported_version из кеша группы (0 запросов на горячем пути, §6 стандарта); разбор deep-link — 1 запрос по уникальному индексу slug, кандидат на кеш при высокой частоте (см. ниже).

Критичные индексы (см. «Модель данных»): (platform, push_token) unique — под идемпотентный upsert регистрации; (last_seen_at)/(failed_deliveries_count) — под выборку джобой очистки; cms_mobile_deeplinks.slug unique — под разбор ссылки.

Кешируется: карта cms_mobile_deeplinks — тег mobile-api:deeplinks, инвалидация при изменении карты в Filament; настройки группы mobile-api (в т.ч. min_supported_version, лимиты) — из кеша группы, 0 запросов на горячем пути.

Персональные данные (список устройств пользователя, push-токены) не попадают в общий page-cache — это не публичный контент, а данные конкретного авторизованного устройства/пользователя.

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

Каждый эндпоинт устройства — проверка владения (mobile-api.manage.own): доступ к чужому устройству по перебору id невозможен, выборки скоуплены where user_id = auth()->id() в сервисе (IDOR-защита), не собираются по прямому id в контроллере. Rate-limit per-устройство поверх общего лимита API не даёт превысить лимит даже при параллельных запросах одного устройства. Push-токен не логируется в открытом виде (маскируется в логах и метриках — канон §11 стандарта: логи без ПДн/секретов). Запрос с версией ниже min_supported_version получает явную ошибку обновления, не 500. Мёртвый push-токен помечается и не используется повторно после порога неудач. Idempotency-Key на регистрации устройства предотвращает создание дублей при повторной отправке офлайн-очередью клиента.

Права: mobile-api.manage.own (пользователь — свои устройства), mobile-api.view (поддержка/менеджер — просмотр устройств и статистики).

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

РольДействие
Пользовательрегистрирует/отзывает только свои устройства (mobile-api.manage.own)
Менеджер/поддержкапросматривает список устройств и статистику по версиям для диагностики обращений (mobile-api.view), не может отозвать чужое устройство без отдельного права
Studioуправляет min_supported_version, ссылками на сторы, push_kill_switch; запускает prune-dead-tokens вручную

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

Для пользователя мобильного приложения:

  • регистрация устройства при входе — фоновая, не блокирует экран логина ощутимой задержкой;
  • принудительное обновление — отдельный понятный экран («доступна новая версия, продолжить работу нельзя») со ссылкой на стор, а не системная ошибка сети;
  • депрекация версии (выше min_supported_version, но устаревшая) — не блокирует работу, в лучшем случае мягкое ненавязчивое напоминание внутри приложения, решает клиент по заголовку Deprecation, не сервер принудительно;
  • офлайн-очередь клиента при восстановлении связи — повтор запроса безопасен (Idempotency-Key), пользователь не видит задвоенных заявок/устройств из-за плохой сети.

Для администратора/поддержки:

  • пустой список устройств пользователя — «устройства не зарегистрированы» (типично для новых аккаунтов, не ошибка);
  • статистика по версиям — наглядно показывает долю устаревших установок при принятии решения о повышении min_supported_version;
  • массовое действие «отозвать все устройства пользователя» (компрометация аккаунта) — с подтверждением, необратимо в моменте (переустановка потребует повторного логина);
  • человеческая ошибка при недоступности push-провайдера — «FCM/APNS недоступен, устройства зарегистрированы, push отложен» вместо стектрейса.

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

  • mobile-api-contract в исходном манифесте → ⚠️ Противоречие: исходная версия ТЗ объявляла provides: notification-channel push, mobile-api-contract. В каноническом реестре provides-контрактов (standard.md, §2) такого имени нет — провижн-контракты нужны для взаимозаменяемых реализаций абстрактной способности (как notification-channel), а версионирование REST-поверхности модуля — это просто конвенция API ядра (Deprecation, версия в пути/заголовке), а не DI-резолвимая способность, которую мог бы предоставить другой модуль. Разрешение: манифест приведён к provides: notification-channel push; версионирование контракта описано в разделе «API» через заголовки X-App-Version/Deprecation, не через фиктивный provides-контракт;
  • запрос без заголовка X-App-Version (очень старый клиент, не знающий о заголовке) → трактуется как версия ниже min_supported_version — форсированное обновление, а не 500/500-подобное падение парсинга отсутствующего значения;
  • офлайн-очередь клиента шлёт дубль регистрации устройстваIdempotency-Key
    • уникальный индекс (platform, push_token) гарантируют upsert, не вторую строку;
  • токен ротирован на том же устройстве без переустановки (штатное поведение FCM/APNS) → повторная регистрация с новым push_token создаёт новую строку (индекс на паре platform+push_token, не на устройстве как таковом) — старый токен доживает до prune-dead-tokens по критерию неудачных доставок, не считается ошибкой;
  • гонка: prune-dead-tokens удаляет токен, пока по нему уже летит push → джоба удаления работает по снимку на момент старта (чанками, Bus::batch); доставка, стартовавшая до удаления, долетает или падает штатным MobilePushDeliveryFailed — повторно токен не переиспользуется, т.к. строки уже нет;
  • выключен cms/notifications-bus (requires) → регистрация/отзыв устройств и deep-links продолжают работать (независимая функциональность), но NotificationDispatch уходит в log-fallback ядра — push не долетает без 500, устройства не теряются и восстанавливают доставку сразу после включения модуля;
  • push-провайдер (FCM/APNS) недоступен/таймаут → отправка только из очереди с retry/backoff (§9 стандарта); при исчерпании попыток — MobilePushDeliveryFailed, не блокирует остальные устройства в батче;
  • push_batch_size больше лимита провайдера на один вызов → батч режется внутри джобы на под-пачки по лимиту провайдера, настройка задаёт верхнюю границу логики модуля, а не сырой размер сетевого вызова;
  • deeplinks/{slug} указывает на экран, удалённый из актуальной версии приложения → API отдаёт screen/params как есть (контракт версии, в которой экран ещё существовал); актуальность самого экрана — ответственность клиента, сервер не хранит версионность самих экранов;
  • пустая/огромная карта deep-links при первом включении на существующем сайте → разбор при пустой карте — 404 по каждой ссылке (штатно, не ошибка); импорт большой карты — обычная миграция/сидер, не требует отдельного батчинга (справочная таблица, не растёт лавинообразно);
  • измерение site_id отсутствует (сайт без мультисайта) → регистрация устройств и push работают в этом режиме так же, как при заполненном измерении — контрактный тест в обоих режимах (§4 стандарта);
  • push-kill-switch включён посреди массовой рассылки → уже взятые воркером джобы батча довыполняются или отменяются явным решением (не зависает): новые постановки в очередь mobile-api для push блокируются немедленно, регистрация устройств кил-свитчем не затрагивается.

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

Что взятьПуть
Подход к push-регистрации и доставкеpush-server (проект студии, путь не выдан — референс архитектуры)

Миграция legacy-данных. Команда cms:mobile-api:import-legacy --source=<профиль> --json переносит зарегистрированные устройства из push-server (или иной легаси-базы токенов) на схему cms_mobile_devices: ключ идемпотентности — (platform, push_token) (повторный прогон обновляет user_id/app_version, не дублирует), --dry-run показывает расхождения (например, токены без сопоставленного user_id в целевой БД) до применения. Прогон на копии донорских данных — часть приёмки модуля (§16 стандарта); без него токены существующего парка устройств пришлось бы собирать заново через принудительный релиз с ре-регистрацией — недопустимо для продакшена.

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

  • [ ] Контрактный тест: запрос с версией ниже min_supported_version получает явную ошибку обновления, не 500;
  • [ ] Запрос без заголовка X-App-Version трактуется как устаревшая версия (форсированное обновление), не падает;
  • [ ] Версия в окне депрекации получает заголовок Deprecation, функциональность не блокируется;
  • [ ] Мёртвый push-токен помечается/удаляется по любому из двух независимых критериев (возраст, число неудач);
  • [ ] Повторная регистрация того же (platform, push_token) — upsert, не дубль строки;
  • [ ] Idempotency-Key на регистрации устройства не создаёт вторую запись при повторной отправке офлайн-очередью;
  • [ ] Rate-limit per-устройство не даёт превысить лимит даже при параллельных запросах одного устройства;
  • [ ] Deep-link с несуществующим slug возвращает 404 в конверте data/meta, не падает;
  • [ ] IDOR: доступ/отзыв чужого устройства по перебору id невозможен (матрица ролей выше);
  • [ ] Деградация при выключении cms/notifications-bus не роняет регистрацию устройств — push уходит в log-fallback без 500;
  • [ ] Push-токен не логируется в открытом виде (чувствительные данные);
  • [ ] cms:mobile-api:import-legacy --dry-run прогнан на копии данных push-server без расхождений сверх ожидаемых;
  • [ ] push_kill_switch останавливает новые отправки push без влияния на регистрацию устройств;
  • [ ] Контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] Тестовая БД только mobile-api_test; migrate:fresh/refresh/reset, db:wipe запрещены.

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