Files
PnvPanel/docs/api-design.md
T
Leonid Pershin 6dfd51ae7c
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Enhance pricing validation and update related components
- Added validation logic in `UpdatePricingSettingsCommandValidator` to ensure the annual price does not fall below the equivalent quarterly price, preventing potential pricing discrepancies.
- Updated `PricingSettings` model documentation to clarify that both pricing fields represent monthly rates, with calculations for total costs based on the number of months.
- Modified frontend components to reflect the new validation, including error messages when the annual price is cheaper than the quarterly price.
- Adjusted API documentation to accurately describe the pricing structure and validation rules for the pricing endpoints.
2026-07-18 19:56:21 +03:00

352 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`/ответ `/me`**`role` строкой** (одна роль, не массив). Группа `/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}` | — | см. ниже |
`deepLink``null`, если `Telegram:BotToken` не настроен или Bot API недоступен (username бота
панель получает сама через `getMe`, см. [telegram-bot.md](telegram-bot.md#конфигурация)), иначе
`https://t.me/<bot>?start=link_<token>` / `?start=login_<requestId>`. **QR backend не рендерит**
фронт строит QR из `deepLink` сам (`qrcode.react`).
`GET …/login-request/{id}` (поллинг) → варианты ответа:
```json
// ожидание / отклонено / истекло — 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](telegram-bot.md).
## Configs (пользователь)
Группа `/api` (не вложена дальше), `RequireAuthorization()`.
| Метод | Путь | Тело запроса | Тело ответа |
| ------ | --------------------------------- | ------------------------------------ | --------------------------------------- |
| GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` |
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }`**без пагинации**, весь список сразу |
| 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](domain-model.md)). Ссылка подключения **не приходит вместе с созданием** — фронт
запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из
`connectionString`.
Все `/api/configs/*`, `/api/news`, `/api/apps` без активации → `403` (`Auth.NotActivated`, единая
проверка `RequireActivationBehavior`); создание сверх квоты роли → `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`, ОС без приложений в ответе отсутствует):
```json
{
"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? }` | `{ id, comment, createdAt }` |
## Support (пользователь)
Группа `/api/support`, `RequireAuthorization()` + `IRequiresActivation` (кроме `GET /attachments/{id}`,
который тоже требует активации, но не привязан к типу тикета). Создание баг-репорта и добавление
комментария — `multipart/form-data` (вложения), остальное — JSON.
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке |
| 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/role-requests` | `{ existingRoleId? \| (newRoleName, newRoleMaxConfigs, newRoleMaxIpLimit), justification }` | `TicketDetailDto` |
| 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` добавляет `requestedRoleId, requestedRoleName,
proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, comments: TicketCommentDto[]`.
`TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`.
Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403
Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом
запросе на роль → `409 Support.RoleRequestAlreadyPending`. `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` (любой тип, только из `Open`) |
| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) |
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) |
| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) |
Обработать **собственный** тикет админу можно (в т.ч. одобрить свою же заявку на роль) — resolve/close/
reject/approve владением тикета не ограничены. Единственное реальное ограничение — `approve` вернёт
`409 Roles.CannotRemoveLastAdmin` через `ChangeUserRoleAsync`, если заявка (своя или чужая) снимает
`admin` с последнего администратора в системе.
`approve`/`reject` — единственный способ решить заявку на роль (нельзя одобрить через `resolve`).
При одобрении: если заявка на существующую роль — сразу `ChangeUserRoleCommand`-эквивалент; если на
новую — сперва создаётся `AppRole` (`IRoleService.CreateRoleAsync`), затем назначается. То же самое
администратор может сделать **из Telegram, не заходя на сайт** — инлайн-кнопки на уведомлении о
заявке (см. [telegram-bot.md](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, maxConfigs, maxIpLimit }` | `RoleDto` |
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` |
| 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`, новая роль другая, и это единственный админ) |
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) |
| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`409`/`400`, если годовая ставка×12 дешевле квартальной×3) |
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
approve/reject над `ActivationRequest`.
## 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, location?, isEnabled, 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](tech-stack.md)), а не осознанная защита.
`username`/`password` в `PUT` — оба опциональны; креденшлы меняются, только если заданы **оба**.
## Admin — Inbounds
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` |
| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds? }` | `InboundDto` |
## Admin — Users & Stats
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | ---------------------------------------- | ----- | --------------------------- | ------------- |
| GET | `/api/admin/users` | admin | query: `page, pageSize, search?` | `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?` | `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` | `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 }` | владельцу |
### Client → Server
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
`UserId` из JWT (плюс `admins`, если роль админская).
## Коды ошибок
| Код | Когда |
| --- | -------------------------------------------------------------------- |
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
| 401 | Нет/просрочен/невалиден access-токен |
| 403 | Нет прав по роли, либо `Auth.NotActivated` |
| 404 | Ресурс не найден |
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed` |
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
`502`/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится `Result.Failure` и
маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.