POST /api/internal/notifications/enqueue — Прямой enqueue уведомления (m2m)
Назначение
Доверенный сервис PIN, у которого нет подходящего интеграционного события, напрямую ставит уведомление в
очередь. Вызывающий указывает Kind, получателей, типизированный Payload и SourceMessageId (ключ
идемпотентности); сервис материализует NotificationEntity в статусе Pending тем же путём, что и консюмеры
(persist-then-send), после чего dispatcher отправляет их по каналам. Owning-контекст — notifications,
feature group enqueue.
| Поле | Значение |
|---|
| Сервис/контекст | notifications |
| API area | internal (m2m, service-to-service) |
| Feature group | enqueue |
| Статус | draft |
| Документ | docs/06-services/notifications/api/internal/enqueue-notification.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | ExternalSystem (доверенный сервис PIN, m2m-токен) |
| Auth policy / Permissions | notifications.enqueue (platform-scope, m2m) |
| Scope (RBAC) | платформа (сервисный клиент) |
| Tenant isolation | TenantId уведомления берётся из получателя (компания/null), не из вызывающего |
| Audit | create (actor = вызывающий сервис) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/internal/notifications/enqueue |
| Success status | 202 (принято к отправке) |
| Idempotency header | Idempotency-Key = SourceMessageId (обязателен) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
SourceService | m2m claims | string | Да | 1..32; контекст вызывающего | — |
SourceMessageId | header/body | Guid | Да | не Guid.Empty; ключ идемпотентности | — |
Kind | body | NotificationKind | Да | значение enum (notification.md) | — |
RecipientUserIds | body | IReadOnlyList<Guid> | Да | 1..500, непустые, уникальны | — |
Urgency | body | NotificationUrgency? | Нет | default по Kind-политике | — |
Locale | body | string? | Нет | BCP-47; иначе — локаль получателя | — |
Payload | body | PayloadModel | Да | типизированный union по Kind, поля соответствуют Kind | PII — по классификации |
Channels | body | IReadOnlyList<Channel>? | Нет | ограничить каналы; иначе — по настройкам получателя | — |
Модель ответа EnqueueResultModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
EnqueuedCount | int | Да | число созданных NotificationEntity | — | Сколько экземпляров (получатель×канал) поставлено. |
SkippedCount | int | Да | dedup/opt-out | — | Пропущено (дубль/opt-out/нет канала). |
Алгоритм
- Контекст и доступ. Вызывает доверенный сервис PIN (m2m-токен); требуется право
notifications.enqueue. Нет валидного токена → UNAUTHENTICATED; нет права → FORBIDDEN. По ключу
идемпотентности (SourceMessageId): если запрос с этим ключом уже обработан — вернуть прежний результат
(202), ничего повторно не создавая.
- Проверка входа.
Kind/Channel — допустимые значения enum; Payload соответствует заявленному
Kind (типизированный union по виду); число получателей в диапазоне 1..500, идентификаторы непустые и
уникальны. Нарушение → VALIDATION_FAILED.
- Резолв доставки (по каждому получателю). По настройкам получателя
(user-notification-settings.md) определить каналы для
Kind/Urgency (пересечь с Channels, если задан); подобрать шаблон (Kind, Locale, Channel); если
активного шаблона под Kind/каналы нет — NTF_TEMPLATE_NOT_RESOLVED. Адрес доставки и локаль берутся
снапшотом из контактной реплики recipient-contact.md (INV-RC-4/5;
graceful — отсутствие реплики/точки не срывает enqueue, in-app доступен всегда); учитываются тихие часы.
- Материализация. Для каждой валидной пары
(получатель, канал) создаётся NotificationEntity в
статусе Pending с отрисованным телом; дубли по ключу дедупликации (SourceMessageId, получатель, канал, Kind) (INV-NTF-1) пропускаются.
- Согласованность. Созданные уведомления и отметка идемпотентности по
SourceMessageId фиксируются в
одном согласованном изменении; отправку по каналам выполняет dispatcher (persist-then-send). In-app
уведомления доставляются realtime после фиксации.
- Результат. Возвращается
EnqueueResultModel (EnqueuedCount, SkippedCount), HTTP 202.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Внешние вызовы | нет синхронного lookup: адрес/локаль — из локальной контактной реплики RecipientContactEntity |
| Уведомления | созданные NotificationEntity (Pending); отправку по каналам и dispatched-факт эмитит dispatcher |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
UNAUTHENTICATED | 401 | нет/невалидный m2m-токен | «Требуется авторизация» | no retry |
FORBIDDEN | 403 | нет notifications.enqueue | «Недостаточно прав» | no retry |
VALIDATION_FAILED | 400 | Kind/Payload/получатели вне ограничений | «Проверьте запрос» | no retry |
NTF_TEMPLATE_NOT_RESOLVED | 422 | нет Active-шаблона для Kind/каналов | «Шаблон не настроен» | no retry (настроить шаблон) |
Частичный результат и предельные входы
- Per-получатель, per-канал. Резолв независим по каждому получателю: каналы фильтруются его настройками
(opt-out,
GlobalMute, Urgency), для канала подбирается тело шаблона. EnqueuedCount — число созданных
NotificationEntity, SkippedCount — сумма пропусков: dedup-дубль (INV-NTF-1), opt-out канала (INV-UNS-3),
отсутствие тела включённого канала (INV-TPL-5), тихие часы не пропуск (канал ставится с ScheduledAt).
- Отсутствие шаблона
Kind. Если для Kind вообще нет Active-шаблона (ни в запрошенной локали, ни в
fallback ru) — весь запрос отклоняется NTF_TEMPLATE_NOT_RESOLVED (422), ничего не создаётся (админ
должен завести/опубликовать шаблон). Если шаблон есть, но у части каналов нет тела — эти каналы просто
пропускаются (в SkippedCount).
- Все каналы получателя отключены / только
Critical-канал при не-Critical Kind → получатель
пропущен (в SkippedCount), это не ошибка запроса.
- Границы.
RecipientUserIds 1..500, непустые и уникальны (дубликаты в списке схлопываются, повтор не
создаёт двойного уведомления благодаря dedup-ключу). Пустой список → VALIDATION_FAILED.
- Тихие часы / срочность. Interruptive-каналы в окне тишины при
Urgency != Critical получают
ScheduledAt = открытие окна (INV-NTF-4); Critical игнорирует тихие часы и GlobalMute (INV-UNS-4).
Идемпотентность и конкурентность
- Ключ повтора —
SourceMessageId (= Idempotency-Key). Повторный вызов с тем же ключом возвращает
прежний EnqueueResultModel (202), ничего не досоздавая. Вызывающий обязан сохранять SourceMessageId
стабильным между своими ретраями (иначе повтор породит новый набор уведомлений).
- Гонка двух одинаковых вызовов (тот же
SourceMessageId) — один материализует набор, второй упирается в
отметку идемпотентности / dedup-уникальность (SourceMessageId, RecipientUserId, Channel, Kind) и не
создаёт дублей; оба получают согласованный результат.
Пред- и постусловия
- Предусловие. Валидный m2m-токен с
notifications.enqueue; Payload типизирован под Kind; для Kind
существует Active-шаблон; RecipientUserIds в границах.
- Постусловие (успех). Для каждой разрешённой пары
(получатель, канал) создан NotificationEntity
(Pending, при тихих часах — с ScheduledAt); отметка идемпотентности по SourceMessageId зафиксирована в
том же согласованном изменении; in-app доставлено realtime; отправку внешних каналов подхватит dispatcher.
Кросс-сервисные контракты
- Ожидает от вызывающего: стабильный
SourceMessageId (Guid, ключ идемпотентности), Payload,
соответствующий Kind (дом полей — event-контракт сервиса-источника), корректные RecipientUserId.
- Ожидает от
identity/profile: событийную реплику контактов (RecipientContactEntity, ведётся из
pin.identity.users.v1 — ФИО/телефон/статус; email/локаль — forward из контактного события profile) —
синхронного lookup адреса при enqueue нет. Отсутствие реплики/точки — graceful: уведомление
создаётся, внешний адрес догоняется после апдейта реплики/адаптером (in-app от адреса не зависит).
- Предоставляет: синхронный ответ
202 с EnqueuedCount/SkippedCount; далее — тот же путь доставки,
что у консюмеров (dispatcher, pin.notifications.dispatched.v1 при финальном исходе).
Совместимость и наблюдаемость
- Новые
Kind/каналы — additive. SourceMessageId обязателен для идемпотентности повторов.
- Logs:
enqueue-received/enqueued (SourceService, Kind, count); metrics:
notifications_enqueue_api_total{source,kind}, latency p95 < 1 с.
Acceptance criteria
- Given валидный сервис с
notifications.enqueue, Active-шаблон и 3 получателя, when enqueue с
корректным Payload, then 202; EnqueuedCount = число разрешённых пар получатель×канал, Pending
созданы, in-app доставлены realtime.
- Given тот же
SourceMessageId повторно, when enqueue, then 202 с прежним результатом,
новых уведомлений нет.
- Given часть получателей с opt-out канала, when enqueue, then они учтены в
SkippedCount, запрос
успешен.
- Given для
Kind нет Active-шаблона (даже fallback ru), when enqueue, then 422
NTF_TEMPLATE_NOT_RESOLVED, ничего не создано.
- Given
Payload не соответствует Kind или RecipientUserIds пуст/> 500, when enqueue, then
400 VALIDATION_FAILED.
- Given невалидный m2m-токен, when enqueue, then
401 UNAUTHENTICATED; при валидном токене без
права — 403 FORBIDDEN.
- Given получатель в тихих часах и
Urgency = Normal, when enqueue interruptive-канала, then
уведомление создано с ScheduledAt = открытие окна (не отправлено немедленно); при Urgency = Critical —
отправляется сразу.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы