mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-22 12:21:26 +00:00
feat: add web admin api server
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
166
app/database/crud/api_token.py
Normal file
166
app/database/crud/api_token.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
5
app/webapi/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Пакет с FastAPI-приложением для веб-админки."""
|
||||
|
||||
from .server import WebAPIServer, create_app
|
||||
|
||||
__all__ = ["WebAPIServer", "create_app"]
|
||||
90
app/webapi/dependencies.py
Normal file
90
app/webapi/dependencies.py
Normal 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
|
||||
16
app/webapi/routers/__init__.py
Normal file
16
app/webapi/routers/__init__.py
Normal 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
147
app/webapi/routers/auth.py
Normal 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)
|
||||
17
app/webapi/routers/health.py
Normal file
17
app/webapi/routers/health.py
Normal 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(),
|
||||
)
|
||||
152
app/webapi/routers/payments.py
Normal file
152
app/webapi/routers/payments.py
Normal 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],
|
||||
)
|
||||
96
app/webapi/routers/settings.py
Normal file
96
app/webapi/routers/settings.py
Normal 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)
|
||||
87
app/webapi/routers/stats.py
Normal file
87
app/webapi/routers/stats.py
Normal 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,
|
||||
)
|
||||
128
app/webapi/routers/tickets.py
Normal file
128
app/webapi/routers/tickets.py
Normal 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)
|
||||
45
app/webapi/routers/transactions.py
Normal file
45
app/webapi/routers/transactions.py
Normal 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
385
app/webapi/routers/users.py
Normal 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
364
app/webapi/schemas.py
Normal 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
86
app/webapi/server.py
Normal 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
26
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user