Optimizing database queries/ Combining cash registers into a single point/ Caching key moments/ API versioning/ ruff formatting

This commit is contained in:
Vladless
2026-02-20 00:27:10 +03:00
parent 2580835f55
commit 05a19f01b1
102 changed files with 3815 additions and 858 deletions
+55 -1
View File
@@ -6,7 +6,7 @@ from fastapi import Depends, HTTPException, Header, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session_maker
from database import async_session_maker, identities as idb
from database.models import Admin
@@ -30,3 +30,57 @@ async def verify_admin_token(
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
return admin
async def verify_identity_token(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
session: AsyncSession = Depends(get_session),
):
"""Проверяет пару identity_id + token; возвращает Identity. Для использования в API v2."""
identity = await idb.verify_identity_token(session, x_identity_id, token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
return identity
async def verify_identity_admin(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
session: AsyncSession = Depends(get_session),
):
"""Проверяет identity + token и что identity.is_admin; для админских ручек v2."""
identity = await idb.verify_identity_token(session, x_identity_id, token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
return identity
async def verify_identity_admin_short(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
):
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
async with async_session_maker() as session:
identity = await idb.verify_identity_token(session, x_identity_id, token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
return identity
async def verify_admin_token_short(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
) -> Admin:
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
hashed = hash_token(token)
async with async_session_maker() as session:
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
return admin
+12 -36
View File
@@ -1,26 +1,19 @@
from time import perf_counter
from fastapi import FastAPI, Request
from api.routes import (
users,
keys,
coupons,
servers,
tariffs,
gifts,
referrals,
misc,
partners,
modules,
management,
settings,
)
from config import API_LOGGING
from config import API_LOGGING, API_VERSION
from logger import logger
if API_VERSION == 1:
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
else:
from api.v2 import router as api_router, VERSION as API_DOC_VERSION
app = FastAPI(
title="SoloBot API (Alpha)",
version="0.5.2",
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
version=API_DOC_VERSION,
description=f"Версия API: **v{API_DOC_VERSION}**.",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
@@ -40,25 +33,8 @@ async def api_access_log_middleware(request: Request, call_next):
if request.url.query:
path_qs = f"{path_qs}?{request.url.query}"
logger.info(
f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms'
)
logger.info(f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms')
return response
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
app.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
app.include_router(misc.router, prefix="/api")
app.include_router(modules.router, prefix="/api")
app.include_router(management.router, prefix="/api/management", tags=["Management"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
@app.get("/api", include_in_schema=False)
async def root():
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
app.include_router(api_router)
-9
View File
@@ -1,9 +0,0 @@
from pydantic import BaseModel
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
+5
View File
@@ -0,0 +1,5 @@
from api.v1.router import router
VERSION = "1.0.0"
__all__ = ("router", "VERSION")
+37
View File
@@ -0,0 +1,37 @@
from fastapi import APIRouter
from api.v1.routes import (
users,
keys,
coupons,
servers,
tariffs,
gifts,
referrals,
misc,
partners,
modules,
management,
settings,
)
router = APIRouter()
@router.get("/api", include_in_schema=False)
async def root():
return {"message": "Welcome to SoloBot API", "docs": "/api/docs"}
router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
@@ -1,7 +1,7 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import CouponBase, CouponResponse, CouponUpdate
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import CouponBase, CouponResponse, CouponUpdate
from database.models import Coupon
@@ -3,8 +3,8 @@ from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Admin, Gift, GiftUsage
+3 -3
View File
@@ -5,8 +5,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
@@ -80,7 +80,7 @@ async def edit_key_by_email(
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
for field, value in key_update.dict(exclude_unset=True).items():
for field, value in key_update.model_dump(exclude_unset=True).items():
if field == "expiry_time" and value is not None:
if isinstance(value, int):
ms = value
@@ -1,219 +1,221 @@
import os
import re
import subprocess
import sys
import asyncio
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from config import API_TOKEN
from core.bootstrap import MANAGEMENT_CONFIG
from core.settings.management_config import update_management_config
from database.models import Key, User
from database.models import Server
from handlers.admin.sender.sender_service import BroadcastService
from handlers.admin.sender.sender_utils import get_recipients, parse_message_buttons
from logger import logger
from utils.backup import backup_database
router = APIRouter()
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
async def _restart_bot() -> None:
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
)
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception:
os._exit(1)
@router.get("/status")
async def get_status(admin=Depends(verify_admin_token)):
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
admin=Depends(verify_admin_token),
):
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
result = await session.execute(stmt)
await session.commit()
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
await session.commit()
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(admin=Depends(verify_admin_token)):
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
text_raw = (payload.text or "").strip()
if not text_raw:
raise HTTPException(status_code=400, detail="Broadcast text is required")
if payload.send_to == "cluster" and not (payload.cluster_name or "").strip():
raise HTTPException(status_code=400, detail="Cluster name is required for cluster broadcast")
clean_text, keyboard = parse_message_buttons(text_raw)
max_len = 1024 if payload.photo else 4096
if len(clean_text) > max_len:
raise HTTPException(status_code=400, detail=f"Message too long. Max {max_len} symbols")
tg_ids, total_users = await get_recipients(session, payload.send_to, (payload.cluster_name or None))
if not tg_ids:
return {"success": False, "message": "No recipients found", "stats": {"total_messages": 0}}
bot = _get_broadcast_bot()
messages = [
{
"tg_id": tg_id,
"text": clean_text,
"photo": payload.photo,
"keyboard": keyboard,
}
for tg_id in tg_ids
]
workers = max(1, min(int(payload.workers or 5), 30))
rate = max(1, min(int(payload.messages_per_second or 35), 60))
broadcast_service = BroadcastService(bot=bot, session=session, messages_per_second=rate)
stats = await broadcast_service.broadcast(messages, workers=workers)
return {
"success": True,
"message": "Broadcast completed",
"recipients": total_users,
"stats": stats,
}
import os
import re
import subprocess
import sys
import asyncio
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token, verify_admin_token_short
from database import async_session_maker
from config import API_TOKEN, BOT_SERVICE
from core.bootstrap import MANAGEMENT_CONFIG
from core.settings.management_config import update_management_config
from database.models import Key, User
from database.models import Server
from handlers.admin.sender.sender_service import BroadcastService
from handlers.admin.sender.sender_utils import get_recipients, parse_message_buttons
from logger import logger
from utils.backup import backup_database
router = APIRouter()
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
async def _restart_bot() -> None:
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
subprocess.run(
["sudo", "systemctl", "restart", BOT_SERVICE],
check=True,
)
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception:
os._exit(1)
@router.get("/status")
async def get_status(admin=Depends(verify_admin_token)):
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
admin=Depends(verify_admin_token),
):
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
result = await session.execute(stmt)
await session.commit()
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
await session.commit()
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(admin=Depends(verify_admin_token)):
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
admin=Depends(verify_admin_token_short),
):
"""Запуск рассылки. Сессия БД не держится на время рассылки."""
text_raw = (payload.text or "").strip()
if not text_raw:
raise HTTPException(status_code=400, detail="Broadcast text is required")
if payload.send_to == "cluster" and not (payload.cluster_name or "").strip():
raise HTTPException(status_code=400, detail="Cluster name is required for cluster broadcast")
clean_text, keyboard = parse_message_buttons(text_raw)
max_len = 1024 if payload.photo else 4096
if len(clean_text) > max_len:
raise HTTPException(status_code=400, detail=f"Message too long. Max {max_len} symbols")
async with async_session_maker() as session:
tg_ids, total_users = await get_recipients(session, payload.send_to, (payload.cluster_name or None))
if not tg_ids:
return {"success": False, "message": "No recipients found", "stats": {"total_messages": 0}}
bot = _get_broadcast_bot()
messages = [
{
"tg_id": tg_id,
"text": clean_text,
"photo": payload.photo,
"keyboard": keyboard,
}
for tg_id in tg_ids
]
workers = max(1, min(int(payload.workers or 5), 30))
rate = max(1, min(int(payload.messages_per_second or 35), 60))
broadcast_service = BroadcastService(bot=bot, session=None, messages_per_second=rate)
stats = await broadcast_service.broadcast(messages, workers=workers)
return {
"success": True,
"message": "Broadcast completed",
"recipients": total_users,
"stats": stats,
}
+2 -2
View File
@@ -3,8 +3,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import (
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
+124 -124
View File
@@ -1,124 +1,124 @@
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_admin_token
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[2] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
@router.get("/")
async def list_modules(admin=Depends(verify_admin_token)):
refresh = getattr(manager, "refresh_state", None)
if callable(refresh):
refresh()
else:
legacy_refresh = getattr(manager, "_load_state", None)
if callable(legacy_refresh):
legacy_refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
local_version = _read_local_module_version(name)
item["local_version"] = local_version
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"item": _module_state(name)}
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_admin_token
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
@router.get("/")
async def list_modules(admin=Depends(verify_admin_token)):
refresh = getattr(manager, "refresh_state", None)
if callable(refresh):
refresh()
else:
legacy_refresh = getattr(manager, "_load_state", None)
if callable(legacy_refresh):
legacy_refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
local_version = _read_local_module_version(name)
item["local_version"] = local_version
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"item": _module_state(name)}
@@ -3,8 +3,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import ReferralResponse
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import ReferralResponse
from database.models import Admin, Referral
@@ -1,7 +1,7 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import ServerBase, ServerResponse, ServerUpdate
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
@@ -1,13 +1,13 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.schemas.settings import SettingResponse, SettingUpsert
from database.models import Setting
from database.settings import set_setting
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from database import settings_cache
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
@@ -15,7 +15,8 @@ from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_noti
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from pydantic import BaseModel
from database.models import Setting
from database.settings import set_setting
router = APIRouter()
@@ -26,12 +27,9 @@ class ConfigUpdatePayload(BaseModel):
@router.get("/", response_model=list[SettingResponse])
async def get_all_settings(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting))
return result.scalars().all()
async def get_all_settings(admin=Depends(verify_admin_token)):
"""Список всех настроек (из кэша, без запроса к БД)."""
return settings_cache.get_all()
@router.get("/configs")
@@ -106,13 +104,9 @@ async def update_config_scope(
@router.get("/{key}", response_model=SettingResponse)
async def get_setting_by_key(
key: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
async def get_setting_by_key(key: str, admin=Depends(verify_admin_token)):
"""Настройка по ключу (из кэша, без запроса к БД)."""
obj = settings_cache.get(key)
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
return obj
@@ -133,6 +127,13 @@ async def upsert_setting(
)
await session.commit()
await session.refresh(obj)
settings_cache.update(
key,
obj.value,
obj.description,
created_at=getattr(obj, "created_at", None),
updated_at=getattr(obj, "updated_at", None),
)
return obj
@@ -148,6 +149,5 @@ async def delete_setting(
raise HTTPException(status_code=404, detail="Setting not found")
await session.delete(obj)
await session.commit()
settings_cache.delete(key)
return {"detail": "Setting deleted"}
@@ -1,7 +1,7 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import TariffBase, TariffResponse, TariffUpdate
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import TariffBase, TariffResponse, TariffUpdate
from database.models import Tariff
@@ -5,8 +5,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
from database import delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.operations import delete_key_from_cluster
+5
View File
@@ -0,0 +1,5 @@
from api.v2.router import router
VERSION = "2.0.0"
__all__ = ("router", "VERSION")
+147
View File
@@ -0,0 +1,147 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_identity_admin
from api.v1.routes.base_crud import cast_identifier_type, normalize_outgoing_object, to_schema
def generate_crud_router(
*,
model: type,
schema_response: type,
schema_create: type,
schema_update: type,
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
async def get_all(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
return [schema_response.model_validate(item, from_attributes=True) for item in items]
if "get_by_email" in enabled_methods and extra_get_by_email:
@router.get("/by_email", response_model=schema_response)
async def get_by_email(
email: str = Query(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model).where(model.email == email))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return to_schema(schema_response, obj)
if "get_one" in enabled_methods:
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
async def get_one(
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return to_schema(schema_response, obj)
if "get_all_by_field" in enabled_methods:
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
async def get_all_by_field(
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for obj in objs:
normalize_outgoing_object(obj)
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: Any = Body(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
validated = schema_create.model_validate(payload)
data = validated.model_dump(exclude_unset=True)
if "days" in data and data["days"] == 0:
data["days"] = None
obj = model(**data)
session.add(obj)
await session.commit()
await session.refresh(obj)
return to_schema(schema_response, obj)
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: Any = Body(...),
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
validated = schema_update.model_validate(payload)
for k, v in validated.model_dump(exclude_unset=True).items():
setattr(obj, k, v)
await session.commit()
await session.refresh(obj)
return to_schema(schema_response, obj)
if "delete" in enabled_methods:
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
async def delete(
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
await session.delete(obj)
await session.commit()
return {"detail": f"{model.__name__} deleted"}
return router
__all__ = ("generate_crud_router", "to_schema", "normalize_outgoing_object", "cast_identifier_type")
+39
View File
@@ -0,0 +1,39 @@
from fastapi import APIRouter
from api.v2.routes import (
root_router,
auth,
users,
keys,
coupons,
servers,
tariffs,
gifts,
referrals,
misc,
partners,
modules,
management,
settings,
payment_links,
identities,
)
router = APIRouter()
router.include_router(root_router)
router.include_router(auth.router, prefix="/api")
router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
router.include_router(payment_links.router, prefix="/api/payment-links", tags=["PaymentLinks"])
router.include_router(identities.router, prefix="/api/identities", tags=["Identities"])
router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
+3
View File
@@ -0,0 +1,3 @@
from api.v2.routes.root import router as root_router
__all__ = ("root_router",)
+100
View File
@@ -0,0 +1,100 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.schemas.identities import (
IdentityResponse,
LinkTelegramRequest,
LoginRequest,
LoginResponse,
LoginTelegramRequest,
RegisterByEmailRequest,
RegisterResponse,
)
from config import API_TOKEN_TTL_DAYS, API_TOKEN
from database import identities as idb
from utils.telegram_login import verify_telegram_login
router = APIRouter(prefix="/auth", tags=["Auth"])
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
TELEGRAM_LOGIN_MAX_AGE = 86400 # 24 часа
@router.post("/register", response_model=RegisterResponse)
async def register_by_email(
body: RegisterByEmailRequest,
session: AsyncSession = Depends(get_session),
):
(
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
if not body.password or len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
existing = await idb.get_identity_by_email(session, email)
if existing:
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
identity, token = await idb.create_identity_with_token(session, email=email, password=body.password)
return RegisterResponse(identity_id=identity.id, token=token)
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
result = await idb.login_by_email(session, email, body.password)
if not result:
raise HTTPException(status_code=401, detail="Неверный email или пароль")
identity, token = result
return LoginResponse(identity_id=identity.id, token=token)
@router.post("/login-telegram", response_model=LoginResponse)
async def login_telegram(
body: LoginTelegramRequest,
session: AsyncSession = Depends(get_session),
):
(
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
identity = await idb.get_or_create_identity_for_tg(session, body.id)
token = await idb.issue_token_for_identity(session, identity)
return LoginResponse(identity_id=identity.id, token=token)
@router.post("/link-telegram", response_model=IdentityResponse)
async def link_telegram(
body: LinkTelegramRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Привязывает Telegram (tg_id) к текущей идентичности. Требуется X-Identity-Id и X-Token."""
result = await idb.attach_telegram(session, identity.id, body.tg_id)
if not result:
raise HTTPException(
status_code=409,
detail="Этот Telegram уже привязан к другой идентичности",
)
return IdentityResponse.model_validate(result)
@router.get("/me", response_model=IdentityResponse)
async def me(
identity=Depends(verify_identity_token),
):
"""Текущая идентичность по заголовкам X-Identity-Id и X-Token."""
return IdentityResponse.model_validate(identity)
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
from database.models import Coupon
router = generate_crud_router(
model=Coupon,
schema_response=CouponResponse,
schema_create=CouponBase,
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+63
View File
@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Gift, GiftUsage
router = APIRouter()
gift_router = generate_crud_router(
model=Gift,
schema_response=GiftResponse,
schema_create=GiftBase,
schema_update=GiftUpdate,
identifier_field="gift_id",
parameter_name="gift_id",
enabled_methods=["get_all", "get_one", "create", "update"],
)
router.include_router(gift_router, prefix="", tags=["Gifts"])
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
async def get_gifts_by_tg_id(
tg_id: int = Path(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список подарков по tg_id отправителя."""
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
gifts = result.scalars().all()
if not gifts:
raise HTTPException(status_code=404, detail="Gifts not found")
return gifts
gift_usage_router = generate_crud_router(
model=GiftUsage,
schema_response=GiftUsageResponse,
schema_create=None,
schema_update=None,
identifier_field="gift_id",
enabled_methods=["get_all", "get_one", "delete"],
)
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts", "GiftUsages"])
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_gift_with_usages(
gift_id: str = Path(..., description="ID подарка"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаляет подарок и все его использования."""
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift:
raise HTTPException(status_code=404, detail="Gift not found")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
await session.commit()
return {"message": "Подарок и связанные использования удалены"}
+100
View File
@@ -0,0 +1,100 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas.identities import (
IdentityAttachEmail,
IdentityAttachTelegram,
IdentityCreate,
IdentityResponse,
)
from database import identities as idb
router = APIRouter(tags=["Identities"])
@router.post("/", response_model=IdentityResponse)
async def create_identity(
body: IdentityCreate,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Создаёт идентичность; можно указать email и/или tg_id."""
email = body.email.strip().lower() if body.email and body.email.strip() else None
if not email and not body.tg_id:
raise HTTPException(status_code=400, detail="Укажите email и/или tg_id")
identity = await idb.create_identity(session, email=email, tg_id=body.tg_id)
return IdentityResponse.model_validate(identity)
@router.get("/{identity_id}", response_model=IdentityResponse)
async def get_identity(
identity_id: str = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по id."""
identity = await idb.get_identity_by_id(session, identity_id)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.get("/by/email", response_model=IdentityResponse)
async def get_identity_by_email(
email: str = Query(..., min_length=1),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по email."""
identity = await idb.get_identity_by_email(session, email)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.get("/by/tg_id/{tg_id}", response_model=IdentityResponse)
async def get_identity_by_tg_id(
tg_id: int = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по Telegram ID."""
identity = await idb.get_identity_by_tg_id(session, tg_id)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.patch("/{identity_id}/attach-email", response_model=IdentityResponse)
async def attach_email(
identity_id: str = Path(...),
body: IdentityAttachEmail = ...,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Привязывает email к идентичности."""
identity = await idb.attach_email(session, identity_id, body.email)
if not identity:
raise HTTPException(
status_code=404,
detail="Identity not found или email уже привязан к другой идентичности",
)
return IdentityResponse.model_validate(identity)
@router.patch("/{identity_id}/attach-telegram", response_model=IdentityResponse)
async def attach_telegram(
identity_id: str = Path(...),
body: IdentityAttachTelegram = ...,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Привязывает Telegram (tg_id) к идентичности."""
identity = await idb.attach_telegram(session, identity_id, body.tg_id)
if not identity:
raise HTTPException(
status_code=404,
detail="Identity not found или tg_id уже привязан к другой идентичности",
)
return IdentityResponse.model_validate(identity)
+134
View File
@@ -0,0 +1,134 @@
from datetime import datetime
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from api.v2.base_crud import generate_crud_router
from database.models import Key, Tariff
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Удаляет ключ по email с кластера и из БД."""
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
try:
await delete_key_from_cluster(
session=session,
email=db_key.email,
client_id=db_key.client_id,
cluster_id=db_key.server_id,
)
await session.delete(db_key)
await session.commit()
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
return {"message": "Ключ успешно удалён"}
except Exception as e:
logger.error(f"[API] Ошибка при удалении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
async def get_router_keys_by_tg_id(
tg_id: int = Path(..., description="Telegram ID пользователя"),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Список ключей пользователя с тарифами группы routers."""
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
return keys_result.scalars().all()
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
async def edit_key_by_email(
email: str = Path(..., description="Email клиента"),
key_update: KeyUpdate = Body(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Обновляет ключ по email и синхронизирует с кластером."""
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
for field, value in key_update.model_dump(exclude_unset=True).items():
if field == "expiry_time" and value is not None:
if isinstance(value, int):
ms = value
elif isinstance(value, datetime):
ms = int(value.timestamp() * 1000)
else:
raise HTTPException(status_code=400, detail="Некорректный формат времени")
setattr(db_key, field, ms)
else:
setattr(db_key, field, value)
try:
new_expiry_time = db_key.expiry_time
await renew_key_in_cluster(
cluster_id=db_key.server_id,
email=db_key.email,
client_id=db_key.client_id,
new_expiry_time=new_expiry_time,
total_gb=getattr(db_key, "traffic_limit", None),
session=session,
hwid_device_limit=getattr(db_key, "device_limit", None),
reset_traffic=True,
)
await session.commit()
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
return db_key
except Exception as e:
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_key_api(
payload: KeyCreateRequest = Body(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Создаёт ключ на кластере."""
try:
await create_key_on_cluster(
cluster_id=payload.cluster_id,
tg_id=payload.tg_id,
client_id=payload.client_id,
email=payload.email or f"{payload.tg_id}_key",
expiry_timestamp=payload.expiry_timestamp,
plan=payload.tariff_id,
session=session,
remnawave_link=payload.remnawave_link,
hwid_limit=payload.hwid_limit,
traffic_limit_bytes=payload.traffic_limit_bytes,
is_trial=payload.is_trial or False,
)
return {"message": "Ключ успешно создан"}
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
+207
View File
@@ -0,0 +1,207 @@
import asyncio
import os
import re
import subprocess
import sys
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin, verify_identity_admin_short
from database import async_session_maker
from config import API_TOKEN, BOT_SERVICE
from core.bootstrap import MANAGEMENT_CONFIG
from core.settings.management_config import update_management_config
from database.models import Key, Server, User
from handlers.admin.sender.sender_service import BroadcastService
from handlers.admin.sender.sender_utils import get_recipients, parse_message_buttons
from logger import logger
from utils.backup import backup_database
router = APIRouter()
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
"""Возвращает экземпляр бота для рассылки."""
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
async def _restart_bot() -> None:
"""Перезапуск процесса бота (systemctl или execv)."""
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
subprocess.run(["sudo", "systemctl", "restart", BOT_SERVICE], check=True)
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception:
os._exit(1)
@router.get("/status")
async def get_status(identity=Depends(verify_identity_admin)):
"""Текущий статус: maintenance и management config."""
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Включение/выключение режима обслуживания."""
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
identity=Depends(verify_identity_admin),
):
"""Запуск перезапуска бота в фоне."""
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Массовая замена домена в ключах и remnawave_link."""
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
result = await session.execute(stmt)
await session.commit()
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает trial=0 у пользователей без ключей."""
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
await session.commit()
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(identity=Depends(verify_identity_admin)):
"""Запуск бэкапа БД в фоне."""
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список кластеров для рассылки по кластеру."""
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
identity=Depends(verify_identity_admin_short),
):
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
text_raw = (payload.text or "").strip()
if not text_raw:
raise HTTPException(status_code=400, detail="Broadcast text is required")
if payload.send_to == "cluster" and not (payload.cluster_name or "").strip():
raise HTTPException(status_code=400, detail="Cluster name is required for cluster broadcast")
clean_text, keyboard = parse_message_buttons(text_raw)
max_len = 1024 if payload.photo else 4096
if len(clean_text) > max_len:
raise HTTPException(status_code=400, detail=f"Message too long. Max {max_len} symbols")
async with async_session_maker() as session:
tg_ids, total_users = await get_recipients(session, payload.send_to, (payload.cluster_name or None))
if not tg_ids:
return {"success": False, "message": "No recipients found", "stats": {"total_messages": 0}}
bot = _get_broadcast_bot()
messages = [{"tg_id": tg_id, "text": clean_text, "photo": payload.photo, "keyboard": keyboard} for tg_id in tg_ids]
workers = max(1, min(int(payload.workers or 5), 30))
rate = max(1, min(int(payload.messages_per_second or 35), 60))
broadcast_service = BroadcastService(bot=bot, session=None, messages_per_second=rate)
stats = await broadcast_service.broadcast(messages, workers=workers)
return {
"success": True,
"message": "Broadcast completed",
"recipients": total_users,
"stats": stats,
}
+152
View File
@@ -0,0 +1,152 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from api.v2.base_crud import generate_crud_router
from database import get_tracking_source_stats
from database.models import (
BlockedUser,
ManualBan,
Notification,
Payment,
TemporaryData,
TrackingSource,
)
router = APIRouter()
router.include_router(
generate_crud_router(
model=Payment,
schema_response=PaymentResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/payments",
tags=["Payments"],
dependencies=[Depends(verify_identity_admin)],
)
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
async def get_payments_by_tg_id(
tg_id: int = Path(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список платежей по tg_id пользователя."""
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
payments = result.scalars().all()
if not payments:
raise HTTPException(status_code=404, detail="Payments not found")
return payments
router.include_router(
generate_crud_router(
model=Notification,
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
tags=["Notifications"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=ManualBan,
schema_response=ManualBanResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
tags=["Bans"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=BlockedUser,
schema_response=BlockedUserResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
tags=["Bans"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=TemporaryData,
schema_response=TemporaryDataResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
tags=["TemporaryData"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=TrackingSource,
schema_response=TrackingSourceResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_identity_admin)],
)
@router.get(
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_identity_admin)]
)
async def get_tracking_source_with_stats(
code: str,
session: AsyncSession = Depends(get_session),
):
"""Источник по коду со статистикой регистраций и платежей."""
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Tracking source not found")
stats = await get_tracking_source_stats(session, code)
return TrackingSourceResponse(
id=source.id,
name=source.name,
code=source.code,
type=source.type,
created_by=source.created_by,
created_at=source.created_at,
registrations=(stats["registrations"] if stats else 0),
trials=(stats["trials"] if stats else 0),
payments=(stats["payments"] if stats else 0),
total_amount=(float(stats["total_amount"]) if stats else 0.0),
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
)
+119
View File
@@ -0,0 +1,119 @@
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_identity_admin
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
"""Имена модулей из папки modules, прошедшие проверку безопасности."""
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
"""Удаляет из состояния менеджера модули, которых нет в файловой системе."""
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
"""Состояние модуля: enabled, loaded, autostart."""
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
"""Читает VERSION из папки модуля."""
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
@router.get("/")
async def list_modules(identity=Depends(verify_identity_admin)):
"""Список модулей с состоянием и локальной версией."""
refresh = getattr(manager, "refresh_state", None)
if callable(refresh):
refresh()
else:
legacy_refresh = getattr(manager, "_load_state", None)
if callable(legacy_refresh):
legacy_refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
local_version = _read_local_module_version(name)
item["local_version"] = local_version
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, identity=Depends(verify_identity_admin)):
"""Запуск, остановка или перезапуск модуля."""
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"item": _module_state(name)}
+689
View File
@@ -0,0 +1,689 @@
import csv
from datetime import datetime
from io import StringIO
import re
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
except Exception:
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
router = APIRouter()
def _parse_percent(value: float) -> float | None:
try:
val = float(value)
except (TypeError, ValueError):
return None
if 0.0 <= val <= 1.0:
val *= 100.0
if 0.0 <= val <= 100.0:
return val
return None
def _default_partner_percent() -> float:
try:
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
except Exception:
return 0.0
def _row_dt_iso(value) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
return None
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список всех партнёров со статистикой. Требуется админ (X-Identity-Id + X-Token)."""
partners_sql = text(
"""
SELECT
p.partner_tg_id AS tg_id,
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method,
COUNT(p.joined_tg_id) as joined_count
FROM partners p
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
WHERE p.partner_tg_id IS NOT NULL
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
ORDER BY partner_balance DESC
LIMIT :limit OFFSET :offset
"""
)
count_sql = text(
"""
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
WHERE partner_tg_id IS NOT NULL
"""
)
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
partners = result.fetchall()
count_result = await session.execute(count_sql)
total = count_result.scalar() or 0
default_percent = _default_partner_percent()
partners_list = []
for partner in partners:
percent_value = partner[2]
percent_custom = bool(partner[3])
percent = float(percent_value) if (percent_custom and percent_value is not None) else float(default_percent)
partners_list.append({
"tg_id": int(partner[0]),
"balance": float(partner[1] or 0),
"percent": percent,
"code": partner[4] or None,
"method": partner[5] or None,
"referred_count": int(partner[6] or 0),
})
return JSONResponse(content={"total": total, "items": partners_list})
@router.get("/stats/all")
async def get_partners_stats(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Общая статистика партнёрской программы."""
stats_sql = text(
"""
WITH partner_refs AS (
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
FROM partners
WHERE partner_tg_id IS NOT NULL
GROUP BY partner_tg_id
)
SELECT
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
(SELECT COUNT(DISTINCT partner_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL AND DATE(created_at) = CURRENT_DATE) AS partners_today,
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
(SELECT COALESCE(SUM(u.partner_balance), 0.0) FROM users u WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)) AS total_balance,
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
"""
)
stats_result = await session.execute(stats_sql)
stats_row = stats_result.fetchone()
if stats_row:
stats = {
"total_partners": int(stats_row[0] or 0),
"partners_today": int(stats_row[1] or 0),
"total_referred": int(stats_row[2] or 0),
"total_balance": float(stats_row[3] or 0.0),
"top_partner_tg_id": int(stats_row[4] or 0),
"top_partner_refs": int(stats_row[5] or 0),
}
else:
stats = {
"total_partners": 0,
"partners_today": 0,
"total_referred": 0,
"total_balance": 0.0,
"top_partner_tg_id": 0,
"top_partner_refs": 0,
}
return JSONResponse(content=stats)
@router.patch("/{tg_id}")
async def update_partner(
tg_id: int = Path(..., description="Telegram ID партнёра"),
balance: float = Query(..., description="Новый баланс партнёра"),
percent: float = Query(..., description="Новый процент партнёра"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Обновляет данные партнёра (баланс и процент)."""
try:
stmt = text(
"""
UPDATE users
SET partner_balance = :balance, partner_percent = :percent
WHERE tg_id = :tg_id
"""
)
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"}, status_code=200
)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.get("/{tg_id}")
async def get_partner_data(
tg_id: int = Path(..., description="Telegram ID партнёра"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Партнёрские данные по tg_id и список приглашённых."""
meta_sql = text(
"""
SELECT COALESCE(u.partner_balance, 0), u.partner_percent, COALESCE(u.partner_percent_custom, false), u.partner_code, u.payout_method
FROM users u WHERE u.tg_id = :tg_id
"""
)
invited_sql = text(
"""
SELECT pr.joined_tg_id, pr.created_at, COALESCE(u.balance, 0),
(SELECT COUNT(*) FROM keys k WHERE k.tg_id = pr.joined_tg_id),
(SELECT COUNT(*) FROM payments pay WHERE pay.tg_id = pr.joined_tg_id AND lower(pay.status) = 'success')
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
meta_row = meta_res.fetchone()
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
default_percent = _default_partner_percent()
percent = default_percent
if meta_row:
percent_value, percent_custom = meta_row[1], bool(meta_row[2])
if percent_custom and percent_value is not None:
percent = float(percent_value)
response = {
"tg_id": tg_id,
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
"partner_percent": percent,
"partner_code": meta_row[3] if meta_row else None,
"payout_method": meta_row[4] if meta_row else None,
"invited": [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
],
}
return JSONResponse(content=response)
@router.post("/{tg_id}/invited")
async def add_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Добавляет приглашённого пользователя партнёру."""
if joined_tg_id == tg_id:
return JSONResponse(
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"}, status_code=400
)
try:
partner_exists = await session.execute(text("SELECT 1 FROM users WHERE tg_id = :tg_id"), {"tg_id": tg_id})
if not partner_exists.scalar():
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
invited_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"), {"joined_tg_id": joined_tg_id}
)
if not invited_exists.scalar():
return JSONResponse(
content={"success": False, "message": "Приглашённый пользователь не найден"}, status_code=404
)
existing = await session.execute(
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
existing_partner = existing.scalar()
if existing_partner is not None:
return JSONResponse(
content={"success": False, "message": f"Пользователь уже привязан к партнёру {existing_partner}"},
status_code=409,
)
await session.execute(
text("INSERT INTO partners (partner_tg_id, joined_tg_id) VALUES (:partner_tg_id, :joined_tg_id)"),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
await session.commit()
return JSONResponse(
content={
"success": True,
"message": "Приглашённый добавлен",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=201,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.delete("/{tg_id}/invited/{joined_tg_id}")
async def delete_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаляет приглашённого у партнёра."""
try:
result = await session.execute(
text("DELETE FROM partners WHERE partner_tg_id = :partner_tg_id AND joined_tg_id = :joined_tg_id"),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={
"success": True,
"message": "Приглашённый удалён",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=200,
)
return JSONResponse(
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"}, status_code=404
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/percent")
async def update_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Обновляет персональный процент партнёра."""
normalized = _parse_percent(percent)
if normalized is None:
return JSONResponse(
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"}, status_code=400
)
try:
result = await session.execute(
text("UPDATE users SET partner_percent = :percent, partner_percent_custom = true WHERE tg_id = :tg_id"),
{"tg_id": tg_id, "percent": normalized},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={"success": True, "message": "Процент обновлён", "percent": normalized}, status_code=200
)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/balance")
async def update_partner_balance(
tg_id: int = Path(..., description="Telegram ID партнёра"),
amount: float = Query(..., description="Сумма операции"),
mode: str = Query("set", description="Режим: set, add, subtract"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Изменяет баланс партнёрской программы."""
mode_normalized = (mode or "set").strip().lower()
if mode_normalized not in {"set", "add", "subtract"}:
return JSONResponse(
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"}, status_code=400
)
try:
amount_val = float(amount)
except (TypeError, ValueError):
return JSONResponse(content={"success": False, "message": "Неверная сумма"}, status_code=400)
if amount_val < 0:
return JSONResponse(content={"success": False, "message": "Сумма не может быть отрицательной"}, status_code=400)
try:
current_res = await session.execute(
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"), {"tg_id": tg_id}
)
current_balance = current_res.scalar()
if current_balance is None:
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
current_balance = float(current_balance or 0.0)
if mode_normalized == "set":
new_balance = amount_val
elif mode_normalized == "add":
new_balance = current_balance + amount_val
else:
if current_balance < amount_val:
return JSONResponse(content={"success": False, "message": "Недостаточно средств"}, status_code=400)
new_balance = current_balance - amount_val
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"tg_id": tg_id, "balance": new_balance},
)
await session.commit()
return JSONResponse(
content={"success": True, "message": "Баланс обновлён", "balance": new_balance}, status_code=200
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.get("/{tg_id}/invited")
async def get_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список приглашённых партнёра."""
invited_sql = text(
"""
SELECT pr.joined_tg_id, pr.created_at, COALESCE(u.balance, 0),
(SELECT COUNT(*) FROM keys k WHERE k.tg_id = pr.joined_tg_id),
(SELECT COUNT(*) FROM payments pay WHERE pay.tg_id = pr.joined_tg_id AND lower(pay.status) = 'success')
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
invited_list = [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
]
return JSONResponse(content=invited_list)
@router.get("/payouts/pending")
async def get_partner_payouts_pending(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
partner_tg_id: int | None = Query(None),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Ожидающие заявки на вывод."""
where_clause = "WHERE pr.status = 'pending'"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT pr.id, pr.tg_id, pr.amount, pr.status, pr.created_at,
COALESCE(pr.method, u.payout_method) AS method, COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at ASC, pr.id ASC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = [
{
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "pending",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
}
for row in result.fetchall()
]
return JSONResponse(content={"total": int(total), "items": items})
@router.get("/payouts/history")
async def get_partner_payouts_history(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
partner_tg_id: int | None = Query(None),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""История выплат (approved/rejected)."""
where_clause = "WHERE pr.status IN ('approved','rejected')"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT pr.id, pr.tg_id, pr.amount, pr.status, pr.created_at,
COALESCE(pr.method, u.payout_method) AS method, COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at DESC, pr.id DESC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = [
{
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
}
for row in result.fetchall()
]
return JSONResponse(content={"total": int(total), "items": items})
@router.post("/payouts/{payout_id}/approve")
async def approve_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Одобряет заявку на вывод."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return JSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"}, status_code=404
)
user_row = await session.execute(
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"), {"tg_id": req[1]}
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"UPDATE payout_requests SET status = 'approved', method = :method, destination = :destination WHERE id = :id"
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
@router.post("/payouts/{payout_id}/reject")
async def reject_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return JSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"}, status_code=404
)
user_row = await session.execute(
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"), {"tg_id": req[1]}
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"UPDATE payout_requests SET status = 'rejected', method = :method, destination = :destination WHERE id = :id"
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
if user is not None:
current_balance = float(user[2] or 0.0)
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
@router.patch("/{tg_id}/percent/reset")
async def reset_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает персональный процент партнёра к дефолту."""
result = await session.execute(
text("UPDATE users SET partner_percent = NULL, partner_percent_custom = false WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.patch("/{tg_id}/code")
async def update_partner_code(
tg_id: int = Path(..., description="Telegram ID партнёра"),
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Обновляет код партнёрской ссылки."""
raw = (code or "").strip().lower()
if not raw:
return JSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
return JSONResponse(
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"}, status_code=400
)
exists = await session.execute(
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"), {"code": raw, "tg_id": tg_id}
)
if exists.first():
return JSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
result = await session.execute(
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"), {"code": raw, "tg_id": tg_id}
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.post("/reset-disabled-methods")
async def reset_disabled_payout_methods(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает реквизиты для отключённых способов вывода."""
try:
from modules.partner_program.settings import (
ENABLE_PAYOUT_CARD,
ENABLE_PAYOUT_SBP,
ENABLE_PAYOUT_TON,
ENABLE_PAYOUT_USDT,
)
from modules.partner_program import buttons as B
except Exception:
ENABLE_PAYOUT_CARD = True
ENABLE_PAYOUT_USDT = True
ENABLE_PAYOUT_TON = True
ENABLE_PAYOUT_SBP = True
B = None
disabled = []
if not ENABLE_PAYOUT_CARD and B:
disabled.append(B.METHOD_CARD)
if not ENABLE_PAYOUT_USDT and B:
disabled.append(B.METHOD_USDT)
if not ENABLE_PAYOUT_TON and B:
disabled.append(B.METHOD_TON)
if not ENABLE_PAYOUT_SBP and B:
disabled.append(B.METHOD_SBP)
if not disabled:
return JSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
await session.execute(
text("UPDATE users SET card_number = NULL WHERE payout_method = ANY(:methods)"), {"methods": disabled}
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
@router.get("/{tg_id}/export")
async def export_partner_invites_csv(
tg_id: int = Path(..., description="Telegram ID партнёра"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Экспорт приглашённых партнёром в CSV."""
rows = await session.execute(
text("SELECT joined_tg_id, created_at FROM partners WHERE partner_tg_id = :tg_id ORDER BY created_at ASC"),
{"tg_id": tg_id},
)
data = rows.fetchall()
if not data:
return JSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
buffer = StringIO()
writer = csv.writer(buffer, delimiter=";")
writer.writerow(["joined_tg_id", "created_at"])
for joined_tg_id, created_at in data:
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
content = buffer.getvalue().encode("utf-8-sig")
filename = f"partner_invites_{tg_id}.csv"
return StreamingResponse(
iter([content]), media_type="text/csv", headers={"Content-Disposition": f"attachment; filename={filename}"}
)
+51
View File
@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse
from database import identities as idb
from handlers.payments import create_payment_link
from handlers.payments.payment_links import PaymentLinkRequest
router = APIRouter(tags=["PaymentLinks"])
async def _resolve_tg_id(body: PaymentLinkCreateRequest, session: AsyncSession) -> int:
"""Возвращает tg_id из body.tg_id или из identity_id; иначе исключение."""
if body.tg_id is not None:
return body.tg_id
if body.identity_id:
tg_id = await idb.resolve_tg_id(session, body.identity_id)
if tg_id is not None:
return tg_id
raise HTTPException(
status_code=400,
detail="У идентичности не привязан Telegram. Привяжите tg_id для создания платёжной ссылки.",
)
raise HTTPException(status_code=400, detail="Укажите tg_id или identity_id")
@router.post("/", response_model=PaymentLinkCreateResponse)
async def create_link(
body: PaymentLinkCreateRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Создаёт платёжную ссылку через выбранную кассу (единая точка входа). Принимает identity_id или tg_id."""
tg_id = await _resolve_tg_id(body, session)
request = PaymentLinkRequest(
tg_id=tg_id,
amount=body.amount,
currency=body.currency or "RUB",
provider_id=body.provider_id,
success_url=body.success_url,
failure_url=body.failure_url,
metadata=body.metadata,
)
result = await create_payment_link(session, request)
return PaymentLinkCreateResponse(
success=result.success,
payment_id=result.payment_id,
payment_url=result.payment_url,
error=result.error,
)
+37
View File
@@ -0,0 +1,37 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import ReferralResponse
from database.models import Referral
router = generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_tg_id",
parameter_name="referrer_tg_id",
enabled_methods=["get_all", "get_one", "get_all_by_field"],
)
@router.delete("/one")
async def delete_one_referral(
referrer_tg_id: int = Query(..., description="ID пригласившего"),
referred_tg_id: int = Query(..., description="ID приглашённого"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаляет одну связь реферала по паре referrer/referred."""
result = await session.execute(
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
await session.commit()
return {"status": "deleted_one"}
+13
View File
@@ -0,0 +1,13 @@
from fastapi import APIRouter
router = APIRouter(tags=["Root"])
@router.get("/api", include_in_schema=False)
async def root():
return {"message": "SoloBot API v2", "docs": "/api/docs"}
@router.get("/api/version", include_in_schema=True)
async def version():
return {"version": 2, "api": "v2"}
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter
from api.v2.schemas import ServerBase, ServerResponse, ServerUpdate
from api.v2.base_crud import generate_crud_router
from database.models import Server
router = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+148
View File
@@ -0,0 +1,148 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import SettingResponse, SettingUpsert
from database import settings_cache
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from database.models import Setting
from database.settings import set_setting
router = APIRouter()
class ConfigUpdatePayload(BaseModel):
value: dict[str, Any] | None = None
@router.get("/", response_model=list[SettingResponse])
async def get_all_settings(identity=Depends(verify_identity_admin)):
"""Список всех настроек (из кэша, без запроса к БД)."""
return settings_cache.get_all()
@router.get("/configs")
async def get_configs(identity=Depends(verify_identity_admin)):
"""Все конфиги (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
return {
"payments": dict(PAYMENTS_CONFIG),
"buttons": dict(BUTTONS_CONFIG),
"notifications": dict(NOTIFICATIONS_CONFIG),
"modes": dict(MODES_CONFIG),
"money": dict(MONEY_CONFIG),
"providers_order": dict(PROVIDERS_ORDER),
"tariffs": dict(TARIFFS_CONFIG),
}
@router.post("/configs/{scope}")
async def update_config_scope(
scope: str,
payload: ConfigUpdatePayload,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Обновление конфига по scope (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
data = dict(payload.value or {})
normalized = scope.strip().lower().replace("-", "_")
if normalized == "payments":
cleaned = {key: bool(value) for key, value in data.items()}
await update_payments_config(session, cleaned)
return {"payments": dict(PAYMENTS_CONFIG)}
if normalized == "buttons":
cleaned = {key: bool(value) for key, value in data.items()}
await update_buttons_config(session, cleaned)
return {"buttons": dict(BUTTONS_CONFIG)}
if normalized == "notifications":
await update_notifications_config(session, data)
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
if normalized == "modes":
cleaned = {key: bool(value) for key, value in data.items()}
await update_modes_config(session, cleaned)
return {"modes": dict(MODES_CONFIG)}
if normalized == "money":
await update_money_config(session, data)
return {"money": dict(MONEY_CONFIG)}
if normalized == "providers_order":
cleaned: dict[str, int] = {}
for key, value in data.items():
try:
cleaned[key] = int(value)
except (TypeError, ValueError):
continue
await update_providers_order(session, cleaned)
return {"providers_order": dict(PROVIDERS_ORDER)}
if normalized == "tariffs":
cleaned = dict(data)
if "ALLOW_DOWNGRADE" in cleaned:
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
if "KEY_ADDONS_PACK_MODE" in cleaned:
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
await update_tariffs_config(session, cleaned)
return {"tariffs": dict(TARIFFS_CONFIG)}
raise HTTPException(status_code=404, detail="Unsupported config scope")
@router.get("/{key}", response_model=SettingResponse)
async def get_setting_by_key(key: str, identity=Depends(verify_identity_admin)):
"""Настройка по ключу (из кэша, без запроса к БД)."""
obj = settings_cache.get(key)
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
return obj
@router.post("/{key}", response_model=SettingResponse)
async def upsert_setting(
key: str,
payload: SettingUpsert,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Создание или обновление настройки по ключу."""
obj = await set_setting(
session=session,
key=key,
value=payload.value,
description=payload.description,
)
await session.commit()
await session.refresh(obj)
settings_cache.update(
key,
obj.value,
obj.description,
created_at=getattr(obj, "created_at", None),
updated_at=getattr(obj, "updated_at", None),
)
return obj
@router.delete("/{key}", response_model=dict)
async def delete_setting(
key: str,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаление настройки по ключу."""
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
await session.delete(obj)
await session.commit()
settings_cache.delete(key)
return {"detail": "Setting deleted"}
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
from api.v2.base_crud import generate_crud_router
from database.models import Tariff
router = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+52
View File
@@ -0,0 +1,52 @@
import asyncio
from fastapi import Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import UserBase, UserResponse, UserUpdate
from api.v2.base_crud import generate_crud_router
from database import delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.operations import delete_key_from_cluster
from logger import logger
router = generate_crud_router(
model=User,
schema_response=UserResponse,
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "create", "update"],
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаляет пользователя и его ключи на серверах."""
try:
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
key_records = result.all()
async def delete_keys_from_servers():
try:
servers = await get_servers(session=session)
tasks = []
for email, client_id in key_records:
for cluster_id in servers:
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
await delete_user_data(session, tg_id)
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
+30
View File
@@ -0,0 +1,30 @@
from api.v1.schemas import (
CouponBase,
CouponResponse,
CouponUpdate,
CouponUsageResponse,
GiftBase,
GiftResponse,
GiftUpdate,
GiftUsageResponse,
KeyDetailsResponse,
KeyResponse,
ReferralResponse,
ServerBase,
ServerResponse,
ServerUpdate,
TariffBase,
TariffResponse,
TariffUpdate,
UserBase,
UserResponse,
UserUpdate,
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
from api.v1.schemas.settings import SettingResponse, SettingUpsert
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime
from pydantic import BaseModel, Field
class IdentityCreate(BaseModel):
email: str | None = Field(None, description="Почта для привязки")
tg_id: int | None = Field(None, description="Telegram ID для привязки")
class IdentityResponse(BaseModel):
id: str
email: str | None
tg_id: int | None
is_admin: bool = False
created_at: datetime | None
updated_at: datetime | None
class Config:
from_attributes = True
class RegisterByEmailRequest(BaseModel):
email: str = Field(..., min_length=1)
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
class RegisterResponse(BaseModel):
identity_id: str
token: str
class LoginRequest(BaseModel):
email: str = Field(..., min_length=1)
password: str = Field(...)
class LoginResponse(BaseModel):
identity_id: str
token: str
class LoginTelegramRequest(BaseModel):
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
id: int = Field(..., description="Telegram user id (tg_id)")
first_name: str = Field("")
last_name: str | None = None
username: str | None = None
photo_url: str | None = None
auth_date: int = Field(..., description="Unix timestamp от Telegram")
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
class LinkTelegramRequest(BaseModel):
tg_id: int = Field(...)
class IdentityAttachEmail(BaseModel):
email: str = Field(..., min_length=1)
class IdentityAttachTelegram(BaseModel):
tg_id: int = Field(...)
+24
View File
@@ -0,0 +1,24 @@
from typing import Any
from pydantic import BaseModel, Field
class PaymentLinkCreateRequest(BaseModel):
tg_id: int | None = Field(None, description="Telegram ID пользователя (если не задан identity_id)")
identity_id: str | None = Field(None, description="ID идентичности; tg_id будет взят из привязки")
amount: int | float = Field(..., gt=0, description="Сумма оплаты")
currency: str = Field(default="RUB", description="Валюта (например RUB)")
provider_id: str = Field(
...,
description="Идентификатор кассы: ROBOKASSA, FREEKASSA, YOOKASSA, YOOMONEY, KASSAI_CARDS, KASSAI_SBP, HELEKET и др.",
)
success_url: str | None = Field(None, description="URL перенаправления после успешной оплаты")
failure_url: str | None = Field(None, description="URL перенаправления после неуспешной оплаты")
metadata: dict[str, Any] | None = Field(None, description="Дополнительные данные")
class PaymentLinkCreateResponse(BaseModel):
success: bool
payment_id: str | None = None
payment_url: str | None = None
error: str | None = None
+2 -1
View File
@@ -1,4 +1,4 @@
from database import async_session_maker
from database import async_session_maker, settings_cache
from database.db import warm_pool
from database.tariffs import initialize_all_tariff_weights
@@ -25,3 +25,4 @@ async def bootstrap() -> None:
await load_management_config(session)
await load_tariffs_config(session)
await session.commit()
await settings_cache.load(session)
+2
View File
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database import settings_cache
from ..defaults import DEFAULT_BUTTONS_CONFIG
@@ -63,3 +64,4 @@ async def update_buttons_config(session: AsyncSession, new_values: dict[str, boo
BUTTONS_CONFIG.clear()
BUTTONS_CONFIG.update(buttons_config)
settings_cache.update("BUTTONS_CONFIG", buttons_config)
+2
View File
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database import settings_cache
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
@@ -58,3 +59,4 @@ async def update_management_config(session: AsyncSession, new_values: dict[str,
MANAGEMENT_CONFIG.clear()
MANAGEMENT_CONFIG.update(management_config)
settings_cache.update(MANAGEMENT_SETTING_KEY, management_config)
+2
View File
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database import settings_cache
from ..defaults import DEFAULT_MODES_CONFIG
@@ -55,3 +56,4 @@ async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]
MODES_CONFIG.clear()
MODES_CONFIG.update(modes_config)
settings_cache.update("MODES_CONFIG", modes_config)
+2
View File
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database import settings_cache
from ..defaults import DEFAULT_MONEY_CONFIG
@@ -73,3 +74,4 @@ async def update_money_config(session: AsyncSession, new_values: dict[str, Any])
MONEY_CONFIG.clear()
MONEY_CONFIG.update(money_config)
settings_cache.update("MONEY_CONFIG", money_config)
+2
View File
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database import settings_cache
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
@@ -57,3 +58,4 @@ async def update_notifications_config(session: AsyncSession, new_values: dict[st
NOTIFICATIONS_CONFIG.clear()
NOTIFICATIONS_CONFIG.update(notifications_config)
settings_cache.update("NOTIFICATIONS_CONFIG", notifications_config)
+2
View File
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database import settings_cache
from ..defaults import DEFAULT_PAYMENTS_CONFIG
@@ -55,3 +56,4 @@ async def update_payments_config(session: AsyncSession, new_values: dict[str, bo
PAYMENTS_CONFIG.clear()
PAYMENTS_CONFIG.update(payments_config)
settings_cache.update("PAYMENTS_CONFIG", payments_config)
+3
View File
@@ -3,6 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database import settings_cache
PROVIDERS_ORDER: dict[str, int] = {}
@@ -36,3 +38,4 @@ async def update_providers_order(session: AsyncSession, new_order: dict[str, int
PROVIDERS_ORDER.clear()
PROVIDERS_ORDER.update(new_order)
settings_cache.update("PROVIDERS_ORDER", new_order)
+3
View File
@@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database import settings_cache
TARIFFS_CONFIG: dict[str, Any] = {
"ALLOW_DOWNGRADE": True,
@@ -62,6 +64,7 @@ async def update_tariffs_config(session: AsyncSession, new_values: dict[str, Any
TARIFFS_CONFIG.clear()
TARIFFS_CONFIG.update(tariffs_config)
settings_cache.update("TARIFFS_CONFIG", tariffs_config)
def calc_extra_devices_price(tariff: dict[str, Any], device_limit: int) -> int:
+2
View File
@@ -2,6 +2,7 @@ from .bans import *
from .coupons import *
from .db import async_session_maker
from .gifts import *
from . import identities
from .hot_leads import *
from .init_db import *
from .keys import *
@@ -9,6 +10,7 @@ from .notifications import *
from .payments import *
from .referrals import *
from .servers import *
from .settings_cache import settings_cache
from .statistics import *
from .tariffs import *
from .temporary_data import *
+1 -1
View File
@@ -8,7 +8,7 @@ from config import DATABASE_URL, DB_MAX_OVERFLOW, DB_POOL_SIZE
CONCURRENT_UPDATES_LIMIT = DB_POOL_SIZE + DB_MAX_OVERFLOW
MAX_UPDATE_AGE_SEC = 28
MAX_UPDATE_AGE_SEC = 15
engine = create_async_engine(
DATABASE_URL,
+197
View File
@@ -0,0 +1,197 @@
import hashlib
import secrets
from datetime import datetime, timedelta
import bcrypt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import API_TOKEN_TTL_DAYS
from database.models import Admin, Identity, User
_BCRYPT_MAX_PASSWORD_BYTES = 72
_BCRYPT_ROUNDS = 12
def _password_bytes(password: str) -> bytes:
"""Пароль в байтах, не длиннее 72 байт (ограничение bcrypt)."""
raw = password.encode("utf-8")
if len(raw) > _BCRYPT_MAX_PASSWORD_BYTES:
return raw[:_BCRYPT_MAX_PASSWORD_BYTES]
return raw
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def hash_password(password: str) -> str:
"""Хеш пароля через bcrypt (соль уникальна на каждый пароль)."""
salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
return bcrypt.hashpw(_password_bytes(password), salt).decode("ascii")
def check_password(password: str, password_hash: str | None) -> bool:
if not password_hash:
return False
try:
return bcrypt.checkpw(_password_bytes(password), password_hash.encode("ascii"))
except Exception:
return False
def generate_token() -> str:
return secrets.token_urlsafe(32)
async def create_identity(
session: AsyncSession,
email: str | None = None,
tg_id: int | None = None,
) -> Identity:
"""Создаёт идентичность; можно задать email и/или tg_id."""
identity = Identity(email=email.strip().lower() if email else None, tg_id=tg_id)
session.add(identity)
await session.flush()
if tg_id:
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity.id))
await session.commit()
await session.refresh(identity)
return identity
async def get_identity_by_id(session: AsyncSession, identity_id: str) -> Identity | None:
"""Возвращает идентичность по id."""
result = await session.execute(select(Identity).where(Identity.id == identity_id))
return result.scalar_one_or_none()
async def get_identity_by_email(session: AsyncSession, email: str) -> Identity | None:
"""Возвращает идентичность по email."""
if not email or not email.strip():
return None
result = await session.execute(select(Identity).where(Identity.email == email.strip().lower()))
return result.scalar_one_or_none()
async def get_identity_by_tg_id(session: AsyncSession, tg_id: int) -> Identity | None:
"""Возвращает идентичность по tg_id."""
result = await session.execute(select(Identity).where(Identity.tg_id == tg_id))
return result.scalar_one_or_none()
async def get_identity_by_token_hash(session: AsyncSession, token_hash: str) -> Identity | None:
"""Возвращает идентичность по хешу API-токена."""
result = await session.execute(select(Identity).where(Identity.api_token_hash == token_hash))
return result.scalar_one_or_none()
async def issue_token_for_identity(session: AsyncSession, identity: Identity) -> str:
"""Генерирует токен, сохраняет хеш и token_issued_at в identity, возвращает токен (показать один раз)."""
token = generate_token()
identity.api_token_hash = hash_token(token)
identity.token_issued_at = datetime.utcnow()
await session.commit()
await session.refresh(identity)
return token
def _is_token_expired(identity: Identity) -> bool:
"""Проверяет, истёк ли срок действия токена (если задан API_TOKEN_TTL_DAYS)."""
if API_TOKEN_TTL_DAYS is None or identity.token_issued_at is None:
return False
expiry = identity.token_issued_at + timedelta(days=API_TOKEN_TTL_DAYS)
return datetime.utcnow() >= expiry
async def create_identity_with_token(
session: AsyncSession,
email: str | None = None,
password: str | None = None,
tg_id: int | None = None,
) -> tuple[Identity, str]:
"""Создаёт идентичность и выдаёт API-токен. При регистрации по почте передать email и password."""
identity = await create_identity(session, email=email, tg_id=tg_id)
if password:
identity.password_hash = hash_password(password)
await session.commit()
await session.refresh(identity)
token = await issue_token_for_identity(session, identity)
return identity, token
async def verify_identity_token(session: AsyncSession, identity_id: str, token: str) -> Identity | None:
"""Проверяет пару identity_id + token и срок действия токена; возвращает Identity или None."""
identity = await get_identity_by_id(session, identity_id)
if not identity or not identity.api_token_hash:
return None
if hash_token(token) != identity.api_token_hash:
return None
if _is_token_expired(identity):
return None
return identity
async def login_by_email(session: AsyncSession, email: str, password: str) -> tuple[Identity, str] | None:
"""Вход по email и паролю: проверяет пароль, выдаёт новый токен; возвращает (identity, token) или None."""
identity = await get_identity_by_email(session, email)
if not identity or not check_password(password, identity.password_hash):
return None
token = await issue_token_for_identity(session, identity)
return identity, token
async def resolve_tg_id(session: AsyncSession, identity_id: str) -> int | None:
"""По identity_id возвращает tg_id, если привязан."""
identity = await get_identity_by_id(session, identity_id)
return identity.tg_id if identity else None
async def attach_email(session: AsyncSession, identity_id: str, email: str) -> Identity | None:
"""Привязывает email к идентичности."""
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
email_clean = email.strip().lower() if email else None
if not email_clean:
return identity
existing = await get_identity_by_email(session, email_clean)
if existing and existing.id != identity_id:
return None
identity.email = email_clean
await session.commit()
await session.refresh(identity)
return identity
async def attach_telegram(session: AsyncSession, identity_id: str, tg_id: int) -> Identity | None:
"""Привязывает Telegram (tg_id) к идентичности и связывает User с identity. Если tg_id в admins — выставляет is_admin."""
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
existing = await get_identity_by_tg_id(session, tg_id)
if existing and existing.id != identity_id:
return None
identity.tg_id = tg_id
admin_row = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if admin_row.scalar_one_or_none():
identity.is_admin = True
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity_id))
await session.commit()
await session.refresh(identity)
return identity
async def get_or_create_identity_for_tg(session: AsyncSession, tg_id: int) -> Identity:
"""Для tg_id возвращает существующую идентичность или создаёт новую и привязывает User."""
identity = await get_identity_by_tg_id(session, tg_id)
if identity:
return identity
identity = Identity(tg_id=tg_id)
session.add(identity)
await session.flush()
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity.id))
await session.commit()
await session.refresh(identity)
return identity
+11 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, User
from database.users import invalidate_user_snapshot
from logger import logger
@@ -81,6 +82,7 @@ async def store_key(
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
await session.commit()
invalidate_user_snapshot(tg_id)
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
@@ -157,10 +159,18 @@ async def get_key_count(session: AsyncSession, tg_id: int) -> int:
async def delete_key(session: AsyncSession, identifier: int | str, commit: bool = True):
stmt = delete(Key).where(Key.tg_id == identifier if str(identifier).isdigit() else Key.client_id == identifier)
tg_id_for_cache = None
if isinstance(identifier, str):
res = await session.execute(select(Key.tg_id).where(Key.client_id == identifier).limit(1))
tg_id_for_cache = res.scalar_one_or_none()
else:
tg_id_for_cache = identifier
stmt = delete(Key).where(Key.tg_id == identifier if isinstance(identifier, int) else Key.client_id == identifier)
await session.execute(stmt)
if commit:
await session.commit()
if tg_id_for_cache is not None:
invalidate_user_snapshot(tg_id_for_cache)
logger.info(f"Ключ с идентификатором {identifier} удалён")
+23 -1
View File
@@ -36,10 +36,32 @@ class DictLikeMixin:
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
class Identity(DictLikeMixin, Base):
"""Слой идентификации: к одному identity можно привязать email и/или Telegram (tg_id)."""
__tablename__ = "identities"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String(255), unique=True, nullable=True, index=True)
tg_id = Column(BigInteger, unique=True, nullable=True, index=True)
api_token_hash = Column(String(64), nullable=True, index=True)
token_issued_at = Column(DateTime, nullable=True)
password_hash = Column(String(64), nullable=True)
is_admin = Column(Boolean, nullable=False, server_default=text("false"))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(DictLikeMixin, Base):
__tablename__ = "users"
tg_id = Column(BigInteger, primary_key=True)
identity_id = Column(
String(36),
ForeignKey("identities.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
)
username = Column(String)
first_name = Column(String)
last_name = Column(String)
@@ -162,7 +184,7 @@ class ServerSpecialgroup(DictLikeMixin, Base):
class Payment(DictLikeMixin, Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True)
id = Column(Integer, primary_key=True, autoincrement=True)
tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
amount = Column(Float)
payment_system = Column(String)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import threading
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .models import Setting
class SettingsCache:
_cache: dict[str, dict[str, Any]]
_lock: threading.Lock
def __init__(self) -> None:
self._cache = {}
self._lock = threading.Lock()
@staticmethod
def _row_to_item(s: Setting) -> dict[str, Any]:
return {
"key": s.key,
"value": s.value,
"description": s.description,
"created_at": getattr(s, "created_at", None),
"updated_at": getattr(s, "updated_at", None),
}
async def load(self, session: AsyncSession) -> None:
result = await session.execute(select(Setting))
rows = result.scalars().all()
with self._lock:
self._cache.clear()
for s in rows:
self._cache[s.key] = self._row_to_item(s)
def get_all(self) -> list[dict[str, Any]]:
with self._lock:
return [dict(x) for x in self._cache.values()]
def get(self, key: str) -> dict[str, Any] | None:
with self._lock:
return dict(self._cache[key]) if key in self._cache else None
def update(
self,
key: str,
value: Any,
description: str | None = None,
created_at: datetime | None = None,
updated_at: datetime | None = None,
) -> None:
now = datetime.utcnow()
with self._lock:
if key in self._cache:
self._cache[key]["value"] = value
self._cache[key]["updated_at"] = updated_at if updated_at is not None else now
if description is not None:
self._cache[key]["description"] = description
else:
self._cache[key] = {
"key": key,
"value": value,
"description": description,
"created_at": created_at if created_at is not None else now,
"updated_at": updated_at if updated_at is not None else now,
}
def delete(self, key: str) -> None:
with self._lock:
self._cache.pop(key, None)
settings_cache = SettingsCache()
async def load(session: AsyncSession) -> None:
await settings_cache.load(session)
+27 -3
View File
@@ -1,5 +1,6 @@
from datetime import datetime
from cachetools import TTLCache
from sqlalchemy import delete, exists, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
@@ -20,6 +21,13 @@ from database.models import (
)
from logger import logger
_SNAPSHOT_CACHE: TTLCache[int, tuple[int, int]] = TTLCache(maxsize=150_000, ttl=30)
_EXISTS_CACHE: TTLCache[int, bool] = TTLCache(maxsize=150_000, ttl=60)
def invalidate_user_snapshot(tg_id: int) -> None:
_SNAPSHOT_CACHE.pop(tg_id, None)
async def add_user(
session: AsyncSession,
@@ -53,6 +61,7 @@ async def add_user(
return False
if commit:
await session.commit()
_EXISTS_CACHE[tg_id] = True
logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})")
return True
except SQLAlchemyError as e:
@@ -82,9 +91,15 @@ async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> No
async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
try:
return _EXISTS_CACHE[tg_id]
except KeyError:
pass
stmt = select(exists().where(User.tg_id == tg_id))
result = await session.execute(stmt)
return result.scalar()
value = result.scalar()
_EXISTS_CACHE[tg_id] = value
return value
async def get_balance(session: AsyncSession, tg_id: int) -> float:
@@ -107,6 +122,7 @@ async def update_trial(session: AsyncSession, tg_id: int, status: int):
try:
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=status))
await session.commit()
invalidate_user_snapshot(tg_id)
logger.info(f"[DB] Триал статус обновлён для пользователя {tg_id}: {status}")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении триала пользователя {tg_id}: {e}")
@@ -158,6 +174,7 @@ async def upsert_user(
if row is None:
return None
await session.commit()
_EXISTS_CACHE[tg_id] = True
return dict(row)
res = await session.execute(
@@ -187,6 +204,7 @@ async def upsert_user(
)
row = res.mappings().one()
await session.commit()
_EXISTS_CACHE[tg_id] = True
return dict(row)
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
@@ -222,16 +240,22 @@ async def delete_user_data(session: AsyncSession, tg_id: int):
async def mark_trial_extended(tg_id: int, session: AsyncSession):
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1))
await session.commit()
invalidate_user_snapshot(tg_id)
async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None:
try:
return _SNAPSHOT_CACHE[tg_id]
except KeyError:
pass
keys_count_sq = select(func.count(Key.client_id)).where(Key.tg_id == tg_id).scalar_subquery()
res = await session.execute(select(func.coalesce(User.trial, 0), keys_count_sq).where(User.tg_id == tg_id))
row = res.first()
if row is None:
return None
return int(row[0]), int(row[1])
value = (int(row[0]), int(row[1]))
_SNAPSHOT_CACHE[tg_id] = value
return value
async def upsert_source_if_empty(
@@ -26,7 +26,8 @@ def extract_tg_id_from_username(value: str | None) -> int | None:
return None
value = value.strip()
match = re.search(r"_(\d+)(?:\D|$)", value)
match = re.search(r"[-_](\d+)(?:\D|$)", value)
if not match:
return None
+21 -7
View File
@@ -8,6 +8,7 @@ from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session_maker
from logger import logger
@@ -52,9 +53,14 @@ class RateLimiter:
class BroadcastService:
def __init__(self, bot: Bot, session: AsyncSession, messages_per_second: int = 35) -> None:
def __init__(
self,
bot: Bot,
session: AsyncSession | None = None,
messages_per_second: int = 35,
) -> None:
self.bot = bot
self.session = session
self._session = session
self.rate_limiter = RateLimiter(max_rate=messages_per_second)
self.blocked_users = set()
self.queue = asyncio.Queue()
@@ -151,23 +157,31 @@ class BroadcastService:
logger.error(f"❌ Ошибка в воркере рассылки: {e}")
await asyncio.sleep(0.1)
async def _save_blocked_users(self):
async def _save_blocked_users(self) -> None:
if not self.blocked_users:
return
try:
async def _do_save(session: AsyncSession) -> None:
from sqlalchemy.dialects.postgresql import insert
from database.models import BlockedUser
values = [{"tg_id": tg_id} for tg_id in self.blocked_users]
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
await self.session.execute(stmt)
await self.session.commit()
await session.execute(stmt)
await session.commit()
logger.info(f"📝 Добавлено {len(self.blocked_users)} пользователей в blocked_users")
try:
if self._session is not None:
await _do_save(self._session)
else:
async with async_session_maker() as session:
await _do_save(session)
except Exception as e:
logger.error(f"❌ Ошибка при сохранении заблокированных пользователей: {e}")
await self.session.rollback()
if self._session is not None:
await self._session.rollback()
async def broadcast(self, messages: list[dict], workers: int = 20) -> dict:
self.is_running = True
+1 -3
View File
@@ -408,9 +408,7 @@ async def back_to_subgroup_tariffs(callback: CallbackQuery, state: FSMContext, s
gc = tariffs_for_cluster[0].get("group_code")
if gc:
tariffs = await get_tariffs(session, group_code=gc)
filtered = [
t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")
]
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
if not filtered:
await back_to_tariff_group_list(callback, state, session)
+13 -1
View File
@@ -1,10 +1,22 @@
__all__ = ("router",)
__all__ = (
"router",
"create_payment_link",
"register_payment_creator",
"PaymentLinkRequest",
"PaymentLinkResult",
)
from aiogram import Router
from config import PROVIDERS_ENABLED
from handlers.payments.providers import get_providers
from .payment_links import (
PaymentLinkRequest,
PaymentLinkResult,
create_payment_link,
register_payment_creator,
)
from .cryptobot import router as cryptobot_router
from .fast_payment_flow import router as fast_payment_flow_router
from .freekassa.freekassa_pay import router as freekassa_router
+367 -353
View File
@@ -1,353 +1,367 @@
import hashlib
from datetime import datetime, timedelta
from typing import Any
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
FREEKASSA_SECRET1,
FREEKASSA_SECRET2,
FREEKASSA_SHOP_ID,
)
from database import (
add_payment,
add_user,
async_session_maker,
check_user_exists,
clear_temporary_data,
get_key_count,
get_payment_by_payment_id,
get_temporary_data,
update_balance,
)
from handlers.buttons import BACK, PAY_2
from handlers.payments.utils import send_payment_success_notification
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
class ReplenishBalanceState(StatesGroup):
choosing_amount_freekassa = State()
waiting_for_payment_confirmation_freekassa = State()
def generate_signature(shop_id: int, amount: float, secret: str, order_id: str, currency: str = "RUB") -> str:
signature_string = f"{shop_id}:{amount}:{secret}:{currency}:{order_id}"
signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest()
logger.debug(f"Generated signature for order {order_id}: {signature}")
return signature
def generate_payment_link(amount: float, order_id: str, tg_id: int, currency: str = "RUB") -> str:
signature = generate_signature(FREEKASSA_SHOP_ID, amount, FREEKASSA_SECRET1, order_id, currency)
payment_url = "https://pay.fk.money/"
params = {
"m": FREEKASSA_SHOP_ID,
"oa": amount,
"currency": currency,
"o": order_id,
"s": signature,
"us_tg_id": tg_id,
}
query_string = "&".join([f"{key}={value}" for key, value in params.items()])
full_url = f"{payment_url}?{query_string}"
logger.info(f"Generated Freekassa payment link: {full_url}")
return full_url
@router.callback_query(F.data == "pay_freekassa")
async def process_callback_pay_freekassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} initiated Freekassa payment.")
builder = InlineKeyboardBuilder()
for i in range(0, len(PAYMENT_OPTIONS), 2):
if i + 1 < len(PAYMENT_OPTIONS):
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}",
),
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i + 1]["text"],
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i + 1]['callback_data']}",
),
)
else:
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
key_count = await get_key_count(session, tg_id)
if key_count == 0:
exists = await check_user_exists(session, tg_id)
if not exists:
from_user = callback_query.from_user
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[DB] Новый пользователь {tg_id} создан через Freekassa.")
await callback_query.message.delete()
new_message = await callback_query.message.answer(
text="Выберите сумму пополнения:",
reply_markup=builder.as_markup(),
)
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
await state.set_state(ReplenishBalanceState.choosing_amount_freekassa)
logger.info(f"Displayed amount selection for user {tg_id}.")
@router.callback_query(F.data.startswith("freekassa_amount|"))
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
logger.info(f"Получены данные callback_data: {callback_query.data}")
data = callback_query.data.split("|")
if len(data) != 3 or data[1] != "amount":
logger.error("Ошибка: callback_data не соответствует формату.")
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: данные повреждены.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
amount_str = data[2]
try:
amount = float(amount_str)
if amount <= 0:
raise ValueError("Сумма должна быть положительным числом.")
except ValueError as e:
logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}")
await edit_or_send_message(
target_message=callback_query.message,
text="Некорректная сумма.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
await state.update_data(amount=amount)
logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.")
tg_id = callback_query.message.chat.id
order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}"
payment_url = generate_payment_link(amount, order_id, tg_id)
logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}")
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")],
]
)
await edit_or_send_message(
target_message=callback_query.message,
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
)
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
def verify_signature(params: dict) -> bool:
try:
merchant_id = params.get("MERCHANT_ID", "")
amount = params.get("AMOUNT", "")
merchant_order_id = params.get("MERCHANT_ORDER_ID", "")
sign = params.get("SIGN", "")
signature_string = f"{merchant_id}:{amount}:{FREEKASSA_SECRET2}:{merchant_order_id}"
expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest()
logger.debug(f"Signature verification: expected={expected_signature}, received={sign}")
return expected_signature == sign
except Exception as e:
logger.error(f"Error verifying signature: {e}")
return False
async def freekassa_webhook(request: web.Request):
try:
params = dict(request.query)
logger.info(f"Received Freekassa webhook: {params}")
merchant_id = params.get("MERCHANT_ID")
amount = params.get("AMOUNT")
merchant_order_id = params.get("MERCHANT_ORDER_ID")
sign = params.get("SIGN")
tg_id = params.get("us_tg_id")
if not all([merchant_id, amount, merchant_order_id, sign]):
logger.error("Missing required parameters in webhook")
return web.Response(status=400, text="Missing required parameters")
if not verify_signature(params):
logger.error("Invalid signature in webhook")
return web.Response(status=400, text="Invalid signature")
if str(merchant_id) != str(FREEKASSA_SHOP_ID):
logger.error(f"Invalid merchant_id: {merchant_id}")
return web.Response(status=400, text="Invalid merchant_id")
try:
amount_float = float(amount)
if tg_id:
tg_id_int = int(tg_id)
else:
order_parts = merchant_order_id.split("_")
if len(order_parts) >= 3 and order_parts[0] == "order":
tg_id_int = int(order_parts[1])
else:
logger.error(f"Cannot extract tg_id from order_id: {merchant_order_id}")
return web.Response(status=400, text="Cannot identify user")
except (ValueError, TypeError) as e:
logger.error(f"Error parsing parameters: {e}")
return web.Response(status=400, text="Invalid parameter format")
async with async_session_maker() as session:
existing = await get_payment_by_payment_id(session, merchant_order_id)
if existing and existing.get("status") == "success":
logger.warning(
f"[Freekassa] Повторный webhook. Платёж уже обработан: order_id={merchant_order_id}"
)
return web.Response(text="YES")
await update_balance(session, tg_id_int, amount_float)
await send_payment_success_notification(tg_id_int, amount_float, session)
await add_payment(
session, tg_id_int, amount_float, "freekassa", payment_id=merchant_order_id
)
await clear_temporary_data(session, tg_id_int)
logger.info(f"Payment processed successfully. User: {tg_id_int}, Amount: {amount_float}")
return web.Response(text="YES")
except Exception as e:
logger.error(f"Error processing Freekassa webhook: {e}")
return web.Response(status=500, text="Internal server error")
@router.callback_query(F.data == "enter_custom_amount_freekassa")
async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} chose to enter a custom amount.")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa"))
await edit_or_send_message(
target_message=callback_query.message,
text=ENTER_SUM,
reply_markup=builder.as_markup(),
)
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa)
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa)
async def handle_custom_amount_input(
message: types.Message | types.CallbackQuery,
state: FSMContext = None,
session: AsyncSession = None,
):
if isinstance(message, types.CallbackQuery):
tg_id = message.message.chat.id
target_message = message.message
else:
tg_id = message.chat.id
target_message = message
logger.info(f"User {tg_id} initiated payment through Freekassa")
try:
user_data = await get_temporary_data(session, tg_id)
if not user_data:
await edit_or_send_message(
target_message=target_message,
text="Данные для оплаты не найдены. Попробуйте снова.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
state_type = user_data["state"]
amount = user_data["data"].get("required_amount", 0)
if amount <= 0:
await edit_or_send_message(
target_message=target_message,
text="Недостаточная сумма для пополнения.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}"
payment_url = generate_payment_link(amount, order_id, tg_id)
logger.info(f"Generated payment link for user {tg_id}: {payment_url}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa"))
if state_type == "waiting_for_payment":
message_text = (
f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
)
elif state_type == "waiting_for_renewal_payment":
message_text = (
f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
)
else:
await edit_or_send_message(
target_message=target_message,
text="Некорректное состояние данных. Попробуйте снова.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
await edit_or_send_message(
target_message=target_message,
text=message_text,
reply_markup=builder.as_markup(),
)
if isinstance(state, FSMContext):
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
await edit_or_send_message(
target_message=target_message,
text="Произошла ошибка при создании платежа. Попробуйте позже.",
reply_markup=types.InlineKeyboardMarkup(),
)
import hashlib
from datetime import datetime, timedelta
from typing import Any
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
FREEKASSA_SECRET1,
FREEKASSA_SECRET2,
FREEKASSA_SHOP_ID,
)
from database import (
add_payment,
add_user,
async_session_maker,
check_user_exists,
clear_temporary_data,
get_key_count,
get_payment_by_payment_id,
get_temporary_data,
update_balance,
)
from handlers.buttons import BACK, PAY_2
from handlers.payments.payment_links import register_payment_creator
from handlers.payments.utils import send_payment_success_notification
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
class ReplenishBalanceState(StatesGroup):
choosing_amount_freekassa = State()
waiting_for_payment_confirmation_freekassa = State()
def generate_signature(shop_id: int, amount: float, secret: str, order_id: str, currency: str = "RUB") -> str:
signature_string = f"{shop_id}:{amount}:{secret}:{currency}:{order_id}"
signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest()
logger.debug(f"Generated signature for order {order_id}: {signature}")
return signature
def generate_payment_link(amount: float, order_id: str, tg_id: int, currency: str = "RUB") -> str:
signature = generate_signature(FREEKASSA_SHOP_ID, amount, FREEKASSA_SECRET1, order_id, currency)
payment_url = "https://pay.fk.money/"
params = {
"m": FREEKASSA_SHOP_ID,
"oa": amount,
"currency": currency,
"o": order_id,
"s": signature,
"us_tg_id": tg_id,
}
query_string = "&".join([f"{key}={value}" for key, value in params.items()])
full_url = f"{payment_url}?{query_string}"
logger.info(f"Generated Freekassa payment link: {full_url}")
return full_url
@router.callback_query(F.data == "pay_freekassa")
async def process_callback_pay_freekassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} initiated Freekassa payment.")
builder = InlineKeyboardBuilder()
for i in range(0, len(PAYMENT_OPTIONS), 2):
if i + 1 < len(PAYMENT_OPTIONS):
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}",
),
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i + 1]["text"],
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i + 1]['callback_data']}",
),
)
else:
builder.row(
InlineKeyboardButton(
text=PAYMENT_OPTIONS[i]["text"],
callback_data=f"freekassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
key_count = await get_key_count(session, tg_id)
if key_count == 0:
exists = await check_user_exists(session, tg_id)
if not exists:
from_user = callback_query.from_user
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[DB] Новый пользователь {tg_id} создан через Freekassa.")
await callback_query.message.delete()
new_message = await callback_query.message.answer(
text="Выберите сумму пополнения:",
reply_markup=builder.as_markup(),
)
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
await state.set_state(ReplenishBalanceState.choosing_amount_freekassa)
logger.info(f"Displayed amount selection for user {tg_id}.")
@router.callback_query(F.data.startswith("freekassa_amount|"))
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
logger.info(f"Получены данные callback_data: {callback_query.data}")
data = callback_query.data.split("|")
if len(data) != 3 or data[1] != "amount":
logger.error("Ошибка: callback_data не соответствует формату.")
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: данные повреждены.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
amount_str = data[2]
try:
amount = float(amount_str)
if amount <= 0:
raise ValueError("Сумма должна быть положительным числом.")
except ValueError as e:
logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}")
await edit_or_send_message(
target_message=callback_query.message,
text="Некорректная сумма.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
await state.update_data(amount=amount)
logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.")
tg_id = callback_query.message.chat.id
order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}"
payment_url = generate_payment_link(amount, order_id, tg_id)
logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}")
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="pay_freekassa")],
]
)
await edit_or_send_message(
target_message=callback_query.message,
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
)
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
def verify_signature(params: dict) -> bool:
try:
merchant_id = params.get("MERCHANT_ID", "")
amount = params.get("AMOUNT", "")
merchant_order_id = params.get("MERCHANT_ORDER_ID", "")
sign = params.get("SIGN", "")
signature_string = f"{merchant_id}:{amount}:{FREEKASSA_SECRET2}:{merchant_order_id}"
expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest()
logger.debug(f"Signature verification: expected={expected_signature}, received={sign}")
return expected_signature == sign
except Exception as e:
logger.error(f"Error verifying signature: {e}")
return False
async def freekassa_webhook(request: web.Request):
try:
params = dict(request.query)
logger.info(f"Received Freekassa webhook: {params}")
merchant_id = params.get("MERCHANT_ID")
amount = params.get("AMOUNT")
merchant_order_id = params.get("MERCHANT_ORDER_ID")
sign = params.get("SIGN")
tg_id = params.get("us_tg_id")
if not all([merchant_id, amount, merchant_order_id, sign]):
logger.error("Missing required parameters in webhook")
return web.Response(status=400, text="Missing required parameters")
if not verify_signature(params):
logger.error("Invalid signature in webhook")
return web.Response(status=400, text="Invalid signature")
if str(merchant_id) != str(FREEKASSA_SHOP_ID):
logger.error(f"Invalid merchant_id: {merchant_id}")
return web.Response(status=400, text="Invalid merchant_id")
try:
amount_float = float(amount)
if tg_id:
tg_id_int = int(tg_id)
else:
order_parts = merchant_order_id.split("_")
if len(order_parts) >= 3 and order_parts[0] == "order":
tg_id_int = int(order_parts[1])
else:
logger.error(f"Cannot extract tg_id from order_id: {merchant_order_id}")
return web.Response(status=400, text="Cannot identify user")
except (ValueError, TypeError) as e:
logger.error(f"Error parsing parameters: {e}")
return web.Response(status=400, text="Invalid parameter format")
async with async_session_maker() as session:
existing = await get_payment_by_payment_id(session, merchant_order_id)
if existing and existing.get("status") == "success":
logger.warning(f"[Freekassa] Повторный webhook. Платёж уже обработан: order_id={merchant_order_id}")
return web.Response(text="YES")
await update_balance(session, tg_id_int, amount_float)
await send_payment_success_notification(tg_id_int, amount_float, session)
await add_payment(session, tg_id_int, amount_float, "freekassa", payment_id=merchant_order_id)
await clear_temporary_data(session, tg_id_int)
logger.info(f"Payment processed successfully. User: {tg_id_int}, Amount: {amount_float}")
return web.Response(text="YES")
except Exception as e:
logger.error(f"Error processing Freekassa webhook: {e}")
return web.Response(status=500, text="Internal server error")
@router.callback_query(F.data == "enter_custom_amount_freekassa")
async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} chose to enter a custom amount.")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa"))
await edit_or_send_message(
target_message=callback_query.message,
text=ENTER_SUM,
reply_markup=builder.as_markup(),
)
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa)
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_freekassa)
async def handle_custom_amount_input(
message: types.Message | types.CallbackQuery,
state: FSMContext = None,
session: AsyncSession = None,
):
if isinstance(message, types.CallbackQuery):
tg_id = message.message.chat.id
target_message = message.message
else:
tg_id = message.chat.id
target_message = message
logger.info(f"User {tg_id} initiated payment through Freekassa")
try:
user_data = await get_temporary_data(session, tg_id)
if not user_data:
await edit_or_send_message(
target_message=target_message,
text="Данные для оплаты не найдены. Попробуйте снова.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
state_type = user_data["state"]
amount = user_data["data"].get("required_amount", 0)
if amount <= 0:
await edit_or_send_message(
target_message=target_message,
text="Недостаточная сумма для пополнения.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}"
payment_url = generate_payment_link(amount, order_id, tg_id)
logger.info(f"Generated payment link for user {tg_id}: {payment_url}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_freekassa"))
if state_type == "waiting_for_payment":
message_text = (
f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
)
elif state_type == "waiting_for_renewal_payment":
message_text = (
f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
)
else:
await edit_or_send_message(
target_message=target_message,
text="Некорректное состояние данных. Попробуйте снова.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
await edit_or_send_message(
target_message=target_message,
text=message_text,
reply_markup=builder.as_markup(),
)
if isinstance(state, FSMContext):
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
await edit_or_send_message(
target_message=target_message,
text="Произошла ошибка при создании платежа. Попробуйте позже.",
reply_markup=types.InlineKeyboardMarkup(),
)
async def create_link(
session: AsyncSession,
tg_id: int,
amount: float,
currency: str,
success_url: str | None,
failure_url: str | None,
) -> tuple[str, str]:
if currency not in ("RUB", "USD"):
raise ValueError("Freekassa поддерживает только RUB или USD")
order_id = f"order_{tg_id}_{int(amount)}_{hash(str(tg_id) + str(amount))}"
url = generate_payment_link(amount, order_id, tg_id, currency)
return (url, order_id)
register_payment_creator("FREEKASSA", create_link)
+24
View File
@@ -30,6 +30,7 @@ from handlers.payments.currency_rates import (
pick_currency,
to_rub,
)
from handlers.payments.payment_links import register_payment_creator
from handlers.payments.keyboards import (
build_amounts_keyboard,
parse_amount_from_callback,
@@ -420,3 +421,26 @@ async def generate_heleket_payment_link(
except Exception as e:
logger.error(f"Error creating Heleket payment: {e}")
return "https://heleket.com/"
async def create_link(
session: AsyncSession,
tg_id: int,
amount: float,
currency: str,
success_url: str | None,
failure_url: str | None,
) -> tuple[str, str | None]:
method = HELEKET_METHODS.get("crypto")
if not method or not method.get("enable"):
raise ValueError("Heleket недоступен")
amount_int = int(amount)
if amount_int < 10:
raise ValueError("Минимальная сумма для Heleket — 10₽")
url = await generate_heleket_payment_link(amount_int, tg_id, method, session)
if not url or url == "https://heleket.com/":
raise ValueError("Не удалось создать платёж Heleket")
return (url, None)
register_payment_creator("HELEKET", create_link)
+32
View File
@@ -34,6 +34,7 @@ from handlers.payments.keyboards import (
pay_keyboard,
payment_options_for_user,
)
from handlers.payments.payment_links import register_payment_creator
from handlers.payments.providers import get_providers
from handlers.texts import (
ENTER_SUM,
@@ -441,3 +442,34 @@ async def generate_kassai_payment_link(
except Exception as e:
logger.error(f"Error creating KassaAI order: {e}")
return "https://fk.life/"
def create_link_factory(method_name: str):
async def create_link(
session: AsyncSession,
tg_id: int,
amount: float,
currency: str,
success_url: str | None,
failure_url: str | None,
) -> tuple[str, str | None]:
if currency != "RUB":
raise ValueError("KassaI поддерживает только RUB")
method = KASSAI_METHODS.get(method_name)
if not method or not method.get("enable"):
raise ValueError("Способ оплаты KassaI недоступен")
amount_int = int(amount)
if method_name == "cards" and amount_int < 50:
raise ValueError("Минимальная сумма для карт — 50₽")
if method_name == "sbp" and amount_int < 10:
raise ValueError("Минимальная сумма для СБП — 10₽")
url = await generate_kassai_payment_link(amount_int, tg_id, method, session)
if not url or url == "https://fk.life/":
raise ValueError("Не удалось создать платёж KassaI")
return (url, None)
return create_link
register_payment_creator("KASSAI_CARDS", create_link_factory("cards"))
register_payment_creator("KASSAI_SBP", create_link_factory("sbp"))
+1 -3
View File
@@ -77,9 +77,7 @@ async def kassai_webhook(request: web.Request):
if payment.get("status") == "success":
logger.info(f"KassaAI: платёж {order_id} уже обработан")
return web.Response(text=KASSAI_WEBHOOK_RESPONSE)
ok = await update_payment_status(
session=session, internal_id=int(payment["id"]), new_status="success"
)
ok = await update_payment_status(session=session, internal_id=int(payment["id"]), new_status="success")
if not ok:
logger.error(f"KassaAI: не удалось обновить статус платежа {order_id}")
return web.Response(status=500)
+89
View File
@@ -0,0 +1,89 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from hooks.hooks import run_hooks
from logger import logger
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass(frozen=True)
class PaymentLinkRequest:
tg_id: int
amount: int | float
currency: str
provider_id: str
success_url: str | None = None
failure_url: str | None = None
metadata: dict[str, Any] | None = None
@dataclass
class PaymentLinkResult:
success: bool
payment_id: str | None = None
payment_url: str | None = None
error: str | None = None
PaymentLinkCreator = Callable[
[AsyncSession, int, float, str, str | None, str | None],
Awaitable[tuple[str, str | None]],
]
_registry: dict[str, PaymentLinkCreator] = {}
def register_payment_creator(provider_id: str, creator: PaymentLinkCreator) -> None:
"""Регистрирует создателя платёжной ссылки для кассы."""
key = provider_id.strip().upper()
_registry[key] = creator
logger.debug(f"[Payments] Зарегистрирован создатель ссылки: {key}")
async def merge_creators_from_hooks() -> None:
"""Подтягивает создателей из хука payment_register_creators в реестр."""
results = await run_hooks("payment_register_creators")
for item in results:
if isinstance(item, dict):
for pid, creator in item.items():
if pid and callable(creator):
key = str(pid).strip().upper()
_registry[key] = creator
async def create_payment_link(
session: AsyncSession,
request: PaymentLinkRequest,
) -> PaymentLinkResult:
"""Формирует платёжную ссылку через зарегистрированную кассу."""
await merge_creators_from_hooks()
provider_key = request.provider_id.strip().upper()
creator = _registry.get(provider_key)
if not creator:
return PaymentLinkResult(
success=False,
error=f"Провайдер не найден или не поддерживает ссылку: {provider_key}",
)
try:
amount = float(request.amount)
except (TypeError, ValueError):
return PaymentLinkResult(success=False, error="Некорректная сумма")
if amount <= 0:
return PaymentLinkResult(success=False, error="Сумма должна быть больше нуля")
currency = (request.currency or "RUB").strip().upper()
try:
url, payment_id = await creator(
session,
request.tg_id,
amount,
currency,
request.success_url,
request.failure_url,
)
return PaymentLinkResult(success=True, payment_url=url, payment_id=payment_id)
except ValueError as e:
return PaymentLinkResult(success=False, error=str(e))
except Exception as e:
logger.exception(f"[Payments] Ошибка создания ссылки для {provider_key}: {e}")
return PaymentLinkResult(success=False, error="Ошибка при создании платёжной ссылки")
+19
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import ROBOKASSA_LOGIN, ROBOKASSA_PASSWORD1, ROBOKASSA_PASSWORD2, ROBOKASSA_TEST_MODE
from database import add_payment
from handlers.payments.payment_links import register_payment_creator
def _build_receipt(amount: float, sno: str = "usn_income") -> dict:
@@ -85,3 +86,21 @@ def check_payment_signature(params) -> bool:
base = f"{out_sum}:{inv_id}:{ROBOKASSA_PASSWORD2}{shp_suffix}"
expected_sig = hashlib.md5(base.encode("utf-8")).hexdigest().upper()
return received_sig == expected_sig
async def create_link(
session: AsyncSession,
tg_id: int,
amount: float,
currency: str,
success_url: str | None,
failure_url: str | None,
) -> tuple[str, str]:
if currency != "RUB":
raise ValueError("Robokassa поддерживает только RUB")
amount_val = int(amount) if amount == int(amount) else amount
url, pid = await create_and_store_robokassa_payment(session, tg_id, amount_val, "Пополнение баланса", inv_id=0)
return (url, pid)
register_payment_creator("ROBOKASSA", create_link)
+1 -3
View File
@@ -73,9 +73,7 @@ async def process_callback_view_profile(
balance_rub,
getattr(user, "language_code", None),
)
profile_menu_buttons = await run_hooks(
"profile_menu", chat_id=chat_id, admin=admin, session=session
)
profile_menu_buttons = await run_hooks("profile_menu", chat_id=chat_id, admin=admin, session=session)
text_hooks = await run_hooks(
"profile_text",
username=username,
+3 -1
View File
@@ -60,6 +60,7 @@ from handlers.texts import (
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
from middlewares.session import release_session_early
from .admin.panel.keyboard import AdminPanelCallback
from .refferal import handle_referral_link
@@ -320,10 +321,11 @@ async def show_start_menu(
module_buttons = await run_hooks("start_menu", chat_id=message.chat.id, session=session)
kb = insert_hook_buttons(kb, module_buttons)
except Exception as e:
logger.error(f"[Hooks:start_menu] Ошибка вставки кнопок: {e}", exc_info=True)
logger.error(f"[Hooks:start_menu] Ошибка вставки кнопов: {e}", exc_info=True)
kb.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
await release_session_early(session)
await edit_or_send_message(message, WELCOME_TEXT, reply_markup=kb.as_markup(), media_path=image_path)
+1 -5
View File
@@ -508,11 +508,7 @@ async def render_user_config_screen(
is_renew_mode = data.get("renew_mode") == "renew"
confirm_prefix = "cfg_renew_confirm" if is_renew_mode else "cfg_user_confirm"
back_callback = (
"back_to_subgroup_tariffs"
if data.get("tariff_subgroup_hash")
else "back_to_tariff_group_list"
)
back_callback = "back_to_subgroup_tariffs" if data.get("tariff_subgroup_hash") else "back_to_tariff_group_list"
builder.row(
InlineKeyboardButton(
text=CONFIG_PAY_BUTTON_TEXT.format(amount=price_text),
+13 -2
View File
@@ -3,6 +3,7 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject
from cachetools import TTLCache
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,8 +11,12 @@ from config import ADMIN_ID
from database.models import Admin
_ADMIN_CACHE_TTL = 60
_admin_cache: TTLCache[int, bool] = TTLCache(maxsize=10_000, ttl=_ADMIN_CACHE_TTL)
class AdminMiddleware(BaseMiddleware):
"""Проверяет, является ли пользователь администратором."""
"""Проверяет, является ли пользователь администратором. Сессию не создаёт — только data['session']."""
_admin_ids: set[int] = set(ADMIN_ID) if isinstance(ADMIN_ID, list | tuple) else {ADMIN_ID}
@@ -50,10 +55,16 @@ class AdminMiddleware(BaseMiddleware):
if user_id in self._admin_ids:
return True
if user_id in _admin_cache:
return _admin_cache[user_id]
if not session:
_admin_cache[user_id] = False
return False
result = await session.execute(select(Admin).where(Admin.tg_id == user_id))
return result.scalar_one_or_none() is not None
is_admin = result.scalar_one_or_none() is not None
_admin_cache[user_id] = is_admin
return is_admin
except Exception:
return False
+3 -3
View File
@@ -4,6 +4,7 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
from cachetools import TTLCache
from pytz import timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,12 +16,11 @@ from logger import logger
TZ = timezone("Europe/Moscow")
_BAN_CACHE_TTL = 30
_ban_cache: dict[int, tuple[float, dict | None]] = {}
_ban_cache: TTLCache[int, tuple[float, dict | None]] = TTLCache(maxsize=50_000, ttl=_BAN_CACHE_TTL)
class BanCheckerMiddleware(BaseMiddleware):
def __init__(self, session_factory: Callable[[], AsyncSession] | None = None) -> None:
self.session_factory = session_factory
"""Проверка банов."""
async def __call__(
self,
+3 -3
View File
@@ -1,10 +1,10 @@
import time
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import Message, Update
from cachetools import TTLCache
from sqlalchemy.ext.asyncio import AsyncSession
from config import ADMIN_ID, DISABLE_DIRECT_START
@@ -14,7 +14,7 @@ from logger import logger
_TTL = 20
_cache_user_exists: dict[int, tuple[float, bool]] = {}
_cache_user_exists: TTLCache[int, tuple[float, bool]] = TTLCache(maxsize=50_000, ttl=_TTL)
class DirectStartBlockerMiddleware(BaseMiddleware):
@@ -63,7 +63,7 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
return True
cached = _cache_user_exists.get(tg_id)
if cached and cached[0] > now:
if cached is not None and cached[0] > now:
return cached[1]
exists = await check_user_exists(session, tg_id)
+107 -37
View File
@@ -1,14 +1,74 @@
import time
from typing import Any
from aiogram import BaseMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from logger import logger
try:
from config import LOG_SESSION_DURATION
except ImportError:
LOG_SESSION_DURATION = False
async def release_session_early(session: Any) -> bool:
if hasattr(session, "release_early"):
return await session.release_early()
return False
class _SessionProxy:
__slots__ = ("_session", "_maker", "_released", "_data")
def __init__(self, session: AsyncSession, maker, data: dict) -> None:
self._session = session
self._maker = maker
self._released = False
self._data = data
async def release_early(self) -> bool:
if self._released:
return False
self._released = True
try:
await self._session.commit()
except Exception:
await self._session.rollback()
try:
await self._session.close()
except Exception:
pass
self._session = None
self._data["_session_released_early"] = True
return True
async def _with_short_session(self, method: str, *args, **kwargs):
import asyncio
async with self._maker() as s:
result = getattr(s, method)(*args, **kwargs)
if asyncio.iscoroutine(result):
return await result
return result
def __getattr__(self, name: str):
if name in ("_session", "_maker", "_released", "_data", "release_early", "_with_short_session"):
raise AttributeError(name)
if self._released:
def _short(*a, **k):
return self._with_short_session(name, *a, **k)
return _short
return getattr(self._session, name)
class SessionMiddleware(BaseMiddleware):
def __init__(self, sessionmaker):
self.sessionmaker = sessionmaker
async def _rollback(self, session, context: str) -> None:
"""Attempt rollback so invalid transaction is cleared; log if rollback fails."""
async def _rollback(self, session: AsyncSession, context: str) -> None:
try:
await session.rollback()
except Exception as rollback_err:
@@ -24,47 +84,57 @@ class SessionMiddleware(BaseMiddleware):
if data.get("session"):
return await handler(event, data)
session = self.sessionmaker()
data["session"] = session
committed = False
handler_name = getattr(handler, "__qualname__", getattr(handler, "__name__", str(handler)))
event_type = type(event).__name__
t0 = time.perf_counter() if LOG_SESSION_DURATION else None
try:
result = await handler(event, data)
async with self.sessionmaker() as session:
proxy = _SessionProxy(session, self.sessionmaker, data)
data["session"] = proxy
committed = False
try:
await session.commit()
committed = True
return result
except Exception as commit_err:
result = await handler(event, data)
if data.get("_session_released_early"):
committed = True
return result
try:
await session.commit()
committed = True
return result
except Exception as commit_err:
logger.warning(
"Session commit failed, rolling back — handler=%s, event=%s, error=%s: %s",
handler_name,
event_type,
type(commit_err).__name__,
commit_err,
exc_info=True,
)
await self._rollback(session, "commit failure")
return result
except Exception as e:
logger.warning(
"Session commit failed, rolling back (ошибка не пробрасывается) — handler=%s, event=%s, error=%s: %s",
"Session rollback: ошибка при обработке — handler=%s, event=%s, error=%s: %s",
handler_name,
event_type,
type(commit_err).__name__,
commit_err,
type(e).__name__,
e,
exc_info=True,
)
await self._rollback(session, "commit failure")
return result
except Exception as e:
logger.warning(
"Session rollback: ошибка при обработке — handler=%s, event=%s, error=%s: %s",
handler_name,
event_type,
type(e).__name__,
e,
exc_info=True,
)
await self._rollback(session, "handler failure")
raise
finally:
if not committed:
try:
await session.rollback()
except Exception:
pass
try:
await session.close()
except Exception:
pass
await self._rollback(session, "handler failure")
raise
finally:
if not committed and not data.get("_session_released_early"):
try:
await session.rollback()
except Exception:
pass
if t0 is not None:
duration_ms = int((time.perf_counter() - t0) * 1000)
logger.debug(
"[Session] %s %s handler=%s duration_ms=%d",
event_type,
getattr(event, "update_id", ""),
handler_name,
duration_ms,
)
+5 -3
View File
@@ -5,6 +5,7 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, User
from cachetools import TTLCache
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,9 +15,11 @@ from logger import logger
class UserMiddleware(BaseMiddleware):
def __init__(self, debounce_sec: float = 60.0) -> None:
def __init__(self, debounce_sec: float = 60.0, cache_maxsize: int = 100_000) -> None:
self._debounce = float(debounce_sec)
self._cache: dict[int, tuple[str, float, float, dict | None]] = {}
self._cache: TTLCache[int, tuple[str, float, float, dict | None]] = TTLCache(
maxsize=cache_maxsize, ttl=debounce_sec * 2
)
async def __call__(
self,
@@ -75,7 +78,6 @@ class UserMiddleware(BaseMiddleware):
obj = res.scalar_one_or_none()
if obj is None:
return None
await session.commit()
d = obj.__dict__.copy()
d.pop("_sa_instance_state", None)
return d
+1
View File
@@ -35,6 +35,7 @@ MarkupSafe==3.0.2
mdurl==0.1.2
multidict==6.1.0
netaddr==1.3.0
bcrypt>=4.0.0
pillow==11.3.0
ping3==4.0.8
propcache==0.2.0

Some files were not shown because too many files have changed in this diff Show More