Перейти к содержанию

Архитектура

Модуль CM Ocean Loyalty — балльная платформа лояльности, построенная вокруг бонусного леджера на двойной бухгалтерии. Модуль реализует доменную поверхность лояльности (счета, баланс, начисление, двухфазное списание, перевод, обмен, сгорание, акции, уровни, бизнес-день) и интегрируется с соседними модулями контура CM Ocean — Promo, MDM, RT-Flow/SDE, DWH и единым окном-хостом Core UI.

Компоненты модуля

Компонент Технология Роль
loyalty-billing Spring Boot, Java, REST /v1 Бэкенд-сервис: бонусный леджер, доменный API лояльности, акции, конфигурации программы и уровни, бизнес-день, мониторинг и отчёты, справочники (НСИ), интеграционные эндпоинты
loyalty-ui React, antd, MF-remote под Core UI Микрофронтенд: разделы «Акции», «Клиенты», «Ручные операции», «Конфигурации программы», «Настройка ПЛ», «Мониторинг», «Отчёты» и «Администрирование» (с подменю разделов)
PostgreSQL СУБД, миграции Liquibase System of record сервиса: счета, лоты, проводки (леджер), акции, покупки и возвраты, уровни клиентов, справочники, журнал операций, очереди приёма команд и исходящих событий
Keycloak OAuth2 / OIDC Сервер аутентификации; loyalty-billing — resource server (проверка JWT), роли LOYALTY_*
Redis (опционально) Cache-aside Кеш баланса счёта — ускоряет чтение под нагрузкой; выключен по умолчанию, модуль полностью работоспособен без него
Kafka (опционально) Брокер сообщений Приём команд извне, транспорт чанков пакетов ручных операций, отправка событий смены уровня и уведомлений клиентам; каждый транспорт выключен по умолчанию, без брокера приём работает синхронно через REST, а исходящие события копятся в очереди

Леджер — ядро модуля

Все балльные операции выполняются методом двойной записи: каждая транзакция формирует сбалансированные проводки (дебет/кредит), а баланс счёта раскладывается на три величины — balance (всего), held (в холде под двухфазное списание) и available (доступно к списанию: баланс за вычетом резервов и баллов, срок которых уже наступил, даже если ночной прогон сгорания их ещё не сжёг; отрицательным не бывает). Денежные операции идемпотентны: повтор с тем же Idempotency-Key (а в ядре — с тем же reference) не создаёт повторной проводки. Это даёт обратимость, аудируемость и устойчивость к ретраям.

Взаимодействие с контуром

Loyalty — один из модулей платформы CM Ocean. Соседние модули (Promo, MDM, RT-Flow/SDE, DWH, Core UI) — самостоятельные продукты; Loyalty с ними интегрируется, но их не включает в себя.

Кто С кем Зачем
Loyalty Promo Promo при погашении промокода с эффектом «баллы» асинхронно (из outbox) вызывает Loyalty POST /v1/integration/promo/accruals с Idempotency-Key = redemptionId. Loyalty исполняет начисление и пишет операцию в журнал с источником PROMOCODE. Владелец промокодов — Promo, владелец балльного леджера — Loyalty
Loyalty MDM MDM (Data Ocean Governance) — мастер golden-record клиента (1 телефон = 1 профиль). Loyalty читает identity клиента по externalId через адаптер; профиль клиента доступен в UI как вкладка
Loyalty RT-Flow / SDE RT-Flow / Talys.SDE (Apache Flink) — потоковый decisioning. Исполняет механики акций в реальном времени и вызывает доменный API Loyalty (начисление/списание) как внешний сервис
Loyalty DWH Хранилище данных потребляет операции и витрины лояльности для аналитики и отчётности
Loyalty Система расчёта сегментов (например, Batch Flow) Рассчитывает состав сегментов клиентов и заливает его в Loyalty через интеграционный API; залитый состав — единственный источник сегментов клиента
Loyalty Система взаимодействия с клиентом Получает из брокера события смены уровня и уведомления клиентам и сама выбирает канал доставки
Loyalty Core UI Единое окно-хост платформы. loyalty-ui подключается как MF-remote: разделы продукта попадают в левое меню хоста из манифеста навигации, «Администрирование» — раскрываемый пункт с подменю разделов — внутри общей навигации и дизайн-системы CM Ocean
flowchart TB
    POS[POS / касса / Promo] -->|чек, начисление| LB
    SDE[RT-Flow / SDE Flink] -->|механики: accrue / hold / commit| LB
    subgraph Loyalty[Модуль CM Ocean Loyalty]
        LB[loyalty-billing<br/>доменный API /v1]
        UI[loyalty-ui<br/>MF-remote]
        PG[(PostgreSQL<br/>леджер, акции, НСИ)]
        LB --- PG
    end
    KC[Keycloak<br/>OAuth2 / OIDC] -.JWT.-> LB
    CoreUI[Core UI — окно-хост] --- UI
    UI -->|REST /v1| LB
    LB -->|identity по externalId| MDM[MDM — golden record]
    LB -->|лента изменений, витрины| DWH[(DWH)]
    BF[Система расчёта сегментов] -->|состав сегментов| LB
    LB -.->|"баланс (опционально)"| REDIS[(Redis)]
    KAFKA[[Kafka]] -.->|"приём команд (опционально)"| LB
    LB -.->|"смена уровня, уведомления (опционально)"| KAFKA

Redis и Kafka на диаграмме — опциональная инфраструктура: связи с ними выключены по умолчанию, модуль полностью работоспособен без них (подробнее — «Кеш баланса», «Приём операций» и «Исходящие события» ниже).

Функциональные модули бэкенда

Бэкенд — один сервис, разделённый на доменные модули. Помимо леджера, счетов, акций и механик, уровней, тегов и меток, справочников, приёма команд и обмена данными в сервис входят:

Модуль Назначение
Конфигурации программы Черновики и активная конфигурация программы: уровни, правила накопления и пересмотра, правила списания, акции программы и уровней, история активаций
Движок уровней Хранимый уровень клиента, накопление по журналу покупок, пересмотр в личную дату клиента, событие смены уровня
Покупки и возвраты Журнал покупок (одна покупка на чек), возвраты по чеку со сторно баллов
Реестр условий акции Коробочные и добавленные условия акции, переменные покупки и их статистика
Согласование Согласование запуска акции по принципу «четырёх глаз», реестр сотрудников
Сегменты клиентов Приём состава сегментов из внешней системы, история входов и выходов, журнал заливок
География, мерчанты, каталог товаров Справочники городов, торговых точек, мерчантов, дерева категорий и товаров — для условий акций, партнёрских транзакций и ограничений субсчетов
Профиль клиента Версии настройки карточки клиента, схема полей, таблицы-источники смежных систем
Валюты и обмен Справочник валют программы, пары обмена валют и их курсы
Уведомления Очередь исходящих уведомлений клиентам, пояс клиента
Отчёты Отчёты за период: пульс программы, баллы, акции, уровни, реферальная программа

Поток данных: покупка → акция → начисление

sequenceDiagram
    participant POS as POS / Promo
    participant SDE as RT-Flow / SDE
    participant LB as loyalty-billing
    participant PG as PostgreSQL (леджер)
    POS->>SDE: событие покупки / чек
    SDE->>LB: применить акцию (механика)
    LB->>LB: арбитраж и расчёт по версии акции
    LB->>PG: проводки двойной записи (accrual)
    PG-->>LB: новый balance / available
    LB-->>SDE: transactionId, balanceMinor

Приём операций: синхронный и асинхронный

Помимо синхронных REST-эндпоинтов (чек покупки, интеграция с Promo, ручные операции), модуль умеет принимать команды изменения баланса (начисление, списание, резерв) асинхронно — через очередь приёма (command_inbox).

flowchart LR
    SRC["Внешняя система"] -->|"POST /v1/intake/commands<br/>или Kafka-топик"| INBOX[("command_inbox<br/>PostgreSQL")]
    KAFKA["Kafka-потребитель<br/>(выключен по умолчанию)"] --> INBOX
    INBOX --> WORKER["Фоновый воркер<br/>(тик раз в секунду)"]
    WORKER -->|"начисление / списание / холд"| LB2[loyalty-billing use case]
    WORKER -.->|"неисполнимо"| PARKED["Статус PARKED<br/>разбор оператором"]
  • Транспорт только принимает и сохраняет. Приёмная транзакция — один INSERT; отдельный транзакционный воркер исполняет команду поверх обычных use case начисления/списания/холда. Время ответа отправителю не зависит от времени работы леджера, а недоступность БД не теряет событие.
  • Два входа в одну очередь. POST /v1/intake/commands (роль LOYALTY_INTAKE_WRITE, ответ 202) и Kafka-потребитель (loyalty.intake.kafka.*) — оба кладут строку в тот же command_inbox, дальше её обрабатывает один и тот же воркер. Kafka-транспорт выключен по умолчанию: на контуре без брокера приложение стартует и работает как раньше, синхронные точки приёма (/events/purchase, /integration/**) не меняются и не считаются устаревшими.
  • Порядок исполнения — в пределах клиента. Ключ партиционирования Kafka — идентификатор клиента, но сам порядок обеспечивает не транспорт, а воркер: захват берёт не более одной команды на клиента и только если у него нет команды в работе; исполнение идёт под блокировкой клиента с повторной проверкой, что более старой незавершённой команды нет. Для пары «начисление → его сторно» это важно; для независимых операций разных клиентов порядок не гарантируется и не нужен.
  • Три рубежа против повторного исполнения: event_id как первичный ключ очереди гасит транспортный дубль; аренда обработчика (с TTL) не даёт двум репликам взять одну и ту же команду; ключ проводки ledger_posting_reference ловит повтор самого бизнес-факта, даже пришедший под другим идентификатором.
  • Неисполнимое откладывается в разбор (статус PARKED) вместо бесконечных повторов — видно через GET /v1/admin/intake/commands (роль LOYALTY_INTAKE_READ), оператор возвращает команду в работу или отменяет её с основанием (LOYALTY_INTAKE_WRITE).
  • Отложенное исполнение: у команды может быть указан срок — воркер берёт в работу только созревшие строки, отдельного планировщика для отсрочки не потребовалось.

Исходящие события: очередь исходящих

События, которые Loyalty отдаёт наружу, — смена уровня клиента и уведомления клиенту — не отправляются в брокер из бизнес-операции напрямую. Они пишутся строкой очереди исходящих в БД в той же транзакции, что и сам факт, а отправляет накопленное отдельный отправитель:

flowchart LR
    OP["Бизнес-операция<br/>(покупка, пересмотр, сгорание)"] -->|"одна транзакция"| Q[("Очередь исходящих<br/>PostgreSQL")]
    Q --> RELAY["Отправитель<br/>(одна реплика за тик)"]
    RELAY -->|"событие, ключ — клиент"| K[[Kafka]]
    RELAY -.->|"уведомление не ушло за N попыток"| PARK["Разбор"]
  • Откат операции уносит и событие; касса не ждёт брокер и канал доставки.
  • Тик отправителя выполняет ровно одна реплика (advisory-блокировка PostgreSQL), первая неудачная отправка останавливает тик — события одного клиента уходят в порядке появления.
  • Доставка «не менее одного раза»: у события стабильный eventId, по нему потребитель отличает повтор.
  • Пока отправка выключена, события копятся; пролежавшие дольше срока годности списываются, а не уходят разом при включении. Очередь уведомлений убирается по сроку хранения и при выключенной отправке; очередь событий смены уровня списывается и убирается только работающим отправителем, то есть после включения отправки.

Подробнее о составе событий — «Интеграции» → «Исходящие события».

Время: пояс программы и UTC

Сервис различает два пояса. Пояс среды исполнения — всегда UTC (задан в образе и чартах): по нему считаются технические границы — партиции леджера, печати журнала аудита, технические расписания. Пояс программы (program.timezone, по умолчанию Europe/Moscow) — бизнес-пояс контура: в нём режутся календарные сутки лимитов, итогов и отчётов, подставляется учётная дата при пустом справочнике рабочих дней, трактуются расписания сгорания баллов, предупреждений и выгрузок и подписываются даты в текстах клиенту. Подробнее — «Конфигурация» → «Часовой пояс программы».

Кеш баланса (Redis)

Чтение баланса счёта по умолчанию идёт напрямую в PostgreSQL. Опционально включается Redis-кеш (cache-aside), который подставляется в тот же порт чтения баланса без изменения вызывающего кода:

  • Версионирование по account.updated_at. Значение в кеш кладётся, только если версия счёта не изменилась между чтением баланса и записью в кеш — это защищает от «застревания» устаревшего значения под конкурентной записью.
  • Отказ Redis не роняет операцию. Любая ошибка кеша (недоступность, таймаут) гасится и превращается в обычное чтение из БД — кеш это оптимизация, а не источник истины.
  • Срок лота учитывается кешем. Закешированный баланс устаревает в момент ближайшего срока лота — баллы с наступившим сроком становятся недоступны сразу, а не после прогона сгорания.
  • Чтение внутри транзакции денежной операции идёт мимо кеша (например, подбор акций внутри покупки, который тут же опирается на этот баланс): секундная давность значения там неприемлема.
  • Выключен по умолчанию; включается контур за контуром.

Леджер: время проводки и партиционирование

У проводки леджера есть колонка времени (posted_at, назначается часами БД в момент вставки) и колонка операционного дня (business_date) — раньше время существовало только в старших битах идентификатора-снежинки, а операционный день был не у всех видов движений. Леджер партиционирован помесячно по posted_at:

  • Партиции нарезаются заранее (обслуживающая задача, loyalty.ledger.partitions.*), с запасом вперёд; есть страховочная DEFAULT-партиция и метрика её заполнения — попадание строки в неё означает, что нарезка не поспевает.
  • Первичный ключ проводки стал составным (включает ключ партиционирования), поэтому уникальность идентификатора и отсутствие «лотов-сирот» (лотов, ссылающихся на несуществующую проводку) больше не держится ограничениями схемы — это проверяет ночная задача с метриками (loyalty.ledger.integrity.duplicate.entries, loyalty.ledger.integrity.orphan.lots).
  • Контур с уже накопленными данными переводится на партиционированную схему отдельной эксплуатационной процедурой до деплоя новой версии (см. «Установка и развёртывание» → перевод на партиционированный леджер); на пустой базе миграция применяется сама через Liquibase.

Распределённые блокировки

Денежная операция, затрагивающая больше одного счёта (перевод, обмен баллов), берёт advisory-блокировки Postgres сразу по нескольким счетам. Порядок захвата зафиксирован в контракте, едином для всех реализаций и тестовых двойников: ключи сортируются перед захватом, снятие идёт в обратном порядке. Это стандартная защита от взаимной блокировки (deadlock) при конкурентных операциях над одной парой счетов в разном порядке. В ключ блокировки входит только счёт клиента — системные счета (источник начислений, приёмник списаний) не блокируются, что даёт межклиентскую параллельность при сохранении порядка операций одного клиента.

Аренда instance-id для генератора идентификаторов

Идентификаторы (snowflake) кодируют время, номер реплики (instance-id) и порядковый номер внутри миллисекунды. Номер реплики не жёстко задан конфигурацией — каждая реплика арендует свободный слот в таблице БД (id_generator_slot) при старте и продлевает аренду периодическим heartbeat (loyalty.id-generator.lease.*):

  • Протухший слот (heartbeat не продлевался дольше TTL) переиспользуется следующей стартующей репликой.
  • Реплика, потерявшая аренду (соседняя реплика её перехватила, либо собственные часы «встали» дольше TTL), останавливает генерацию идентификаторов, а не продолжает её — это исключает коллизии ID между репликами и дата-центрами при масштабировании или временной недоступности БД.

Обмен данными с внешними системами

Модуль отдаёт свои данные внешним потребителям (DWH и другим системам контура) двумя способами:

  • Лента изменений по запросу — GET /v1/admin/data-exchange/feeds/{customers,accounts,operations}: инкрементальная выборка «что изменилось после момента X» с курсорной пагинацией (без параметра total, чтобы не считать COUNT(*) на каждый запрос). Роль — LOYALTY_EXCHANGE_READ, отдельная от LOYALTY_INTEGRATION_READ, так как лента отдаёт реестр клиентов целиком, включая персональные данные.
  • Регламентная выгрузка витрин — задания с расписанием (cron), журналом прогонов и ручным запуском (/v1/admin/data-exchange/jobs*, роль LOYALTY_EXCHANGE_WRITE/READ); водяной знак сдвигается только на успехе, поэтому упавший прогон не теряет срез — следующий его повторяет. Опционально задание переливает витрину в целевую БД потребителя (DTP) запросом вставки с обработкой конфликта (идемпотентно — соседние прогоны намеренно перекрываются отступом от текущего момента).
  • Задания выгрузки в поставке выключены — включение требует настроенного приёмника и договорённости о расписании с контуром заказчика.

Интеграционный периметр

Интеграционные и файловые входы модуля защищены сквозным набором мер:

  • Ограничение частоты (rate limiting) по всем группам эндпоинтов, а не выборочно: алгоритм GCRA, ключ — субъект токена или IP, состояние в памяти реплики (при нескольких репликах фактический предел ≈ N × capacity). Новые правила заводятся в режиме наблюдения (метрика и предупреждение в логе без отказа) и переводятся в принудительный режим после обкатки порога на реальном трафике.
  • Проверка загружаемых файлов до разбора содержимого — сигнатура (текст, не архив, не исполняемый файл), кодировка UTF-8, размер, число строк, опциональная антивирусная проверка (fail-closed: включённая проверка при недоступном сканере отказывает, а не пропускает файл).
  • Сверка итогов и целостности баланса (reconciliation) — сравнение суммы проводок леджера с балансами счетов, при наличии — с итогами контрагента; расхождения фиксируются в журнале прогонов с уровнем серьёзности.

Безопасность и наблюдаемость

  • Аутентификация и авторизация. loyalty-billing — OAuth2 resource server на Keycloak (валидация JWT). Доступ — по принципу deny-by-default: эндпоинты закрыты @PreAuthorize под роли семейства LOYALTY_* (например, LOYALTY_INTEGRATION_WRITE для приёма начислений от Promo).
  • Наблюдаемость. JSON-логи с traceId, метрики Prometheus (/metrics), пробы готовности и живучести (/health) для оркестратора. Контракт API — code-first OpenAPI с diff-gate на изменения.
  • Обработка ошибок интеграций. Ответы об ошибках — в формате RFC-7807 (application/problem+json) со стабильным code и traceId; 4xx — без ретраев, 5xx/таймауты — безопасный ретрай за счёт идемпотентности.

Хранилище и миграции

PostgreSQL — единый system of record модуля: счета и лоты, проводки леджера (партиционированные помесячно — см. выше), ссылки идемпотентности, акции и их версии, конфигурации программы и уровни клиентов, журнал покупок и возвратов, журнал бонусных операций, очередь приёма команд (command_inbox) и очереди исходящих событий (tier_event_outbox, outbound_notification), состав сегментов клиентов, справочники (локальные reference_entry, типизированные справочники — города, торговые точки, мерчанты, каталог товаров, единицы измерения, операционная конфигурация system_config). Схема ведётся миграциями Liquibase; поддерживаемая версия PostgreSQL — см. «Установка и развёртывание».

В дорожной карте (не реализовано)

Перечисленное ниже относится к плану развития и в текущей версии модуля отсутствует:

  • Публикация справочников/акций и бонусных операций в поток событий (CDC/Kafka → SDE) — сегодня справочники кэшируются только в памяти самой реплики (loyalty.reference-cache.*), потоковой публикации во Flink-контур нет. В брокер уже уходят только события смены уровня и уведомления клиентам (см. «Исходящие события» выше).
  • Механики-как-параметры — конфигурируемые шаблоны механик.
  • Сертификаты — выпуск и погашение.
  • Umbrella-чарт, объединяющий поставку backend и UI одной установкой (см. «Установка и развёртывание»).

Приём начислений/списаний от RT-Flow и выгрузка данных в DWH (лента изменений, регламентные задания, POST /v1/integration/rtflow/condition-checks) — уже готовая функциональность, а не план; целевой моделью остаётся только потоковая публикация справочников и акций во Flink-контур (первый пункт выше).