Архитектура¶
Модуль 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-контур
(первый пункт выше).