diff --git a/README.md b/README.md index 748e2e94..3da58bcf 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,15 @@ LOG_FILE=logs/bot.log DEBUG=false WEBHOOK_URL= WEBHOOK_PATH=/webhook + +# ===== WEB API ===== +WEBAPI_ENABLED=false +WEBAPI_HOST=0.0.0.0 +WEBAPI_PORT=8080 +WEBAPI_ALLOWED_ORIGINS=* +WEBAPI_ALLOWED_IPS= +WEBAPI_DOCS_ENABLED=false +WEBAPI_MASTER_KEY=super-secret-key ``` diff --git a/app/config.py b/app/config.py index 3bd36774..62258b68 100644 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,7 @@ import os import re import html from collections import defaultdict -from datetime import time +from datetime import time, timedelta from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings from pydantic import field_validator, Field @@ -231,10 +231,27 @@ class Settings(BaseSettings): DEBUG: bool = False WEBHOOK_URL: Optional[str] = None WEBHOOK_PATH: str = "/webhook" - + + WEBAPI_ENABLED: bool = False + WEBAPI_HOST: str = "0.0.0.0" + WEBAPI_PORT: int = 8080 + WEBAPI_ROOT_PATH: str = "" + WEBAPI_ALLOWED_ORIGINS: str = "*" + WEBAPI_ALLOWED_IPS: str = "" + WEBAPI_LOG_LEVEL: str = "INFO" + WEBAPI_ACCESS_LOG: bool = False + WEBAPI_DOCS_ENABLED: bool = False + WEBAPI_TITLE: str = "Remnawave Bot Admin API" + WEBAPI_DESCRIPTION: str = ( + "REST API для управления ботом Remnawave и интеграции веб-админки" + ) + WEBAPI_VERSION: str = "1.0.0" + WEBAPI_MASTER_KEY: Optional[str] = None + WEBAPI_TOKEN_TTL_HOURS: int = 0 + APP_CONFIG_PATH: str = "app-config.json" ENABLE_DEEP_LINKS: bool = True - APP_CONFIG_CACHE_TTL: int = 3600 + APP_CONFIG_CACHE_TTL: int = 3600 VERSION_CHECK_ENABLED: bool = True VERSION_CHECK_REPO: str = "fr1ngg/remnawave-bedolaga-telegram-bot" @@ -964,11 +981,36 @@ class Settings(BaseSettings): def is_support_contact_enabled(self) -> bool: return self.get_support_system_mode() in {"contact", "both"} - + + def is_webapi_enabled(self) -> bool: + return bool(self.WEBAPI_ENABLED) + + def get_webapi_allowed_origins(self) -> List[str]: + raw = (self.WEBAPI_ALLOWED_ORIGINS or "").strip() + if not raw or raw == "*": + return ["*"] + return [origin.strip() for origin in raw.split(",") if origin.strip()] + + def get_webapi_allowed_ips(self) -> List[str]: + raw = (self.WEBAPI_ALLOWED_IPS or "").strip() + if not raw: + return [] + return [ip.strip() for ip in raw.split(",") if ip.strip()] + + def get_webapi_token_ttl(self) -> Optional[timedelta]: + try: + hours = int(self.WEBAPI_TOKEN_TTL_HOURS) + except (TypeError, ValueError): + hours = 0 + + if hours <= 0: + return None + return timedelta(hours=hours) + model_config = { "env_file": ".env", "env_file_encoding": "utf-8", - "extra": "ignore" + "extra": "ignore" } diff --git a/app/database/crud/api_token.py b/app/database/crud/api_token.py new file mode 100644 index 00000000..b0f230aa --- /dev/null +++ b/app/database/crud/api_token.py @@ -0,0 +1,166 @@ +import hashlib +import secrets +from datetime import datetime +from typing import Iterable, List, Optional, Union + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import ApiToken + + +UNSET = object() + + +def _normalize_iterable(values: Optional[Iterable[str]]) -> Optional[List[str]]: + if not values: + return None + + normalized: List[str] = [] + for raw in values: + if raw is None: + continue + value = str(raw).strip() + if not value: + continue + if value not in normalized: + normalized.append(value) + return normalized or None + + +TOKEN_LENGTH = 64 +TOKEN_PREFIX_LENGTH = 12 + + +def generate_token(length: int = TOKEN_LENGTH) -> str: + """Генерирует случайный токен.""" + alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def get_token_prefix(token: str, length: int = TOKEN_PREFIX_LENGTH) -> str: + return token[:length] + + +async def create_api_token( + db: AsyncSession, + *, + name: str, + token: str, + description: Optional[str] = None, + permissions: Optional[Iterable[str]] = None, + allowed_ips: Optional[Iterable[str]] = None, + expires_at: Optional[datetime] = None, +) -> ApiToken: + token_hash = hash_token(token) + token_prefix = get_token_prefix(token) + + api_token = ApiToken( + name=name, + token_hash=token_hash, + token_prefix=token_prefix, + description=description, + permissions=_normalize_iterable(permissions), + allowed_ips=_normalize_iterable(allowed_ips), + expires_at=expires_at, + ) + + db.add(api_token) + await db.commit() + await db.refresh(api_token) + return api_token + + +async def list_api_tokens(db: AsyncSession, *, active_only: bool = False) -> List[ApiToken]: + stmt = select(ApiToken) + if active_only: + stmt = stmt.where(ApiToken.is_active.is_(True)) + stmt = stmt.order_by(ApiToken.created_at.desc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def get_api_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[ApiToken]: + result = await db.execute( + select(ApiToken).where(ApiToken.token_hash == token_hash) + ) + return result.scalar_one_or_none() + + +async def get_api_token_by_id(db: AsyncSession, token_id: int) -> Optional[ApiToken]: + result = await db.execute( + select(ApiToken).where(ApiToken.id == token_id) + ) + return result.scalar_one_or_none() + + +async def update_last_used(db: AsyncSession, token: ApiToken) -> None: + token.last_used_at = datetime.utcnow() + await db.commit() + + +async def deactivate_api_token(db: AsyncSession, token: ApiToken) -> ApiToken: + token.is_active = False + token.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(token) + return token + + +async def delete_api_token(db: AsyncSession, token_id: int) -> None: + await db.execute(delete(ApiToken).where(ApiToken.id == token_id)) + await db.commit() + + +async def update_api_token( + db: AsyncSession, + token: ApiToken, + *, + name: Optional[str] = None, + description: Optional[str] = None, + permissions: Union[Iterable[str], object] = UNSET, + allowed_ips: Union[Iterable[str], object] = UNSET, + expires_at: Union[Optional[datetime], object] = UNSET, + is_active: Optional[bool] = None, +) -> ApiToken: + if name is not None: + token.name = name + if description is not None: + token.description = description + if permissions is not UNSET: + token.permissions = ( + _normalize_iterable(permissions) if permissions is not None else None + ) + if allowed_ips is not UNSET: + token.allowed_ips = ( + _normalize_iterable(allowed_ips) if allowed_ips is not None else None + ) + if expires_at is not UNSET: + token.expires_at = expires_at + if is_active is not None: + token.is_active = is_active + + token.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(token) + return token + + +async def purge_expired_tokens(db: AsyncSession) -> int: + now = datetime.utcnow() + result = await db.execute( + delete(ApiToken) + .where(ApiToken.expires_at.is_not(None)) + .where(ApiToken.expires_at < now) + .returning(ApiToken.id) + ) + deleted = result.scalars().all() + if deleted: + await db.commit() + return len(deleted) + await db.rollback() + return 0 diff --git a/app/database/models.py b/app/database/models.py index bc36c94e..6bf33413 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -740,22 +740,53 @@ class Squad(Base): class ServiceRule(Base): __tablename__ = "service_rules" - + id = Column(Integer, primary_key=True, index=True) - + order = Column(Integer, default=0) title = Column(String(255), nullable=False) - + content = Column(Text, nullable=False) - + is_active = Column(Boolean, default=True) - + language = Column(String(5), default="ru") - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) +class ApiToken(Base): + __tablename__ = "api_tokens" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + token_hash = Column(String(128), unique=True, nullable=False) + token_prefix = Column(String(32), nullable=False) + description = Column(Text, nullable=True) + permissions = Column(JSON, nullable=True) + allowed_ips = Column(JSON, nullable=True) + is_active = Column(Boolean, default=True) + expires_at = Column(DateTime, nullable=True) + last_used_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint("token_prefix", name="uq_api_tokens_token_prefix"), + ) + + def is_valid(self) -> bool: + if not self.is_active: + return False + if self.expires_at and self.expires_at < datetime.utcnow(): + return False + return True + + def __repr__(self) -> str: + return f"" + + class SystemSetting(Base): __tablename__ = "system_settings" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index ee1e5cee..4b89b0d0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1886,6 +1886,82 @@ async def create_system_settings_table() -> bool: return False +async def create_api_tokens_table() -> bool: + table_exists = await check_table_exists("api_tokens") + if table_exists: + logger.info("ℹ️ Таблица api_tokens уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + create_sql = """ + CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL UNIQUE, + description TEXT NULL, + permissions TEXT NULL, + allowed_ips TEXT NULL, + is_active BOOLEAN NOT NULL DEFAULT 1, + expires_at DATETIME NULL, + last_used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """ + elif db_type == "postgresql": + create_sql = """ + CREATE TABLE api_tokens ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + token_hash VARCHAR(128) NOT NULL UNIQUE, + token_prefix VARCHAR(32) NOT NULL UNIQUE, + description TEXT NULL, + permissions JSONB NULL, + allowed_ips JSONB NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + expires_at TIMESTAMP NULL, + last_used_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + elif db_type == "mysql": + create_sql = """ + CREATE TABLE api_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + token_hash VARCHAR(128) NOT NULL UNIQUE, + token_prefix VARCHAR(32) NOT NULL UNIQUE, + description TEXT NULL, + permissions JSON NULL, + allowed_ips JSON NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + expires_at DATETIME NULL, + last_used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """ + else: + logger.warning( + f"⚠️ Неизвестный тип базы данных {db_type}, пропускаем создание api_tokens" + ) + return False + + await conn.execute(text(create_sql)) + logger.info("✅ Таблица api_tokens создана") + return True + + except Exception as error: + logger.error(f"Ошибка создания таблицы api_tokens: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -1904,6 +1980,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей system_settings") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ API TOKENS ===") + api_tokens_ready = await create_api_tokens_table() + if api_tokens_ready: + logger.info("✅ Таблица api_tokens готова") + else: + logger.warning("⚠️ Проблемы с таблицей api_tokens") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===") cryptobot_created = await create_cryptobot_payments_table() if cryptobot_created: diff --git a/app/webapi/__init__.py b/app/webapi/__init__.py new file mode 100644 index 00000000..ae25995b --- /dev/null +++ b/app/webapi/__init__.py @@ -0,0 +1,5 @@ +"""Пакет с FastAPI-приложением для веб-админки.""" + +from .server import WebAPIServer, create_app + +__all__ = ["WebAPIServer", "create_app"] diff --git a/app/webapi/dependencies.py b/app/webapi/dependencies.py new file mode 100644 index 00000000..e13343dd --- /dev/null +++ b/app/webapi/dependencies.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Callable, List, Optional + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import APIKeyHeader +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.api_token import ( + get_api_token_by_hash, + hash_token, + update_last_used, +) +from app.database.database import AsyncSessionLocal +from app.database.models import ApiToken + + +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + yield session + + +def _is_ip_allowed(client_ip: Optional[str], allowed_ips: List[str]) -> bool: + if not allowed_ips: + return True + if not client_ip: + return False + return client_ip in allowed_ips + + +async def get_current_token( + request: Request, + api_key: Optional[str] = Depends(api_key_header), + db: AsyncSession = Depends(get_db), +) -> ApiToken: + if not settings.is_webapi_enabled(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Web API отключен", + ) + + if not api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Требуется API ключ", + ) + + token_hash = hash_token(api_key) + token = await get_api_token_by_hash(db, token_hash) + + if not token or not token.is_valid(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Неверный или неактивный API ключ", + ) + + client_ip = request.client.host if request.client else None + global_allowed = settings.get_webapi_allowed_ips() + if global_allowed and not _is_ip_allowed(client_ip, global_allowed): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="IP адрес не разрешен глобальными настройками", + ) + + token_allowed_ips = token.allowed_ips or [] + if token_allowed_ips and not _is_ip_allowed(client_ip, token_allowed_ips): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="IP адрес не разрешен для этого токена", + ) + + await update_last_used(db, token) + return token + + +def require_permission(permission: str) -> Callable[[ApiToken], ApiToken]: + async def _dependency(token: ApiToken = Depends(get_current_token)) -> ApiToken: + permissions = token.permissions or [] + if "*" in permissions or permission in permissions: + return token + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для выполнения операции", + ) + + return _dependency diff --git a/app/webapi/routers/__init__.py b/app/webapi/routers/__init__.py new file mode 100644 index 00000000..87fa5233 --- /dev/null +++ b/app/webapi/routers/__init__.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from . import auth, health, payments, settings, stats, tickets, transactions, users + +api_router = APIRouter(prefix="/api") + +api_router.include_router(health.router, tags=["health"]) +api_router.include_router(auth.router, tags=["auth"]) +api_router.include_router(stats.router, tags=["stats"]) +api_router.include_router(users.router, tags=["users"]) +api_router.include_router(transactions.router, tags=["transactions"]) +api_router.include_router(payments.router, tags=["payments"]) +api_router.include_router(tickets.router, tags=["tickets"]) +api_router.include_router(settings.router, tags=["settings"]) + +__all__ = ["api_router"] diff --git a/app/webapi/routers/auth.py b/app/webapi/routers/auth.py new file mode 100644 index 00000000..9b865aaf --- /dev/null +++ b/app/webapi/routers/auth.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Path, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud import api_token as api_token_crud +from app.database.models import ApiToken +from app.webapi.dependencies import get_current_token, get_db, require_permission +from app.webapi.schemas import ( + APIMessage, + TokenCreateRequest, + TokenResponse, + TokenUpdateRequest, + TokenWithSecretResponse, +) + +router = APIRouter(prefix="/auth") + + +def _token_to_response(token: ApiToken) -> TokenResponse: + return TokenResponse( + id=token.id, + name=token.name, + description=token.description, + permissions=token.permissions or [], + allowed_ips=token.allowed_ips or [], + is_active=token.is_active, + token_prefix=token.token_prefix, + created_at=token.created_at, + updated_at=token.updated_at, + last_used_at=token.last_used_at, + expires_at=token.expires_at, + ) + + +@router.post("/token", response_model=TokenWithSecretResponse, status_code=status.HTTP_201_CREATED) +async def create_token( + payload: TokenCreateRequest, + db: AsyncSession = Depends(get_db), +): + if not settings.WEBAPI_MASTER_KEY: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="WEBAPI_MASTER_KEY не задан в конфигурации", + ) + + if payload.secret != settings.WEBAPI_MASTER_KEY: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Неверный секрет", + ) + + plain_token = api_token_crud.generate_token() + + expires_at = None + if payload.expires_in_hours: + expires_at = datetime.utcnow() + timedelta(hours=payload.expires_in_hours) + else: + ttl = settings.get_webapi_token_ttl() + if ttl: + expires_at = datetime.utcnow() + ttl + + api_token = await api_token_crud.create_api_token( + db, + name=payload.name, + token=plain_token, + description=payload.description, + permissions=payload.permissions, + allowed_ips=payload.allowed_ips, + expires_at=expires_at, + ) + + response = TokenWithSecretResponse( + **_token_to_response(api_token).model_dump(), + token=plain_token, + ) + return response + + +@router.get("/tokens", response_model=List[TokenResponse]) +async def list_tokens( + db: AsyncSession = Depends(get_db), + _: ApiToken = Depends(require_permission("webapi.tokens:read")), +) -> List[TokenResponse]: + tokens = await api_token_crud.list_api_tokens(db) + return [_token_to_response(token) for token in tokens] + + +@router.patch("/tokens/{token_id}", response_model=TokenResponse) +async def update_token( + payload: TokenUpdateRequest, + token_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: ApiToken = Depends(require_permission("webapi.tokens:write")), +) -> TokenResponse: + token = await api_token_crud.get_api_token_by_id(db, token_id) + if not token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Токен не найден") + + updated = await api_token_crud.update_api_token( + db, + token, + name=payload.name, + description=payload.description, + permissions=payload.permissions, + allowed_ips=payload.allowed_ips, + expires_at=payload.expires_at, + is_active=payload.is_active, + ) + return _token_to_response(updated) + + +@router.post("/tokens/{token_id}/revoke", response_model=TokenResponse) +async def revoke_token( + token_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: ApiToken = Depends(require_permission("webapi.tokens:write")), +) -> TokenResponse: + token = await api_token_crud.get_api_token_by_id(db, token_id) + if not token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Токен не найден") + + revoked = await api_token_crud.deactivate_api_token(db, token) + return _token_to_response(revoked) + + +@router.delete("/tokens/{token_id}", response_model=APIMessage) +async def delete_token( + token_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: ApiToken = Depends(require_permission("webapi.tokens:write")), +) -> APIMessage: + token = await api_token_crud.get_api_token_by_id(db, token_id) + if not token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Токен не найден") + + await api_token_crud.delete_api_token(db, token_id) + return APIMessage(message="Токен удален") + + +@router.get("/me", response_model=TokenResponse) +async def whoami(current_token: ApiToken = Depends(get_current_token)) -> TokenResponse: + return _token_to_response(current_token) diff --git a/app/webapi/routers/health.py b/app/webapi/routers/health.py new file mode 100644 index 00000000..b13b8a13 --- /dev/null +++ b/app/webapi/routers/health.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from fastapi import APIRouter + +from app.config import settings +from app.webapi.schemas import HealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +async def health_check() -> HealthResponse: + return HealthResponse( + status="ok", + version=settings.WEBAPI_VERSION, + timestamp=datetime.utcnow(), + ) diff --git a/app/webapi/routers/payments.py b/app/webapi/routers/payments.py new file mode 100644 index 00000000..c765056e --- /dev/null +++ b/app/webapi/routers/payments.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import CryptoBotPayment, MulenPayPayment, Pal24Payment, YooKassaPayment +from app.webapi.dependencies import get_db, require_permission +from app.webapi.schemas import ( + CryptoBotPaymentListResponse, + CryptoBotPaymentSchema, + MulenPayPaymentListResponse, + MulenPayPaymentSchema, + Pal24PaymentListResponse, + Pal24PaymentSchema, + Pagination, + YooKassaPaymentListResponse, + YooKassaPaymentSchema, +) + +router = APIRouter(prefix="/payments") + + +async def _paginate( + db: AsyncSession, + base_query, + count_query, + limit: int, + offset: int, +): + result = await db.execute(base_query.offset(offset).limit(limit)) + items = result.scalars().all() + total = await db.scalar(count_query) or 0 + return items, total + + +@router.get("/yookassa", response_model=YooKassaPaymentListResponse) +async def list_yookassa_payments( + user_id: Optional[int] = Query(default=None, ge=1), + status_filter: Optional[str] = Query(default=None, alias="status"), + is_paid: Optional[bool] = Query(default=None), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.payments:read")), +): + stmt = select(YooKassaPayment) + count_stmt = select(func.count(YooKassaPayment.id)) + + if user_id: + stmt = stmt.where(YooKassaPayment.user_id == user_id) + count_stmt = count_stmt.where(YooKassaPayment.user_id == user_id) + if status_filter: + stmt = stmt.where(YooKassaPayment.status == status_filter) + count_stmt = count_stmt.where(YooKassaPayment.status == status_filter) + if is_paid is not None: + stmt = stmt.where(YooKassaPayment.is_paid.is_(is_paid)) + count_stmt = count_stmt.where(YooKassaPayment.is_paid.is_(is_paid)) + + items, total = await _paginate(db, stmt.order_by(YooKassaPayment.created_at.desc()), count_stmt, limit, offset) + return YooKassaPaymentListResponse( + pagination=Pagination(total=total, limit=limit, offset=offset), + items=[YooKassaPaymentSchema.model_validate(item) for item in items], + ) + + +@router.get("/cryptobot", response_model=CryptoBotPaymentListResponse) +async def list_cryptobot_payments( + user_id: Optional[int] = Query(default=None, ge=1), + status_filter: Optional[str] = Query(default=None, alias="status"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.payments:read")), +): + stmt = select(CryptoBotPayment) + count_stmt = select(func.count(CryptoBotPayment.id)) + + if user_id: + stmt = stmt.where(CryptoBotPayment.user_id == user_id) + count_stmt = count_stmt.where(CryptoBotPayment.user_id == user_id) + if status_filter: + stmt = stmt.where(CryptoBotPayment.status == status_filter) + count_stmt = count_stmt.where(CryptoBotPayment.status == status_filter) + + items, total = await _paginate(db, stmt.order_by(CryptoBotPayment.created_at.desc()), count_stmt, limit, offset) + return CryptoBotPaymentListResponse( + pagination=Pagination(total=total, limit=limit, offset=offset), + items=[CryptoBotPaymentSchema.model_validate(item) for item in items], + ) + + +@router.get("/mulenpay", response_model=MulenPayPaymentListResponse) +async def list_mulen_payments( + user_id: Optional[int] = Query(default=None, ge=1), + status_filter: Optional[str] = Query(default=None, alias="status"), + is_paid: Optional[bool] = Query(default=None), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.payments:read")), +): + stmt = select(MulenPayPayment) + count_stmt = select(func.count(MulenPayPayment.id)) + + if user_id: + stmt = stmt.where(MulenPayPayment.user_id == user_id) + count_stmt = count_stmt.where(MulenPayPayment.user_id == user_id) + if status_filter: + stmt = stmt.where(MulenPayPayment.status == status_filter) + count_stmt = count_stmt.where(MulenPayPayment.status == status_filter) + if is_paid is not None: + stmt = stmt.where(MulenPayPayment.is_paid.is_(is_paid)) + count_stmt = count_stmt.where(MulenPayPayment.is_paid.is_(is_paid)) + + items, total = await _paginate(db, stmt.order_by(MulenPayPayment.created_at.desc()), count_stmt, limit, offset) + return MulenPayPaymentListResponse( + pagination=Pagination(total=total, limit=limit, offset=offset), + items=[MulenPayPaymentSchema.model_validate(item) for item in items], + ) + + +@router.get("/pal24", response_model=Pal24PaymentListResponse) +async def list_pal24_payments( + user_id: Optional[int] = Query(default=None, ge=1), + status_filter: Optional[str] = Query(default=None, alias="status"), + is_paid: Optional[bool] = Query(default=None), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.payments:read")), +): + stmt = select(Pal24Payment) + count_stmt = select(func.count(Pal24Payment.id)) + + if user_id: + stmt = stmt.where(Pal24Payment.user_id == user_id) + count_stmt = count_stmt.where(Pal24Payment.user_id == user_id) + if status_filter: + stmt = stmt.where(Pal24Payment.status == status_filter) + count_stmt = count_stmt.where(Pal24Payment.status == status_filter) + if is_paid is not None: + stmt = stmt.where(Pal24Payment.is_paid.is_(is_paid)) + count_stmt = count_stmt.where(Pal24Payment.is_paid.is_(is_paid)) + + items, total = await _paginate(db, stmt.order_by(Pal24Payment.created_at.desc()), count_stmt, limit, offset) + return Pal24PaymentListResponse( + pagination=Pagination(total=total, limit=limit, offset=offset), + items=[Pal24PaymentSchema.model_validate(item) for item in items], + ) diff --git a/app/webapi/routers/settings.py b/app/webapi/routers/settings.py new file mode 100644 index 00000000..dbcd1205 --- /dev/null +++ b/app/webapi/routers/settings.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.system_settings_service import bot_configuration_service +from app.webapi.dependencies import get_db, require_permission +from app.webapi.schemas import SettingResponse, SettingsListResponse, SettingUpdateRequest + +router = APIRouter(prefix="/settings") + + +def _build_setting_response(key: str) -> SettingResponse: + summary = bot_configuration_service.get_setting_summary(key) + return SettingResponse( + key=summary["key"], + name=summary["name"], + value=summary["current"], + original=summary["original"], + has_override=summary["has_override"], + category_key=summary["category_key"], + category_label=summary["category_label"], + type=summary["type"], + ) + + +@router.get("", response_model=SettingsListResponse) +async def list_settings( + category: Optional[str] = Query(default=None), + _: object = Depends(require_permission("webapi.settings:read")), +) -> SettingsListResponse: + bot_configuration_service.initialize_definitions() + + if category: + definitions = bot_configuration_service.get_settings_for_category(category) + else: + definitions = list(bot_configuration_service._definitions.values()) # type: ignore[attr-defined] + + definitions = sorted(definitions, key=lambda definition: definition.display_name) + + items = [_build_setting_response(definition.key) for definition in definitions] + return SettingsListResponse(items=items) + + +@router.get("/{key}", response_model=SettingResponse) +async def get_setting( + key: str = Path(..., min_length=1), + _: object = Depends(require_permission("webapi.settings:read")), +) -> SettingResponse: + try: + bot_configuration_service.get_definition(key) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Настройка не найдена") + + return _build_setting_response(key) + + +@router.put("/{key}", response_model=SettingResponse) +async def update_setting( + payload: SettingUpdateRequest, + key: str = Path(..., min_length=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.settings:write")), +) -> SettingResponse: + try: + bot_configuration_service.get_definition(key) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Настройка не найдена") + + try: + if payload.value is None: + parsed_value = None + else: + parsed_value = bot_configuration_service.parse_user_value(key, str(payload.value)) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) + + await bot_configuration_service.set_value(db, key, parsed_value) + return _build_setting_response(key) + + +@router.delete("/{key}", response_model=SettingResponse) +async def reset_setting( + key: str = Path(..., min_length=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.settings:write")), +) -> SettingResponse: + try: + bot_configuration_service.get_definition(key) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Настройка не найдена") + + await bot_configuration_service.reset_value(db, key) + return _build_setting_response(key) diff --git a/app/webapi/routers/stats.py b/app/webapi/routers/stats.py new file mode 100644 index 00000000..32c34706 --- /dev/null +++ b/app/webapi/routers/stats.py @@ -0,0 +1,87 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends + +from app.database.models import ( + CryptoBotPayment, + MulenPayPayment, + Pal24Payment, + Subscription, + SubscriptionStatus, + Ticket, + TicketStatus, + User, + UserStatus, + YooKassaPayment, +) +from app.webapi.dependencies import get_db, require_permission +from app.webapi.schemas import StatsResponse + +router = APIRouter(prefix="/stats") + + +@router.get("/overview", response_model=StatsResponse) +async def get_overview( + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.stats:read")), +) -> StatsResponse: + total_users = await db.scalar(select(func.count(User.id))) + + active_users = await db.scalar( + select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value) + ) + blocked_users = await db.scalar( + select(func.count(User.id)).where(User.status == UserStatus.BLOCKED.value) + ) + total_balance = await db.scalar(select(func.coalesce(func.sum(User.balance_kopeks), 0))) + + active_subscriptions = await db.scalar( + select(func.count(Subscription.id)).where( + Subscription.status == SubscriptionStatus.ACTIVE.value + ) + ) + expired_subscriptions = await db.scalar( + select(func.count(Subscription.id)).where( + Subscription.status == SubscriptionStatus.EXPIRED.value + ) + ) + + open_tickets = await db.scalar( + select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN.value) + ) + + pending_yookassa = await db.scalar( + select(func.count(YooKassaPayment.id)).where( + YooKassaPayment.is_paid.is_(False) + ) + ) or 0 + pending_cryptobot = await db.scalar( + select(func.count(CryptoBotPayment.id)).where( + CryptoBotPayment.status.in_(["active", "pending"]) + ) + ) or 0 + pending_mulenpay = await db.scalar( + select(func.count(MulenPayPayment.id)).where( + MulenPayPayment.is_paid.is_(False) + ) + ) or 0 + pending_pal24 = await db.scalar( + select(func.count(Pal24Payment.id)).where( + Pal24Payment.is_paid.is_(False) + ) + ) or 0 + + pending_payments = ( + pending_yookassa + pending_cryptobot + pending_mulenpay + pending_pal24 + ) + + return StatsResponse( + total_users=total_users or 0, + active_users=active_users or 0, + blocked_users=blocked_users or 0, + total_balance_kopeks=total_balance or 0, + active_subscriptions=active_subscriptions or 0, + expired_subscriptions=expired_subscriptions or 0, + open_tickets=open_tickets or 0, + pending_payments=pending_payments or 0, + ) diff --git a/app/webapi/routers/tickets.py b/app/webapi/routers/tickets.py new file mode 100644 index 00000000..65afc048 --- /dev/null +++ b/app/webapi/routers/tickets.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import Ticket, TicketStatus +from app.webapi.dependencies import get_db, require_permission +from app.webapi.schemas import ( + Pagination, + TicketListResponse, + TicketMessageSchema, + TicketSchema, + TicketUpdateRequest, +) + +router = APIRouter(prefix="/tickets") + + +def _ticket_to_schema(ticket: Ticket) -> TicketSchema: + messages = sorted(ticket.messages, key=lambda message: message.created_at) + return TicketSchema( + id=ticket.id, + user_id=ticket.user_id, + title=ticket.title, + status=ticket.status, + priority=ticket.priority, + created_at=ticket.created_at, + updated_at=ticket.updated_at, + closed_at=ticket.closed_at, + user_reply_block_permanent=ticket.user_reply_block_permanent, + user_reply_block_until=ticket.user_reply_block_until, + messages=[TicketMessageSchema.model_validate(message) for message in messages], + ) + + +@router.get("", response_model=TicketListResponse) +async def list_tickets( + status_filter: Optional[TicketStatus] = Query(default=None, alias="status"), + user_id: Optional[int] = Query(default=None, ge=1), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.tickets:read")), +) -> TicketListResponse: + stmt = select(Ticket).options(selectinload(Ticket.messages)) + count_stmt = select(func.count(Ticket.id)) + + if status_filter: + stmt = stmt.where(Ticket.status == status_filter.value) + count_stmt = count_stmt.where(Ticket.status == status_filter.value) + if user_id: + stmt = stmt.where(Ticket.user_id == user_id) + count_stmt = count_stmt.where(Ticket.user_id == user_id) + + stmt = stmt.order_by(Ticket.created_at.desc()).offset(offset).limit(limit) + + result = await db.execute(stmt) + tickets = result.scalars().unique().all() + + total = await db.scalar(count_stmt) or 0 + pagination = Pagination(total=total, limit=limit, offset=offset) + + return TicketListResponse( + pagination=pagination, + items=[_ticket_to_schema(ticket) for ticket in tickets], + ) + + +@router.get("/{ticket_id}", response_model=TicketSchema) +async def get_ticket( + ticket_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.tickets:read")), +) -> TicketSchema: + stmt = ( + select(Ticket) + .options(selectinload(Ticket.messages)) + .where(Ticket.id == ticket_id) + ) + result = await db.execute(stmt) + ticket = result.scalars().unique().one_or_none() + + if not ticket: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Тикет не найден") + + return _ticket_to_schema(ticket) + + +@router.patch("/{ticket_id}", response_model=TicketSchema) +async def update_ticket( + payload: TicketUpdateRequest, + ticket_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.tickets:write")), +) -> TicketSchema: + stmt = ( + select(Ticket) + .options(selectinload(Ticket.messages)) + .where(Ticket.id == ticket_id) + ) + result = await db.execute(stmt) + ticket = result.scalars().unique().one_or_none() + + if not ticket: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Тикет не найден") + + data = payload.model_dump(exclude_unset=True) + if "status" in data and data["status"] is not None: + new_status = data["status"].value if isinstance(data["status"], TicketStatus) else data["status"] + data["status"] = new_status + if new_status == TicketStatus.CLOSED.value: + ticket.closed_at = ticket.closed_at or datetime.utcnow() + else: + ticket.closed_at = None + + for field, value in data.items(): + setattr(ticket, field, value) + + ticket.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(ticket) + + return _ticket_to_schema(ticket) diff --git a/app/webapi/routers/transactions.py b/app/webapi/routers/transactions.py new file mode 100644 index 00000000..dc2c3749 --- /dev/null +++ b/app/webapi/routers/transactions.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Transaction +from app.webapi.dependencies import get_db, require_permission +from app.webapi.schemas import Pagination, TransactionListResponse, TransactionSchema + +router = APIRouter(prefix="/transactions") + + +@router.get("", response_model=TransactionListResponse) +async def list_transactions( + user_id: Optional[int] = Query(default=None, ge=1), + tx_type: Optional[str] = Query(default=None, alias="type"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.transactions:read")), +) -> TransactionListResponse: + stmt = select(Transaction) + count_stmt = select(func.count(Transaction.id)) + + if user_id: + stmt = stmt.where(Transaction.user_id == user_id) + count_stmt = count_stmt.where(Transaction.user_id == user_id) + + if tx_type: + stmt = stmt.where(Transaction.type == tx_type) + count_stmt = count_stmt.where(Transaction.type == tx_type) + + stmt = stmt.order_by(Transaction.created_at.desc()).offset(offset).limit(limit) + + result = await db.execute(stmt) + transactions = result.scalars().all() + + total = await db.scalar(count_stmt) or 0 + + pagination = Pagination(total=total, limit=limit, offset=offset) + items = [TransactionSchema.model_validate(tx) for tx in transactions] + return TransactionListResponse(pagination=pagination, items=items) diff --git a/app/webapi/routers/users.py b/app/webapi/routers/users.py new file mode 100644 index 00000000..801e433a --- /dev/null +++ b/app/webapi/routers/users.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from sqlalchemy import func, or_, select +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.subscription import ( + add_subscription_devices, + add_subscription_squad, + add_subscription_traffic, + extend_subscription, + get_subscription_by_user_id, + remove_subscription_squad, +) +from app.database.crud.user import ( + add_user_balance, + create_user, + get_user_by_id, + update_user, +) +from app.database.models import Subscription, Transaction, User, UserStatus +from app.webapi.dependencies import get_db, require_permission +from app.webapi.schemas import ( + Pagination, + SubscriptionDevicesRequest, + SubscriptionExtendRequest, + SubscriptionSchema, + SubscriptionSquadRequest, + SubscriptionTrafficRequest, + TransactionListResponse, + TransactionSchema, + UserBalanceUpdateRequest, + UserCreateRequest, + UserDetailResponse, + UserListItem, + UserListResponse, + UserStatusUpdateRequest, + UserUpdateRequest, +) + +router = APIRouter(prefix="/users") + + +def _user_to_list_item(user: User) -> UserListItem: + subscription = user.subscription + return UserListItem( + id=user.id, + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + language=user.language, + status=user.status, + balance_kopeks=user.balance_kopeks, + referral_code=user.referral_code, + created_at=user.created_at, + updated_at=user.updated_at, + subscription_status=subscription.status if subscription else None, + subscription_end_date=subscription.end_date if subscription else None, + ) + + +def _user_to_detail(user: User, transactions: List[Transaction]) -> UserDetailResponse: + subscription = user.subscription + return UserDetailResponse( + id=user.id, + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + language=user.language, + status=user.status, + balance_kopeks=user.balance_kopeks, + referred_by_id=user.referred_by_id, + referral_code=user.referral_code, + promo_group_id=user.promo_group_id, + created_at=user.created_at, + updated_at=user.updated_at, + subscription=SubscriptionSchema.model_validate(subscription) + if subscription + else None, + transactions=[TransactionSchema.model_validate(tx) for tx in transactions], + ) + + +@router.get("", response_model=UserListResponse) +async def list_users( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + status_filter: Optional[str] = Query(None, alias="status"), + search: Optional[str] = Query(None), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.users:read")), +) -> UserListResponse: + stmt = select(User).options(selectinload(User.subscription)) + count_stmt = select(func.count(User.id)) + + filters = [] + + if status_filter: + valid_statuses = {status.value for status in UserStatus} + if status_filter not in valid_statuses: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Недопустимый статус фильтра", + ) + filters.append(User.status == status_filter) + + if search: + search = search.strip() + if search.startswith("@"): + search = search[1:] + if search.isdigit(): + filters.append(User.telegram_id == int(search)) + else: + like_pattern = f"%{search.lower()}%" + filters.append( + or_( + func.lower(User.username).like(like_pattern), + func.lower(User.first_name).like(like_pattern), + func.lower(User.last_name).like(like_pattern), + func.lower(User.referral_code).like(like_pattern), + ) + ) + + for condition in filters: + stmt = stmt.where(condition) + count_stmt = count_stmt.where(condition) + + stmt = stmt.order_by(User.created_at.desc()).offset(offset).limit(limit) + + result = await db.execute(stmt) + users = result.scalars().unique().all() + + total = await db.scalar(count_stmt) or 0 + + items = [_user_to_list_item(user) for user in users] + pagination = Pagination(total=total, limit=limit, offset=offset) + return UserListResponse(pagination=pagination, items=items) + + +@router.get("/{user_id}", response_model=UserDetailResponse) +async def get_user_detail( + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.users:read")), +) -> UserDetailResponse: + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") + + transactions_stmt = ( + select(Transaction) + .where(Transaction.user_id == user.id) + .order_by(Transaction.created_at.desc()) + .limit(50) + ) + tx_result = await db.execute(transactions_stmt) + transactions = tx_result.scalars().all() + + return _user_to_detail(user, transactions) + + +@router.post("", response_model=UserDetailResponse, status_code=status.HTTP_201_CREATED) +async def create_user_endpoint( + payload: UserCreateRequest, + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.users:write")), +) -> UserDetailResponse: + existing_stmt = select(User).where(User.telegram_id == payload.telegram_id) + existing = await db.scalar(existing_stmt) + if existing: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Пользователь уже существует") + + user = await create_user( + db, + telegram_id=payload.telegram_id, + username=payload.username, + first_name=payload.first_name, + last_name=payload.last_name, + language=payload.language or "ru", + referred_by_id=payload.referred_by_id, + referral_code=payload.referral_code, + ) + + refreshed = await get_user_by_id(db, user.id) + return _user_to_detail(refreshed or user, []) + + +@router.patch("/{user_id}", response_model=UserDetailResponse) +async def update_user_endpoint( + payload: UserUpdateRequest, + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.users:write")), +) -> UserDetailResponse: + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") + + updates = payload.model_dump(exclude_unset=True) + + await update_user(db, user, **updates) + refreshed = await get_user_by_id(db, user_id) + + transactions_stmt = ( + select(Transaction) + .where(Transaction.user_id == user_id) + .order_by(Transaction.created_at.desc()) + .limit(50) + ) + tx_result = await db.execute(transactions_stmt) + transactions = tx_result.scalars().all() + + return _user_to_detail(refreshed or user, transactions) + + +@router.post("/{user_id}/balance", response_model=UserDetailResponse) +async def update_balance( + payload: UserBalanceUpdateRequest, + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.users:write")), +) -> UserDetailResponse: + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") + + success = await add_user_balance( + db, + user, + amount_kopeks=payload.amount_kopeks, + description=payload.description or "Изменение баланса через API", + create_transaction=True, + ) + + if not success: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Не удалось обновить баланс") + + refreshed = await get_user_by_id(db, user_id) + transactions_stmt = ( + select(Transaction) + .where(Transaction.user_id == user_id) + .order_by(Transaction.created_at.desc()) + .limit(50) + ) + tx_result = await db.execute(transactions_stmt) + transactions = tx_result.scalars().all() + + return _user_to_detail(refreshed or user, transactions) + + +@router.post("/{user_id}/status", response_model=UserDetailResponse) +async def update_status( + payload: UserStatusUpdateRequest, + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.users:write")), +) -> UserDetailResponse: + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") + + try: + new_status = UserStatus(payload.status).value + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Недопустимый статус") + + await update_user(db, user, status=new_status) + refreshed = await get_user_by_id(db, user_id) + + transactions_stmt = ( + select(Transaction) + .where(Transaction.user_id == user_id) + .order_by(Transaction.created_at.desc()) + .limit(50) + ) + tx_result = await db.execute(transactions_stmt) + transactions = tx_result.scalars().all() + + return _user_to_detail(refreshed or user, transactions) + + +def _get_subscription_or_404(subscription: Optional[Subscription]) -> Subscription: + if not subscription: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="У пользователя нет активной подписки") + return subscription + + +@router.post("/{user_id}/subscription/extend", response_model=SubscriptionSchema) +async def extend_user_subscription( + payload: SubscriptionExtendRequest, + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.subscriptions:write")), +) -> SubscriptionSchema: + subscription = await get_subscription_by_user_id(db, user_id) + subscription = _get_subscription_or_404(subscription) + updated = await extend_subscription(db, subscription, payload.days) + return SubscriptionSchema.model_validate(updated) + + +@router.post("/{user_id}/subscription/traffic", response_model=SubscriptionSchema) +async def add_subscription_traffic_api( + payload: SubscriptionTrafficRequest, + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.subscriptions:write")), +) -> SubscriptionSchema: + subscription = await get_subscription_by_user_id(db, user_id) + subscription = _get_subscription_or_404(subscription) + updated = await add_subscription_traffic(db, subscription, payload.gb) + return SubscriptionSchema.model_validate(updated) + + +@router.post("/{user_id}/subscription/devices", response_model=SubscriptionSchema) +async def add_subscription_devices_api( + payload: SubscriptionDevicesRequest, + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.subscriptions:write")), +) -> SubscriptionSchema: + subscription = await get_subscription_by_user_id(db, user_id) + subscription = _get_subscription_or_404(subscription) + updated = await add_subscription_devices(db, subscription, payload.devices) + return SubscriptionSchema.model_validate(updated) + + +@router.post("/{user_id}/subscription/squads", response_model=SubscriptionSchema) +async def add_subscription_squad_api( + payload: SubscriptionSquadRequest, + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.subscriptions:write")), +) -> SubscriptionSchema: + subscription = await get_subscription_by_user_id(db, user_id) + subscription = _get_subscription_or_404(subscription) + updated = await add_subscription_squad(db, subscription, payload.squad_uuid) + return SubscriptionSchema.model_validate(updated) + + +@router.delete("/{user_id}/subscription/squads/{squad_uuid}", response_model=SubscriptionSchema) +async def remove_subscription_squad_api( + squad_uuid: str, + user_id: int = Path(..., ge=1), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.subscriptions:write")), +) -> SubscriptionSchema: + subscription = await get_subscription_by_user_id(db, user_id) + subscription = _get_subscription_or_404(subscription) + updated = await remove_subscription_squad(db, subscription, squad_uuid) + return SubscriptionSchema.model_validate(updated) + + +@router.get("/{user_id}/transactions", response_model=TransactionListResponse) +async def list_user_transactions( + user_id: int = Path(..., ge=1), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("webapi.transactions:read")), +) -> TransactionListResponse: + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") + + total_stmt = select(func.count(Transaction.id)).where(Transaction.user_id == user_id) + total = await db.scalar(total_stmt) or 0 + + stmt = ( + select(Transaction) + .where(Transaction.user_id == user_id) + .order_by(Transaction.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(stmt) + transactions = result.scalars().all() + + pagination = Pagination(total=total, limit=limit, offset=offset) + items = [TransactionSchema.model_validate(tx) for tx in transactions] + return TransactionListResponse(pagination=pagination, items=items) diff --git a/app/webapi/schemas.py b/app/webapi/schemas.py new file mode 100644 index 00000000..2865f765 --- /dev/null +++ b/app/webapi/schemas.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from app.database.models import TicketStatus + + +class APIMessage(BaseModel): + message: str + + +class TokenCreateRequest(BaseModel): + name: str + secret: str = Field(..., description="Секрет из конфигурации WEBAPI_MASTER_KEY") + description: Optional[str] = None + permissions: Optional[List[str]] = None + allowed_ips: Optional[List[str]] = None + expires_in_hours: Optional[int] = Field( + default=None, + ge=1, + description="Время жизни токена в часах. Если не указано, используется глобальное значение", + ) + + +class TokenUpdateRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + permissions: Optional[List[str]] = None + allowed_ips: Optional[List[str]] = None + expires_at: Optional[datetime] = None + is_active: Optional[bool] = None + + +class TokenResponse(BaseModel): + id: int + name: str + description: Optional[str] + permissions: List[str] + allowed_ips: List[str] + is_active: bool + token_prefix: str + created_at: datetime + updated_at: datetime + last_used_at: Optional[datetime] + expires_at: Optional[datetime] + + model_config = ConfigDict(from_attributes=True) + + +class TokenWithSecretResponse(TokenResponse): + token: str + + +class HealthResponse(BaseModel): + status: str = "ok" + version: str + timestamp: datetime + + +class StatsResponse(BaseModel): + total_users: int + active_users: int + blocked_users: int + total_balance_kopeks: int + active_subscriptions: int + expired_subscriptions: int + open_tickets: int + pending_payments: int + + +class Pagination(BaseModel): + total: int + limit: int + offset: int + + +class SubscriptionSchema(BaseModel): + id: int + status: str + is_trial: bool + start_date: Optional[datetime] + end_date: Optional[datetime] + traffic_limit_gb: Optional[int] + traffic_used_gb: Optional[int] + device_limit: Optional[int] + connected_squads: List[str] + + model_config = ConfigDict(from_attributes=True) + + +class TransactionSchema(BaseModel): + id: int + type: str + amount_kopeks: int + description: Optional[str] + payment_method: Optional[str] + external_id: Optional[str] + is_completed: bool + created_at: datetime + completed_at: Optional[datetime] + + model_config = ConfigDict(from_attributes=True) + + +class UserListItem(BaseModel): + id: int + telegram_id: int + username: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + language: Optional[str] + status: str + balance_kopeks: int + referral_code: Optional[str] + created_at: datetime + updated_at: datetime + subscription_status: Optional[str] = None + subscription_end_date: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class UserListResponse(BaseModel): + pagination: Pagination + items: List[UserListItem] + + +class UserCreateRequest(BaseModel): + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + language: Optional[str] = Field(default="ru", max_length=5) + referred_by_id: Optional[int] = None + referral_code: Optional[str] = None + + +class UserUpdateRequest(BaseModel): + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + language: Optional[str] = Field(default=None, max_length=5) + referral_code: Optional[str] = None + + +class UserBalanceUpdateRequest(BaseModel): + amount_kopeks: int + description: Optional[str] = Field(default="Пополнение/списание через API") + + +class UserStatusUpdateRequest(BaseModel): + status: str = Field(..., description="Новый статус пользователя") + + +class SubscriptionExtendRequest(BaseModel): + days: int = Field(..., gt=0) + + +class SubscriptionTrafficRequest(BaseModel): + gb: int = Field(..., gt=0) + + +class SubscriptionDevicesRequest(BaseModel): + devices: int = Field(..., gt=0) + + +class SubscriptionSquadRequest(BaseModel): + squad_uuid: str = Field(..., min_length=3) + + +class UserDetailResponse(BaseModel): + id: int + telegram_id: int + username: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + language: Optional[str] + status: str + balance_kopeks: int + referred_by_id: Optional[int] + referral_code: Optional[str] + promo_group_id: Optional[int] + created_at: datetime + updated_at: datetime + subscription: Optional[SubscriptionSchema] + transactions: List[TransactionSchema] + + +class TransactionListResponse(BaseModel): + pagination: Pagination + items: List[TransactionSchema] + + +class TicketMessageSchema(BaseModel): + id: int + author_type: str + content: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TicketSchema(BaseModel): + id: int + user_id: int + title: str + status: str + priority: str + created_at: datetime + updated_at: datetime + closed_at: Optional[datetime] + user_reply_block_permanent: bool + user_reply_block_until: Optional[datetime] + messages: List[TicketMessageSchema] + + model_config = ConfigDict(from_attributes=True) + + +class TicketListResponse(BaseModel): + pagination: Pagination + items: List[TicketSchema] + + +class TicketUpdateRequest(BaseModel): + status: Optional[TicketStatus] = None + priority: Optional[str] = Field(default=None, regex=r"^(low|normal|high|urgent)$") + user_reply_block_permanent: Optional[bool] = None + user_reply_block_until: Optional[datetime] = None + + +class SettingUpdateRequest(BaseModel): + value: Optional[Any] + + +class SettingResponse(BaseModel): + key: str + name: str + value: Optional[str] + original: Optional[str] + has_override: bool + category_key: str + category_label: str + type: str + + +class SettingsListResponse(BaseModel): + items: List[SettingResponse] + + +class PromoGroupSchema(BaseModel): + id: int + name: str + server_discount_percent: int + traffic_discount_percent: int + device_discount_percent: int + is_default: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class PromoGroupListResponse(BaseModel): + items: List[PromoGroupSchema] + + +class PromoGroupUpdateRequest(BaseModel): + name: Optional[str] = None + server_discount_percent: Optional[int] = Field(default=None, ge=0, le=100) + traffic_discount_percent: Optional[int] = Field(default=None, ge=0, le=100) + device_discount_percent: Optional[int] = Field(default=None, ge=0, le=100) + is_default: Optional[bool] = None + + +class YooKassaPaymentSchema(BaseModel): + id: int + user_id: int + yookassa_payment_id: str + amount_kopeks: int + currency: str + description: Optional[str] + status: str + is_paid: bool + is_captured: bool + confirmation_url: Optional[str] + payment_method_type: Optional[str] + refundable: bool + created_at: datetime + updated_at: datetime + yookassa_created_at: Optional[datetime] + captured_at: Optional[datetime] + + model_config = ConfigDict(from_attributes=True) + + +class CryptoBotPaymentSchema(BaseModel): + id: int + user_id: int + invoice_id: str + amount: str + asset: str + status: str + description: Optional[str] + paid_at: Optional[datetime] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class MulenPayPaymentSchema(BaseModel): + id: int + user_id: int + mulen_payment_id: Optional[int] + uuid: str + amount_kopeks: int + currency: str + status: str + is_paid: bool + paid_at: Optional[datetime] + payment_url: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class Pal24PaymentSchema(BaseModel): + id: int + user_id: int + pal24_payment_id: Optional[str] + amount_kopeks: int + currency: str + status: str + is_paid: bool + paid_at: Optional[datetime] + payment_url: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class YooKassaPaymentListResponse(BaseModel): + pagination: Pagination + items: List[YooKassaPaymentSchema] + + +class CryptoBotPaymentListResponse(BaseModel): + pagination: Pagination + items: List[CryptoBotPaymentSchema] + + +class MulenPayPaymentListResponse(BaseModel): + pagination: Pagination + items: List[MulenPayPaymentSchema] + + +class Pal24PaymentListResponse(BaseModel): + pagination: Pagination + items: List[Pal24PaymentSchema] diff --git a/app/webapi/server.py b/app/webapi/server.py new file mode 100644 index 00000000..21263164 --- /dev/null +++ b/app/webapi/server.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.webapi.routers import api_router + +logger = logging.getLogger(__name__) + + +def create_app() -> FastAPI: + docs_url = "/docs" if settings.WEBAPI_DOCS_ENABLED else None + redoc_url = "/redoc" if settings.WEBAPI_DOCS_ENABLED else None + + app = FastAPI( + title=settings.WEBAPI_TITLE, + description=settings.WEBAPI_DESCRIPTION, + version=settings.WEBAPI_VERSION, + docs_url=docs_url, + redoc_url=redoc_url, + ) + + origins = settings.get_webapi_allowed_origins() + allow_all_origins = origins == ["*"] + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"] if allow_all_origins else origins, + allow_credentials=True, + allow_methods=["*"] if allow_all_origins else ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["*"] if allow_all_origins else ["*"], + ) + + app.include_router(api_router) + + return app + + +class WebAPIServer: + def __init__(self) -> None: + self.app = create_app() + self._server: Optional[uvicorn.Server] = None + self._task: Optional[asyncio.Task[None]] = None + + async def start(self) -> None: + if self._task and not self._task.done(): + return + + config = uvicorn.Config( + self.app, + host=settings.WEBAPI_HOST, + port=settings.WEBAPI_PORT, + log_level=settings.WEBAPI_LOG_LEVEL.lower(), + access_log=settings.WEBAPI_ACCESS_LOG, + loop="asyncio", + lifespan="on", + timeout_keep_alive=15, + root_path=settings.WEBAPI_ROOT_PATH or "", + ) + server = uvicorn.Server(config) + server.install_signal_handlers = False + self._server = server + + logger.info( + "🚀 Запуск Web API на %s:%s", + settings.WEBAPI_HOST, + settings.WEBAPI_PORT, + ) + + self._task = asyncio.create_task(server.serve()) + + async def stop(self) -> None: + if not self._server or not self._task: + return + + logger.info("🛑 Остановка Web API") + self._server.should_exit = True + await self._task + self._task = None + self._server = None diff --git a/main.py b/main.py index cf19d0b5..45ba95bb 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,7 @@ from app.services.backup_service import backup_service from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates from app.services.system_settings_service import bot_configuration_service +from app.webapi.server import WebAPIServer class GracefulExit: @@ -63,6 +64,7 @@ async def main(): maintenance_task = None version_check_task = None polling_task = None + webapi_server: WebAPIServer | None = None try: logger.info("📊 Инициализация базы данных...") @@ -165,6 +167,17 @@ async def main(): else: logger.info("ℹ️ PayPalych отключен, webhook сервер не запускается") + if settings.is_webapi_enabled(): + try: + logger.info("🌐 Запуск Web API сервера...") + webapi_server = WebAPIServer() + await webapi_server.start() + except Exception as webapi_error: + logger.error(f"❌ Не удалось запустить Web API: {webapi_error}") + webapi_server = None + else: + logger.info("ℹ️ Web API отключен") + logger.info("📊 Запуск службы мониторинга...") monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) @@ -200,6 +213,10 @@ async def main(): logger.info( f" PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}" ) + if settings.is_webapi_enabled(): + logger.info( + " Web API: http://%s:%s/api", settings.WEBAPI_HOST, settings.WEBAPI_PORT + ) logger.info("📄 Активные фоновые сервисы:") logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}") logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}") @@ -279,7 +296,14 @@ async def main(): if pal24_server: logger.info("ℹ️ Остановка PayPalych webhook сервера...") await asyncio.get_running_loop().run_in_executor(None, pal24_server.stop) - + + if webapi_server is not None: + logger.info("ℹ️ Остановка Web API...") + try: + await webapi_server.stop() + except Exception as error: + logger.error(f"Ошибка остановки Web API: {error}") + if maintenance_task and not maintenance_task.done(): logger.info("ℹ️ Остановка службы техработ...") await maintenance_service.stop_monitoring() diff --git a/requirements.txt b/requirements.txt index 53f98559..95eb3156 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,12 +6,14 @@ SQLAlchemy==2.0.43 alembic==1.16.5 aiosqlite==0.21.0 -# Дополнительные зависимости +# Дополнительные зависимости pydantic==2.11.9 pydantic-settings==2.10.1 python-dotenv==1.1.1 redis==5.0.1 PyYAML==6.0.2 +fastapi==0.115.6 +uvicorn[standard]==0.32.1 # YooKassa SDK yookassa==3.7.0