Добавлены уведомления

This commit is contained in:
mi
2026-07-27 17:36:53 +03:00
parent a072005164
commit 958fba5f3e
149 changed files with 6371 additions and 110 deletions
+36 -3
View File
@@ -30,6 +30,7 @@
| `BITRIX_SYNC_SERVICE_TOKEN` | `bitrix-sync` | ops / мониторинг | `GET /internal/sync/v1/*` | `Authorization: Bearer` или `X-Service-Token` |
| `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | `api-backend` | Keycloak SPI | `GET /internal/settings/v1/otp` | `Authorization: Bearer` |
| `SMS_SERVICE_TOKEN` | `sms-service` | Keycloak SPI | `POST/GET /internal/sms/v1/*` | `Authorization: Bearer` |
| `NOTIFICATIONS_TOKEN_<SOURCE>` | `api-backend` | соответствующий продюсер | `POST /internal/notifications/v1/*` | `Authorization: Bearer` |
Пары значений (должны совпадать):
@@ -39,6 +40,8 @@
Генерация: `openssl rand -hex 32`. Секреты не коммитить.
Для Notifications токен отдельный на каждый `source`: секрет существует только в deployment secret/env, а `notification_sources` хранит только hash. Токен разрешает identity продюсера и сравнивается constant-time; `source` в body обязан совпасть. Seed-источник `producer_test` и `NOTIFICATIONS_TOKEN_PRODUCER_TEST` предназначены для smoke Create/Cancel, не для бизнес-интеграции.
**Не путать с webhook-токенами** (публичные callback от Bitrix24, не internal service API):
| Переменная | Назначение |
@@ -68,6 +71,15 @@
| `POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete` | `api-backend` | Expo frontend | Подтверждение загрузки, проверка объекта в quarantine, фиксация checksum/metadata | JWT |
| `GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url` | `api-backend` | Expo frontend | Presigned URL вложения чата; обязателен audit | JWT |
| `WS /api/v1/realtime` | `api-backend` | Expo frontend | Realtime-события чата, статусы доставки, unread | JWT |
| `GET /api/v1/public/notifications` | `api-backend` | Expo frontend | Активные гостевые кампании G | public + CORS/rate limit |
| `GET /api/v1/public/notification-types` | `api-backend` | Expo frontend | Публичный каталог видов с ETag, без серверных правил переходов | public + cache/rate limit |
| `GET /api/v1/notifications?place=home\|center` | `api-backend` | Expo frontend | Персональная выборка P с серверными лимитами 7/15 и сортировкой | JWT |
| `GET /api/v1/notifications/counter` | `api-backend` | Expo frontend | Счётчик непрочитанных в окне Центра | JWT |
| `GET /api/v1/notifications/{id}` | `api-backend` | Expo frontend | Деталка активного собственного уведомления | JWT |
| `POST /api/v1/notifications/{id}/read|hide|cta` | `api-backend` | Expo frontend | Идемпотентные действия и CTA по каталогу | JWT + rate limit |
| `POST /api/v1/notifications/{id}/buttons/{button_code}` | `api-backend` | Expo frontend | Единое действие кнопки деталки | JWT + rate limit |
| `GET /api/v1/notifications/{id}/documents/{document_id}/download-url` | `api-backend` | Expo frontend | Presigned GET + audit; первое скачивание любого связанного документа может скрыть уведомление | JWT |
| `POST/GET/DELETE /api/v1/uploads/*` | `api-backend` | Expo frontend | Универсальные upload drafts клиента | JWT + rate limit |
Единый формат ошибки:
@@ -101,7 +113,10 @@
| `403` | `forbidden` | Доступ запрещён и ресурс не скрывается | нет |
| `404` | `not_found` | Ресурс не существует или принадлежит другому пользователю | нет |
| `409` | `idempotency_key_reused` | Тот же `Idempotency-Key` с другим fingerprint | нет |
| `409` | `notification_conflict` | `(source, external_id)` уже занят Create с другим fingerprint | нет |
| `409` | `notification_closed` | Действие по уже закрытому уведомлению | нет |
| `422` | `message_blocked` | Message Safety вернул final deny | нет |
| `422` | `button_not_allowed` | Кнопка не привязана к виду уведомления | нет |
| `429` | `rate_limit_exceeded` | Edge/API лимит превышен; должен быть `Retry-After`, если повтор допустим | да |
| `503` | `dependency_unavailable` | Circuit open или недоступны safety/Bitrix/S3 | да |
| `504` | `dependency_timeout` | Истёк timeout budget внешней зависимости | да |
@@ -313,10 +328,10 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
3. Клиент отправляет подписку:
```json
{ "type": "subscribe", "dialog_ids": ["uuid"] }
{ "type": "subscribe", "dialog_ids": ["uuid"], "notifications": true }
```
4. Сервер отвечает `{ "type": "subscribed", "dialog_ids": ["uuid"] }`.
4. Сервер отвечает `{ "type": "subscribed", "dialog_ids": ["uuid"], "notifications": true }`. Поле `notifications` опционально, default `false`; старые chat-клиенты совместимы.
**События сервер → клиент:**
@@ -325,12 +340,17 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
| `message.new` | Новое сообщение в диалоге | `dialog_id`, `message` (DTO как в REST) |
| `message.status` | Смена `safety_status` / `delivery_status` | `dialog_id`, `message_id`, `safety_status`, `delivery_status` |
| `dialog.status` | Смена `Dialog.status` | `dialog_id`, `status` |
| `notification.created` | Создано персональное уведомление | `event_id`, `occurred_at`, `notification`, `unread_count` |
| `notification.updated` | Изменено состояние/документы уведомления | `event_id`, `occurred_at`, `notification_id`, изменённые поля, `unread_count` |
| `notification.closed` | Уведомление закрыто | `event_id`, `occurred_at`, `notification_id`, `close_reason`, `unread_count` |
**Reconnect:**
- exponential backoff: 1s → 2s → 4s → … max 30s;
- после reconnect — повтор `subscribe` с актуальным списком `dialog_ids`;
- при недоступности WS > 30s — fallback на polling `GET .../messages?after=<cursor>`.
- при недоступности WS > 30s — fallback на polling чата и, при подписке на уведомления, `GET /api/v1/notifications` + `/counter` раз в 60 секунд.
События уведомлений публикуются в `han:rt:user:{user_id}` на все соединения, включая инициатора. Массовый expire job не отправляет событие на каждую запись; reconnect/polling всегда выполняет REST reconcile.
**Ping:** сервер может слать `{ "type": "ping" }` каждые 30s; клиент отвечает `{ "type": "pong" }`.
@@ -350,6 +370,19 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и
- internal API версионируется тем же правилом (`/internal/{mnemonic}/v2/...`);
- OpenAPI генерируется или поддерживается вручную — на усмотрение модуля, но файл обязателен в DoD (arch-05).
## Producers ↔ api-backend: Notifications
| Контракт | Назначение | Защита |
|---|---|---|
| `POST /internal/notifications/v1/notifications` | Create персонального уведомления | private network + Bearer token конкретного `source` |
| `POST /internal/notifications/v1/notifications/cancel` | Cancel по `(source, external_id)` с `cancelled` или `paid` | то же |
Пара `(source, external_id)` уникальна бессрочно и заменяет `Idempotency-Key`: одинаковый canonical fingerprint возвращает существующую запись с `200`, другой — `409 notification_conflict`. Cancel идемпотентен; чужой `source` не раскрывается.
Каталог, валидация `details`, обязательных полей CTA и эффектов кнопок применяются по данным справочников без ветвления по `notification_type`. Инструкция `install_app` всегда возвращает открытие `instruction_url` в новой вкладке, без iframe/модалки.
При скрытии действие всегда ставит `visibility=hidden`. TTL из вида/default применяется только если `date_expired IS NULL`; уже заданная продюсером дата сохраняется. Первое успешное получение download URL для **любого** связанного документа считается началом скачивания и, при `hide_on_document_download=true`, один раз скрывает уведомление; последующие документы состояние не меняют.
## Keycloak SPI ↔ api-backend settings bridge
Keycloak SPI получает product limits OTP из `app_settings` через internal endpoint, а не через прямой доступ к `han_app`.