Optimizing database queries/ Combining cash registers into a single point/ Caching key moments/ API versioning/ ruff formatting
This commit is contained in:
+55
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
@@ -0,0 +1,5 @@
|
||||
from api.v1.router import router
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
__all__ = ("router", "VERSION")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
from api.v2.router import router
|
||||
|
||||
VERSION = "2.0.0"
|
||||
|
||||
__all__ = ("router", "VERSION")
|
||||
@@ -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")
|
||||
@@ -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"])
|
||||
@@ -0,0 +1,3 @@
|
||||
from api.v2.routes.root import router as root_router
|
||||
|
||||
__all__ = ("root_router",)
|
||||
@@ -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)
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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": "Подарок и связанные использования удалены"}
|
||||
@@ -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)
|
||||
@@ -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="Ошибка при создании ключа")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 []),
|
||||
)
|
||||
@@ -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)}
|
||||
@@ -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}"}
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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="Ошибка при удалении пользователя")
|
||||
@@ -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
|
||||
@@ -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(...)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="Ошибка при создании платёжной ссылки")
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-3
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user