Артефакт 6/10

Lazy Promotion и глобальные контрагенты

Единая база не создаётся принудительно. Она вырастает через операции. Контрагент начинает как локальный и глобализируется только при необходимости.

1 Ключевая идея: телефонная книга, не банковская выписка

Глобальная база знает КТО, но не знает СКОЛЬКО

Глобальная база — телефонная книга, не банковская выписка.
Она знает, что Вася существует (UID, телефон, user_id). Она НЕ знает его балансы, заметки обменника, историю операций. Каждый обменник ведёт свою карточку контрагента независимо — как контакт в телефоне с разными заметками у разных людей.
Платформенная БД

GlobalPartnerEntity

uidFO-7a3f8b2c4d1e
user_id15 (nullable)
phone+7...925... (nullable)
telegram_idnullable
created_atdatetime
Знает: UID, user_id, телефон — минимум для идентификации
Не знает: балансы, заметки, историю, теги
Тенантная БД

PartnerEntity

id847 (локальный PK)
global_uidFO-7a3f... (nullable)
name"Вася" / "Василий П."
typeclient | cashier | ...
memo"VIP" / "мелкий"
balanceUSD +5K / RUB +150K
Знает: всё о к/а у ЭТОГО брокера — имя, теги, баланс, заметки
Один человек — один UID — каждый обменник видит по-своему:

Глобальный реестр              Тенант Альфы           Тенант Беты

┌──────────────────┐
│ FO-7a3f8b2c4d1e  │──────>  PartnerEntity           PartnerEntity
│ user_id: 15      │         name: "Вася"             name: "Василий П."
│ phone: +7...     │         type: client              type: client
│                  │         memo: "VIP"              memo: "мелкий"
└──────────────────┘         balance: USD +5K          balance: RUB +150K

2 Формат UID — глобальный адрес контрагента

Как адрес кошелька в блокчейне: глобально уникальный, короткий, проверяемый

Формула: prefix + "-" + random_hex(6) + crc8(random)
FO-7a3f8b2c4d1e
Префикс FO — FastOTC
Случайные байты crypto_random_bytes(6) → hex
Контрольная сумма CRC8 — ловит опечатки
Зачем свой формат, а не UUID?
UUID — 36 символов, невозможно продиктовать по телефону. UID FastOTC — 15 символов, с проверкой опечаток через CRC8. "Переведи $5K на FO-7a3f8b2c4d1e" — однозначно, без путаницы.

3 Четыре триггера глобализации (Lazy Promotion)

Контрагент попадает в глобальную базу не при создании, а при первой операции, где нужна глобальность

Уровень 1
Локальный
global_uid = null
Существует только у этого обменника
Уровень 2
Глобальный
global_uid = FO-xxx
Виден на платформе
a

Привязка аккаунта (user_id)

Клиент зарегистрировался на платформе и связал свой аккаунт с карточкой у обменника

b

Межброкерская операция

Обменник создал заявку с участием этого клиента для другого брокера

c

Перевод на UID другого к/а

Клиент отправляет деньги на UID другого контрагента — оба должны быть глобальными

d

Ручная публикация

Обменник вручную "опубликовал" контрагента в сеть

Триггер A: Привязка аккаунта (user_id)

Клиент зарегистрировался на платформе (получил user_id) и связал его с существующей карточкой у обменника.

// Клиент нажал "Это мой обменник" → подтвердил по SMS

GlobalPartnerService::register(user_id: 15, phone: "+7925...")
  → GlobalPartnerEntity создаётся (uid = FO-7a3f8b2c4d1e)
  → PartnerEntity.global_uid = "FO-7a3f8b2c4d1e"
  → UserPartnerLinkEntity создаётся (user → broker → partner)

// Теперь клиент видит свой баланс у этого обменника в ЛК

Триггер B: Межброкерская операция

Система требует UID для создания межброкерской заявки → автоматическое создание GlobalPartnerEntity.

// Альфа создаёт поручение для кассира Беты: "выдать Васе $500"

OrderEntity:
  counterpart_uid: ???  ← нужен UID, но Вася локальный!
  executor_broker_id: Бета
  type: payout
  amount: $500

Система автоматически:
  1. Создаёт GlobalPartnerEntity для Васи
  2. PartnerEntity.global_uid = новый UID
  3. Заполняет counterpart_uid в OrderEntity

Триггер C: Перевод на UID другого контрагента

Если клиент переводит средства на UID другого к/а, оба участника должны быть в глобальной базе.

// "Переведи $5K с Васи на FO-2b9c1d4e8f3a"

TransactionEntity:
  global_uid_from: FO-7a3f8b2c4d1e  ← Вася должен быть глобальным
  global_uid_to:   FO-2b9c1d4e8f3a
  instrument: USD-CASH
  amount: 5000.00

Если Вася ещё локальный:
  → Промоция → GlobalPartnerEntity → UID → транзакция

Триггер D: Ручная публикация

Обменник решил опубликовать контрагента в сеть — например, для совместной работы с другими брокерами.

// Обменник в интерфейсе нажал "Опубликовать в сеть"

GlobalPartnerService::publish(partner_id: 847, broker_id: 1)
  → Проверка: есть ли у к/а телефон или telegram_id
  → Создаёт GlobalPartnerEntity
  → PartnerEntity.global_uid = новый UID
  → К/а теперь доступен для межброкерских операций

// Другие брокеры могут найти его по UID или телефону

4 Жизненный цикл контрагента

От локальной записи до мульти-брокерского клиента

Обменник добавил "Васю"

Создана PartnerEntity (локальный, global_uid = null). Вася существует только у этого обменника. Достаточно для внутренних операций.

Вася зарегистрировался на платформе

Создана GlobalPartnerEntity (uid = FO-7a3f8b2c4d1e). PartnerEntity получает global_uid. Создана UserPartnerLinkEntity — связь user → broker → partner.

Вася пришёл к Бете

Бета вводит UID или телефон → платформа находит FO-7a3f8b2c4d1e → создаёт новую PartnerEntity в тенанте Беты с тем же global_uid. Баланс у Беты = 0 (новый счёт). Один UID, два независимых баланса.

Вася видит оба обменника в ЛК

Клиентский портал через UserPartnerLinkEntity собирает балансы со всех тенантов. Вася видит: Альфа (USD $5K, EUR €3K) + Бета (RUB ₽150K) в одном интерфейсе.

5 TenantPartnerMapping — резолвинг между базами

Как по UID найти конкретную запись в конкретном тенанте

Проблема: UID глобальный, но PartnerEntity живёт в per-broker DB. Один и тот же Вася — partner_id=15 у Альфы и partner_id=847 у Беты. Нужен способ: FO-7a3f8b2c4d1e → в какой базе искать → local_partner_id.
// Мастер-БД (fastotc) — знает UID → broker → local_id

GlobalPartnerEntity (таблица: exchange_global_partner)
  uid           FO-7a3f8b2c4d1e  — PK, уникальный
  user_id       15                — FK → UserEntity (nullable)
  phone_hash    sha256(+7925...)  — для дедупликации
  telegram_id   @vasya            — для резолва из мессенджеров
  created_at    datetime

UserPartnerLinkEntity (таблица: exchange_user_partner_link)
  user_id       15          — FK → UserEntity
  broker_id     1           — FK → BrokerEntity
  partner_id    847         — id в тенантной базе

// Per-broker DB — знает всё о к/а у ЭТОГО брокера

PartnerEntity (таблица: crm_partner) + новое поле:
  uid           VARCHAR, indexed  — копия из мастер-БД, nullable
ОперацияГде ищемКак
Найти к/а по UID Мастер-БД GlobalPartnerEntity::findByUid('FO-...')
Найти все связи с брокерами Мастер-БД UserPartnerLinkEntity::findByGlobalUid()
Найти local_partner_id у Беты Мастер-БД WHERE user_id=15 AND broker_id=2 → partner_id
Быстрый поиск внутри тенанта Per-broker DB PartnerEntity::findByUid('FO-...') — по indexed полю
Дедупликация при регистрации Мастер-БД GlobalPartnerEntity::findByPhoneHash()

6 Приватность: что видит Бета про клиента Альфы

Жёсткие границы — обменники не видят финансы друг друга

Бета видит

  • UID (FO-7a3f8b2c4d1e)
  • Телефон (если клиент разрешил)
  • Факт, что он клиент Альфы
  • Имя (если клиент публичный)

Бета НЕ видит

  • Баланс у Альфы
  • Историю операций у Альфы
  • Заметки Альфы про этого клиента
  • Других обменников, где он клиент
Аналогия: Бета видит номер телефона Васи в телефонной книге. Бета не видит его банковскую выписку. Даже если оба обменника на одной платформе — финансовая информация изолирована в тенантных базах. Глобальная БД хранит только идентификаторы.

7 Мульти-брокерский клиентский портал

Один UID — один интерфейс — балансы у всех обменников

Вася Петров

FO-7a3f8b2c4d1e

Альфа-Обмен

USD-CASH $5,000
EUR-CASH-BCN €3,000

Бета-Обмен

RUB-CASH ₽150,000
USDT-TRC20 2,500 USDT

Все транзакции (обе компании):

12.04 Бета Deposit +₽150,000 TX-A8F3B2
11.04 Альфа Swap EUR→USD TX-7C2D1E
10.04 Альфа Deposit +€3,000 TX-4B8F9A
Как это работает технически:
1. Клиент логинится → user_id = 15
2. UserPartnerLinkEntity::findByUserId(15) → [{broker:1, partner:847}, {broker:2, partner:12}]
3. Для каждого брокера: подключаемся к его тенантной БД → читаем AccountEntity
4. Агрегируем балансы и транзакции → отдаём клиенту

Приватность соблюдена: клиент видит СВОИ балансы у разных обменников. Обменники не видят балансы друг друга.

8 Межброкерский перевод через UID

UID — общий язык. Обменникам не нужно знать внутренние id друг друга.

1

Альфа создаёт поручение

OrderEntity: counterpart_uid = FO-7a3f8b2c4d1e, executor_broker_id = Бета, type = payout, instrument = USD-CASH, amount = $500

2

Бета видит поручение

"Выдать $500 контрагенту FO-7a3f8b2c4d1e" → ищет у себя PartnerEntity с этим uid

3

Резолвинг контрагента

Нашла → "Выдать $500 Василию". Не нашла → автосоздание PartnerEntity с global_uid, name из GlobalPartnerEntity

4

Исполнение и обязательство

Кассир Беты выдаёт $500 → TransactionEntity в тенанте Беты + ObligationEntity: debtor=Альфа, creditor=Бета, $500

// Хэши строятся через UID, не локальные id

TransactionEntity.tx_hash = SHA256(
  global_uid_from:   FO-7a3f8b2c4d1e
  global_uid_to:     FO-2b9c1d4e8f3a
  instrument:        USD-CASH
  amount:            500.00
  timestamp:         2026-04-12T14:30:15Z
  prev_hash:         tx-предыдущий
)

// Преимущества UID в хэшах:
// — Верифицируется из любого тенанта
// — Арбитр сопоставляет операции из разных тенантов
// — Переименование к/а не ломает хэш

9 Интерактивная симуляция: Lazy Promotion в действии

Нажимайте кнопки и наблюдайте, как контрагенты глобализируются

Альфа-Обмен

Бета-Обмен

Глобальный реестр (GlobalPartnerEntity)

Пусто — контрагенты ещё локальные
Готово к симуляции. Нажмите "Добавить локального к/а" чтобы начать.

10 Статус реализации

Что уже создано в коде и что осталось

Создано

GlobalPartnerEntity

Таблица exchange_global_partner. PK = uid (FO-xxx). Поля: user_id, phone, telegram_id, created_at.

src/Bundles/Exchange/Entity/GlobalPartnerEntity.php
Создано

UserPartnerLinkEntity

Связь user → broker → partner_id в тенанте. Кросс-тенантная навигация.

src/Bundles/Exchange/Entity/UserPartnerLinkEntity.php
Создано

UidService

generate() — FO-{12hex}{CRC8}, validate(), normalize(). Crypto-random.

src/Bundles/Exchange/Service/UidService.php
Создано

GlobalPartnerService

register() (идемпотентно), findByUid/Phone/UserId(), linkUserToPartner(), confirmLink().

src/Bundles/Exchange/Service/GlobalPartnerService.php
Нужно

PartnerEntity.uid поле

Добавить nullable VARCHAR + index в существующую crm_partner. Синхронизация с мастер-БД.

Нужно

Lazy Promotion триггеры

Автоматическая глобализация при 4 событиях. Event listeners / Domain Events.

Нужно

Клиентский портал API

Агрегация балансов с нескольких тенантов. Multi-context query service.

Нужно

Privacy Middleware

Гарантия, что межброкерский API не отдаёт финансовые данные чужого тенанта.

11 Ключевые выводы

Не принудительная миграция

Контрагенты не загружаются в глобальную базу массово. Каждый начинает локально и промоутится только при реальной потребности. Нет давления на обменника.

Минимум данных глобально

GlobalPartnerEntity хранит только UID, user_id, телефон. Вся бизнес-информация остаётся в тенанте. Это упрощает GDPR и снижает риски утечки.

UID как общий язык

"Переведи $5K на FO-7a3f8b2c4d1e" — однозначно. Хэши транзакций строятся на UID, не на локальных id. Арбитраж между брокерами возможен.

Вирусный рост через портал

Брокер приглашает клиента → клиент видит удобный ЛК → сам становится брокером. Каждый GlobalPartnerEntity — потенциальный платящий клиент платформы.