Files
remnawave-bedolaga-telegram…/app/services/modem_service.py
gy9vin da46e39c61 refactor(modem): рефакторинг модуля управления модемом
Рефакторинг архитектуры управления модемом:

- Создан сервис app/services/modem_service.py:
  - ModemService с бизнес-логикой подключения/отключения
  - ModemError enum для типизации ошибок
  - ModemPriceInfo, ModemOperationResult dataclass'ы
  - Константы MODEM_WARNING_DAYS_* для уровней предупреждений
2025-12-25 18:44:27 +03:00

393 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""
Сервис для управления модемом в подписке.
Модем - это дополнительное устройство, которое можно подключить к подписке
за отдельную плату. При подключении увеличивается лимит устройств.
"""
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