diff --git a/.gitignore b/.gitignore index 35826240..b83527b1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ !app-config.json !main.py !requirements.txt +!docs/ +!docs/** # Разрешаем папку app/ и все её содержимое рекурсивно !app/ diff --git a/README.md b/README.md index 748e2e94..6f4f973e 100644 --- a/README.md +++ b/README.md @@ -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). + ### 📊 Статус серверов в главном меню | Переменная | Описание | Пример | diff --git a/app/webapi/routes/config.py b/app/webapi/routes/config.py index 873cdf0f..6d4ec8fa 100644 --- a/app/webapi/routes/config.py +++ b/app/webapi/routes/config.py @@ -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: diff --git a/app/webapi/routes/health.py b/app/webapi/routes/health.py index c720d560..2d459f8d 100644 --- a/app/webapi/routes/health.py +++ b/app/webapi/routes/health.py @@ -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), + ), + ) diff --git a/app/webapi/schemas/config.py b/app/webapi/schemas/config.py new file mode 100644 index 00000000..39aedbad --- /dev/null +++ b/app/webapi/schemas/config.py @@ -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") diff --git a/app/webapi/schemas/health.py b/app/webapi/schemas/health.py new file mode 100644 index 00000000..e3f0c4ab --- /dev/null +++ b/app/webapi/schemas/health.py @@ -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") diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md new file mode 100644 index 00000000..4e922bc2 --- /dev/null +++ b/docs/web-admin-integration.md @@ -0,0 +1,155 @@ +# Интеграция веб-админки + +Этот документ описывает запуск встроенного административного веб-API бота и типовой сценарий интеграции c внешней веб-админкой. +API разворачивается вместе с ботом, использует FastAPI и защищено токенами доступа. + +## 1. Обзор архитектуры + +- Веб-API запускается в том же процессе, что и бот, через встроенный `uvicorn` сервер. +- Авторизация выполняется по токену: `X-API-Key` или `Authorization: Bearer `. +- Все эндпоинты работают поверх 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://:`. + +## 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.