> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Клиенты и импорт

> Перенос существующей базы пользователей Remnawave в Omniflow и выгрузка списка клиентов

У оператора, переходящего на Omniflow, клиенты уже есть — они в Remnawave, где их определяют
идентификатор пользователя Remnawave и, обычно, Telegram ID. Конвейер импорта превращает эти записи в
клиентов Omniflow, не сливая молча и не пропуская молча тех, кто конфликтует с кем-то уже
существующим.

Конвейер намеренно двухфазный и возобновляемый. **Предпросмотр** постранично проходит список
пользователей Remnawave, размещает каждую запись во временной области с вердиктом валидации и
подсчитывает, сколько записей чистых, сколько конфликтуют и сколько не поддаются чтению — до того,
как что-либо будет записано. **Применение** затем фиксирует чистые записи, по одному клиенту на
транзакцию. Прогон из сорока тысяч записей, прерванный на тридцатитысячной, возобновляется с
сохранённого курсора, а не начинается заново.

> [!NOTE]
>
> Источник импорта ровно один. Таблица ограничивает его условием
> `CHECK (source = 'remnawave')`, а читателем выступает эндпоинт списка Remnawave. Нет ни импорта из
> CSV, ни загрузки файлов, ни второго поставщика. CSV, описанный в конце этой страницы, — это только
> **выгрузка**.

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

Для каждой применённой записи — ровно две строки: строка `users` со статусом `active` и сопоставление
`remnawave_users`, несущее идентификатор пользователя Remnawave, Telegram ID, если он был, наблюдаемый
статус и отметку времени сверки.

Он **не** производит ни подписку, ни право на услугу, ни заказ, ни подтверждённую личность. Omniflow
не знает, что эти клиенты платили, когда и за что — Remnawave этого не фиксирует, — а придумывание
права на услугу внесло бы в книгу проводок период и цену, о которых никто не договаривался. См.
[Владение данными](https://omniflow-vpn.mintlify.site/ru/architecture/data-ownership.md) — границу, которую это уважает.

Что даёт сопоставление, так это узнавание. Когда импортированный клиент в следующий раз напишет боту,
путь разрешения клиента сначала ищет личность 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 большой панели, прошёл бы оба раза и создал бы двух
клиентов, которых нельзя разрешить одновременно.

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

| Код | Когда возникает |
| - | - |
| `missing_source_id` | Идентификатор пользователя Remnawave пуст |
| `source_already_linked` | Этот пользователь Remnawave уже сопоставлен с клиентом Omniflow |
| `duplicate_source_id` | Один и тот же идентификатор пользователя Remnawave встречается на этой странице дважды |
| `invalid_telegram_id` | Telegram ID равен нулю или отрицателен |
| `telegram_already_linked` | Этот Telegram ID уже занят сопоставлением, активной личностью или ранее размещённой страницей |
| `duplicate_telegram_id` | Один и тот же Telegram ID встречается на этой странице дважды |

Статус элемента вытекает из кодов: ни одного кода — `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` можно возобновить с
сохранённого курсора, как только панель снова будет доступна.

| Статус | Значение |
| - | - |
| `previewing` | Остались ещё страницы |
| `ready` | Все страницы размещены; можно начинать применение |
| `applying` | Применение проходит по валидным элементам |
| `completed` | Валидных элементов не осталось |
| `failed` | Чтение из Remnawave не удалось; возобновляемо с курсора |
| `cancelled` | Допустимый статус, который не пишет ни один путь кода |

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

Применение выбирает до `limit` элементов со статусом `valid`, упорядоченных по идентификатору
источника, и фиксирует каждый в собственной транзакции уровня `Serializable`. `limit` зажимается так
же, как `pageSize`.

Каждый элемент — это одна цепочка CTE в одном выражении: заблокировать элемент через `FOR UPDATE` по
`status = 'valid'`, вставить клиента, вставить сопоставление Remnawave, пометить элемент как `applied`
с его новым идентификатором клиента и отметкой времени. Поскольку это одно выражение в одной
транзакции, сбой не создаёт ни частичного клиента, ни осиротевшего сопоставления. Параллельный второй
прогон не может применить тот же элемент дважды, потому что блокировка совпадает только с элементом,
всё ещё находящимся в `valid`.

Конфликтующие и невалидные элементы никогда не выбираются, а значит, никогда не применяются. После
пакета, если не осталось ни одного элемента `valid`, прогон переходит в `completed` и проставляется
`completed_at`; иначе он остаётся в `applying`, и следующий вызов продолжает работу.

> [!WARNING]
>
> **Отката нет, и отмены нет.** «Сбойная транзакция элемента не создаёт частичного клиента» означает
> атомарность на уровне элемента, а не отмену. Ничто в кодовой базе не удаляет клиента или
> сопоставление применённого импорта, и ничто не пишет статус `cancelled`. Конфликтующие и невалидные
> элементы просто остаются размещёнными; действий слияния, пропуска или переопределения в продукте нет
> нигде. Сначала выполните предпросмотр на маленьком размере страницы и прочитайте счётчики, прежде чем
> применять.

Ещё две вещи, которых стоит ожидать:

* **Импортированные клиенты всегда создаются с локалью `ru`.** Локаль жёстко задана в пути применения
  независимо от чего бы то ни было в записи Remnawave. Клиент может сменить её сам в боте или в
  веб-панели впоследствии.
* **Импорт не пишет событий аудита.** Таблицы импорта и есть запись о том, что произошло; журнал
  аудита, описанный в [Административных операциях](https://omniflow-vpn.mintlify.site/ru/operations/admin-operations.md), их не покрывает.

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

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

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

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

**2. Откройте прогон**

```bash theme={null}
curl -X POST "https://example.com/v1/admin/imports/remnawave/preview?pageSize=100" \
  -H "Authorization: Bearer $APP_OPERATOR_TOKEN"
```

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

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

```bash theme={null}
curl -X POST "https://example.com/v1/admin/imports/remnawave/$IMPORT_ID/resume?pageSize=100" \
  -H "Authorization: Bearer $APP_OPERATOR_TOKEN"
```

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

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

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

**5. Примените**

```bash theme={null}
curl -X POST "https://example.com/v1/admin/imports/remnawave/$IMPORT_ID/apply?limit=100" \
  -H "Authorization: Bearer $APP_OPERATOR_TOKEN"
```

Повторяйте, пока `status` не станет `completed`.

| Метод и путь | Аутентификация | Право доступа |
| - | - | - |
| `POST /v1/admin/imports/remnawave/preview` | Операторский токен | — |
| `POST /v1/admin/imports/remnawave/{importID}/resume` | Операторский токен | — |
| `POST /v1/admin/imports/remnawave/{importID}/apply` | Операторский токен | — |
| `GET /v1/panel/imports` | Сессия оператора | `customers.read` |
| `GET /v1/panel/customers/export` | Сессия оператора | `customers.read` |

Отказы, которых стоит ожидать:

* Применение прогона в статусе `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 одинаковых строк.

> [!NOTE]
>
> Сводка ошибок бывает либо пустой, либо содержит единственную запись
> `{"code":"remnawave_unavailable"}`. Карточка построена так, чтобы отрисовывать количества по кодам,
> но сервис импорта их никогда не заполняет, поэтому сбойный прогон показывает одну пару с названием
> сбоя панели, а не разбивку по кодам валидации. Разбивка, на которую можно опираться, — это три
> счётчика.

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

Рядом с историей импорта есть кнопка **Export** — обычная ссылка на
`GET /v1/panel/customers/export`, которой нужно право `customers.read`. Она отдаёт потоком CSV со
списком клиентов, при желании отфильтрованным через `?status=`.

| Колонка | Содержимое |
| - | - |
| `customer_id` | Канонический UUID клиента в Omniflow |
| `status` | Статус жизненного цикла клиента |
| `locale` | Его локаль интерфейса |
| `timezone` | Его часовой пояс IANA |
| `created_at` | RFC 3339, UTC |
| `telegram_id` | Из сопоставления Remnawave, пусто, если его нет |
| `subscriptions` | Сколько подписок он держит |
| `paid_orders` | Сколько его заказов находится в `paid` или `fulfilled` |

Набор колонок — это и есть суть. Выгрузка — это файл, покидающий установку, поэтому он несёт
идентификаторы, которые оператору можно безопасно отдать, и факты, необходимые ему для сверки с другой
системой, — и намеренно ни ссылки на подписку, ни платёжного токена, ни идентификатора устройства.
Контактные значения тоже не появляются; они зашифрованы при хранении и не входят в этот файл.

Обработчик проходит курсор на стороне сервера по 200 строк на страницу и сбрасывает данные по ходу,
поэтому выгрузка по большой установке никогда не удерживается в памяти. Он останавливается после 200
страниц — по той же причине, что и выгрузки аудита и финансов: один запрос не должен иметь возможности
бесконечно обходить неограниченную таблицу. Установке с более чем сорока тысячами клиентов следует
ожидать, что файл на этом и закончится.

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

Привязка личности к аккаунту, в который вы уже вошли, работает. Соединение двух
существующих аккаунтов — другая задача, и именно она возникает на практике:
клиент покупает в Telegram, позже входит в вебе через провайдера, которого не
было у первого аккаунта, и держит пустой второй аккаунт, тогда как подписка
лежит на первом.

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

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

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

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

| Отклоняется, когда | Почему |
| - | - |
| Аккаунт объединяют сам с собой | Ничего бы не произошло, а выглядело бы как произошло |
| Источник уже объединён | Операция идемпотентна; вторая попытка сообщает о первой |
| Цель сама является объединённым аккаунтом | Всё оказалось бы в двух переходах от того места, куда смотрят |
| Один из аккаунтов удалён | Объединение вынесло бы записи из-под удаления, о котором попросили |
| Один аккаунт пригласил другой | Клиент стал бы собственным пригласителем, а за ту регистрацию было выплачено вознаграждение |

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

На `users` ссылаются сорок шесть таблиц. Для большинства это смена владельца.
Пять — это решение, и поэтому здесь нет цикла по каталогу внешних ключей:

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

### После

Источник сохраняет свои строки и историю и помечается как `merged` со ссылкой на
цель. Он не удаляется: запись о том, что произошло, ценнее, чем освобождённая
строка, а поддержке, отвечающей на «куда делась моя подписка», нужно куда-то
приземлиться.

Оба аккаунта получают событие жизненного цикла — `merged_away` и `merged_in`, —
поэтому объединение читается из собственной истории любого из клиентов, а не
только из операторского журнала. Событие аудита — `customer.merged`, оно несёт
счётчики.

Причина обязательна. Она попадает и в журнал аудита, и в обе истории, потому что
«почему эти два аккаунта соединили» — первый вопрос, который потом задают.

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

> **[Интеграция с Remnawave](https://omniflow-vpn.mintlify.site/ru/integrations/remnawave.md)**
>
> Адаптер, через который читает импорт, и каждый вызов, который он делает.

> **[Приватность клиента](https://omniflow-vpn.mintlify.site/ru/customer/privacy.md)**
>
> Что хранится о клиенте, что он может выгрузить и как происходит удаление.

> **[Административные операции](https://omniflow-vpn.mintlify.site/ru/operations/admin-operations.md)**
>
> Массовые действия, журнал аудита и остальная клиентская поверхность.

> **[Выдача доступа](https://omniflow-vpn.mintlify.site/ru/commerce/fulfillment.md)**
>
> Как первая покупка импортированного клиента становится правом на услугу.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.