Skip to content

Клиенты и подписки

Четыре сущности вместо одной таблицы «пользователь»:

Сущность Отвечает на вопрос Жизненный цикл
Клиент Кто это? Живёт, пока существует человек
Подписка Что и до когда ему доступно? Активируется, продлевается, истекает
План Условия продажи Справочник, управляется админом
Группа доступа Какие инбаунды входят? Справочник
erDiagram
    clients ||--o{ subscriptions : ""
    plans ||--o{ subscriptions : "необязательно"
    access_groups ||--o{ subscriptions : ""
    access_groups ||--o{ access_group_inbounds : ""
    inbounds ||--o{ access_group_inbounds : ""
    subscriptions ||--o{ protocol_credentials : ""

Разделение обосновано разными жизненными циклами: у клиента может быть история подписок (продления — новые периоды, а не перезапись полей), подписка может существовать без плана (выдана вручную), план меняется без влияния на уже проданные подписки.

Идентичность: email (уникален) и опциональное имя. Появляется двумя путями: создание админом (сразу активен) или саморегистрация, если включена — с режимами открыто / по заявке / закрыто (Саморегистрация). Вход в кабинет — magic link, пароль или OAuth (методы входа).

Статусы: pending (заявка на рассмотрении) · active · rejected · disabled (бан). Вход и покупка доступны только active-клиентам с подтверждённым email. Клиент может существовать без подписок — статус клиента управляет доступом в кабинет, статусы подписок — доступом к VPN; это независимые измерения.

Поле Назначение
client_id Владелец
plan_id Nullable: подписка, выданная вручную, плана не имеет
access_group_id Какие инбаунды доступны. При покупке копируется из плана
status active | expired | suspended | disabled
expires_at Nullable — бессрочная
traffic_limit_bytes Nullable — безлимит. Копируется из плана при покупке
traffic_reset none | monthly — сброс расхода по границе периода
token Секрет ссылки подписки (/sub/{token}), 🔒 шифруется

access_group_id и traffic_limit_bytes копируются из плана в момент активации сознательно: изменение плана задним числом не должно менять условия уже проданных подписок. Это снапшот условий сделки, а не дублирование данных.

stateDiagram-v2
    direction LR
    [*] --> active
    active --> expired : expires_at прошёл
    expired --> active : продление
    active --> suspended : лимит трафика исчерпан
    suspended --> active : сброс периода / продление
    active --> disabled : бан админом
    disabled --> active : разбан

Только active-подписки попадают в рендер desired state. Любой переход статуса инкрементирует ревизии затронутых нод — отзыв доступа доходит до Xray за секунды при живом стриме.

expired и suspended вычисляются фоновым процессом и обработчиком отчётов о трафике соответственно; disabled ставится только админом и не снимается автоматикой.

Секреты протоколов генерируются backend-ом per-подписка per-протокол (лениво, при первом появлении протокола в группе доступа):

Протокол Секрет
VLESS/VMess UUID
Trojan Случайный пароль (32 байта)
Shadowsocks Случайный пароль

Хранятся зашифрованными (Шифрование) — они нужны в открытом виде для рендера конфигов нод и ссылки подписки, поэтому шифруются, а не хэшируются.

Перевыпуск ключа (кнопка у клиента и у админа): секреты подписки генерируются заново, ревизии затронутых нод инкрементируются, старые конфиги перестают работать немедленно. Идентификатор подписки при этом не меняется — статистика и история не теряются.

В Xray клиент идентифицируется email-тегом <subscription_id>@astral — счётчики трафика однозначно относятся к подписке без парсинга чего-либо, кроме UUID.

Группа доступа — именованный набор инбаундов. Единственный механизм выдачи доступа: и планы, и ручные подписки ссылаются на группу. Нет «привязки клиента к ноде», нет особого случая для мультихопа — цепочка задаётся на инбаунде (Мультихоп), подписка этого не видит.

Изменение состава группы немедленно влияет на все её подписки (это операция управления доступом, в отличие от условий плана).

name, price, currency, period, traffic_limit_bytes, traffic_reset, access_group_id. План — шаблон для создания/продления подписок через платежи. Удаление плана не трогает существующие подписки (plan_id → SET NULL — их условия уже скопированы).

GET /sub/{token} отдаёт клиентский конфиг: по подписке выбираются все enabled-инбаунды её группы доступа на active-нодах, для каждого рендерится URI (vless://… и т.д.) с адресом ноды и секретом подписки. Формат ответа определяется по User-Agent (Clash, sing-box, v2ray base64) — стандартное поведение для клиентских приложений.