- Add AlertCreationService to handle alert creation and notifications. - Implement idempotency key support to prevent duplicate alerts. - Create alert notification service for sending FCM messages. - Add tests for alert creation and notification services. - Document FCM notification flow and alert creation process.
14 KiB
FCM-уведомления о новых алертах
Backend отправляет уведомление через Firebase Cloud Messaging (FCM) после
создания нового активного алерта через POST /api/v1/alerts. Android-клиент
RideBus должен быть подписан на тему route_alerts и заранее создать канал
уведомлений с идентификатором route_alerts.
Firebase Admin SDK работает только на сервере. Приватный ключ или service account JSON нельзя добавлять в репозиторий, frontend-сборку или публичные артефакты админки.
Поток обработки
Админка
-> POST /api/v1/alerts
-> валидация запроса
-> сохранение алерта в PostgreSQL
-> прямая отправка FCM в тему route_alerts
-> HTTP 201 с сохранённым алертом
FCM вызывается только после подтверждённой успешной вставки в БД. Если вставка
завершилась ошибкой, push не отправляется. Создание алерта с
is_active: false, а также его последующее обновление, отключение, удаление или
автоматическая очистка не отправляют событие alert_created.
Отправка синхронная и best-effort: отдельной очереди, transactional outbox и
автоматических повторных попыток в проекте нет. Ошибка Firebase логируется с
alertId, кодом и причиной, но не меняет уже состоявшееся сохранение: endpoint
по-прежнему возвращает 201 Created. Токены, credentials и другие секреты в
лог не записываются.
Ограничения прямой отправки
- Между сохранением в PostgreSQL и вызовом FCM есть crash window: если процесс завершится в этот момент, алерт останется в БД, но push может не уйти.
- Если FCM временно недоступен, автоматического retry нет.
- Нельзя атомарно подтвердить одной транзакцией и сохранение в PostgreSQL, и приём сообщения инфраструктурой FCM.
- Ответ
201означает, что алерт сохранён; он не гарантирует доставку push на конкретное устройство.
Если понадобятся гарантированная доставка и управляемые retries, следующим этапом следует добавить transactional outbox и отдельный worker. Текущая реализация намеренно не вводит тяжёлую инфраструктуру.
Защита от повторов
POST /api/v1/alerts поддерживает необязательный заголовок
Idempotency-Key. Ключ и нормализованный payload запроса сохраняются в БД.
-
Первый запрос сохраняет алерт, отправляет один push и возвращает
201. -
Повтор с тем же ключом и тем же нормализованным payload возвращает исходный raw Alert body со статусом
201, не создавая новый алерт и второй push. -
Тот же ключ с другим payload возвращает
409 Conflict:{ "error": "Idempotency-Key was already used with a different request payload" } -
Запрос без
Idempotency-Keyсохраняет прежнее, неидемпотентное поведение: каждый успешный повтор создаёт новый алерт и может отправить новый push.
Клиенту админки следует генерировать новый стабильный ключ на одну логическую попытку создания и повторно использовать его только при сетевом retry этого же запроса.
Idempotency уменьшает дубли при HTTP-retry, но не устраняет crash window прямой отправки. Например, авария после записи алерта, но до отправки FCM не может быть автоматически восстановлена без outbox.
Миграция базы данных
Новая база, созданная Docker Compose с нуля, автоматически применит
backend/migrations/007_alert_creation_idempotency.sql вместе с остальными
init-скриптами. Для уже существующей базы примените только новую миграцию до
деплоя backend:
psql "$DATABASE_URL" -f backend/migrations/007_alert_creation_idempotency.sql
Для запущенного Compose stack:
docker compose exec -T postgres \
psql -U transit_admin -d polotsk_transit \
-f /docker-entrypoint-initdb.d/007_alert_creation_idempotency.sql
Таблица хранит idempotency key, SHA-256-хэш нормализованного запроса, неизменяемый снимок HTTP-ответа, ссылку на созданный алерт и время создания. Снимок позволяет безопасно повторить исходный ответ даже после обновления или удаления алерта. Firebase credentials и payload уведомления в таблицу не записываются.
Конфигурация
| Переменная | Значение по умолчанию | Назначение |
|---|---|---|
FCM_ENABLED |
false |
Включает инициализацию Firebase и отправку FCM. При false credentials не требуются. |
FCM_TOPIC |
route_alerts |
Тема, на которую подписывается Android-клиент. |
FCM_ANDROID_CHANNEL_ID |
route_alerts |
Идентификатор Android notification channel. |
FIREBASE_PROJECT_ID |
пусто | Явный Firebase/Google Cloud project ID; полезен, если ADC не определяет проект автоматически. |
GOOGLE_APPLICATION_CREDENTIALS |
пусто | Стандартный путь ADC к service account JSON в окружении запущенного backend-процесса. |
FCM_ENABLED=false — безопасный режим по умолчанию. В нём приложение не
пытается инициализировать Firebase и полностью работает без credentials.
Firebase Admin SDK в этом проекте требует Node.js 22. Docker-образ backend также должен использовать Node.js 22.
Настройка Application Default Credentials
Google Cloud runtime
Предпочтительный production-вариант — назначить runtime-сервису service account с правами на отправку FCM и использовать credentials, предоставленные окружением Google Cloud (metadata server / workload identity). Файл ключа в этом случае не нужен:
FCM_ENABLED=true
FCM_TOPIC=route_alerts
FCM_ANDROID_CHANNEL_ID=route_alerts
FIREBASE_PROJECT_ID=ridebus-production
GOOGLE_APPLICATION_CREDENTIALS=
Не выдавайте service account более широкие права, чем требуются backend для Firebase Messaging.
Локальный запуск backend без Docker
Храните service account JSON вне checkout и укажите абсолютный путь:
cd backend
cp ../.env.example .env
В backend/.env:
FCM_ENABLED=true
FCM_TOPIC=route_alerts
FCM_ANDROID_CHANNEL_ID=route_alerts
FIREBASE_PROJECT_ID=ridebus-development
GOOGLE_APPLICATION_CREDENTIALS=/absolute/private/path/firebase-service-account.json
После этого запустите npm run dev. Не копируйте JSON в backend/, не
добавляйте его в .env в виде текста и не коммитьте файл.
Локальный запуск в Docker Compose
Значение GOOGLE_APPLICATION_CREDENTIALS внутри контейнера должно указывать на
путь внутри контейнера, поэтому одного host-пути в корневом .env недостаточно.
Создайте локальный, не коммитящийся файл
docker-compose.firebase.override.yml:
services:
backend:
environment:
FCM_ENABLED: "true"
FIREBASE_PROJECT_ID: ridebus-development
GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase-service-account.json
volumes:
- /absolute/private/path/firebase-service-account.json:/run/secrets/firebase-service-account.json:ro
Запуск:
docker compose \
-f docker-compose.yml \
-f docker-compose.firebase.override.yml \
up -d --build backend
Основной docker-compose.yml передаёт все FCM-переменные, но не требует и не
монтирует credential-файл. Поэтому обычный запуск с FCM_ENABLED=false
остается рабочим без Firebase.
Формирование сообщения
FCM получает одновременно видимый notification payload и строковый data
payload. Android priority всегда high. Все значения в data, включая ID и
даты, преобразуются в строки; отсутствующие значения передаются как пустые
строки.
notification.title берётся из alert.title. Если заголовок отсутствует или
состоит только из пробелов, используется fallback по типу:
alert_type |
Fallback title |
|---|---|
delay |
Задержка движения |
cancellation |
Отмена рейса |
detour |
Изменение маршрута |
info |
Информация о маршруте |
notification.body содержит alert.message. Для пустого сообщения
используется безопасный текст с номером маршрута, а если маршрут не указан —
общий текст об обновлении движения.
Пример сообщения для алерта с ID 42:
{
"topic": "route_alerts",
"notification": {
"title": "Задержка движения",
"body": "Автобусы задерживаются примерно на 15 минут"
},
"data": {
"event": "alert_created",
"alertId": "42",
"type": "delay",
"routeId": "7",
"routeNumber": "5",
"startTime": "2026-07-11T09:00:00.000Z",
"endTime": "2026-07-11T11:00:00.000Z"
},
"android": {
"priority": "high",
"notification": {
"channelId": "route_alerts",
"tag": "alert_42"
}
}
}
Для алерта без маршрута поля выглядят так:
{
"routeId": "",
"routeNumber": ""
}
Пример создания алерта
Endpoint принимает JWT администратора/оператора или API key согласно общей
аутентификации /api/v1. Пример с API key:
curl -i -X POST http://localhost:3000/api/v1/alerts \
-H 'X-API-Key: dev_key_12345678' \
-H 'Idempotency-Key: alert-delay-route-5-20260711T120000Z' \
-H 'Content-Type: application/json' \
--data '{
"route_id": 7,
"alert_type": "delay",
"title": "Задержка движения",
"message": "Автобусы задерживаются примерно на 15 минут",
"start_time": "2026-07-11T12:00:00+03:00",
"end_time": "2026-07-11T14:00:00+03:00",
"is_active": true
}'
Успешный ответ — 201 Created и сохранённый Alert. Активные алерты затем
доступны через GET /api/v1/alerts/active.
Локальная проверка
Без реальной отправки
- Оставьте
FCM_ENABLED=falseв.env. - Запустите backend или весь Compose stack.
- Выполните приведённый выше
curlс валидным API key/JWT и существующимroute_id(либо передайтеnull). - Проверьте
201, наличие записи вGET /api/v1/alerts/activeи отсутствие требования Firebase credentials. - Повторите запрос с тем же
Idempotency-Key: ответ снова будет201с тем же Alert body, без второй записи.
С реальной отправкой
- Подпишите тестовое Android-устройство на тему
route_alertsи убедитесь, что приложение создало notification channelroute_alerts. - Настройте ADC одним из способов выше и задайте
FCM_ENABLED=true. - Перезапустите backend и создайте новый активный алерт с уникальным
Idempotency-Key. - Проверьте
201, server log с успешным Firebase message ID и уведомление на устройстве. - Для проверки обработки ошибки временно используйте недоступный Firebase
project/отзовите право отправки: алерт всё равно должен сохраниться и
вернуть
201, а лог должен содержатьalertId, Firebase code и причину без credentials или токенов.
Поведение Android при foreground/background зависит от клиентской реализации,
но серверный контракт всегда содержит и notification, и data payload.