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:
Egor
2025-09-26 05:37:28 +03:00
committed by GitHub
7 changed files with 302 additions and 53 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@
!app-config.json
!main.py
!requirements.txt
!docs/
!docs/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/

View File

@@ -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).
### 📊 Статус серверов в главном меню
| Переменная | Описание | Пример |

View File

@@ -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:

View File

@@ -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),
),
)

View 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")

View 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")

View 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.