- Implemented AppLayout component for application shell with navigation and sidebar. - Created BrandLogo component to display the application logo. - Developed ProfileMenu component for user profile management with dropdown functionality. - Added RouteBadge component to visually represent route numbers with color coding. style: add CSS styles for Dashboard and Routes pages - Introduced Dashboard.css for styling the dashboard layout and components. - Created RoutesPage.css for styling the routes page, including filters and table layout. - Added SchedulesPage.css for styling the schedules layout and route options.
297 lines
15 KiB
Markdown
297 lines
15 KiB
Markdown
# FCM-уведомления о новых алертах
|
||
|
||
Backend отправляет уведомление через Firebase Cloud Messaging (FCM) после
|
||
создания нового активного алерта через `POST /api/v1/alerts`. Android-клиент
|
||
RideBus должен быть подписан на тему `route_alerts` и заранее создать канал
|
||
уведомлений с идентификатором `route_alerts`.
|
||
|
||
Firebase Admin SDK работает только на сервере. Приватный ключ или service
|
||
account JSON нельзя добавлять в репозиторий, frontend-сборку или публичные
|
||
артефакты админки.
|
||
|
||
## Поток обработки
|
||
|
||
```text
|
||
Админка
|
||
-> 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`:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```bash
|
||
psql "$DATABASE_URL" -f backend/migrations/007_alert_creation_idempotency.sql
|
||
```
|
||
|
||
Для запущенного Compose stack:
|
||
|
||
```bash
|
||
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). Файл ключа в
|
||
этом случае не нужен:
|
||
|
||
```env
|
||
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 и укажите абсолютный путь:
|
||
|
||
```bash
|
||
cd backend
|
||
cp ../.env.example .env
|
||
```
|
||
|
||
В `backend/.env`:
|
||
|
||
```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 на Proxmox
|
||
|
||
Docker должен работать внутри VM или LXC-контейнера Proxmox. На этом Docker-
|
||
хосте создайте закрытый каталог и положите в него service account JSON:
|
||
|
||
```bash
|
||
sudo install -d -m 700 /opt/polotsk-transit/secrets
|
||
sudo install -m 600 /private/path/firebase-service-account.json \
|
||
/opt/polotsk-transit/secrets/firebase-service-account.json
|
||
```
|
||
|
||
В корневом `.env` проекта задайте:
|
||
|
||
```env
|
||
FCM_ENABLED=true
|
||
FCM_TOPIC=route_alerts
|
||
FCM_ANDROID_CHANNEL_ID=route_alerts
|
||
FIREBASE_PROJECT_ID=ridebus-production
|
||
FIREBASE_SECRETS_DIR=/opt/polotsk-transit/secrets
|
||
GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json
|
||
```
|
||
|
||
Основной `docker-compose.yml` монтирует `FIREBASE_SECRETS_DIR` в backend как
|
||
read-only `/run/secrets`. Запуск отдельного override-файла больше не требуется:
|
||
|
||
```bash
|
||
docker compose up -d --build backend
|
||
docker compose logs -f backend
|
||
```
|
||
|
||
При `FCM_ENABLED=false` Firebase не инициализируется, поэтому отсутствие JSON
|
||
не мешает работе приложения.
|
||
|
||
## Формирование сообщения
|
||
|
||
FCM получает только строковый `data` payload. Верхнеуровневого `notification`
|
||
и `android.notification` нет, поэтому Android-клиент сам создаёт уведомление и
|
||
может добавить локальные действия, включая кнопку «Прочитано». Android
|
||
priority всегда `high`. Все значения в `data`, включая ID, даты, заголовок и
|
||
текст, являются строками; отсутствующие значения передаются как пустые строки.
|
||
|
||
`data.title` берётся из `alert.title`. Если заголовок отсутствует или
|
||
состоит только из пробелов, используется fallback по типу:
|
||
|
||
| `alert_type` | Fallback title |
|
||
| --- | --- |
|
||
| `delay` | `Задержка движения` |
|
||
| `cancellation` | `Отмена рейса` |
|
||
| `detour` | `Изменение маршрута` |
|
||
| `info` | `Информация о маршруте` |
|
||
|
||
`data.body` содержит `alert.message`. Для пустого сообщения
|
||
используется безопасный текст с номером маршрута, а если маршрут не указан —
|
||
общий текст об обновлении движения.
|
||
|
||
Пример сообщения для алерта с ID `42`:
|
||
|
||
```json
|
||
{
|
||
"topic": "route_alerts",
|
||
"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",
|
||
"title": "Задержка движения",
|
||
"body": "Автобусы задерживаются примерно на 15 минут"
|
||
},
|
||
"android": {
|
||
"priority": "high"
|
||
}
|
||
}
|
||
```
|
||
|
||
Для алерта без маршрута поля выглядят так:
|
||
|
||
```json
|
||
{
|
||
"routeId": "",
|
||
"routeNumber": ""
|
||
}
|
||
```
|
||
|
||
## Пример создания алерта
|
||
|
||
Endpoint принимает JWT администратора/оператора или API key согласно общей
|
||
аутентификации `/api/v1`. Пример с API key:
|
||
|
||
```bash
|
||
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`.
|
||
|
||
## Локальная проверка
|
||
|
||
### Без реальной отправки
|
||
|
||
1. Оставьте `FCM_ENABLED=false` в `.env`.
|
||
2. Запустите backend или весь Compose stack.
|
||
3. Выполните приведённый выше `curl` с валидным API key/JWT и существующим
|
||
`route_id` (либо передайте `null`).
|
||
4. Проверьте `201`, наличие записи в `GET /api/v1/alerts/active` и отсутствие
|
||
требования Firebase credentials.
|
||
5. Повторите запрос с тем же `Idempotency-Key`: ответ снова будет `201` с тем
|
||
же Alert body, без второй записи.
|
||
|
||
### С реальной отправкой
|
||
|
||
1. Подпишите тестовое Android-устройство на тему `route_alerts` и убедитесь,
|
||
что приложение создало notification channel `route_alerts`.
|
||
2. Настройте ADC одним из способов выше и задайте `FCM_ENABLED=true`.
|
||
3. Перезапустите backend и создайте новый активный алерт с уникальным
|
||
`Idempotency-Key`.
|
||
4. Проверьте `201`, server log с успешным Firebase message ID и уведомление на
|
||
устройстве.
|
||
5. Для проверки обработки ошибки временно используйте недоступный Firebase
|
||
project/отзовите право отправки: алерт всё равно должен сохраниться и
|
||
вернуть `201`, а лог должен содержать `alertId`, Firebase code и причину без
|
||
credentials или токенов.
|
||
|
||
Поведение Android при foreground/background полностью определяется клиентской
|
||
обработкой data-only сообщения.
|