mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 11:50:27 +00:00
Рефакторинг архитектуры управления модемом: - Создан сервис app/services/modem_service.py: - ModemService с бизнес-логикой подключения/отключения - ModemError enum для типизации ошибок - ModemPriceInfo, ModemOperationResult dataclass'ы - Константы MODEM_WARNING_DAYS_* для уровней предупреждений
393 lines
13 KiB
Python
393 lines
13 KiB
Python
"""
|
||
Сервис для управления модемом в подписке.
|
||
|
||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||
"""
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Optional, Tuple
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.models import Subscription, User, TransactionType
|
||
from app.database.crud.transaction import create_transaction
|
||
from app.database.crud.user import subtract_user_balance
|
||
from app.services.subscription_service import SubscriptionService
|
||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class ModemError(Enum):
|
||
"""Типы ошибок при работе с модемом."""
|
||
NO_SUBSCRIPTION = "no_subscription"
|
||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||
MODEM_DISABLED = "modem_disabled"
|
||
ALREADY_ENABLED = "already_enabled"
|
||
NOT_ENABLED = "not_enabled"
|
||
INSUFFICIENT_FUNDS = "insufficient_funds"
|
||
CHARGE_ERROR = "charge_error"
|
||
UPDATE_ERROR = "update_error"
|
||
|
||
|
||
@dataclass
|
||
class ModemAvailabilityResult:
|
||
"""Результат проверки доступности модема."""
|
||
available: bool
|
||
error: Optional[ModemError] = None
|
||
modem_enabled: bool = False
|
||
|
||
|
||
@dataclass
|
||
class ModemPriceResult:
|
||
"""Результат расчёта цены модема."""
|
||
base_price: int
|
||
final_price: int
|
||
discount_percent: int
|
||
discount_amount: int
|
||
charged_months: int
|
||
remaining_days: int
|
||
end_date: datetime
|
||
|
||
@property
|
||
def has_discount(self) -> bool:
|
||
return self.discount_percent > 0
|
||
|
||
|
||
@dataclass
|
||
class ModemEnableResult:
|
||
"""Результат подключения модема."""
|
||
success: bool
|
||
error: Optional[ModemError] = None
|
||
charged_amount: int = 0
|
||
new_device_limit: int = 0
|
||
|
||
|
||
@dataclass
|
||
class ModemDisableResult:
|
||
"""Результат отключения модема."""
|
||
success: bool
|
||
error: Optional[ModemError] = None
|
||
new_device_limit: int = 0
|
||
|
||
|
||
# Константы для предупреждений о сроке действия
|
||
MODEM_WARNING_DAYS_CRITICAL = 7
|
||
MODEM_WARNING_DAYS_INFO = 30
|
||
|
||
|
||
class ModemService:
|
||
"""
|
||
Сервис для управления модемом в подписке.
|
||
|
||
Инкапсулирует всю бизнес-логику:
|
||
- Проверки доступности
|
||
- Расчёт цен и скидок
|
||
- Подключение/отключение модема
|
||
- Синхронизация с RemnaWave
|
||
"""
|
||
|
||
def __init__(self):
|
||
self._subscription_service = SubscriptionService()
|
||
|
||
@staticmethod
|
||
def is_modem_feature_enabled() -> bool:
|
||
"""Проверяет, включена ли функция модема в настройках."""
|
||
return settings.is_modem_enabled()
|
||
|
||
@staticmethod
|
||
def get_modem_enabled(subscription: Optional[Subscription]) -> bool:
|
||
"""Безопасно получает статус модема из подписки."""
|
||
if subscription is None:
|
||
return False
|
||
return getattr(subscription, 'modem_enabled', False) or False
|
||
|
||
def check_availability(
|
||
self,
|
||
user: User,
|
||
for_enable: bool = False,
|
||
for_disable: bool = False
|
||
) -> ModemAvailabilityResult:
|
||
"""
|
||
Проверяет доступность модема для пользователя.
|
||
|
||
Args:
|
||
user: Пользователь
|
||
for_enable: Проверка для подключения (модем должен быть отключен)
|
||
for_disable: Проверка для отключения (модем должен быть включен)
|
||
|
||
Returns:
|
||
ModemAvailabilityResult с результатом проверки
|
||
"""
|
||
subscription = user.subscription
|
||
modem_enabled = self.get_modem_enabled(subscription)
|
||
|
||
if not subscription:
|
||
return ModemAvailabilityResult(
|
||
available=False,
|
||
error=ModemError.NO_SUBSCRIPTION,
|
||
modem_enabled=modem_enabled
|
||
)
|
||
|
||
if subscription.is_trial:
|
||
return ModemAvailabilityResult(
|
||
available=False,
|
||
error=ModemError.TRIAL_SUBSCRIPTION,
|
||
modem_enabled=modem_enabled
|
||
)
|
||
|
||
if not self.is_modem_feature_enabled():
|
||
return ModemAvailabilityResult(
|
||
available=False,
|
||
error=ModemError.MODEM_DISABLED,
|
||
modem_enabled=modem_enabled
|
||
)
|
||
|
||
if for_enable and modem_enabled:
|
||
return ModemAvailabilityResult(
|
||
available=False,
|
||
error=ModemError.ALREADY_ENABLED,
|
||
modem_enabled=modem_enabled
|
||
)
|
||
|
||
if for_disable and not modem_enabled:
|
||
return ModemAvailabilityResult(
|
||
available=False,
|
||
error=ModemError.NOT_ENABLED,
|
||
modem_enabled=modem_enabled
|
||
)
|
||
|
||
return ModemAvailabilityResult(
|
||
available=True,
|
||
modem_enabled=modem_enabled
|
||
)
|
||
|
||
def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
|
||
"""
|
||
Рассчитывает стоимость подключения модема.
|
||
|
||
Использует пропорциональную цену на основе оставшегося времени подписки
|
||
и применяет скидки в зависимости от периода.
|
||
|
||
Args:
|
||
subscription: Подписка пользователя
|
||
|
||
Returns:
|
||
ModemPriceResult с детализацией цены
|
||
"""
|
||
modem_price_per_month = settings.get_modem_price_per_month()
|
||
|
||
base_price, charged_months = calculate_prorated_price(
|
||
modem_price_per_month,
|
||
subscription.end_date,
|
||
)
|
||
|
||
now = datetime.utcnow()
|
||
remaining_days = max(0, (subscription.end_date - now).days)
|
||
|
||
discount_percent = settings.get_modem_period_discount(charged_months)
|
||
if discount_percent > 0:
|
||
discount_amount = base_price * discount_percent // 100
|
||
final_price = base_price - discount_amount
|
||
else:
|
||
discount_amount = 0
|
||
final_price = base_price
|
||
|
||
return ModemPriceResult(
|
||
base_price=base_price,
|
||
final_price=final_price,
|
||
discount_percent=discount_percent,
|
||
discount_amount=discount_amount,
|
||
charged_months=charged_months,
|
||
remaining_days=remaining_days,
|
||
end_date=subscription.end_date
|
||
)
|
||
|
||
def check_balance(self, user: User, price: int) -> Tuple[bool, int]:
|
||
"""
|
||
Проверяет достаточность баланса.
|
||
|
||
Args:
|
||
user: Пользователь
|
||
price: Требуемая сумма
|
||
|
||
Returns:
|
||
Tuple[достаточно ли средств, недостающая сумма]
|
||
"""
|
||
if price <= 0:
|
||
return True, 0
|
||
|
||
if user.balance_kopeks >= price:
|
||
return True, 0
|
||
|
||
missing = price - user.balance_kopeks
|
||
return False, missing
|
||
|
||
async def enable_modem(
|
||
self,
|
||
db: AsyncSession,
|
||
user: User,
|
||
subscription: Subscription
|
||
) -> ModemEnableResult:
|
||
"""
|
||
Подключает модем к подписке.
|
||
|
||
Выполняет:
|
||
1. Расчёт цены
|
||
2. Проверку баланса
|
||
3. Списание средств
|
||
4. Создание транзакции
|
||
5. Обновление подписки
|
||
6. Синхронизацию с RemnaWave
|
||
|
||
Args:
|
||
db: Сессия базы данных
|
||
user: Пользователь
|
||
subscription: Подписка
|
||
|
||
Returns:
|
||
ModemEnableResult с результатом операции
|
||
"""
|
||
price_info = self.calculate_price(subscription)
|
||
price = price_info.final_price
|
||
|
||
has_funds, _ = self.check_balance(user, price)
|
||
if not has_funds:
|
||
return ModemEnableResult(
|
||
success=False,
|
||
error=ModemError.INSUFFICIENT_FUNDS
|
||
)
|
||
|
||
try:
|
||
if price > 0:
|
||
success = await subtract_user_balance(
|
||
db, user, price,
|
||
"Подключение модема"
|
||
)
|
||
|
||
if not success:
|
||
return ModemEnableResult(
|
||
success=False,
|
||
error=ModemError.CHARGE_ERROR
|
||
)
|
||
|
||
await create_transaction(
|
||
db=db,
|
||
user_id=user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=price,
|
||
description=f"Подключение модема на {price_info.charged_months} мес"
|
||
)
|
||
|
||
subscription.modem_enabled = True
|
||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||
subscription.updated_at = datetime.utcnow()
|
||
|
||
await db.commit()
|
||
|
||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||
|
||
await db.refresh(user)
|
||
await db.refresh(subscription)
|
||
|
||
logger.info(
|
||
f"Пользователь {user.telegram_id} подключил модем, списано: {price / 100}₽"
|
||
)
|
||
|
||
return ModemEnableResult(
|
||
success=True,
|
||
charged_amount=price,
|
||
new_device_limit=subscription.device_limit
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка подключения модема для пользователя {user.telegram_id}: {e}")
|
||
await db.rollback()
|
||
return ModemEnableResult(
|
||
success=False,
|
||
error=ModemError.UPDATE_ERROR
|
||
)
|
||
|
||
async def disable_modem(
|
||
self,
|
||
db: AsyncSession,
|
||
user: User,
|
||
subscription: Subscription
|
||
) -> ModemDisableResult:
|
||
"""
|
||
Отключает модем от подписки.
|
||
|
||
Возврат средств не производится.
|
||
|
||
Args:
|
||
db: Сессия базы данных
|
||
user: Пользователь
|
||
subscription: Подписка
|
||
|
||
Returns:
|
||
ModemDisableResult с результатом операции
|
||
"""
|
||
try:
|
||
subscription.modem_enabled = False
|
||
if subscription.device_limit and subscription.device_limit > 1:
|
||
subscription.device_limit = subscription.device_limit - 1
|
||
subscription.updated_at = datetime.utcnow()
|
||
|
||
await db.commit()
|
||
|
||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||
|
||
await db.refresh(user)
|
||
await db.refresh(subscription)
|
||
|
||
logger.info(f"Пользователь {user.telegram_id} отключил модем")
|
||
|
||
return ModemDisableResult(
|
||
success=True,
|
||
new_device_limit=subscription.device_limit
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отключения модема для пользователя {user.telegram_id}: {e}")
|
||
await db.rollback()
|
||
return ModemDisableResult(
|
||
success=False,
|
||
error=ModemError.UPDATE_ERROR
|
||
)
|
||
|
||
@staticmethod
|
||
def get_period_warning_level(remaining_days: int) -> Optional[str]:
|
||
"""
|
||
Определяет уровень предупреждения о сроке действия.
|
||
|
||
Args:
|
||
remaining_days: Оставшиеся дни подписки
|
||
|
||
Returns:
|
||
"critical" если <= 7 дней
|
||
"info" если <= 30 дней
|
||
None если больше 30 дней
|
||
"""
|
||
if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
|
||
return "critical"
|
||
if remaining_days <= MODEM_WARNING_DAYS_INFO:
|
||
return "info"
|
||
return None
|
||
|
||
|
||
# Singleton instance для использования в хендлерах
|
||
_modem_service: Optional[ModemService] = None
|
||
|
||
|
||
def get_modem_service() -> ModemService:
|
||
"""Возвращает singleton экземпляр ModemService."""
|
||
global _modem_service
|
||
if _modem_service is None:
|
||
_modem_service = ModemService()
|
||
return _modem_service
|