feat: add web admin api server

This commit is contained in:
Egor
2025-09-26 05:09:26 +03:00
parent 879607982e
commit 4d22e99076
20 changed files with 1988 additions and 13 deletions

View File

@@ -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
```
</details>

View File

@@ -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"
}

View File

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

View File

@@ -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"<ApiToken id={self.id} name={self.name} active={self.is_active}>"
class SystemSetting(Base):
__tablename__ = "system_settings"

View File

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

5
app/webapi/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Пакет с FastAPI-приложением для веб-админки."""
from .server import WebAPIServer, create_app
__all__ = ["WebAPIServer", "create_app"]

View File

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

View File

@@ -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"]

147
app/webapi/routers/auth.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

385
app/webapi/routers/users.py Normal file
View File

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

364
app/webapi/schemas.py Normal file
View File

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

86
app/webapi/server.py Normal file
View File

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

26
main.py
View File

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

View File

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