596 lines
20 KiB
Python
596 lines
20 KiB
Python
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
|
|
|
|
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:
|
|
"""Normalize percent input to 0-100 range."""
|
|
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
|
|
|
|
|
|
@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,
|
|
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
|
|
|
|
partners_list = []
|
|
default_percent = _default_partner_percent()
|
|
for partner in partners:
|
|
percent_value = partner[2]
|
|
percent_custom = bool(partner[3])
|
|
if percent_custom and percent_value is not None:
|
|
percent = float(percent_value)
|
|
else:
|
|
percent = 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(
|
|
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,
|
|
u.partner_percent,
|
|
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
|
|
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()
|
|
|
|
default_percent = _default_partner_percent()
|
|
percent = default_percent
|
|
if meta_row:
|
|
percent_value = meta_row[1]
|
|
percent_custom = 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 приглашённого"),
|
|
admin=Depends(verify_admin_token),
|
|
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 приглашённого"),
|
|
admin=Depends(verify_admin_token),
|
|
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)"),
|
|
admin=Depends(verify_admin_token),
|
|
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"),
|
|
admin=Depends(verify_admin_token),
|
|
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 партнёра"),
|
|
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)
|