- Introduced a new pricing field, `PricePerConfigPerHalfYear`, to the `PricingSettings` model, allowing for more flexible pricing options. - Updated the `UpdatePricingSettingsCommand` and its validator to include the new half-year pricing, ensuring proper validation against the quarterly and yearly rates. - Modified the `PricingSettingsDto` and related frontend components to accommodate the new half-year pricing field, including validation logic to prevent pricing discrepancies. - Enhanced API documentation and frontend forms to reflect the updated pricing structure and validation rules.
35 KiB
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), иначе
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[], 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). Ссылка подключения не приходит вместе с созданием — фронт
запрашивает 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, ОС без приложений в ответе отсутствует):
{
"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 только кнопка-ссылка
на /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, 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?, pricePerConfigPerHalfYear?, pricePerConfigPerYear? } |
PricingSettingsDto (400, если итог более длинного тарифа дешевле итога более короткого) |
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
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), а не осознанная защита.
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) |
| 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), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.