diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 37adebc1..13594e43 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -411,9 +411,13 @@ async def extend_subscription( old_traffic = subscription.traffic_limit_gb subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 + # Сбрасываем все докупки трафика при смене тарифа + from app.database.models import TrafficPurchase + from sqlalchemy import delete as sql_delete + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ") + logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)") elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 # В режиме тарифов сохраняем докупленный трафик при продлении diff --git a/app/database/models.py b/app/database/models.py index a204ef14..940902b1 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1080,6 +1080,7 @@ class Subscription(Base): tariff = relationship("Tariff", back_populates="subscriptions") discount_offers = relationship("DiscountOffer", back_populates="subscription") temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription") + traffic_purchases = relationship("TrafficPurchase", back_populates="subscription", cascade="all, delete-orphan") @property def is_active(self) -> bool: @@ -1238,6 +1239,26 @@ class Subscription(Base): return True +class TrafficPurchase(Base): + """Докупка трафика с индивидуальной датой истечения.""" + __tablename__ = "traffic_purchases" + + id = Column(Integer, primary_key=True, index=True) + subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + + traffic_gb = Column(Integer, nullable=False) # Количество ГБ в покупке + expires_at = Column(DateTime, nullable=False, index=True) # Дата истечения (покупка + 30 дней) + + created_at = Column(DateTime, default=func.now()) + + subscription = relationship("Subscription", back_populates="traffic_purchases") + + @property + def is_expired(self) -> bool: + """Проверяет, истекла ли докупка.""" + return datetime.utcnow() >= self.expires_at + + class Transaction(Base): __tablename__ = "transactions" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index fa7ccb02..5973c355 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5135,6 +5135,68 @@ async def create_withdrawal_requests_table() -> bool: return False +# ============================================================================= +# МИГРАЦИЯ ДЛЯ ИНДИВИДУАЛЬНЫХ ДОКУПОК ТРАФИКА +# ============================================================================= + +async def create_traffic_purchases_table() -> bool: + """Создаёт таблицу для индивидуальных докупок трафика с отдельными датами истечения.""" + try: + if await check_table_exists('traffic_purchases'): + logger.info("ℹ️ Таблица traffic_purchases уже существует") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE traffic_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + traffic_gb INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE + ); + CREATE INDEX idx_traffic_purchases_subscription_id ON traffic_purchases(subscription_id); + CREATE INDEX idx_traffic_purchases_expires_at ON traffic_purchases(expires_at); + """ + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE traffic_purchases ( + id SERIAL PRIMARY KEY, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + traffic_gb INTEGER NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_traffic_purchases_subscription_id ON traffic_purchases(subscription_id); + CREATE INDEX idx_traffic_purchases_expires_at ON traffic_purchases(expires_at); + """ + else: # mysql + create_sql = """ + CREATE TABLE traffic_purchases ( + id INT AUTO_INCREMENT PRIMARY KEY, + subscription_id INT NOT NULL, + traffic_gb INT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, + INDEX idx_traffic_purchases_subscription_id (subscription_id), + INDEX idx_traffic_purchases_expires_at (expires_at) + ); + """ + + await conn.execute(text(create_sql)) + logger.info("✅ Таблица traffic_purchases создана") + + return True + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы traffic_purchases: {error}") + return False + + # ============================================================================= # МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ # ============================================================================= @@ -6460,6 +6522,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ ДОКУПОК ТРАФИКА ===") + traffic_purchases_ready = await create_traffic_purchases_table() + if traffic_purchases_ready: + logger.info("✅ Таблица traffic_purchases готова") + else: + logger.warning("⚠️ Проблемы с таблицей traffic_purchases") + logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===") tariffs_table_ready = await create_tariffs_table() if tariffs_table_ready: diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index ad869044..1a3796cd 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -510,6 +510,54 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess if subscription_cost > 0: info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" + # Отображаем докупленный трафик + if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов + from app.database.models import TrafficPurchase + from sqlalchemy import select as sql_select + from datetime import datetime + + now = datetime.utcnow() + purchases_query = ( + sql_select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription.id) + .where(TrafficPurchase.expires_at > now) + .order_by(TrafficPurchase.expires_at.asc()) + ) + purchases_result = await db.execute(purchases_query) + purchases = purchases_result.scalars().all() + + if purchases: + info_text += "\n\n📦 Докупленный трафик:" + + for purchase in purchases: + time_remaining = purchase.expires_at - now + days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) + + # Генерируем прогресс-бар + total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() + elapsed_seconds = (now - purchase.created_at).total_seconds() + progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + + bar_length = 10 + filled = int((progress_percent / 100) * bar_length) + bar = "▰" * filled + "▱" * (bar_length - filled) + + # Форматируем дату истечения + expire_date = purchase.expires_at.strftime("%d.%m.%Y") + + # Формируем текст о времени + if days_remaining == 0: + time_text = "истекает сегодня" + elif days_remaining == 1: + time_text = "остался 1 день" + elif days_remaining < 5: + time_text = f"осталось {days_remaining} дня" + else: + time_text = f"осталось {days_remaining} дней" + + info_text += f"\n• {purchase.traffic_gb} ГБ — {time_text}" + info_text += f"\n {bar} {progress_percent:.0f}% | до {expire_date}" + if ( subscription_url and subscription_url != "Генерируется..." diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 7fed0af5..a214c866 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -492,6 +492,58 @@ async def show_subscription_info( message += f"• {device_info}\n" message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") + # Отображаем докупленный трафик + if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов + from app.database.models import TrafficPurchase + from sqlalchemy import select as sql_select + + now = datetime.utcnow() + purchases_query = ( + sql_select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription.id) + .where(TrafficPurchase.expires_at > now) + .order_by(TrafficPurchase.expires_at.asc()) + ) + purchases_result = await db.execute(purchases_query) + purchases = purchases_result.scalars().all() + + if purchases: + message += "\n\n" + texts.t( + "SUBSCRIPTION_PURCHASED_TRAFFIC_TITLE", + "
📦 Докупленный трафик:\n", + ) + + for purchase in purchases: + time_remaining = purchase.expires_at - now + days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) + + # Генерируем прогресс-бар + total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() + elapsed_seconds = (now - purchase.created_at).total_seconds() + progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + + bar_length = 10 + filled = int((progress_percent / 100) * bar_length) + bar = "▰" * filled + "▱" * (bar_length - filled) + + # Форматируем дату истечения + expire_date = purchase.expires_at.strftime("%d.%m.%Y") + + # Формируем текст о времени + if days_remaining == 0: + time_text = "истекает сегодня" + elif days_remaining == 1: + time_text = "остался 1 день" + elif days_remaining < 5: + time_text = f"осталось {days_remaining} дня" + else: + time_text = f"осталось {days_remaining} дней" + + message += f"• {purchase.traffic_gb} ГБ — {time_text}\n" + message += f" {bar} {progress_percent:.0f}% | до {expire_date}\n" + + message += texts.t("SUBSCRIPTION_PURCHASED_TRAFFIC_FOOTER", "
") + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index 00feb030..fec735da 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -601,18 +601,49 @@ async def add_traffic( if traffic_gb == 0: subscription.traffic_limit_gb = 0 - # При переходе на безлимит сбрасываем докупленный трафик + # При переходе на безлимит сбрасываем все докупки + from app.database.models import TrafficPurchase + from sqlalchemy import delete + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None else: await add_subscription_traffic(db, subscription, traffic_gb) - # Записываем докупленный трафик для корректного расчета цены сброса + # Создаём новую запись докупки с индивидуальной датой истечения + from app.database.models import TrafficPurchase + from sqlalchemy import select as sql_select + from datetime import timedelta + + new_expires_at = datetime.utcnow() + timedelta(days=30) + new_purchase = TrafficPurchase( + subscription_id=subscription.id, + traffic_gb=traffic_gb, + expires_at=new_expires_at + ) + db.add(new_purchase) + + # Обновляем общий счетчик докупленного трафика current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 subscription.purchased_traffic_gb = current_purchased + traffic_gb - # Устанавливаем дату сброса при первой докупке (не продлеваем при повторной) - if not subscription.traffic_reset_at: - from datetime import timedelta - subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30) + + # Устанавливаем traffic_reset_at на ближайшую дату истечения из всех активных докупок + now = datetime.utcnow() + active_purchases_query = ( + sql_select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription.id) + .where(TrafficPurchase.expires_at > now) + ) + active_purchases_result = await db.execute(active_purchases_query) + active_purchases = active_purchases_result.scalars().all() + + if active_purchases: + # Добавляем только что созданную покупку к списку + all_active = list(active_purchases) + [new_purchase] + earliest_expiry = min(p.expires_at for p in all_active) + subscription.traffic_reset_at = earliest_expiry + else: + # Первая докупка + subscription.traffic_reset_at = new_expires_at subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) @@ -869,7 +900,10 @@ async def execute_switch_traffic( ) subscription.traffic_limit_gb = new_traffic_gb - # Сбрасываем докупленный трафик при переключении пакета + # Сбрасываем все докупки трафика при переключении пакета + from app.database.models import TrafficPurchase + from sqlalchemy import delete + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика subscription.updated_at = datetime.utcnow() diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index 14172de3..f0f15063 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -269,27 +269,35 @@ class DailySubscriptionService: "errors": 0, } + from app.database.models import TrafficPurchase + try: async for db in get_db(): - # Находим подписки с истёкшим сроком сброса трафика + # Находим все истекшие докупки now = datetime.utcnow() query = ( - select(Subscription) - .where(Subscription.traffic_reset_at.isnot(None)) - .where(Subscription.traffic_reset_at <= now) - .where(Subscription.purchased_traffic_gb > 0) + select(TrafficPurchase) + .where(TrafficPurchase.expires_at <= now) ) result = await db.execute(query) - subscriptions = result.scalars().all() - stats["checked"] = len(subscriptions) + expired_purchases = result.scalars().all() + stats["checked"] = len(expired_purchases) - for subscription in subscriptions: + # Группируем по подпискам для обновления + 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) - stats["reset"] += 1 + await self._reset_subscription_traffic(db, subscription_id, purchases) + stats["reset"] += len(purchases) except Exception as e: logger.error( - f"Ошибка сброса трафика подписки {subscription.id}: {e}", + f"Ошибка сброса трафика подписки {subscription_id}: {e}", exc_info=True ) stats["errors"] += 1 @@ -299,31 +307,100 @@ class DailySubscriptionService: return stats - async def _reset_subscription_traffic(self, db: AsyncSession, subscription: Subscription): - """Сбрасывает докупленный трафик у подписки.""" - purchased_gb = subscription.purchased_traffic_gb or 0 - old_limit = subscription.traffic_limit_gb + 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) - base_limit = tariff.traffic_limit_gb if tariff else old_limit - purchased_gb - else: - base_limit = old_limit - purchased_gb + 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.traffic_limit_gb = max(0, base_limit) - subscription.purchased_traffic_gb = 0 - subscription.traffic_reset_at = None subscription.updated_at = datetime.utcnow() await db.commit() logger.info( - f"🔄 Сброс докупленного трафика: подписка {subscription.id}, " - f"было {old_limit} ГБ, стало {subscription.traffic_limit_gb} ГБ " - f"(сброшено {purchased_gb} ГБ)" + 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 @@ -338,7 +415,7 @@ class DailySubscriptionService: 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, purchased_gb) + await self._notify_traffic_reset(user, subscription, total_expired_gb) async def _notify_traffic_reset(self, user: User, subscription: Subscription, reset_gb: int): """Уведомляет пользователя о сбросе докупленного трафика.""" diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index f2500ec5..43ebcfe5 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3495,7 +3495,40 @@ async def get_subscription_details( else: subscription_missing_reason = "not_found" + # Получаем докупки трафика + traffic_purchases_data = [] + if subscription: + from app.database.models import TrafficPurchase + from sqlalchemy import select as sql_select + + now = datetime.utcnow() + purchases_query = ( + sql_select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription.id) + .where(TrafficPurchase.expires_at > now) + .order_by(TrafficPurchase.expires_at.asc()) + ) + purchases_result = await db.execute(purchases_query) + purchases = purchases_result.scalars().all() + + for purchase in purchases: + time_remaining = purchase.expires_at - now + days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) + total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() + elapsed_seconds = (now - purchase.created_at).total_seconds() + progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + + traffic_purchases_data.append({ + "id": purchase.id, + "traffic_gb": purchase.traffic_gb, + "expires_at": purchase.expires_at, + "created_at": purchase.created_at, + "days_remaining": days_remaining, + "progress_percent": round(progress_percent, 1) + }) + return MiniAppSubscriptionResponse( + traffic_purchases=traffic_purchases_data, subscription_id=getattr(subscription, "id", None), remnawave_short_uuid=remnawave_short_uuid, user=response_user, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 152beaf4..0bac786e 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -679,11 +679,22 @@ class MiniAppDailySubscriptionToggleResponse(BaseModel): balance_label: str = "" +class MiniAppTrafficPurchase(BaseModel): + """Докупка трафика с индивидуальной датой истечения.""" + id: int + traffic_gb: int + expires_at: datetime + created_at: datetime + days_remaining: int + progress_percent: float + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: Optional[int] = None remnawave_short_uuid: Optional[str] = None user: MiniAppSubscriptionUser + traffic_purchases: List[MiniAppTrafficPurchase] = Field(default_factory=list) subscription_url: Optional[str] = None subscription_crypto_link: Optional[str] = None subscription_purchase_url: Optional[str] = None