Skip to main content
Omniflow — это один модуль Go с тремя точками входа (cmd/api, cmd/bot, cmd/worker), собираемыми из одного дерева исходников в три образа, плюс одно приложение Next.js, несущее оба браузерных интерфейса. Они используют одну базу данных PostgreSQL и один экземпляр Valkey. PostgreSQL — единственный надёжный источник истины; Valkey хранит кеш, счётчики ограничения частоты запросов, блокировки и временное состояние, а стек compose запускает его с --save "" --appendonly no, чтобы ни у кого не возникло соблазна считать его хранилищем. Разделение на три процесса сделано ради изоляции отказов и различий во времени жизни, а не ради масштабирования. Пакет из четырёхсот изменений подписок не должен быть привязан ко времени жизни HTTP-запроса, который его начал, а автоматические продления — единственное место, где деньги движутся без наблюдателя, — выполняются там, где ни один запрос не может их отменить.

Четыре процесса

Три образа Go различаются только тем, что им нужно во время выполнения: api поставляется на distroless-базе, а bot и worker — на Alpine с postgresql18-client, потому что оба вызывают pg_dump и pg_restore. Веб-образ отдаёт standalone-сборку Next.js на базе Node. См. Развёртывание.

Без чего каждый процесс отказывается стартовать

Конфигурация читается из переменных окружения один раз, при старте, и неверное значение приводит к фатальной ошибке, а не остаётся скрытой проблемой. Ничто не перечитывает окружение, поэтому любое изменение — это перезапуск.
  • api запускается с пустым APP_DATABASE_URL и тогда обслуживает только /healthz, /livez, /readyz и /metrics. Если URL базы данных задан, дополнительно требуются APP_OPERATOR_TOKEN, 32-байтный APP_DATA_ENCRYPTION_KEY, APP_REMNAWAVE_URL, APP_REMNAWAVE_TOKEN и APP_VALKEY_URL, иначе процесс завершается с сообщением commerce requires APP_OPERATOR_TOKEN, APP_DATA_ENCRYPTION_KEY, APP_REMNAWAVE_URL, APP_REMNAWAVE_TOKEN, and APP_VALKEY_URL.
  • bot с пустым APP_TELEGRAM_TOKEN пишет в журнал telegram bot disabled because APP_TELEGRAM_TOKEN is empty и простаивает, пока не получит сигнал, — это поддерживаемая конфигурация, а не ошибка. С токеном он требует APP_DATABASE_URL, APP_VALKEY_URL, APP_REMNAWAVE_URL и APP_REMNAWAVE_TOKEN. Valkey здесь обязателен, потому что ограничение частоты запросов и защита от повтора callback-ов прикрывают нажатия, которые двигают деньги.
  • worker требует APP_DATABASE_URL, APP_REMNAWAVE_URL и APP_REMNAWAVE_TOKEN. APP_DATA_ENCRYPTION_KEY необязателен: без него воркер пишет digital goods delivery disabled и выполняет всё остальное.
Полный список — в разделе Конфигурация.

Как проходит запрос

Каждая точка входа сначала попадает в PostgreSQL. Только воркер обращается к Remnawave на пути записи, и делает это из надёжной задачи, а не из запроса. Покупка делает эту схему конкретной:
1

Клиент выбирает тариф

В браузере это /account и маршруты /v1/account/checkout; в Telegram — экраны каталога у бота. И то и другое создаёт обычную строку заказа.
2

Создаётся платёжное намерение

Заказ получает намерение против одного адаптера провайдера. Клиента отправляют к провайдеру, либо он платит в Telegram, либо заказ закрывается за счёт баланса кошелька.
3

Провайдер присылает обратный вызов

POST /v1/payments/webhooks/{provider} по необходимости не аутентифицирован и защищён проверкой подписи по тем самым байтам, которые прислал провайдер. Повторное событие провайдера разрешается в {"status":"duplicate"}, а не проводится вторично.
4

Расчёт, право на услугу и задача коммитятся вместе

В одной транзакции API помечает заказ оплаченным, записывает право на услугу, создаёт строку fulfillment_operations с ключом идемпотентности order:<orderID>:fulfill, пишет событие order.paid в outbox, начисляет реферальное вознаграждение, если оно есть, и вставляет задачу River.
5

Воркер выдаёт доступ

Воркер fulfillment забирает задачу, открывает сериализуемую транзакцию, блокирует строку операции и вызывает Remnawave. При успехе предыдущие права на услугу этой подписки замещаются, а заказ переходит в fulfilled.
6

Клиент видит доступ

Ссылка доступа читается из Remnawave вживую на каждый запрос. В Omniflow она никогда не сохраняется.
Если обратный вызов провайдера закоммитился, а вызов Remnawave затем упал, деньги записаны, право на услугу существует, и есть задача с невыполненной работой. Именно ради этого отказа и проведена граница транзакции. Исполнение заказов описывает поведение при повторах, отложенных попытках и расхождениях.

Три префикса API — три модели доверия

HTTP API разделён на три префикса, которые никогда не используют общий маршрутизатор. Объединение любых двух означало бы, что один молча наследует middleware другого, а учётные данные клиента никогда не должны иметь возможность дотянуться до операторского маршрута. Структурное разделение делает это свойством таблицы маршрутов, а не проверкой, о которой кто-то должен помнить; тест утверждает, что клиентский маршрутизатор отвечает 404 на /v1/panel/auth/session, /v1/admin/orders и /v1/panel/admins. Префикс, чей сервис не подключён, отсутствует — 404, а не 503, — потому что 503 сообщил бы анонимному вызывающему, что настроил оператор. Исключение — маршруты до входа в систему, которые отвечают 503 panel_unavailable или 503 account_unavailable, поскольку они работают до того, как появятся какие-либо учётные данные. Общий для процесса middleware применяется ко всем трём: идентификатор запроса, восстановление после паники, 60-секундный таймаут обработчика, OpenTelemetry и метрики. /v1/admin — это внешний интерфейс автоматизации: ничто в этом репозитории его не вызывает, а бот общается с PostgreSQL и Remnawave внутри процесса, а не по HTTP. См. Интеграция с API. Граница веб-маршрутов организационная, а не защитная. Авторизация проверяется в Go API на каждом запросе, а серверные компоненты /admin проверяют те же права доступа перед отрисовкой; отдельное имя хоста или скрытая навигация никогда не являются достаточной авторизацией. Каталог прав доступа и сопоставление ролей вкомпилированы в internal/rbac, поэтому панель отрисовывается ровно из того набора, который применяет API, и правка в базе данных не может расширить доступ.

Изменение состояния и его надёжная задача коммитятся вместе

Правило в AGENTS.md короткое: ставить надёжные задачи River в очередь в той же транзакции PostgreSQL, что и изменение состояния, которое их требует. Именно это делает утверждение «у оплаченного заказа всегда есть ожидающая его задача» истинным, а не желаемым, и существует интеграционный тест, весь смысл которого — это свойство. Хук постановки в очередь — это функция, передаваемая в коммерческое хранилище при старте процесса, поэтому один и тот же код расчёта транзакционен и в API, и в воркере. Бот намеренно не передаёт хук: он создаёт заказы и платёжные намерения, но выдача доступа принадлежит воркеру, а расчёт коммитит тот процесс, который получил обратный вызов. Повтор безопасен, потому что идентичность несёт надёжная запись, а не очередь. У fulfillment_operations есть UNIQUE ключ идемпотентности, а создание строки выполняется как ON CONFLICT (idempotency_key) DO UPDATE … RETURNING, поэтому повтор считывает уже существующую операцию. Повторное использование ключа с другими параметрами отвергается сразу: idempotency key was already used with different fulfillment parameters. Известные формы ключей: order:<orderID>:fulfill, order:<orderID>:addon, gift:<giftID>:fulfill, reconcile:<entitlementID>:<15-minute bucket>, panel:<subscriptionID>:<operation>:<requestID> и bulk:<operationID>:<position>. Работу над отдельной записью несут два вида задач — fulfillment и goods_delivery, — обе на очереди critical с MaxAttempts: 12 и уникальностью по аргументам задачи; третий вид, periodic_task, несёт периодические проходы на default. Настроенных очередей три: critical (16 воркеров), default (8) и bulk (4). Воркеры зарегистрированы только в процессе worker; API и бот создают клиенты River только для вставки.
Собственные таблицы River — river_job, river_leader, river_queue, river_notification, river_migration — создаёт закоммиченная миграция Atlas 20260905130000_river_queue.sql, дословная копия собственной линии миграций River, поэтому сервис migrate в compose оставляет базу, на которой воркер может запуститься. См. Задачи и очереди.

Транзакционный outbox

Три топика записываются в outbox_events внутри той транзакции, которая их вызвала: order.paid, wallet.credited и addon.purchased. Строка несёт топик и полезную нагрузку и записывается с той же гарантией «всё или ничего», что и стоящее рядом изменение состояния. Ничто их не рассылает. Ни один путь кода не выставляет outbox_events.published_at; единственные операторы, которые трогают этот столбец, — два чтения с WHERE published_at IS NULL и удаление по сроку хранения, которое отбирает published_at IS NOT NULL. На практике это означает, что outbox — надёжная запись о случившемся, а не механизм доставки: GET /v1/admin/outbox сообщает pendingCount, который только растёт, APP_RETENTION_OUTBOX_DAYS ничего не удаляет, а вкладка outbox в панели по адресу GET /v1/panel/system/outbox показывает только топик и возраст — никогда полезную нагрузку, в которой могут быть имя клиента и сумма.

Периодические проходы и внутрипроцессные циклы

Каждый проход по всей таблице, меняющий состояние, — обслуживание заказов, кошельков и корзин, сверка платежей, продления, планирование сверки и доставки, обходчик жизненного цикла, вычисление аномалий, раскрытие кампаний, массовые операции, очистка по срокам хранения и резервные копии — это периодическая задача River вида periodic_task на воркере. Ставит их в очередь только выбранный River лидер, и каждая остаётся уникальной, пока стоит в очереди или выполняется, поэтому проход выполняется один раз за интервал на всю установку, сколько бы воркеров ни работало. У прохода одна попытка: сбой записывается, а следующий интервал запускает новый проход по тем же строкам. Несколько циклов остаются обычными внутрипроцессными таймерами, потому что касаются только своего процесса или безвредны при повторе: контроллер режима обслуживания, телеметрический сигнал и очистка коллектора в API, а также проверка членства в каналах и разбор очередей уведомлений в боте. Настраиваются только интервалы обслуживания, очистки по срокам хранения и резервного копирования; остальные вкомпилированы. Полный перечень — в разделе Задачи и очереди.

Поддерживаемая топология

Запускайте сколько угодно реплик api за прокси и сколько угодно реплик worker — и ровно один bot: Telegram доставляет обновления одному потребителю. Каждой реплике воркера нужны общие каталоги резервных копий и вложений, а пул подключений каждой реплики расходует max_connections PostgreSQL. См. Развёртывание. Стек compose не объявляет политику restart: ни для postgres, ни для valkey, api, bot, worker или web; её задаёт только оверлей Traefik. Упавший процесс останется лежать, пока кто-нибудь не заметит, — именно для этого нужны пробы и Наблюдаемость.

Почему модульный монолит

AGENTS.md формулирует правило — держать приложение на Go модульным монолитом с независимыми точками входа api, bot и worker, — и его удерживают четыре вспомогательных ограничения:
  • Доменные пакеты не должны импортировать транспорт, генерацию базы данных, Telegram и пакеты конкретных провайдеров. Стрелки зависимостей направлены в одну сторону.
  • Внешние системы скрыты за небольшими интерфейсами. К Remnawave обращаются только через remnawave.Service, remnawave.Provisioner и remnawave.Importer, поэтому все типы, повторяющие форму Remnawave, живут в одном пакете.
  • Доменные события идут через транзакционный outbox, и брокер сообщений не вводится без принятого архитектурного решения.
  • Построение зависимостей остаётся явным, без фреймворка внедрения зависимостей до тех пор, пока этого не потребует сложность связывания.
Что из этого получает оператор: одну базу данных, один набор миграций, один .env и три контейнера для запуска. Что получает контрибьютор: изменение, затрагивающее оформление заказа, право на услугу и выдачу доступа, — это одна транзакция и один тест, а не распределённый протокол. Это позволяет поддерживать сотни независимых установок, не навязывая ни одной из них брокер, Kubernetes или среду выполнения плагинов.

Куда двигаться дальше

Владение данными

Точная граница с Remnawave: за что каждая система авторитетна и что ломается при нарушении границы.

Технологии

Каждый компонент стека и причина, по которой он там есть.

Задачи и очереди

Виды задач, повторы, недоставленные задачи, циклы по расписанию и проходы очистки по срокам хранения.

Развёртывание

Топология compose, тома, миграции и обновления.