Перейти к основному содержимому

Neo4j: база groundmatrix

Места, кешбэк и связи вышек с землями есть только в Neo4j. По этой базе p2-backend выбирает родителя для новой земли и землю-хозяина для вышки, а бэкенд выдаёт подписи на покупку. Контракты эти решения не проверяют, поэтому ошибка в графе превращается в ошибку размещения.

Какие базы участвуют​

БазаКонстантаЧто в нейЗачем Land
groundmatrixGROUND_DATABASE (apps/p2-backend/src/constants/neo4j.constants.ts:2)Земли и вышкиВсё, что описано ниже
program1P1_DATABASE (neo4j.constants.ts:1)Матрица Program 1 (Apex)Цепочка аплайнов для поиска мест
neo4j (по умолчанию)—Матрица Program 2Не используется

Все три живут в одном инстансе Neo4j Enterprise, подключение одно — NEO4J_DRIVER из apps/p2-backend/src/neo4j.module.ts.

База должна существовать заранее

Neo4j не создаёт базу при подключении: groundmatrix нужно создать вручную (CREATE DATABASE groundmatrix), иначе LiquidityGroundEventsJob.onModuleInit упадёт на старте p2-backend. В описании ProdDB groundmatrix не указана ни в списке баз, ни в бэкапах (prod-programs-graph-backups сохраняет только program1 и neo4j) — это нужно проверить на сервере.

Схема​

Узел Ground​

СвойствоТипОткуда
groundIdintGroundPurchased.groundId
groundIndexintGroundPurchased.groundIndex
levelintGroundPurchased.level
ownerUidintGroundPurchased.uidFromDiamond (у корней — 1)
chainIdintсеть события (у корней — 56)
placesint4 при создании (у корней — 4294967295), уменьшается вышками
groundCashbackint[4][0, 0, 0, 0], меняет владелец
towerCashbackint[4][0, 0, 0, 0], меняет владелец

Узел Tower​

СвойствоТипОткуда
towerIdintNftPurchased.nftId — ID NFT MagnetLiquidityShared
ownerUidintuid покупателя по адресу buyer (через основной бэкенд)
buyerstringадрес покупателя
levelintуровень своей земли
chainIdintNftPurchased.groundChainId
groundId, groundIndexintземля-хозяин
towerUplinePlaceIndexintномер места на земле-хозяине
towerOwnerPlaceIndexintномер места на своей земле

Связи​

Каждая связь заведена парой в обе стороны — запросы ходят по той, что удобнее.

СвязьНаправлениеСмысл
UPLINE_OF / DOWNLINE_OFродитель → ребёнок / ребёнок → родительДерево земель
OWNS_TOWER / OWNED_BYсвоя земля → вышка / вышка → своя земляВышка стоит на земле покупателя
HAS_TOWER / TOWER_OFземля-хозяин → вышка / вышка → земля-хозяинВышка занимает место на земле аплайна

Ограничения и индексы​

Создаются при старте (apps/p2-backend/src/jobs/liquidityGroundEvents.job.ts:31-48):

  • ground_id_index_unique — уникальность (groundId, groundIndex) у Ground;
  • ground_search_idx — индекс (level, ownerUid);
  • ground_level_idx — индекс level;
  • tower_id_unique — уникальность towerId у Tower.

Уникальность земли — по паре, а не по одному groundId: так задумано для «виртуальных» корней groundId = 1 с разными индексами (см. корневые земли).

Как граф наполняется​

Граф пишут только два крона p2-backend. Других путей записи, кроме POST /liquidity/ground/cashback, нет.

LiquidityGroundEventsJob​

apps/p2-backend/src/jobs/liquidityGroundEvents.job.ts.

  • Старт. Создаёт ограничения и 15 корневых земель (MERGE по level, поэтому повторный старт ничего не меняет).
  • Каждые 5 секунд читает GroundPurchased контракта земли во всех сетях LiquidityNetwork (сейчас только BSC), у которых задан groundContractAddress.
  • Если uplineGroundId = 1, сначала создаёт корень с нужным индексом (CREATE_ROOT_GROUND_QUERY).
  • Затем CREATE_GROUND_QUERY: находит родителя по groundId, level и индексу новой земли, создаёт землю с places = 4 и нулевым кешбэком, связывает UPLINE_OF/DOWNLINE_OF. Земля создаётся через MERGE по groundId, так что повторная обработка события безопасна.
  • Если родитель не нашёлся, бросает Neo4j ground creation failed.

LiquidityTowerEventsJob​

apps/p2-backend/src/jobs/liquidityTowerEvents.job.ts.

  • Каждые 5 секунд читает NftPurchased контракта MagnetLiquidityShared (окно — 499 блоков).
  • Всё в одной транзакции Neo4j:
    1. проверяет, что земля-хозяин есть (Ground not found for tower creation);
    2. получает uid покупателя по адресу из основного бэкенда;
    3. если вышка с таким towerId уже есть — выходит;
    4. считает номера мест на земле-хозяине и на своей земле;
    5. CREATE_TOWER_QUERY: создаёт вышку, уменьшает places у обеих земель, заводит обе пары связей.
  • После коммита сообщает основному бэкенду, что операция покупки завершена (POST /liquidity/shared/operation/event → handleBuyNftOperationEvent). Ошибка этого вызова только логируется.
Вышка на заполненную землю не попадает в граф молча

В CREATE_TOWER_QUERY стоит WHERE upline.places > 0. Если к моменту индексации на земле-хозяине мест не осталось, запрос не вернёт строк, вышка не создастся, а крон закоммитит транзакцию и отметит операцию завершённой — без ошибки. NFT при этом в сети есть. То же происходит, если не нашлась своя земля покупателя (MATCH owner). Защита от этого — только кулдаун подписей в бэкенде.

Курсор и повторы​

Оба крона читают события через EventsService.listenForEvents (packages/reusable-magnet-backend/src/events/events.service.ts):

  • последний обработанный блок хранится в Redis по ключу события (LIQUIDITY_GROUND_PURCHASED_56, LIQUIDITY_SHARED_NFT_PURCHASED_56);
  • при первом запуске курсор ставится на текущий блок — события, случившиеся до первого запуска крона, не подхватятся;
  • каждое обработанное событие помечается в Redis по хешу транзакции и индексу лога, повторно не обрабатывается;
  • если обработчик бросил ошибку, курсор за этот диапазон блоков не сдвигается, и следующий запуск начнёт с того же места.

Последний пункт значит, что одно «битое» событие останавливает индексацию всех следующих покупок, пока его не исправят вручную. При отставании больше чем на 80 блоков EventsService шлёт уведомление (на окружениях из availableEnvironmentsToNotify).

Что считается в графе​

Все запросы — в apps/p2-backend/src/tree.service.ts.

Цепочка партнёров из program1​

getPartnersChain(uid) (tree.service.ts:1777-1818):

MATCH (chain:User {uid: $uid, level: 1})-[:REFERRED_TO*..]->(upline)
RETURN COLLECT(upline.uid) AS partnersChain
  • Берётся первый уровень Program 1, какой бы уровень земли ни покупали.
  • Обход идёт от всех узлов пользователя на этом уровне (у него может быть несколько матриц) до корня, без ограничения глубины.
  • Результат сортируется по убыванию uid. Расчёт на то, что аплайн зарегистрирован раньше даунлайна, поэтому больший uid — ближе к пользователю. Глубину пути запрос не учитывает.

На первом уровне Program 1 обгона нет (уровень 1 есть у всех), поэтому это фактически вся линия пригласителей до uid 1. Устройство матрицы Program 1 — ниже.

Родитель новой земли​

getFreeGroundId(uid, level) (tree.service.ts:2032-2175) — алгоритм описан в дереве земель. Ключевой запрос:

MATCH (g:Ground)
WHERE g.level = $level AND g.ownerUid IN $partnersChain AND g.groundIndex = $groundIndex
ORDER BY apoc.coll.indexOf($partnersChain, g.ownerUid) ASC, g.groundIndex ASC, g.places ASC, g.groundId ASC
RETURN g LIMIT 1 -- фактически collect(g)[0]

places в этом запросе только сортирует, но не фильтрует: земля встаёт под землю с любым числом мест. Возвращает groundId родителя (он же parentId), индекс новой земли, inviterChain и кешбэк.

Земля-хозяин вышки​

getFreeTowerGroundId(uid, level, groundIndex) (tree.service.ts:2177-2280) — тот же запрос, но с фильтром g.places > 0 и с проверкой, что у самого пользователя есть земля (level, groundIndex) со свободным местом. Возвращает groundId и uplineGroundIndex хозяина, groundChainId, inviterChain и кешбэк.

Цепочка для партнёрки (inviterChain)​

getGroundInviterChain (tree.service.ts:1968-2008):

MATCH (g:Ground {groundId: $groundId, level: $level, groundIndex: $groundIndex})
OPTIONAL MATCH path = (g)-[:DOWNLINE_OF*0..3]->(upline:Ground)
WITH upline, length(path) AS depth ORDER BY depth
RETURN collect(upline.ownerUid) AS chain

Начинает с земли-родителя (для земли) или земли-хозяина (для вышки) — глубина 0 включает её саму — и поднимается по дереву земель ещё на три шага. Получаются 4 uid: владельцы земель на линиях 1–4. Если дерево кончилось раньше (дошли до корня), массив дополняется uid 1. Для вышки используется та же функция (getTowerInviterChain — просто обёртка).

Кешбэк​

resolveCashbackByInviterChain (tree.service.ts:97-178) находит земли партнёров из inviterChain того же уровня и индекса и для линии i берёт i-й элемент массива кешбэка земли партнёра этой линии: groundCashback при покупке земли, towerCashback при покупке вышки. Как кешбэк превращается в проценты — на странице партнёрки.

Номера мест вышки​

Перед созданием вышки (liquidityTowerEvents.job.ts:139-175) считается, сколько вышек уже связано с землёй через HAS_TOWER и OWNS_TOWER; это число становится номером места. Запрос делает два OPTIONAL MATCH подряд и считает count() по их произведению, поэтому, когда на земле есть и свои, и чужие вышки, номер получается завышенным. На логику мест это не влияет — она опирается на places.

Прочее​

  • getMaxGroundLevelByUid(uid) (tree.service.ts:2287-2315) — максимальный уровень земли пользователя, 0, если земель нет. Фронт по нему решает, докупать ли уровни программ.
  • getGroundsWithTowersByLevel(level, uid, groundIndex?) (tree.service.ts:2371-2437) — земли пользователя на уровне со своими вышками (OWNS_TOWER). Вышки даунлайна, стоящие на этих землях, не возвращаются. Сортировка по t.towerIndex фактически не работает: такого свойства у вышки нет.
  • updateGroundCashback(...) (tree.service.ts:2327-2369) — меняет оба массива кешбэка, если ownerUid земли совпадает с uid из JWT.

API p2-backend​

Контроллер — apps/p2-backend/src/app.controller.ts, все маршруты под JwtAuthGuard.

МетодМаршрутКто вызываетЧто делает
GET/service/ground/free?uid&levelосновной бэкенд (роль Service)getFreeGroundId
GET/service/ground/tower/free?uid&level&groundIndexосновной бэкенд (роль Service)getFreeTowerGroundId
GET/liquidity/grounds?level&uid?&groundIndex?фронтЗемли с вышками; без uid — свои, с uid — любого пользователя
POST/liquidity/ground/cashbackфронтОбновить кешбэк своей земли
GET/liquidity/ground/max-level?uidфронтМаксимальный уровень земли
GET/service/program1/partners-chain?uid&level&depth?основной бэкенд (роль Service)Цепочка Program 1 — для MagnetIndexManager, не для земель

DTO — apps/p2-backend/src/dto/ground.dto.ts. Кешбэк валидируется: ровно 4 целых числа от 0 до 100 в каждом массиве.

Матрица Program 1 коротко​

Цепочка партнёров для земель берётся из program1, поэтому полезно понимать её устройство. Подробности о расхождении с реферальным деревом MySQL — в engineering/Сервисы/backend/partner-lines.md (ветка preprod-start-v2).

  • Узел User {uid, level, matrixIndex} — одна матрица пользователя на уровне. У пользователя на уровне может быть несколько матриц.
  • (child)-[:REFERRED_TO]->(parent) и обратная REFERRED — ребёнок стоит в матрице родителя. В матрице 3 места (position 0–2, programs-helper.service.ts:2410-2461).
  • Когда занимается третье место, матрица закрывается (matrixClosed = true), а её владелец открывает новую матрицу и сам встаёт в открытую матрицу своего родителя — каскад (processProgram1Cascade, packages/reusable-magnet-backend/src/programs-helper/programs-helper.service.ts:2150-2408). То же правило на контракте — setP1UserPos (apps/contracts-v2/contracts/diamond/libs/CoreLib.sol:311-347).
  • Граф строит TreeEventService (apps/p2-backend/src/tree.event.service.ts) по событиям Diamond RegistrationInPrograms и BuyP1Level.

Полезные запросы​

Только чтение, запускать в базе groundmatrix (:use groundmatrix).

// Все земли пользователя с числом мест
MATCH (g:Ground {ownerUid: 123}) RETURN g.level, g.groundIndex, g.groundId, g.places ORDER BY g.level, g.groundIndex;

// Цепочка родителей земли до корня
MATCH path = (g:Ground {groundId: 456})-[:DOWNLINE_OF*]->(root)
RETURN [n IN nodes(path) | n.ownerUid] AS owners;

// Вышки на земле: свои и чужие
MATCH (g:Ground {groundId: 456})
OPTIONAL MATCH (g)-[:OWNS_TOWER]->(own:Tower)
OPTIONAL MATCH (g)-[:HAS_TOWER]->(hosted:Tower)
RETURN g.places, collect(DISTINCT own.towerId) AS own, collect(DISTINCT hosted.towerId) AS hosted;

// Сверка счётчика мест: 4 − (свои + чужие) должно совпадать с places
MATCH (g:Ground) WHERE g.ownerUid <> 1
OPTIONAL MATCH (g)-[r:OWNS_TOWER|HAS_TOWER]->(:Tower)
WITH g, count(r) AS used
WHERE g.places <> 4 - used
RETURN g.groundId, g.groundIndex, g.places, used;