Files
PnvPanel/docs/api-design.md
T
Leonid Pershin 33ad98cf62
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Enhance admin endpoints and queries for improved filtering and management
- Updated `ListPaymentRequestsQuery` to include `Kind` and `Search` parameters for better filtering of payment requests.
- Enhanced `ListAuditLogsQuery` to support additional filters: `Source`, `TargetType`, and `Action`, improving audit log retrieval.
- Modified `ListUsersQuery` to accept new filters: `RoleId`, `IsActivated`, `IsBlocked`, and `BillingExpired`, allowing for more granular user management.
- Introduced `DeleteInbound` endpoint to allow deletion of inbounds that are not currently available, enhancing inbound management capabilities.
- Updated frontend API calls to reflect new query parameters and support for additional filtering options in the admin interface.
- Revised API documentation to include new parameters and endpoint functionalities for better clarity and usage guidance.
2026-07-20 10:35:48 +03:00

391 lines
42 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 }` |
## Billing (пользователь)
Группа `/api/billing`, `RequireAuthorization()` + `IRequiresActivation`. Доступна независимо от роли —
`GET /status` сам сообщает `billingEnabled=false`, если биллинг для роли пользователя не включён.
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | -------------------------------------------- | ---------------------- | ------------- |
| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }`. `PaymentRequestDto` теперь несёт `kind` (`Subscription`/`RoleChangeTopUp`) и `period: PaymentPeriod \| null` (`null` для `RoleChangeTopUp` — см. domain-model.md#rolechangetopup). Если у пользователя одновременно активны заявки обоих `Kind`, видна только одна (см. известное ограничение там же) |
| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`Kind.Subscription`; `409 Billing.ActiveRequestExists`, если уже есть активная заявка **того же Kind** — активный `RoleChangeTopUp` не блокирует; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `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/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке |
| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена (включая скидочную лесенку `discountTiers`), что и `/api/admin/pricing`, для справки в диалоге заявки на роль |
| 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/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` добавляет `requestedRoleId, requestedRoleName,
proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, requestedDays, comments: TicketCommentDto[]`.
`TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`.
Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403
Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом
запросе на роль → `409 Support.RoleRequestAlreadyPending`; аналогично для продления →
`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` (любой тип, только из `Open`) |
| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) |
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль сразу; если новая роль дороже старой и у пользователя активен `BillingPaidUntil` — дополнительно создаёт `PaymentRequest(Kind.RoleChangeTopUp)` на разницу в цене, см. domain-model.md#rolechangetopup) |
| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) |
| 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/
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, billingEnabled }` | `RoleDto` (`400`, если `billingEnabled=true` для `name="admin"`) |
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, 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`, новая роль другая, и это единственный админ) |
| 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`.
## 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.RoleChangeTopUp` — только помечает `Confirmed`, `BillingPaidUntil` не трогает, см. domain-model.md#rolechangetopup) |
| 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](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, 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` |
| 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](domain-model.md#синхронизация-панели-3x-ui-со-статусом-оплаты--billingconfigresumer)):
клиент в ответ инвалидирует `my-billing-status`, а не читает payload.
### Client → Server
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
`UserId` из JWT (плюс `admins`, если роль админская).
## Коды ошибок
| Код | Когда |
| --- | -------------------------------------------------------------------- |
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
| 401 | Нет/просрочен/невалиден access-токен |
| 403 | Нет прав по роли, либо `Auth.NotActivated`, либо `Configs.BillingRequired` (просрочена оплата) |
| 404 | Ресурс не найден |
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed`, `Billing.ActiveRequestExists`, `Billing.UnlimitedRoleNotSupported`, `Telegram.NotLinked` |
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
`502`/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится `Result.Failure` и
маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.