mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
NEW VERSION
NEW VERSION
This commit is contained in:
88
app/bot.py
Normal file
88
app/bot.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, common
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes, messages as admin_messages,
|
||||
monitoring as admin_monitoring, referrals as admin_referrals,
|
||||
rules as admin_rules, remnawave as admin_remnawave,
|
||||
statistics as admin_statistics, servers as admin_servers
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def debug_callback_handler(callback: types.CallbackQuery):
|
||||
logger.info(f"🔍 DEBUG CALLBACK:")
|
||||
logger.info(f" - Data: {callback.data}")
|
||||
logger.info(f" - User: {callback.from_user.id}")
|
||||
logger.info(f" - Username: {callback.from_user.username}")
|
||||
|
||||
|
||||
async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
try:
|
||||
await cache.connect()
|
||||
logger.info("✅ Кеш инициализирован")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Кеш не инициализирован: {e}")
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, parse_mode="HTML")
|
||||
|
||||
try:
|
||||
redis_client = redis.from_url(settings.REDIS_URL)
|
||||
await redis_client.ping()
|
||||
storage = RedisStorage(redis_client)
|
||||
logger.info("✅ Подключено к Redis для FSM storage")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}")
|
||||
logger.info("🔄 Используется MemoryStorage для FSM")
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
balance.register_handlers(dp)
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
admin_servers.register_handlers(dp)
|
||||
admin_promocodes.register_handlers(dp)
|
||||
admin_messages.register_handlers(dp)
|
||||
admin_monitoring.register_handlers(dp)
|
||||
admin_referrals.register_handlers(dp)
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
|
||||
common.register_handlers(dp)
|
||||
|
||||
logger.info("✅ Бот успешно настроен")
|
||||
|
||||
return bot, dp
|
||||
171
app/config.py
Normal file
171
app/config.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import os
|
||||
from typing import List, Optional, Union
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
BOT_TOKEN: str
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
|
||||
DATABASE_URL: str
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_SQUAD_UUID: str
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
ENABLE_NOTIFICATIONS: bool = True
|
||||
NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
|
||||
BASE_SUBSCRIPTION_PRICE: int = 50000
|
||||
|
||||
PRICE_14_DAYS: int = 50000
|
||||
PRICE_30_DAYS: int = 99000
|
||||
PRICE_60_DAYS: int = 189000
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 10000
|
||||
PRICE_TRAFFIC_10GB: int = 19000
|
||||
PRICE_TRAFFIC_25GB: int = 45000
|
||||
PRICE_TRAFFIC_50GB: int = 85000
|
||||
PRICE_TRAFFIC_100GB: int = 159000
|
||||
PRICE_TRAFFIC_250GB: int = 369000
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 0
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
|
||||
REFERRAL_REGISTRATION_REWARD: int = 5000
|
||||
REFERRED_USER_REWARD: int = 2500
|
||||
REFERRAL_COMMISSION_PERCENT: int = 10
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
|
||||
TRIBUTE_ENABLED: bool = False
|
||||
TRIBUTE_API_KEY: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_SECRET: Optional[str] = None
|
||||
TRIBUTE_DONATE_LINK: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
|
||||
TRIBUTE_WEBHOOK_PORT: int = 8081
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
def ensure_log_dir(cls, v):
|
||||
log_path = Path(v)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return str(log_path)
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
return user_id in self.get_admin_ids()
|
||||
|
||||
def get_admin_ids(self) -> List[int]:
|
||||
try:
|
||||
admin_ids = self.ADMIN_IDS
|
||||
|
||||
if isinstance(admin_ids, str):
|
||||
if not admin_ids.strip():
|
||||
return []
|
||||
return [int(x.strip()) for x in admin_ids.split(',') if x.strip()]
|
||||
|
||||
return []
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
days = self.AUTOPAY_WARNING_DAYS
|
||||
if isinstance(days, str):
|
||||
if not days.strip():
|
||||
return [3, 1]
|
||||
return [int(x.strip()) for x in days.split(',') if x.strip()]
|
||||
return [3, 1]
|
||||
except (ValueError, AttributeError):
|
||||
return [3, 1]
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
if isinstance(langs, str):
|
||||
if not langs.strip():
|
||||
return ["ru", "en"]
|
||||
return [x.strip() for x in langs.split(',') if x.strip()]
|
||||
return ["ru", "en"]
|
||||
except AttributeError:
|
||||
return ["ru", "en"]
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks / 100
|
||||
return f"{rubles:.2f} ₽"
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
|
||||
def rubles_to_kopeks(self, rubles: float) -> int:
|
||||
return int(rubles * 100)
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
def is_notifications_enabled(self) -> bool:
|
||||
return self.ENABLE_NOTIFICATIONS
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8"
|
||||
}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
PERIOD_PRICES = {
|
||||
14: settings.PRICE_14_DAYS,
|
||||
30: settings.PRICE_30_DAYS,
|
||||
60: settings.PRICE_60_DAYS,
|
||||
90: settings.PRICE_90_DAYS,
|
||||
180: settings.PRICE_180_DAYS,
|
||||
360: settings.PRICE_360_DAYS,
|
||||
}
|
||||
|
||||
TRAFFIC_PRICES = {
|
||||
5: settings.PRICE_TRAFFIC_5GB,
|
||||
10: settings.PRICE_TRAFFIC_10GB,
|
||||
25: settings.PRICE_TRAFFIC_25GB,
|
||||
50: settings.PRICE_TRAFFIC_50GB,
|
||||
100: settings.PRICE_TRAFFIC_100GB,
|
||||
250: settings.PRICE_TRAFFIC_250GB,
|
||||
0: settings.PRICE_TRAFFIC_UNLIMITED,
|
||||
}
|
||||
236
app/database/crud/promocode.py
Normal file
236
app/database/crud/promocode.py
Normal file
@@ -0,0 +1,236 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(selectinload(PromoCode.uses))
|
||||
.where(PromoCode.code == code.upper())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_promocode(
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
type: PromoCodeType,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_days: int = 0,
|
||||
max_uses: int = 1,
|
||||
valid_until: Optional[datetime] = None,
|
||||
created_by: Optional[int] = None
|
||||
) -> PromoCode:
|
||||
|
||||
promocode = PromoCode(
|
||||
code=code.upper(),
|
||||
type=type.value,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks,
|
||||
subscription_days=subscription_days,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(promocode)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
|
||||
async def use_promocode(
|
||||
db: AsyncSession,
|
||||
promocode_id: int,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
usage = PromoCodeUse(
|
||||
promocode_id=promocode_id,
|
||||
user_id=user_id
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
promocode.current_uses += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Промокод {promocode.code} использован пользователем {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка использования промокода: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def check_user_promocode_usage(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promocode_id: int
|
||||
) -> bool:
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(
|
||||
and_(
|
||||
PromoCodeUse.user_id == user_id,
|
||||
PromoCodeUse.promocode_id == promocode_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
|
||||
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
|
||||
promocode_use = PromoCodeUse(
|
||||
promocode_id=promocode_id,
|
||||
user_id=user_id,
|
||||
used_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(promocode_use)
|
||||
await db.commit()
|
||||
await db.refresh(promocode_use)
|
||||
|
||||
logger.info(f"📝 Записано использование промокода {promocode_id} пользователем {user_id}")
|
||||
return promocode_use
|
||||
|
||||
|
||||
async def get_promocode_use_by_user_and_code(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promocode_id: int
|
||||
) -> Optional[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(
|
||||
and_(
|
||||
PromoCodeUse.user_id == user_id,
|
||||
PromoCodeUse.promocode_id == promocode_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_promocodes(db: AsyncSession, user_id: int) -> List[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse)
|
||||
.where(PromoCodeUse.user_id == user_id)
|
||||
.order_by(PromoCodeUse.used_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
|
||||
async def get_promocodes_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[PromoCode]:
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.uses))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promocodes_count(
|
||||
db: AsyncSession,
|
||||
is_active: Optional[bool] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(PromoCode.id))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def update_promocode(
|
||||
db: AsyncSession,
|
||||
promocode: PromoCode,
|
||||
**kwargs
|
||||
) -> PromoCode:
|
||||
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(promocode, field):
|
||||
setattr(promocode, field, value)
|
||||
|
||||
promocode.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
return promocode
|
||||
|
||||
|
||||
async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
|
||||
try:
|
||||
await db.delete(promocode)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🗑️ Удален промокод: {promocode.code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления промокода: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
|
||||
total_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id))
|
||||
.where(PromoCodeUse.promocode_id == promocode_id)
|
||||
)
|
||||
total_uses = total_uses_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id))
|
||||
.where(
|
||||
and_(
|
||||
PromoCodeUse.promocode_id == promocode_id,
|
||||
PromoCodeUse.used_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
today_uses = today_uses_result.scalar()
|
||||
|
||||
recent_uses_result = await db.execute(
|
||||
select(PromoCodeUse)
|
||||
.where(PromoCodeUse.promocode_id == promocode_id)
|
||||
.order_by(PromoCodeUse.used_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
recent_uses = recent_uses_result.scalars().all()
|
||||
|
||||
return {
|
||||
"total_uses": total_uses,
|
||||
"today_uses": today_uses,
|
||||
"recent_uses": recent_uses
|
||||
}
|
||||
268
app/database/crud/referral.py
Normal file
268
app/database/crud/referral.py
Normal file
@@ -0,0 +1,268 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ReferralEarning, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_referral_earning(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
referral_id: int,
|
||||
amount_kopeks: int,
|
||||
reason: str,
|
||||
referral_transaction_id: Optional[int] = None
|
||||
) -> ReferralEarning:
|
||||
|
||||
earning = ReferralEarning(
|
||||
user_id=user_id,
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
reason=reason,
|
||||
referral_transaction_id=referral_transaction_id
|
||||
)
|
||||
|
||||
db.add(earning)
|
||||
await db.commit()
|
||||
await db.refresh(earning)
|
||||
|
||||
logger.info(f"💰 Создан реферальный заработок: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return earning
|
||||
|
||||
|
||||
async def get_referral_earnings_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[ReferralEarning]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.options(
|
||||
selectinload(ReferralEarning.referral),
|
||||
selectinload(ReferralEarning.referral_transaction)
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_referral_earnings_by_referral(
|
||||
db: AsyncSession,
|
||||
referral_id: int
|
||||
) -> List[ReferralEarning]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.where(ReferralEarning.referral_id == referral_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_referral_earnings_sum(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == user_id
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.where(ReferralEarning.created_at >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.where(ReferralEarning.created_at <= end_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
users_with_referrals_result = await db.execute(
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
users_with_referrals = users_with_referrals_result.scalar()
|
||||
|
||||
active_referrers_result = await db.execute(
|
||||
select(func.count(func.distinct(User.referred_by_id)))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
active_referrers = active_referrers_result.scalar()
|
||||
|
||||
referral_paid_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
)
|
||||
referral_paid = referral_paid_result.scalar()
|
||||
|
||||
from app.database.models import Transaction, TransactionType
|
||||
transaction_paid_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
|
||||
)
|
||||
transaction_paid = transaction_paid_result.scalar()
|
||||
|
||||
total_paid = referral_paid + transaction_paid
|
||||
|
||||
top_referrers_result = await db.execute(
|
||||
select(
|
||||
User.referred_by_id.label('referrer_id'),
|
||||
func.count(User.id).label('referrals_count'),
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('referral_earnings'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('transaction_earnings')
|
||||
)
|
||||
.outerjoin(
|
||||
ReferralEarning,
|
||||
ReferralEarning.user_id == User.referred_by_id
|
||||
)
|
||||
.outerjoin(
|
||||
Transaction,
|
||||
and_(
|
||||
Transaction.user_id == User.referred_by_id,
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.referred_by_id != User.id
|
||||
)
|
||||
)
|
||||
.group_by(User.referred_by_id)
|
||||
.order_by(func.count(User.id).desc())
|
||||
.limit(5)
|
||||
)
|
||||
top_referrers_raw = top_referrers_result.all()
|
||||
|
||||
top_referrers = []
|
||||
for row in top_referrers_raw:
|
||||
user_result = await db.execute(
|
||||
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
|
||||
.where(User.id == row.referrer_id)
|
||||
)
|
||||
user = user_result.first()
|
||||
|
||||
if user:
|
||||
display_name = ""
|
||||
if user.first_name:
|
||||
display_name = user.first_name
|
||||
if user.last_name:
|
||||
display_name += f" {user.last_name}"
|
||||
elif user.username:
|
||||
display_name = f"@{user.username}"
|
||||
else:
|
||||
display_name = f"ID{user.telegram_id}"
|
||||
|
||||
total_earned = (row.referral_earnings or 0) + (row.transaction_earnings or 0)
|
||||
|
||||
top_referrers.append({
|
||||
"user_id": row.referrer_id,
|
||||
"display_name": display_name,
|
||||
"username": user.username,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"referrals_count": row.referrals_count
|
||||
})
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
today_referral_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= today)
|
||||
)
|
||||
today_transaction_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
today_earnings = today_referral_earnings.scalar() + today_transaction_earnings.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_referral_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= week_ago)
|
||||
)
|
||||
week_transaction_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= week_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
week_earnings = week_referral_earnings.scalar() + week_transaction_earnings.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_referral_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= month_ago)
|
||||
)
|
||||
month_transaction_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= month_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
month_earnings = month_referral_earnings.scalar() + month_transaction_earnings.scalar()
|
||||
|
||||
return {
|
||||
"users_with_referrals": users_with_referrals,
|
||||
"active_referrers": active_referrers,
|
||||
"total_paid_kopeks": total_paid,
|
||||
"today_earnings_kopeks": today_earnings,
|
||||
"week_earnings_kopeks": week_earnings,
|
||||
"month_earnings_kopeks": month_earnings,
|
||||
"top_referrers": top_referrers
|
||||
}
|
||||
|
||||
|
||||
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
invited_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
invited_count = invited_count_result.scalar()
|
||||
|
||||
total_earned = await get_referral_earnings_sum(db, user_id)
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
active_referrals_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.join(User.subscription)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.subscription.has()
|
||||
)
|
||||
)
|
||||
)
|
||||
active_referrals = active_referrals_result.scalar()
|
||||
|
||||
return {
|
||||
"invited_count": invited_count,
|
||||
"active_referrals": active_referrals,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"month_earned_kopeks": month_earned
|
||||
}
|
||||
82
app/database/crud/rules.py
Normal file
82
app/database/crud/rules.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import ServiceRule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_rules_by_language(db: AsyncSession, language: str = "ru") -> Optional[ServiceRule]:
|
||||
result = await db.execute(
|
||||
select(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.order_by(ServiceRule.order, ServiceRule.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_or_update_rules(
|
||||
db: AsyncSession,
|
||||
content: str,
|
||||
language: str = "ru",
|
||||
title: str = "Правила сервиса"
|
||||
) -> ServiceRule:
|
||||
|
||||
existing_rules_result = await db.execute(
|
||||
select(ServiceRule).where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
)
|
||||
existing_rules = existing_rules_result.scalars().all()
|
||||
|
||||
for rule in existing_rules:
|
||||
rule.is_active = False
|
||||
rule.updated_at = datetime.utcnow()
|
||||
|
||||
new_rules = ServiceRule(
|
||||
title=title,
|
||||
content=content,
|
||||
language=language,
|
||||
is_active=True,
|
||||
order=0
|
||||
)
|
||||
|
||||
db.add(new_rules)
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f"✅ Правила для языка {language} обновлены")
|
||||
return new_rules
|
||||
|
||||
|
||||
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
|
||||
rules = await get_rules_by_language(db, language)
|
||||
|
||||
if rules:
|
||||
return rules.content
|
||||
else:
|
||||
return """
|
||||
🔒 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Сервис предоставляется "как есть" без каких-либо гарантий.
|
||||
|
||||
2. Запрещается использование сервиса для незаконных действий.
|
||||
|
||||
3. Администрация оставляет за собой право заблокировать доступ пользователя при нарушении правил.
|
||||
|
||||
4. Возврат средств осуществляется в соответствии с политикой возврата.
|
||||
|
||||
5. Пользователь несет полную ответственность за безопасность своего аккаунта.
|
||||
|
||||
6. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
355
app/database/crud/server_squad.py
Normal file
355
app/database/crud/server_squad.py
Normal file
@@ -0,0 +1,355 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, func, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str,
|
||||
display_name: str,
|
||||
original_name: str = None,
|
||||
country_code: str = None,
|
||||
price_kopeks: int = 0,
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True
|
||||
) -> ServerSquad:
|
||||
|
||||
server_squad = ServerSquad(
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=display_name,
|
||||
original_name=original_name,
|
||||
country_code=country_code,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available
|
||||
)
|
||||
|
||||
db.add(server_squad)
|
||||
await db.commit()
|
||||
await db.refresh(server_squad)
|
||||
|
||||
logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})")
|
||||
return server_squad
|
||||
|
||||
|
||||
async def get_server_squad_by_uuid(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_server_squad_by_id(
|
||||
db: AsyncSession,
|
||||
server_id: int
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.id == server_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_server_squads(
|
||||
db: AsyncSession,
|
||||
available_only: bool = False,
|
||||
page: int = 1,
|
||||
limit: int = 50
|
||||
) -> Tuple[List[ServerSquad], int]:
|
||||
|
||||
query = select(ServerSquad)
|
||||
|
||||
if available_only:
|
||||
query = query.where(ServerSquad.is_available == True)
|
||||
|
||||
count_query = select(func.count(ServerSquad.id))
|
||||
if available_only:
|
||||
count_query = count_query.where(ServerSquad.is_available == True)
|
||||
|
||||
count_result = await db.execute(count_query)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
servers = result.scalars().all()
|
||||
|
||||
return servers, total_count
|
||||
|
||||
|
||||
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad)
|
||||
.where(ServerSquad.is_available == True)
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_server_squad(
|
||||
db: AsyncSession,
|
||||
server_id: int,
|
||||
**updates
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order'
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
|
||||
if not filtered_updates:
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(**filtered_updates)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return await get_server_squad_by_id(db, server_id)
|
||||
|
||||
|
||||
async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
|
||||
|
||||
connections_result = await db.execute(
|
||||
select(func.count(SubscriptionServer.id))
|
||||
.where(SubscriptionServer.server_squad_id == server_id)
|
||||
)
|
||||
connections_count = connections_result.scalar()
|
||||
|
||||
if connections_count > 0:
|
||||
logger.warning(f"❌ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
delete(ServerSquad).where(ServerSquad.id == server_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🗑️ Удален сервер (ID: {server_id})")
|
||||
return True
|
||||
|
||||
|
||||
async def sync_with_remnawave(
|
||||
db: AsyncSession,
|
||||
remnawave_squads: List[dict]
|
||||
) -> Tuple[int, int, int]:
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
disabled = 0
|
||||
|
||||
existing_servers = {}
|
||||
result = await db.execute(select(ServerSquad))
|
||||
for server in result.scalars().all():
|
||||
existing_servers[server.squad_uuid] = server
|
||||
|
||||
remnawave_uuids = {squad['uuid'] for squad in remnawave_squads}
|
||||
|
||||
for squad in remnawave_squads:
|
||||
squad_uuid = squad['uuid']
|
||||
original_name = squad.get('name', f'Squad {squad_uuid[:8]}')
|
||||
|
||||
if squad_uuid in existing_servers:
|
||||
server = existing_servers[squad_uuid]
|
||||
if server.original_name != original_name:
|
||||
server.original_name = original_name
|
||||
updated += 1
|
||||
else:
|
||||
await create_server_squad(
|
||||
db=db,
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=_generate_display_name(original_name),
|
||||
original_name=original_name,
|
||||
country_code=_extract_country_code(original_name),
|
||||
price_kopeks=1000,
|
||||
is_available=False
|
||||
)
|
||||
created += 1
|
||||
|
||||
for uuid, server in existing_servers.items():
|
||||
if uuid not in remnawave_uuids and server.is_available:
|
||||
server.is_available = False
|
||||
disabled += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
|
||||
return created, updated, disabled
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
country_names = {
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'US': '🇺🇸 США',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'JP': '🇯🇵 Япония',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'AU': '🇦🇺 Австралия',
|
||||
}
|
||||
|
||||
name_upper = original_name.upper()
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
return f"🌍 {original_name}"
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
|
||||
name_upper = original_name.upper()
|
||||
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
available_result = await db.execute(
|
||||
select(func.count(ServerSquad.id))
|
||||
.where(ServerSquad.is_available == True)
|
||||
)
|
||||
available_servers = available_result.scalar()
|
||||
|
||||
with_connections_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionServer.server_squad_id)))
|
||||
)
|
||||
servers_with_connections = with_connections_result.scalar()
|
||||
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))
|
||||
)
|
||||
total_revenue_kopeks = revenue_result.scalar()
|
||||
|
||||
return {
|
||||
'total_servers': total_servers,
|
||||
'available_servers': available_servers,
|
||||
'unavailable_servers': total_servers - available_servers,
|
||||
'servers_with_connections': servers_with_connections,
|
||||
'total_revenue_kopeks': total_revenue_kopeks,
|
||||
'total_revenue_rubles': total_revenue_kopeks / 100
|
||||
}
|
||||
|
||||
async def add_user_to_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка увеличения счетчика пользователей: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def remove_user_from_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка уменьшения счетчика пользователей: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_server_ids_by_uuids(
|
||||
db: AsyncSession,
|
||||
squad_uuids: List[str]
|
||||
) -> List[int]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.id)
|
||||
.where(ServerSquad.squad_uuid.in_(squad_uuids))
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(
|
||||
ServerSquad.id,
|
||||
ServerSquad.squad_uuid,
|
||||
func.count(SubscriptionServer.id).label('actual_users')
|
||||
)
|
||||
.outerjoin(SubscriptionServer, ServerSquad.id == SubscriptionServer.server_squad_id)
|
||||
.join(Subscription, SubscriptionServer.subscription_id == Subscription.id)
|
||||
.where(Subscription.status == 'active')
|
||||
.group_by(ServerSquad.id, ServerSquad.squad_uuid)
|
||||
)
|
||||
|
||||
updated_count = 0
|
||||
for server_id, squad_uuid, actual_users in result.fetchall():
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=actual_users)
|
||||
)
|
||||
updated_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Синхронизированы счетчики для {updated_count} серверов")
|
||||
return updated_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
61
app/database/crud/squad.py
Normal file
61
app/database/crud/squad.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Squad
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Optional[Squad]:
|
||||
result = await db.execute(
|
||||
select(Squad).where(Squad.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_available_squads(db: AsyncSession) -> List[Squad]:
|
||||
result = await db.execute(
|
||||
select(Squad).where(Squad.is_available == True)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def create_squad(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
name: str,
|
||||
country_code: str = None,
|
||||
price_kopeks: int = 0,
|
||||
description: str = None
|
||||
) -> Squad:
|
||||
squad = Squad(
|
||||
uuid=uuid,
|
||||
name=name,
|
||||
country_code=country_code,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
db.add(squad)
|
||||
await db.commit()
|
||||
await db.refresh(squad)
|
||||
|
||||
logger.info(f"✅ Создан сквад: {name}")
|
||||
return squad
|
||||
|
||||
|
||||
async def update_squad(
|
||||
db: AsyncSession,
|
||||
squad: Squad,
|
||||
**kwargs
|
||||
) -> Squad:
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(squad, field):
|
||||
setattr(squad, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(squad)
|
||||
|
||||
return squad
|
||||
485
app/database/crud/subscription.py
Normal file
485
app/database/crud/subscription.py
Normal file
@@ -0,0 +1,485 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(Subscription.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_trial_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
squad_uuid: str = None
|
||||
) -> Subscription:
|
||||
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=True,
|
||||
start_date=datetime.utcnow(),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else []
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
start_date=datetime.utcnow(),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or []
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def extend_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
days: int
|
||||
) -> Subscription:
|
||||
|
||||
subscription.extend_subscription(days)
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Подписка пользователя {subscription.user_id} продлена на {days} дней")
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_traffic(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
gb: int
|
||||
) -> Subscription:
|
||||
|
||||
subscription.add_traffic(gb)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика")
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_devices(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
devices: int
|
||||
) -> Subscription:
|
||||
|
||||
subscription.device_limit += devices
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств")
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_squad(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
squad_uuid: str
|
||||
) -> Subscription:
|
||||
|
||||
if squad_uuid not in subscription.connected_squads:
|
||||
subscription.connected_squads = subscription.connected_squads + [squad_uuid]
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def remove_subscription_squad(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
squad_uuid: str
|
||||
) -> Subscription:
|
||||
|
||||
if squad_uuid in subscription.connected_squads:
|
||||
squads = subscription.connected_squads.copy()
|
||||
squads.remove(squad_uuid)
|
||||
subscription.connected_squads = squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def update_subscription_autopay(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
enabled: bool,
|
||||
days_before: int = 3
|
||||
) -> Subscription:
|
||||
|
||||
subscription.autopay_enabled = enabled
|
||||
subscription.autopay_days_before = days_before
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
status = "включен" if enabled else "выключен"
|
||||
logger.info(f"💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def deactivate_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована")
|
||||
return subscription
|
||||
|
||||
|
||||
async def get_expiring_subscriptions(
|
||||
db: AsyncSession,
|
||||
days_before: int = 3
|
||||
) -> List[Subscription]:
|
||||
|
||||
threshold_date = datetime.utcnow() + timedelta(days=days_before)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date <= threshold_date,
|
||||
Subscription.end_date > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date <= datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.autopay_enabled == True,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
all_autopay_subscriptions = result.scalars().all()
|
||||
|
||||
ready_for_autopay = []
|
||||
for subscription in all_autopay_subscriptions:
|
||||
days_until_expiry = (subscription.end_date - current_time).days
|
||||
|
||||
if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time:
|
||||
ready_for_autopay.append(subscription)
|
||||
|
||||
return ready_for_autopay
|
||||
|
||||
|
||||
async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(Subscription.id)))
|
||||
total_subscriptions = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(Subscription.status == SubscriptionStatus.ACTIVE.value)
|
||||
)
|
||||
active_subscriptions = active_result.scalar()
|
||||
|
||||
trial_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_trial == True,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
trial_subscriptions = trial_result.scalar()
|
||||
|
||||
paid_subscriptions = active_subscriptions - trial_subscriptions
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= today,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_today = today_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= week_ago,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_week = week_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= month_ago,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_month = month_result.scalar()
|
||||
|
||||
return {
|
||||
"total_subscriptions": total_subscriptions,
|
||||
"active_subscriptions": active_subscriptions,
|
||||
"trial_subscriptions": trial_subscriptions,
|
||||
"paid_subscriptions": paid_subscriptions,
|
||||
"purchased_today": purchased_today,
|
||||
"purchased_week": purchased_week,
|
||||
"purchased_month": purchased_month
|
||||
}
|
||||
|
||||
|
||||
async def update_subscription_usage(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
used_gb: float
|
||||
) -> Subscription:
|
||||
subscription.traffic_used_gb = used_gb
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
return subscription
|
||||
|
||||
async def get_all_subscriptions(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 10
|
||||
) -> Tuple[List[Subscription], int]:
|
||||
count_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
return subscriptions, total_count
|
||||
|
||||
async def add_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
server_squad_ids: List[int],
|
||||
paid_prices: List[int] = None
|
||||
) -> Subscription:
|
||||
|
||||
if paid_prices is None:
|
||||
paid_prices = [0] * len(server_squad_ids)
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
subscription_server = SubscriptionServer(
|
||||
subscription_id=subscription.id,
|
||||
server_squad_id=server_id,
|
||||
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
|
||||
)
|
||||
db.add(subscription_server)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов")
|
||||
return subscription
|
||||
|
||||
async def get_subscription_server_ids(
|
||||
db: AsyncSession,
|
||||
subscription_id: int
|
||||
) -> List[int]:
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer.server_squad_id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription_id: int
|
||||
) -> List[dict]:
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer, ServerSquad)
|
||||
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
|
||||
servers_info = []
|
||||
for sub_server, server_squad in result.fetchall():
|
||||
servers_info.append({
|
||||
'server_id': server_squad.id,
|
||||
'squad_uuid': server_squad.squad_uuid,
|
||||
'display_name': server_squad.display_name,
|
||||
'country_code': server_squad.country_code,
|
||||
'paid_price_kopeks': sub_server.paid_price_kopeks,
|
||||
'connected_at': sub_server.connected_at,
|
||||
'is_available': server_squad.is_available
|
||||
})
|
||||
|
||||
return servers_info
|
||||
|
||||
async def create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: str = "trial",
|
||||
is_trial: bool = True,
|
||||
end_date: datetime = None,
|
||||
traffic_limit_gb: int = 10,
|
||||
traffic_used_gb: float = 0.0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = ""
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
is_trial=is_trial,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"✅ Создана подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
278
app/database/crud/transaction.py
Normal file
278
app/database/crud/transaction.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import Transaction, TransactionType, PaymentMethod, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
type: TransactionType,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
external_id: Optional[str] = None,
|
||||
is_completed: bool = True
|
||||
) -> Transaction:
|
||||
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
type=type.value,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method.value if payment_method else None,
|
||||
external_id=external_id,
|
||||
is_completed=is_completed,
|
||||
completed_at=datetime.utcnow() if is_completed else None
|
||||
)
|
||||
|
||||
db.add(transaction)
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return transaction
|
||||
|
||||
|
||||
async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Optional[Transaction]:
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(Transaction.id == transaction_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_transaction_by_external_id(
|
||||
db: AsyncSession,
|
||||
external_id: str,
|
||||
payment_method: PaymentMethod
|
||||
) -> Optional[Transaction]:
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.external_id == external_id,
|
||||
Transaction.payment_method == payment_method.value
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_transactions(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Transaction]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.where(Transaction.user_id == user_id)
|
||||
.order_by(Transaction.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_transactions_count(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
transaction_type: Optional[TransactionType] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(Transaction.id)).where(Transaction.user_id == user_id)
|
||||
|
||||
if transaction_type:
|
||||
query = query.where(Transaction.type == transaction_type.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
|
||||
|
||||
transaction.is_completed = True
|
||||
transaction.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"✅ Транзакция {transaction.id} завершена")
|
||||
return transaction
|
||||
|
||||
|
||||
async def get_pending_transactions(db: AsyncSession) -> List[Transaction]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(Transaction.is_completed == False)
|
||||
.order_by(Transaction.created_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_transactions_statistics(
|
||||
db: AsyncSession,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> dict:
|
||||
|
||||
if not start_date:
|
||||
start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
|
||||
income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
total_income = income_result.scalar()
|
||||
|
||||
expenses_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.WITHDRAWAL.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
total_expenses = expenses_result.scalar()
|
||||
|
||||
subscription_income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
subscription_income = subscription_income_result.scalar()
|
||||
|
||||
transactions_count_result = await db.execute(
|
||||
select(
|
||||
Transaction.type,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.type)
|
||||
)
|
||||
transactions_by_type = {row.type: {"count": row.count, "amount": row.total_amount}
|
||||
for row in transactions_count_result}
|
||||
|
||||
payment_methods_result = await db.execute(
|
||||
select(
|
||||
Transaction.payment_method,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.payment_method)
|
||||
)
|
||||
payment_methods = {row.payment_method: {"count": row.count, "amount": row.total_amount}
|
||||
for row in payment_methods_result}
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(Transaction.id))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
transactions_today = today_result.scalar()
|
||||
|
||||
today_income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
income_today = today_income_result.scalar()
|
||||
|
||||
return {
|
||||
"period": {
|
||||
"start_date": start_date,
|
||||
"end_date": end_date
|
||||
},
|
||||
"totals": {
|
||||
"income_kopeks": total_income,
|
||||
"expenses_kopeks": total_expenses,
|
||||
"profit_kopeks": total_income - total_expenses,
|
||||
"subscription_income_kopeks": subscription_income
|
||||
},
|
||||
"today": {
|
||||
"transactions_count": transactions_today,
|
||||
"income_kopeks": income_today
|
||||
},
|
||||
"by_type": transactions_by_type,
|
||||
"by_payment_method": payment_methods
|
||||
}
|
||||
|
||||
|
||||
async def get_revenue_by_period(
|
||||
db: AsyncSession,
|
||||
days: int = 30
|
||||
) -> List[dict]:
|
||||
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date
|
||||
)
|
||||
)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
.order_by(func.date(Transaction.created_at))
|
||||
)
|
||||
|
||||
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
|
||||
327
app/database/crud/user.py
Normal file
327
app/database/crud/user.py
Normal file
@@ -0,0 +1,327 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_referral_code() -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
return f"ref{code_suffix}"
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
max_attempts = 10
|
||||
|
||||
for _ in range(max_attempts):
|
||||
code = generate_referral_code()
|
||||
existing_user = await get_user_by_referral_code(db, code)
|
||||
if not existing_user:
|
||||
return code
|
||||
|
||||
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language: str = "ru",
|
||||
referred_by_id: int = None,
|
||||
referral_code: str = None
|
||||
) -> User:
|
||||
|
||||
if not referral_code:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
**kwargs
|
||||
) -> User:
|
||||
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> User:
|
||||
|
||||
user.add_balance(amount_kopeks)
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Пополнен баланс пользователя {user.telegram_id}: +{amount_kopeks/100}₽")
|
||||
return user
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str = "Списание с баланса"
|
||||
) -> bool:
|
||||
|
||||
if not user.subtract_balance(amount_kopeks):
|
||||
return False
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.WITHDRAWAL,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💸 Списан баланс пользователя {user.telegram_id}: -{amount_kopeks/100}₽")
|
||||
return True
|
||||
|
||||
|
||||
async def get_users_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[UserStatus] = None
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
conditions = [
|
||||
User.first_name.ilike(search_term),
|
||||
User.last_name.ilike(search_term),
|
||||
User.username.ilike(search_term)
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
db: AsyncSession,
|
||||
status: Optional[UserStatus] = None,
|
||||
search: Optional[str] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(User.id))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
conditions = [
|
||||
User.first_name.ilike(search_term),
|
||||
User.last_name.ilike(search_term),
|
||||
User.username.ilike(search_term)
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
user.status = UserStatus.DELETED.value
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🗑️ Пользователь {user.telegram_id} помечен как удаленный")
|
||||
return True
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
active_users = active_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= today,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_today = today_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= week_ago,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_week = week_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= month_ago,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_month = month_result.scalar()
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"blocked_users": total_users - active_users,
|
||||
"new_today": new_today,
|
||||
"new_week": new_week,
|
||||
"new_month": new_month
|
||||
}
|
||||
51
app/database/database.py
Normal file
51
app/database/database.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
poolclass=NullPool,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
async def close_db():
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
421
app/database/models.py
Normal file
421
app/database/models.py
Normal file
@@ -0,0 +1,421 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, Float, JSON, BigInteger
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
BLOCKED = "blocked"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
class SubscriptionStatus(Enum):
|
||||
TRIAL = "trial"
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund" # Возврат
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
|
||||
|
||||
class PaymentMethod(Enum):
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
TRIBUTE = "tribute"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
|
||||
username = Column(String(255), nullable=True)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
|
||||
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
||||
language = Column(String(5), default="ru")
|
||||
|
||||
balance_kopeks = Column(Integer, default=0)
|
||||
|
||||
used_promocodes = Column(Integer, default=0)
|
||||
|
||||
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
referral_code = Column(String(20), unique=True, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_activity = Column(DateTime, default=func.now())
|
||||
|
||||
remnawave_uuid = Column(String(255), nullable=True, unique=True)
|
||||
|
||||
broadcasts = relationship("BroadcastHistory", back_populates="admin")
|
||||
|
||||
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
|
||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
return self.balance_kopeks / 100
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
|
||||
def subtract_balance(self, kopeks: int) -> bool:
|
||||
if self.balance_kopeks >= kopeks:
|
||||
self.balance_kopeks -= kopeks
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
|
||||
is_trial = Column(Boolean, default=True)
|
||||
|
||||
start_date = Column(DateTime, default=func.now())
|
||||
end_date = Column(DateTime, nullable=False)
|
||||
|
||||
traffic_limit_gb = Column(Integer, default=0)
|
||||
traffic_used_gb = Column(Float, default=0.0)
|
||||
|
||||
subscription_url = Column(String, nullable=True)
|
||||
|
||||
device_limit = Column(Integer, default=1)
|
||||
|
||||
connected_squads = Column(JSON, default=list)
|
||||
|
||||
autopay_enabled = Column(Boolean, default=False)
|
||||
autopay_days_before = Column(Integer, default=3)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value and
|
||||
self.end_date > datetime.utcnow()
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.end_date <= datetime.utcnow()
|
||||
|
||||
@property
|
||||
def days_left(self) -> int:
|
||||
if self.is_expired:
|
||||
return 0
|
||||
delta = self.end_date - datetime.utcnow()
|
||||
return delta.days
|
||||
|
||||
@property
|
||||
def traffic_used_percent(self) -> float:
|
||||
if self.traffic_limit_gb == 0:
|
||||
return 0.0
|
||||
if self.traffic_limit_gb > 0:
|
||||
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
|
||||
return 0.0
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
if self.end_date > datetime.utcnow():
|
||||
self.end_date = self.end_date + timedelta(days=days)
|
||||
else:
|
||||
self.end_date = datetime.utcnow() + timedelta(days=days)
|
||||
|
||||
if self.status == SubscriptionStatus.EXPIRED.value:
|
||||
self.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
def add_traffic(self, gb: int):
|
||||
if self.traffic_limit_gb == 0:
|
||||
return
|
||||
self.traffic_limit_gb += gb
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
type = Column(String(50), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
external_id = Column(String(255), nullable=True)
|
||||
|
||||
is_completed = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="transactions")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promocodes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
type = Column(String(50), nullable=False)
|
||||
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
subscription_days = Column(Integer, default=0)
|
||||
|
||||
max_uses = Column(Integer, default=1)
|
||||
current_uses = Column(Integer, default=0)
|
||||
|
||||
valid_from = Column(DateTime, default=func.now())
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
uses = relationship("PromoCodeUse", back_populates="promocode")
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
now = datetime.utcnow()
|
||||
return (
|
||||
self.is_active and
|
||||
self.current_uses < self.max_uses and
|
||||
self.valid_from <= now and
|
||||
(self.valid_until is None or self.valid_until >= now)
|
||||
)
|
||||
|
||||
@property
|
||||
def uses_left(self) -> int:
|
||||
return max(0, self.max_uses - self.current_uses)
|
||||
|
||||
|
||||
class PromoCodeUse(Base):
|
||||
__tablename__ = "promocode_uses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
used_at = Column(DateTime, default=func.now())
|
||||
|
||||
promocode = relationship("PromoCode", back_populates="uses")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class ReferralEarning(Base):
|
||||
__tablename__ = "referral_earnings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
referral_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
reason = Column(String(100), nullable=False)
|
||||
|
||||
referral_transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="referral_earnings")
|
||||
referral = relationship("User", foreign_keys=[referral_id])
|
||||
referral_transaction = relationship("Transaction")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
|
||||
class Squad(Base):
|
||||
__tablename__ = "squads"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
uuid = Column(String(255), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
return self.price_kopeks / 100
|
||||
|
||||
|
||||
class ServiceRule(Base):
|
||||
__tablename__ = "service_rules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
order = Column(Integer, default=0)
|
||||
title = Column(String(255), nullable=False)
|
||||
|
||||
content = Column(Text, nullable=False)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
language = Column(String(5), default="ru")
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(255), unique=True, nullable=False)
|
||||
value = Column(Text, nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class MonitoringLog(Base):
|
||||
__tablename__ = "monitoring_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
event_type = Column(String(100), nullable=False)
|
||||
|
||||
message = Column(Text, nullable=False)
|
||||
data = Column(JSON, nullable=True)
|
||||
|
||||
is_success = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
total_count = Column(Integer, default=0)
|
||||
sent_count = Column(Integer, default=0)
|
||||
failed_count = Column(Integer, default=0)
|
||||
status = Column(String(50), default="in_progress")
|
||||
admin_id = Column(Integer, ForeignKey("users.id"))
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
display_name = Column(String(255), nullable=False)
|
||||
|
||||
original_name = Column(String(255), nullable=True)
|
||||
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
max_users = Column(Integer, nullable=True)
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
return self.price_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
if self.max_users is None:
|
||||
return False
|
||||
return self.current_users >= self.max_users
|
||||
|
||||
@property
|
||||
def availability_status(self) -> str:
|
||||
if not self.is_available:
|
||||
return "Недоступен"
|
||||
elif self.is_full:
|
||||
return "Переполнен"
|
||||
else:
|
||||
return "Доступен"
|
||||
|
||||
|
||||
class SubscriptionServer(Base):
|
||||
__tablename__ = "subscription_servers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False)
|
||||
server_squad_id = Column(Integer, ForeignKey("server_squads.id"), nullable=False)
|
||||
|
||||
connected_at = Column(DateTime, default=func.now())
|
||||
|
||||
paid_price_kopeks = Column(Integer, default=0)
|
||||
|
||||
subscription = relationship("Subscription", backref="subscription_servers")
|
||||
server_squad = relationship("ServerSquad", backref="subscription_servers")
|
||||
521
app/external/remnawave_api.py
vendored
Normal file
521
app/external/remnawave_api.py
vendored
Normal file
@@ -0,0 +1,521 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
DISABLED = "DISABLED"
|
||||
LIMITED = "LIMITED"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
|
||||
class TrafficLimitStrategy(Enum):
|
||||
NO_RESET = "NO_RESET"
|
||||
DAY = "DAY"
|
||||
WEEK = "WEEK"
|
||||
MONTH = "MONTH"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveUser:
|
||||
uuid: str
|
||||
short_uuid: str
|
||||
username: str
|
||||
status: UserStatus
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
traffic_limit_bytes: int
|
||||
traffic_limit_strategy: TrafficLimitStrategy
|
||||
expire_at: datetime
|
||||
telegram_id: Optional[int]
|
||||
email: Optional[str]
|
||||
hwid_device_limit: Optional[int]
|
||||
description: Optional[str]
|
||||
tag: Optional[str]
|
||||
subscription_url: str
|
||||
active_internal_squads: List[Dict[str, str]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveInternalSquad:
|
||||
uuid: str
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveNode:
|
||||
uuid: str
|
||||
name: str
|
||||
address: str
|
||||
country_code: str
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: Optional[int]
|
||||
traffic_used_bytes: Optional[int]
|
||||
traffic_limit_bytes: Optional[int]
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.response_data = response_data
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
self.session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
if not self.session:
|
||||
raise RemnaWaveAPIError("Session not initialized. Use async context manager.")
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
'url': url,
|
||||
'params': params
|
||||
}
|
||||
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
raise RemnaWaveAPIError(
|
||||
error_message,
|
||||
response.status,
|
||||
response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise RemnaWaveAPIError(f"Request failed: {str(e)}")
|
||||
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
username: str,
|
||||
expire_at: datetime,
|
||||
status: UserStatus = UserStatus.ACTIVE,
|
||||
traffic_limit_bytes: int = 0,
|
||||
traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
active_internal_squads: Optional[List[str]] = None
|
||||
) -> RemnaWaveUser:
|
||||
data = {
|
||||
'username': username,
|
||||
'status': status.value,
|
||||
'expireAt': expire_at.isoformat(),
|
||||
'trafficLimitBytes': traffic_limit_bytes,
|
||||
'trafficLimitStrategy': traffic_limit_strategy.value
|
||||
}
|
||||
|
||||
if telegram_id:
|
||||
data['telegramId'] = telegram_id
|
||||
if email:
|
||||
data['email'] = email
|
||||
if hwid_device_limit:
|
||||
data['hwidDeviceLimit'] = hwid_device_limit
|
||||
if description:
|
||||
data['description'] = description
|
||||
if tag:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/{uuid}')
|
||||
return self._parse_user(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
|
||||
return [self._parse_user(user) for user in response['response']]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-username/{username}')
|
||||
return self._parse_user(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
uuid: str,
|
||||
status: Optional[UserStatus] = None,
|
||||
traffic_limit_bytes: Optional[int] = None,
|
||||
traffic_limit_strategy: Optional[TrafficLimitStrategy] = None,
|
||||
expire_at: Optional[datetime] = None,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
active_internal_squads: Optional[List[str]] = None
|
||||
) -> RemnaWaveUser:
|
||||
data = {'uuid': uuid}
|
||||
|
||||
if status:
|
||||
data['status'] = status.value
|
||||
if traffic_limit_bytes is not None:
|
||||
data['trafficLimitBytes'] = traffic_limit_bytes
|
||||
if traffic_limit_strategy:
|
||||
data['trafficLimitStrategy'] = traffic_limit_strategy.value
|
||||
if expire_at:
|
||||
data['expireAt'] = expire_at.isoformat()
|
||||
if telegram_id is not None:
|
||||
data['telegramId'] = telegram_id
|
||||
if email is not None:
|
||||
data['email'] = email
|
||||
if hwid_device_limit is not None:
|
||||
data['hwidDeviceLimit'] = hwid_device_limit
|
||||
if description is not None:
|
||||
data['description'] = description
|
||||
if tag is not None:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads is not None:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def delete_user(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/users/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def enable_user(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def disable_user(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
|
||||
data = {}
|
||||
if new_short_uuid:
|
||||
data['shortUuid'] = new_short_uuid
|
||||
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]:
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
|
||||
return {
|
||||
'users': [self._parse_user(user) for user in response['response']['users']],
|
||||
'total': response['response']['total']
|
||||
}
|
||||
|
||||
|
||||
async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]:
|
||||
response = await self._make_request('GET', '/api/internal-squads')
|
||||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||||
|
||||
async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/internal-squads/{uuid}')
|
||||
return self._parse_internal_squad(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad:
|
||||
data = {
|
||||
'name': name,
|
||||
'inbounds': inbounds
|
||||
}
|
||||
response = await self._make_request('POST', '/api/internal-squads', data)
|
||||
return self._parse_internal_squad(response['response'])
|
||||
|
||||
async def update_internal_squad(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
inbounds: Optional[List[str]] = None
|
||||
) -> RemnaWaveInternalSquad:
|
||||
data = {'uuid': uuid}
|
||||
if name:
|
||||
data['name'] = name
|
||||
if inbounds is not None:
|
||||
data['inbounds'] = inbounds
|
||||
|
||||
response = await self._make_request('PATCH', '/api/internal-squads', data)
|
||||
return self._parse_internal_squad(response['response'])
|
||||
|
||||
async def delete_internal_squad(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
|
||||
async def get_all_nodes(self) -> List[RemnaWaveNode]:
|
||||
response = await self._make_request('GET', '/api/nodes')
|
||||
return [self._parse_node(node) for node in response['response']]
|
||||
|
||||
async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/nodes/{uuid}')
|
||||
return self._parse_node(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def enable_node(self, uuid: str) -> RemnaWaveNode:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable')
|
||||
return self._parse_node(response['response'])
|
||||
|
||||
async def disable_node(self, uuid: str) -> RemnaWaveNode:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable')
|
||||
return self._parse_node(response['response'])
|
||||
|
||||
async def restart_node(self, uuid: str) -> bool:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def restart_all_nodes(self) -> bool:
|
||||
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
|
||||
return response['response']['eventSent']
|
||||
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
|
||||
return response['response']
|
||||
|
||||
|
||||
async def get_system_stats(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats')
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats/bandwidth')
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_statistics(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats/nodes')
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/nodes/usage/realtime')
|
||||
return response['response']
|
||||
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
username=user_data['username'],
|
||||
status=UserStatus(user_data['status']),
|
||||
used_traffic_bytes=int(user_data['usedTrafficBytes']),
|
||||
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
|
||||
traffic_limit_bytes=user_data['trafficLimitBytes'],
|
||||
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
|
||||
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
|
||||
telegram_id=user_data.get('telegramId'),
|
||||
email=user_data.get('email'),
|
||||
hwid_device_limit=user_data.get('hwidDeviceLimit'),
|
||||
description=user_data.get('description'),
|
||||
tag=user_data.get('tag'),
|
||||
subscription_url=user_data['subscriptionUrl'],
|
||||
active_internal_squads=user_data['activeInternalSquads'],
|
||||
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00'))
|
||||
)
|
||||
|
||||
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
|
||||
return RemnaWaveInternalSquad(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
members_count=squad_data['info']['membersCount'],
|
||||
inbounds_count=squad_data['info']['inboundsCount'],
|
||||
inbounds=squad_data['inbounds']
|
||||
)
|
||||
|
||||
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
|
||||
return RemnaWaveNode(
|
||||
uuid=node_data['uuid'],
|
||||
name=node_data['name'],
|
||||
address=node_data['address'],
|
||||
country_code=node_data['countryCode'],
|
||||
is_connected=node_data['isConnected'],
|
||||
is_disabled=node_data['isDisabled'],
|
||||
is_node_online=node_data['isNodeOnline'],
|
||||
is_xray_running=node_data['isXrayRunning'],
|
||||
users_online=node_data.get('usersOnline'),
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
)
|
||||
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
if bytes_value == 0:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = bytes_value
|
||||
unit_index = 0
|
||||
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
return f"{size:.1f} {units[unit_index]}"
|
||||
|
||||
|
||||
def parse_bytes(size_str: str) -> int:
|
||||
size_str = size_str.upper().strip()
|
||||
|
||||
units = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 ** 2,
|
||||
'GB': 1024 ** 3,
|
||||
'TB': 1024 ** 4
|
||||
}
|
||||
|
||||
for unit, multiplier in units.items():
|
||||
if size_str.endswith(unit):
|
||||
try:
|
||||
value = float(size_str[:-len(unit)].strip())
|
||||
return int(value * multiplier)
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def test_api_connection(api: RemnaWaveAPI) -> bool:
|
||||
try:
|
||||
await api.get_system_stats()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||||
return response['response']
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return {'total': 0, 'devices': []}
|
||||
raise
|
||||
|
||||
|
||||
async def reset_user_devices(self, user_uuid: str) -> bool:
|
||||
try:
|
||||
devices_info = await self.get_user_devices(user_uuid)
|
||||
devices = devices_info.get('devices', [])
|
||||
|
||||
if not devices:
|
||||
return True
|
||||
|
||||
failed_count = 0
|
||||
for device in devices:
|
||||
device_hwid = device.get('hwid')
|
||||
if device_hwid:
|
||||
try:
|
||||
delete_data = {
|
||||
"userUuid": user_uuid,
|
||||
"hwid": device_hwid
|
||||
}
|
||||
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
except Exception as device_error:
|
||||
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
|
||||
failed_count += 1
|
||||
|
||||
return failed_count < len(devices) / 2
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе устройств: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
|
||||
try:
|
||||
delete_data = {
|
||||
"userUuid": user_uuid,
|
||||
"hwid": device_hwid
|
||||
}
|
||||
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
|
||||
return False
|
||||
100
app/external/telegram_stars.py
vendored
Normal file
100
app/external/telegram_stars.py
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramStarsService:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
chat_id: int,
|
||||
title: str,
|
||||
description: str,
|
||||
amount_kopeks: int,
|
||||
payload: str,
|
||||
start_parameter: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label=title, amount=stars_amount)],
|
||||
start_parameter=start_parameter
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд для {chat_id}")
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
return None
|
||||
|
||||
async def send_invoice(
|
||||
self,
|
||||
chat_id: int,
|
||||
title: str,
|
||||
description: str,
|
||||
amount_kopeks: int,
|
||||
payload: str,
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label=title, amount=stars_amount)],
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
logger.info(f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд")
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"stars_amount": stars_amount,
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки Stars invoice: {e}")
|
||||
return None
|
||||
|
||||
async def answer_pre_checkout_query(
|
||||
self,
|
||||
pre_checkout_query_id: str,
|
||||
ok: bool = True,
|
||||
error_message: Optional[str] = None
|
||||
) -> bool:
|
||||
try:
|
||||
await self.bot.answer_pre_checkout_query(
|
||||
pre_checkout_query_id=pre_checkout_query_id,
|
||||
ok=ok,
|
||||
error_message=error_message
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка ответа на pre_checkout_query: {e}")
|
||||
return False
|
||||
|
||||
def calculate_stars_amount(self, rubles: float) -> int:
|
||||
return max(1, int(rubles))
|
||||
|
||||
def calculate_rubles_from_stars(self, stars: int) -> float:
|
||||
return float(stars)
|
||||
108
app/external/tribute.py
vendored
Normal file
108
app/external/tribute.py
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TributeService:
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.TRIBUTE_API_KEY
|
||||
self.webhook_secret = settings.TRIBUTE_WEBHOOK_SECRET
|
||||
self.donate_link = settings.TRIBUTE_DONATE_LINK
|
||||
|
||||
async def create_payment_link(
|
||||
self,
|
||||
user_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> Optional[str]:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
logger.warning("Tribute платежи отключены")
|
||||
return None
|
||||
|
||||
try:
|
||||
|
||||
payment_url = f"{self.donate_link}&user_id={user_id}"
|
||||
|
||||
logger.info(f"Создана ссылка Tribute для пользователя {user_id}")
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute ссылки: {e}")
|
||||
return None
|
||||
|
||||
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("Webhook secret не настроен")
|
||||
return True
|
||||
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
self.webhook_secret.encode(),
|
||||
payload.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_webhook(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
|
||||
payment_id = payload.get("id") or payload.get("payment_id")
|
||||
status = payload.get("status")
|
||||
amount_rubles = payload.get("amount", 0)
|
||||
telegram_user_id = payload.get("telegram_user_id") or payload.get("user_id")
|
||||
|
||||
if not payment_id and "payload" in payload:
|
||||
data = payload["payload"]
|
||||
payment_id = data.get("id") or data.get("payment_id")
|
||||
status = data.get("status")
|
||||
amount_rubles = data.get("amount", 0)
|
||||
telegram_user_id = data.get("telegram_user_id") or data.get("user_id")
|
||||
|
||||
if not payment_id and "name" in payload:
|
||||
event_name = payload.get("name")
|
||||
data = payload.get("payload", {})
|
||||
payment_id = str(data.get("donation_request_id"))
|
||||
amount_kopeks = data.get("amount", 0)
|
||||
telegram_user_id = data.get("telegram_user_id")
|
||||
|
||||
if event_name == "new_donation":
|
||||
status = "paid"
|
||||
elif event_name == "cancelled_subscription":
|
||||
status = "cancelled"
|
||||
else:
|
||||
status = "unknown"
|
||||
|
||||
logger.info(f"Обработка Tribute webhook: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}")
|
||||
|
||||
if not telegram_user_id:
|
||||
logger.error("Не найден telegram_user_id в webhook данных")
|
||||
return None
|
||||
|
||||
return {
|
||||
"event_type": "payment",
|
||||
"payment_id": payment_id or f"tribute_{telegram_user_id}_{amount_kopeks}",
|
||||
"user_id": int(telegram_user_id),
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"status": status or "paid",
|
||||
"external_id": f"donation_{payment_id}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Tribute webhook: {e}")
|
||||
logger.error(f"Webhook payload: {json.dumps(payload, ensure_ascii=False)}")
|
||||
return None
|
||||
98
app/external/webhook_server.py
vendored
Normal file
98
app/external/webhook_server.py
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookServer:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.app = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
self.tribute_service = TributeService(bot)
|
||||
|
||||
async def create_app(self) -> web.Application:
|
||||
|
||||
self.app = web.Application()
|
||||
|
||||
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: /health")
|
||||
|
||||
return self.app
|
||||
|
||||
async def start(self):
|
||||
|
||||
try:
|
||||
if not self.app:
|
||||
await self.create_app()
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
host='0.0.0.0',
|
||||
port=settings.TRIBUTE_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"✅ Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 Tribute webhook URL: http://your-server:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска webhook сервера: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
|
||||
try:
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
logger.info("Webhook сайт остановлен")
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Webhook runner очищен")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки webhook сервера: {e}")
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
raw_body = await request.read()
|
||||
payload = raw_body.decode('utf-8')
|
||||
|
||||
signature = request.headers.get('X-Tribute-Signature')
|
||||
|
||||
result = await self.tribute_service.process_webhook(payload, signature)
|
||||
|
||||
return web.json_response(result, status=200)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Tribute webhook: {e}")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error"},
|
||||
status=500
|
||||
)
|
||||
|
||||
async def _health_check(self, request: web.Request) -> web.Response:
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "tribute-webhooks",
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED
|
||||
})
|
||||
0
app/handlers/__init__.py
Normal file
0
app/handlers/__init__.py
Normal file
1
app/handlers/admin/__init__.py
Normal file
1
app/handlers/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Инициализация админских обработчиков
|
||||
37
app/handlers/admin/main.py
Normal file
37
app/handlers/admin/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_admin_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
admin_text = texts.ADMIN_PANEL
|
||||
|
||||
await callback.message.edit_text(
|
||||
admin_text,
|
||||
reply_markup=get_admin_main_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
551
app/handlers/admin/messages.py
Normal file
551
app/handlers/admin/messages.py
Normal file
@@ -0,0 +1,551 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
from app.database.crud.subscription import get_expiring_subscriptions
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📨 <b>Управление рассылками</b>
|
||||
|
||||
Выберите тип рассылки:
|
||||
|
||||
- <b>Всем пользователям</b> - рассылка всем активным пользователям
|
||||
- <b>По подпискам</b> - фильтрация по типу подписки
|
||||
- <b>По критериям</b> - настраиваемые фильтры
|
||||
- <b>История</b> - просмотр предыдущих рассылок
|
||||
|
||||
⚠️ Будьте осторожны с массовыми рассылками!
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_broadcast_targets(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🎯 <b>Выбор целевой аудитории</b>\n\n"
|
||||
"Выберите категорию пользователей для рассылки:",
|
||||
reply_markup=get_broadcast_target_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 1
|
||||
if '_page_' in callback.data:
|
||||
page = int(callback.data.split('_page_')[1])
|
||||
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
stmt = select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
broadcasts = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count(BroadcastHistory.id))
|
||||
count_result = await db.execute(count_stmt)
|
||||
total_count = count_result.scalar() or 0
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
if not broadcasts:
|
||||
text = """
|
||||
📋 <b>История рассылок</b>
|
||||
|
||||
❌ История рассылок пуста.
|
||||
Отправьте первую рассылку, чтобы увидеть её здесь.
|
||||
"""
|
||||
keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]]
|
||||
else:
|
||||
text = f"📋 <b>История рассылок</b> (страница {page}/{total_pages})\n\n"
|
||||
|
||||
for broadcast in broadcasts:
|
||||
status_emoji = "✅" if broadcast.status == "completed" else "❌" if broadcast.status == "failed" else "⏳"
|
||||
success_rate = round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0
|
||||
|
||||
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
|
||||
text += f"""
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
📝 Сообщение: <i>{message_preview}</i>
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_custom_broadcast(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_users_statistics(db)
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Рассылка по критериям</b>
|
||||
|
||||
📊 <b>Доступные фильтры:</b>
|
||||
|
||||
👥 <b>По регистрации:</b>
|
||||
• Сегодня: {stats['today']} чел.
|
||||
• За неделю: {stats['week']} чел.
|
||||
• За месяц: {stats['month']} чел.
|
||||
|
||||
💼 <b>По активности:</b>
|
||||
• Активные сегодня: {stats['active_today']} чел.
|
||||
• Неактивные 7+ дней: {stats['inactive_week']} чел.
|
||||
• Неактивные 30+ дней: {stats['inactive_month']} чел.
|
||||
|
||||
🔗 <b>По источнику:</b>
|
||||
• Через рефералов: {stats['referrals']} чел.
|
||||
• Прямая регистрация: {stats['direct']} чел.
|
||||
|
||||
Выберите критерий для фильтрации:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_custom_criteria_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_custom_criteria(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
criteria = callback.data.replace('criteria_', '')
|
||||
|
||||
criteria_names = {
|
||||
"today": "Зарегистрированные сегодня",
|
||||
"week": "Зарегистрированные за неделю",
|
||||
"month": "Зарегистрированные за месяц",
|
||||
"active_today": "Активные сегодня",
|
||||
"inactive_week": "Неактивные 7+ дней",
|
||||
"inactive_month": "Неактивные 30+ дней",
|
||||
"referrals": "Пришедшие через рефералов",
|
||||
"direct": "Прямая регистрация"
|
||||
}
|
||||
|
||||
user_count = await get_custom_users_count(db, criteria)
|
||||
|
||||
await state.update_data(broadcast_target=f"custom_{criteria}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📨 <b>Создание рассылки</b>\n\n"
|
||||
f"🎯 <b>Критерий:</b> {criteria_names.get(criteria, criteria)}\n"
|
||||
f"👥 <b>Получателей:</b> {user_count}\n\n"
|
||||
f"Введите текст сообщения для рассылки:\n\n"
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_broadcast_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
target = callback.data.split('_')[-1]
|
||||
|
||||
target_names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской"
|
||||
}
|
||||
|
||||
user_count = await get_target_users_count(db, target)
|
||||
|
||||
await state.update_data(broadcast_target=target)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📨 <b>Создание рассылки</b>\n\n"
|
||||
f"🎯 <b>Аудитория:</b> {target_names.get(target, target)}\n"
|
||||
f"👥 <b>Получателей:</b> {user_count}\n\n"
|
||||
f"Введите текст сообщения для рассылки:\n\n"
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_broadcast_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
broadcast_text = message.text
|
||||
|
||||
if len(broadcast_text) > 4000:
|
||||
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
|
||||
await state.update_data(broadcast_message=broadcast_text)
|
||||
|
||||
target_display = get_target_display_name(target)
|
||||
|
||||
preview_text = f"""
|
||||
📨 <b>Предварительный просмотр рассылки</b>
|
||||
|
||||
🎯 <b>Аудитория:</b> {target_display}
|
||||
👥 <b>Получателей:</b> {user_count}
|
||||
|
||||
📝 <b>Сообщение:</b>
|
||||
{broadcast_text}
|
||||
|
||||
Подтвердить отправку?
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_broadcast(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
"⏳ Это может занять несколько минут.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
if target.startswith('custom_'):
|
||||
users = await get_custom_users(db, target.replace('custom_', ''))
|
||||
else:
|
||||
users = await get_target_users(db, target)
|
||||
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
total_count=len(users),
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
admin_id=db_user.id,
|
||||
admin_name=db_user.full_name,
|
||||
status="in_progress"
|
||||
)
|
||||
db.add(broadcast_history)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast_history)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = "completed" if failed_count == 0 else "partial"
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
users = await get_target_users(db, target)
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
if target == "all":
|
||||
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
elif target == "active":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
|
||||
elif target == "trial":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_trial]
|
||||
elif target == "no":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if not user.subscription or not user.subscription.is_active]
|
||||
elif target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
users = await get_custom_users(db, criteria)
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
"""Получение пользователей по настраиваемым критериям"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
|
||||
if criteria == "today":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= today)
|
||||
)
|
||||
elif criteria == "week":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= week_ago)
|
||||
)
|
||||
elif criteria == "month":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= month_ago)
|
||||
)
|
||||
elif criteria == "active_today":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity >= today)
|
||||
)
|
||||
elif criteria == "inactive_week":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity < week_ago)
|
||||
)
|
||||
elif criteria == "inactive_month":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity < month_ago)
|
||||
)
|
||||
elif criteria == "referrals":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.referred_by_id.isnot(None))
|
||||
)
|
||||
elif criteria == "direct":
|
||||
stmt = select(User).where(
|
||||
and_(
|
||||
User.status == "active",
|
||||
User.referred_by_id.is_(None)
|
||||
)
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"""Получение статистики пользователей для отображения"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
|
||||
stats = {}
|
||||
|
||||
stats['today'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= today)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['week'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= week_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['month'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= month_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['active_today'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity >= today)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['inactive_week'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity < week_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['inactive_month'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity < month_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['referrals'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.referred_by_id.isnot(None))
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['direct'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.status == "active",
|
||||
User.referred_by_id.is_(None)
|
||||
)
|
||||
)
|
||||
) or 0
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def get_target_name(target_type: str) -> str:
|
||||
names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской",
|
||||
"custom_today": "Зарегистрированные сегодня",
|
||||
"custom_week": "Зарегистрированные за неделю",
|
||||
"custom_month": "Зарегистрированные за месяц",
|
||||
"custom_active_today": "Активные сегодня",
|
||||
"custom_inactive_week": "Неактивные 7+ дней",
|
||||
"custom_inactive_month": "Неактивные 30+ дней",
|
||||
"custom_referrals": "Через рефералов",
|
||||
"custom_direct": "Прямая регистрация"
|
||||
}
|
||||
return names.get(target_type, target_type)
|
||||
|
||||
|
||||
def get_target_display_name(target: str) -> str:
|
||||
return get_target_name(target)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_messages_menu, F.data == "admin_messages")
|
||||
dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"]))
|
||||
dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_"))
|
||||
dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast")
|
||||
|
||||
dp.callback_query.register(show_messages_history, F.data.startswith("admin_msg_history"))
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
313
app/handlers/admin/monitoring.py
Normal file
313
app/handlers/admin/monitoring.py
Normal file
@@ -0,0 +1,313 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
"""Главное меню мониторинга"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
|
||||
last_update = status['last_update'].strftime('%H:%M:%S') if status['last_update'] else "Никогда"
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Система мониторинга</b>
|
||||
|
||||
📊 <b>Статус:</b> {running_status}
|
||||
🕐 <b>Последнее обновление:</b> {last_update}
|
||||
⚙️ <b>Интервал проверки:</b> {settings.MONITORING_INTERVAL} мин
|
||||
|
||||
📈 <b>Статистика за 24 часа:</b>
|
||||
• Всего событий: {status['stats_24h']['total_events']}
|
||||
• Успешных: {status['stats_24h']['successful']}
|
||||
• Ошибок: {status['stats_24h']['failed']}
|
||||
• Успешность: {status['stats_24h']['success_rate']}%
|
||||
|
||||
🔧 Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = get_monitoring_keyboard()
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в админ меню мониторинга: {e}")
|
||||
await callback.answer("❌ Ошибка получения данных", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_start")
|
||||
@admin_required
|
||||
async def start_monitoring_callback(callback: CallbackQuery):
|
||||
try:
|
||||
if monitoring_service.is_running:
|
||||
await callback.answer("ℹ️ Мониторинг уже запущен")
|
||||
return
|
||||
|
||||
if not monitoring_service.bot:
|
||||
monitoring_service.bot = callback.bot
|
||||
|
||||
asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
await callback.answer("✅ Мониторинг запущен!")
|
||||
|
||||
await admin_monitoring_menu(callback)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга: {e}")
|
||||
await callback.answer(f"❌ Ошибка запуска: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_stop")
|
||||
@admin_required
|
||||
async def stop_monitoring_callback(callback: CallbackQuery):
|
||||
try:
|
||||
if not monitoring_service.is_running:
|
||||
await callback.answer("ℹ️ Мониторинг уже остановлен")
|
||||
return
|
||||
|
||||
monitoring_service.stop_monitoring()
|
||||
await callback.answer("⏹️ Мониторинг остановлен!")
|
||||
|
||||
await admin_monitoring_menu(callback)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
await callback.answer(f"❌ Ошибка остановки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_force_check")
|
||||
@admin_required
|
||||
async def force_check_callback(callback: CallbackQuery):
|
||||
try:
|
||||
await callback.answer("⏳ Выполняем проверку подписок...")
|
||||
|
||||
async for db in get_db():
|
||||
results = await monitoring_service.force_check_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
✅ <b>Принудительная проверка завершена</b>
|
||||
|
||||
📊 <b>Результаты проверки:</b>
|
||||
• Истекших подписок: {results['expired']}
|
||||
• Истекающих подписок: {results['expiring']}
|
||||
• Готовых к автооплате: {results['autopay_ready']}
|
||||
|
||||
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
|
||||
|
||||
Нажмите "Назад" для возврата в меню мониторинга.
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной проверки: {e}")
|
||||
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_logs")
|
||||
@admin_required
|
||||
async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
|
||||
|
||||
if not logs:
|
||||
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
|
||||
else:
|
||||
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
|
||||
|
||||
for log in logs:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
|
||||
message = log['message']
|
||||
if len(message) > 60:
|
||||
message = message[:60] + "..."
|
||||
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
if len(text) > 3500:
|
||||
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
|
||||
break
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_clear_logs")
|
||||
@admin_required
|
||||
async def clear_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
|
||||
|
||||
if deleted_count > 0:
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
|
||||
else:
|
||||
await callback.answer("ℹ️ Нет старых логов для удаления")
|
||||
|
||||
await monitoring_logs_callback(callback)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_test_notifications")
|
||||
@admin_required
|
||||
async def test_notifications_callback(callback: CallbackQuery):
|
||||
"""Тест системы уведомлений"""
|
||||
try:
|
||||
test_message = f"""
|
||||
🧪 <b>Тестовое уведомление системы мониторинга</b>
|
||||
|
||||
Это тестовое сообщение для проверки работы системы уведомлений.
|
||||
|
||||
📊 <b>Статус системы:</b>
|
||||
• Мониторинг: {'🟢 Работает' if monitoring_service.is_running else '🔴 Остановлен'}
|
||||
• Уведомления: {'🟢 Включены' if settings.ENABLE_NOTIFICATIONS else '🔴 Отключены'}
|
||||
• Время теста: {datetime.now().strftime('%H:%M:%S %d.%m.%Y')}
|
||||
|
||||
✅ Если вы получили это сообщение, система уведомлений работает корректно!
|
||||
"""
|
||||
|
||||
await callback.bot.send_message(
|
||||
callback.from_user.id,
|
||||
test_message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Тестовое уведомление отправлено!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки тестового уведомления: {e}")
|
||||
await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_statistics")
|
||||
@admin_required
|
||||
async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
mon_status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
week_ago = datetime.now() - timedelta(days=7)
|
||||
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
|
||||
|
||||
week_success = sum(1 for log in week_logs if log['is_success'])
|
||||
week_errors = len(week_logs) - week_success
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика мониторинга</b>
|
||||
|
||||
📱 <b>Подписки:</b>
|
||||
• Всего: {sub_stats['total_subscriptions']}
|
||||
• Активных: {sub_stats['active_subscriptions']}
|
||||
• Тестовых: {sub_stats['trial_subscriptions']}
|
||||
• Платных: {sub_stats['paid_subscriptions']}
|
||||
|
||||
📈 <b>За сегодня:</b>
|
||||
• Успешных операций: {mon_status['stats_24h']['successful']}
|
||||
• Ошибок: {mon_status['stats_24h']['failed']}
|
||||
• Успешность: {mon_status['stats_24h']['success_rate']}%
|
||||
|
||||
📊 <b>За неделю:</b>
|
||||
• Всего событий: {len(week_logs)}
|
||||
• Успешных: {week_success}
|
||||
• Ошибок: {week_errors}
|
||||
• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
|
||||
|
||||
🔧 <b>Система:</b>
|
||||
• Интервал: {settings.MONITORING_INTERVAL} мин
|
||||
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
|
||||
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.message(Command("monitoring"))
|
||||
@admin_required
|
||||
async def monitoring_command(message: Message):
|
||||
"""Команда /monitoring для быстрого доступа"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Быстрый статус мониторинга</b>
|
||||
|
||||
📊 <b>Статус:</b> {running_status}
|
||||
📈 <b>События за 24ч:</b> {status['stats_24h']['total_events']}
|
||||
✅ <b>Успешность:</b> {status['stats_24h']['success_rate']}%
|
||||
|
||||
Для подробного управления используйте админ-панель.
|
||||
"""
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка команды /monitoring: {e}")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Регистрация обработчиков мониторинга"""
|
||||
dp.include_router(router)
|
||||
562
app/handlers/admin/promocodes.py
Normal file
562
app/handlers/admin/promocodes.py
Normal file
@@ -0,0 +1,562 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, PromoCodeType
|
||||
from app.keyboards.admin import (
|
||||
get_admin_promocodes_keyboard, get_promocode_type_keyboard,
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.promocode import (
|
||||
get_promocodes_list, get_promocodes_count, create_promocode,
|
||||
get_promocode_statistics, get_promocode_by_code, update_promocode,
|
||||
delete_promocode
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocodes_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
total_codes = await get_promocodes_count(db)
|
||||
active_codes = await get_promocodes_count(db, is_active=True)
|
||||
|
||||
text = f"""
|
||||
🎫 <b>Управление промокодами</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
- Всего промокодов: {total_codes}
|
||||
- Активных: {active_codes}
|
||||
- Неактивных: {total_codes - active_codes}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_promocodes_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocodes_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
promocodes = await get_promocodes_list(db, offset=offset, limit=limit)
|
||||
total_count = await get_promocodes_count(db)
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
if not promocodes:
|
||||
await callback.message.edit_text(
|
||||
"🎫 Промокоды не найдены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"🎫 <b>Список промокодов</b> (стр. {page}/{total_pages})\n\n"
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
|
||||
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 Дней: {promo.subscription_days}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
text += f"🔧 Управление: /promo_{promo.id}\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
page, total_pages, "admin_promo_list", "admin_promocodes", db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
text = f"""
|
||||
🎫 <b>Управление промокодом</b>
|
||||
|
||||
{type_emoji} <b>Код:</b> <code>{promo.code}</code>
|
||||
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
|
||||
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
|
||||
"""
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
text += f"📅 <b>Создан:</b> {format_datetime(promo.created_at)}\n"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"promo_edit_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Переключить статус",
|
||||
callback_data=f"promo_toggle_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=f"promo_stats_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=f"promo_delete_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_promocode_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not promo.is_active
|
||||
await update_promocode(db, promo, is_active=new_status)
|
||||
|
||||
status_text = "активирован" if new_status else "деактивирован"
|
||||
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
|
||||
|
||||
await show_promocode_management(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_delete_promocode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"""
|
||||
⚠️ <b>Подтверждение удаления</b>
|
||||
|
||||
Вы действительно хотите удалить промокод <code>{promo.code}</code>?
|
||||
|
||||
<b>Внимание:</b> Это действие нельзя отменить!
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=f"promo_delete_confirm_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_promocode_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
code = promo.code
|
||||
success = await delete_promocode(db, promo)
|
||||
|
||||
if success:
|
||||
await callback.answer(f"✅ Промокод {code} удален", show_alert=True)
|
||||
await show_promocodes_list(callback, db_user, db)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка удаления промокода", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
stats = await get_promocode_statistics(db, promo_id)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика промокода</b> <code>{promo.code}</code>
|
||||
|
||||
📈 <b>Общая статистика:</b>
|
||||
- Всего использований: {stats['total_uses']}
|
||||
- Использований сегодня: {stats['today_uses']}
|
||||
- Осталось использований: {promo.max_uses - promo.current_uses}
|
||||
|
||||
📅 <b>Последние использования:</b>
|
||||
"""
|
||||
|
||||
for use in stats['recent_uses'][:5]:
|
||||
use_date = format_datetime(use.used_at)
|
||||
text += f"- {use_date} (ID: {use.user_id})\n"
|
||||
|
||||
if not stats['recent_uses']:
|
||||
text += "- Пока не было использований\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_promocode_creation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🎫 <b>Создание промокода</b>\n\n"
|
||||
"Выберите тип промокода:",
|
||||
reply_markup=get_promocode_type_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_promocode_type(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
promo_type = callback.data.split('_')[-1]
|
||||
|
||||
type_names = {
|
||||
"balance": "💰 Пополнение баланса",
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка"
|
||||
}
|
||||
|
||||
await state.update_data(promocode_type=promo_type)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🎫 <b>Создание промокода</b>\n\n"
|
||||
f"Тип: {type_names.get(promo_type, promo_type)}\n\n"
|
||||
f"Введите код промокода (только латинские буквы и цифры):",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.creating_promocode)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_code(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
code = message.text.strip().upper()
|
||||
|
||||
if not code.isalnum() or len(code) < 3 or len(code) > 20:
|
||||
await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)")
|
||||
return
|
||||
|
||||
from app.database.crud.promocode import get_promocode_by_code
|
||||
existing = await get_promocode_by_code(db, code)
|
||||
if existing:
|
||||
await message.answer("❌ Промокод с таким кодом уже существует")
|
||||
return
|
||||
|
||||
await state.update_data(promocode_code=code)
|
||||
|
||||
data = await state.get_data()
|
||||
promo_type = data.get('promocode_type')
|
||||
|
||||
if promo_type == "balance":
|
||||
await message.answer(
|
||||
f"💰 <b>Промокод:</b> <code>{code}</code>\n\n"
|
||||
f"Введите сумму пополнения баланса (в рублях):"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
elif promo_type == "days":
|
||||
await message.answer(
|
||||
f"📅 <b>Промокод:</b> <code>{code}</code>\n\n"
|
||||
f"Введите количество дней подписки:"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
elif promo_type == "trial":
|
||||
await message.answer(
|
||||
f"🎁 <b>Промокод:</b> <code>{code}</code>\n\n"
|
||||
f"Введите количество дней тестовой подписки:"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_value(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
value = int(message.text.strip())
|
||||
|
||||
data = await state.get_data()
|
||||
promo_type = data.get('promocode_type')
|
||||
|
||||
if promo_type == "balance" and (value < 1 or value > 10000):
|
||||
await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей")
|
||||
return
|
||||
elif promo_type in ["days", "trial"] and (value < 1 or value > 3650):
|
||||
await message.answer("❌ Количество дней должно быть от 1 до 3650")
|
||||
return
|
||||
|
||||
await state.update_data(promocode_value=value)
|
||||
|
||||
await message.answer(
|
||||
f"📊 Введите количество использований промокода (или 0 для безлимита):"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_uses)
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_uses(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
max_uses = int(message.text.strip())
|
||||
|
||||
if max_uses < 0 or max_uses > 100000:
|
||||
await message.answer("❌ Количество использований должно быть от 0 до 100,000")
|
||||
return
|
||||
|
||||
if max_uses == 0:
|
||||
max_uses = 999999
|
||||
|
||||
await state.update_data(promocode_max_uses=max_uses)
|
||||
|
||||
await message.answer(
|
||||
f"⏰ Введите срок действия промокода в днях (или 0 для бессрочного):"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_expiry)
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_expiry(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
expiry_days = int(message.text.strip())
|
||||
|
||||
if expiry_days < 0 or expiry_days > 3650:
|
||||
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
code = data.get('promocode_code')
|
||||
promo_type = data.get('promocode_type')
|
||||
value = data.get('promocode_value', 0)
|
||||
max_uses = data.get('promocode_max_uses', 1)
|
||||
|
||||
valid_until = None
|
||||
if expiry_days > 0:
|
||||
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
type_map = {
|
||||
"balance": PromoCodeType.BALANCE,
|
||||
"days": PromoCodeType.SUBSCRIPTION_DAYS,
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
|
||||
}
|
||||
|
||||
promocode = await create_promocode(
|
||||
db=db,
|
||||
code=code,
|
||||
type=type_map[promo_type],
|
||||
balance_bonus_kopeks=value * 100 if promo_type == "balance" else 0,
|
||||
subscription_days=value if promo_type in ["days", "trial"] else 0,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=db_user.id
|
||||
)
|
||||
|
||||
type_names = {
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка"
|
||||
}
|
||||
|
||||
summary_text = f"""
|
||||
✅ <b>Промокод создан!</b>
|
||||
|
||||
🎫 <b>Код:</b> <code>{promocode.code}</code>
|
||||
📝 <b>Тип:</b> {type_names.get(promo_type)}
|
||||
"""
|
||||
|
||||
if promo_type == "balance":
|
||||
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
|
||||
elif promo_type in ["days", "trial"]:
|
||||
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
|
||||
|
||||
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
|
||||
|
||||
if promocode.valid_until:
|
||||
summary_text += f"⏰ <b>Действует до:</b> {format_datetime(promocode.valid_until)}\n"
|
||||
|
||||
await message.answer(
|
||||
summary_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Создан промокод {code} администратором {db_user.telegram_id}")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число дней")
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes")
|
||||
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
|
||||
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
|
||||
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_"))
|
||||
dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_"))
|
||||
dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_"))
|
||||
|
||||
dp.message.register(process_promocode_code, AdminStates.creating_promocode)
|
||||
dp.message.register(process_promocode_value, AdminStates.setting_promocode_value)
|
||||
dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses)
|
||||
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
|
||||
72
app/handlers/admin/referrals.py
Normal file
72
app/handlers/admin/referrals.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_referral_statistics(db)
|
||||
|
||||
avg_per_referrer = 0
|
||||
if stats['active_referrers'] > 0:
|
||||
avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers']
|
||||
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Пользователей с рефералами: {stats['users_with_referrals']}
|
||||
- Активных рефереров: {stats['active_referrers']}
|
||||
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
|
||||
|
||||
<b>За период:</b>
|
||||
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
|
||||
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
|
||||
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
|
||||
|
||||
<b>Средние показатели:</b>
|
||||
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
|
||||
|
||||
<b>Топ-5 рефереров:</b>
|
||||
"""
|
||||
|
||||
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
|
||||
text += f"{i}. ID {referrer['user_id']}: {settings.format_price(referrer['total_earned_kopeks'])} ({referrer['referrals_count']} реф.)\n"
|
||||
|
||||
if not stats['top_referrers']:
|
||||
text += "Нет данных\n"
|
||||
|
||||
text += f"""
|
||||
|
||||
<b>Настройки:</b>
|
||||
- Бонус за регистрацию: {settings.format_price(settings.REFERRAL_REGISTRATION_REWARD)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Комиссия: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
|
||||
1477
app/handlers/admin/remnawave.py
Normal file
1477
app/handlers/admin/remnawave.py
Normal file
File diff suppressed because it is too large
Load Diff
168
app/handlers/admin/rules.py
Normal file
168
app/handlers/admin/rules.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_rules_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📋 <b>Управление правилами сервиса</b>
|
||||
|
||||
Текущие правила показываются пользователям при регистрации и в главном меню.
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_current_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_rules_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_rules = message.text
|
||||
|
||||
if len(new_rules) > 4000:
|
||||
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
new_rules = data.get('new_rules')
|
||||
|
||||
if not new_rules:
|
||||
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await create_or_update_rules(
|
||||
db=db,
|
||||
content=new_rules,
|
||||
language=db_user.language
|
||||
)
|
||||
|
||||
from app.localization.texts import clear_rules_cache
|
||||
clear_rules_cache()
|
||||
|
||||
from app.localization.texts import refresh_rules_cache
|
||||
await refresh_rules_cache(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила сервиса обновлены!</b>\n\n"
|
||||
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
|
||||
"Кеш правил очищен и обновлен.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Правила сервиса обновлены администратором {db_user.telegram_id}")
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения правил: {e}")
|
||||
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_rules_management, F.data == "admin_rules")
|
||||
dp.callback_query.register(view_current_rules, F.data == "admin_view_rules")
|
||||
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
|
||||
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
|
||||
|
||||
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
|
||||
962
app/handlers/admin/servers.py
Normal file
962
app/handlers/admin/servers.py
Normal file
@@ -0,0 +1,962 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads, get_server_squad_by_id, update_server_squad,
|
||||
delete_server_squad, sync_with_remnawave, get_server_statistics,
|
||||
create_server_squad, get_available_server_squads
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_server_statistics(db)
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Управление серверами</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
• Всего серверов: {stats['total_servers']}
|
||||
• Доступные: {stats['available_servers']}
|
||||
• Недоступные: {stats['unavailable_servers']}
|
||||
• С подключениями: {stats['servers_with_connections']}
|
||||
|
||||
💰 <b>Выручка от серверов:</b>
|
||||
• Общая: {stats['total_revenue_rubles']:.2f} ₽
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Синхронизировать счетчики", callback_data="admin_servers_sync_counts"),
|
||||
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
servers, total_count = await get_all_server_squads(db, page=page, limit=10)
|
||||
total_pages = (total_count + 9) // 10
|
||||
|
||||
if not servers:
|
||||
text = "🌐 <b>Список серверов</b>\n\n❌ Серверы не найдены."
|
||||
else:
|
||||
text = f"🌐 <b>Список серверов</b>\n\n"
|
||||
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
|
||||
|
||||
for i, server in enumerate(servers, 1 + (page - 1) * 10):
|
||||
status_emoji = "✅" if server.is_available else "❌"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text += f"{i}. {status_emoji} {server.display_name}\n"
|
||||
text += f" 💰 Цена: {price_text}"
|
||||
|
||||
if server.max_users:
|
||||
text += f" | 👥 {server.current_users}/{server.max_users}"
|
||||
|
||||
text += f"\n UUID: <code>{server.squad_uuid}</code>\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for i, server in enumerate(servers):
|
||||
row_num = i // 2
|
||||
if len(keyboard) <= row_num:
|
||||
keyboard.append([])
|
||||
|
||||
status_emoji = "✅" if server.is_available else "❌"
|
||||
keyboard[row_num].append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{status_emoji} {server.display_name[:15]}...",
|
||||
callback_data=f"admin_server_edit_{server.id}"
|
||||
)
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="⬅️", callback_data=f"admin_servers_list_page_{page-1}"
|
||||
))
|
||||
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}", callback_data="current_page"
|
||||
))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="➡️", callback_data=f"admin_servers_list_page_{page+1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="➕ Добавить", callback_data="admin_servers_add")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_servers_with_remnawave(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
squads = await remnawave_service.get_all_squads()
|
||||
|
||||
if not squads:
|
||||
await callback.message.edit_text(
|
||||
"❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
|
||||
📊 <b>Результаты:</b>
|
||||
• Создано новых серверов: {created}
|
||||
• Обновлено существующих: {updated}
|
||||
• Отключено неактивных: {disabled}
|
||||
• Всего обработано: {len(squads)}
|
||||
|
||||
ℹ️ Новые серверы созданы как недоступные.
|
||||
Настройте их в списке серверов.
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации серверов: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ Ошибка синхронизации: {str(e)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_edit_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_availability(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not server.is_available
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
status_text = "включен" if new_status else "отключен"
|
||||
await callback.answer(f"✅ Сервер {status_text}!")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_price(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_price)
|
||||
|
||||
current_price = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💰 <b>Редактирование цены</b>\n\n"
|
||||
f"Текущая цена: <b>{current_price}</b>\n\n"
|
||||
f"Отправьте новую цену в рублях (например: 15.50) или 0 для бесплатного доступа:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_price_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
try:
|
||||
price_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if price_rubles < 0:
|
||||
await message.answer("❌ Цена не может быть отрицательной")
|
||||
return
|
||||
|
||||
if price_rubles > 10000:
|
||||
await message.answer("❌ Слишком высокая цена (максимум 10,000 ₽)")
|
||||
return
|
||||
|
||||
price_kopeks = int(price_rubles * 100)
|
||||
|
||||
server = await update_server_squad(db, server_id, price_kopeks=price_kopeks)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
price_text = f"{price_rubles:.2f} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
await message.answer(
|
||||
f"✅ Цена сервера изменена на: <b>{price_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат цены. Используйте числа (например: 15.50)")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_name(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_name)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование названия</b>\n\n"
|
||||
f"Текущее название: <b>{server.display_name}</b>\n\n"
|
||||
f"Отправьте новое название для сервера:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_name_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_name = message.text.strip()
|
||||
|
||||
if len(new_name) > 255:
|
||||
await message.answer("❌ Название слишком длинное (максимум 255 символов)")
|
||||
return
|
||||
|
||||
if len(new_name) < 3:
|
||||
await message.answer("❌ Название слишком короткое (минимум 3 символа)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, display_name=new_name)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Название сервера изменено на: <b>{new_name}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_server_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"""
|
||||
🗑️ <b>Удаление сервера</b>
|
||||
|
||||
Вы действительно хотите удалить сервер:
|
||||
<b>{server.display_name}</b>
|
||||
|
||||
⚠️ <b>Внимание!</b>
|
||||
Сервер можно удалить только если к нему нет активных подключений.
|
||||
|
||||
Это действие нельзя отменить!
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Да, удалить", callback_data=f"admin_server_delete_confirm_{server_id}"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_server_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
success = await delete_server_squad(db, server_id)
|
||||
|
||||
if success:
|
||||
await cache.delete("available_countries")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📋 К списку серверов", callback_data="admin_servers_list")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\n"
|
||||
f"Возможно, к нему есть активные подключения.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_detailed_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_server_statistics(db)
|
||||
available_servers = await get_available_server_squads(db)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Подробная статистика серверов</b>
|
||||
|
||||
<b>🌐 Общая информация:</b>
|
||||
• Всего серверов: {stats['total_servers']}
|
||||
• Доступные: {stats['available_servers']}
|
||||
• Недоступные: {stats['unavailable_servers']}
|
||||
• С активными подключениями: {stats['servers_with_connections']}
|
||||
|
||||
<b>💰 Финансовая статистика:</b>
|
||||
• Общая выручка: {stats['total_revenue_rubles']:.2f} ₽
|
||||
• Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f} ₽
|
||||
|
||||
<b>🔥 Топ серверов по цене:</b>
|
||||
"""
|
||||
|
||||
sorted_servers = sorted(available_servers, key=lambda x: x.price_kopeks, reverse=True)
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
text += f"{i}. {server.display_name} - {price_text}\n"
|
||||
|
||||
if not sorted_servers:
|
||||
text += "Нет доступных серверов\n"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_stats"),
|
||||
types.InlineKeyboardButton(text="📋 Список", callback_data="admin_servers_list")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_country(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_country)
|
||||
|
||||
current_country = server.country_code or "Не указан"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🌍 <b>Редактирование кода страны</b>\n\n"
|
||||
f"Текущий код страны: <b>{current_country}</b>\n\n"
|
||||
f"Отправьте новый код страны (например: RU, US, DE) или '-' для удаления:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_country_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_country = message.text.strip().upper()
|
||||
|
||||
if new_country == "-":
|
||||
new_country = None
|
||||
elif len(new_country) > 5:
|
||||
await message.answer("❌ Код страны слишком длинный (максимум 5 символов)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, country_code=new_country)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
country_text = new_country or "Удален"
|
||||
await message.answer(
|
||||
f"✅ Код страны изменен на: <b>{country_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_limit(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_limit)
|
||||
|
||||
current_limit = server.max_users or "Без лимита"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👥 <b>Редактирование лимита пользователей</b>\n\n"
|
||||
f"Текущий лимит: <b>{current_limit}</b>\n\n"
|
||||
f"Отправьте новый лимит пользователей (число) или 0 для безлимитного доступа:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_limit_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
try:
|
||||
limit = int(message.text.strip())
|
||||
|
||||
if limit < 0:
|
||||
await message.answer("❌ Лимит не может быть отрицательным")
|
||||
return
|
||||
|
||||
if limit > 10000:
|
||||
await message.answer("❌ Слишком большой лимит (максимум 10,000)")
|
||||
return
|
||||
|
||||
max_users = limit if limit > 0 else None
|
||||
|
||||
server = await update_server_squad(db, server_id, max_users=max_users)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
limit_text = f"{limit} пользователей" if limit > 0 else "Без лимита"
|
||||
await message.answer(
|
||||
f"✅ Лимит пользователей изменен на: <b>{limit_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат числа. Введите целое число.")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_description(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_description)
|
||||
|
||||
current_desc = server.description or "Не указано"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Редактирование описания</b>\n\n"
|
||||
f"Текущее описание:\n<i>{current_desc}</i>\n\n"
|
||||
f"Отправьте новое описание сервера или '-' для удаления:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_description_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_description = message.text.strip()
|
||||
|
||||
if new_description == "-":
|
||||
new_description = None
|
||||
elif len(new_description) > 1000:
|
||||
await message.answer("❌ Описание слишком длинное (максимум 1000 символов)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, description=new_description)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
desc_text = new_description or "Удалено"
|
||||
await message.answer(
|
||||
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_server_user_counts_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 Синхронизация счетчиков пользователей...",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import sync_server_user_counts
|
||||
|
||||
updated_count = await sync_server_user_counts(db)
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Обновлено серверов: {updated_count}
|
||||
|
||||
Счетчики пользователей синхронизированы с реальными данными.
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync_counts")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ Ошибка синхронизации: {str(e)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_servers_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_servers_list(callback, db_user, db, page)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_servers_menu, F.data == "admin_servers")
|
||||
dp.callback_query.register(show_servers_list, F.data == "admin_servers_list")
|
||||
dp.callback_query.register(sync_servers_with_remnawave, F.data == "admin_servers_sync")
|
||||
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
|
||||
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
|
||||
|
||||
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
|
||||
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
|
||||
|
||||
dp.message.register(process_server_name_edit, AdminStates.editing_server_name)
|
||||
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
|
||||
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
|
||||
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
|
||||
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
|
||||
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
|
||||
|
||||
dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_"))
|
||||
350
app/handlers/admin/statistics.py
Normal file
350
app/handlers/admin/statistics.py
Normal file
@@ -0,0 +1,350 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_statistics_keyboard, get_period_selection_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
|
||||
from app.database.crud.referral import get_referral_statistics
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_percentage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_statistics_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📊 <b>Статистика системы</b>
|
||||
|
||||
Выберите раздел для просмотра статистики:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_statistics_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_service = UserService()
|
||||
stats = await user_service.get_user_statistics(db)
|
||||
|
||||
total_users = stats['total_users']
|
||||
active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0)
|
||||
|
||||
text = f"""
|
||||
👥 <b>Статистика пользователей</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Всего зарегистрировано: {stats['total_users']}
|
||||
- Активных: {stats['active_users']} ({active_rate})
|
||||
- Заблокированных: {stats['blocked_users']}
|
||||
|
||||
<b>Новые регистрации:</b>
|
||||
- Сегодня: {stats['new_today']}
|
||||
- За неделю: {stats['new_week']}
|
||||
- За месяц: {stats['new_month']}
|
||||
|
||||
<b>Активность:</b>
|
||||
- Коэффициент активности: {active_rate}
|
||||
- Рост за месяц: +{stats['new_month']} ({format_percentage(stats['new_month'] / total_users * 100 if total_users > 0 else 0)})
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
total_subs = stats['total_subscriptions']
|
||||
conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0)
|
||||
|
||||
text = f"""
|
||||
📱 <b>Статистика подписок</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Всего подписок: {stats['total_subscriptions']}
|
||||
- Активных: {stats['active_subscriptions']}
|
||||
- Платных: {stats['paid_subscriptions']}
|
||||
- Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
<b>Конверсия:</b>
|
||||
- Из триала в платную: {conversion_rate}
|
||||
- Активных платных: {stats['paid_subscriptions']}
|
||||
|
||||
<b>Продажи:</b>
|
||||
- Сегодня: {stats['purchased_today']}
|
||||
- За неделю: {stats['purchased_week']}
|
||||
- За месяц: {stats['purchased_month']}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_revenue_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
month_stats = await get_transactions_statistics(db, month_start, now)
|
||||
all_time_stats = await get_transactions_statistics(db)
|
||||
|
||||
text = f"""
|
||||
💰 <b>Статистика доходов</b>
|
||||
|
||||
<b>За текущий месяц:</b>
|
||||
- Доходы: {settings.format_price(month_stats['totals']['income_kopeks'])}
|
||||
- Расходы: {settings.format_price(month_stats['totals']['expenses_kopeks'])}
|
||||
- Прибыль: {settings.format_price(month_stats['totals']['profit_kopeks'])}
|
||||
- От подписок: {settings.format_price(month_stats['totals']['subscription_income_kopeks'])}
|
||||
|
||||
<b>Сегодня:</b>
|
||||
- Транзакций: {month_stats['today']['transactions_count']}
|
||||
- Доходы: {settings.format_price(month_stats['today']['income_kopeks'])}
|
||||
|
||||
<b>За все время:</b>
|
||||
- Общий доход: {settings.format_price(all_time_stats['totals']['income_kopeks'])}
|
||||
- Общая прибыль: {settings.format_price(all_time_stats['totals']['profit_kopeks'])}
|
||||
|
||||
<b>Способы оплаты:</b>
|
||||
"""
|
||||
|
||||
for method, data in month_stats['by_payment_method'].items():
|
||||
if method and data['count'] > 0:
|
||||
text += f"• {method}: {data['count']} ({settings.format_price(data['amount'])})\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_referral_statistics(db)
|
||||
|
||||
avg_per_referrer = 0
|
||||
if stats['active_referrers'] > 0:
|
||||
avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers']
|
||||
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Пользователей с рефералами: {stats['users_with_referrals']}
|
||||
- Активных рефереров: {stats['active_referrers']}
|
||||
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
|
||||
|
||||
<b>За период:</b>
|
||||
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
|
||||
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
|
||||
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
|
||||
|
||||
<b>Средние показатели:</b>
|
||||
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
|
||||
|
||||
<b>Топ рефереры:</b>
|
||||
"""
|
||||
|
||||
if stats['top_referrers']:
|
||||
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
|
||||
name = referrer['display_name']
|
||||
earned = settings.format_price(referrer['total_earned_kopeks'])
|
||||
count = referrer['referrals_count']
|
||||
text += f"{i}. {name}: {earned} ({count} реф.)\n"
|
||||
else:
|
||||
text += "Пока нет активных рефереров"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_summary_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_service = UserService()
|
||||
user_stats = await user_service.get_user_statistics(db)
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
revenue_stats = await get_transactions_statistics(db, month_start, now)
|
||||
|
||||
conversion_rate = 0
|
||||
if user_stats['total_users'] > 0:
|
||||
conversion_rate = sub_stats['paid_subscriptions'] / user_stats['total_users'] * 100
|
||||
|
||||
arpu = 0
|
||||
if user_stats['active_users'] > 0:
|
||||
arpu = revenue_stats['totals']['income_kopeks'] / user_stats['active_users']
|
||||
|
||||
text = f"""
|
||||
📊 <b>Общая сводка системы</b>
|
||||
|
||||
<b>Пользователи:</b>
|
||||
- Всего: {user_stats['total_users']}
|
||||
- Активных: {user_stats['active_users']}
|
||||
- Новых за месяц: {user_stats['new_month']}
|
||||
|
||||
<b>Подписки:</b>
|
||||
- Активных: {sub_stats['active_subscriptions']}
|
||||
- Платных: {sub_stats['paid_subscriptions']}
|
||||
- Конверсия: {format_percentage(conversion_rate)}
|
||||
|
||||
<b>Финансы (месяц):</b>
|
||||
- Доходы: {settings.format_price(revenue_stats['totals']['income_kopeks'])}
|
||||
- ARPU: {settings.format_price(int(arpu))}
|
||||
- Транзакций: {sum(data['count'] for data in revenue_stats['by_type'].values())}
|
||||
|
||||
<b>Рост:</b>
|
||||
- Пользователи: +{user_stats['new_month']} за месяц
|
||||
- Продажи: +{sub_stats['purchased_month']} за месяц
|
||||
|
||||
<b>Обновлено:</b> {format_datetime(datetime.utcnow())}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_revenue_by_period(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
period = callback.data.split('_')[-1]
|
||||
|
||||
period_map = {
|
||||
"today": 1,
|
||||
"yesterday": 1,
|
||||
"week": 7,
|
||||
"month": 30,
|
||||
"all": 365
|
||||
}
|
||||
|
||||
days = period_map.get(period, 30)
|
||||
revenue_data = await get_revenue_by_period(db, days)
|
||||
|
||||
if period == "yesterday":
|
||||
yesterday = datetime.utcnow().date() - timedelta(days=1)
|
||||
revenue_data = [r for r in revenue_data if r['date'] == yesterday]
|
||||
elif period == "today":
|
||||
today = datetime.utcnow().date()
|
||||
revenue_data = [r for r in revenue_data if r['date'] == today]
|
||||
|
||||
total_revenue = sum(r['amount_kopeks'] for r in revenue_data)
|
||||
avg_daily = total_revenue / len(revenue_data) if revenue_data else 0
|
||||
|
||||
text = f"""
|
||||
📈 <b>Доходы за период: {period}</b>
|
||||
|
||||
<b>Сводка:</b>
|
||||
- Общий доход: {settings.format_price(total_revenue)}
|
||||
- Дней с данными: {len(revenue_data)}
|
||||
- Средний доход в день: {settings.format_price(int(avg_daily))}
|
||||
|
||||
<b>По дням:</b>
|
||||
"""
|
||||
|
||||
for revenue in revenue_data[-10:]:
|
||||
text += f"• {revenue['date'].strftime('%d.%m')}: {settings.format_price(revenue['amount_kopeks'])}\n"
|
||||
|
||||
if len(revenue_data) > 10:
|
||||
text += f"... и еще {len(revenue_data) - 10} дней"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📊 Другой период", callback_data="admin_revenue_period")],
|
||||
[types.InlineKeyboardButton(text="⬅️ К доходам", callback_data="admin_stats_revenue")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_statistics_menu, F.data == "admin_statistics")
|
||||
dp.callback_query.register(show_users_statistics, F.data == "admin_stats_users")
|
||||
dp.callback_query.register(show_subscriptions_statistics, F.data == "admin_stats_subs")
|
||||
dp.callback_query.register(show_revenue_statistics, F.data == "admin_stats_revenue")
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_stats_referrals")
|
||||
dp.callback_query.register(show_summary_statistics, F.data == "admin_stats_summary")
|
||||
dp.callback_query.register(show_revenue_by_period, F.data.startswith("period_"))
|
||||
|
||||
periods = ["today", "yesterday", "week", "month", "all"]
|
||||
for period in periods:
|
||||
dp.callback_query.register(
|
||||
show_revenue_by_period,
|
||||
F.data == f"period_{period}"
|
||||
)
|
||||
498
app/handlers/admin/subscriptions.py
Normal file
498
app/handlers/admin/subscriptions.py
Normal file
@@ -0,0 +1,498 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_subscriptions_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.subscription import (
|
||||
get_expiring_subscriptions, get_subscriptions_statistics, get_expired_subscriptions,
|
||||
get_all_subscriptions
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
|
||||
|
||||
def get_country_flag(country_name: str) -> str:
|
||||
flags = {
|
||||
'USA': '🇺🇸', 'United States': '🇺🇸', 'US': '🇺🇸',
|
||||
'Germany': '🇩🇪', 'DE': '🇩🇪', 'Deutschland': '🇩🇪',
|
||||
'Netherlands': '🇳🇱', 'NL': '🇳🇱', 'Holland': '🇳🇱',
|
||||
'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'GB': '🇬🇧',
|
||||
'Japan': '🇯🇵', 'JP': '🇯🇵',
|
||||
'France': '🇫🇷', 'FR': '🇫🇷',
|
||||
'Canada': '🇨🇦', 'CA': '🇨🇦',
|
||||
'Russia': '🇷🇺', 'RU': '🇷🇺',
|
||||
'Singapore': '🇸🇬', 'SG': '🇸🇬',
|
||||
}
|
||||
return flags.get(country_name, '🌍')
|
||||
|
||||
|
||||
async def get_users_by_countries(db: AsyncSession) -> dict:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(User.preferred_location, func.count(User.id))
|
||||
.where(User.preferred_location.isnot(None))
|
||||
.group_by(User.preferred_location)
|
||||
)
|
||||
|
||||
stats = {}
|
||||
for location, count in result.fetchall():
|
||||
if location:
|
||||
stats[location] = count
|
||||
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики по странам: {e}")
|
||||
return {}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
text = f"""
|
||||
📱 <b>Управление подписками</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
- Всего: {stats['total_subscriptions']}
|
||||
- Активных: {stats['active_subscriptions']}
|
||||
- Платных: {stats['paid_subscriptions']}
|
||||
- Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
📈 <b>Продажи:</b>
|
||||
- Сегодня: {stats['purchased_today']}
|
||||
- За неделю: {stats['purchased_week']}
|
||||
- За месяц: {stats['purchased_month']}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список подписок", callback_data="admin_subs_list"),
|
||||
types.InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
|
||||
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
|
||||
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
subscriptions, total_count = await get_all_subscriptions(db, page=page, limit=10)
|
||||
total_pages = (total_count + 9) // 10
|
||||
|
||||
if not subscriptions:
|
||||
text = "📱 <b>Список подписок</b>\n\n❌ Подписки не найдены."
|
||||
else:
|
||||
text = f"📱 <b>Список подписок</b>\n\n"
|
||||
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
|
||||
|
||||
for i, sub in enumerate(subscriptions, 1 + (page - 1) * 10):
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
status = "✅ Активна" if sub.is_active else "❌ Неактивна"
|
||||
|
||||
text += f"{i}. {sub_type} {user_info}\n"
|
||||
text += f" {status} | До: {format_datetime(sub.end_date)}\n"
|
||||
if sub.device_limit > 0:
|
||||
text += f" 📱 Устройств: {sub.device_limit}\n"
|
||||
text += "\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="⬅️", callback_data=f"admin_subs_list_page_{page-1}"
|
||||
))
|
||||
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}", callback_data="current_page"
|
||||
))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="➡️", callback_data=f"admin_subs_list_page_{page+1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_list")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_expiring_subscriptions(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
expiring_3d = await get_expiring_subscriptions(db, 3)
|
||||
expiring_1d = await get_expiring_subscriptions(db, 1)
|
||||
expired = await get_expired_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
⏰ <b>Истекающие подписки</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
- Истекают через 3 дня: {len(expiring_3d)}
|
||||
- Истекают завтра: {len(expiring_1d)}
|
||||
- Уже истекли: {len(expired)}
|
||||
|
||||
<b>Истекают через 3 дня:</b>
|
||||
"""
|
||||
|
||||
for sub in expiring_3d[:5]:
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
|
||||
|
||||
if len(expiring_3d) > 5:
|
||||
text += f"... и еще {len(expiring_3d) - 5}\n"
|
||||
|
||||
text += f"\n<b>Истекают завтра:</b>\n"
|
||||
for sub in expiring_1d[:5]:
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
|
||||
|
||||
if len(expiring_1d) > 5:
|
||||
text += f"... и еще {len(expiring_1d) - 5}\n"
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📨 Отправить напоминания", callback_data="admin_send_expiry_reminders")],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_expiring")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
expiring_3d = await get_expiring_subscriptions(db, 3)
|
||||
expiring_7d = await get_expiring_subscriptions(db, 7)
|
||||
expired = await get_expired_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Детальная статистика подписок</b>
|
||||
|
||||
<b>📱 Общая информация:</b>
|
||||
• Всего подписок: {stats['total_subscriptions']}
|
||||
• Активных: {stats['active_subscriptions']}
|
||||
• Неактивных: {stats['total_subscriptions'] - stats['active_subscriptions']}
|
||||
|
||||
<b>💎 По типам:</b>
|
||||
• Платных: {stats['paid_subscriptions']}
|
||||
• Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
<b>📈 Продажи:</b>
|
||||
• Сегодня: {stats['purchased_today']}
|
||||
• За неделю: {stats['purchased_week']}
|
||||
• За месяц: {stats['purchased_month']}
|
||||
|
||||
<b>⏰ Истечение:</b>
|
||||
• Истекают через 3 дня: {len(expiring_3d)}
|
||||
• Истекают через 7 дней: {len(expiring_7d)}
|
||||
• Уже истекли: {len(expired)}
|
||||
|
||||
<b>💰 Конверсия:</b>
|
||||
• Из триала в платную: {stats.get('trial_to_paid_conversion', 0)}%
|
||||
• Продлений: {stats.get('renewals_count', 0)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
|
||||
types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_pricing_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки цен</b>
|
||||
|
||||
<b>Периоды подписки:</b>
|
||||
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
|
||||
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
|
||||
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
|
||||
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
|
||||
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
|
||||
|
||||
<b>Трафик-пакеты:</b>
|
||||
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
|
||||
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
|
||||
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
|
||||
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
|
||||
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
|
||||
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
|
||||
|
||||
<b>Дополнительно:</b>
|
||||
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_countries_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
nodes_data = await remnawave_service.get_all_nodes()
|
||||
squads_data = await remnawave_service.get_all_squads()
|
||||
|
||||
text = "🌍 <b>Управление странами</b>\n\n"
|
||||
|
||||
if nodes_data:
|
||||
text += "<b>Доступные серверы:</b>\n"
|
||||
countries = {}
|
||||
|
||||
for node in nodes_data:
|
||||
country_code = node.get('country_code', 'XX')
|
||||
country_name = country_code
|
||||
|
||||
if country_name not in countries:
|
||||
countries[country_name] = []
|
||||
countries[country_name].append(node)
|
||||
|
||||
for country, nodes in countries.items():
|
||||
active_nodes = len([n for n in nodes if n.get('is_connected') and n.get('is_node_online')])
|
||||
total_nodes = len(nodes)
|
||||
|
||||
country_flag = get_country_flag(country)
|
||||
text += f"{country_flag} {country}: {active_nodes}/{total_nodes} серверов\n"
|
||||
|
||||
total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes)
|
||||
if total_users_online > 0:
|
||||
text += f" 👥 Пользователей онлайн: {total_users_online}\n"
|
||||
else:
|
||||
text += "❌ Не удалось загрузить данные о серверах\n"
|
||||
|
||||
if squads_data:
|
||||
text += f"\n<b>Всего сквадов:</b> {len(squads_data)}\n"
|
||||
|
||||
total_members = sum(squad.get('members_count', 0) for squad in squads_data)
|
||||
text += f"<b>Участников в сквадах:</b> {total_members}\n"
|
||||
|
||||
text += "\n<b>Сквады:</b>\n"
|
||||
for squad in squads_data[:5]:
|
||||
name = squad.get('name', 'Неизвестно')
|
||||
members = squad.get('members_count', 0)
|
||||
inbounds = squad.get('inbounds_count', 0)
|
||||
text += f"• {name}: {members} участников, {inbounds} inbound(s)\n"
|
||||
|
||||
if len(squads_data) > 5:
|
||||
text += f"... и еще {len(squads_data) - 5} сквадов\n"
|
||||
|
||||
user_stats = await get_users_by_countries(db)
|
||||
if user_stats:
|
||||
text += "\n<b>Пользователи по регионам:</b>\n"
|
||||
for country, count in user_stats.items():
|
||||
country_flag = get_country_flag(country)
|
||||
text += f"{country_flag} {country}: {count} пользователей\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных о странах: {e}")
|
||||
text = f"""
|
||||
🌍 <b>Управление странами</b>
|
||||
|
||||
❌ <b>Ошибка загрузки данных</b>
|
||||
Не удалось получить информацию о серверах.
|
||||
|
||||
Проверьте подключение к RemnaWave API.
|
||||
|
||||
<b>Детали ошибки:</b> {str(e)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries"),
|
||||
types.InlineKeyboardButton(text="⚙️ API настройки", callback_data="admin_rw_api")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"),
|
||||
types.InlineKeyboardButton(text="🔧 Сквады", callback_data="admin_rw_squads")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_expiry_reminders(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"📨 Отправка напоминаний...\n\nПодождите, это может занять время.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
expiring_subs = await get_expiring_subscriptions(db, 1)
|
||||
sent_count = 0
|
||||
|
||||
for subscription in expiring_subs:
|
||||
if subscription.user:
|
||||
try:
|
||||
user = subscription.user
|
||||
days_left = max(1, subscription.days_left)
|
||||
|
||||
reminder_text = f"""
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days_left} день(а).
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
|
||||
💎 Продлить подписку можно в главном меню.
|
||||
"""
|
||||
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=reminder_text
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки напоминания пользователю {subscription.user_id}: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subs_expiring")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_subscriptions_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_subscriptions_list(callback, db_user, db, page)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_subscriptions_menu, F.data == "admin_subscriptions")
|
||||
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
|
||||
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
|
||||
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
|
||||
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
|
||||
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
|
||||
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_subscriptions_pagination,
|
||||
F.data.startswith("admin_subs_list_page_")
|
||||
)
|
||||
856
app/handlers/admin/users.py
Normal file
856
app/handlers/admin/users.py
Normal file
@@ -0,0 +1,856 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.keyboards.admin import (
|
||||
get_admin_users_keyboard, get_user_management_keyboard,
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_service = UserService()
|
||||
stats = await user_service.get_user_statistics(db)
|
||||
|
||||
text = f"""
|
||||
👥 <b>Управление пользователями</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
• Всего: {stats['total_users']}
|
||||
• Активных: {stats['active_users']}
|
||||
• Заблокированных: {stats['blocked_users']}
|
||||
|
||||
📈 <b>Новые пользователи:</b>
|
||||
• Сегодня: {stats['new_today']}
|
||||
• За неделю: {stats['new_week']}
|
||||
• За месяц: {stats['new_month']}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_users_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_users_page(db, page=page, limit=10)
|
||||
|
||||
if not users_data["users"]:
|
||||
await callback.message.edit_text(
|
||||
"👥 Пользователи не найдены",
|
||||
reply_markup=get_admin_users_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Список пользователей</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
|
||||
for user in users_data["users"]:
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "❌"
|
||||
subscription_info = ""
|
||||
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_info = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_info = "💎"
|
||||
else:
|
||||
subscription_info = "⏰"
|
||||
|
||||
text += f"{status_emoji} {subscription_info} <b>{user.full_name}</b>\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
text += f"💰 {settings.format_price(user.balance_kopeks)}\n"
|
||||
text += f"📅 {format_time_ago(user.created_at)}\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
users_data["total_pages"],
|
||||
"admin_users_list",
|
||||
"admin_users",
|
||||
db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_list_pagination_fixed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list(callback, db_user, db, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка парсинга номера страницы: {e}")
|
||||
await show_users_list(callback, db_user, db, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_user_search(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔍 <b>Поиск пользователя</b>\n\n"
|
||||
"Введите для поиска:\n"
|
||||
"• Telegram ID\n"
|
||||
"• Username (без @)\n"
|
||||
"• Имя или фамилию\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_user_search)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_service = UserService()
|
||||
stats = await user_service.get_user_statistics(db)
|
||||
|
||||
from sqlalchemy import select, func, and_
|
||||
|
||||
with_sub_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.join(Subscription)
|
||||
.where(
|
||||
and_(
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
users_with_subscription = with_sub_result.scalar() or 0
|
||||
|
||||
trial_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.join(Subscription)
|
||||
.where(
|
||||
and_(
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.is_trial == True,
|
||||
Subscription.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
trial_users = trial_result.scalar() or 0
|
||||
|
||||
avg_balance_result = await db.execute(
|
||||
select(func.avg(User.balance_kopeks))
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
avg_balance = avg_balance_result.scalar() or 0
|
||||
|
||||
text = f"""
|
||||
📊 <b>Детальная статистика пользователей</b>
|
||||
|
||||
👥 <b>Общие показатели:</b>
|
||||
• Всего: {stats['total_users']}
|
||||
• Активных: {stats['active_users']}
|
||||
• Заблокированных: {stats['blocked_users']}
|
||||
|
||||
📱 <b>Подписки:</b>
|
||||
• С активной подпиской: {users_with_subscription}
|
||||
• На триале: {trial_users}
|
||||
• Без подписки: {stats['active_users'] - users_with_subscription}
|
||||
|
||||
💰 <b>Финансы:</b>
|
||||
• Средний баланс: {settings.format_price(int(avg_balance))}
|
||||
|
||||
📈 <b>Регистрации:</b>
|
||||
• Сегодня: {stats['new_today']}
|
||||
• За неделю: {stats['new_week']}
|
||||
• За месяц: {stats['new_month']}
|
||||
|
||||
📊 <b>Активность:</b>
|
||||
• Конверсия в подписку: {(users_with_subscription / max(stats['active_users'], 1) * 100):.1f}%
|
||||
• Доля триальных: {(trial_users / max(users_with_subscription, 1) * 100):.1f}%
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_users_stats")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = f"📱 <b>Подписка пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
if subscription:
|
||||
status_emoji = "✅" if subscription.is_active else "❌"
|
||||
type_emoji = "🎁" if subscription.is_trial else "💎"
|
||||
|
||||
text += f"<b>Статус:</b> {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n"
|
||||
text += f"<b>Тип:</b> {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n"
|
||||
text += f"<b>Начало:</b> {format_datetime(subscription.start_date)}\n"
|
||||
text += f"<b>Окончание:</b> {format_datetime(subscription.end_date)}\n"
|
||||
text += f"<b>Трафик:</b> {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n"
|
||||
text += f"<b>Устройства:</b> {subscription.device_limit}\n"
|
||||
text += f"<b>Подключенных устройств:</b> {len(subscription.connected_devices) if subscription.connected_devices else 0}\n"
|
||||
|
||||
if subscription.is_active:
|
||||
days_left = (subscription.end_date - datetime.utcnow()).days
|
||||
text += f"<b>Осталось дней:</b> {days_left}\n"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⏰ Продлить",
|
||||
callback_data=f"admin_sub_extend_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📊 Трафик",
|
||||
callback_data=f"admin_sub_traffic_{user_id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
if subscription.is_active:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🚫 Деактивировать",
|
||||
callback_data=f"admin_sub_deactivate_{user_id}"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Активировать",
|
||||
callback_data=f"admin_sub_activate_{user_id}"
|
||||
)
|
||||
])
|
||||
else:
|
||||
text += "❌ <b>Подписка отсутствует</b>\n\n"
|
||||
text += "Пользователь еще не активировал подписку."
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎁 Выдать триал",
|
||||
callback_data=f"admin_sub_grant_trial_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💎 Выдать подписку",
|
||||
callback_data=f"admin_sub_grant_{user_id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_transactions(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.transaction import get_user_transactions
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
transactions = await get_user_transactions(db, user_id, limit=10)
|
||||
|
||||
text = f"💳 <b>Транзакции пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n"
|
||||
text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
|
||||
if transactions:
|
||||
text += "<b>Последние транзакции:</b>\n\n"
|
||||
|
||||
for transaction in transactions:
|
||||
type_emoji = "📈" if transaction.amount_kopeks > 0 else "📉"
|
||||
text += f"{type_emoji} {settings.format_price(abs(transaction.amount_kopeks))}\n"
|
||||
text += f"📋 {transaction.description}\n"
|
||||
text += f"📅 {format_datetime(transaction.created_at)}\n\n"
|
||||
else:
|
||||
text += "📭 <b>Транзакции отсутствуют</b>"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_user_delete(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🗑️ <b>Удаление пользователя</b>\n\n"
|
||||
"⚠️ <b>ВНИМАНИЕ!</b>\n"
|
||||
"Вы уверены, что хотите удалить этого пользователя?\n\n"
|
||||
"Это действие:\n"
|
||||
"• Пометит пользователя как удаленного\n"
|
||||
"• Деактивирует его подписку\n"
|
||||
"• Заблокирует доступ к боту\n\n"
|
||||
"Данное действие необратимо!",
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
f"admin_user_delete_confirm_{user_id}",
|
||||
f"admin_user_manage_{user_id}",
|
||||
db_user.language
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_user_account(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
success = await user_service.delete_user_account(db, user_id, db_user.id)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
"✅ Пользователь успешно удален",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👥 К списку пользователей", callback_data="admin_users_list")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка удаления пользователя",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_user_search(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
query = message.text.strip()
|
||||
|
||||
if not query:
|
||||
await message.answer("❌ Введите корректный запрос для поиска")
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
search_results = await user_service.search_users(db, query, page=1, limit=10)
|
||||
|
||||
if not search_results["users"]:
|
||||
await message.answer(
|
||||
f"🔍 По запросу '<b>{query}</b>' ничего не найдено",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
text = f"🔍 <b>Результаты поиска:</b> '{query}'\n\n"
|
||||
keyboard = []
|
||||
|
||||
for user in search_results["users"]:
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "❌"
|
||||
subscription_info = ""
|
||||
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_info = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_info = "💎"
|
||||
else:
|
||||
subscription_info = "⏰"
|
||||
|
||||
text += f"{status_emoji} {subscription_info} <b>{user.full_name}</b>\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
text += f"💰 {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"👤 {user.full_name}",
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
status_text = "✅ Активен" if user.status == UserStatus.ACTIVE.value else "❌ Заблокирован"
|
||||
|
||||
text = f"""
|
||||
👤 <b>Управление пользователем</b>
|
||||
|
||||
<b>Основная информация:</b>
|
||||
• Имя: {user.full_name}
|
||||
• ID: <code>{user.telegram_id}</code>
|
||||
• Username: @{user.username or 'не указан'}
|
||||
• Статус: {status_text}
|
||||
• Язык: {user.language}
|
||||
|
||||
<b>Финансы:</b>
|
||||
• Баланс: {settings.format_price(user.balance_kopeks)}
|
||||
• Транзакций: {profile['transactions_count']}
|
||||
|
||||
<b>Активность:</b>
|
||||
• Регистрация: {format_datetime(user.created_at)}
|
||||
• Последняя активность: {format_time_ago(user.last_activity) if user.last_activity else 'Неизвестно'}
|
||||
• Дней с регистрации: {profile['registration_days']}
|
||||
"""
|
||||
|
||||
if subscription:
|
||||
text += f"""
|
||||
<b>Подписка:</b>
|
||||
• Тип: {'🎁 Триал' if subscription.is_trial else '💎 Платная'}
|
||||
• Статус: {'✅ Активна' if subscription.is_active else '❌ Неактивна'}
|
||||
• До: {format_datetime(subscription.end_date)}
|
||||
• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ
|
||||
• Устройства: {subscription.device_limit}
|
||||
• Стран: {len(subscription.connected_squads)}
|
||||
"""
|
||||
else:
|
||||
text += "\n<b>Подписка:</b> Отсутствует"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_user_management_keyboard(user.id, db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_balance_edit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await state.update_data(editing_user_id=user_id)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💰 <b>Изменение баланса</b>\n\n"
|
||||
"Введите сумму для изменения баланса:\n"
|
||||
"• Положительное число для пополнения\n"
|
||||
"• Отрицательное число для списания\n"
|
||||
"• Примеры: 100, -50, 25.5\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_balance)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_balance_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
user_id = data.get("editing_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer("❌ Ошибка: пользователь не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
amount_rubles = float(message.text.replace(',', '.'))
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
|
||||
if abs(amount_kopeks) > 10000000:
|
||||
await message.answer("❌ Слишком большая сумма (максимум 100,000 ₽)")
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
|
||||
description = f"Изменение баланса администратором {db_user.full_name}"
|
||||
if amount_kopeks > 0:
|
||||
description = f"Пополнение администратором: +{amount_rubles} ₽"
|
||||
else:
|
||||
description = f"Списание администратором: {amount_rubles} ₽"
|
||||
|
||||
success = await user_service.update_user_balance(
|
||||
db, user_id, amount_kopeks, description, db_user.id
|
||||
)
|
||||
|
||||
if success:
|
||||
action = "пополнен" if amount_kopeks > 0 else "списан"
|
||||
await message.answer(
|
||||
f"✅ Баланс пользователя {action} на {settings.format_price(abs(amount_kopeks))}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка изменения баланса (возможно, недостаточно средств для списания)")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректную сумму (например: 100 или -50)")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_user_block(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🚫 <b>Блокировка пользователя</b>\n\n"
|
||||
"Вы уверены, что хотите заблокировать этого пользователя?\n"
|
||||
"Пользователь потеряет доступ к боту.",
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
f"admin_user_block_confirm_{user_id}",
|
||||
f"admin_user_manage_{user_id}",
|
||||
db_user.language
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def block_user(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
success = await user_service.block_user(
|
||||
db, user_id, db_user.id, "Заблокирован администратором"
|
||||
)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
"✅ Пользователь заблокирован",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка блокировки пользователя",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_inactive_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_service = UserService()
|
||||
|
||||
from app.database.crud.user import get_inactive_users
|
||||
inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS)
|
||||
|
||||
if not inactive_users:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Неактивных пользователей (более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев) не найдено",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"🗑️ <b>Неактивные пользователи</b>\n"
|
||||
text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n"
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
text += f"👤 {user.full_name}\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
text += f"📅 {format_time_ago(user.last_activity) if user.last_activity else 'Никогда'}\n\n"
|
||||
|
||||
if len(inactive_users) > 10:
|
||||
text += f"... и еще {len(inactive_users) - 10} пользователей"
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить всех", callback_data="admin_cleanup_inactive")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cleanup_inactive_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_service = UserService()
|
||||
deleted_count = await user_service.cleanup_inactive_users(db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Очистка завершена\n\n"
|
||||
f"Удалено неактивных пользователей: {deleted_count}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_menu,
|
||||
F.data == "admin_users"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_list,
|
||||
F.data == "admin_users_list"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_statistics,
|
||||
F.data == "admin_users_stats"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_subscription,
|
||||
F.data.startswith("admin_user_sub_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_transactions,
|
||||
F.data.startswith("admin_user_trans_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
confirm_user_delete,
|
||||
F.data.startswith("admin_user_delete_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_user_account,
|
||||
F.data.startswith("admin_user_delete_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_list_pagination_fixed,
|
||||
F.data.startswith("admin_users_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_user_search,
|
||||
F.data == "admin_users_search"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_user_search,
|
||||
AdminStates.waiting_for_user_search
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_management,
|
||||
F.data.startswith("admin_user_manage_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_balance_edit,
|
||||
F.data.startswith("admin_user_balance_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_balance_edit,
|
||||
AdminStates.editing_user_balance
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
confirm_user_block,
|
||||
F.data.startswith("admin_user_block_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
block_user,
|
||||
F.data.startswith("admin_user_block_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_inactive_users,
|
||||
F.data == "admin_users_inactive"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
cleanup_inactive_users,
|
||||
F.data == "admin_cleanup_inactive"
|
||||
)
|
||||
341
app/handlers/balance.py
Normal file
341
app/handlers/balance.py
Normal file
@@ -0,0 +1,341 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import BalanceStates
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.crud.transaction import (
|
||||
get_user_transactions, get_user_transactions_count,
|
||||
create_transaction
|
||||
)
|
||||
from app.database.models import User, TransactionType, PaymentMethod
|
||||
from app.keyboards.inline import (
|
||||
get_balance_keyboard, get_payment_methods_keyboard,
|
||||
get_back_keyboard, get_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offset = (page - 1) * TRANSACTIONS_PER_PAGE
|
||||
|
||||
transactions = await get_user_transactions(
|
||||
db, db_user.id,
|
||||
limit=TRANSACTIONS_PER_PAGE,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
total_count = await get_user_transactions_count(db, db_user.id)
|
||||
|
||||
if not transactions:
|
||||
await callback.message.edit_text(
|
||||
"📊 История операций пуста",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📊 <b>История операций</b>\n\n"
|
||||
|
||||
for transaction in transactions:
|
||||
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
|
||||
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
|
||||
|
||||
text += f"{emoji} {amount_text}\n"
|
||||
text += f"📝 {transaction.description}\n"
|
||||
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
keyboard = []
|
||||
total_pages = (total_count + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_pagination_keyboard(
|
||||
page, total_pages, "balance_history", db_user.language
|
||||
)
|
||||
keyboard.extend(pagination_row)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_balance_history_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_balance_history(callback, db_user, db, page)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_balance_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""Начать пополнение - только для Telegram Stars"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Пополнение временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.TOP_UP_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_topup_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""Обработка введенной суммы - только для Telegram Stars"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
amount_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("❌ Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer("❌ Максимальная сумма пополнения: 50,000 ₽")
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
|
||||
await state.update_data(amount_kopeks=amount_kopeks)
|
||||
|
||||
payment_text = texts.TOP_UP_METHODS.format(
|
||||
amount=texts.format_price(amount_kopeks)
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
payment_text,
|
||||
reply_markup=get_payment_methods_keyboard(amount_kopeks, db_user.language)
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Оплата Stars временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_tribute_quick_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
"""Быстрое пополнение через Tribute - без выбора суммы"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
tribute_service = TributeService(callback.bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=db_user.telegram_id,
|
||||
amount_kopeks=0,
|
||||
description="Пополнение баланса VPN"
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||
f"• Введите любую сумму от 50 ₽\n"
|
||||
f"• Безопасная оплата через Tribute\n"
|
||||
f"• Мгновенное зачисление на баланс\n"
|
||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||
f"Нажмите кнопку для перехода к оплате:",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def request_support_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
support_text = f"""
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
Для пополнения баланса обратитесь в техподдержку:
|
||||
{settings.SUPPORT_USERNAME}
|
||||
|
||||
Укажите:
|
||||
• ID: {db_user.telegram_id}
|
||||
• Сумму пополнения
|
||||
• Способ оплаты
|
||||
|
||||
⏰ Время обработки: 1-24 часа
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="💬 Написать в поддержку",
|
||||
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
|
||||
)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
support_text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_menu,
|
||||
F.data == "menu_balance"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_history,
|
||||
F.data == "balance_history"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_balance_history_pagination,
|
||||
F.data.startswith("balance_history_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_balance_topup,
|
||||
F.data == "balance_topup"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
process_stars_payment,
|
||||
F.data.startswith("pay_stars_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
process_tribute_quick_payment,
|
||||
F.data == "tribute_quick_pay"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
request_support_topup,
|
||||
F.data == "balance_support"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_topup_amount,
|
||||
BalanceStates.waiting_for_amount
|
||||
)
|
||||
85
app/handlers/common.py
Normal file
85
app/handlers/common.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_unknown_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await callback.answer(
|
||||
"❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.clear()
|
||||
await callback.message.edit_text(
|
||||
texts.OPERATION_CANCELLED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_unknown_message(
|
||||
message: types.Message,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await message.answer(
|
||||
"❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
reply_markup=get_back_keyboard(db_user.language if db_user else "ru")
|
||||
)
|
||||
|
||||
|
||||
async def show_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
rules_text = texts.RULES_TEXT
|
||||
|
||||
await callback.message.edit_text(
|
||||
rules_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data.in_(["cancel", "subscription_cancel"])
|
||||
)
|
||||
|
||||
132
app/handlers/menu.py
Normal file
132
app/handlers/menu.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
has_active_subscription = bool(db_user.subscription)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
menu_text = texts.MAIN_MENU.format(
|
||||
user_name=db_user.full_name,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
subscription_status=_get_subscription_status(db_user, texts)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
) -> None:
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
rules_text = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
if not rules_text:
|
||||
texts = get_texts(db_user.language)
|
||||
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_back_to_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await state.clear()
|
||||
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
return texts.SUBSCRIPTION_NONE
|
||||
|
||||
if user.subscription.is_trial:
|
||||
return f"{texts.SUBSCRIPTION_TRIAL} (до {user.subscription.end_date.strftime('%d.%m.%Y')})"
|
||||
elif user.subscription.is_active:
|
||||
days_left = user.subscription.days_left
|
||||
return f"{texts.SUBSCRIPTION_ACTIVE} ({days_left} дн.)"
|
||||
else:
|
||||
return texts.SUBSCRIPTION_EXPIRED
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_back_to_menu,
|
||||
F.data == "back_to_menu"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
89
app/handlers/promocode.py
Normal file
89
app/handlers/promocode.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import PromoCodeStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_promocode_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.PROMOCODE_ENTER,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(PromoCodeStates.waiting_for_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_promocode(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
if not code:
|
||||
await message.answer(
|
||||
"❌ Введите корректный промокод",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, db_user.id, code)
|
||||
|
||||
if result["success"]:
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
"expired": texts.PROMOCODE_EXPIRED,
|
||||
"used": texts.PROMOCODE_USED,
|
||||
"already_used_by_user": texts.PROMOCODE_USED,
|
||||
"server_error": texts.ERROR
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
|
||||
await message.answer(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promocode_menu,
|
||||
F.data == "menu_promocode"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_promocode,
|
||||
PromoCodeStates.waiting_for_code
|
||||
)
|
||||
144
app/handlers/referral.py
Normal file
144
app/handlers/referral.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral import get_referral_earnings_sum
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard, get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.user_utils import get_user_referral_summary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_referral_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
|
||||
|
||||
referral_text += f"📊 <b>Ваша статистика:</b>\n"
|
||||
referral_text += f"• Приглашено пользователей: {summary['invited_count']}\n"
|
||||
referral_text += f"• Купили подписку: {summary['paid_referrals_count']}\n"
|
||||
referral_text += f"• Заработано всего: {texts.format_price(summary['total_earned_kopeks'])}\n"
|
||||
referral_text += f"• За последний месяц: {texts.format_price(summary['month_earned_kopeks'])}\n\n"
|
||||
|
||||
referral_text += f"🎁 <b>Как работают награды:</b>\n"
|
||||
referral_text += f"• Новый пользователь получает: {texts.format_price(settings.REFERRED_USER_REWARD)}\n"
|
||||
referral_text += f"• Вы получаете при первой покупке реферала: {texts.format_price(settings.REFERRAL_REGISTRATION_REWARD)}\n"
|
||||
referral_text += f"• Комиссия с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%\n\n"
|
||||
|
||||
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
|
||||
referral_text += f"<code>{referral_link}</code>\n\n"
|
||||
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
|
||||
|
||||
if summary['recent_earnings']:
|
||||
referral_text += f"💰 <b>Последние начисления:</b>\n"
|
||||
for earning in summary['recent_earnings'][:3]:
|
||||
reason_text = {
|
||||
"referral_first_purchase": "🎉 Первая покупка",
|
||||
"referral_commission": "💰 Комиссия",
|
||||
"referral_registration_pending": "⏳ Ожидание покупки"
|
||||
}.get(earning['reason'], earning['reason'])
|
||||
|
||||
referral_text += f"• {reason_text}: {texts.format_price(earning['amount_kopeks'])} от {earning['referral_name']}\n"
|
||||
referral_text += "\n"
|
||||
|
||||
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def create_invite_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
|
||||
invite_text += f"💎 При регистрации по моей ссылке ты получишь {texts.format_price(settings.REFERRED_USER_REWARD)} на баланс!\n\n"
|
||||
invite_text += f"🚀 Быстрое подключение\n"
|
||||
invite_text += f"🌍 Серверы по всему миру\n"
|
||||
invite_text += f"🔒 Надежная защита\n\n"
|
||||
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
switch_inline_query=invite_text
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📋 Скопировать ссылку",
|
||||
callback_data="copy_referral_link"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Используйте кнопку ниже для отправки приглашения или скопируйте текст:\n\n"
|
||||
f"<code>{invite_text}</code>",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def copy_referral_link(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
await callback.answer(
|
||||
f"Ссылка скопирована: {referral_link}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_referral_info,
|
||||
F.data == "menu_referrals"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
create_invite_message,
|
||||
F.data == "referral_create_invite"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
copy_referral_link,
|
||||
F.data == "copy_referral_link"
|
||||
)
|
||||
507
app/handlers/start.py
Normal file
507
app/handlers/start.py
Normal file
@@ -0,0 +1,507 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import RegistrationStates
|
||||
from app.database.crud.user import (
|
||||
get_user_by_telegram_id, create_user, get_user_by_referral_code
|
||||
)
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession):
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
referral_code = None
|
||||
if len(message.text.split()) > 1:
|
||||
potential_code = message.text.split()[1]
|
||||
referral_code = potential_code
|
||||
logger.info(f"🔎 Найден реферальный код: {referral_code}")
|
||||
|
||||
if referral_code:
|
||||
await state.set_data({'referral_code': referral_code})
|
||||
|
||||
user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if user:
|
||||
logger.info(f"✅ Пользователь найден: {user.telegram_id}")
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if referral_code and not user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
balance=texts.format_price(user.balance_kopeks),
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
language = 'ru'
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен русский язык по умолчанию")
|
||||
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
|
||||
|
||||
async def process_rules_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"📋 RULES: Начало обработки правил")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
if callback.data == 'rules_accept':
|
||||
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с правилами удалено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"✅ Правила приняты! Завершаем регистрацию...",
|
||||
reply_markup=None
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Референс найден: {referrer.id}")
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
|
||||
try:
|
||||
rules_required_text = getattr(texts, 'RULES_REQUIRED',
|
||||
"Для использования бота необходимо принять правила сервиса.")
|
||||
await callback.message.edit_text(
|
||||
rules_required_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"Для использования бота необходимо принять правила сервиса.",
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
|
||||
await callback.answer("❌ Произошла ошибка. Попробуйте еще раз.", show_alert=True)
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
await callback.message.answer(
|
||||
"Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def process_referral_code_input(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
referral_code = message.text.strip()
|
||||
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
await message.answer("✅ Реферальный код применен!")
|
||||
logger.info(f"✅ Реферальный код применен")
|
||||
else:
|
||||
await message.answer("❌ Неверный реферальный код")
|
||||
logger.info(f"❌ Неверный реферальный код")
|
||||
return
|
||||
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
|
||||
async def process_referral_code_skip(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}")
|
||||
await callback.answer()
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с вопросом о реферальном коде удалено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"✅ Завершаем регистрацию...",
|
||||
reply_markup=None
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
async def complete_registration_from_callback(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
if existing_user:
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже существует! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await callback.message.answer(f"Добро пожаловать, {user_name}!")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=callback.from_user.id,
|
||||
username=callback.from_user.username,
|
||||
first_name=callback.from_user.first_name,
|
||||
last_name=callback.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id)
|
||||
bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!"
|
||||
await callback.message.answer(bonus_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
user_language = user.language
|
||||
has_had_paid_subscription = user.has_had_paid_subscription
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
try:
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await callback.message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as final_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке простого сообщения: {final_error}")
|
||||
|
||||
logger.info(f"✅ Зарегистрирован новый пользователь: {user_telegram_id}")
|
||||
|
||||
|
||||
async def complete_registration(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id)
|
||||
bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!"
|
||||
await message.answer(bonus_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
user_language = user.language
|
||||
has_had_paid_subscription = user.has_had_paid_subscription
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
try:
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info(f"✅ Зарегистрирован новый пользователь: {user_telegram_id}")
|
||||
|
||||
|
||||
def _get_subscription_status(user, texts):
|
||||
if user.subscription and user.subscription.is_active:
|
||||
return getattr(texts, 'SUBSCRIPTION_ACTIVE', 'Активна')
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
|
||||
def _get_subscription_status_simple(texts):
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
|
||||
def get_referral_code_keyboard(language: str):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="⭐️ Пропустить",
|
||||
callback_data="referral_skip"
|
||||
)]
|
||||
])
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
|
||||
|
||||
dp.message.register(
|
||||
cmd_start,
|
||||
Command("start")
|
||||
)
|
||||
logger.info("✅ Зарегистрирован cmd_start")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_rules_accept,
|
||||
F.data.in_(["rules_accept", "rules_decline"]),
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_referral_code_skip,
|
||||
F.data == "referral_skip",
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_skip")
|
||||
|
||||
dp.message.register(
|
||||
process_referral_code_input,
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_input")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
1979
app/handlers/subscription.py
Normal file
1979
app/handlers/subscription.py
Normal file
File diff suppressed because it is too large
Load Diff
32
app/handlers/support.py
Normal file
32
app/handlers/support.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_support_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_support_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SUPPORT_INFO,
|
||||
reply_markup=get_support_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_support_info,
|
||||
F.data == "menu_support"
|
||||
)
|
||||
139
app/handlers/webhooks.py
Normal file
139
app/handlers/webhooks.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_id, add_user_balance
|
||||
from app.database.crud.transaction import create_transaction, get_transaction_by_external_id
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.external.tribute import TributeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def tribute_webhook(request):
|
||||
try:
|
||||
signature = request.headers.get('X-Signature', '')
|
||||
payload = await request.text()
|
||||
|
||||
tribute_service = TributeService()
|
||||
|
||||
if not tribute_service.verify_webhook_signature(payload, signature):
|
||||
logger.warning("Неверная подпись Tribute webhook")
|
||||
return web.Response(status=400, text="Invalid signature")
|
||||
|
||||
webhook_data = await request.json()
|
||||
processed_data = await tribute_service.process_webhook(webhook_data)
|
||||
|
||||
if not processed_data:
|
||||
logger.error("Ошибка обработки Tribute webhook")
|
||||
return web.Response(status=400, text="Invalid webhook data")
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
existing_transaction = await get_transaction_by_external_id(
|
||||
db, processed_data['payment_id'], PaymentMethod.TRIBUTE
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
logger.info(f"Платеж {processed_data['payment_id']} уже обработан")
|
||||
return web.Response(status=200, text="Already processed")
|
||||
|
||||
if processed_data['status'] == 'completed':
|
||||
user = await get_user_by_id(db, processed_data['user_id'])
|
||||
|
||||
if user:
|
||||
await add_user_balance(
|
||||
db, user, processed_data['amount_kopeks'],
|
||||
f"Пополнение через Tribute: {processed_data['payment_id']}"
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=processed_data['amount_kopeks'],
|
||||
description=f"Пополнение через Tribute",
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=processed_data['payment_id']
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Tribute платеж: {processed_data['payment_id']}")
|
||||
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Tribute webhook: {e}")
|
||||
await db.rollback()
|
||||
return web.Response(status=500, text="Internal error")
|
||||
finally:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в Tribute webhook: {e}")
|
||||
return web.Response(status=500, text="Internal error")
|
||||
|
||||
|
||||
async def handle_successful_payment(message: types.Message):
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
|
||||
payload_parts = payment.invoice_payload.split('_')
|
||||
if len(payload_parts) >= 3 and payload_parts[0] == 'balance':
|
||||
user_id = int(payload_parts[1])
|
||||
amount_kopeks = int(payload_parts[2])
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
existing_transaction = await get_transaction_by_external_id(
|
||||
db, payment.telegram_payment_charge_id, PaymentMethod.TELEGRAM_STARS
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
logger.info(f"Stars платеж {payment.telegram_payment_charge_id} уже обработан")
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if user:
|
||||
await add_user_balance(
|
||||
db, user, amount_kopeks,
|
||||
f"Пополнение через Telegram Stars"
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars",
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=payment.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Stars платежа: {e}")
|
||||
await db.rollback()
|
||||
finally:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в обработчике Stars платежа: {e}")
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(pre_checkout_query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
logger.info(f"Pre-checkout query принят: {pre_checkout_query.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre-checkout query: {e}")
|
||||
await pre_checkout_query.answer(ok=False, error_message="Ошибка обработки платежа")
|
||||
529
app/keyboards/admin.py
Normal file
529
app/keyboards/admin.py
Normal file
@@ -0,0 +1,529 @@
|
||||
from typing import List, Optional
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data="admin_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Все пользователи", callback_data="admin_users_list"),
|
||||
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"),
|
||||
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Все подписки", callback_data="admin_subs_list"),
|
||||
InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройки цен", callback_data="admin_subs_pricing"),
|
||||
InlineKeyboardButton(text="🌍 Управление странами", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Все промокоды", callback_data="admin_promo_list"),
|
||||
InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_promo_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"promo_edit_{promo_id}"),
|
||||
InlineKeyboardButton(text="🔄 Статус", callback_data=f"promo_toggle_{promo_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"promo_stats_{promo_id}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"promo_delete_{promo_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📨 Всем пользователям", callback_data="admin_msg_all"),
|
||||
InlineKeyboardButton(text="🎯 По подпискам", callback_data="admin_msg_by_sub")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔍 По критериям", callback_data="admin_msg_custom"),
|
||||
InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
|
||||
InlineKeyboardButton(text="⏸️ Остановить", callback_data="admin_mon_stop")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статус", callback_data="admin_mon_status"),
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Системная статистика", callback_data="admin_rw_system"),
|
||||
InlineKeyboardButton(text="🖥️ Управление нодами", callback_data="admin_rw_nodes")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"),
|
||||
InlineKeyboardButton(text="🌍 Управление сквадами", callback_data="admin_rw_squads")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_stats_users"),
|
||||
InlineKeyboardButton(text="📱 Подписки", callback_data="admin_stats_subs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"),
|
||||
InlineKeyboardButton(text="🤝 Рефералы", callback_data="admin_stats_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_user_management_keyboard(user_id: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_sub_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_stats_{user_id}"),
|
||||
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_trans_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🚫 Заблокировать", callback_data=f"admin_user_block_{user_id}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_confirmation_keyboard(
|
||||
confirm_action: str,
|
||||
cancel_action: str = "admin_panel",
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.YES, callback_data=confirm_action),
|
||||
InlineKeyboardButton(text=texts.NO, callback_data=cancel_action)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data="promo_type_balance"),
|
||||
InlineKeyboardButton(text="📅 Дни подписки", callback_data="promo_type_days")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 Триал", callback_data="promo_type_trial")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {type_emoji} {promo.code}",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = []
|
||||
|
||||
if page > 1:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="⬅️", callback_data=f"admin_promo_list_page_{page - 1}")
|
||||
)
|
||||
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="➡️", callback_data=f"admin_promo_list_page_{page + 1}")
|
||||
)
|
||||
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Всем", callback_data="broadcast_all"),
|
||||
InlineKeyboardButton(text="📱 С подпиской", callback_data="broadcast_active")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 Триал", callback_data="broadcast_trial"),
|
||||
InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Сегодня", callback_data="criteria_today"),
|
||||
InlineKeyboardButton(text="📅 За неделю", callback_data="criteria_week")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 За месяц", callback_data="criteria_month"),
|
||||
InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
|
||||
InlineKeyboardButton(text="💤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"),
|
||||
InlineKeyboardButton(text="🎫 Использовали промокоды", callback_data="criteria_promocodes")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎯 Прямая регистрация", callback_data="criteria_direct")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = []
|
||||
|
||||
if page > 1:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="⬅️", callback_data=f"admin_msg_history_page_{page - 1}")
|
||||
)
|
||||
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="➡️", callback_data=f"admin_msg_history_page_{page + 1}")
|
||||
)
|
||||
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_msg_history")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Синхронизировать всех", callback_data="sync_all_users"),
|
||||
InlineKeyboardButton(text="➕ Только новых", callback_data="sync_new_users")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Обновить данные", callback_data="sync_update_data")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Сегодня", callback_data="period_today"),
|
||||
InlineKeyboardButton(text="📅 Вчера", callback_data="period_yesterday")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Неделя", callback_data="period_week"),
|
||||
InlineKeyboardButton(text="📅 Месяц", callback_data="period_month")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Все время", callback_data="period_all")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_node_management_keyboard(node_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Включить", callback_data=f"node_enable_{node_uuid}"),
|
||||
InlineKeyboardButton(text="⏸️ Отключить", callback_data=f"node_disable_{node_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить", callback_data=f"node_restart_{node_uuid}"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"node_stats_{node_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_nodes")
|
||||
]
|
||||
])
|
||||
|
||||
def get_squad_management_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Добавить всех пользователей", callback_data=f"squad_add_users_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Удалить всех пользователей", callback_data=f"squad_remove_users_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"squad_edit_{squad_uuid}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить сквад", callback_data=f"squad_delete_{squad_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_squads")
|
||||
]
|
||||
])
|
||||
|
||||
def get_squad_edit_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔧 Изменить инбаунды", callback_data=f"squad_edit_inbounds_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Переименовать", callback_data=f"squad_rename_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад к сквадам", callback_data=f"admin_squad_manage_{squad_uuid}")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
|
||||
InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
|
||||
InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить старые", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server_id}"),
|
||||
InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server_id}"),
|
||||
InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отключить" if is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server_id}"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_pagination_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
callback_prefix: str,
|
||||
back_callback: str = "admin_panel",
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
row = []
|
||||
|
||||
if current_page > 1:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
594
app/keyboards/inline.py
Normal file
594
app/keyboards/inline.py
Normal file
@@ -0,0 +1,594 @@
|
||||
from typing import List, Optional
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.RULES_ACCEPT, callback_data="rules_accept"),
|
||||
InlineKeyboardButton(text=texts.RULES_DECLINE, callback_data="rules_decline")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_main_menu_keyboard(
|
||||
language: str = "ru",
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}")
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_BALANCE, callback_data="menu_balance"),
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
show_buy = not has_active_subscription or not subscription_is_active
|
||||
|
||||
subscription_buttons = []
|
||||
|
||||
if show_trial:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
|
||||
)
|
||||
|
||||
if show_buy:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
|
||||
if subscription_buttons:
|
||||
if len(subscription_buttons) == 2:
|
||||
keyboard.append(subscription_buttons)
|
||||
else:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"),
|
||||
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
if is_admin:
|
||||
if settings.DEBUG:
|
||||
print("DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА!")
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel")
|
||||
])
|
||||
else:
|
||||
if settings.DEBUG:
|
||||
print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена")
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_keyboard(
|
||||
language: str = "ru",
|
||||
has_subscription: bool = False,
|
||||
is_trial: bool = False,
|
||||
subscription=None
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if has_subscription:
|
||||
if is_trial:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
|
||||
])
|
||||
else:
|
||||
row1 = []
|
||||
row2 = []
|
||||
row3 = []
|
||||
row4 = []
|
||||
|
||||
row1.append(InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries"))
|
||||
|
||||
if subscription and subscription.traffic_limit_gb > 0:
|
||||
row1.append(InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic"))
|
||||
|
||||
row2.append(InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices"))
|
||||
|
||||
if subscription and subscription.days_left <= 3:
|
||||
row2.append(InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend"))
|
||||
|
||||
row3.append(InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic"))
|
||||
|
||||
row3.append(InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay"))
|
||||
|
||||
row4.append(InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices"))
|
||||
|
||||
if row1:
|
||||
keyboard.append(row1)
|
||||
if row2:
|
||||
keyboard.append(row2)
|
||||
if row3:
|
||||
keyboard.append(row3)
|
||||
if row4:
|
||||
keyboard.append(row4)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 Активировать", callback_data="trial_activate"),
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
periods = [
|
||||
(14, texts.PERIOD_14_DAYS),
|
||||
(30, texts.PERIOD_30_DAYS),
|
||||
(60, texts.PERIOD_60_DAYS),
|
||||
(90, texts.PERIOD_90_DAYS),
|
||||
(180, texts.PERIOD_180_DAYS),
|
||||
(360, texts.PERIOD_360_DAYS)
|
||||
]
|
||||
|
||||
for days, text in periods:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"period_{days}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
packages = [
|
||||
(5, texts.TRAFFIC_5GB),
|
||||
(10, texts.TRAFFIC_10GB),
|
||||
(25, texts.TRAFFIC_25GB),
|
||||
(50, texts.TRAFFIC_50GB),
|
||||
(100, texts.TRAFFIC_100GB),
|
||||
(250, texts.TRAFFIC_250GB),
|
||||
(0, texts.TRAFFIC_UNLIMITED) # 0 = безлимит
|
||||
]
|
||||
|
||||
for gb, text in packages:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"traffic_{gb}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
|
||||
emoji = "✅" if country['uuid'] in selected else "⚪"
|
||||
|
||||
if country['price_kopeks'] > 0:
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})"
|
||||
else:
|
||||
price_text = " (Бесплатно)"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {country['name']}{price_text}",
|
||||
callback_data=f"country_{country['uuid']}"
|
||||
)
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет доступных серверов",
|
||||
callback_data="no_servers"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="✅ Продолжить", callback_data="countries_continue")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
for devices in range(1, 6):
|
||||
price = (devices - 1) * settings.PRICE_PER_DEVICE
|
||||
price_text = f" (+{texts.format_price(price)})" if price > 0 else ""
|
||||
emoji = "✅" if devices == current else "⚪"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {devices} устройство{_get_device_suffix(devices)}{price_text}",
|
||||
callback_data=f"devices_{devices}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="✅ Продолжить", callback_data="devices_continue")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.CONFIRM, callback_data="subscription_confirm"),
|
||||
InlineKeyboardButton(text=texts.CANCEL, callback_data="subscription_cancel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data="balance_history"),
|
||||
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup")
|
||||
])
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="💳 Быстрое пополнение", callback_data="tribute_quick_pay")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BALANCE_SUPPORT_REQUEST, callback_data="balance_support")
|
||||
])
|
||||
|
||||
# Назад
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.TOP_UP_STARS, callback_data=f"pay_stars_{amount_kopeks}")
|
||||
])
|
||||
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.CREATE_INVITE, callback_data="referral_create_invite")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_support_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.CONTACT_SUPPORT, url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_pagination_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
callback_prefix: str,
|
||||
language: str = "ru"
|
||||
) -> List[List[InlineKeyboardButton]]:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
row = []
|
||||
|
||||
if current_page > 1:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(row)
|
||||
|
||||
return keyboard
|
||||
|
||||
|
||||
def _get_device_suffix(count: int) -> str:
|
||||
if count == 1:
|
||||
return ""
|
||||
elif 2 <= count <= 4:
|
||||
return "а"
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def get_confirmation_keyboard(
|
||||
confirm_data: str,
|
||||
cancel_data: str = "cancel",
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.YES, callback_data=confirm_data),
|
||||
InlineKeyboardButton(text=texts.NO, callback_data=cancel_data)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_autopay_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Включить", callback_data="autopay_enable"),
|
||||
InlineKeyboardButton(text="❌ Выключить", callback_data="autopay_disable")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настроить дни", callback_data="autopay_set_days")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_autopay_days_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
for days in [1, 3, 7, 14]:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{days} дн{_get_days_suffix(days)}",
|
||||
callback_data=f"autopay_days_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="subscription_autopay")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_days_suffix(days: int) -> str:
|
||||
if days == 1:
|
||||
return "ь"
|
||||
elif 2 <= days <= 4:
|
||||
return "я"
|
||||
else:
|
||||
return "ей"
|
||||
|
||||
|
||||
|
||||
def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
periods = [
|
||||
(14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"),
|
||||
(30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"),
|
||||
(60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"),
|
||||
(90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}")
|
||||
]
|
||||
|
||||
for days, text in periods:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
packages = [
|
||||
(5, f"📊 +5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"),
|
||||
(10, f"📊 +10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"),
|
||||
(25, f"📊 +25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"),
|
||||
(50, f"📊 +50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"),
|
||||
(100, f"📊 +100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"),
|
||||
(0, f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}")
|
||||
]
|
||||
|
||||
for gb, text in packages:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
max_devices = 10
|
||||
|
||||
for add_count in range(1, min(6, max_devices - current_devices + 1)):
|
||||
price = add_count * settings.PRICE_PER_DEVICE
|
||||
total_devices = current_devices + add_count
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📱 +{add_count} устройство{_get_device_suffix(add_count)} (итого: {total_devices}) - {settings.format_price(price)}",
|
||||
callback_data=f"add_devices_{add_count}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"✅ Сбросить за {settings.format_price(price_kopeks)}",
|
||||
callback_data="confirm_reset_traffic"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
def get_manage_countries_keyboard(
|
||||
countries: List[dict],
|
||||
selected: List[str],
|
||||
current_subscription_countries: List[str],
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура для управления странами подписки БЕЗ состояний FSM"""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
|
||||
is_currently_connected = country['uuid'] in current_subscription_countries
|
||||
is_selected = country['uuid'] in selected
|
||||
|
||||
if is_currently_connected:
|
||||
if is_selected:
|
||||
emoji = "✅"
|
||||
status = ""
|
||||
else:
|
||||
emoji = "➖"
|
||||
status = " (отключить БЕСПЛАТНО)"
|
||||
else:
|
||||
if is_selected:
|
||||
emoji = "➕"
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
|
||||
status = price_text
|
||||
else:
|
||||
emoji = "⚪"
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
|
||||
status = price_text
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {country['name']}{status}",
|
||||
callback_data=f"country_manage_{country['uuid']}"
|
||||
)
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет доступных серверов",
|
||||
callback_data="no_servers"
|
||||
)
|
||||
])
|
||||
|
||||
added = [c for c in selected if c not in current_subscription_countries]
|
||||
removed = [c for c in current_subscription_countries if c not in selected]
|
||||
|
||||
apply_text = "✅ Применить изменения"
|
||||
if added or removed:
|
||||
changes_count = len(added) + len(removed)
|
||||
apply_text += f" ({changes_count})"
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text=apply_text, callback_data="countries_apply")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
116
app/keyboards/reply.py
Normal file
116
app/keyboards/reply.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove
|
||||
from typing import List
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_BALANCE),
|
||||
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_PROMOCODE),
|
||||
KeyboardButton(text=texts.MENU_REFERRALS)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_SUPPORT),
|
||||
KeyboardButton(text=texts.MENU_RULES)
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
|
||||
def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="👥 Пользователи"),
|
||||
KeyboardButton(text="📱 Подписки")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🎫 Промокоды"),
|
||||
KeyboardButton(text="📨 Рассылки")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📊 Статистика"),
|
||||
KeyboardButton(text="🔧 Мониторинг")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🏠 Главное меню")
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
|
||||
def get_cancel_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text=texts.CANCEL)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text=texts.YES),
|
||||
KeyboardButton(text=texts.NO)
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="⏭️ Пропустить")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def remove_keyboard() -> ReplyKeyboardRemove:
|
||||
return ReplyKeyboardRemove()
|
||||
|
||||
|
||||
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
488
app/localization/texts.py
Normal file
488
app/localization/texts.py
Normal file
@@ -0,0 +1,488 @@
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from app.config import settings
|
||||
|
||||
_cached_rules = {}
|
||||
|
||||
def _get_default_rules(language: str = "ru") -> str:
|
||||
if language == "en":
|
||||
return """
|
||||
🔒 <b>Service Usage Rules</b>
|
||||
|
||||
1. It is forbidden to use the service for illegal activities
|
||||
2. Copyright infringement is prohibited
|
||||
3. Spam and malware distribution are prohibited
|
||||
4. Using the service for DDoS attacks is prohibited
|
||||
5. One account - one user
|
||||
6. Refunds are made only in exceptional cases
|
||||
7. Administration reserves the right to block an account for violating the rules
|
||||
|
||||
<b>By accepting the rules, you agree to comply with them.</b>
|
||||
"""
|
||||
else:
|
||||
return """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
class Texts:
|
||||
def __init__(self, language: str = "ru"):
|
||||
self.language = language
|
||||
|
||||
@property
|
||||
def RULES_TEXT(self) -> str:
|
||||
if self.language in _cached_rules:
|
||||
return _cached_rules[self.language]
|
||||
|
||||
return _get_default_rules(self.language)
|
||||
|
||||
BACK = "⬅️ Назад"
|
||||
CANCEL = "❌ Отмена"
|
||||
CONFIRM = "✅ Подтвердить"
|
||||
CONTINUE = "➡️ Продолжить"
|
||||
YES = "✅ Да"
|
||||
NO = "❌ Нет"
|
||||
LOADING = "⏳ Загрузка..."
|
||||
ERROR = "❌ Произошла ошибка"
|
||||
SUCCESS = "✅ Успешно"
|
||||
|
||||
@staticmethod
|
||||
def format_price(kopeks: int) -> str:
|
||||
return f"{kopeks / 100:.2f} ₽"
|
||||
|
||||
@staticmethod
|
||||
def format_traffic(gb: float) -> str:
|
||||
if gb == 0:
|
||||
return "∞ (безлимит)"
|
||||
elif gb >= 1024:
|
||||
return f"{gb/1024:.1f} ТБ"
|
||||
else:
|
||||
return f"{gb:.0f} ГБ"
|
||||
|
||||
|
||||
class RussianTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("ru")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Добро пожаловать в VPN сервис!</b>
|
||||
|
||||
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
|
||||
|
||||
🔐 <b>Преимущества:</b>
|
||||
• Высокая скорость подключения
|
||||
• Серверы в разных странах
|
||||
• Надежная защита данных
|
||||
• Круглосуточная поддержка
|
||||
|
||||
Для начала работы выберите язык интерфейса:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
|
||||
|
||||
RULES_ACCEPT = "✅ Принимаю правила"
|
||||
RULES_DECLINE = "❌ Не принимаю"
|
||||
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
|
||||
|
||||
REFERRAL_CODE_QUESTION = """
|
||||
🤝 <b>У вас есть реферальный код от друга?</b>
|
||||
|
||||
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
|
||||
|
||||
Введите код или нажмите "Пропустить":
|
||||
"""
|
||||
|
||||
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
|
||||
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
|
||||
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
|
||||
|
||||
MAIN_MENU = """
|
||||
👤 <b>{user_name}</b>
|
||||
|
||||
💰 <b>Баланс:</b> {balance}
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
MENU_BALANCE = "💰 Баланс"
|
||||
MENU_SUBSCRIPTION = "📱 Подписка"
|
||||
MENU_TRIAL = "🎁 Тестовая подписка"
|
||||
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
|
||||
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
|
||||
MENU_PROMOCODE = "🎫 Промокод"
|
||||
MENU_REFERRALS = "🤝 Рефералы"
|
||||
MENU_SUPPORT = "🛠️ Техподдержка"
|
||||
MENU_RULES = "📋 Правила сервиса"
|
||||
MENU_LANGUAGE = "🌐 Язык"
|
||||
MENU_ADMIN = "⚙️ Админ-панель"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🎁 Тестовая подписка"
|
||||
SUBSCRIPTION_ACTIVE = "✅ Активна"
|
||||
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
|
||||
|
||||
SUBSCRIPTION_INFO = """
|
||||
📱 <b>Информация о подписке</b>
|
||||
|
||||
📊 <b>Статус:</b> {status}
|
||||
🎭 <b>Тип:</b> {type}
|
||||
📅 <b>Действует до:</b> {end_date}
|
||||
⏰ <b>Осталось дней:</b> {days_left}
|
||||
|
||||
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
|
||||
🌍 <b>Серверы:</b> {countries_count} стран
|
||||
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
"""
|
||||
|
||||
TRIAL_AVAILABLE = """
|
||||
🎁 <b>Тестовая подписка</b>
|
||||
|
||||
Вы можете получить бесплатную тестовую подписку:
|
||||
|
||||
⏰ <b>Период:</b> {days} дней
|
||||
📈 <b>Трафик:</b> {traffic} ГБ
|
||||
📱 <b>Устройства:</b> {devices} шт.
|
||||
🌍 <b>Сервер:</b> 1 страна
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
|
||||
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
|
||||
|
||||
BUY_SUBSCRIPTION_START = """
|
||||
💎 <b>Настройка подписки</b>
|
||||
|
||||
Давайте настроим вашу подписку под ваши потребности.
|
||||
|
||||
Сначала выберите период подписки:
|
||||
"""
|
||||
|
||||
SELECT_PERIOD = "Выберите период:"
|
||||
SELECT_TRAFFIC = "Выберите пакет трафика:"
|
||||
SELECT_COUNTRIES = "Выберите страны:"
|
||||
SELECT_DEVICES = "Количество устройств:"
|
||||
|
||||
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
|
||||
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
|
||||
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
|
||||
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
|
||||
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
|
||||
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
|
||||
|
||||
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
|
||||
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
|
||||
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
|
||||
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
|
||||
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
|
||||
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
|
||||
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
|
||||
|
||||
SUBSCRIPTION_SUMMARY = """
|
||||
📋 <b>Итоговая конфигурация</b>
|
||||
|
||||
📅 <b>Период:</b> {period} дней
|
||||
📈 <b>Трафик:</b> {traffic}
|
||||
🌍 <b>Страны:</b> {countries}
|
||||
📱 <b>Устройства:</b> {devices}
|
||||
|
||||
💰 <b>Итого к оплате:</b> {total_price}
|
||||
|
||||
Подтвердить покупку?
|
||||
"""
|
||||
|
||||
INSUFFICIENT_BALANCE = "❌ Недостаточно средств на балансе. Пополните баланс и попробуйте снова."
|
||||
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
|
||||
|
||||
BALANCE_INFO = """
|
||||
💰 <b>Баланс: {balance}</b>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
BALANCE_HISTORY = "📊 История операций"
|
||||
BALANCE_TOP_UP = "💳 Пополнить"
|
||||
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
|
||||
|
||||
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
|
||||
TOP_UP_METHODS = """
|
||||
💳 <b>Выберите способ оплаты</b>
|
||||
|
||||
Сумма: {amount}
|
||||
"""
|
||||
|
||||
TOP_UP_STARS = "⭐ Telegram Stars"
|
||||
TOP_UP_TRIBUTE = "💎 Банковская карта"
|
||||
|
||||
PROMOCODE_ENTER = "🎫 Введите промокод:"
|
||||
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
|
||||
PROMOCODE_INVALID = "❌ Неверный промокод"
|
||||
PROMOCODE_EXPIRED = "❌ Промокод истек"
|
||||
PROMOCODE_USED = "❌ Промокод уже использован"
|
||||
|
||||
REFERRAL_INFO = """
|
||||
🤝 <b>Реферальная программа</b>
|
||||
|
||||
👥 <b>Приглашено:</b> {referrals_count} друзей
|
||||
💰 <b>Заработано:</b> {earned_amount}
|
||||
|
||||
🔗 <b>Ваша реферальная ссылка:</b>
|
||||
<code>{referral_link}</code>
|
||||
|
||||
🎫 <b>Ваш промокод:</b>
|
||||
<code>{referral_code}</code>
|
||||
|
||||
💰 <b>Условия:</b>
|
||||
• За каждого друга: {registration_bonus}
|
||||
• Процент с пополнений: {commission_percent}%
|
||||
"""
|
||||
|
||||
REFERRAL_INVITE_MESSAGE = """
|
||||
🎯 <b>Приглашение в VPN сервис</b>
|
||||
|
||||
Привет! Приглашаю тебя в отличный VPN сервис!
|
||||
|
||||
🎁 По моей ссылке ты получишь бонус: {bonus}
|
||||
|
||||
🔗 Переходи: {link}
|
||||
🎫 Или используй промокод: {code}
|
||||
|
||||
💪 Быстро, надежно, недорого!
|
||||
"""
|
||||
|
||||
CREATE_INVITE = "📝 Создать приглашение"
|
||||
|
||||
TRIAL_ENDING_SOON = """
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {price}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRING_PAID = """
|
||||
⚠️ <b>Подписка истекает через {days} дней!</b>
|
||||
|
||||
Ваша платная подписка истекает {end_date}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
|
||||
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
|
||||
Новая дата окончания: {new_end_date}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
|
||||
💰 Ваш баланс: {balance}
|
||||
💳 Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
SUPPORT_INFO = f"""
|
||||
🛠️ <b>Техническая поддержка</b>
|
||||
|
||||
По всем вопросам обращайтесь к нашей поддержке:
|
||||
|
||||
👤 {settings.SUPPORT_USERNAME}
|
||||
|
||||
Мы поможем с:
|
||||
• Настройкой подключения
|
||||
• Решением технических проблем
|
||||
• Вопросами по оплате
|
||||
• Другими вопросами
|
||||
|
||||
⏰ Время ответа: обычно в течение 1-2 часов
|
||||
"""
|
||||
|
||||
CONTACT_SUPPORT = "💬 Написать в поддержку"
|
||||
|
||||
ADMIN_PANEL = """
|
||||
⚙️ <b>Административная панель</b>
|
||||
|
||||
Выберите раздел для управления:
|
||||
"""
|
||||
|
||||
ADMIN_USERS = "👥 Пользователи"
|
||||
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
|
||||
ADMIN_PROMOCODES = "🎫 Промокоды"
|
||||
ADMIN_MESSAGES = "📨 Рассылки"
|
||||
ADMIN_MONITORING = "🔍 Мониторинг"
|
||||
ADMIN_REFERRALS = "🤝 Рефералы"
|
||||
ADMIN_RULES = "📋 Правила"
|
||||
ADMIN_REMNAWAVE = "🖥️ RemnaWave"
|
||||
ADMIN_STATISTICS = "📊 Статистика"
|
||||
|
||||
ACCESS_DENIED = "❌ Доступ запрещен"
|
||||
USER_NOT_FOUND = "❌ Пользователь не найден"
|
||||
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
|
||||
INVALID_AMOUNT = "❌ Неверная сумма"
|
||||
OPERATION_CANCELLED = "❌ Операция отменена"
|
||||
|
||||
SUBSCRIPTION_EXPIRING = """
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days} дней.
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
Недостаточно средств на балансе: {balance}
|
||||
Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
|
||||
class EnglishTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("en")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Welcome to VPN Service!</b>
|
||||
|
||||
Our service provides fast and secure internet access without restrictions.
|
||||
|
||||
🔐 <b>Advantages:</b>
|
||||
• High connection speed
|
||||
• Servers in different countries
|
||||
• Reliable data protection
|
||||
• 24/7 support
|
||||
|
||||
To get started, select interface language:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
|
||||
|
||||
BACK = "⬅️ Back"
|
||||
CANCEL = "❌ Cancel"
|
||||
CONFIRM = "✅ Confirm"
|
||||
CONTINUE = "➡️ Continue"
|
||||
YES = "✅ Yes"
|
||||
NO = "❌ No"
|
||||
|
||||
MENU_BALANCE = "💰 Balance"
|
||||
MENU_SUBSCRIPTION = "📱 Subscription"
|
||||
MENU_TRIAL = "🎁 Trial subscription"
|
||||
|
||||
|
||||
LANGUAGES = {
|
||||
"ru": RussianTexts,
|
||||
"en": EnglishTexts
|
||||
}
|
||||
|
||||
|
||||
def get_texts(language: str = "ru") -> Texts:
|
||||
return LANGUAGES.get(language, RussianTexts)()
|
||||
|
||||
async def get_rules_from_db(language: str = "ru") -> str:
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
async for db in get_db():
|
||||
rules = await get_current_rules_content(db, language)
|
||||
if rules:
|
||||
_cached_rules[language] = rules
|
||||
return rules
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил из БД: {e}")
|
||||
|
||||
default_rules = _get_default_rules(language)
|
||||
_cached_rules[language] = default_rules
|
||||
return default_rules
|
||||
|
||||
def get_rules_sync(language: str = "ru") -> str:
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
rules = loop.run_until_complete(get_rules_from_db(language))
|
||||
loop.close()
|
||||
return rules
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил: {e}")
|
||||
return _get_default_rules(language)
|
||||
|
||||
async def refresh_rules_cache(language: str = "ru"):
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
del _cached_rules[language]
|
||||
|
||||
await get_rules_from_db(language)
|
||||
print(f"✅ Кеш правил для языка {language} обновлен")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка обновления кеша правил: {e}")
|
||||
|
||||
def clear_rules_cache():
|
||||
global _cached_rules
|
||||
_cached_rules.clear()
|
||||
print("✅ Кеш правил очищен")
|
||||
93
app/middlewares/auth.py
Normal file
93
app/middlewares/auth.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.states import RegistrationStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user:
|
||||
return await handler(event, data)
|
||||
|
||||
if user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
db_user = await get_user_by_telegram_id(db, user.id)
|
||||
|
||||
if not db_user:
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (isinstance(event, CallbackQuery) and current_state and
|
||||
any(str(state) in str(current_state) for state in registration_states))
|
||||
or (isinstance(event, CallbackQuery) and event.data and
|
||||
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
|
||||
)
|
||||
|
||||
if is_registration_process:
|
||||
logger.info(f"🔓 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
data['is_admin'] = False
|
||||
return await handler(event, data)
|
||||
else:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(
|
||||
"◀️ Для начала работы необходимо выполнить команду /start"
|
||||
)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
"◀️ Необходимо начать с команды /start",
|
||||
show_alert=True
|
||||
)
|
||||
logger.info(f"🚫 Заблокирован незарегистрированный пользователь {user.id}")
|
||||
return
|
||||
else:
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
data['db'] = db
|
||||
data['db_user'] = db_user
|
||||
data['is_admin'] = settings.is_admin(user.id)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в AuthMiddleware: {e}")
|
||||
logger.error(f"Event type: {type(event)}")
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f"Callback data: {event.data}")
|
||||
await db.rollback()
|
||||
raise
|
||||
42
app/middlewares/logging.py
Normal file
42
app/middlewares/logging.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
|
||||
text = event.text or event.caption or "[медиа]"
|
||||
logger.info(f"📩 Сообщение от {user_info}: {text}")
|
||||
|
||||
elif isinstance(event, CallbackQuery):
|
||||
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
|
||||
logger.info(f"🔘 Callback от {user_info}: {event.data}")
|
||||
|
||||
result = await handler(event, data)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
if execution_time > 1.0:
|
||||
logger.warning(f"⏱️ Медленная операция: {execution_time:.2f}s")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
execution_time = time.time() - start_time
|
||||
logger.error(f"❌ Ошибка при обработке события за {execution_time:.2f}s: {e}")
|
||||
raise
|
||||
53
app/middlewares/throttling.py
Normal file
53
app/middlewares/throttling.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, rate_limit: float = 0.5):
|
||||
self.rate_limit = rate_limit
|
||||
self.user_buckets: Dict[int, float] = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
user_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user_id = event.from_user.id
|
||||
|
||||
if not user_id:
|
||||
return await handler(event, data)
|
||||
|
||||
now = time.time()
|
||||
last_call = self.user_buckets.get(user_id, 0)
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f"🚫 Throttling для пользователя {user_id}")
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
|
||||
|
||||
return
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
cleanup_threshold = now - 60
|
||||
self.user_buckets = {
|
||||
uid: timestamp
|
||||
for uid, timestamp in self.user_buckets.items()
|
||||
if timestamp > cleanup_threshold
|
||||
}
|
||||
|
||||
return await handler(event, data)
|
||||
3
app/services/__init__.py
Normal file
3
app/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Сервисы бизнес-логики
|
||||
"""
|
||||
621
app/services/monitoring_service.py
Normal file
621
app/services/monitoring_service.py
Normal file
@@ -0,0 +1,621 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Set
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.subscription import (
|
||||
get_expired_subscriptions, get_expiring_subscriptions,
|
||||
get_subscriptions_for_autopay, deactivate_subscription,
|
||||
extend_subscription
|
||||
)
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_inactive_users, delete_user,
|
||||
subtract_user_balance
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
|
||||
def __init__(self, bot=None):
|
||||
self.is_running = False
|
||||
self.subscription_service = SubscriptionService()
|
||||
self.payment_service = PaymentService()
|
||||
self.bot = bot
|
||||
self._notified_users: Set[str] = set() # Защита от дублирования уведомлений
|
||||
|
||||
async def start_monitoring(self):
|
||||
if self.is_running:
|
||||
logger.warning("Мониторинг уже запущен")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
logger.info("🔄 Запуск службы мониторинга")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
await self._monitoring_cycle()
|
||||
await asyncio.sleep(settings.MONITORING_INTERVAL * 60)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
def stop_monitoring(self):
|
||||
self.is_running = False
|
||||
logger.info("ℹ️ Мониторинг остановлен")
|
||||
|
||||
async def _monitoring_cycle(self):
|
||||
async for db in get_db():
|
||||
try:
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db) # Новый метод!
|
||||
await self._process_autopayments(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
# Очищаем кеш уведомлений каждые 24 часа
|
||||
current_hour = datetime.utcnow().hour
|
||||
if current_hour == 0:
|
||||
self._notified_users.clear()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "monitoring_cycle_completed",
|
||||
"Цикл мониторинга успешно завершен",
|
||||
{"timestamp": datetime.utcnow().isoformat()}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await self._log_monitoring_event(
|
||||
db, "monitoring_cycle_error",
|
||||
f"Ошибка в цикле мониторинга: {str(e)}",
|
||||
{"error": str(e)},
|
||||
is_success=False
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _check_expired_subscriptions(self, db: AsyncSession):
|
||||
"""Проверка истекших подписок"""
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
await deactivate_subscription(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
# Отправляем уведомление об истечении
|
||||
if user and self.bot:
|
||||
await self._send_subscription_expired_notification(user)
|
||||
|
||||
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и деактивирована")
|
||||
|
||||
if expired_subscriptions:
|
||||
await self._log_monitoring_event(
|
||||
db, "expired_subscriptions_processed",
|
||||
f"Обработано {len(expired_subscriptions)} истекших подписок",
|
||||
{"count": len(expired_subscriptions)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истекших подписок: {e}")
|
||||
|
||||
async def _check_expiring_subscriptions(self, db: AsyncSession):
|
||||
"""Проверка подписок, истекающих через 2-3 дня (только платные)"""
|
||||
try:
|
||||
warning_days = settings.get_autopay_warning_days()
|
||||
|
||||
for days in warning_days:
|
||||
# Получаем только платные подписки
|
||||
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
|
||||
|
||||
for subscription in expiring_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d"
|
||||
if notification_key in self._notified_users:
|
||||
continue # Уже уведомляли сегодня
|
||||
|
||||
if self.bot:
|
||||
await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
self._notified_users.add(notification_key)
|
||||
|
||||
logger.info(f"⚠️ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
|
||||
if expiring_subscriptions:
|
||||
await self._log_monitoring_event(
|
||||
db, "expiring_notifications_sent",
|
||||
f"Отправлено {len(expiring_subscriptions)} уведомлений об истечении через {days} дней",
|
||||
{"days": days, "count": len(expiring_subscriptions)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истекающих подписок: {e}")
|
||||
|
||||
async def _check_trial_expiring_soon(self, db: AsyncSession):
|
||||
"""Проверка тестовых подписок, истекающих через 2 часа"""
|
||||
try:
|
||||
# Получаем тестовые подписки, истекающие через 2 часа
|
||||
threshold_time = datetime.utcnow() + timedelta(hours=2)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == True,
|
||||
Subscription.end_date <= threshold_time,
|
||||
Subscription.end_date > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
trial_expiring = result.scalars().all()
|
||||
|
||||
for subscription in trial_expiring:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"trial_2h_{user.telegram_id}"
|
||||
if notification_key in self._notified_users:
|
||||
continue # Уже уведомляли
|
||||
|
||||
if self.bot:
|
||||
await self._send_trial_ending_notification(user, subscription)
|
||||
self._notified_users.add(notification_key)
|
||||
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
|
||||
if trial_expiring:
|
||||
await self._log_monitoring_event(
|
||||
db, "trial_expiring_notifications_sent",
|
||||
f"Отправлено {len(trial_expiring)} уведомлений об окончании тестовых подписок",
|
||||
{"count": len(trial_expiring)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
|
||||
|
||||
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
|
||||
"""Получение платных подписок, истекающих через указанное количество дней"""
|
||||
threshold_date = datetime.utcnow() + timedelta(days=days_before)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == False, # Только платные
|
||||
Subscription.end_date <= threshold_date,
|
||||
Subscription.end_date > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def _process_autopayments(self, db: AsyncSession):
|
||||
"""Обработка автоплатежей"""
|
||||
try:
|
||||
# Исправленный запрос с использованием индивидуальных настроек
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.autopay_enabled == True,
|
||||
Subscription.is_trial == False # Автооплата только для платных
|
||||
)
|
||||
)
|
||||
)
|
||||
all_autopay_subscriptions = result.scalars().all()
|
||||
|
||||
# Фильтруем по времени с учетом индивидуальных настроек
|
||||
autopay_subscriptions = []
|
||||
for sub in all_autopay_subscriptions:
|
||||
days_before_expiry = (sub.end_date - current_time).days
|
||||
if days_before_expiry <= sub.autopay_days_before:
|
||||
autopay_subscriptions.append(sub)
|
||||
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
renewal_cost = settings.PRICE_30_DAYS
|
||||
|
||||
# Проверяем, не списывали ли уже сегодня
|
||||
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= renewal_cost:
|
||||
# Списываем средства
|
||||
success = await subtract_user_balance(
|
||||
db, user, renewal_cost,
|
||||
"Автопродление подписки"
|
||||
)
|
||||
|
||||
if success:
|
||||
# Продлеваем подписку
|
||||
await extend_subscription(db, subscription, 30)
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Уведомляем об успешном автоплатеже
|
||||
if self.bot:
|
||||
await self._send_autopay_success_notification(user, renewal_cost, 30)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
|
||||
else:
|
||||
failed_count += 1
|
||||
# Уведомляем о недостатке средств
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
|
||||
|
||||
if processed_count > 0 or failed_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "autopayments_processed",
|
||||
f"Автоплатежи: успешно {processed_count}, неудачно {failed_count}",
|
||||
{"processed": processed_count, "failed": failed_count}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки автоплатежей: {e}")
|
||||
|
||||
# Методы отправки уведомлений
|
||||
async def _send_subscription_expired_notification(self, user: User):
|
||||
"""Уведомление об истечении подписки"""
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.SUBSCRIPTION_EXPIRED
|
||||
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
|
||||
"""Уведомление об истечении подписки через N дней"""
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.SUBSCRIPTION_EXPIRING.format(days=days)
|
||||
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription):
|
||||
"""Уведомление об окончании тестовой подписки через 2 часа"""
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
# Создаем специальное сообщение для тестовой подписки
|
||||
message = f"""
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку со скидкой!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
# Добавляем inline клавиатуру с кнопкой покупки
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="buy_subscription")],
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_top_up")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
|
||||
"""Уведомление об успешном автоплатеже"""
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.AUTOPAY_SUCCESS.format(
|
||||
days=days,
|
||||
amount=settings.format_price(amount)
|
||||
)
|
||||
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_autopay_failed_notification(self, user: User, balance: int, required: int):
|
||||
"""Уведомление о неудачном автоплатеже"""
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.AUTOPAY_FAILED.format(
|
||||
balance=settings.format_price(balance),
|
||||
required=settings.format_price(required)
|
||||
)
|
||||
|
||||
# Добавляем кнопку пополнения баланса
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_top_up")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}")
|
||||
|
||||
# Остальные методы остаются без изменений...
|
||||
async def _cleanup_inactive_users(self, db: AsyncSession):
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
if now.hour != 3:
|
||||
return
|
||||
|
||||
inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS)
|
||||
deleted_count = 0
|
||||
|
||||
for user in inactive_users:
|
||||
if not user.subscription or not user.subscription.is_active:
|
||||
success = await delete_user(db, user)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
if deleted_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "inactive_users_cleanup",
|
||||
f"Удалено {deleted_count} неактивных пользователей",
|
||||
{"deleted_count": deleted_count}
|
||||
)
|
||||
logger.info(f"🗑️ Удалено {deleted_count} неактивных пользователей")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
|
||||
|
||||
async def _sync_with_remnawave(self, db: AsyncSession):
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
if now.minute != 0:
|
||||
return
|
||||
|
||||
async with self.subscription_service.api as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "remnawave_sync",
|
||||
"Синхронизация с RemnaWave завершена",
|
||||
{"stats": system_stats}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации с RemnaWave: {e}")
|
||||
await self._log_monitoring_event(
|
||||
db, "remnawave_sync_error",
|
||||
f"Ошибка синхронизации с RemnaWave: {str(e)}",
|
||||
{"error": str(e)},
|
||||
is_success=False
|
||||
)
|
||||
|
||||
async def _log_monitoring_event(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: str,
|
||||
message: str,
|
||||
data: Dict[str, Any] = None,
|
||||
is_success: bool = True
|
||||
):
|
||||
try:
|
||||
log_entry = MonitoringLog(
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
data=data or {},
|
||||
is_success=is_success
|
||||
)
|
||||
|
||||
db.add(log_entry)
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка логирования события мониторинга: {e}")
|
||||
|
||||
async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
recent_events_result = await db.execute(
|
||||
select(MonitoringLog)
|
||||
.order_by(desc(MonitoringLog.created_at))
|
||||
.limit(10)
|
||||
)
|
||||
recent_events = recent_events_result.scalars().all()
|
||||
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
|
||||
events_24h_result = await db.execute(
|
||||
select(MonitoringLog)
|
||||
.where(MonitoringLog.created_at >= yesterday)
|
||||
)
|
||||
events_24h = events_24h_result.scalars().all()
|
||||
|
||||
successful_events = sum(1 for event in events_24h if event.is_success)
|
||||
failed_events = sum(1 for event in events_24h if not event.is_success)
|
||||
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"last_update": datetime.utcnow(),
|
||||
"recent_events": [
|
||||
{
|
||||
"type": event.event_type,
|
||||
"message": event.message,
|
||||
"success": event.is_success,
|
||||
"created_at": event.created_at
|
||||
}
|
||||
for event in recent_events
|
||||
],
|
||||
"stats_24h": {
|
||||
"total_events": len(events_24h),
|
||||
"successful": successful_events,
|
||||
"failed": failed_events,
|
||||
"success_rate": round(successful_events / len(events_24h) * 100, 1) if events_24h else 0
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса мониторинга: {e}")
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"last_update": datetime.utcnow(),
|
||||
"recent_events": [],
|
||||
"stats_24h": {
|
||||
"total_events": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"success_rate": 0
|
||||
}
|
||||
}
|
||||
|
||||
async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
|
||||
try:
|
||||
# Проверяем истекшие
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
expired_count = 0
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
await deactivate_subscription(db, subscription)
|
||||
expired_count += 1
|
||||
|
||||
expiring_subscriptions = await get_expiring_subscriptions(db, 1)
|
||||
expiring_count = len(expiring_subscriptions)
|
||||
|
||||
autopay_subscriptions = await get_subscriptions_for_autopay(db)
|
||||
autopay_processed = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.balance_kopeks >= settings.PRICE_30_DAYS:
|
||||
autopay_processed += 1
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "manual_check_subscriptions",
|
||||
f"Принудительная проверка: истекло {expired_count}, истекает {expiring_count}, автоплатежей {autopay_processed}",
|
||||
{
|
||||
"expired": expired_count,
|
||||
"expiring": expiring_count,
|
||||
"autopay_ready": autopay_processed
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"expired": expired_count,
|
||||
"expiring": expiring_count,
|
||||
"autopay_ready": autopay_processed
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной проверки подписок: {e}")
|
||||
return {"expired": 0, "expiring": 0, "autopay_ready": 0}
|
||||
|
||||
async def get_monitoring_logs(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
event_type: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
query = select(MonitoringLog).order_by(desc(MonitoringLog.created_at))
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"event_type": log.event_type,
|
||||
"message": log.message,
|
||||
"data": log.data,
|
||||
"is_success": log.is_success,
|
||||
"created_at": log.created_at
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Удалено {deleted_count} старых записей логов")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
monitoring_service = MonitoringService()
|
||||
115
app/services/payment_service.py
Normal file
115
app/services/payment_service.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Optional
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentService:
|
||||
|
||||
def __init__(self, bot: Optional[Bot] = None):
|
||||
self.bot = bot
|
||||
|
||||
async def create_stars_invoice(
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
payload: Optional[str] = None
|
||||
) -> str:
|
||||
|
||||
if not self.bot:
|
||||
raise ValueError("Bot instance required for Stars payments")
|
||||
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title="Пополнение баланса VPN",
|
||||
description=description,
|
||||
payload=payload or f"balance_topup_{amount_kopeks}",
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд")
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
raise
|
||||
|
||||
async def create_tribute_payment(
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
description: str
|
||||
) -> str:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
raise ValueError("Tribute payments are disabled")
|
||||
|
||||
try:
|
||||
payment_data = {
|
||||
"amount": amount_kopeks,
|
||||
"currency": "RUB",
|
||||
"description": description,
|
||||
"user_id": user_id,
|
||||
"callback_url": f"{settings.WEBHOOK_URL}/tribute/callback"
|
||||
}
|
||||
|
||||
payment_url = f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}"
|
||||
|
||||
logger.info(f"Создан Tribute платеж на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
raise
|
||||
|
||||
def verify_tribute_webhook(
|
||||
self,
|
||||
data: dict,
|
||||
signature: str
|
||||
) -> bool:
|
||||
|
||||
if not settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
return False
|
||||
|
||||
try:
|
||||
message = str(data).encode()
|
||||
expected_signature = hmac.new(
|
||||
settings.TRIBUTE_WEBHOOK_SECRET.encode(),
|
||||
message,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки Tribute webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_successful_payment(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
payment_method: str
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
# Здесь должна быть логика обработки платежа
|
||||
# Например, пополнение баланса пользователя
|
||||
|
||||
logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки платежа: {e}")
|
||||
return False
|
||||
117
app/services/promocode_service.py
Normal file
117
app/services/promocode_service.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promocode import (
|
||||
get_promocode_by_code, use_promocode, check_user_promocode_usage,
|
||||
create_promocode_use, get_promocode_use_by_user_and_code
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PromoCodeService:
|
||||
|
||||
def __init__(self):
|
||||
self.remnawave_service = RemnaWaveService()
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def activate_promocode(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
code: str
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return {"success": False, "error": "user_not_found"}
|
||||
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
if not promocode:
|
||||
return {"success": False, "error": "not_found"}
|
||||
|
||||
if not promocode.is_valid:
|
||||
if promocode.current_uses >= promocode.max_uses:
|
||||
return {"success": False, "error": "used"}
|
||||
else:
|
||||
return {"success": False, "error": "expired"}
|
||||
|
||||
existing_use = await check_user_promocode_usage(db, user_id, promocode.id)
|
||||
if existing_use:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
|
||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
await mark_user_as_had_paid_subscription(db, user)
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
promocode.current_uses += 1
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"description": result_description
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации промокода {code} для пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return {"success": False, "error": "server_error"}
|
||||
|
||||
async def _apply_promocode_effects(self, db: AsyncSession, user: User, promocode: PromoCode) -> str:
|
||||
effects = []
|
||||
|
||||
if promocode.balance_bonus_kopeks > 0:
|
||||
await add_user_balance(
|
||||
db, user, promocode.balance_bonus_kopeks,
|
||||
f"Бонус по промокоду {promocode.code}"
|
||||
)
|
||||
|
||||
balance_bonus_rubles = promocode.balance_bonus_kopeks / 100
|
||||
effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}₽")
|
||||
|
||||
if promocode.subscription_days > 0:
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if subscription:
|
||||
await extend_subscription(db, subscription, promocode.subscription_days)
|
||||
effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней")
|
||||
else:
|
||||
await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=1,
|
||||
connected_squads=[]
|
||||
)
|
||||
effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней")
|
||||
|
||||
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
|
||||
from app.database.crud.subscription import create_trial_subscription
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if not subscription and not user.has_had_paid_subscription:
|
||||
await create_trial_subscription(db, user.id)
|
||||
effects.append("🎁 Активирована тестовая подписка")
|
||||
else:
|
||||
effects.append("ℹ️ Тестовая подписка уже недоступна")
|
||||
|
||||
return "\n".join(effects) if effects else "✅ Промокод активирован"
|
||||
174
app/services/referral_service.py
Normal file
174
app/services/referral_service.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.referral import create_referral_earning
|
||||
from app.database.models import TransactionType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_referral_registration(
|
||||
db: AsyncSession,
|
||||
new_user_id: int,
|
||||
referrer_id: int
|
||||
):
|
||||
|
||||
try:
|
||||
new_user = await get_user_by_id(db, new_user_id)
|
||||
referrer = await get_user_by_id(db, referrer_id)
|
||||
|
||||
if not new_user or not referrer:
|
||||
logger.error(f"Пользователи не найдены: {new_user_id}, {referrer_id}")
|
||||
return False
|
||||
|
||||
if new_user.referred_by_id != referrer_id:
|
||||
logger.error(f"Пользователь {new_user_id} не привязан к рефереру {referrer_id}")
|
||||
return False
|
||||
|
||||
if settings.REFERRED_USER_REWARD > 0:
|
||||
await add_user_balance(
|
||||
db, new_user, settings.REFERRED_USER_REWARD,
|
||||
f"Бонус за регистрацию по реферальной ссылке"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Новый пользователь {new_user_id} получил бонус {settings.REFERRED_USER_REWARD/100}₽")
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer_id,
|
||||
referral_id=new_user_id,
|
||||
amount_kopeks=0,
|
||||
reason="referral_registration_pending"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработана реферальная регистрация: {new_user_id} -> {referrer_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферальной регистрации: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def process_referral_purchase(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
purchase_amount_kopeks: int,
|
||||
transaction_id: int = None
|
||||
):
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
logger.info(f"Пользователь {user_id} не является рефералом")
|
||||
return False
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
from app.database.crud.referral import get_referral_earnings_by_referral
|
||||
existing_earnings = await get_referral_earnings_by_referral(db, user_id)
|
||||
|
||||
purchase_earnings = [
|
||||
earning for earning in existing_earnings
|
||||
if earning.reason in ["referral_first_purchase", "referral_commission"]
|
||||
]
|
||||
|
||||
is_first_purchase = len(purchase_earnings) == 0
|
||||
|
||||
logger.info(f"🔍 Покупка реферала {user_id}: первая = {is_first_purchase}, сумма = {purchase_amount_kopeks/100}₽")
|
||||
|
||||
if is_first_purchase:
|
||||
reward_amount = settings.REFERRAL_REGISTRATION_REWARD
|
||||
|
||||
await add_user_balance(
|
||||
db, referrer, reward_amount,
|
||||
f"Реферальная награда за первую покупку {user.full_name}"
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=reward_amount,
|
||||
reason="referral_first_purchase",
|
||||
referral_transaction_id=transaction_id
|
||||
)
|
||||
|
||||
logger.info(f"🎉 Первая покупка реферала: {referrer.telegram_id} получил {reward_amount/100}₽")
|
||||
|
||||
commission_amount = int(purchase_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с покупки {user.full_name}"
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission",
|
||||
referral_transaction_id=transaction_id
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с покупки: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
await db.commit()
|
||||
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки покупки реферала: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_referral_stats_for_user(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
try:
|
||||
from app.database.crud.referral import get_referral_earnings_sum
|
||||
from sqlalchemy import select, func
|
||||
from app.database.models import User
|
||||
|
||||
invited_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
invited_count = invited_count_result.scalar()
|
||||
|
||||
paid_referrals_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.referred_by_id == user_id,
|
||||
User.has_had_paid_subscription == True
|
||||
)
|
||||
)
|
||||
paid_referrals_count = paid_referrals_result.scalar()
|
||||
|
||||
total_earned = await get_referral_earnings_sum(db, user_id)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
return {
|
||||
"invited_count": invited_count,
|
||||
"paid_referrals_count": paid_referrals_count,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"month_earned_kopeks": month_earned
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики рефералов: {e}")
|
||||
return {
|
||||
"invited_count": 0,
|
||||
"paid_referrals_count": 0,
|
||||
"total_earned_kopeks": 0,
|
||||
"month_earned_kopeks": 0
|
||||
}
|
||||
868
app/services/remnawave_service.py
Normal file
868
app/services/remnawave_service.py
Normal file
@@ -0,0 +1,868 @@
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage
|
||||
from app.database.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemnaWaveService:
|
||||
|
||||
def __init__(self):
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY
|
||||
)
|
||||
|
||||
async def get_system_statistics(self) -> Dict[str, Any]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
logger.info("Получение системной статистики RemnaWave...")
|
||||
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
logger.info(f"Системная статистика получена")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения системной статистики: {e}")
|
||||
system_stats = {}
|
||||
|
||||
try:
|
||||
bandwidth_stats = await api.get_bandwidth_stats()
|
||||
logger.info(f"Статистика трафика получена")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики трафика: {e}")
|
||||
bandwidth_stats = {}
|
||||
|
||||
try:
|
||||
realtime_usage = await api.get_nodes_realtime_usage()
|
||||
logger.info(f"Реалтайм статистика получена")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения реалтайм статистики: {e}")
|
||||
realtime_usage = []
|
||||
|
||||
try:
|
||||
nodes_stats = await api.get_nodes_statistics()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики нод: {e}")
|
||||
nodes_stats = {}
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
total_download = sum(node.get('downloadBytes', 0) for node in realtime_usage)
|
||||
total_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage)
|
||||
total_realtime_traffic = total_download + total_upload
|
||||
|
||||
total_user_traffic = int(system_stats.get('users', {}).get('totalTrafficBytes', '0'))
|
||||
|
||||
nodes_weekly_data = []
|
||||
if nodes_stats.get('lastSevenDays'):
|
||||
nodes_by_name = {}
|
||||
for day_data in nodes_stats['lastSevenDays']:
|
||||
node_name = day_data['nodeName']
|
||||
if node_name not in nodes_by_name:
|
||||
nodes_by_name[node_name] = {
|
||||
'name': node_name,
|
||||
'total_bytes': 0,
|
||||
'days_data': []
|
||||
}
|
||||
|
||||
daily_bytes = int(day_data['totalBytes'])
|
||||
nodes_by_name[node_name]['total_bytes'] += daily_bytes
|
||||
nodes_by_name[node_name]['days_data'].append({
|
||||
'date': day_data['date'],
|
||||
'bytes': daily_bytes
|
||||
})
|
||||
|
||||
nodes_weekly_data = list(nodes_by_name.values())
|
||||
nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True)
|
||||
|
||||
result = {
|
||||
"system": {
|
||||
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||
"total_users": system_stats.get('users', {}).get('totalUsers', 0),
|
||||
"active_connections": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||
"nodes_online": system_stats.get('nodes', {}).get('totalOnline', 0),
|
||||
"users_last_day": system_stats.get('onlineStats', {}).get('lastDay', 0),
|
||||
"users_last_week": system_stats.get('onlineStats', {}).get('lastWeek', 0),
|
||||
"users_never_online": system_stats.get('onlineStats', {}).get('neverOnline', 0),
|
||||
"total_user_traffic": total_user_traffic
|
||||
},
|
||||
"users_by_status": system_stats.get('users', {}).get('statusCounts', {}),
|
||||
"server_info": {
|
||||
"cpu_cores": system_stats.get('cpu', {}).get('cores', 0),
|
||||
"cpu_physical_cores": system_stats.get('cpu', {}).get('physicalCores', 0),
|
||||
"memory_total": system_stats.get('memory', {}).get('total', 0),
|
||||
"memory_used": system_stats.get('memory', {}).get('used', 0),
|
||||
"memory_free": system_stats.get('memory', {}).get('free', 0),
|
||||
"memory_available": system_stats.get('memory', {}).get('available', 0),
|
||||
"uptime_seconds": system_stats.get('uptime', 0)
|
||||
},
|
||||
"bandwidth": {
|
||||
"realtime_download": total_download,
|
||||
"realtime_upload": total_upload,
|
||||
"realtime_total": total_realtime_traffic
|
||||
},
|
||||
"traffic_periods": {
|
||||
"last_2_days": {
|
||||
"current": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthLastTwoDays', {}).get('current', '0 B')
|
||||
),
|
||||
"previous": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthLastTwoDays', {}).get('previous', '0 B')
|
||||
),
|
||||
"difference": bandwidth_stats.get('bandwidthLastTwoDays', {}).get('difference', '0 B')
|
||||
},
|
||||
"last_7_days": {
|
||||
"current": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthLastSevenDays', {}).get('current', '0 B')
|
||||
),
|
||||
"previous": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthLastSevenDays', {}).get('previous', '0 B')
|
||||
),
|
||||
"difference": bandwidth_stats.get('bandwidthLastSevenDays', {}).get('difference', '0 B')
|
||||
},
|
||||
"last_30_days": {
|
||||
"current": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthLast30Days', {}).get('current', '0 B')
|
||||
),
|
||||
"previous": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthLast30Days', {}).get('previous', '0 B')
|
||||
),
|
||||
"difference": bandwidth_stats.get('bandwidthLast30Days', {}).get('difference', '0 B')
|
||||
},
|
||||
"current_month": {
|
||||
"current": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthCalendarMonth', {}).get('current', '0 B')
|
||||
),
|
||||
"previous": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthCalendarMonth', {}).get('previous', '0 B')
|
||||
),
|
||||
"difference": bandwidth_stats.get('bandwidthCalendarMonth', {}).get('difference', '0 B')
|
||||
},
|
||||
"current_year": {
|
||||
"current": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthCurrentYear', {}).get('current', '0 B')
|
||||
),
|
||||
"previous": self._parse_bandwidth_string(
|
||||
bandwidth_stats.get('bandwidthCurrentYear', {}).get('previous', '0 B')
|
||||
),
|
||||
"difference": bandwidth_stats.get('bandwidthCurrentYear', {}).get('difference', '0 B')
|
||||
}
|
||||
},
|
||||
"nodes_realtime": realtime_usage,
|
||||
"nodes_weekly": nodes_weekly_data,
|
||||
"last_updated": datetime.now()
|
||||
}
|
||||
|
||||
logger.info(f"Статистика сформирована: пользователи={result['system']['total_users']}, общий трафик={total_user_traffic}")
|
||||
return result
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API при получении статистики: {e}")
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Общая ошибка получения системной статистики: {e}")
|
||||
return {"error": f"Внутренняя ошибка сервера: {str(e)}"}
|
||||
|
||||
|
||||
def _parse_bandwidth_string(self, bandwidth_str: str) -> int:
|
||||
try:
|
||||
if not bandwidth_str or bandwidth_str == '0 B' or bandwidth_str == '0':
|
||||
return 0
|
||||
|
||||
bandwidth_str = bandwidth_str.replace(' ', '').upper()
|
||||
|
||||
units = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 ** 2,
|
||||
'GB': 1024 ** 3,
|
||||
'TB': 1024 ** 4,
|
||||
'KIB': 1024,
|
||||
'MIB': 1024 ** 2,
|
||||
'GIB': 1024 ** 3,
|
||||
'TIB': 1024 ** 4,
|
||||
'KBPS': 1024,
|
||||
'MBPS': 1024 ** 2,
|
||||
'GBPS': 1024 ** 3
|
||||
}
|
||||
|
||||
import re
|
||||
match = re.match(r'([0-9.,]+)([A-Z]+)', bandwidth_str)
|
||||
if match:
|
||||
value_str = match.group(1).replace(',', '.')
|
||||
value = float(value_str)
|
||||
unit = match.group(2)
|
||||
|
||||
if unit in units:
|
||||
result = int(value * units[unit])
|
||||
logger.debug(f"Парсинг '{bandwidth_str}': {value} {unit} = {result} байт")
|
||||
return result
|
||||
else:
|
||||
logger.warning(f"Неизвестная единица измерения: {unit}")
|
||||
|
||||
logger.warning(f"Не удалось распарсить строку трафика: '{bandwidth_str}'")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка парсинга строки трафика '{bandwidth_str}': {e}")
|
||||
return 0
|
||||
|
||||
async def get_all_nodes(self) -> List[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
result = []
|
||||
for node in nodes:
|
||||
result.append({
|
||||
'uuid': node.uuid,
|
||||
'name': node.name,
|
||||
'address': node.address,
|
||||
'country_code': node.country_code,
|
||||
'is_connected': node.is_connected,
|
||||
'is_disabled': node.is_disabled,
|
||||
'is_node_online': node.is_node_online,
|
||||
'is_xray_running': node.is_xray_running,
|
||||
'users_online': node.users_online,
|
||||
'traffic_used_bytes': node.traffic_used_bytes,
|
||||
'traffic_limit_bytes': node.traffic_limit_bytes
|
||||
})
|
||||
|
||||
logger.info(f"✅ Получено {len(result)} нод из RemnaWave")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения нод из RemnaWave: {e}")
|
||||
return []
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
stats = await api.get_system_stats()
|
||||
logger.info("✅ Соединение с RemnaWave API работает")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка соединения с RemnaWave API: {e}")
|
||||
return False
|
||||
|
||||
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
node = await api.get_node_by_uuid(node_uuid)
|
||||
|
||||
if not node:
|
||||
return None
|
||||
|
||||
return {
|
||||
"uuid": node.uuid,
|
||||
"name": node.name,
|
||||
"address": node.address,
|
||||
"country_code": node.country_code,
|
||||
"is_connected": node.is_connected,
|
||||
"is_disabled": node.is_disabled,
|
||||
"is_node_online": node.is_node_online,
|
||||
"is_xray_running": node.is_xray_running,
|
||||
"users_online": node.users_online or 0,
|
||||
"traffic_used_bytes": node.traffic_used_bytes or 0,
|
||||
"traffic_limit_bytes": node.traffic_limit_bytes or 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о ноде {node_uuid}: {e}")
|
||||
return None
|
||||
|
||||
async def manage_node(self, node_uuid: str, action: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
if action == "enable":
|
||||
await api.enable_node(node_uuid)
|
||||
elif action == "disable":
|
||||
await api.disable_node(node_uuid)
|
||||
elif action == "restart":
|
||||
await api.restart_node(node_uuid)
|
||||
else:
|
||||
return False
|
||||
|
||||
logger.info(f"✅ Действие {action} выполнено для ноды {node_uuid}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка управления нодой {node_uuid}: {e}")
|
||||
return False
|
||||
|
||||
async def restart_all_nodes(self) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
result = await api.restart_all_nodes()
|
||||
|
||||
if result:
|
||||
logger.info("✅ Команда перезагрузки всех нод отправлена")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка перезагрузки всех нод: {e}")
|
||||
return False
|
||||
|
||||
async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'inbounds': inbound_uuids
|
||||
}
|
||||
response = await api._make_request('PATCH', '/api/internal-squads', data)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating squad inbounds: {e}")
|
||||
return False
|
||||
|
||||
async def get_all_squads(self) -> List[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
squads = await api.get_internal_squads()
|
||||
|
||||
result = []
|
||||
for squad in squads:
|
||||
result.append({
|
||||
'uuid': squad.uuid,
|
||||
'name': squad.name,
|
||||
'members_count': squad.members_count,
|
||||
'inbounds_count': squad.inbounds_count,
|
||||
'inbounds': squad.inbounds
|
||||
})
|
||||
|
||||
logger.info(f"✅ Получено {len(result)} сквадов из RemnaWave")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сквадов из RemnaWave: {e}")
|
||||
return []
|
||||
|
||||
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
squad = await api.create_internal_squad(name, inbounds)
|
||||
|
||||
logger.info(f"✅ Создан новый сквад: {name}")
|
||||
return squad.uuid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания сквада {name}: {e}")
|
||||
return None
|
||||
|
||||
async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
await api.update_internal_squad(uuid, name, inbounds)
|
||||
|
||||
logger.info(f"✅ Обновлен сквад {uuid}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления сквада {uuid}: {e}")
|
||||
return False
|
||||
|
||||
async def delete_squad(self, uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
result = await api.delete_internal_squad(uuid)
|
||||
|
||||
if result:
|
||||
logger.info(f"✅ Удален сквад {uuid}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления сквада {uuid}: {e}")
|
||||
return False
|
||||
|
||||
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
|
||||
try:
|
||||
stats = {"created": 0, "updated": 0, "errors": 0}
|
||||
|
||||
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
|
||||
|
||||
async with self.api as api:
|
||||
panel_users_data = await api._make_request('GET', '/api/users')
|
||||
panel_users = panel_users_data['response']['users']
|
||||
|
||||
logger.info(f"👥 Найдено пользователей в панели: {len(panel_users)}")
|
||||
|
||||
for i, panel_user in enumerate(panel_users):
|
||||
try:
|
||||
telegram_id = panel_user.get('telegramId')
|
||||
if not telegram_id:
|
||||
logger.debug(f"➡️ Пропускаем пользователя без telegram_id")
|
||||
continue
|
||||
|
||||
logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(panel_users)}: {telegram_id}")
|
||||
|
||||
db_user = await get_user_by_telegram_id(db, telegram_id)
|
||||
|
||||
if not db_user:
|
||||
if sync_type in ["new_only", "all"]:
|
||||
logger.info(f"📝 Создание пользователя для telegram_id {telegram_id}")
|
||||
|
||||
from app.database.crud.user import create_user
|
||||
|
||||
db_user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
username=panel_user.get('username') or f"user_{telegram_id}",
|
||||
first_name=f"Panel User {telegram_id}",
|
||||
language="ru"
|
||||
)
|
||||
|
||||
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
|
||||
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
stats["created"] += 1
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с подпиской")
|
||||
|
||||
else:
|
||||
if sync_type in ["update_only", "all"]:
|
||||
logger.debug(f"🔄 Обновление пользователя {telegram_id}")
|
||||
|
||||
if not db_user.remnawave_uuid:
|
||||
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
|
||||
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
stats["updated"] += 1
|
||||
logger.debug(f"✅ Обновлён пользователь {telegram_id}")
|
||||
|
||||
except Exception as user_error:
|
||||
logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}")
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}")
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка синхронизации пользователей: {e}")
|
||||
return {"created": 0, "updated": 0, "errors": 1}
|
||||
|
||||
async def _create_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
|
||||
try:
|
||||
from app.database.crud.subscription import create_subscription
|
||||
from app.database.models import SubscriptionStatus
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
|
||||
expire_at_str = panel_user.get('expireAt', '')
|
||||
try:
|
||||
if expire_at_str:
|
||||
if expire_at_str.endswith('Z'):
|
||||
expire_at_str = expire_at_str[:-1] + '+00:00'
|
||||
|
||||
expire_at = datetime.fromisoformat(expire_at_str)
|
||||
|
||||
if expire_at.tzinfo is not None:
|
||||
expire_at = expire_at.replace(tzinfo=None)
|
||||
|
||||
else:
|
||||
expire_at = datetime.utcnow() + timedelta(days=30)
|
||||
except Exception as date_error:
|
||||
logger.warning(f"⚠️ Ошибка парсинга даты {expire_at_str}: {date_error}")
|
||||
expire_at = datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
panel_status = panel_user.get('status', 'ACTIVE')
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if panel_status == 'ACTIVE' and expire_at > current_time:
|
||||
status = SubscriptionStatus.ACTIVE
|
||||
elif expire_at <= current_time:
|
||||
status = SubscriptionStatus.EXPIRED
|
||||
else:
|
||||
status = SubscriptionStatus.DISABLED
|
||||
|
||||
traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0)
|
||||
traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0
|
||||
|
||||
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
|
||||
traffic_used_gb = used_traffic_bytes / (1024**3)
|
||||
|
||||
active_squads = panel_user.get('activeInternalSquads', [])
|
||||
squad_uuids = []
|
||||
if isinstance(active_squads, list):
|
||||
for squad in active_squads:
|
||||
if isinstance(squad, dict) and 'uuid' in squad:
|
||||
squad_uuids.append(squad['uuid'])
|
||||
elif isinstance(squad, str):
|
||||
squad_uuids.append(squad)
|
||||
|
||||
subscription_data = {
|
||||
'user_id': user.id,
|
||||
'status': status.value,
|
||||
'is_trial': False,
|
||||
'end_date': expire_at,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'traffic_used_gb': traffic_used_gb,
|
||||
'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1,
|
||||
'connected_squads': squad_uuids,
|
||||
'remnawave_short_uuid': panel_user.get('shortUuid'),
|
||||
'subscription_url': panel_user.get('subscriptionUrl', '')
|
||||
}
|
||||
|
||||
subscription = await create_subscription(db, **subscription_data)
|
||||
logger.info(f"✅ Создана подписка для пользователя {user.telegram_id} до {expire_at}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка создания подписки для пользователя {user.telegram_id}: {e}")
|
||||
try:
|
||||
from app.database.crud.subscription import create_subscription
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
basic_subscription = await create_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
traffic_limit_gb=0,
|
||||
traffic_used_gb=0.0,
|
||||
device_limit=1,
|
||||
connected_squads=[],
|
||||
remnawave_short_uuid=panel_user.get('shortUuid'),
|
||||
subscription_url=panel_user.get('subscriptionUrl', '')
|
||||
)
|
||||
logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}")
|
||||
except Exception as basic_error:
|
||||
logger.error(f"❌ Ошибка создания базовой подписки: {basic_error}")
|
||||
|
||||
async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
|
||||
try:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not subscription:
|
||||
await self._create_subscription_from_panel_data(db, user, panel_user)
|
||||
return
|
||||
|
||||
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
|
||||
traffic_used_gb = used_traffic_bytes / (1024**3)
|
||||
|
||||
if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01:
|
||||
subscription.traffic_used_gb = traffic_used_gb
|
||||
|
||||
if not subscription.remnawave_short_uuid:
|
||||
subscription.remnawave_short_uuid = panel_user.get('shortUuid')
|
||||
|
||||
if not subscription.subscription_url:
|
||||
subscription.subscription_url = panel_user.get('subscriptionUrl', '')
|
||||
|
||||
active_squads = panel_user.get('activeInternalSquads', [])
|
||||
squad_uuids = []
|
||||
if isinstance(active_squads, list):
|
||||
for squad in active_squads:
|
||||
if isinstance(squad, dict) and 'uuid' in squad:
|
||||
squad_uuids.append(squad['uuid'])
|
||||
elif isinstance(squad, str):
|
||||
squad_uuids.append(squad)
|
||||
|
||||
if squad_uuids != subscription.connected_squads:
|
||||
subscription.connected_squads = squad_uuids
|
||||
|
||||
await db.commit()
|
||||
logger.debug(f"✅ Обновлена подписка для пользователя {user.telegram_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления подписки для пользователя {user.telegram_id}: {e}")
|
||||
|
||||
async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]:
|
||||
try:
|
||||
stats = {"created": 0, "updated": 0, "errors": 0}
|
||||
|
||||
users = await get_users_list(db, offset=0, limit=10000)
|
||||
|
||||
async with self.api as api:
|
||||
for user in users:
|
||||
if not user.subscription:
|
||||
continue
|
||||
|
||||
try:
|
||||
subscription = user.subscription
|
||||
|
||||
if user.remnawave_uuid:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
username = f"user_{user.telegram_id}"
|
||||
|
||||
new_user = await api.create_user(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
# Обновляем UUID в нашей базе
|
||||
await update_user(db, user, remnawave_uuid=new_user.uuid)
|
||||
subscription.remnawave_short_uuid = new_user.short_uuid
|
||||
await db.commit()
|
||||
|
||||
stats["created"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
|
||||
stats["errors"] += 1
|
||||
|
||||
logger.info(f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}")
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации пользователей в панель: {e}")
|
||||
return {"created": 0, "updated": 0, "errors": 1}
|
||||
|
||||
async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
users = await api.get_user_by_telegram_id(telegram_id)
|
||||
|
||||
if not users:
|
||||
return None
|
||||
|
||||
user = users[0]
|
||||
|
||||
return {
|
||||
"used_traffic_bytes": user.used_traffic_bytes,
|
||||
"used_traffic_gb": user.used_traffic_bytes / (1024**3),
|
||||
"lifetime_used_traffic_bytes": user.lifetime_used_traffic_bytes,
|
||||
"lifetime_used_traffic_gb": user.lifetime_used_traffic_bytes / (1024**3),
|
||||
"traffic_limit_bytes": user.traffic_limit_bytes,
|
||||
"traffic_limit_gb": user.traffic_limit_bytes / (1024**3) if user.traffic_limit_bytes > 0 else 0,
|
||||
"subscription_url": user.subscription_url
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики трафика для пользователя {telegram_id}: {e}")
|
||||
return None
|
||||
|
||||
async def test_api_connection(self) -> Dict[str, Any]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
return {
|
||||
"status": "connected",
|
||||
"message": "Подключение успешно",
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"system_info": system_stats
|
||||
}
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Ошибка API: {e.message}",
|
||||
"status_code": e.status_code,
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Ошибка подключения: {str(e)}",
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
usage_data = await api.get_nodes_realtime_usage()
|
||||
return usage_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения актуального использования нод: {e}")
|
||||
return []
|
||||
|
||||
async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
squad = await api.get_internal_squad_by_uuid(squad_uuid)
|
||||
if squad:
|
||||
return {
|
||||
'uuid': squad.uuid,
|
||||
'name': squad.name,
|
||||
'members_count': squad.members_count,
|
||||
'inbounds_count': squad.inbounds_count,
|
||||
'inbounds': squad.inbounds
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting squad details: {e}")
|
||||
return None
|
||||
|
||||
async def add_all_users_to_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding users to squad: {e}")
|
||||
return False
|
||||
|
||||
async def remove_all_users_from_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing users from squad: {e}")
|
||||
return False
|
||||
|
||||
async def delete_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
response = await api.delete_internal_squad(squad_uuid)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting squad: {e}")
|
||||
return False
|
||||
|
||||
async def get_all_inbounds(self) -> List[Dict]:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
response = await api._make_request('GET', '/api/config-profiles/inbounds')
|
||||
inbounds_data = response.get('response', {}).get('inbounds', [])
|
||||
|
||||
return [
|
||||
{
|
||||
'uuid': inbound['uuid'],
|
||||
'tag': inbound['tag'],
|
||||
'type': inbound['type'],
|
||||
'network': inbound.get('network'),
|
||||
'security': inbound.get('security'),
|
||||
'port': inbound.get('port')
|
||||
}
|
||||
for inbound in inbounds_data
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all inbounds: {e}")
|
||||
return []
|
||||
|
||||
async def rename_squad(self, squad_uuid: str, new_name: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'name': new_name
|
||||
}
|
||||
response = await api._make_request('PATCH', '/api/internal-squads', data)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error renaming squad: {e}")
|
||||
return False
|
||||
|
||||
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
squad = await api.create_internal_squad(name, inbound_uuids)
|
||||
return squad is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating squad: {e}")
|
||||
return False
|
||||
|
||||
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
start_str = start_date.isoformat() + "Z"
|
||||
end_str = end_date.isoformat() + "Z"
|
||||
|
||||
params = {
|
||||
'start': start_str,
|
||||
'end': end_str
|
||||
}
|
||||
|
||||
usage_data = await api._make_request(
|
||||
'GET',
|
||||
f'/api/nodes/usage/{node_uuid}/users/range',
|
||||
params=params
|
||||
)
|
||||
|
||||
return usage_data.get('response', [])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики использования ноды {node_uuid}: {e}")
|
||||
return []
|
||||
|
||||
async def get_node_statistics(self, node_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
node = await self.get_node_details(node_uuid)
|
||||
if not node:
|
||||
return None
|
||||
|
||||
realtime_stats = await self.get_nodes_realtime_usage()
|
||||
|
||||
node_realtime = None
|
||||
for stats in realtime_stats:
|
||||
if stats.get('nodeUuid') == node_uuid:
|
||||
node_realtime = stats
|
||||
break
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
usage_history = await self.get_node_user_usage_by_range(
|
||||
node_uuid, start_date, end_date
|
||||
)
|
||||
|
||||
return {
|
||||
'node': node,
|
||||
'realtime': node_realtime,
|
||||
'usage_history': usage_history,
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}")
|
||||
|
||||
async def validate_user_data_before_sync(self, panel_user) -> bool:
|
||||
try:
|
||||
if not panel_user.telegram_id:
|
||||
logger.debug(f"Нет telegram_id для пользователя {panel_user.uuid}")
|
||||
return False
|
||||
|
||||
if not panel_user.uuid:
|
||||
logger.debug(f"Нет UUID для пользователя {panel_user.telegram_id}")
|
||||
return False
|
||||
|
||||
if panel_user.telegram_id <= 0:
|
||||
logger.debug(f"Некорректный telegram_id: {panel_user.telegram_id}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка валидации данных пользователя: {e}")
|
||||
return False
|
||||
240
app/services/subscription_service.py
Normal file
240
app/services/subscription_service.py
Normal file
@@ -0,0 +1,240 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self):
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY
|
||||
)
|
||||
|
||||
async def create_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {subscription.user_id} не найден")
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
remnawave_user = existing_users[0]
|
||||
updated_user = await api.update_user(
|
||||
uuid=remnawave_user.uuid,
|
||||
status=UserStatus.ACTIVE,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
else:
|
||||
username = f"user_{user.telegram_id}"
|
||||
updated_user = await api.create_user(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
user.remnawave_uuid = updated_user.uuid
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Создан RemnaWave пользователь для подписки {subscription.id}")
|
||||
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
|
||||
logger.info(f"📊 Стратегия сброса трафика: MONTH")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid}")
|
||||
logger.info(f"📊 Стратегия сброса трафика: MONTH")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
await api.disable_user(user_uuid)
|
||||
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения RemnaWave пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def revoke_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[str]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
|
||||
return updated_user.subscription_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления ссылки подписки: {e}")
|
||||
return None
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
info = await api.get_subscription_info(short_uuid)
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о подписке: {e}")
|
||||
return None
|
||||
|
||||
async def sync_subscription_usage(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
return False
|
||||
|
||||
async with self.api as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if not remnawave_user:
|
||||
return False
|
||||
|
||||
used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes)
|
||||
subscription.traffic_used_gb = used_gb
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации трафика: {e}")
|
||||
return False
|
||||
|
||||
async def calculate_subscription_price(
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
traffic_price = TRAFFIC_PRICES.get(traffic_gb, 0)
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_prices.append(server.price_kopeks)
|
||||
total_servers_price += server.price_kopeks
|
||||
else:
|
||||
server_prices.append(0)
|
||||
|
||||
devices_price = max(0, devices - 1) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_price = base_price + traffic_price + total_servers_price + devices_price
|
||||
return total_price, server_prices
|
||||
|
||||
async def _get_countries_price(self, country_uuids: List[str]) -> int:
|
||||
# TODO: Реализовать получение цен из базы данных сквадов
|
||||
# Пока возвращаем базовую логику
|
||||
price_per_country = 1000
|
||||
return len(country_uuids) * price_per_country
|
||||
|
||||
def _gb_to_bytes(self, gb: int) -> int:
|
||||
if gb == 0:
|
||||
return 0
|
||||
return gb * 1024 * 1024 * 1024
|
||||
|
||||
def _bytes_to_gb(self, bytes_value: int) -> float:
|
||||
if bytes_value == 0:
|
||||
return 0.0
|
||||
return bytes_value / (1024 * 1024 * 1024)
|
||||
284
app/services/tribute_service.py
Normal file
284
app/services/tribute_service.py
Normal file
@@ -0,0 +1,284 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.models import Transaction, TransactionType, PaymentMethod
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction, get_transaction_by_external_id, complete_transaction
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TributeService:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.tribute_api = TributeAPI()
|
||||
|
||||
async def create_payment_link(
|
||||
self,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> Optional[str]:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
logger.warning("Tribute платежи отключены")
|
||||
return None
|
||||
|
||||
try:
|
||||
payment_url = await self.tribute_api.create_payment_link(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
return None
|
||||
|
||||
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
return None
|
||||
|
||||
async def process_webhook(
|
||||
self,
|
||||
payload: str,
|
||||
signature: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
if not self.tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.warning("Неверная подпись Tribute webhook")
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON в Tribute webhook")
|
||||
return {"status": "error", "reason": "invalid_json"}
|
||||
|
||||
logger.info(f"Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}")
|
||||
|
||||
processed_data = await self.tribute_api.process_webhook(webhook_data)
|
||||
if not processed_data:
|
||||
return {"status": "ignored", "reason": "invalid_data"}
|
||||
|
||||
event_type = processed_data.get("event_type", "payment")
|
||||
status = processed_data.get("status")
|
||||
|
||||
if event_type == "payment" and status == "paid":
|
||||
await self._handle_successful_payment(processed_data)
|
||||
elif event_type == "payment" and status == "failed":
|
||||
await self._handle_failed_payment(processed_data)
|
||||
elif event_type == "refund":
|
||||
await self._handle_refund(processed_data)
|
||||
|
||||
return {"status": "ok", "event": event_type}
|
||||
|
||||
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
amount_kopeks = payment_data["amount_kopeks"]
|
||||
payment_id = payment_data["payment_id"]
|
||||
|
||||
async for session in get_db():
|
||||
existing_transaction = await get_transaction_by_external_id(
|
||||
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
logger.warning(f"Транзакция с donation_request_id {payment_id} уже существует")
|
||||
return
|
||||
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден")
|
||||
return
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=session,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Tribute: {amount_kopeks/100}₽",
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=f"donation_{payment_id}",
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
await session.commit()
|
||||
|
||||
await self._send_success_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}")
|
||||
|
||||
async def _handle_failed_payment(self, payment_data: Dict[str, Any]):
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
payment_id = payment_data["payment_id"]
|
||||
|
||||
async for session in get_db():
|
||||
transaction = await get_transaction_by_external_id(
|
||||
session, payment_id, PaymentMethod.TRIBUTE
|
||||
)
|
||||
|
||||
if transaction:
|
||||
transaction.description = f"{transaction.description} (платеж отклонен)"
|
||||
await session.commit()
|
||||
|
||||
await self._send_failure_notification(user_id)
|
||||
|
||||
logger.info(f"Обработан неудачный Tribute платеж для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
|
||||
|
||||
async def _handle_refund(self, refund_data: Dict[str, Any]):
|
||||
|
||||
try:
|
||||
user_id = refund_data["user_id"]
|
||||
amount_kopeks = refund_data["amount_kopeks"]
|
||||
payment_id = refund_data["payment_id"]
|
||||
|
||||
async for session in get_db():
|
||||
await create_transaction(
|
||||
db=session,
|
||||
user_id=user_id,
|
||||
type=TransactionType.REFUND,
|
||||
amount_kopeks=-amount_kopeks,
|
||||
description=f"Возврат Tribute платежа {payment_id}",
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=f"refund_{payment_id}",
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if user and user.balance_kopeks >= amount_kopeks:
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
await session.commit()
|
||||
|
||||
await self._send_refund_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"Обработан возврат Tribute: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки возврата Tribute: {e}")
|
||||
|
||||
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
f"💰 Сумма: {amount_rubles:.2f} ₽\n"
|
||||
f"💳 Способ оплаты: Tribute\n"
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
async def _send_failure_notification(self, user_id: int):
|
||||
|
||||
try:
|
||||
text = (
|
||||
"❌ **Платеж не прошел**\n\n"
|
||||
"К сожалению, ваш платеж через Tribute был отклонен.\n\n"
|
||||
"Возможные причины:\n"
|
||||
"• Недостаточно средств на карте\n"
|
||||
"• Технические проблемы банка\n"
|
||||
"• Превышен лимит операций\n\n"
|
||||
"Попробуйте еще раз или обратитесь в поддержку."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
|
||||
|
||||
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
text = (
|
||||
f"🔄 **Возврат средств**\n\n"
|
||||
f"💰 Сумма возврата: {amount_rubles:.2f} ₽\n"
|
||||
f"💳 Способ: Tribute\n\n"
|
||||
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
|
||||
f"Если у вас есть вопросы, обратитесь в поддержку."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о возврате: {e}")
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
return await self.tribute_api.get_payment_status(payment_id)
|
||||
|
||||
async def create_refund(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
reason: str = "Возврат по запросу"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
|
||||
333
app/services/user_service.py
Normal file
333
app/services/user_service.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user
|
||||
)
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import User, UserStatus
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserService:
|
||||
|
||||
async def get_user_profile(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
transactions_count = await get_user_transactions_count(db, user_id)
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"subscription": subscription,
|
||||
"transactions_count": transactions_count,
|
||||
"is_admin": settings.is_admin(user.telegram_id),
|
||||
"registration_days": (datetime.utcnow() - user.created_at).days
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения профиля пользователя {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def search_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
page: int = 1,
|
||||
limit: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, search=query
|
||||
)
|
||||
total_count = await get_users_count(db, search=query)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total_count,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка поиска пользователей: {e}")
|
||||
return {
|
||||
"users": [],
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"total_count": 0,
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
async def get_users_page(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
status: Optional[UserStatus] = None
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, status=status
|
||||
)
|
||||
total_count = await get_users_count(db, status=status)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total_count,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения страницы пользователей: {e}")
|
||||
return {
|
||||
"users": [],
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"total_count": 0,
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
async def update_user_balance(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if amount_kopeks > 0:
|
||||
await add_user_balance(db, user, amount_kopeks, description)
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
else:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
return success
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int,
|
||||
reason: str = "Заблокирован администратором"
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
await update_user(db, user, status=UserStatus.BLOCKED.value)
|
||||
|
||||
logger.info(f"Админ {admin_id} заблокировал пользователя {user_id}: {reason}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка блокировки пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def unblock_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
await update_user(db, user, status=UserStatus.ACTIVE.value)
|
||||
|
||||
logger.info(f"Админ {admin_id} разблокировал пользователя {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка разблокировки пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def delete_user_account(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
success = await delete_user(db, user)
|
||||
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} удалил пользователя {user_id}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def get_user_statistics(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
try:
|
||||
stats = await get_users_statistics(db)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики пользователей: {e}")
|
||||
return {
|
||||
"total_users": 0,
|
||||
"active_users": 0,
|
||||
"blocked_users": 0,
|
||||
"new_today": 0,
|
||||
"new_week": 0,
|
||||
"new_month": 0
|
||||
}
|
||||
|
||||
async def cleanup_inactive_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
months: int = None
|
||||
) -> int:
|
||||
try:
|
||||
if months is None:
|
||||
months = settings.INACTIVE_USER_DELETE_MONTHS
|
||||
|
||||
inactive_users = await get_inactive_users(db, months)
|
||||
deleted_count = 0
|
||||
|
||||
for user in inactive_users:
|
||||
success = await delete_user(db, user)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
logger.info(f"Удалено {deleted_count} неактивных пользователей")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
|
||||
return 0
|
||||
|
||||
async def get_user_activity_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
transactions_count = await get_user_transactions_count(db, user_id)
|
||||
|
||||
days_since_registration = (datetime.utcnow() - user.created_at).days
|
||||
|
||||
days_since_activity = (datetime.utcnow() - user.last_activity).days if user.last_activity else None
|
||||
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"full_name": user.full_name,
|
||||
"status": user.status,
|
||||
"language": user.language,
|
||||
"balance_kopeks": user.balance_kopeks,
|
||||
"registration_date": user.created_at,
|
||||
"last_activity": user.last_activity,
|
||||
"days_since_registration": days_since_registration,
|
||||
"days_since_activity": days_since_activity,
|
||||
"has_subscription": subscription is not None,
|
||||
"subscription_active": subscription.is_active if subscription else False,
|
||||
"subscription_trial": subscription.is_trial if subscription else False,
|
||||
"transactions_count": transactions_count,
|
||||
"referrer_id": user.referrer_id,
|
||||
"referral_code": user.referral_code
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сводки активности пользователя {user_id}: {e}")
|
||||
return {}
|
||||
|
||||
async def get_users_by_criteria(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
criteria: Dict[str, Any]
|
||||
) -> List[User]:
|
||||
try:
|
||||
status = criteria.get('status')
|
||||
has_subscription = criteria.get('has_subscription')
|
||||
is_trial = criteria.get('is_trial')
|
||||
min_balance = criteria.get('min_balance', 0)
|
||||
max_balance = criteria.get('max_balance')
|
||||
days_inactive = criteria.get('days_inactive')
|
||||
|
||||
registered_after = criteria.get('registered_after')
|
||||
registered_before = criteria.get('registered_before')
|
||||
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=status)
|
||||
|
||||
filtered_users = []
|
||||
for user in users:
|
||||
if user.balance_kopeks < min_balance:
|
||||
continue
|
||||
if max_balance and user.balance_kopeks > max_balance:
|
||||
continue
|
||||
|
||||
if registered_after and user.created_at < registered_after:
|
||||
continue
|
||||
if registered_before and user.created_at > registered_before:
|
||||
continue
|
||||
|
||||
if days_inactive and user.last_activity:
|
||||
inactive_threshold = datetime.utcnow() - timedelta(days=days_inactive)
|
||||
if user.last_activity > inactive_threshold:
|
||||
continue
|
||||
|
||||
filtered_users.append(user)
|
||||
|
||||
return filtered_users
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения пользователей по критериям: {e}")
|
||||
return []
|
||||
82
app/states.py
Normal file
82
app/states.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class SubscriptionStates(StatesGroup):
|
||||
selecting_period = State()
|
||||
selecting_traffic = State()
|
||||
selecting_countries = State()
|
||||
selecting_devices = State()
|
||||
confirming_purchase = State()
|
||||
|
||||
adding_countries = State()
|
||||
adding_traffic = State()
|
||||
adding_devices = State()
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_stars_payment = State()
|
||||
waiting_for_support_request = State()
|
||||
|
||||
|
||||
class PromoCodeStates(StatesGroup):
|
||||
waiting_for_code = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class AdminStates(StatesGroup):
|
||||
|
||||
waiting_for_user_search = State()
|
||||
editing_user_balance = State()
|
||||
editing_user_subscription = State()
|
||||
|
||||
creating_promocode = State()
|
||||
setting_promocode_type = State()
|
||||
setting_promocode_value = State()
|
||||
setting_promocode_uses = State()
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
editing_device_price = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
|
||||
confirming_sync = State()
|
||||
|
||||
editing_server_name = State()
|
||||
editing_server_price = State()
|
||||
editing_server_country = State()
|
||||
editing_server_limit = State()
|
||||
editing_server_description = State()
|
||||
|
||||
creating_server_uuid = State()
|
||||
creating_server_name = State()
|
||||
creating_server_price = State()
|
||||
creating_server_country = State()
|
||||
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
|
||||
class SquadCreateStates(StatesGroup):
|
||||
waiting_for_name = State()
|
||||
selecting_inbounds = State()
|
||||
|
||||
class SquadRenameStates(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
3
app/utils/__init__.py
Normal file
3
app/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Утилиты
|
||||
"""
|
||||
264
app/utils/cache.py
Normal file
264
app/utils/cache.py
Normal file
@@ -0,0 +1,264 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional, Union
|
||||
from datetime import datetime, timedelta
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheService:
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
self._connected = False
|
||||
|
||||
async def connect(self):
|
||||
try:
|
||||
self.redis_client = redis.from_url(settings.REDIS_URL)
|
||||
await self.redis_client.ping()
|
||||
self._connected = True
|
||||
logger.info("✅ Подключение к Redis кешу установлено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}")
|
||||
self._connected = False
|
||||
|
||||
async def disconnect(self):
|
||||
if self.redis_client:
|
||||
await self.redis_client.close()
|
||||
self._connected = False
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self.redis_client.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения из кеша {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
expire: Union[int, timedelta] = None
|
||||
) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized_value = json.dumps(value, default=str)
|
||||
|
||||
if isinstance(expire, timedelta):
|
||||
expire = int(expire.total_seconds())
|
||||
|
||||
await self.redis_client.set(key, serialized_value, ex=expire)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка записи в кеш {key}: {e}")
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
deleted = await self.redis_client.delete(key)
|
||||
return deleted > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления из кеша {key}: {e}")
|
||||
return False
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self.redis_client.exists(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки существования в кеше {key}: {e}")
|
||||
return False
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self.redis_client.expire(key, seconds)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки TTL для {key}: {e}")
|
||||
return False
|
||||
|
||||
async def get_keys(self, pattern: str = "*") -> list:
|
||||
if not self._connected:
|
||||
return []
|
||||
|
||||
try:
|
||||
keys = await self.redis_client.keys(pattern)
|
||||
return [key.decode() if isinstance(key, bytes) else key for key in keys]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения ключей по паттерну {pattern}: {e}")
|
||||
return []
|
||||
|
||||
async def flush_all(self) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.redis_client.flushall()
|
||||
logger.info("🗑️ Кеш полностью очищен")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки кеша: {e}")
|
||||
return False
|
||||
|
||||
async def increment(self, key: str, amount: int = 1) -> Optional[int]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self.redis_client.incrby(key, amount)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка инкремента {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set_hash(self, name: str, mapping: dict, expire: int = None) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.redis_client.hset(name, mapping=mapping)
|
||||
if expire:
|
||||
await self.redis_client.expire(name, expire)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка записи хеша {name}: {e}")
|
||||
return False
|
||||
|
||||
async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
if key:
|
||||
value = await self.redis_client.hget(name, key)
|
||||
return value.decode() if value else None
|
||||
else:
|
||||
hash_data = await self.redis_client.hgetall(name)
|
||||
return {k.decode(): v.decode() for k, v in hash_data.items()}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения хеша {name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
cache = CacheService()
|
||||
|
||||
|
||||
def cache_key(*parts) -> str:
|
||||
return ":".join(str(part) for part in parts)
|
||||
|
||||
|
||||
async def cached_function(key: str, expire: int = 300):
|
||||
def decorator(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
cache_result = await cache.get(key)
|
||||
if cache_result is not None:
|
||||
return cache_result
|
||||
|
||||
result = await func(*args, **kwargs)
|
||||
await cache.set(key, result, expire)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class UserCache:
|
||||
|
||||
@staticmethod
|
||||
async def get_user_data(user_id: int) -> Optional[dict]:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_user_data(user_id: int, data: dict, expire: int = 3600) -> bool:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.set(key, data, expire)
|
||||
|
||||
@staticmethod
|
||||
async def delete_user_data(user_id: int) -> bool:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.delete(key)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_session(user_id: int, session_key: str) -> Optional[Any]:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_user_session(
|
||||
user_id: int,
|
||||
session_key: str,
|
||||
data: Any,
|
||||
expire: int = 1800
|
||||
) -> bool:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.set(key, data, expire)
|
||||
|
||||
|
||||
class SystemCache:
|
||||
|
||||
@staticmethod
|
||||
async def get_system_stats() -> Optional[dict]:
|
||||
return await cache.get("system:stats")
|
||||
|
||||
@staticmethod
|
||||
async def set_system_stats(stats: dict, expire: int = 300) -> bool:
|
||||
return await cache.set("system:stats", stats, expire)
|
||||
|
||||
@staticmethod
|
||||
async def get_nodes_status() -> Optional[list]:
|
||||
return await cache.get("remnawave:nodes")
|
||||
|
||||
@staticmethod
|
||||
async def set_nodes_status(nodes: list, expire: int = 60) -> bool:
|
||||
return await cache.set("remnawave:nodes", nodes, expire)
|
||||
|
||||
@staticmethod
|
||||
async def get_daily_stats(date: str) -> Optional[dict]:
|
||||
key = cache_key("stats", "daily", date)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_daily_stats(date: str, stats: dict) -> bool:
|
||||
key = cache_key("stats", "daily", date)
|
||||
return await cache.set(key, stats, 86400) # 24 часа
|
||||
|
||||
|
||||
class RateLimitCache:
|
||||
|
||||
@staticmethod
|
||||
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
|
||||
key = cache_key("rate_limit", user_id, action)
|
||||
current = await cache.get(key)
|
||||
|
||||
if current is None:
|
||||
await cache.set(key, 1, window)
|
||||
return False
|
||||
|
||||
if current >= limit:
|
||||
return True
|
||||
|
||||
await cache.increment(key)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def reset_rate_limit(user_id: int, action: str) -> bool:
|
||||
key = cache_key("rate_limit", user_id, action)
|
||||
return await cache.delete(key)
|
||||
117
app/utils/decorators.py
Normal file
117
app/utils/decorators.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
import functools
|
||||
from typing import Callable, Any
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def admin_required(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
user = None
|
||||
if isinstance(event, (types.Message, types.CallbackQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or not settings.is_admin(user.id):
|
||||
texts = get_texts()
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ACCESS_DENIED)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
|
||||
logger.warning(f"Попытка доступа к админской функции от {user.id if user else 'Unknown'}")
|
||||
return
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def error_handler(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в {func.__name__}: {e}", exc_info=True)
|
||||
|
||||
event = None
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, (types.Message, types.CallbackQuery)):
|
||||
event = arg
|
||||
break
|
||||
|
||||
if event:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ERROR)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ERROR, show_alert=True)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def state_cleanup(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
state = kwargs.get('state')
|
||||
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if state and isinstance(state, FSMContext):
|
||||
await state.clear()
|
||||
raise e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def typing_action(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
if isinstance(event, types.Message):
|
||||
await event.bot.send_chat_action(
|
||||
chat_id=event.chat.id,
|
||||
action="typing"
|
||||
)
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def rate_limit(rate: float = 1.0, key: str = None):
|
||||
def decorator(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
207
app/utils/formatters.py
Normal file
207
app/utils/formatters.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Union, Optional
|
||||
|
||||
|
||||
def format_datetime(dt: Union[datetime, str], format_str: str = "%d.%m.%Y %H:%M") -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_time_ago(dt: Union[datetime, str]) -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
now = datetime.utcnow()
|
||||
diff = now - dt
|
||||
|
||||
if diff.days > 0:
|
||||
if diff.days == 1:
|
||||
return "вчера"
|
||||
elif diff.days < 7:
|
||||
return f"{diff.days} дн. назад"
|
||||
elif diff.days < 30:
|
||||
weeks = diff.days // 7
|
||||
return f"{weeks} нед. назад"
|
||||
elif diff.days < 365:
|
||||
months = diff.days // 30
|
||||
return f"{months} мес. назад"
|
||||
else:
|
||||
years = diff.days // 365
|
||||
return f"{years} г. назад"
|
||||
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} ч. назад"
|
||||
|
||||
elif diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} мин. назад"
|
||||
|
||||
else:
|
||||
return "только что"
|
||||
|
||||
|
||||
def format_duration(seconds: int) -> str:
|
||||
if seconds < 60:
|
||||
return f"{seconds} сек."
|
||||
|
||||
minutes = seconds // 60
|
||||
if minutes < 60:
|
||||
return f"{minutes} мин."
|
||||
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f"{hours} ч."
|
||||
|
||||
days = hours // 24
|
||||
return f"{days} дн."
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
if bytes_value == 0:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = float(bytes_value)
|
||||
unit_index = 0
|
||||
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
if size == int(size):
|
||||
return f"{int(size)} {units[unit_index]}"
|
||||
else:
|
||||
return f"{size:.1f} {units[unit_index]}"
|
||||
|
||||
|
||||
def format_percentage(value: float, decimals: int = 1) -> str:
|
||||
return f"{value:.{decimals}f}%"
|
||||
|
||||
|
||||
def format_number(number: Union[int, float], separator: str = " ") -> str:
|
||||
if isinstance(number, float):
|
||||
integer_part = int(number)
|
||||
decimal_part = number - integer_part
|
||||
|
||||
formatted_integer = f"{integer_part:,}".replace(",", separator)
|
||||
|
||||
if decimal_part > 0:
|
||||
return f"{formatted_integer}.{decimal_part:.2f}".split('.')[0] + f".{str(decimal_part).split('.')[1][:2]}"
|
||||
else:
|
||||
return formatted_integer
|
||||
else:
|
||||
return f"{number:,}".replace(",", separator)
|
||||
|
||||
|
||||
def format_price_range(min_price: int, max_price: int) -> str:
|
||||
from app.config import settings
|
||||
|
||||
min_formatted = settings.format_price(min_price)
|
||||
max_formatted = settings.format_price(max_price)
|
||||
|
||||
if min_price == max_price:
|
||||
return min_formatted
|
||||
else:
|
||||
return f"{min_formatted} - {max_formatted}"
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
|
||||
def format_username(username: Optional[str], user_id: int, full_name: Optional[str] = None) -> str:
|
||||
if full_name:
|
||||
return full_name
|
||||
elif username:
|
||||
return f"@{username}"
|
||||
else:
|
||||
return f"ID{user_id}"
|
||||
|
||||
|
||||
def format_subscription_status(
|
||||
is_active: bool,
|
||||
is_trial: bool,
|
||||
end_date: Union[datetime, str],
|
||||
language: str = "ru"
|
||||
) -> str:
|
||||
|
||||
if isinstance(end_date, str):
|
||||
try:
|
||||
end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
end_date = datetime.now()
|
||||
|
||||
if not is_active:
|
||||
return "❌ Неактивна" if language == "ru" else "❌ Inactive"
|
||||
|
||||
if is_trial:
|
||||
status = "🎁 Тестовая" if language == "ru" else "🎁 Trial"
|
||||
else:
|
||||
status = "✅ Активна" if language == "ru" else "✅ Active"
|
||||
|
||||
now = datetime.utcnow()
|
||||
if end_date > now:
|
||||
days_left = (end_date - now).days
|
||||
if days_left > 0:
|
||||
status += f" ({days_left} дн.)" if language == "ru" else f" ({days_left} days)"
|
||||
else:
|
||||
hours_left = (end_date - now).seconds // 3600
|
||||
status += f" ({hours_left} ч.)" if language == "ru" else f" ({hours_left} hrs)"
|
||||
else:
|
||||
status = "⏰ Истекла" if language == "ru" else "⏰ Expired"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def format_traffic_usage(used_gb: float, limit_gb: int, language: str = "ru") -> str:
|
||||
|
||||
if limit_gb == 0:
|
||||
if language == "ru":
|
||||
return f"{used_gb:.1f} ГБ / ∞"
|
||||
else:
|
||||
return f"{used_gb:.1f} GB / ∞"
|
||||
|
||||
percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0
|
||||
|
||||
if language == "ru":
|
||||
return f"{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)"
|
||||
else:
|
||||
return f"{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)"
|
||||
|
||||
|
||||
def format_boolean(value: bool, language: str = "ru") -> str:
|
||||
if language == "ru":
|
||||
return "✅ Да" if value else "❌ Нет"
|
||||
else:
|
||||
return "✅ Yes" if value else "❌ No"
|
||||
82
app/utils/pagination.py
Normal file
82
app/utils/pagination.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from typing import List, TypeVar, Generic, Dict, Any
|
||||
from math import ceil
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class PaginationResult(Generic[T]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: List[T],
|
||||
total_count: int,
|
||||
page: int,
|
||||
per_page: int
|
||||
):
|
||||
self.items = items
|
||||
self.total_count = total_count
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total_pages = ceil(total_count / per_page) if per_page > 0 else 1
|
||||
self.has_prev = page > 1
|
||||
self.has_next = page < self.total_pages
|
||||
self.prev_page = page - 1 if self.has_prev else None
|
||||
self.next_page = page + 1 if self.has_next else None
|
||||
|
||||
|
||||
def paginate_list(
|
||||
items: List[T],
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> PaginationResult[T]:
|
||||
total_count = len(items)
|
||||
|
||||
start_index = (page - 1) * per_page
|
||||
end_index = start_index + per_page
|
||||
|
||||
page_items = items[start_index:end_index]
|
||||
|
||||
return PaginationResult(
|
||||
items=page_items,
|
||||
total_count=total_count,
|
||||
page=page,
|
||||
per_page=per_page
|
||||
)
|
||||
|
||||
|
||||
def get_pagination_info(
|
||||
total_count: int,
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
total_pages = ceil(total_count / per_page) if per_page > 0 else 1
|
||||
|
||||
return {
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"has_prev": page > 1,
|
||||
"has_next": page < total_pages,
|
||||
"prev_page": page - 1 if page > 1 else None,
|
||||
"next_page": page + 1 if page < total_pages else None,
|
||||
"offset": (page - 1) * per_page
|
||||
}
|
||||
|
||||
|
||||
def get_page_numbers(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
max_visible: int = 5
|
||||
) -> List[int]:
|
||||
if total_pages <= max_visible:
|
||||
return list(range(1, total_pages + 1))
|
||||
|
||||
half_visible = max_visible // 2
|
||||
start_page = max(1, current_page - half_visible)
|
||||
end_page = min(total_pages, start_page + max_visible - 1)
|
||||
|
||||
if end_page - start_page + 1 < max_visible:
|
||||
start_page = max(1, end_page - max_visible + 1)
|
||||
|
||||
return list(range(start_page, end_page + 1))
|
||||
76
app/utils/user_utils.py
Normal file
76
app/utils/user_utils.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
) -> None:
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
|
||||
|
||||
|
||||
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
|
||||
|
||||
base_code = str(telegram_id)[-6:]
|
||||
|
||||
for attempt in range(10):
|
||||
if attempt == 0:
|
||||
referral_code = base_code
|
||||
else:
|
||||
suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=2))
|
||||
referral_code = base_code + suffix
|
||||
|
||||
result = await db.execute(
|
||||
select(User.id).where(User.referral_code == referral_code)
|
||||
)
|
||||
|
||||
if not result.scalar():
|
||||
return referral_code
|
||||
|
||||
import uuid
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
async def get_user_referral_summary(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
try:
|
||||
from app.services.referral_service import get_referral_stats_for_user
|
||||
from app.database.crud.referral import get_referral_earnings_by_user
|
||||
|
||||
stats = await get_referral_stats_for_user(db, user_id)
|
||||
|
||||
recent_earnings = await get_referral_earnings_by_user(db, user_id, limit=5)
|
||||
|
||||
return {
|
||||
**stats,
|
||||
"recent_earnings": [
|
||||
{
|
||||
"amount_kopeks": earning.amount_kopeks,
|
||||
"reason": earning.reason,
|
||||
"created_at": earning.created_at,
|
||||
"referral_name": earning.referral.full_name if earning.referral else "Неизвестно"
|
||||
}
|
||||
for earning in recent_earnings
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сводки рефералов: {e}")
|
||||
return {
|
||||
"invited_count": 0,
|
||||
"paid_referrals_count": 0,
|
||||
"total_earned_kopeks": 0,
|
||||
"month_earned_kopeks": 0,
|
||||
"recent_earnings": []
|
||||
}
|
||||
137
app/utils/validators.py
Normal file
137
app/utils/validators.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
|
||||
def validate_phone(phone: str) -> bool:
|
||||
pattern = r'^\+?[1-9]\d{1,14}$'
|
||||
cleaned_phone = re.sub(r'[\s\-\(\)]', '', phone)
|
||||
return re.match(pattern, cleaned_phone) is not None
|
||||
|
||||
|
||||
def validate_telegram_username(username: str) -> bool:
|
||||
if not username:
|
||||
return False
|
||||
username = username.lstrip('@')
|
||||
pattern = r'^[a-zA-Z0-9_]{5,32}$'
|
||||
return re.match(pattern, username) is not None
|
||||
|
||||
|
||||
def validate_promocode(code: str) -> bool:
|
||||
if not code or len(code) < 3 or len(code) > 20:
|
||||
return False
|
||||
return code.replace('_', '').replace('-', '').isalnum()
|
||||
|
||||
|
||||
def validate_amount(amount_str: str, min_amount: float = 0, max_amount: float = float('inf')) -> Optional[float]:
|
||||
try:
|
||||
amount = float(amount_str.replace(',', '.'))
|
||||
if min_amount <= amount <= max_amount:
|
||||
return amount
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_positive_integer(value: Union[str, int], max_value: int = None) -> Optional[int]:
|
||||
try:
|
||||
num = int(value)
|
||||
if num > 0 and (max_value is None or num <= max_value):
|
||||
return num
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_date_string(date_str: str, date_format: str = "%Y-%m-%d") -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.strptime(date_str, date_format)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_url(url: str) -> bool:
|
||||
pattern = r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$'
|
||||
return re.match(pattern, url) is not None
|
||||
|
||||
|
||||
def validate_uuid(uuid_str: str) -> bool:
|
||||
pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
return re.match(pattern, uuid_str.lower()) is not None
|
||||
|
||||
|
||||
def validate_traffic_amount(traffic_str: str) -> Optional[int]:
|
||||
traffic_str = traffic_str.upper().strip()
|
||||
|
||||
if traffic_str in ['UNLIMITED', 'БЕЗЛИМИТ', '∞']:
|
||||
return 0
|
||||
|
||||
units = {
|
||||
'MB': 1,
|
||||
'GB': 1024,
|
||||
'TB': 1024 * 1024,
|
||||
'МБ': 1,
|
||||
'ГБ': 1024,
|
||||
'ТБ': 1024 * 1024
|
||||
}
|
||||
|
||||
for unit, multiplier in units.items():
|
||||
if traffic_str.endswith(unit):
|
||||
try:
|
||||
value = float(traffic_str[:-len(unit)].strip())
|
||||
return int(value * multiplier)
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
try:
|
||||
return int(float(traffic_str))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
days_int = int(days)
|
||||
if 1 <= days_int <= 3650:
|
||||
return days_int
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_html(text: str) -> str:
|
||||
allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre']
|
||||
|
||||
for tag in allowed_tags:
|
||||
text = re.sub(f'<{tag}>', f'<{tag}>', text, flags=re.IGNORECASE)
|
||||
text = re.sub(f'</{tag}>', f'</{tag}>', text, flags=re.IGNORECASE)
|
||||
|
||||
text = re.sub(r'<(?!/?(?:' + '|'.join(allowed_tags) + r')\b)[^>]*>', '', text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def validate_device_count(count: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
count_int = int(count)
|
||||
if 1 <= count_int <= 10:
|
||||
return count_int
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_referral_code(code: str) -> bool:
|
||||
if not code:
|
||||
return False
|
||||
|
||||
if code.startswith('ref') and len(code) > 3:
|
||||
user_id_part = code[3:]
|
||||
return user_id_part.isdigit()
|
||||
|
||||
return validate_promocode(code)
|
||||
81
main.py
Normal file
81
main.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.bot import setup_bot
|
||||
from app.config import settings
|
||||
from app.database.database import init_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.external.webhook_server import WebhookServer
|
||||
|
||||
|
||||
async def main():
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(settings.LOG_FILE, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🚀 Запуск VPN бота...")
|
||||
|
||||
webhook_server = None
|
||||
|
||||
try:
|
||||
logger.info("📊 Инициализация базы данных...")
|
||||
await init_db()
|
||||
|
||||
logger.info("🤖 Настройка бота...")
|
||||
bot, dp = await setup_bot()
|
||||
|
||||
monitoring_service.bot = bot
|
||||
|
||||
# Инициализируем webhook сервер если Tribute включен
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info("🌐 Запуск webhook сервера для Tribute...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
else:
|
||||
logger.info("ℹ️ Tribute отключен, webhook сервер не запускается")
|
||||
|
||||
logger.info("🔍 Запуск службы мониторинга...")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
logger.info("🔄 Запуск polling...")
|
||||
|
||||
try:
|
||||
await asyncio.gather(
|
||||
dp.start_polling(bot),
|
||||
monitoring_task
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в основном цикле: {e}")
|
||||
monitoring_service.stop_monitoring()
|
||||
if webhook_server:
|
||||
await webhook_server.stop()
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка при запуске: {e}")
|
||||
raise
|
||||
finally:
|
||||
logger.info("🛑 Завершение работы бота")
|
||||
monitoring_service.stop_monitoring()
|
||||
if webhook_server:
|
||||
await webhook_server.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Бот остановлен пользователем")
|
||||
except Exception as e:
|
||||
print(f"❌ Критическая ошибка: {e}")
|
||||
sys.exit(1)
|
||||
41
migrations/alembic/alembic.ini
Normal file
41
migrations/alembic/alembic.ini
Normal file
@@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
67
migrations/alembic/env.py
Normal file
67
migrations/alembic/env.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.database.models import Base
|
||||
from app.config import settings
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
# Основные зависимости
|
||||
aiogram==3.4.1
|
||||
aiohttp==3.9.1
|
||||
asyncpg==0.29.0
|
||||
SQLAlchemy==2.0.25
|
||||
alembic==1.13.1
|
||||
aiosqlite==0.19.0
|
||||
|
||||
# Дополнительные зависимости
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
redis==5.0.1
|
||||
|
||||
# Логирование и мониторинг
|
||||
structlog==23.2.0
|
||||
|
||||
# Утилиты
|
||||
python-dateutil==2.8.2
|
||||
pytz==2023.4
|
||||
cryptography>=41.0.0
|
||||
Reference in New Issue
Block a user