- Introduced `MediaImage` entity to manage images for markdown in instructions and news. - Updated `IAppDbContext` and `AppDbContext` to include `MediaImages` DbSet. - Implemented `DeleteMediaImageFilesAsync` method in `FactoryResetCommandHandler` to remove media images during factory reset. - Added new API endpoints for uploading and retrieving media images, enhancing markdown support. - Updated frontend components to utilize the new `MarkdownEditor` for image uploads in instructions and news. - Enhanced documentation to reflect the new media handling features and API specifications.
444 lines
48 KiB
Markdown
444 lines
48 KiB
Markdown
# 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[], 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](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`, ОС без приложений в ответе отсутствует):
|
||
```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`.
|
||
|
||
## Media — картинки для markdown
|
||
|
||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||
| ----- | -------------------------- | --------- | ------------------------------- | ---------------- |
|
||
| POST | `/api/admin/media/images` | admin | `multipart/form-data`: `file` | `MediaImageDto` |
|
||
| GET | `/api/media/images/{id}` | аноним | — | тело картинки (`Content-Type` как при загрузке) |
|
||
|
||
Картинки вставляются админом в markdown инструкций и новостей. `MediaImageDto`:
|
||
`{ id, fileName, contentType, sizeBytes }` — ссылку клиент строит сам: `/api/media/images/{id}`.
|
||
|
||
Отдача **анонимная**: markdown рендерится обычным `<img>`, который не шлёт `Authorization` (в отличие
|
||
от вложений тикетов, которые клиент качает как blob). Защита — непрозрачный `Guid` в ссылке; в
|
||
картинках инструкций/новостей персональных данных нет. Ответ помечен
|
||
`Cache-Control: public, max-age=31536000, immutable` — содержимое по `Id` неизменно (перезалив даёт новый `Id`).
|
||
|
||
Ограничения загрузки — как у вложений тикетов: ≤5 МБ, `image/jpeg|png|webp|gif`. SVG не поддерживается
|
||
осознанно: картинка открывается по прямой ссылке, а SVG — документ со скриптами. Отдельного экрана
|
||
управления медиа нет; файлы лежат в том же `IFileStorage` (volume) и стираются при factory reset.
|
||
|
||
## 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-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` (1–3650) | `{ 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](domain-model.md#plan--тариф-самообслуживание-квота-конфигов).
|
||
|
||
| Метод | Путь | Тело запроса | Тело ответа |
|
||
| ----- | -------------------- | -------------------------------------------------------------------- | ------------- |
|
||
| 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](domain-model.md#planchangetopup).
|
||
`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](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](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](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.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), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.
|