@@ -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
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -510,6 +510,54 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
if subscription_cost > 0:
|
||||
info_text += f"\n💰 <b>Стоимость подписки в месяц:</b> {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📦 <b>Докупленный трафик:</b>"
|
||||
|
||||
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 != "Генерируется..."
|
||||
|
||||
@@ -492,6 +492,58 @@ async def show_subscription_info(
|
||||
message += f"• {device_info}\n"
|
||||
message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "</blockquote>")
|
||||
|
||||
# Отображаем докупленный трафик
|
||||
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",
|
||||
"<blockquote>📦 <b>Докупленный трафик:</b>\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", "</blockquote>")
|
||||
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
"""Уведомляет пользователя о сбросе докупленного трафика."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user