Тема
ТЗ — Мобильный 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_devices | id, user_id, platform, push_token, app_version, last_seen_at, failed_deliveries_count | зарегистрированные устройства |
cms_mobile_deeplinks | id, screen, params (json), slug | карта deep-link → экран приложения |
FK user_id — constrained()->index(); platform — PHP Enum (ios/android). params — json()->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/devices | platform, push_token, app_version | FormRequest-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, MobileDeviceTokenExpired | payload события |
Настройки (группа mobile-api)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
mobile-api.min_supported_version | string | 1.0.0 | нет | Минимальная поддерживаемая версия приложения — ниже неё принудительное обновление |
mobile-api.rate_limit_per_device | int | 120 | нет | Запросов в минуту на устройство |
mobile-api.push_batch_size | int | 500 | нет | Размер пачки при массовой рассылке push |
mobile-api.dead_token_max_age_days | int | 60 | нет | Критерий устаревания токена по возрасту: last_seen_at старше — кандидат на удаление |
mobile-api.dead_token_max_failed_deliveries | int | 5 | нет | Критерий устаревания токена по числу подряд неудачных доставок |
mobile-api.api_version_deprecation_window_days | int | 90 | нет | Сколько дней версия API помечена Deprecation, прежде чем требования к ней ужесточаются до min_supported_version |
mobile-api.app_store_url / mobile-api.play_store_url | string | — | нет | Ссылки на сторы для экрана принудительного обновления |
mobile-api.push_kill_switch | bool | false | нет | Kill-switch: аварийная остановка отправки push (FCM/APNS) без выключения модуля — регистрация устройств и deep-links продолжают работать |
Достижение rate_limit_per_device — 429 с Retry-After (конвенция API ядра), не тихое отбрасывание запроса.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/mobile/devices | auth | Регистрация устройства и 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 |
MobilePushDeliveryFailed | push не доставлен (ошибка 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-store | cms: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запрещены.