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
|
||||
Reference in New Issue
Block a user