Docker/ API 0.5.0: Partner Statistics/Coupons for New Customers and More

This commit is contained in:
Vladless
2026-02-01 09:58:58 +03:00
parent 13e653a43a
commit 73214b2cc9
46 changed files with 820 additions and 495 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
from fastapi import FastAPI
from api.routes import coupons, gifts, keys, misc, referrals, servers, settings, tariffs, users
from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, misc, partners
app = FastAPI(
title="SoloBot API (Alpha)",
version="0.4.0",
version="0.5.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
@@ -16,7 +16,7 @@ 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(settings.router, prefix="/api/settings", tags=["Settings"])
app.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
app.include_router(misc.router, prefix="/api")
+34 -15
View File
@@ -1,3 +1,5 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,13 +9,23 @@ from api.depends import get_session, verify_admin_token
from database.models import Admin
def _cast_identifier_type(field: InstrumentedAttribute, value: int | str):
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
column_type = type(field.property.columns[0].type).__name__
if column_type in ("Integer", "BigInteger"):
return int(value)
return value
def normalize_outgoing_object(obj: object) -> None:
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
setattr(obj, "vless", False)
def to_schema(schema_response: type, obj: object):
normalize_outgoing_object(obj)
return schema_response.model_validate(obj, from_attributes=True)
def generate_crud_router(
*,
model: type,
@@ -35,7 +47,10 @@ def generate_crud_router(
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
return result.scalars().all()
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:
@@ -49,7 +64,7 @@ def generate_crud_router(
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return obj
return to_schema(schema_response, obj)
if "get_one" in enabled_methods:
@@ -60,12 +75,12 @@ def generate_crud_router(
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
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 obj
return to_schema(schema_response, obj)
if "get_all_by_field" in enabled_methods:
@@ -76,52 +91,56 @@ def generate_crud_router(
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
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")
return objs
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: schema_create, # type: ignore
payload: Any,
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = payload.dict(exclude_unset=True)
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 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: schema_update, # type: ignore
payload: Any,
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
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")
for k, v in payload.dict(exclude_unset=True).items():
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 obj
return to_schema(schema_response, obj)
if "delete" in enabled_methods:
@@ -132,7 +151,7 @@ def generate_crud_router(
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
+4 -20
View File
@@ -93,31 +93,15 @@ async def edit_key_by_email(
setattr(db_key, field, value)
try:
tariff = None
if db_key.tariff_id is not None:
tariff_result = await session.execute(select(Tariff).where(Tariff.id == db_key.tariff_id))
tariff = tariff_result.scalar_one_or_none()
total_gb = db_key.current_traffic_limit
if total_gb is None:
total_gb = db_key.selected_traffic_limit
if total_gb is None and tariff is not None:
total_gb = tariff.traffic_limit
hwid_device_limit = db_key.current_device_limit
if hwid_device_limit is None:
hwid_device_limit = db_key.selected_device_limit
if hwid_device_limit is None and tariff is not None:
hwid_device_limit = tariff.device_limit
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=db_key.expiry_time,
total_gb=total_gb,
new_expiry_time=new_expiry_time,
total_gb=getattr(db_key, "traffic_limit", None),
session=session,
hwid_device_limit=hwid_device_limit,
hwid_device_limit=getattr(db_key, "device_limit", None),
reset_traffic=True,
)
await session.commit()
+325
View File
@@ -0,0 +1,325 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
router = APIRouter()
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список всех партнёров со статистикой.
Структура ответа:
{
"total": int,
"items": [
{
"tg_id": int,
"balance": float,
"percent": float,
"code": str | None,
"method": str | None,
"referred_count": int
}
]
}
"""
partners_sql = text(
"""
SELECT
p.partner_tg_id AS tg_id,
COALESCE(u.partner_balance, 0) AS partner_balance,
COALESCE(u.partner_percent, 0) AS partner_percent,
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_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
partners_list = [
{
"tg_id": int(partner[0]),
"balance": float(partner[1] or 0),
"percent": float(partner[2] or 0),
"code": partner[3] or None,
"method": partner[4] or None,
"referred_count": int(partner[5] or 0),
}
for partner in partners
]
return JSONResponse(content={"total": total, "items": partners_list})
@router.get("/stats/all")
async def get_partners_stats(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает общую статистику партнёрской программы.
Структура ответа:
{
"total_partners": int,
"total_referred": int,
"total_balance": float,
"top_partner_tg_id": int,
"top_partner_refs": int
}
"""
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 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),
"total_referred": int(stats_row[1] or 0),
"total_balance": float(stats_row[2] or 0.0),
"top_partner_tg_id": int(stats_row[3] or 0),
"top_partner_refs": int(stats_row[4] or 0),
}
else:
stats = {
"total_partners": 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="Новый процент партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет данные партнёра (баланс и процент).
Структура ответа:
{
"success": bool,
"message": str
}
"""
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,
)
else:
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 партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает партнёрские данные для указанного `tg_id`.
Структура ответа:
{
"tg_id": int,
"partner_balance": float,
"partner_percent": float,
"partner_code": str | None,
"payout_method": str | None,
"invited": [
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
}
"""
meta_sql = text(
"""
SELECT
COALESCE(u.partner_balance, 0) AS partner_balance,
COALESCE(u.partner_percent, 0) AS partner_percent,
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) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
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()
response = {
"tg_id": tg_id,
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
"partner_percent": float(meta_row[1] or 0) if meta_row else 0.0,
"partner_code": meta_row[2] if meta_row else None,
"payout_method": meta_row[3] 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.get("/{tg_id}/invited")
async def get_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список приглашённых пользователей конкретного партнёра.
Структура ответа:
[
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
"""
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
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)