← назад к разделу

Каждый раз, когда покупатель оформляет заказ на маркетплейсе, ему приходит email «Заказ подтверждён» и пуш в приложение. Кажется просто — взял событие, отправил письмо. На деле за этим стоит отдельный сервис с ретраями, дедупликацией, журналом попыток и обработкой webhook'ов. Разберём, как он устроен.

Зачем отдельный сервис

Самый очевидный вариант — отправлять письмо прямо из сервиса заказов. Проблема: если Mailgun недоступен, транзакция заказа откатывается? Или письмо теряется? Платёж провёлся, заказ создан, а письмо пропало — пользователь не знает что происходит.

Второй вариант — отправлять в той же транзакции через очередь. Но тогда сервис заказов знает про email и push-каналы, провайдеров, шаблоны. Это лишняя ответственность не на своём месте.

Решение — отдельный Notification Service, который подписан на события заказа в Kafka и занимается только доставкой: берёт событие, выбирает каналы, рендерит шаблон, отправляет через провайдера, записывает результат.

Задача сервиса — не принимать решения о бизнес-логике (когда слать — решает сервис заказов, публикуя событие), а надёжно доставить уведомление.

Откуда берутся уведомления

Notification Service слушает топик marketplace.orders.v1 в Kafka. Туда Order Service публикует события жизненного цикла заказа: OrderConfirmed, OrderPaid, OrderShipped, OrderCancelled, OrderRefunded, OrderDelivered, DisputeOpened, DisputeResolved.

Каждое событие содержит event_id, тип события, userId покупателя и детали заказа. По userId сервис идёт в Customer BFF — запрашивает email и push-токены устройств пользователя, а также его язык (locale).

Идемпотентность. Kafka гарантирует доставку at-least-once — одно событие может прийти дважды. Чтобы не создавать дубликаты, каждый event_id записывается в таблицу processed_events. При повторной обработке — INSERT … ON CONFLICT DO NOTHING, дубль игнорируется.

Какие каналы и когда

Каждый тип события → конкретные каналы (жёстко задано в коде):

  • OrderConfirmed, OrderPaid, OrderShipped, OrderCancelled, OrderRefunded, DisputeResolved → email + push покупателю
  • OrderDelivered → только email
  • DisputeOpened → push продавцу + email оператору поддержки

Одно событие может породить две записи в журнале — по одной на каждый канал. В журнале это две независимые строки: письмо и пуш отслеживаются отдельно.

Контакт (адрес, токен) материализуется в момент отправки и сохраняется в журнале. Если пользователь потом поменяет email — история доставки покажет, на какой адрес реально ушло письмо.

Шаблоны и рендеринг

Текст письма и пуш-уведомления хранится в таблице templates. Ключ шаблона — это комбинация типа события и канала, например order.confirmed.email или order.confirmed.push. Каждый шаблон хранится в двух языках: ru и en.

В шаблоне есть плейсхолдеры вида ${orderNumber}, ${amount} — они подставляются из данных события перед отправкой.

Если шаблона для нужной комбинации (событие, канал, locale) нет — уведомление не создаётся. Пустое письмо хуже чем никакого, поэтому при отсутствии шаблона срабатывает метрика notification_template_missing_total и алёрт. Шаблоны хранятся в БД и обновляются без перезапуска сервиса (кэш TTL 60 секунд).

Статусы доставки

Каждая запись уведомления проходит через несколько статусов:

СтатусСмысл
QUEUEDсоздано из события, ждёт отправки
SENTушло в провайдер
DELIVEREDwebhook от Mailgun подтвердил доставку (только email)
BOUNCEDwebhook сообщил о неверном адресе
FAILEDретраи исчерпаны или постоянная ошибка
diagram

Push-уведомления не имеют механизма подтверждения на уровне FCM — HTTP 200 от Firebase означает «принято», но не «доставлено». Поэтому push остаётся в статусе SENT навсегда. Это ограничение FCM, а не баг.

Как работают ретраи

Отправкой занимается шедулер DispatchPending, который каждую секунду берёт пачку записей в статусе QUEUED (до 100 штук) и отправляет их через провайдера.

Если провайдер ответил временной ошибкой (5xx, таймаут) — запись получает статус QUEUED снова с задержкой: 30 секунд, потом 5 минут, потом 30 минут. После трёх неудачных попыток — FAILED. Если ошибка постоянная (4xx, невалидный адрес) — сразу FAILED без ретраев.

Каждая попытка записывается в таблицу delivery_attempts: дата, номер попытки, результат, фрагмент ответа провайдера. Это нужно для разбора инцидентов.

Чтобы не перегружать провайдеров, действует ограничение скорости — не более 100 писем в секунду (механизм token bucket).

Webhook от Mailgun

Когда Mailgun доставил письмо или получил отказ — он шлёт webhook на POST /webhooks/email-events. По external_id (идентификатор, который Mailgun вернул при отправке) сервис находит запись и переводит её в DELIVERED или BOUNCED.

Webhook проверяется по HMAC-подписи — без валидной подписи запрос отклоняется (401). Дедупликация по (notification_id, webhook_event_id) защищает от повторных доставок одного события.

Что видит оператор поддержки

Когда покупатель пишет «мне не пришло подтверждение» — оператор открывает журнал и фильтрует по userId. Он видит все записи: когда создано, в какой канал, на какой адрес, статус, сколько попыток и почему последняя провалилась.

Если уведомление в статусе FAILED — оператор может нажать «Retry» и оно снова встанет в QUEUED. Если адрес невалидный и retry бессмысленен — «Abandon», финальный статус.

Из соображений безопасности email и push-токены шифруются при хранении и маскируются в логах (u***@example.com). Данные журнала удаляются через 90 дней (требование по хранению персональных данных).

Схема базы данных

Четыре таблицы:

  • notifications — главная: одна строка на попытку доставки в один канал. Хранит материализованный контакт, ключ шаблона, статус, external_id для матчинга webhook.
  • templates — шаблоны (ключ события × канал × locale).
  • delivery_attempts — лог каждой попытки с результатом и ответом провайдера.
  • processed_events — таблица идемпотентности по event_id.
diagram

Технический стек

Java 21, Spring Boot 3, Spring Kafka — потребитель событий. PostgreSQL + jOOQ + Flyway — хранение. Spring Web — REST-эндпоинты (webhook + админ-журнал). Spring Security + OAuth2 + HMAC-фильтр — авторизация. Resilience4j — Circuit Breaker и retry при обращении к Customer BFF. Micrometer/Prometheus, OpenTelemetry — метрики и трассировка.

Рабочий пример — github.com/remodov/notification-service.

Коротко

  • Notification Service подписан на события заказа в Kafka и занимается только доставкой — бизнес-решения «когда слать» остаются в Order Service.
  • Идемпотентность по event_id защищает от дублей при повторной доставке из Kafka.
  • Каналы (email/push) выбираются по типу события: одно событие может породить две записи в журнале.
  • Контакт материализуется в момент отправки — история точна даже после смены адреса.
  • Шаблоны в БД обновляются без перезапуска (кэш 60 секунд); нет шаблона — нет уведомления, срабатывает алёрт.
  • Ретраи: три попытки при временных ошибках (30с/5мин/30мин); при постоянной ошибке — сразу FAILED.
  • Статус DELIVERED доступен только для email (через webhook Mailgun); push остаётся в SENT — ограничение FCM.
  • Журнал с фильтрацией и ручным retry — основной инструмент оператора при разборе жалоб.

Что почитать дальше

  • Order Service — архитектура маркетплейса — сервис, который публикует события для Notification.
  • Kafka: основы брокера сообщений — основы для понимания потребителей и at-least-once.