Skip to content

Архитектура

Платформа NPPWEB построена как разделённый конвейер, где внешние источники, нормализация, доменная модель и UI разведены по разным сервисам и разным репозиториям.

Это сделано специально: сбор с государственных площадок нестабилен, и его нельзя смешивать с бизнес-хранилищем и аналитикой.

Сквозная схема

text
Внешние площадки
  EIS / EASUZ / RNP / FNS / Fedresurs / GIS Torgi
                |
                v
         scrape-helper
   collect -> artifacts -> source.raw.v1
                |
                v
             RabbitMQ
                |
                v
       processing-worker
 validate -> normalize -> ingest
                |
                v
          npp-backend
  Postgres + GraphQL + reports + analytics
                |
                v
             npp-web

Архитектура как набор независимых слоёв

NPPWEB специально не устроен как единый монолит. В нём разведены четыре разных типа ответственности:

  1. Интеграционный слой Внешние площадки нестабильны, могут менять HTML, throttling и правила доступа. Поэтому логика подключения и парсинга вынесена в scrape-helper.
  2. Транспортный слой Между сбором и доменной моделью стоит RabbitMQ. Это позволяет не связывать парсер и backend синхронным HTTP-вызовом.
  3. Слой нормализацииprocessing-worker превращает внешний payload не просто в JSON, а в унифицированную бизнес-форму, пригодную для аналитики.
  4. Доменный слойnpp-backend хранит состояние системы, считает агрегаты и является единственным владельцем бизнес-сущностей.

Такое разделение важно не только архитектурно, но и эксплуатационно: каждый слой может деградировать по-своему, и это нужно уметь диагностировать отдельно.

Главные архитектурные принципы

Сбор не пишет напрямую в базу

scrape-helper не создаёт Procurement, Report или Supplier. Он только:

  • обращается к площадке;
  • извлекает карточки;
  • прикладывает артефакты;
  • публикует transport-level событие.

Это позволяет безопасно менять парсер, не ломая доменную модель.

Нормализация отделена от парсинга

Парсер знает внешний HTML или JSON. Worker знает, как привести данные разных источников к общей форме.

Поэтому:

  • изменение площадки чаще всего чинится в scrape-helper;
  • изменение derived-полей и нормализованной логики чаще живёт в processing-worker;
  • изменение доменной модели и аналитики живёт в npp-backend.

Backend — единственный владелец состояния

Только npp-backend отвечает за:

  • хранение пользователей и сессий;
  • Source, SourceRun, Procurement, Supplier, Report;
  • агрегаты и аналитические витрины;
  • GraphQL-контракт для фронта.

Frontend не должен вычислять бизнес-метрики самостоятельно.

Поток данных подробнее

1. Сбор данных

scrape-helper запускает источники по расписанию или вручную через control API.

Каждый источник оформлен как SourceAdapter с одним единым контрактом:

ts
export interface SourceAdapter {
  code: string;
  name: string;
  collect(context: SourceRunContext): Promise<CollectedRawRecord[]>;
}

На выходе адаптер не возвращает доменную сущность. Он возвращает:

  • URL источника;
  • сырой payload;
  • metadata;
  • набор артефактов, например RAW_HTML, RAW_JSON, REPORT_FILE.

Принципиально важно, что adapter не возвращает Procurement или другую доменную запись. Это транспортный слой, а не бизнес-модель.

Ниже показан упрощённый контракт адаптера:

ts
export interface SourceAdapter {
  code: string;
  name: string;
  collect(context: SourceRunContext): Promise<CollectedRawRecord[]>;
}

2. Transport layer

После сбора scrape-helper:

  1. загружает артефакты в MinIO/S3;
  2. формирует RawSourceEvent;
  3. валидирует его по схемам contracts;
  4. публикует в RabbitMQ;
  5. отправляет проблемные элементы в quarantine queue.

Типовой фрагмент формирования raw-события выглядит так:

ts
const rawEvent: RawSourceEvent = {
  eventId: randomUUID(),
  runKey,
  source: adapter.code,
  payloadVersion: "v1",
  collectedAt,
  url: input.record.url,
  artifacts,
  raw: enrichRawPayload(input.record.raw, input.record.url, input.collectedAt, artifacts)
};

validateRaw(rawEvent);
await publisher.publish(rawEvent);

Отдельно сервис репортит SourceRun состояния в backend:

  • RUNNING
  • SUCCESS
  • FAILED

Это и есть источник данных для operational-экранов по здоровью пайплайна.

3. Нормализация

processing-worker читает source.raw.v1 и определяет, во что превращать событие:

  • procurement
  • registry
  • risk_signal
  • company_profile
  • auction

В этой точке:

  • вычисляются общие поля;
  • добавляются source-specific derived fields;
  • строятся enrichment-поля вроде targetStationName, supplierNormalized, analyticsCategory;
  • проверяется, нужно ли quarantining на уровне контента.

Упрощённо worker ведёт себя так:

ts
const raw = queue.parseMessage<RawSourceEvent>(message);
validators.validateRaw(raw);

const quarantinedEvent = detectQuarantinableRawEvent(raw);
if (quarantinedEvent) {
  await reportQuarantinedRawEvent(apiGraphqlUrl, ingestToken, quarantinedEvent);
  queue.ack(message);
  return;
}

const normalized = normalizeRawEvent(raw);
validators.validateNormalized(normalized);
await sendToBackend(apiGraphqlUrl, ingestToken, normalized);
await queue.publish(queueNormalized, normalized);
queue.ack(message);

Здесь хорошо видно три принципа:

  • worker сначала валидирует transport-событие;
  • затем может остановить поток через quarantine;
  • только после этого создаёт normalized event и делает ingest.

4. Ingest и доменная запись

npp-backend принимает нормализованное событие по GraphQL и кладёт его в нужную доменную таблицу.

В зависимости от типа это могут быть:

  • Procurement
  • RegistryRecord
  • SupplierRiskSignal
  • SupplierCompanyProfile
  • AuctionItem

Также backend хранит:

  • Source
  • SourceRun
  • RawEvent
  • Artifact
  • NormalizedItem

Это даёт трассировку от аналитического экрана назад до исходного артефакта.

Ключевой принцип backend: он хранит не только конечную бизнес-запись, но и историю её происхождения.

Упрощённый фрагмент идемпотентной загрузки:

ts
const contentHash = createHash("sha256")
  .update(JSON.stringify({
    externalId: input.externalId,
    source: input.source,
    payloadVersion: input.payloadVersion,
    title: input.title,
    rawPayload: input.rawPayload ?? null
  }))
  .digest("hex");

const idempotencyKey = createHash("sha256")
  .update(`${input.source}:${input.externalId}:${input.payloadVersion}:${contentHash}`)
  .digest("hex");

Это защищает систему от повторной доставки одного и того же сообщения и позволяет backend безопасно работать в асинхронной среде.

5. Аналитика и UI

npp-backend считает агрегаты поверх доменной модели, а npp-web только отображает их через GraphQL.

Ключевая идея:

  • backend решает, что считать срочной закупкой, атомным контуром и риском по источнику;
  • frontend не дублирует эту логику;
  • отчёты и аналитика воспроизводимы и не зависят от локального UI-state.

Это один из самых важных принципов проекта: frontend не должен быть вторым backend.

Для клиента GraphQL выглядит как тонкий типизированный слой доступа:

ts
export const ANALYTICS_QUERY = gql`
  query AnalyticsSummary {
    analyticsSummary {
      closingSoonCount
      overdueCount
      highValueCount
      atRiskSources
      runSuccessRate
      publicationEfficiency
    }
  }
`;

Основные технические компоненты

КомпонентРоль
Postgresосновное хранилище backend
Redisслужебный runtime backend
RabbitMQtransport между сбором и нормализацией
MinIOхранение артефактов
Xray proxyдоступ к ограниченным внешним площадкам
GitHub Pagesпубликация документации

Доменные сущности

Ключевые объекты платформы:

  • Source — каталог подключённых источников;
  • SourceRun — запуск источника со статусом и счётчиками;
  • RawEvent — зафиксированное сырое сообщение transport-слоя;
  • Artifact — HTML, JSON, PDF и другие исходные документы;
  • Procurement — нормализованная закупка или договор;
  • Supplier — сущность поставщика, объединяющая несколько контуров;
  • SupplierRiskSignal — риск-сигналы, например по Fedresurs;
  • SupplierCompanyProfile — профили компаний, например из FNS;
  • RegistryRecord — записи РНП;
  • Report — snapshot-отчёт.

Почему в аналитике бывают “пустые” экраны

Архитектурно важно понимать: не каждый успешно собранный источник автоматически наполняет каждый экран.

Примеры:

  • источник может успешно формировать RegistryRecord, но конкретный экран читать только Procurement;
  • закупка может существовать, но не иметь связанного supplierId;
  • RNP и FNS могут обогащать supplier-domain, но не участвовать в конкретном графике без дополнительных агрегатов;
  • Fedresurs может быть нужен не для закупок, а для watchlist по рискам.

Поэтому техническое “данные есть” и аналитическое “экран наполнен” — это не одно и то же.

Границы ответственности

СервисЗа что отвечаетЧего не должен делать
scrape-helperдоступ к площадкам, raw-сбор, артефакты, source runsписать бизнес-сущности в Postgres
processing-workerнормализация, derived fields, ingestхранить состояние системы
npp-backendдоменная модель, auth, отчёты, аналитика, GraphQLзависеть от UI или прямого формата внешней площадки
npp-webотображение и пользовательские сценариисчитать бизнес-метрики на клиенте

Как читать сбой по слоям

Хорошая эксплуатационная диагностика в NPPWEB почти всегда идёт сверху вниз:

  1. пользователь видит пустой экран или неверную метрику;
  2. проверяется GraphQL-ответ backend;
  3. затем доменные таблицы и отчётные агрегаты;
  4. потом normalized / raw-поток;
  5. и только в конце внешний источник и его HTML/API.

Это позволяет не чинить “не тот” слой и быстрее локализовать проблему.

Когда меняется какой слой

Если меняется:

  • внешний HTML или API площадки — первым меняется scrape-helper;
  • схема события — первым меняется contracts;
  • форма normalized payload — меняются contracts, processing-worker, затем backend;
  • GraphQL API — меняются contracts, npp-backend, npp-web;
  • бизнес-метрика или отчёт — первым меняется npp-backend;
  • только визуальное представление — меняется npp-web.

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

  1. Аналитический контур
  2. Карта репозиториев
  3. Диагностика и Runbook

Техническая и аналитическая документация платформы NPPWEB.