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 она никогда не сохраняется.
Три префикса 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, тома, миграции и обновления.