Go — один модуль с тремя точками входа
go.mod объявляет go 1.26.0 и один модуль, github.com/omniflow/omniflow. cmd/api, cmd/bot и
cmd/worker компилируются из одного дерева, поэтому доменное изменение доходит до всех трёх сразу и не бывает
рассинхронизации версий между API, который пишет строку, и воркером, который её читает.
Альтернатива — три сервиса с тремя репозиториями и тремя темпами выпуска — дала бы независимое масштабирование,
которое не нужно ни одной установке такого размера, в обмен на распределённый протокол между оформлением
заказа, правом на услугу и выдачей доступа. Вместо этого значимая граница — транзакция PostgreSQL. См.
Обзор архитектуры.
Достижимость здесь — настоящее ограничение, а не договорённость: go list -deps ./cmd/<name> показывает ровно
то, к чему может обратиться каждый процесс, и бинарник api действительно не содержит ни исполнителя
кампаний, ни очистки по срокам хранения.
chi вместо фреймворка
github.com/go-chi/chi/v5 — это маршрутизатор поверх net/http, а не фреймворк. Каждый обработчик — это
http.HandlerFunc, а каждый middleware — обычная func(http.Handler) http.Handler, поэтому три префикса могут
нести три по-настоящему разных набора middleware в одном процессе, и ни один из них не наследует чужой.
Два поведения chi являются несущими. middleware.RequestID присваивает идентификатор, который попадает в
заголовок ответа X-Request-ID, в каждое тело ошибки как request_id, в строки аудита, в идентификаторы
корреляции исполнения заказов и в события безопасности клиента — одна строка, связывающая жалобу клиента со
строкой журнала обработчика и вызовом Remnawave. А chi.RouteContext(...).RoutePattern() даёт метричному
middleware шаблон маршрута для метки, поэтому путь, содержащий идентификатор клиента, никогда не
становится значением метки Prometheus.
pgx, sqlc и Atlas
pgx/v5 используется напрямую, без ORM. Запросы лежат как SQL в database/queries/, а sqlc генерирует
типизированный Go в internal/database/dbgen с sql_package: pgx/v5. Это даёт то, что SQL, который читает
ревьюер, — это и есть SQL, который выполняется, включая те части PostgreSQL, которые ORM обычно прячет и на
которые эта кодовая база сильно опирается: ON CONFLICT … DO UPDATE … RETURNING для идемпотентных вставок,
pg_advisory_xact_lock, чтобы потолок параллелизма выдержал две одновременные покупки, SELECT … FOR UPDATE
внутри сериализуемых транзакций, частичные уникальные индексы и ограничения CHECK, делающие некорректные
состояния непредставимыми.
Изменения схемы идут в обратном порядке по сравнению с migration-first-инструментами: правите
database/schema.sql, генерируете отрецензированную миграцию Atlas командой make atlas-diff, затем заново
генерируете sqlc. Миграции — только вперёд: down-миграций нет, а откат схемы — это восстановление из резервной
копии. database/migrations/atlas.sum создаётся только командой atlas migrate hash и никогда не правится
руками: несовпадение контрольной суммы означает, что миграция изменилась, а не что файл устарел.
CI применяет каждую закоммиченную миграцию от нескольких базовых точек до головы и доказывает, что повторное
применение головы ничего не меняет, а интеграционный набор применяет их в PostgreSQL 18.4 из Testcontainers.
См. База данных.
River для надёжной работы
riverqueue/river v0.43.0 с драйвером riverpgxv5 выполняет задачи в той же базе данных PostgreSQL, что и всё
остальное. В этом и смысл: задачу можно вставить через InsertTx внутри той транзакции, которая произвела
требующее её изменение состояния, — чего брокер в отдельной системе не может предложить без двухфазного
протокола или диспетчера outbox. AGENTS.md делает это правилом и запрещает вводить брокер без принятого
архитектурного решения.
Настроены три очереди — critical (16 воркеров), default (8) и bulk (4). fulfillment и
goods_delivery выполняются на critical с MaxAttempts: 12; periodic_task, проходы по всей таблице, —
на default с одной попыткой.
Собственные таблицы River поставляются вместе с миграциями Atlas:
20260905130000_river_queue.sql — это
дословно скопированная собственная линия миграций River, поэтому сервис migrate в compose создаёт
river_job и её собратьев вместе со схемой Omniflow. Интеграционный тест падает, если rivermigrate видит
неприменённую версию River, поэтому обновлению River нужна соответствующая миграция.time.Ticker
остаются только циклы, которые касаются своего процесса. Обе модели перечислены в разделе
Задачи и очереди.
PostgreSQL 18 и Valkey 9
PostgreSQL — единственный надёжный источник истины. Стек compose закрепляетpostgres:18.4-alpine, а образы
bot и worker несут postgresql18-client, чтобы pg_dump и pg_restore совпадали с мажорной версией
сервера, — Dockerfile предупреждает, что понижать базу Alpine ниже 3.23 нельзя, поскольку это первый выпуск с
этим пакетом.
Valkey (valkey/valkey:9.1.1-alpine) используется для счётчиков ограничения частоты запросов, одноразовых
токенов защиты от повтора, блокировок и кеша и запускается с --save "" --appendonly no. Persistence отключён
намеренно: ничто в Valkey не имеет права быть единственной копией чего бы то ни было. Оба примитива — это по
одному Lua-скрипту: Allow — это INCR плюс PEXPIRE, Once — это SET NX PX, — и оба возвращают ошибку, а
не разрешающее значение по умолчанию, когда клиент отсутствует, поэтому сбой не может молча снять защиту.
Резервное копирование вызывает pg_dump --format=custom и pg_restore, а не переизобретает их, поэтому
резервная копия всегда восстановима стандартными инструментами PostgreSQL; шифрование — это обёртка AES-256-GCM
с кадрированием поверх этого потока. См. Резервное копирование и восстановление.
OpenAPI 3.0.3 и сгенерированные привязки
api/openapi.yaml — это контракт. Он написан в OpenAPI 3.0.3, а не в 3.1, потому что у выбранного
генератора Go пока нет стабильной поддержки 3.1, и AGENTS.md фиксирует это как условие для перехода.
make generate запускает три генератора: go tool oapi-codegen для серверных типов Go, go tool sqlc generate
для слоя базы данных и bun run generate:api, который дважды запускает Orval — один раз для типов TypeScript и
SWR-хуков, другой раз для схем Zod. CI перегенерирует всё это и падает на любом расхождении, поэтому изменение
контракта, закоммиченное без перегенерации, не пройдёт в основную ветку.
Транспорт, который на самом деле использует веб-приложение, написан вручную
(packages/api-client/src/fetcher.ts), а не сгенерирован, и это сделано намеренно и задокументировано в самом
файле: SWR-клиент Orval 8.24 выдаёт голые вызовы fetch и игнорирует override.mutator, поэтому
сгенерированный хук не отправлял бы ни cookie сессии, ни CSRF-токен — каждая мутация через него была бы
отклонена, а каждое чтение было бы анонимным. Обёртка ориентируется на те же сгенерированные типы, так что
формы запроса и ответа остаются сверенными с контрактом; различается только транспорт. Она задаёт
credentials: "include", перечитывает заголовок ответа X-CSRF-Token после каждого вызова, чтобы долгоживущая
вкладка пережила ротацию сессии, не трогает Content-Type для FormData, потому что только браузер знает свою
границу multipart, и превращает ответ-проблему в ApiError, чей code — последний сегмент type проблемы.
Контрибьюторам стоит знать, что сгенерированные серверные типы Go в internal/httpapi/apigen сейчас не
импортируются ничем: обработчики декодируют в анонимные структуры и пишут собранные вручную мапы. Ничто не
проверяет ответ по схеме ни на сборке, ни во время выполнения, поэтому изменению схемы нужен тест, который его
проконтролирует.
Веб-приложение
Одно приложение Next.js 16.3 (apps/web) несёт оба интерфейса: /account для клиентов и /admin для
операторов. Одно приложение вместо двух означает одну систему компонентов, один каталог локализации и одну
сборку, которую нужно держать зелёной, — а разделение маршрутов организационное, поскольку авторизацию
применяет Go API на каждом запросе.
Продакшен-образ собирается с Bun 1.3.14 и отдаёт standalone-сборку Next.js из
node:26.7-alpine на порту 3000.
Инструментарий
Bun workspaces (apps/*, packages/*) отвечают за установку и скрипты, bun.lock закоммичен, и рядом нет
lock-файлов npm, pnpm или Yarn — один lock-файл означает один граф зависимостей. Biome 2.5 — единственный
форматтер и линтер для TypeScript и JavaScript, поэтому форматирование никогда не становится темой ревью.
TypeScript — версии 7.0, и сгенерированные клиенты не должны зависеть от удалённых программных API TypeScript.
Тестирование делится на три части по тому, что может доказать каждый слой. go test -race -cover ./...
работает вообще без демона Docker, потому что наборы на Testcontainers лежат в internal/integrationtest за
тегом сборки integration. Эти наборы применяют каждую закоммиченную миграцию в настоящий PostgreSQL и
прогоняют репозитории. Playwright работает против продакшен-сборки, исходя из того, что проверка, проходящая
против сборки, которую никто не выкладывает, — это не проверка.
Наблюдаемость
log/slog пишет JSON в stdout у API и бота и в stderr у воркера, на уровне по умолчанию для обработчика.
Переменной окружения для уровня, формата или назначения журнала нет нигде в репозитории, поэтому повышение
подробности — это изменение кода. Общий редактор вырезает токены ботов, значения bearer и учётных данных,
последовательности цифр, похожие на карту, пары hwid и charge_id, а также любой URL со схемой http(s),
vless, vmess, trojan или ss — целиком, потому что даже хост идентифицирует установку, к которой
подключается клиент. Заявленный компромисс таков: перередактированная строка журнала — небольшое неудобство, а
утёкшая ссылка подписки — инцидент безопасности.
Метрики Prometheus берутся из реестра, отдельного для каждого процесса (не из глобального), и отдаются на
/metrics с постоянной меткой service со значением api, bot или worker. Каждая метка — ограниченное и
неидентифицирующее значение, потому что значения меток хранятся у цели сбора бессрочно.
Инструментирование OpenTelemetry подключено в четырёх точках: otelhttp на входящем маршрутизаторе и исходящем
клиенте, otelpgx на пуле соединений, спан на каждое обновление Telegram и спан на каждую задачу исполнения
заказа. otelpgx записывает текст запроса и никогда — значения аргументов; исходящий клиент записывает только
метод и хост.
Omniflow никогда не устанавливает провайдер трассировки.
otel.SetTracerProvider не встречается нигде, ни
один экспортёр OTLP не импортируется, и ни один процесс не читает переменные OTEL_*. Поэтому глобальный
провайдер является заглушкой, и описанные выше спаны никуда не экспортируются. Инструментирование на месте,
экспортёра нет.Развёртывание
compose.yaml в корне репозитория описывает весь односерверный стек: postgres, valkey, одноразовый сервис
migrate на arigaio/atlas:1.3.0, затем api, bot, worker и необязательный web под профилем web.
Хостовые порты публикуют только api и web; PostgreSQL и Valkey остаются во внутренней сети.
Образы собираются из build/go.Dockerfile с CGO_ENABLED=0 go build -trimpath. RUNTIME=static даёт
distroless-образ nonroot для API; RUNTIME=postgres даёт образ Alpine с клиентом PostgreSQL для бота и
воркера, которым он нужен для резервных копий. Выпущенные образы публикуются в GHCR для linux/amd64 и
linux/arm64 с provenance и SBOM.
Обратный прокси не входит в поставку и не обязателен. В deploy/proxies/ лежат проверенные в CI примеры для
Caddy и Traefik, которые терминируют TLS, ограничивают размер тела запроса, выставляют заголовки безопасности
и — что важнее всего — убирают /metrics и /readyz с публичного хоста API. Пример для Traefik отключает
журналы доступа, потому что путь запроса может быть ссылкой подписки. См.
Обратный прокси.
Чего намеренно нет
- Брокера сообщений. Доменные события идут через транзакционный outbox, и
AGENTS.mdтребует принятого архитектурного решения до того, как брокер будет введён. - Фреймворка внедрения зависимостей. Связывание явно прописано в каждом
main— многословно и читаемо; оно изменится, когда сложность связывания этого действительно потребует. - ORM. SQL пишется, рецензируется и генерируется в типы.
- Kubernetes, Helm и Pulumi. Единственная топология развёртывания в репозитории — Docker Compose плюс два примера прокси.
- Среды выполнения плагинов. Нет ни WASM-хоста, ни ABI плагинов, ни точки расширения, загружающей сторонний код в процесс.
- Второго места, которое решает, что позволено роли. Каталог прав доступа и сопоставление ролей
вкомпилированы в
internal/rbac, поэтому правка в базе данных не может расширить доступ.
Смежные материалы
Обзор архитектуры
Процессы, пути запросов и транзакционный шаблон.
Разработка
Настройка рабочего пространства, генераторы, запуск стека и проверки.
База данных
Схема, Atlas, sqlc и правила миграций.
Развёртывание
Топология compose, тома, обновления и откат.