Files
PnvPanel/docs/api-design.md
T
Leonid Pershin cc7e2a7f8f
CI / Backend (build + test) (push) Failing after 1m37s
CI / Frontend (lint + typecheck + build) (push) Successful in 44s
Enhance activation request validation and documentation
- Updated the `RequestActivationCommandValidator` to require a non-empty comment, ensuring that users provide necessary identification information.
- Modified integration tests to validate the new requirement for a comment, including a test for handling empty comments.
- Updated API documentation to reflect that the comment is now mandatory and clarified its purpose.
- Enhanced frontend components to enforce comment requirements and provide user guidance on the comment's importance.
2026-07-30 03:23:03 +03:00

46 KiB
Raw Blame History

API Design

REST поверх HTTP/JSON, авторизация — Authorization: Bearer <access-token> (кроме публичных эндпоинтов). Ошибки — application/problem+json. Пагинация — ?page=&pageSize=, ответ PagedList<T> (items, total, page, pageSize) — используется не везде, см. таблицы ниже. Все даты — ISO-8601 UTC. Тела запросов/ответов — camelCase JSON; енумы сериализуются строками ("Active", не 0).

Базовый префикс: /api (без версионирования). Схема генерируется нативным Microsoft.AspNetCore.OpenApi (/openapi/v1.json) и Scalar UI (/scalar) — каждый эндпоинт аннотирован .Produces<T>(), так что схема полностью описывает и тела запросов, и тела ответов. Ниже — полный контракт, сверенный построчно с кодом (backend/src/PnvPanel.Api/Endpoints/*.cs).

Auth

Метод Путь Роль Тело запроса Тело ответа
POST /api/auth/register { userName, password } { id, userName }
POST /api/auth/login { userName, password } { accessToken, expiresAt, user } + refresh в httpOnly cookie
POST /api/auth/refresh — (refresh из cookie) то же, что login; ротация cookie
POST /api/auth/logout user 204 No Content
POST /api/auth/change-password user { currentPassword, newPassword } 204 No Content
POST /api/auth/change-username user { newUserName } 204 No Content
GET /api/auth/me user { id, userName, role, isActivated, telegramLinked }
DELETE /api/auth/me user 204 No Content

user/ответ /merole строкой (одна роль, не массив). Группа /api/auth/* под общим rate-limit'ом (RateLimiting:AuthPermitLimit, по умолчанию 20 запросов/мин).

Вход по username. Email в системе не используется. Забыт пароль: при привязанном Telegram — вход без пароля через бота и смена пароля в настройках; иначе — сброс админом (см. Admin).

Auth — Telegram (привязка и passwordless-вход)

Группа /api/auth/telegram/*, тот же rate-limit, что и /api/auth/*.

Метод Путь Роль Тело ответа
POST /api/auth/telegram/link-token user { deepLink, expiresAt }
POST /api/auth/telegram/unlink user 204 No Content
POST /api/auth/telegram/login-request { requestId, deepLink, expiresAt }
GET /api/auth/telegram/login-request/{id} см. ниже

deepLinknull, если Telegram:BotToken не настроен или Bot API недоступен (username бота панель получает сама через getMe, см. telegram-bot.md), иначе https://t.me/<bot>?start=link_<token> / ?start=login_<requestId>. QR backend не рендерит — фронт строит QR из deepLink сам (qrcode.react).

GET …/login-request/{id} (поллинг) → варианты ответа:

// ожидание / отклонено / истекло — accessToken/expiresAt/user всегда null, кроме Approved
{ "status": "Pending", "accessToken": null, "expiresAt": null, "user": null }
// подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос помечается Consumed
{ "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "userName": "…", "role": "user", "isActivated": true, "telegramLinked": true } }

status — одно из Pending/Approved/Rejected/Expired/Consumed.

Апдейты Telegram (/start, кнопки, /configs) обрабатывает in-process бот (long polling), а не HTTP-эндпоинты. Команды бота — в telegram-bot.md.

Configs (пользователь)

Группа /api (не вложена дальше), RequireAuthorization().

Метод Путь Тело запроса Тело ответа
GET /api/inbounds/available AvailableInboundDto[]
GET /api/configs { configs: VpnConfigDto[], configQuota, planId }без пагинации, весь список сразу
POST /api/configs { inboundId, label? } VpnConfigDto (200 OK, не 201)
PATCH /api/configs/{id} { label? } VpnConfigDto
POST /api/configs/{id}/rotate VpnConfigDto (новый id тот же, новый SubscriptionToken)
DELETE /api/configs/{id} 204 No Content
GET /api/configs/{id}/link { connectionString, subscriptionUrl }
GET /api/subscription { subscriptionUrl }

Нет отдельного GET /api/configs/{id} — детали конфига берутся из списка GET /api/configs. VpnConfigDto: { id, label, protocol, location, usedUpBytes, usedDownBytes, expiresAt, status, createdAt }. expiresAt всегда null (лимиты по сроку не реализованы — см. domain-model.md). Ссылка подключения не приходит вместе с созданием — фронт запрашивает GET .../link отдельно, по кнопке на карточке конфига; QR строится на фронте из connectionString.

Все /api/configs/*, /api/news, /api/apps без активации → 403 (Auth.NotActivated, единая проверка RequireActivationBehavior); создание сверх квоты тарифа (AppUser.ConfigQuota) → 409 (Configs.QuotaExceeded).

Apps — каталог приложений

Метод Путь Роль Тело запроса Тело ответа
GET /api/apps user Record<OsPlatform, ClientAppDto[]>
GET /api/admin/apps admin AdminAppDto[] (вкл. выключенные)
POST /api/admin/apps admin { name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isRecommended } AdminAppDto
PUT /api/admin/apps/{id} admin { name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled, isRecommended } AdminAppDto
DELETE /api/admin/apps/{id} admin 204 No Content

IsRecommended — рекомендованные приложения идут первыми внутри своей группы ОС на /api/apps (сортировка IsRecommended desc, SortOrder asc, применяется после фильтра IsEnabled), помечаются значком-звездой на странице инструкций и в списке приложений в админке.

GET /api/apps → пример (только isEnabled == true, ОС без приложений в ответе отсутствует):

{
  "Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "description": null, "iconUrl": null } ],
  "IOS":     [ { "id": "…", "name": "Hiddify",  "downloadUrl": "https://…", "description": null, "iconUrl": null } ]
}

Значение OsPlatform в C#/JSON — IOS (не iOS).

Instructions — страница инструкций

Вводный markdown-текст (singleton) над вкладками + дополнительные вкладки, обе части редактируются из админки. Встроенная вкладка «Приложения» (каталог ClientApp) в этот API не входит — фронт всегда рисует её первой, сама её достаёт через GET /api/apps.

Метод Путь Роль Тело запроса Тело ответа
GET /api/instructions/intro user InstructionIntroDto
GET /api/instructions/tabs user InstructionTabDto[] (сортировка SortOrder asc)
PUT /api/admin/instructions/intro admin { body } InstructionIntroDto
POST /api/admin/instructions/tabs admin { title, body, sortOrder } InstructionTabDto
PUT /api/admin/instructions/tabs/{id} admin { title, body, sortOrder } InstructionTabDto
DELETE /api/admin/instructions/tabs/{id} admin 204 No Content

PUT /api/admin/instructions/intro — get-or-create (строка одна на всю систему; если её ещё нет, создаётся, иначе обновляется на месте). GET /api/instructions/intro никогда не 404-ит — если строка ещё не создана, отдаёт { id: "00000000-0000-0000-0000-000000000000", body: "", updatedAt: <MinValue> }, чтобы публичная страница не падала. Вкладки — обычный CRUD без статуса черновик/опубликовано, как у NewsPostDto.

News — лента новостей

Метод Путь Роль Тело запроса Тело ответа
GET /api/news user query: page, pageSize PagedList<NewsPostDto>
GET /api/admin/news admin query: page, pageSize PagedList<NewsPostDto>
POST /api/admin/news admin { title, body } NewsPostDto
PUT /api/admin/news/{id} admin { title, body } NewsPostDto
DELETE /api/admin/news/{id} admin 204 No Content

Нет черновиков/отложенной публикации — POST сразу видна всем аутентифицированным пользователям и триггерит SignalR-событие newsPublished (см. ниже). NewsPostDto: { id, title, body, createdAt, updatedAt }.

Activation (пользователь)

Метод Путь Роль Тело запроса Тело ответа
GET /api/activation/status user { isActivated, pendingRequest: { id, comment, createdAt } | null }
POST /api/activation/request user { comment } (обязателен, ≤500) { id, comment, createdAt }

Billing (пользователь)

Группа /api/billing, RequireAuthorization() + IRequiresActivation. Доступна независимо от роли — GET /status сам сообщает billingEnabled=false, если биллинг для роли пользователя не включён.

Метод Путь Тело запроса Тело ответа
GET /api/billing/status BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto | null }. PaymentRequestDto теперь несёт kind (Subscription/PlanChangeTopUp) и period: PaymentPeriod | null (null для PlanChangeTopUp — см. domain-model.md#planchangetopup). Если у пользователя одновременно активны заявки обоих Kind, видна только одна (см. известное ограничение там же)
POST /api/billing/requests { period } (Quarter/HalfYear/Year) PaymentRequestDto (Kind.Subscription; 409 Billing.ActiveRequestExists, если уже есть активная заявка того же Kind — активный PlanChangeTopUp не блокирует; 409 Billing.UnlimitedRoleNotSupported при ConfigQuota=-1 (безлимит — только у admin); 500 Billing.PricingNotConfigured, если ставка для периода не задана)
POST /api/billing/requests/{id}/cancel 204 No Content (только из AwaitingPayment)
POST /api/billing/requests/{id}/mark-paid 204 No Content (AwaitingPayment → AwaitingConfirmation, уведомляет админов в Telegram)
POST /api/billing/requests/{id}/send-requisites 204 No Content (дублирует реквизиты в свой Telegram; 409 Telegram.NotLinked, если Telegram не привязан)

Support (пользователь)

Группа /api/support, RequireAuthorization() + IRequiresActivation (кроме GET /attachments/{id}, который тоже требует активации, но не привязан к типу тикета). Создание баг-репорта и добавление комментария — multipart/form-data (вложения), остальное — JSON.

Метод Путь Тело запроса Тело ответа
GET /api/support/pricing PricingSettingsDto — та же цена (включая скидочную лесенку discountTiers), что и /api/admin/pricing, для справки на странице смены тарифа (/plan, см. Plans ниже)
GET /api/support/tickets query: type?, status?, page=1, pageSize=20 PagedList<TicketSummaryDto> (только свои)
GET /api/support/tickets/{id} TicketDetailDto (404, если не свой)
POST /api/support/tickets/bug-reports multipart: message + files[] (до 5, изображения до 5 МБ) TicketDetailDto
POST /api/support/tickets/extension-requests { requestedDays, justification } TicketDetailDto (403 Billing.NotEnabled, если роль не billing; 409 Support.ExtensionRequestAlreadyPending)
POST /api/support/tickets/{id}/comments multipart: body + files[] TicketCommentDto
POST /api/support/tickets/{id}/reopen 204 No Content (только владелец, только из Resolved)
GET /api/support/attachments/{id} бинарный поток с Content-Type вложения

TicketSummaryDto: { id, userId, userName, type, status, createdAt, lastActivityAt } — один DTO для своего и админского списков. TicketDetailDto добавляет requestedDays, comments: TicketCommentDto[]. TicketCommentDto: { id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }.

Заявка при существующем открытом запросе на продление → 409 Support.ExtensionRequestAlreadyPending. POST …/comments на Closed-тикете → 409 Support.TicketClosed. Вложения отдаются не статикой — <img src> не может передать Authorization-заголовок, фронт качает их как Blob через fetch и рендерит Object URL.

Admin — Support

Группа /api/admin/support, RequireAuthorization(RoleNames.Admin) (активация не проверяется — сеяный админ активирован всегда).

Метод Путь Тело запроса Тело ответа
GET /api/admin/support/tickets query: type?, status?, page, pageSize PagedList<TicketSummaryDto> (все пользователи)
GET /api/admin/support/tickets/{id} TicketDetailDto
POST /api/admin/support/tickets/{id}/comments multipart: body + files[] TicketCommentDto
POST /api/admin/support/tickets/{id}/resolve 204 No Content (только BugReport, только из Open)
POST /api/admin/support/tickets/{id}/close 204 No Content (только BugReport, финал)
POST /api/admin/support/tickets/{id}/approve-extension 204 No Content (только ExtensionRequest/Open; продлевает BillingPaidUntil на RequestedDays)
POST /api/admin/support/tickets/{id}/reject-extension { reason? } 204 No Content (только ExtensionRequest; reason уходит комментарием)

Обработать собственный тикет админу можно — resolve/close/approve-extension/reject-extension владением тикета не ограничены (единственный админ иначе не смог бы закрыть свой же тикет).

approve-extension/reject-extension — единственный способ решить заявку на продление (нельзя одобрить через resolve). То же самое администратор может сделать из Telegram, не заходя на сайт — инлайн-кнопки на уведомлении о заявке (см. telegram-bot.md); для баг-репортов в Telegram только кнопка-ссылка на /admin/support?ticket={id} — переписка и вложения только на сайте (отдельного роута на конкретный тикет нет, ?ticket= открывает диалог поверх списка).

Admin — Maintenance

Группа /api/admin/maintenance, RequireAuthorization(RoleNames.Admin). Вкладка «Обслуживание» — разовые операции подчистки, задумана расширяемой (следующие кандидаты: очистка старых новостей и т.п.).

Метод Путь Тело запроса Тело ответа
DELETE /api/admin/maintenance/tickets/closed { deletedCount } — удаляет все тикеты в статусе Closed вместе с комментариями и вложениями (файлы стираются с диска через IFileStorage.DeleteAsync)
DELETE /api/admin/maintenance/audit-logs query: olderThanDays (13650) { deletedCount } — удаляет записи AuditLog старше olderThanDays дней
DELETE /api/admin/maintenance/apps/disabled { deletedCount } — удаляет все ClientApp с IsEnabled = false
DELETE /api/admin/maintenance/factory-reset 204 No Content — полный сброс панели к состоянию свежего деплоя

Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. SupportTicket), поэтому хендлер удаляет вручную в порядке вложения → комментарии → тикеты.

Очистка аудита пишет собственную запись AuditLogsCleanedUp уже после выборки старых записей — её CreatedAt позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет осознанно — AuditLog в проекте append-only, доступна только очистка по возрасту.

factory-reset — самая деструктивная операция панели, на фронте спрятана под спойлер («Опасная зона») и требует ввести фразу-подтверждение в диалоге (не просто confirm()). Удаляет: всех пользователей кроме текущего админа, все VpnConfig/TrafficSample (конфиги сначала best-effort отзываются на нодах через IXuiPanelGateway.RemoveClientAsync — недоступная нода не блокирует сброс), все Node/Inbound, тикеты с перепиской/вложениями (+файлы), NewsPost, InstructionIntro/ InstructionTab, весь AuditLog, все кастомные роли (AppRole.IsSystem == false) и ClientApp — каталог приложений и вводный текст инструкций затем пересеиваются дефолтными значениями через IClientAppCatalogSeeder/IInstructionIntroSeeder (те же сервисы, что использует DbInitializer при первом старте); вкладки инструкций дефолтами не пересеиваются, как и новости. Не атомарно целиком (несколько SaveChangesAsync внутри хендлера, как и в DeleteUserCommandHandler) — при сбое посередине возможно частичное состояние, компенсации нет, это осознанный компромисс для редкой ручной админской операции. Финальная запись FactoryReset в аудит добавляется уже после очистки самого журнала.

Admin — Activation, Roles

Метод Путь Роль Тело запроса Тело ответа
GET /api/admin/activation-requests admin query: statusFilter?, page=1, pageSize=20 PagedList<ActivationRequestAdminDto>
POST /api/admin/activation-requests/{id}/approve admin 204 No Content
POST /api/admin/activation-requests/{id}/reject admin { reason? } 204 No Content
GET /api/admin/roles admin RoleDto[]
POST /api/admin/roles admin { name, maxIpLimit, billingEnabled } RoleDto (400, если billingEnabled=true для name="admin")
PUT /api/admin/roles/{id} admin { maxIpLimit, billingEnabled } RoleDto (то же ограничение на admin; включение billingEnabled ретроактивно выдаёт грейс-период уже назначенным пользователям без PaidUntil)
DELETE /api/admin/roles/{id} admin 204 No Content (системные admin/user удалить нельзя)
PATCH /api/admin/users/{id}/role admin { roleId } 204 No Content (409 Roles.CannotRemoveLastAdmin, если у цели сейчас admin, новая роль другая, и это единственный админ; см. domain-model.md#approle — переход на/с admin автоматически выдаёт/сбрасывает безлимитную квоту)
PATCH /api/admin/users/{id}/plan admin { planId? | customConfigCount? } 204 No Content — прямой оверрайд квоты конфигов пользователя (ровно одно из полей); в отличие от POST /api/plans/change — без пикера конфигов на понижение (грандфазеринг) и без доплаты; customConfigCount = -1 (безлимит) — 409 Plans.UnlimitedOnlyForAdmin, если текущая роль цели не admin
GET /api/admin/pricing admin PricingSettingsDto (глобальная справочная цена за конфиг в месяц + скидочная лесенка discountTiers: { minConfigs, discountPercent }[], одна на весь сервис — не per-роль/тариф)
PUT /api/admin/pricing admin { pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear?, discountTiers: { minConfigs, discountPercent }[] } PricingSettingsDto (400, если итог более длинного тарифа дешевле итога более короткого, либо discountTiers не уникальны/не прогрессивны — см. domain-model.md#pricingdiscounttier). discountTiers при сохранении полностью заменяет прежний набор

Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через approve/reject над ActivationRequest.

Plans (пользователь) — самостоятельная смена тарифа

Группа /api/plans, RequireAuthorization() + IRequiresActivation. Полная модель — см. domain-model.md.

Метод Путь Тело запроса Тело ответа
GET /api/plans PlanDto[] ({ id, name, configCount }, только isEnabled == true, сортировка sortOrder asc)
GET /api/plans/status MyPlanStatusDto { configQuota, planId, activeConfigCount, billingEnabled, billingPaidUntil }
POST /api/plans/change { planId? | customConfigCount?, configIdsToRevoke: Guid[] } ChangePlanResultDto { configQuota, planId, topUpAmount: int? }

POST /api/plans/change — ровно одно из planId/customConfigCount (иначе 400); квота меняется сразу, без подтверждения админом. Если новое количество меньше текущего числа конфигов, всё ещё занимающих квоту (Active и Expired — приостановленные за неуплату тоже считаются, иначе их можно было бы молча вернуть сверх новой квоты следующей оплатой) — configIdsToRevoke обязателен и должен содержать ровно (это число − новое количество) id, иначе 400 Plans.MustSelectConfigsToRevoke (фронт по этой ошибке показывает пикер конфигов, включая приостановленные). Если у роли пользователя включён биллинг, увеличение тарифа при активном BillingPaidUntil создаёт PaymentRequest(Kind.PlanChangeTopUp) на разницу в цене — topUpAmount в ответе ненулевой, см. domain-model.md. customConfigCount ограничен [Plans__MinCustomConfigCount, Plans__MaxCustomConfigCount] (по умолчанию [3, 50]) — иначе 400; -1 (безлимит) недоступен через самообслуживание в принципе (вне допустимого диапазона). Тариф выключен/не найден → 404 Plans.NotFound / 400 Plans.Disabled.

Admin — Plans

Группа /api/admin/plans, RequireAuthorization(RoleNames.Admin). Точное зеркало /api/admin/apps.

Метод Путь Тело запроса Тело ответа
GET /api/admin/plans AdminPlanDto[] (вкл. выключенные, { id, name, configCount, sortOrder, isEnabled })
POST /api/admin/plans { name, configCount, sortOrder } AdminPlanDto
PUT /api/admin/plans/{id} { name, configCount, sortOrder, isEnabled } AdminPlanDto
DELETE /api/admin/plans/{id} 204 No Content

ConfigCount — только положительное число (каталожные тарифы не поддерживают безлимит; безлимит доступен исключительно роли admin через AppUser.ConfigQuota=-1, вне каталога).

Admin — Billing

Метод Путь Роль Тело запроса Тело ответа
GET /api/admin/billing/settings admin BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }
PUT /api/admin/billing/settings admin { requisitesText, graceDays, defaultBillingEnabledForNewRoles } BillingSettingsDto
GET /api/admin/billing/requests admin query: status?, kind?, search?, page=1, pageSize=20 (search — по имени пользователя, резолвится до пагинации) PagedList<AdminPaymentRequestDto> (включает userName, kind, period: PaymentPeriod | null)
POST /api/admin/billing/requests/{id}/confirm admin 204 No Content (для Kind.Subscription продлевает BillingPaidUntil и возвращает приостановленные конфиги в Active; для Kind.PlanChangeTopUp — только помечает Confirmed, BillingPaidUntil не трогает, см. domain-model.md#planchangetopup)
POST /api/admin/billing/requests/{id}/reject admin { reason? } 204 No Content
POST /api/admin/billing/gift admin { userId, days } 204 No Content (продлевает BillingPaidUntil на days от max(текущий, сейчас), возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; 403 Billing.NotEnabled, если роль пользователя не billing)

То же подтверждение/отклонение доступно из Telegram, не заходя на сайт — инлайн-кнопки на уведомлении о заявке (pay:approve:{id}/pay:reject:{id}, см. telegram-bot.md). Гифт — только на сайте, в боте не решается.

Admin — Nodes

Метод Путь Роль Тело запроса Тело ответа
GET /api/admin/nodes admin NodeDto[]
POST /api/admin/nodes admin { name, baseAddress, username, password, location? } NodeDto
PUT /api/admin/nodes/{id} admin { name, baseAddress, location?, isEnabled, notifyOnStatusChange, username?, password? } NodeDto
DELETE /api/admin/nodes/{id} admin 204 No Content
POST /api/admin/nodes/{id}/sync admin { inboundsSynced, status }
POST /api/admin/nodes/{id}/probe admin { isReachable, errorMessage, status }

DELETE /api/admin/nodes/{id} удаляет её инбаунды каскадно без проверки существующих конфигов на них — известный пробел (см. tech-stack.md), а не осознанная защита. username/password в PUT — оба опциональны; креденшлы меняются, только если заданы оба. baseAddress в PUT обязателен (в отличие от username/password) — форма всегда шлёт текущее значение; при реальном изменении бэкенд валидирует новый адрес через IXuiPanelGateway.ValidateBaseAddress и инвалидирует закэшированный per-node клиент гейтвея (как и при смене креденшлов).

Admin — Inbounds

Метод Путь Роль Тело запроса Тело ответа
GET /api/admin/inbounds admin query: nodeId? InboundDto[]
PUT /api/admin/inbounds/{id}/publish admin { isPublished, displayName?, allowedRoleIds? } InboundDto
DELETE /api/admin/inbounds/{id} admin только для IsAvailable=false, иначе Inbounds.StillAvailable 204

Admin — Users & Stats

Метод Путь Роль Тело запроса Тело ответа
GET /api/admin/users admin query: page, pageSize, search?, roleId?, isActivated?, isBlocked?, billingExpired? PagedList<UserSummaryDto>
PATCH /api/admin/users/{id}/block admin 204 No Content
PATCH /api/admin/users/{id}/unblock admin 204 No Content
POST /api/admin/users/{id}/reset-password admin { newPassword } 204 No Content
DELETE /api/admin/users/{id} admin 204 No Content (отзывает все конфиги пользователя в 3x-ui, затем удаляет учётку; себя удалить нельзя)
GET /api/admin/users/{id}/configs admin VpnConfigDto[]
GET /api/admin/configs admin query: page, pageSize, search?, status?, protocol?, nodeId? PagedList<AdminVpnConfigDto>
DELETE /api/admin/configs/{id} admin 204 No Content (принудительный отзыв любого конфига)
GET /api/admin/stats admin StatsDto
GET /api/admin/audit admin query: page, pageSize, source?, targetType?, action? (action — подстрока) PagedList<AuditLogDto>

AdminVpnConfigDto — глобальный список конфигов для админа (не скоупится одним пользователем, в отличие от VpnConfigDto): { id, userId, userName, label, clientEmail, protocol, location, nodeName, usedUpBytes, usedDownBytes, expiresAt, status, createdAt }. search матчится по clientEmail/label.

Блокировка/разблокировка — два отдельных эндпоинта без тела, не один переключатель isBlocked. StatsDto: { totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes, totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes } — считается на лету при запросе, не кэшируется.

Public — Subscription

Метод Путь Роль Ответ
GET /sub/{token} text/plain, base64 от списка connection strings, \n-разделены

Вне /api (публичный эндпоинт для VPN-клиентов), под тем же rate-limit'ом, что и /api/auth/*. Токен — либо AppUser.SubscriptionToken (все активные конфиги юзера), либо VpnConfig.SubscriptionToken (один конфиг); пробуются по очереди, первый успешный — в ответе. Неизвестный/погашенный токен → 404. Заголовки ответа: Subscription-Userinfo: upload=<up>; download=<down>; total=<up+down>; expire=<unix|0> и Profile-Update-Interval: 12 — их читают клиенты (v2rayN/Nekoray и т.п.), чтобы показать остаток.

SignalR — Hub /hubs/panel

Авторизация — тем же JWT. Группы: user:{userId} (личные события), admins (админам).

Server → Client

Событие Payload Кому
configTrafficUpdated { configId, usedUpBytes, usedDownBytes } владельцу
configStatusChanged { configId, status } владельцу
nodeStatusChanged { nodeId, status, lastSyncAt } admins
activationRequested { requestId, userId, userName, comment, createdAt } admins
userActivated { userId } владельцу
newsPublished { id, title, createdAt } все (broadcast)
ticketCreated { ticketId, userId, userName, type } admins
ticketUpdated { ticketId } владельцу
billingStatusChanged { userId } владельцу

billingStatusChanged — безадресный пинг без данных (см. BillingConfigResumer в domain-model.md): клиент в ответ инвалидирует my-billing-status, а не читает payload.

Client → Server

Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по UserId из JWT (плюс admins, если роль админская).

Коды ошибок

Код Когда
400 Ошибка валидации (FluentValidation, не на все команды — см. backend-conventions.md)
401 Нет/просрочен/невалиден access-токен
403 Нет прав по роли, либо Auth.NotActivated, либо Configs.BillingRequired (просрочена оплата)
404 Ресурс не найден
409 Конфликт домена: Configs.QuotaExceeded, дубликат имени пользователя при регистрации, уже есть Pending-запрос активации, Support.ExtensionRequestAlreadyPending, Support.TicketClosed, Billing.ActiveRequestExists, Billing.UnlimitedRoleNotSupported, Plans.MustSelectConfigsToRevoke, Plans.UnlimitedOnlyForAdmin, Telegram.NotLinked
422 Прочие управляемые ошибки, не подошедшие под коды выше
429 Rate limit (/api/auth/*, /api/auth/telegram/*, /sub/{token})
500 Необработанное исключение (перехватывается UseExceptionHandler(), тело без деталей)

502/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится Result.Failure и маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.