Merge pull request #2288 from BEDOLAGA-DEV/dev5

Dev5
This commit is contained in:
Egor
2026-01-15 17:42:58 +03:00
committed by GitHub
9 changed files with 384 additions and 35 deletions
+5 -1
View File
@@ -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
# В режиме тарифов сохраняем докупленный трафик при продлении
+21
View File
@@ -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"
+69
View File
@@ -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:
+48
View File
@@ -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 != "Генерируется..."
+52
View File
@@ -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()
+41 -7
View File
@@ -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()
+104 -27
View File
@@ -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):
"""Уведомляет пользователя о сбросе докупленного трафика."""
+33
View File
@@ -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,
+11
View File
@@ -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