Files
remnawave-bedolaga-telegram…/app/services/daily_subscription_service.py
2026-01-17 01:15:28 +03:00

496 lines
22 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Сервис для автоматического списания суточных подписок.
Проверяет подписки с суточным тарифом и списывает плату раз в сутки.
Также сбрасывает докупленный трафик по истечении 30 дней.
"""
import logging
import asyncio
from datetime import datetime
from typing import Optional
from aiogram import Bot
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.crud.subscription import (
get_daily_subscriptions_for_charge,
update_daily_charge_time,
suspend_daily_subscription_insufficient_balance,
)
from app.database.crud.user import subtract_user_balance, get_user_by_id
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType, PaymentMethod, Subscription, User
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
class DailySubscriptionService:
"""
Сервис автоматического списания для суточных подписок.
"""
def __init__(self):
self._running = False
self._bot: Optional[Bot] = None
self._check_interval_minutes = 30 # Проверка каждые 30 минут
def set_bot(self, bot: Bot):
"""Устанавливает бота для отправки уведомлений."""
self._bot = bot
def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис суточных подписок."""
return getattr(settings, 'DAILY_SUBSCRIPTIONS_ENABLED', True)
def get_check_interval_minutes(self) -> int:
"""Возвращает интервал проверки в минутах."""
return getattr(settings, 'DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES', 30)
async def process_daily_charges(self) -> dict:
"""
Обрабатывает суточные списания.
Returns:
dict: Статистика обработки
"""
stats = {
"checked": 0,
"charged": 0,
"suspended": 0,
"errors": 0,
}
try:
async with AsyncSessionLocal() as db:
try:
subscriptions = await get_daily_subscriptions_for_charge(db)
stats["checked"] = len(subscriptions)
for subscription in subscriptions:
try:
result = await self._process_single_charge(db, subscription)
if result == "charged":
stats["charged"] += 1
elif result == "suspended":
stats["suspended"] += 1
elif result == "error":
stats["errors"] += 1
except Exception as e:
logger.error(
f"Ошибка обработки суточной подписки {subscription.id}: {e}",
exc_info=True
)
stats["errors"] += 1
await db.commit()
except Exception as e:
logger.error(f"Ошибка при обработке подписок: {e}", exc_info=True)
await db.rollback()
except Exception as e:
logger.error(f"Ошибка при получении подписок для списания: {e}", exc_info=True)
return stats
async def _process_single_charge(self, db, subscription) -> str:
"""
Обрабатывает списание для одной подписки.
Returns:
str: "charged", "suspended", "error", "skipped"
"""
user = subscription.user
if not user:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.warning(f"Пользователь не найден для подписки {subscription.id}")
return "error"
tariff = subscription.tariff
if not tariff:
logger.warning(f"Тариф не найден для подписки {subscription.id}")
return "error"
daily_price = tariff.daily_price_kopeks
if daily_price <= 0:
logger.warning(f"Некорректная суточная цена для тарифа {tariff.id}")
return "error"
# Проверяем баланс
if user.balance_kopeks < daily_price:
# Недостаточно средств - приостанавливаем подписку
await suspend_daily_subscription_insufficient_balance(db, subscription)
# Уведомляем пользователя
if self._bot:
await self._notify_insufficient_balance(user, subscription, daily_price)
logger.info(
f"Подписка {subscription.id} приостановлена: недостаточно средств "
f"(баланс: {user.balance_kopeks}, требуется: {daily_price})"
)
return "suspended"
# Списываем средства
description = f"Суточная оплата тарифа «{tariff.name}»"
try:
deducted = await subtract_user_balance(
db,
user,
daily_price,
description,
)
if not deducted:
logger.warning(f"Не удалось списать средства для подписки {subscription.id}")
return "error"
# Создаём транзакцию
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=description,
payment_method=PaymentMethod.MANUAL,
)
# Обновляем время последнего списания и продлеваем подписку
subscription = await update_daily_charge_time(db, subscription)
logger.info(
f"✅ Суточное списание: подписка {subscription.id}, "
f"сумма {daily_price} коп., пользователь {user.telegram_id}"
)
# Синхронизируем с Remnawave (обновляем срок подписки)
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.warning(f"Не удалось обновить Remnawave: {e}")
# Уведомляем пользователя
if self._bot:
await self._notify_daily_charge(user, subscription, daily_price)
return "charged"
except Exception as e:
logger.error(
f"Ошибка при списании средств для подписки {subscription.id}: {e}",
exc_info=True
)
return "error"
async def _notify_daily_charge(self, user, subscription, amount_kopeks: int):
"""Уведомляет пользователя о суточном списании."""
if not self._bot:
return
try:
texts = get_texts(getattr(user, "language", "ru"))
amount_rubles = amount_kopeks / 100
balance_rubles = user.balance_kopeks / 100
message = (
f"💳 <b>Суточное списание</b>\n\n"
f"Списано: {amount_rubles:.2f}\n"
f"Остаток баланса: {balance_rubles:.2f}\n\n"
f"Следующее списание через 24 часа."
)
await self._bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML",
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление о списании: {e}")
async def _notify_insufficient_balance(self, user, subscription, required_amount: int):
"""Уведомляет пользователя о недостатке средств."""
if not self._bot:
return
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
texts = get_texts(getattr(user, "language", "ru"))
required_rubles = required_amount / 100
balance_rubles = user.balance_kopeks / 100
message = (
f"⚠️ <b>Подписка приостановлена</b>\n\n"
f"Недостаточно средств для суточной оплаты.\n\n"
f"Требуется: {required_rubles:.2f}\n"
f"Баланс: {balance_rubles:.2f}\n\n"
f"Пополните баланс, чтобы возобновить подписку."
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="menu_balance"
)],
[InlineKeyboardButton(
text="📱 Моя подписка",
callback_data="menu_subscription"
)],
]
)
await self._bot.send_message(
chat_id=user.telegram_id,
text=message,
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление о недостатке средств: {e}")
async def process_traffic_resets(self) -> dict:
"""
Сбрасывает докупленный трафик у подписок, у которых истёк срок.
Returns:
dict: Статистика обработки
"""
stats = {
"checked": 0,
"reset": 0,
"errors": 0,
}
from app.database.models import TrafficPurchase
try:
async with AsyncSessionLocal() as db:
try:
# Находим все истекшие докупки
now = datetime.utcnow()
query = (
select(TrafficPurchase)
.where(TrafficPurchase.expires_at <= now)
)
result = await db.execute(query)
expired_purchases = result.scalars().all()
stats["checked"] = len(expired_purchases)
# Группируем по подпискам для обновления
subscriptions_to_update = {}
for purchase in expired_purchases:
if purchase.subscription_id not in subscriptions_to_update:
subscriptions_to_update[purchase.subscription_id] = []
subscriptions_to_update[purchase.subscription_id].append(purchase)
# Удаляем истекшие докупки и обновляем подписки
for subscription_id, purchases in subscriptions_to_update.items():
try:
await self._reset_subscription_traffic(db, subscription_id, purchases)
stats["reset"] += len(purchases)
except Exception as e:
logger.error(
f"Ошибка сброса трафика подписки {subscription_id}: {e}",
exc_info=True
)
stats["errors"] += 1
await db.commit()
except Exception as e:
logger.error(f"Ошибка при обработке сброса трафика: {e}", exc_info=True)
await db.rollback()
except Exception as e:
logger.error(f"Ошибка при получении подписок для сброса трафика: {e}", exc_info=True)
return stats
async def _reset_subscription_traffic(self, db: AsyncSession, subscription_id: int, expired_purchases: list):
"""Сбрасывает истекшие докупки трафика у подписки."""
from app.database.models import TrafficPurchase
# Получаем подписку
subscription_query = select(Subscription).where(Subscription.id == subscription_id)
subscription_result = await db.execute(subscription_query)
subscription = subscription_result.scalar_one_or_none()
if not subscription:
return
# Считаем сколько ГБ нужно убрать
total_expired_gb = sum(p.traffic_gb for p in expired_purchases)
old_limit = subscription.traffic_limit_gb
old_purchased = subscription.purchased_traffic_gb or 0
# КРИТИЧЕСКАЯ ПРОВЕРКА: защита от некорректных данных
if total_expired_gb > old_purchased:
logger.error(
f"⚠️ ОШИБКА ДАННЫХ: подписка {subscription.id}, "
f"истекает {total_expired_gb} ГБ, но purchased_traffic_gb = {old_purchased} ГБ. "
f"Сбрасываем только {old_purchased} ГБ."
)
total_expired_gb = old_purchased
# Рассчитываем базовый лимит тарифа (без докупок)
base_limit = old_limit - old_purchased
# Получаем базовый лимит из тарифа для проверки
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
tariff_base_limit = tariff.traffic_limit_gb or 0
# Проверяем, что базовый лимит не отрицательный
if base_limit < 0:
logger.warning(
f"⚠️ Базовый лимит отрицательный для подписки {subscription.id}: {base_limit} ГБ. "
f"Используем лимит из тарифа: {tariff_base_limit} ГБ"
)
base_limit = tariff_base_limit
# Защита от отрицательного базового лимита
base_limit = max(0, base_limit)
# Удаляем истекшие записи
for purchase in expired_purchases:
await db.delete(purchase)
# Рассчитываем новый лимит
new_purchased = old_purchased - total_expired_gb
new_limit = base_limit + new_purchased
# Двойная защита: новый лимит не может быть меньше базового
if new_limit < base_limit:
logger.error(
f"⚠️ КРИТИЧЕСКАЯ ОШИБКА: новый лимит ({new_limit} ГБ) меньше базового ({base_limit} ГБ). "
f"Устанавливаем базовый лимит."
)
new_limit = base_limit
new_purchased = 0
# Обновляем подписку
subscription.traffic_limit_gb = max(0, new_limit)
subscription.purchased_traffic_gb = max(0, new_purchased)
# Проверяем, остались ли активные докупки
now = datetime.utcnow()
remaining_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription_id)
.where(TrafficPurchase.expires_at > now)
)
remaining_result = await db.execute(remaining_query)
remaining_purchases = remaining_result.scalars().all()
if not remaining_purchases:
# Нет больше активных докупок - сбрасываем дату
subscription.traffic_reset_at = None
else:
# Устанавливаем дату сброса по ближайшей истекающей докупке
next_expiry = min(p.expires_at for p in remaining_purchases)
subscription.traffic_reset_at = next_expiry
subscription.updated_at = datetime.utcnow()
await db.commit()
logger.info(
f"🔄 Сброс истекших докупок: подписка {subscription.id}, "
f"было {old_limit} ГБ (базовый: {base_limit} ГБ, докуплено: {old_purchased} ГБ), "
f"стало {subscription.traffic_limit_gb} ГБ (базовый: {base_limit} ГБ, докуплено: {new_purchased} ГБ), "
f"убрано {total_expired_gb} ГБ из {len(expired_purchases)} покупок"
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.warning(f"Не удалось синхронизировать с RemnaWave после сброса трафика: {e}")
# Уведомляем пользователя
if self._bot and subscription.user_id:
user = await get_user_by_id(db, subscription.user_id)
if user:
await self._notify_traffic_reset(user, subscription, total_expired_gb)
async def _notify_traffic_reset(self, user: User, subscription: Subscription, reset_gb: int):
"""Уведомляет пользователя о сбросе докупленного трафика."""
if not self._bot:
return
try:
message = (
f" <b>Сброс докупленного трафика</b>\n\n"
f"Ваш докупленный трафик ({reset_gb} ГБ) был сброшен, "
f"так как прошло 30 дней с момента первой докупки.\n\n"
f"Текущий лимит трафика: {subscription.traffic_limit_gb} ГБ\n\n"
f"Вы можете докупить трафик снова в любое время."
)
await self._bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML",
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление о сбросе трафика: {e}")
async def start_monitoring(self):
"""Запускает периодическую проверку суточных подписок и сброса трафика."""
self._running = True
interval_minutes = self.get_check_interval_minutes()
logger.info(
f"🔄 Запуск сервиса суточных подписок (интервал: {interval_minutes} мин)"
)
while self._running:
try:
# Обработка суточных списаний
stats = await self.process_daily_charges()
if stats["charged"] > 0 or stats["suspended"] > 0:
logger.info(
f"📊 Суточные списания: проверено={stats['checked']}, "
f"списано={stats['charged']}, приостановлено={stats['suspended']}, "
f"ошибок={stats['errors']}"
)
# Обработка сброса докупленного трафика
traffic_stats = await self.process_traffic_resets()
if traffic_stats["reset"] > 0:
logger.info(
f"📊 Сброс трафика: проверено={traffic_stats['checked']}, "
f"сброшено={traffic_stats['reset']}, ошибок={traffic_stats['errors']}"
)
except Exception as e:
logger.error(f"Ошибка в цикле проверки суточных подписок: {e}", exc_info=True)
await asyncio.sleep(interval_minutes * 60)
def stop_monitoring(self):
"""Останавливает периодическую проверку."""
self._running = False
logger.info("⏹️ Сервис суточных подписок остановлен")
# Глобальный экземпляр сервиса
daily_subscription_service = DailySubscriptionService()
__all__ = ["DailySubscriptionService", "daily_subscription_service"]