ТЗ. Антифрод-сервис топливного процессинга FuelProcessing
- Проект
- FuelProcessing (Wellsoft), прод — сервер
oktane(141.101.196.65) - БД
- PostgreSQL
fuel_prod1,127.0.0.1:5432на том же хосте - Стек приложения
- .NET (legacy net5 + модули net8), EF Core; Docker-compose
/home/wellsoft/projects/fuel_processing - Источники фактов
db_schema_digest.md(схема, снята с прода 2026-07-24),checks_audit.md(детекторы D-01…D-14)- Дата / автор
- 2026-07-24, архитектура
- Статус
- Черновик на согласование
Документ не переписывает SQL детекторов — они зафиксированы на странице «Детекторы», здесь ссылки по номерам D-XX.
1. Цели и метрики успеха
Проблема. За июнь–июль 2026 прод пережил три инцидента класса «порча / слив лимитов», каждый вычислен вручную постфактум:
- zero-limit flood 2026-07-01 (ГПН+Татнефть, три компании-агрегатора, ~18K тасков, ~74% карт с InDay=0);
- limit-wipe «Лайт Скай» 2026-06-23 (2268 из 2896 карт потеряли лимит);
- фрод ~3,57 млн ₽ net (сговор водителя с АЗС), найден руками.
Цель — превратить ручной постфактум-разбор в автоматическое обнаружение с приоритизацией по риску.
Метрики (measurable success criteria)
| Метрика | Целевое значение MVP |
|---|---|
| MTTD операционных инцидентов (порча лимитов, D-14) | ≤ 1 час от события |
| MTTD карт-скоринг детекторов (поведенческие) | ≤ 24 ч (ночной прогон); опция — каждые 4 ч |
| Recall на известных инцидентах (бэктест) | ≥ 80% карт/событий из размеченных инцидентов попадают в алерты |
| Precision «красных» алертов (score ≥ 75) | ≥ 60% (подтверждённый фрод среди красных) после калибровки |
| Ложные срабатывания | ≤ 5% карт в окне помечены алертом |
| Покрытие ущерба | ≥ 70% рублей подтверждённого фрода флагуются сервисом до ручного обнаружения |
| Ключевой критерий готовности к проду | реальный кейс фрода 07/2026 (≈3,57 млн ₽ net, ядро 7 клон-карт) ловится за ≤ 14 дней при ≤ 5% карт в ложняках (см. §7) |
Метрики без калибровки — это гипотезы; их арбитр — бэктест (§7), а не декларация.
2. Скоуп MVP и не-скоуп
В скоупе MVP
- Read-only витрина
antifraudвfuel_prod1+ инкрементальный ETL (§4). - Движок правил на детекторах D-01…D-14 с параметризацией порогов, окнами, скорингом 0–100, подавлением дублей, whitelist (§5).
- Операционный алерт D-14 (порча лимитов) — отдельный быстрый канал, плюс стопгап-версия на Metabase до готовности сервиса.
- Алерт-карточки в Mattermost + минимальный workflow разбора со статусами и обратной связью для калибровки (§6).
- Бэктест-режим против бэкапа БД с разметкой трёх известных инцидентов (§7).
НЕ в скоупе MVP (backlog)
- ML / anomaly detection обучаемыми моделями — только эвристики D-XX. (TabFM показал на реальных данных R²≈0.20 — сигнал скромный; ML не даёт быстрого ROI для MVP.)
- Автоматический фриз/блокировка карт без человека (только флаг «рекомендован фриз»; авто-действие — за approval-гейтом, отдельная фаза).
- Realtime-стриминг с суб-минутной задержкой (не требуется, см. §3-в).
- Полноценный кейс-менеджмент/тикеты вне Mattermost (интеграция с Jira — backlog).
- Расследование по данным вне
fuel_prod1(аудит-лог слеп:CardChangeLogsбез значений, ES-аудит гаситсяIsBlockLog/isTransaction— на историю изменений лимитов внутри БД опираться нельзя, толькоLimitVinkHistory/ApiLimitInfoдля зеркала ВИНК).
3. Варианты архитектуры
Три варианта — от самого лёгкого к тяжёлому, с трейд-оффами.
Cron/pgAgent гоняет детекторы как SQL, Metabase шлёт threshold-алерты в Mattermost.
Плюсы
- Дни на запуск, ноль нового кода/деплоя, Metabase уже ходит в fuel_prod1 read-only.
Минусы
- Нет агрегации скоринга по карте между детекторами (Metabase алертит по одному запросу, не суммирует 14 сигналов в 0–100).
- Нет подавления дублей, whitelist, workflow разбора (new→investigating→confirmed).
- Нет обратной связи для калибровки.
- Каждый запрос в лоб = seq scan 2.3 ГБ (нет индекса по CreateTime).
Вывод: не тянет карт-скоринг и разбор. Но D-14 (простой порог по всплеску изменений лимитов) на этом стеке живёт отлично — забираем как стопгап.
Отдельный worker (Python 3.12 или .NET-worker net8) в том же compose-стеке oktane. Инкрементальный ETL по Id в схему antifraud, движок правил на конфиге, скоринг, дедуп, whitelist, алерт-карточки в Mattermost, статусы разбора в своих таблицах, бэктест-режим против бэкапа.
Плюсы
- Ровно под задачу: cross-detector скоринг, workflow, калибровка, бэктест — всё есть.
- Прод трогается легко и непрерывно (PK-index чтение), тяжёлые запросы изолированы в витрине.
- Малый стек, один контейнер, деплой существующим CI.
Минусы
Язык: Python предпочтителен (pandas/psycopg, быстрый REPL для калибровки на бэктесте), но .NET-worker допустим ради единого стека и переиспользования EF-моделей — решение за командой; на архитектуру не влияет.
CDC из Postgres (Debezium/логическая репликация) → Kafka → materialized views в ClickHouse на logan (dedic-2), где уже живёт otel-стек fuel.
Плюсы
- Суб-минутная задержка, колоночная агрегация перцентилей на больших окнах.
Минусы
- Требования realtime нет (цель — часы/≤14 дней, не секунды).
- CDC — тяжёлая новая конечность (Debezium, топики, DLQ, backfill, реконсиляция удалений из BillingCheckService.RemoveRange).
- otel-ClickHouse на logan заточен под телеметрию (трейсы/доменные события), смешивать транзакционный фрод-анализ — архитектурный конфликт и кросс-серверная зависимость.
- Over-engineering для 4.9M строк.
Вывод: отклонить для MVP. Держать в уме как scale-out, если объём/латентность вырастут на порядок.
Рекомендация. Вариант (б) как ядро, с D-14-стопгапом из варианта (а) на Metabase в первую же неделю (дешёвая защита от рецидива порчи лимитов, пока сервис пишется). ClickHouse-стриминг (в) — явно отложить.
4. Подключение к данным
4.1. Read-only роль PostgreSQL
Отдельная роль antifraud_ro — принцип наименьших привилегий:
GRANT SELECTтолько на нужные таблицы схемыpublic(FinanceTransaction,Cards,CardOwners,Limits,CardOwnerLimits,GasStations,Region,Companies,DirectContracts,ContractTerms,LimitVinkHistory,ApiLimitInfo); никаких INSERT/UPDATE/DELETE/DDL наpublic.pg_hba.conf: доступ только с loopback (сервис в том же docker-стеке) под этой ролью,scram-sha-256.ALTER ROLE antifraud_ro SET statement_timeout = '60s'— ни один запрос не вешает прод.ALTER ROLE antifraud_ro SET default_transaction_read_only = on— страховка от записи на уровне сессии.- Отдельная роль
antifraud_rw— CREATE/INSERT/UPDATE только в схемеantifraud. Так «блэк-радиус записи» — это вопрос грантов, а не изоляции БД.
4.2. Где живёт витрина — схема antifraud внутри fuel_prod1
Рассмотрены три варианта размещения:
| Вариант | Вердикт |
|---|---|
Схема antifraud в fuel_prod1 | ВЫБРАНО. Детекторы джойнят живые словари без репликации; запись изолирована грантами. |
Отдельная БД fuel_antifraud | Отклонено: Postgres не умеет cross-database join, пришлось бы реплицировать Cards/Limits/GasStations/Region и следить за их свежестью. |
| ClickHouse на logan | Отклонено: кросс-серверная зависимость, otel-стек занят телеметрией (см. §3-в). |
Обоснование выбора. Часть детекторов живёт не на FinanceTransaction, а на мутирующих словарях: D-08 (Limits), D-12 (Cards), D-14 (Limits.LastModifed), D-04 (GasStations/Region). Ключевое: D-14 по смыслу ловит всплеск изменений Limits в конкретный момент — ночной снапшот такое пропустит структурно. Отдельная БД заставила бы реплицировать эти таблицы и всё равно проиграла бы по свежести. Схема в той же БД даёт джойн живых словарей и запуск SQL из checks_audit.md почти вербатим (только schema-qualify ft_recent). Цена — «замусоривание» прод-БД (там и так 187 таблиц с легаси Metabase/Quartz) — косметическая и принимается.
Практическое следствие: не все 14 детекторов читают только витрину. Поведенческая нагрузка
(2.3 ГБ FinanceTransaction) идёт через материализованную antifraud.ft_recent
(последние 90 дней, джойн АЗС/региона). Point-in-time детекторы D-12 и D-14 ходят напрямую в живые
public.Cards / public.Limits (это дёшево: Cards 113K, Limits 650K).
4.3. ETL — инкрементально по Id, НЕ по CreateTime
Критическое ограничение схемы: по FinanceTransaction.CreateTime индекса нет — «за последние N дней» = seq scan 2.3 ГБ. Поэтому:
- Витрина
antifraud.ft_recentнаполняется инкрементально по watermarkmax(Id)(Id — PK, монотонный):WHERE "Id" > :watermark. Это чтение по PK-индексу, а не по времени. - Late-arriving offline: IsOffline-транзакция приходит постфактум — получает высокий Id, но старое CreateTime. По Id мы её всё равно заберём (строка новая); окна пересчитываем по её CreateTime. Корректно.
- Удаления:
BillingCheckService.RemoveRange(FinanceTransaction)физически удаляет строки мимо аудита. Чистый append-watermark их не увидит → раз в сутки реконсиляция: сверка count() за скользящее окно и точечный ре-pull последних K дней. - Limits тянется по LastModifed, а не по Id — таблица UPDATE-heavy, watermark по Id там неприменим. 650K строк — seq scan дёшев, оставляем как есть.
- CreateTime — naive-локальное, CreateTimeUtc частично '0001-01-01'. Все временные проверки (ночь/выходные, D-01) считают время в таймзоне региона АЗС через Region.TimzeZone (смещение; единицы проверить на реальных данных перед калибровкой). NOW() сервера ≠ время транзакции.
- Координаты АЗС — text (CoordsX/CoordsY): в витрине материализуем очищенный CAST (regexp + NULLIF) для haversine (D-04), PostGIS не вводим.
4.4. BRIN-индекс по CreateTime — опция (не основной путь)
CREATE INDEX CONCURRENTLY ... USING brin ("CreateTime") — дешёвый по размеру,
любит корреляцию порядка вставки. Порядок Id ≈ порядок CreateTime (несмотря на naive-TZ разброс, insert-order грубо
трекает время), поэтому BRIN-блоки хорошо прунятся.
- Плюс: делает прямые «last N days» запросы пригодными без витрины, ускоряет backfill/реконсиляцию.
- Риск на прод: CONCURRENTLY на 2.3 ГБ делает полный проход и грузит I/O — строить в окно низкой нагрузки (после бэкапа 04:02 UTC). BRIN не блокирует запись.
- Решение: строить как enabler, но primary-путь — витрина + Id-watermark (не зависим от BRIN).
5. Детекторы и движок правил
Ядро — 14 детекторов D-01…D-14 из checks_audit.md (SQL там, здесь не дублируется). Классы:
- Жёсткие факты: D-05 (дубли номеров ВИНК), D-04 (impossible travel), D-12 (транзакция по мёртвой карте).
- Поведенческие: D-01 (ночь вне профиля), D-03 (velocity), D-06 (круглые/стабильные суммы), D-13 (доля offline/ручных).
- Контекстные: D-02 (объём вне нормы), D-07 (смена типа топлива), D-08 (добор лимита), D-09 (концентрация на АЗС), D-10 (карта между компаниями), D-11 (дрейф поведения).
- Операционный: D-14 (массовая порча лимитов) — вне карт-скоринга, отдельный алерт.
Движок правил
- Конфиг порогов — YAML (файл в репо, монтируется в контейнер), горячий reload. Каждый детектор:
enabled,window, пороги,weight. Значения по умолчанию — переиспользуем из checks_audit.md (веса: D-12/D-05/D-04 = 30–40; поведенческие 15–25; контекстные 10–15; D-02/D-08 = 15), не изобретаем. YAML лишь параметризует. - Окна: детекторы объявляют свои окна (D-02 — 180 дн история / 7 дн триггер, D-03/D-04 — 7 дн, D-09 — 60 дн и т.д.); движок гоняет их против ft_recent/живых словарей по расписанию.
- Скоринг 0–100:
score(card) = min(100, Σ weight активных детекторов за окно 7 дней). Порог алерта: 50 — жёлтый, 75 — красный (+ рекомендация фриза по конфигу; авто-фриз — backlog, §2). - Фильтрация шума (общий дефект исходных проверок): во всех детекторах фильтровать
NoFuelGoodId IS NULL(убрать нетопливные), учитыватьOperationType/From(возвраты/источник) — иначе пороги плывут. - Подавление дублей: алерт идемпотентен по ключу (CardId, набор сработавших детекторов, дата-бакет). Пока карта в статусе investigating/confirmed — не плодим новые карточки, обновляем существующую (новые транзакции под риском доливаются).
- Whitelist карт/компаний/АЗС — таблица
antifraud.whitelist(+ причина, автор, TTL). Карта в whitelist исключается из скоринга (напр. заведомо маршрутный корпоративный транспорт, дающий легитимную концентрацию D-09).
6. Алертинг и workflow разбора
- Канал: Mattermost (уже принимает алерты fuel). Telegram — опция дублирования критичных (красных/D-14).
- Карточка алерта: карта (Number/CardId) · компания (Title/CompanyId) · водитель (CardOwner) · score · список сработавших детекторов с их вкладом · сумма под риском (Σ FullSum подозрительных транзакций окна) · топ-АЗС · окно · ссылка в Metabase на детализацию.
- Статусы разбора:
new → investigating → confirmed_fraud | false_positive. Хранятся вantifraud.alerts(+ assignee, комментарии, timestamps). Переходы — реакциями/командой в Mattermost или мини-UI в Metabase. - Обратная связь для калибровки: каждый confirmed_fraud/false_positive — размеченный пример. Периодический отчёт precision/recall по детекторам на накопленной разметке → правка порогов в YAML. false_positive на конкретной карте → предложение в whitelist.
7. Бэктест и калибровка
- Данные: ежедневный бэкап fuel_prod1 (cron 04:02 UTC, ~7 ГБ raw). Разворачиваем в изолированный Postgres (docker, вне прода), гоняем детекторы в режиме «as of дата» — фиксируем NOW() на момент инцидента, чтобы окна считались корректно.
- Разметка ground truth (3 инцидента):
- zero-flood 2026-07-01 — те же три компании-агрегатора, карты CardType 1/2 с обнулённым InDay → ожидаем D-14.
- «Лайт Скай» 2026-06-23 — 2268/2896 карт, DirectContract-2298 → ожидаем D-14 (+ зеркало LimitVinkHistory).
- фрод ≈3,57 млн ₽ net (сговор водитель↔АЗС).
- Честная оговорка по фроду ≈3,57 млн ₽ net. Он найден вручную постфактум — значит очевидные одиночные правила его не ловили. Не выдаём, что D-set берёт его тривиально. Гипотеза сигнатуры: сговор проявляется комбинацией, не одним детектором — концентрация на «своей» АЗС (D-09) + систематический добор лимита (D-08) + подозрительно ровные/стабильные суммы (D-06) + доля offline/ручных (D-13), всплывающая через суммарный score, а не отдельный сигнал. Арбитр — бэктест. Низкий recall на этом кейсе — это калибровочный сигнал, легитимный результат которого может быть добавление детектора, а не подгонка под ответ.
- Критерий готовности к проду: на бэктесте система флагует реальный net-кейс фрода 07/2026 (≈3,57 млн ₽, ядро 7 клон-карт) (карта попадает в красную зону) за ≤ 14 дней от начала схемы, при ≤ 5% карт окна в ложняках, и ловит оба limit-инцидента (D-14) в час их возникновения. Не выполнено — калибруем/дорабатываем, в прод не выкатываем.
8. Безопасность
- Секреты — HashiCorp Vault (есть на vidak, AppRole). DSN роли antifraud_ro/antifraud_rw, токен Mattermost, креды бэктест-БД — из Vault по AppRole, не в образ/не в compose plaintext. Ни одного пароля в git.
- Никакого write-доступа к прод-данным: antifraud_ro физически SELECT-only на public + default_transaction_read_only; запись только antifraud_rw в схему antifraud. Проверяется тестом прав при старте (fail-fast, если роль внезапно получила лишнее).
- Аудит запросов: сервис логирует каждый запрос к проду (детектор, длительность, число строк) в Loki (Promtail на oktane уже настроен). log_min_duration_statement на роли — ловить медленные.
- Доступ к инфре — через devops MCP / infra-operator (approval-gate), anti-lockout, без ручного перебора кредов.
- Алерт-карточки содержат номера карт/ИНН — канал Mattermost должен быть приватным (доступ безопасников/ответственных), не общий.
9. Этапы, оценка трудоёмкости, риски
| Этап | Содержание | Оценка (чел-дни) |
|---|---|---|
| E0. D-14 стопгап | SQL D-14 в Metabase-on-oktane + алерт в Mattermost | 1–2 |
| E1. Фундамент данных | роли antifraud_ro/rw, схема antifraud, pg_hba/timeouts, витрина ft_recent + инкрементальный ETL по Id, реконсиляция удалений | 3–4 |
| E2. Движок детекторов | порт D-01…D-14 на витрину/живые словари, YAML-конфиг, скоринг, дедуп, whitelist | 4–6 |
| E3. Алертинг + workflow | карточки Mattermost, статусы разбора, таблицы alerts, обратная связь | 2–3 |
| E4. Бэктест-харнесс | разворот бэкапа, «as of» прогон, разметка 3 инцидентов, отчёт precision/recall | 2–3 |
| E5. Калибровка | подгон порогов до критерия §7 | 2–4 |
| E6. Прод-выкатка | деплой в compose, мониторинг сервиса, runbook | 1–2 |
| Итого MVP | 15–24 чел-дня | |
| Опция: BRIN-индекс | сборка CONCURRENTLY в окне | 0.5 |
Риски
- Region.TimzeZone — единицы/корректность. Ночные детекторы (D-01) зависят от смещения; кривые данные → шум. Митигация: проверить на реальных данных до калибровки, детектор с параметром «доверять TZ / фолбэк».
- Мусорные text-координаты (D-04): часть АЗС без/с битыми координатами → NULL, impossible-travel не считается. Митигация: метрика покрытия координат, детектор скипает NULL честно.
- Дрейф Id-watermark из-за удалений (BillingCheckService). Митигация: суточная реконсиляция (§4.3).
- Аудит-лог слеп — историю изменений лимитов внутри БД восстановить нельзя. Митигация: D-14 по Limits.LastModifed + зеркало LimitVinkHistory/ApiLimitInfo, не по несуществующему CardChangeLogs.ChangeType.
- Пороги переносятся с бэктеста на «живой» прод неидеально (распределения дрейфуют). Митигация: обратная связь §6, периодическая рекалибровка.
- Нагрузка на прод от backfill/BRIN. Митигация: только окно низкой нагрузки + statement_timeout (§10).
10. Эксплуатация
- Мониторинг самого сервиса: healthcheck-эндпоинт + метрики (лаг ETL = now - max(CreateTime) в витрине, длительность прогона детекторов, число алертов, ошибки запросов) → Grafana fuel-overview на logan. Алерт, если ETL-лаг > порога или прогон падает.
- Деградация при недоступности БД: ETL/детекторы идемпотентны и retryable; при недоступности прода сервис не пишет мусорные алерты, а поднимает флаг «stale» (последний успешный прогон в карточке health). Витрина переживает рестарт (данные персистентны).
- Нагрузочные ограничения на прод:
- Steady-state Id-watermark pull — лёгкое чтение по PK-индексу, окна низкой нагрузки не требует (идёт непрерывно). Это и есть аргумент, почему (б)+витрина трогают прод легко, где (а) молотил бы 2.3 ГБ.
- Тяжёлое (окно низкой нагрузки + statement_timeout) — только: (1) начальный backfill витрины, (2) сборка BRIN, (3) суточная реконсиляция/ре-sync словарей.
statement_timeout=60sна роли — предохранитель от рассинхрона нагрузки.- Read-реплика прод-Postgres — опция масштабирования: если steady-state нагрузка начнёт мешать проду, ETL/детекторы переключаются на реплику одним изменением DSN (витрина при этом остаётся в fuel_prod1, либо переезжает вместе — решается на этапе, если понадобится).
- Runbook: что делать при stale ETL, при всплеске ложняков (крутить пороги в YAML), при рецидиве порчи лимитов (D-14 → эскалация).
Приложение. Связь детекторов и таблиц (быстрая карта)
| Детектор | Источник | Читает витрину / живое |
|---|---|---|
| D-01 | ночь вне профиля — FinanceTransaction + Region.TimzeZone | витрина |
| D-02 | объём вне нормы — FinanceTransaction | витрина |
| D-03 | velocity — FinanceTransaction (LAG) | витрина |
| D-04 | impossible travel — FinanceTransaction + GasStations (coords) | витрина (очищенные coords) |
| D-05 | дубли номеров ВИНК — FinanceTransaction | витрина |
| D-06 | круглые/стабильные суммы — FinanceTransaction | витрина |
| D-07 | смена типа топлива — FinanceTransaction | витрина |
| D-08 | добор лимита — Limits (+ FinanceTransaction для полной версии) | живые Limits + витрина |
| D-09 | концентрация на АЗС — FinanceTransaction | витрина |
| D-10 | карта между компаниями — FinanceTransaction | витрина |
| D-11 | дрейф поведения — FinanceTransaction (weekly) | витрина |
| D-12 | мёртвая карта — FinanceTransaction ⋈ Cards | витрина ⋈ живые Cards |
| D-13 | offline/ручные — FinanceTransaction | витрина |
| D-14 | порча лимитов — Limits.LastModifed (+ LimitVinkHistory) | живые Limits (point-in-time) |
Черновик на согласование, 2026-07-24. Детекторы — D-01…D-14, схема БД — fuel_prod1, живой пример — кейс 3,5 млн.