mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-05-05 04:16:17 +00:00
Merge pull request #480 from Fr1ngg/bedolaga/update-api-bot-for-web-admin-integration
Document and type admin web API integration
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
|
||||
@@ -108,6 +108,10 @@ docker compose logs
|
||||
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
|
||||
### 🌐 Интеграция веб-админки
|
||||
|
||||
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|
||||
@@ -8,6 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.config import (
|
||||
SettingCategoryRef,
|
||||
SettingCategorySummary,
|
||||
SettingChoice,
|
||||
SettingDefinition,
|
||||
SettingUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -59,55 +66,55 @@ def _coerce_value(key: str, value: Any) -> Any:
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize_definition(definition, include_choices: bool = True) -> dict[str, Any]:
|
||||
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
|
||||
current = bot_configuration_service.get_current_value(definition.key)
|
||||
original = bot_configuration_service.get_original_value(definition.key)
|
||||
has_override = bot_configuration_service.has_override(definition.key)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"key": definition.key,
|
||||
"name": definition.display_name,
|
||||
"category": {
|
||||
"key": definition.category_key,
|
||||
"label": definition.category_label,
|
||||
},
|
||||
"type": definition.type_label,
|
||||
"is_optional": definition.is_optional,
|
||||
"current": current,
|
||||
"original": original,
|
||||
"has_override": has_override,
|
||||
}
|
||||
|
||||
choices: list[SettingChoice] = []
|
||||
if include_choices:
|
||||
choices = [
|
||||
{
|
||||
"value": option.value,
|
||||
"label": option.label,
|
||||
"description": option.description,
|
||||
}
|
||||
SettingChoice(
|
||||
value=option.value,
|
||||
label=option.label,
|
||||
description=option.description,
|
||||
)
|
||||
for option in bot_configuration_service.get_choice_options(definition.key)
|
||||
]
|
||||
if choices:
|
||||
payload["choices"] = choices
|
||||
|
||||
return payload
|
||||
return SettingDefinition(
|
||||
key=definition.key,
|
||||
name=definition.display_name,
|
||||
category=SettingCategoryRef(
|
||||
key=definition.category_key,
|
||||
label=definition.category_label,
|
||||
),
|
||||
type=definition.type_label,
|
||||
is_optional=definition.is_optional,
|
||||
current=current,
|
||||
original=original,
|
||||
has_override=has_override,
|
||||
choices=choices,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories(_: object = Depends(require_api_token)) -> list[dict[str, Any]]:
|
||||
@router.get("/categories", response_model=list[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
_: object = Depends(require_api_token),
|
||||
) -> list[SettingCategorySummary]:
|
||||
categories = bot_configuration_service.get_categories()
|
||||
return [
|
||||
{"key": key, "label": label, "items": count}
|
||||
SettingCategorySummary(key=key, label=label, items=count)
|
||||
for key, label, count in categories
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("", response_model=list[SettingDefinition])
|
||||
async def list_settings(
|
||||
_: object = Depends(require_api_token),
|
||||
category: Optional[str] = Query(default=None, alias="category_key"),
|
||||
) -> list[dict[str, Any]]:
|
||||
items = []
|
||||
) -> list[SettingDefinition]:
|
||||
items: list[SettingDefinition] = []
|
||||
if category:
|
||||
definitions = bot_configuration_service.get_settings_for_category(category)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
@@ -120,11 +127,11 @@ async def list_settings(
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{key}")
|
||||
@router.get("/{key}", response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
_: object = Depends(require_api_token),
|
||||
) -> dict[str, Any]:
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error: # pragma: no cover - защита от некорректного ключа
|
||||
@@ -133,34 +140,31 @@ async def get_setting(
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.put("/{key}")
|
||||
@router.put("/{key}", response_model=SettingDefinition)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: dict[str, Any],
|
||||
payload: SettingUpdateRequest,
|
||||
_: object = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, Any]:
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
if "value" not in payload:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing value")
|
||||
|
||||
value = _coerce_value(key, payload["value"])
|
||||
value = _coerce_value(key, payload.value)
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
await db.commit()
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
@router.delete("/{key}", response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
_: object = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, Any]:
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
|
||||
@@ -6,20 +6,21 @@ from app.config import settings
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
from ..schemas.health import HealthCheckResponse, HealthFeatureFlags
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", tags=["health"])
|
||||
async def health_check(_: object = Depends(require_api_token)) -> dict[str, object]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": settings.WEB_API_VERSION,
|
||||
"bot_version": version_service.current_version,
|
||||
"features": {
|
||||
"monitoring": settings.MONITORING_INTERVAL > 0,
|
||||
"maintenance": True,
|
||||
"reporting": True,
|
||||
"webhooks": bool(settings.WEBHOOK_URL),
|
||||
},
|
||||
}
|
||||
@router.get("/health", tags=["health"], response_model=HealthCheckResponse)
|
||||
async def health_check(_: object = Depends(require_api_token)) -> HealthCheckResponse:
|
||||
return HealthCheckResponse(
|
||||
status="ok",
|
||||
api_version=settings.WEB_API_VERSION,
|
||||
bot_version=version_service.current_version,
|
||||
features=HealthFeatureFlags(
|
||||
monitoring=settings.MONITORING_INTERVAL > 0,
|
||||
maintenance=True,
|
||||
reporting=True,
|
||||
webhooks=bool(settings.WEBHOOK_URL),
|
||||
),
|
||||
)
|
||||
|
||||
58
app/webapi/schemas/config.py
Normal file
58
app/webapi/schemas/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SettingCategorySummary(BaseModel):
|
||||
"""Краткое описание категории настройки."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
items: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingCategoryRef(BaseModel):
|
||||
"""Ссылка на категорию, к которой относится настройка."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingChoice(BaseModel):
|
||||
"""Вариант значения для настройки с выбором."""
|
||||
|
||||
value: Any
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingDefinition(BaseModel):
|
||||
"""Полное описание настройки и её текущего состояния."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
category: SettingCategoryRef
|
||||
type: str
|
||||
is_optional: bool
|
||||
current: Any | None = Field(default=None)
|
||||
original: Any | None = Field(default=None)
|
||||
has_override: bool
|
||||
choices: list[SettingChoice] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingUpdateRequest(BaseModel):
|
||||
"""Запрос на обновление значения настройки."""
|
||||
|
||||
value: Any
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
25
app/webapi/schemas/health.py
Normal file
25
app/webapi/schemas/health.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class HealthFeatureFlags(BaseModel):
|
||||
"""Флаги доступности функций административного API."""
|
||||
|
||||
monitoring: bool
|
||||
maintenance: bool
|
||||
reporting: bool
|
||||
webhooks: bool
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
"""Ответ на health-check административного API."""
|
||||
|
||||
status: str
|
||||
api_version: str
|
||||
bot_version: str | None
|
||||
features: HealthFeatureFlags
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
155
docs/web-admin-integration.md
Normal file
155
docs/web-admin-integration.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Интеграция веб-админки
|
||||
|
||||
Этот документ описывает запуск встроенного административного веб-API бота и типовой сценарий интеграции c внешней веб-админкой.
|
||||
API разворачивается вместе с ботом, использует FastAPI и защищено токенами доступа.
|
||||
|
||||
## 1. Обзор архитектуры
|
||||
|
||||
- Веб-API запускается в том же процессе, что и бот, через встроенный `uvicorn` сервер.
|
||||
- Авторизация выполняется по токену: `X-API-Key` или `Authorization: Bearer <token>`.
|
||||
- Все эндпоинты работают поверх HTTPS/HTTP и возвращают структуры в формате JSON.
|
||||
- Встроенный механизм миграций создаёт таблицу `web_api_tokens` и бутстрап-токен, если указан в конфигурации.
|
||||
|
||||
## 2. Настройка окружения
|
||||
|
||||
Добавьте переменные в `.env` (или другую систему конфигурации):
|
||||
|
||||
| Переменная | Назначение | Значение по умолчанию / пример |
|
||||
|------------|------------|---------------------------------|
|
||||
| `WEB_API_ENABLED` | Включает веб-API. | `true`
|
||||
| `WEB_API_HOST` | IP/hostname, на котором слушает API. | `0.0.0.0`
|
||||
| `WEB_API_PORT` | Порт веб-API. | `8080`
|
||||
| `WEB_API_ALLOWED_ORIGINS` | Список доменов для CORS, через запятую. `*` разрешит всё. | `https://admin.example.com`
|
||||
| `WEB_API_DOCS_ENABLED` | Включить `/docs` и `/openapi.json`. В проде лучше `false`. | `false`
|
||||
| `WEB_API_WORKERS` | Количество воркеров uvicorn. В embed-режиме всегда приводится к `1`. | `1`
|
||||
| `WEB_API_REQUEST_LOGGING` | Логировать каждый запрос API. | `true`
|
||||
| `WEB_API_DEFAULT_TOKEN` | Бутстрап-токен, который будет создан при миграции. | `super-secret-token`
|
||||
| `WEB_API_DEFAULT_TOKEN_NAME` | Отображаемое имя созданного токена. | `Bootstrap Token`
|
||||
| `WEB_API_TOKEN_HASH_ALGORITHM` | Алгоритм хеширования токенов (`sha256`, `sha512`, ...). | `sha256`
|
||||
|
||||
> ⚠️ Если вы храните конфигурацию в Kubernetes/Ansible/других системах — не забудьте обновить секреты, чтобы бот видел эти переменные.
|
||||
|
||||
## 3. Подготовка базы данных
|
||||
|
||||
1. Убедитесь, что настройки БД верны (`DATABASE_URL` или параметры PostgreSQL/SQLite).
|
||||
2. При старте бота автоматически запускается универсальная миграция `run_universal_migration`, которая:
|
||||
- создаёт таблицу `web_api_tokens`, если её нет;
|
||||
- активирует токен из `WEB_API_DEFAULT_TOKEN`, если он задан.
|
||||
3. Если нужно запустить миграцию вручную, выполните:
|
||||
|
||||
```bash
|
||||
python -c "import asyncio; from app.database.universal_migration import run_universal_migration; asyncio.run(run_universal_migration())"
|
||||
```
|
||||
|
||||
Или просто запустите `python main.py` — бот выполнит ту же процедуру автоматически.
|
||||
|
||||
## 4. Запуск веб-API
|
||||
|
||||
```bash
|
||||
# Создаём .env и включаем веб-API
|
||||
cp .env.example .env
|
||||
nano .env # проставьте WEB_API_* переменные и BOT_TOKEN
|
||||
|
||||
# Запускаем бота (локально)
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
В Docker достаточно пробросить порт `WEB_API_PORT` из контейнера бота. После запуска API будет доступно по адресу `http://<WEB_API_HOST>:<WEB_API_PORT>`.
|
||||
|
||||
## 5. Аутентификация и токены
|
||||
|
||||
- Первый токен удобно задать через `WEB_API_DEFAULT_TOKEN`. Он появится в таблице при запуске миграции.
|
||||
- Для управления токенами используйте эндпоинты `/tokens`:
|
||||
- `GET /tokens` — список токенов.
|
||||
- `POST /tokens` — создать новый токен. Возвращает открытое значение один раз.
|
||||
- `POST /tokens/{id}/revoke` и `/activate` — управление статусом.
|
||||
- `DELETE /tokens/{id}` — удаление.
|
||||
- Заголовок авторизации можно передавать двумя способами:
|
||||
|
||||
```http
|
||||
X-API-Key: <ваш_токен>
|
||||
# или
|
||||
Authorization: Bearer <ваш_токен>
|
||||
```
|
||||
|
||||
Пример запроса на создание токена:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
-H "X-API-Key: super-secret-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Web admin", "description": "UI token"}'
|
||||
```
|
||||
|
||||
## 6. Основные эндпоинты
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|-------|------|------------|
|
||||
| `GET` | `/health` | Статус API, версия бота, флаги включённых сервисов.
|
||||
| `GET` | `/stats/overview` | Сводная статистика по пользователям, подпискам, платежам и тикетам.
|
||||
| `GET` | `/settings/categories` | Категории системных настроек.
|
||||
| `GET` | `/settings` | Полный список настроек (с текущими и дефолтными значениями).
|
||||
| `GET` | `/settings/{key}` | Получить одну настройку.
|
||||
| `PUT` | `/settings/{key}` | Обновить значение настройки.
|
||||
| `DELETE` | `/settings/{key}` | Сбросить настройку к значению по умолчанию.
|
||||
| `GET` | `/users` | Список пользователей с фильтрами и пагинацией.
|
||||
| `GET` | `/users/{id}` | Детали пользователя.
|
||||
| `POST` | `/users` | Создать пользователя (например, для ручной выдачи доступа).
|
||||
| `PATCH` | `/users/{id}` | Обновить профиль пользователя или статус.
|
||||
| `POST` | `/users/{id}/balance` | Корректировка баланса с созданием транзакции.
|
||||
| `GET` | `/subscriptions` | Список подписок с фильтрами.
|
||||
| `POST` | `/subscriptions` | Создать триальную или платную подписку.
|
||||
| `POST` | `/subscriptions/{id}/extend` | Продлить подписку на N дней.
|
||||
| `POST` | `/subscriptions/{id}/traffic` | Добавить трафик (ГБ).
|
||||
| `POST` | `/subscriptions/{id}/devices` | Добавить устройства.
|
||||
| `POST` | `/subscriptions/{id}/squads` | Привязать сквад.
|
||||
| `DELETE` | `/subscriptions/{id}/squads/{uuid}` | Удалить сквад.
|
||||
| `GET` | `/transactions` | История транзакций.
|
||||
| `GET` | `/tickets` | Список тикетов поддержки.
|
||||
| `GET` | `/tickets/{id}` | Тикет с перепиской.
|
||||
| `POST` | `/tickets/{id}/status` | Изменить статус тикета.
|
||||
| `POST` | `/tickets/{id}/priority` | Изменить приоритет.
|
||||
| `POST` | `/tickets/{id}/reply-block` | Заблокировать ответы пользователя.
|
||||
| `DELETE` | `/tickets/{id}/reply-block` | Снять блокировку.
|
||||
| `GET` | `/promo-groups` | Список промо-групп с количеством участников.
|
||||
| `POST` | `/promo-groups` | Создать промо-группу.
|
||||
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
|
||||
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
|
||||
| `GET` | `/tokens` | Управление токенами доступа.
|
||||
|
||||
> Все списковые эндпоинты поддерживают пагинацию (`limit`, `offset`) и фильтры, описанные в OpenAPI спецификации. Если `WEB_API_DOCS_ENABLED=true`, документация доступна по `/docs`. В ответах `/settings` поле `choices` всегда массив: пустой список означает отсутствие предопределённых значений.
|
||||
|
||||
## 7. Сценарий интеграции веб-админки
|
||||
|
||||
1. **Health-check** — перед авторизацией UI вызывает `GET /health`, чтобы отобразить статус и версию бота.
|
||||
2. **Настройки UI** — подгружает категории через `GET /settings/categories`, далее выводит форму со значениями из `GET /settings`.
|
||||
3. **Статистика дашборда** — `GET /stats/overview` для карточек с показателями.
|
||||
4. **Раздел пользователи** — `GET /users` с поиском (`search`), фильтрами по статусу или промо-группе. Для детальной карточки использовать `GET /users/{id}`.
|
||||
5. **Операции с подпиской** — использовать `POST /subscriptions/{id}/...` эндпоинты для продления, выдачи трафика и устройств.
|
||||
6. **Поддержка** — список тикетов (`GET /tickets`), изменение статуса (`POST /tickets/{id}/status`), блокировка ответов (`POST /tickets/{id}/reply-block`).
|
||||
7. **История операций** — `GET /transactions` с фильтрами по пользователю, типу и периоду.
|
||||
|
||||
## 8. CORS, безопасность и логирование
|
||||
|
||||
- Разрешённые домены указываются в `WEB_API_ALLOWED_ORIGINS`. Для нескольких доменов перечислите их через запятую.
|
||||
- Для продакшена рекомендуется отключить публичную документацию (`WEB_API_DOCS_ENABLED=false`).
|
||||
- `WEB_API_REQUEST_LOGGING=true` добавляет middleware, которое логирует метод, путь и статус ответа. Используйте его для аудита или отключите в продакшене, если хватает reverse-proxy логов.
|
||||
- Все токены хранятся в базе в хешированном виде. Не храните открытые значения в коде.
|
||||
|
||||
## 9. Диагностика проблем
|
||||
|
||||
| Симптом | Возможная причина | Что проверить |
|
||||
|---------|------------------|---------------|
|
||||
| 401 Unauthorized | Неверный или просроченный токен. | Пересоздайте токен через `/tokens` и обновите UI. |
|
||||
| 403/404 при работе с настройками | Неверный ключ настройки. | Получите список доступных ключей через `GET /settings`. |
|
||||
| 422 Unprocessable Entity | Неверный тип данных в теле запроса. | Проверьте типы (числа, булевы, строки) и формат JSON. |
|
||||
| API не стартует | Порт занят или неверные переменные окружения. | Проверьте логи контейнера бота и значения `WEB_API_HOST/PORT`. |
|
||||
|
||||
## 10. Рекомендации по эксплуатации
|
||||
|
||||
- Регулярно ревизируйте список активных токенов и отключайте неиспользуемые.
|
||||
- Для внешних админок размещайте API за reverse-proxy (nginx, Caddy, Traefik) с TLS.
|
||||
- Включите мониторинг доступности (например, curl на `/health`) в систему наблюдения.
|
||||
- Обновляйте бота и админку синхронно, чтобы использовать новые поля API.
|
||||
Reference in New Issue
Block a user