Skip to main content
У оператора, переходящего на Omniflow, клиенты уже есть — они в Remnawave, где их определяют идентификатор пользователя Remnawave и, обычно, Telegram ID. Конвейер импорта превращает эти записи в клиентов Omniflow, не сливая молча и не пропуская молча тех, кто конфликтует с кем-то уже существующим. Конвейер намеренно двухфазный и возобновляемый. Предпросмотр постранично проходит список пользователей Remnawave, размещает каждую запись во временной области с вердиктом валидации и подсчитывает, сколько записей чистых, сколько конфликтуют и сколько не поддаются чтению — до того, как что-либо будет записано. Применение затем фиксирует чистые записи, по одному клиенту на транзакцию. Прогон из сорока тысяч записей, прерванный на тридцатитысячной, возобновляется с сохранённого курсора, а не начинается заново.
Источник импорта ровно один. Таблица ограничивает его условием CHECK (source = 'remnawave'), а читателем выступает эндпоинт списка Remnawave. Нет ни импорта из CSV, ни загрузки файлов, ни второго поставщика. CSV, описанный в конце этой страницы, — это только выгрузка.

Что производит импорт

Для каждой применённой записи — ровно две строки: строка users со статусом active и сопоставление remnawave_users, несущее идентификатор пользователя Remnawave, Telegram ID, если он был, наблюдаемый статус и отметку времени сверки. Он не производит ни подписку, ни право на услугу, ни заказ, ни подтверждённую личность. Omniflow не знает, что эти клиенты платили, когда и за что — Remnawave этого не фиксирует, — а придумывание права на услугу внесло бы в книгу проводок период и цену, о которых никто не договаривался. См. Владение данными — границу, которую это уважает. Что даёт сопоставление, так это узнавание. Когда импортированный клиент в следующий раз напишет боту, путь разрешения клиента сначала ищет личность Telegram, а не найдя её, откатывается к сопоставлению Remnawave по его Telegram ID. Он принимает этого клиента и привязывает к нему личность Telegram, вместо того чтобы создавать второго клиента. В этом и вся причина, по которой предпросмотр считает уже привязанный Telegram ID конфликтом: два клиента, держащие одну личность Telegram, сделали бы этот откат неоднозначным, а неоднозначность проявилась бы как чужая подписка, появившаяся на аккаунте другого человека.

Фаза первая: предпросмотр

Предпросмотр читает одну страницу пользователей Remnawave и размещает их во временной области. Его безопасно запускать повторно, и он не пишет ничего за пределами таблиц импорта. Каждый кандидат размещается только с четырьмя полями — remnawaveId, username, status, telegramId. Размещённая полезная нагрузка — это то, что читают проверки конфликтов и из чего вычисляются счётчики оператора; ничего больше из записи Remnawave в Omniflow не копируется.

Как работает сопоставление

Перед размещением страницы сервис строит множество уже существующих личностей из трёх источников:
  1. Все remnawave_id и telegram_id в remnawave_users — клиенты, которых Omniflow уже знает.
  2. Все активные строки identities с provider = 'telegram' — клиенты, вошедшие через бот или веб-панель и пока не имеющие сопоставления с Remnawave.
  3. Все Telegram ID, уже размещённые в этом прогоне, чей идентификатор источника отсутствует на текущей странице.
Третий источник — тот, который легко упустить и дорого пропустить: без него один и тот же Telegram ID, появившийся на странице 3 и на странице 40 большой панели, прошёл бы оба раза и создал бы двух клиентов, которых нельзя разрешить одновременно.

Коды валидации

Статус элемента вытекает из кодов: ни одного кода — valid; любой код — conflict; код, начинающийся с missing_ или invalid_, делает его invalid вместо этого. Различие — это и есть смысл счётчиков: конфликт — это запись, принадлежащая кому-то, а невалидная запись — та, которую вообще нельзя прочитать, и они требуют от оператора разных решений. Каждый элемент также хранит отпечаток SHA-256 своей размещённой полезной нагрузки, поэтому повторный предпросмотр может отличить изменившуюся запись от неизменившейся. Повторный предпросмотр никогда не перезаписывает элемент, который уже применён или пропущен: upsert несёт WHERE customer_import_items.status NOT IN ('applied', 'skipped'), и именно это делает повторный запуск предпросмотра на частично применённом прогоне безвредным.

Курсор и статус прогона

Курсор — это числовое смещение в списке Remnawave, хранимое как текст и начинающееся с нуля. Каждый проход увеличивает его на число пользователей, которое вернула страница, и переводит прогон в ready, как только курсор достигает заявленного итога; до этого он остаётся в previewing. pageSize зажимается: всё, что не больше нуля или больше 500, становится 100. Если Remnawave недоступен, прогон не остаётся наполовину завершённым и ни одна ошибка не выбрасывается впустую. Он записывается в статус failed с error_summary, равным {"code":"remnawave_unavailable"}, и возвращается как есть. Поскольку путь возобновления останавливается только на completed и cancelled, прогон в статусе failed можно возобновить с сохранённого курсора, как только панель снова будет доступна.

Фаза вторая: применение

Применение выбирает до limit элементов со статусом valid, упорядоченных по идентификатору источника, и фиксирует каждый в собственной транзакции уровня Serializable. limit зажимается так же, как pageSize. Каждый элемент — это одна цепочка CTE в одном выражении: заблокировать элемент через FOR UPDATE по status = 'valid', вставить клиента, вставить сопоставление Remnawave, пометить элемент как applied с его новым идентификатором клиента и отметкой времени. Поскольку это одно выражение в одной транзакции, сбой не создаёт ни частичного клиента, ни осиротевшего сопоставления. Параллельный второй прогон не может применить тот же элемент дважды, потому что блокировка совпадает только с элементом, всё ещё находящимся в valid. Конфликтующие и невалидные элементы никогда не выбираются, а значит, никогда не применяются. После пакета, если не осталось ни одного элемента valid, прогон переходит в completed и проставляется completed_at; иначе он остаётся в applying, и следующий вызов продолжает работу.
Отката нет, и отмены нет. «Сбойная транзакция элемента не создаёт частичного клиента» означает атомарность на уровне элемента, а не отмену. Ничто в кодовой базе не удаляет клиента или сопоставление применённого импорта, и ничто не пишет статус cancelled. Конфликтующие и невалидные элементы просто остаются размещёнными; действий слияния, пропуска или переопределения в продукте нет нигде. Сначала выполните предпросмотр на маленьком размере страницы и прочитайте счётчики, прежде чем применять.
Ещё две вещи, которых стоит ожидать:
  • Импортированные клиенты всегда создаются с локалью ru. Локаль жёстко задана в пути применения независимо от чего бы то ни было в записи Remnawave. Клиент может сменить её сам в боте или в веб-панели впоследствии.
  • Импорт не пишет событий аудита. Таблицы импорта и есть запись о том, что произошло; журнал аудита, описанный в Административных операциях, их не покрывает.

Как выполнить импорт

Маршруты предпросмотра, возобновления и применения находятся на поверхности операторского токена. Элемента управления в панели, который запускает импорт, нет — /admin/customers перечисляет прогоны и предлагает выгрузку, и это всё, что он делает.
1

Проверьте учётные данные панели

Воркер и API читают APP_REMNAWAVE_URL и APP_REMNAWAVE_TOKEN; импорт обращается к той же панели через тот же адаптер. APP_OPERATOR_TOKEN авторизует /v1/admin.
2

Откройте прогон

Ответ — сам прогон: {id, status, cursor, totalCount, validCount, conflictCount, invalidCount, startedAt, updatedAt, completedAt}.
3

Листайте страницы, пока прогон не будет готов

Повторяйте, пока status не станет ready. Каждый вызов читает одну страницу и продвигает курсор.
4

Прочитайте счётчики, прежде чем что-либо записывать

validCount, conflictCount и invalidCount — это и есть вся причина, по которой существует фаза предпросмотра. Большое число конфликтов обычно означает, что установка уже знает этих клиентов, и это повод остановиться, а не повод продолжать.
5

Примените

Повторяйте, пока status не станет completed.
Отказы, которых стоит ожидать:
  • Применение прогона в статусе previewing, completed, failed или cancelled возвращает 422 import_failed с деталью «import is not ready».
  • Некорректный идентификатор импорта возвращает 422 import_failed с «invalid import ID».
  • В отличие от маршрутов панели, которые отображают любой сбой на фиксированное сообщение, эти три маршрута помещают сырой текст ошибки в поле detail документа проблемы. Считайте ответ предназначенным для оператора.

Наблюдение за прогоном в панели

Откройте /admin/customers и прочитайте карточку Imports. Она опрашивает GET /v1/panel/imports каждые пять секунд, потому что идущий прогон меняется, пока оператор за ним наблюдает, и показывает по каждому прогону:
  • первые восемь символов идентификатора прогона и значок статуса;
  • значок resumable всякий раз, когда сохранён курсор — прогон, прерванный на сорокатысячной записи, не начинается заново с первой;
  • четыре счётчика: всего, валидных, конфликтов, невалидных;
  • время начала и, после завершения, время окончания;
  • сводку ошибок, отрисованную парами «класс и количество». Оператору нужно знать, что у 812 записей не было идентификатора Telegram, а не пролистывать 812 одинаковых строк.
Сводка ошибок бывает либо пустой, либо содержит единственную запись {"code":"remnawave_unavailable"}. Карточка построена так, чтобы отрисовывать количества по кодам, но сервис импорта их никогда не заполняет, поэтому сбойный прогон показывает одну пару с названием сбоя панели, а не разбивку по кодам валидации. Разбивка, на которую можно опираться, — это три счётчика.

Выгрузка клиентов в CSV

Рядом с историей импорта есть кнопка Export — обычная ссылка на GET /v1/panel/customers/export, которой нужно право customers.read. Она отдаёт потоком CSV со списком клиентов, при желании отфильтрованным через ?status=. Набор колонок — это и есть суть. Выгрузка — это файл, покидающий установку, поэтому он несёт идентификаторы, которые оператору можно безопасно отдать, и факты, необходимые ему для сверки с другой системой, — и намеренно ни ссылки на подписку, ни платёжного токена, ни идентификатора устройства. Контактные значения тоже не появляются; они зашифрованы при хранении и не входят в этот файл. Обработчик проходит курсор на стороне сервера по 200 строк на страницу и сбрасывает данные по ходу, поэтому выгрузка по большой установке никогда не удерживается в памяти. Он останавливается после 200 страниц — по той же причине, что и выгрузки аудита и финансов: один запрос не должен иметь возможности бесконечно обходить неограниченную таблицу. Установке с более чем сорока тысячами клиентов следует ожидать, что файл на этом и закончится.

Объединение двух аккаунтов клиента

Привязка личности к аккаунту, в который вы уже вошли, работает. Соединение двух существующих аккаунтов — другая задача, и именно она возникает на практике: клиент покупает в Telegram, позже входит в вебе через провайдера, которого не было у первого аккаунта, и держит пустой второй аккаунт, тогда как подписка лежит на первом. Это есть на /admin/customers/{id}, под вкладками. Аккаунт, который вы смотрите, — поглощаемый: направление зафиксировано и названо, потому что «объединить A и B» двусмысленно ровно там, где это важно.

Сначала предпросмотр

Объединение необратимо, поэтому всё, что переедет, пересчитывается до того, как что-либо переедет: подписки, заказы, способы входа, обращения, рефералы и баланс кошелька по валютам — с обеих сторон. Блокировки пересчитываются в момент применения, а не берутся из предпросмотра. Это два разных запроса, и объединение, разрешённое устаревшим экраном, — из тех вещей, что ломаются ровно один раз.

Что не является простой переприпиской

На users ссылаются сорок шесть таблиц. Для большинства это смена владельца. Пять — это решение, и поэтому здесь нет цикла по каталогу внешних ключей:
  • Слоты подписок уникальны в пределах клиента, поэтому переехавшие подписки перенумеровываются на первые свободные слоты цели.
  • Кошелёк переезжает компенсирующей парой записей реестра — списание у источника до нуля, зачисление той же суммы у цели, — потому что ledger_entries append-only. Переписывать user_id в финансовой записи клиента недоступно и не должно быть: эти записи — доказательство каждого баланса, который установка когда-либо показывала.
  • Сохранённая корзина отменяется, а не переезжает. У клиента может быть открыта только одна, а корзина — это выбор, сделанный в заканчивающейся сессии.
  • Отметки о прочтении переезжают только там, где цель не читала тот же пост; пара — первичный ключ, и всё объединение не должно падать из-за отметки.
  • Сохранённые способы оплаты переезжают, но приезжают не по умолчанию. Один способ по умолчанию на клиента — уникальный индекс, и у выжившего это тот, который он выбрал последним.

После

Источник сохраняет свои строки и историю и помечается как merged со ссылкой на цель. Он не удаляется: запись о том, что произошло, ценнее, чем освобождённая строка, а поддержке, отвечающей на «куда делась моя подписка», нужно куда-то приземлиться. Оба аккаунта получают событие жизненного цикла — merged_away и merged_in, — поэтому объединение читается из собственной истории любого из клиентов, а не только из операторского журнала. Событие аудита — customer.merged, оно несёт счётчики. Причина обязательна. Она попадает и в журнал аудита, и в обе истории, потому что «почему эти два аккаунта соединили» — первый вопрос, который потом задают.

Связанные страницы

Интеграция с Remnawave

Адаптер, через который читает импорт, и каждый вызов, который он делает.

Приватность клиента

Что хранится о клиенте, что он может выгрузить и как происходит удаление.

Административные операции

Массовые действия, журнал аудита и остальная клиентская поверхность.

Выдача доступа

Как первая покупка импортированного клиента становится правом на услугу.