Skip to content

Структура backend

internal/
├── api/ # транспорт HTTP: роутинг, DTO, валидация входа
├── controlplane/ # транспорт gRPC: стримы агентов + рендер desired state
├── domain/ # бизнес-логика, по пакету на фичу
├── store/ # доступ к данным: sqlc-запросы, транзакции
├── jobs/ # периодические и отложенные задачи (River)
└── platform/ # инфраструктурные клиенты: crypto/KMS, ACME, mailer, telemetry, config

Направление зависимостей — только вниз: api/controlplane/jobsdomainstore/platform. Обратных импортов нет; domain не знает про Fiber, gRPC и River.

По пакету на фичу: nodes, inbounds, chains, certificates, clients, subscriptions, billing, adminauth, audit. Пакет фичи содержит типы предметной области и сервис с операциями; сервис владеет транзакцией: бизнес-операция (например, «перевести подписку в suspended») выполняет изменение данных, запись в audit_log и инкремент ревизий затронутых нод в одной транзакции.

Инкремент ревизии — единственная точка связи фич с control plane: сервисы зовут nodes.Touch(tx, nodeIDs...), который поднимает desired_revision и после коммита сигналит controlplane отправить свежие снапшоты в открытые стримы.

  • Реестр стримов — какие ноды подключены; идентичность из mTLS-сертификата.
  • Рендер — сборка NodeConfig ноды из данных (domain-читатели): инбаунды + клиенты групп доступа + сегменты цепочек + сертификаты + торрент-правила. Рендер — чистая функция от состояния БД; снапшот нигде не сохраняется.
  • ПриёмApplyResult, StatusReport, UsageReport (транзакция учёта — в domain/subscriptions), LogBatch (маршрутизируется ожидающему HTTP-запросу/SSE).

Изоляция движка. controlplane/render/xray — единственный пакет backend, знающий синтаксис и семантику конфигурации Xray (XrayConfig). Ни domain, ни api не оперируют Xray-структурами — только предметной моделью. Изменение формата Xray или добавление второго движка (render/singbox) локализовано в одном пакете; проверяется в CI запретом импорта render/xray откуда-либо, кроме controlplane.

Сервисы фич публикуют события об изменениях состояния — синхронно, внутри процесса, без брокера:

type DomainEvent interface {
EventName() string // "subscription.suspended", "certificate.renewed", ...
AggregateID() uuid.UUID
OccurredAt() time.Time
}

Диспетчер вызывает подписчиков в той же транзакции (аудит, инкремент ревизий) или после коммита (уведомления, метрики):

Подписчик Когда Что делает
Аудит В транзакции Запись в audit_log с diff
Ревизии В транзакции nodes.Touch() для затронутых нод
Уведомления После коммита Email/webhook админу (сертификат, нода offline)
Метрики После коммита Счётчики Prometheus

Событие — внутреннее соглашение кода, а не инфраструктура: очередей, outbox-таблиц и Event Sourcing нет. Ценность — одна точка, где изменение состояния превращается в свои побочные эффекты, вместо ручного вызова аудита и ревизий в каждом сервисе. Аудит и события концептуально различаются: событие фиксирует состоявшийся переход состояния; в аудит попадают также действия без перехода (неудачный вход, отклонённая валидация) — они пишутся напрямую, минуя события.

Хендлеры тонкие: распарсить, вызвать сервис фичи, сериализовать. Три группы роутов с разными middleware-цепочками: /api/v1 (JWT scope admin, RBAC), /portal/v1 (JWT scope client), публичные (/sub, /webhooks). OpenAPI-аннотации живут на хендлерах.

Все фоновые процессы — задачи River в PostgreSQL: истечение подписок, сброс периодов трафика, продление сертификатов, агрегация и retention статистики, ротация DEK, очистка сессий и неподтверждённых регистраций. Периодические задачи регистрируются в коде с cron-выражениями; исполнение сериализуется самим River — при нескольких инстансах backend задача выполняется один раз.

domain возвращает типизированные ошибки (ErrNotFound, ErrConflict, ErrValidation{fields}); api отображает их в RFC 9457, controlplane — в статусы gRPC. Ошибки внешних систем (KMS, ACME, DNS) оборачиваются с контекстом на границе platform.