Add support for custom days and traffic in tariffs

- Introduced fields for custom days and traffic in the tariff model, including enabling flags, pricing, and limits.
- Updated relevant routes and schemas to handle new tariff features.
- Implemented logic for purchasing and managing custom days and traffic in subscriptions.
- Added database migration scripts to accommodate new columns for tariffs and subscriptions.
This commit is contained in:
PEDZEO
2026-01-13 02:55:32 +03:00
parent 8e44db357a
commit a686333603
13 changed files with 1576 additions and 57 deletions

View File

@@ -202,6 +202,19 @@ async def get_tariff(
servers=servers,
promo_groups=promo_groups,
subscriptions_count=subs_count,
# Произвольное количество дней
custom_days_enabled=tariff.custom_days_enabled,
price_per_day_kopeks=tariff.price_per_day_kopeks,
min_days=tariff.min_days,
max_days=tariff.max_days,
# Произвольный трафик при покупке
custom_traffic_enabled=tariff.custom_traffic_enabled,
traffic_price_per_gb_kopeks=tariff.traffic_price_per_gb_kopeks,
min_traffic_gb=tariff.min_traffic_gb,
max_traffic_gb=tariff.max_traffic_gb,
# Дневной тариф
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@@ -238,6 +251,19 @@ async def create_new_tariff(
allowed_squads=request.allowed_squads,
server_traffic_limits=server_limits_dict,
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
# Произвольное количество дней
custom_days_enabled=request.custom_days_enabled,
price_per_day_kopeks=request.price_per_day_kopeks,
min_days=request.min_days,
max_days=request.max_days,
# Произвольный трафик при покупке
custom_traffic_enabled=request.custom_traffic_enabled,
traffic_price_per_gb_kopeks=request.traffic_price_per_gb_kopeks,
min_traffic_gb=request.min_traffic_gb,
max_traffic_gb=request.max_traffic_gb,
# Дневной тариф
is_daily=request.is_daily,
daily_price_kopeks=request.daily_price_kopeks,
)
logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}")
@@ -299,6 +325,29 @@ async def update_existing_tariff(
updates["server_traffic_limits"] = {
uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()
}
# Произвольное количество дней
if request.custom_days_enabled is not None:
updates["custom_days_enabled"] = request.custom_days_enabled
if request.price_per_day_kopeks is not None:
updates["price_per_day_kopeks"] = request.price_per_day_kopeks
if request.min_days is not None:
updates["min_days"] = request.min_days
if request.max_days is not None:
updates["max_days"] = request.max_days
# Произвольный трафик при покупке
if request.custom_traffic_enabled is not None:
updates["custom_traffic_enabled"] = request.custom_traffic_enabled
if request.traffic_price_per_gb_kopeks is not None:
updates["traffic_price_per_gb_kopeks"] = request.traffic_price_per_gb_kopeks
if request.min_traffic_gb is not None:
updates["min_traffic_gb"] = request.min_traffic_gb
if request.max_traffic_gb is not None:
updates["max_traffic_gb"] = request.max_traffic_gb
# Дневной тариф
if request.is_daily is not None:
updates["is_daily"] = request.is_daily
if request.daily_price_kopeks is not None:
updates["daily_price_kopeks"] = request.daily_price_kopeks
if updates:
await update_tariff(db, tariff, **updates)

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,10 @@ class SubscriptionResponse(BaseModel):
subscription_url: Optional[str] = None
is_active: bool
is_expired: bool
# Daily tariff fields
is_daily: bool = False
is_daily_paused: bool = False
tariff_id: Optional[int] = None
class Config:
from_attributes = True
@@ -111,3 +115,4 @@ class TariffPurchaseRequest(BaseModel):
"""Request to purchase a tariff."""
tariff_id: int = Field(..., description="Tariff ID to purchase")
period_days: int = Field(..., description="Period in days")
traffic_gb: Optional[int] = Field(None, ge=0, description="Custom traffic in GB (for custom_traffic_enabled tariffs)")

View File

@@ -87,6 +87,19 @@ class TariffDetailResponse(BaseModel):
servers: List[ServerInfo]
promo_groups: List[PromoGroupInfo]
subscriptions_count: int
# Произвольное количество дней
custom_days_enabled: bool = False
price_per_day_kopeks: int = 0
min_days: int = 1
max_days: int = 365
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False
traffic_price_per_gb_kopeks: int = 0
min_traffic_gb: int = 1
max_traffic_gb: int = 1000
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = 0
created_at: datetime
updated_at: Optional[datetime] = None
@@ -111,6 +124,19 @@ class TariffCreateRequest(BaseModel):
allowed_squads: List[str] = Field(default_factory=list, description="Server UUIDs")
server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict, description="Per-server traffic limits")
promo_group_ids: List[int] = Field(default_factory=list)
# Произвольное количество дней
custom_days_enabled: bool = False
price_per_day_kopeks: int = Field(0, ge=0)
min_days: int = Field(1, ge=1)
max_days: int = Field(365, ge=1)
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False
traffic_price_per_gb_kopeks: int = Field(0, ge=0)
min_traffic_gb: int = Field(1, ge=1)
max_traffic_gb: int = Field(1000, ge=1)
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = Field(0, ge=0)
class TariffUpdateRequest(BaseModel):
@@ -131,6 +157,19 @@ class TariffUpdateRequest(BaseModel):
allowed_squads: Optional[List[str]] = None
server_traffic_limits: Optional[Dict[str, ServerTrafficLimit]] = None
promo_group_ids: Optional[List[int]] = None
# Произвольное количество дней
custom_days_enabled: Optional[bool] = None
price_per_day_kopeks: Optional[int] = Field(None, ge=0)
min_days: Optional[int] = Field(None, ge=1)
max_days: Optional[int] = Field(None, ge=1)
# Произвольный трафик при покупке
custom_traffic_enabled: Optional[bool] = None
traffic_price_per_gb_kopeks: Optional[int] = Field(None, ge=0)
min_traffic_gb: Optional[int] = Field(None, ge=1)
max_traffic_gb: Optional[int] = Field(None, ge=1)
# Дневной тариф
is_daily: Optional[bool] = None
daily_price_kopeks: Optional[int] = Field(None, ge=0)
class TariffToggleResponse(BaseModel):

View File

@@ -243,6 +243,7 @@ async def replace_subscription(
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
@@ -411,14 +412,17 @@ async def extend_subscription(
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ")
elif settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
# В режиме тарифов сохраняем докупленный трафик при продлении
if subscription.tariff_id is None:
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
else:
# При продлении в режиме тарифов - сохраняем purchased_traffic_gb и traffic_reset_at
logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)")
if device_limit is not None:
@@ -458,6 +462,7 @@ async def extend_subscription(
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
subscription.traffic_limit_gb = fixed_limit
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ")
subscription.updated_at = current_time

View File

@@ -175,6 +175,16 @@ async def create_tariff(
max_topup_traffic_gb: int = 0,
is_daily: bool = False,
daily_price_kopeks: int = 0,
# Произвольное количество дней
custom_days_enabled: bool = False,
price_per_day_kopeks: int = 0,
min_days: int = 1,
max_days: int = 365,
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False,
traffic_price_per_gb_kopeks: int = 0,
min_traffic_gb: int = 1,
max_traffic_gb: int = 1000,
) -> Tariff:
"""Создает новый тариф."""
normalized_prices = _normalize_period_prices(period_prices)
@@ -198,6 +208,16 @@ async def create_tariff(
max_topup_traffic_gb=max(0, max_topup_traffic_gb),
is_daily=is_daily,
daily_price_kopeks=max(0, daily_price_kopeks),
# Произвольное количество дней
custom_days_enabled=custom_days_enabled,
price_per_day_kopeks=max(0, price_per_day_kopeks),
min_days=max(1, min_days),
max_days=max(1, max_days),
# Произвольный трафик при покупке
custom_traffic_enabled=custom_traffic_enabled,
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
min_traffic_gb=max(1, min_traffic_gb),
max_traffic_gb=max(1, max_traffic_gb),
)
db.add(tariff)
@@ -250,6 +270,16 @@ async def update_tariff(
max_topup_traffic_gb: Optional[int] = None,
is_daily: Optional[bool] = None,
daily_price_kopeks: Optional[int] = None,
# Произвольное количество дней
custom_days_enabled: Optional[bool] = None,
price_per_day_kopeks: Optional[int] = None,
min_days: Optional[int] = None,
max_days: Optional[int] = None,
# Произвольный трафик при покупке
custom_traffic_enabled: Optional[bool] = None,
traffic_price_per_gb_kopeks: Optional[int] = None,
min_traffic_gb: Optional[int] = None,
max_traffic_gb: Optional[int] = None,
) -> Tariff:
"""Обновляет существующий тариф."""
if name is not None:
@@ -289,6 +319,24 @@ async def update_tariff(
tariff.is_daily = is_daily
if daily_price_kopeks is not None:
tariff.daily_price_kopeks = max(0, daily_price_kopeks)
# Произвольное количество дней
if custom_days_enabled is not None:
tariff.custom_days_enabled = custom_days_enabled
if price_per_day_kopeks is not None:
tariff.price_per_day_kopeks = max(0, price_per_day_kopeks)
if min_days is not None:
tariff.min_days = max(1, min_days)
if max_days is not None:
tariff.max_days = max(1, max_days)
# Произвольный трафик при покупке
if custom_traffic_enabled is not None:
tariff.custom_traffic_enabled = custom_traffic_enabled
if traffic_price_per_gb_kopeks is not None:
tariff.traffic_price_per_gb_kopeks = max(0, traffic_price_per_gb_kopeks)
if min_traffic_gb is not None:
tariff.min_traffic_gb = max(1, min_traffic_gb)
if max_traffic_gb is not None:
tariff.max_traffic_gb = max(1, max_traffic_gb)
# Обновляем промогруппы если указаны
if promo_group_ids is not None:

View File

@@ -793,6 +793,18 @@ class Tariff(Base):
is_daily = Column(Boolean, default=False, nullable=False) # Является ли тариф суточным
daily_price_kopeks = Column(Integer, default=0, nullable=False) # Цена за день в копейках
# Произвольное количество дней
custom_days_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольное кол-во дней
price_per_day_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 день в копейках
min_days = Column(Integer, default=1, nullable=False) # Минимальное количество дней
max_days = Column(Integer, default=365, nullable=False) # Максимальное количество дней
# Произвольный трафик при покупке
custom_traffic_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольный трафик
traffic_price_per_gb_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 ГБ в копейках
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
@@ -878,6 +890,30 @@ class Tariff(Base):
"""Возвращает суточную цену в рублях."""
return self.daily_price_kopeks / 100 if self.daily_price_kopeks else 0
def get_price_for_custom_days(self, days: int) -> Optional[int]:
"""Возвращает цену для произвольного количества дней."""
if not self.custom_days_enabled or not self.price_per_day_kopeks:
return None
if days < self.min_days or days > self.max_days:
return None
return self.price_per_day_kopeks * days
def get_price_for_custom_traffic(self, gb: int) -> Optional[int]:
"""Возвращает цену для произвольного количества трафика."""
if not self.custom_traffic_enabled or not self.traffic_price_per_gb_kopeks:
return None
if gb < self.min_traffic_gb or gb > self.max_traffic_gb:
return None
return self.traffic_price_per_gb_kopeks * gb
def can_purchase_custom_days(self) -> bool:
"""Проверяет, можно ли купить произвольное количество дней."""
return self.custom_days_enabled and self.price_per_day_kopeks > 0
def can_purchase_custom_traffic(self) -> bool:
"""Проверяет, можно ли купить произвольный трафик."""
return self.custom_traffic_enabled and self.traffic_price_per_gb_kopeks > 0
def __repr__(self):
return f"<Tariff(id={self.id}, name='{self.name}', tier={self.tier_level}, active={self.is_active})>"
@@ -1013,7 +1049,8 @@ class Subscription(Base):
traffic_limit_gb = Column(Integer, default=0)
traffic_used_gb = Column(Float, default=0.0)
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса)
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик
traffic_reset_at = Column(DateTime, nullable=True) # Дата сброса докупленного трафика (30 дней после первой докупки)
subscription_url = Column(String, nullable=True)
subscription_crypto_link = Column(String, nullable=True)

View File

@@ -5770,6 +5770,130 @@ async def add_tariff_daily_columns() -> bool:
return False
async def add_tariff_custom_days_traffic_columns() -> bool:
"""Добавляет колонки для произвольных дней и трафика в тарифы."""
try:
columns_added = 0
db_type = await get_database_type()
# === ПРОИЗВОЛЬНОЕ КОЛИЧЕСТВО ДНЕЙ ===
# custom_days_enabled
if not await check_column_exists('tariffs', 'custom_days_enabled'):
async with engine.begin() as conn:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_days_enabled INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_days_enabled BOOLEAN DEFAULT FALSE NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_days_enabled TINYINT(1) DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка custom_days_enabled добавлена в tariffs")
columns_added += 1
else:
logger.info(" Колонка custom_days_enabled уже существует в tariffs")
# price_per_day_kopeks
if not await check_column_exists('tariffs', 'price_per_day_kopeks'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN price_per_day_kopeks INTEGER DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка price_per_day_kopeks добавлена в tariffs")
columns_added += 1
else:
logger.info(" Колонка price_per_day_kopeks уже существует в tariffs")
# min_days
if not await check_column_exists('tariffs', 'min_days'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN min_days INTEGER DEFAULT 1 NOT NULL"
))
logger.info("✅ Колонка min_days добавлена в tariffs")
columns_added += 1
else:
logger.info(" Колонка min_days уже существует в tariffs")
# max_days
if not await check_column_exists('tariffs', 'max_days'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_days INTEGER DEFAULT 365 NOT NULL"
))
logger.info("✅ Колонка max_days добавлена в tariffs")
columns_added += 1
else:
logger.info(" Колонка max_days уже существует в tariffs")
# === ПРОИЗВОЛЬНЫЙ ТРАФИК ПРИ ПОКУПКЕ ===
# custom_traffic_enabled
if not await check_column_exists('tariffs', 'custom_traffic_enabled'):
async with engine.begin() as conn:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled BOOLEAN DEFAULT FALSE NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled TINYINT(1) DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка custom_traffic_enabled добавлена в tariffs")
columns_added += 1
else:
logger.info(" Колонка custom_traffic_enabled уже существует в tariffs")
# traffic_price_per_gb_kopeks
if not await check_column_exists('tariffs', 'traffic_price_per_gb_kopeks'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_price_per_gb_kopeks INTEGER DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка traffic_price_per_gb_kopeks добавлена в tariffs")
columns_added += 1
else:
logger.info(" Колонка traffic_price_per_gb_kopeks уже существует в tariffs")
# min_traffic_gb
if not await check_column_exists('tariffs', 'min_traffic_gb'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN min_traffic_gb INTEGER DEFAULT 1 NOT NULL"
))
logger.info("✅ Колонка min_traffic_gb добавлена в tariffs")
columns_added += 1
else:
logger.info(" Колонка min_traffic_gb уже существует в tariffs")
# max_traffic_gb
if not await check_column_exists('tariffs', 'max_traffic_gb'):
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_traffic_gb INTEGER DEFAULT 1000 NOT NULL"
))
logger.info("✅ Колонка max_traffic_gb добавлена в tariffs")
columns_added += 1
else:
logger.info(" Колонка max_traffic_gb уже существует в tariffs")
if columns_added > 0:
logger.info(f"✅ Добавлено {columns_added} колонок для произвольных дней/трафика")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонок произвольных дней/трафика: {error}")
return False
async def add_subscription_daily_columns() -> bool:
"""Добавляет колонки для суточных подписок."""
try:
@@ -5828,6 +5952,37 @@ async def add_subscription_daily_columns() -> bool:
return False
async def add_subscription_traffic_reset_at_column() -> bool:
"""Добавляет колонку traffic_reset_at в subscriptions для сброса докупленного трафика через 30 дней."""
try:
if not await check_column_exists('subscriptions', 'traffic_reset_at'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN traffic_reset_at TIMESTAMP NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL"
))
logger.info("✅ Колонка traffic_reset_at добавлена в subscriptions")
return True
else:
logger.info(" Колонка traffic_reset_at уже существует в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки traffic_reset_at: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -6355,6 +6510,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с колонками суточных тарифов в tariffs")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ПРОИЗВОЛЬНЫХ ДНЕЙ/ТРАФИКА ===")
custom_days_traffic_ready = await add_tariff_custom_days_traffic_columns()
if custom_days_traffic_ready:
logger.info("✅ Колонки произвольных дней/трафика в tariffs готовы")
else:
logger.warning("⚠️ Проблемы с колонками произвольных дней/трафика в tariffs")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===")
daily_subscription_columns_ready = await add_subscription_daily_columns()
if daily_subscription_columns_ready:
@@ -6362,6 +6524,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с колонками суточных подписок в subscriptions")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ СБРОСА ТРАФИКА ===")
traffic_reset_column_ready = await add_subscription_traffic_reset_at_column()
if traffic_reset_column_ready:
logger.info("✅ Колонка traffic_reset_at в subscriptions готова")
else:
logger.warning("⚠️ Проблемы с колонкой traffic_reset_at в subscriptions")
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
fk_updated = await fix_foreign_keys_for_user_deletion()
if fk_updated:

View File

@@ -1967,6 +1967,7 @@ async def confirm_extend_subscription(
traffic_was_reset = True
subscription.traffic_limit_gb = fixed_limit
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f"🔄 Сброс трафика при продлении: {old_traffic_limit} ГБ → {fixed_limit} ГБ")
await db.commit()

View File

@@ -603,11 +603,16 @@ async def add_traffic(
subscription.traffic_limit_gb = 0
# При переходе на безлимит сбрасываем докупленный трафик
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
else:
await add_subscription_traffic(db, subscription, traffic_gb)
# Записываем докупленный трафик для корректного расчета цены сброса
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)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
@@ -866,6 +871,7 @@ async def execute_switch_traffic(
subscription.traffic_limit_gb = new_traffic_gb
# Сбрасываем докупленный трафик при переключении пакета
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
subscription.updated_at = datetime.utcnow()
await db.commit()

View File

@@ -1,6 +1,7 @@
"""
Сервис для автоматического списания суточных подписок.
Проверяет подписки с суточным тарифом и списывает плату раз в сутки.
Также сбрасывает докупленный трафик по истечении 30 дней.
"""
import logging
import asyncio
@@ -8,6 +9,8 @@ from datetime import datetime
from typing import Optional
from aiogram import Bot
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import get_db
@@ -18,7 +21,7 @@ from app.database.crud.subscription import (
)
from app.database.crud.user import subtract_user_balance, get_user_by_id
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType, PaymentMethod
from app.database.models import TransactionType, PaymentMethod, Subscription, User
from app.localization.texts import get_texts
@@ -253,8 +256,114 @@ class DailySubscriptionService:
except Exception as e:
logger.warning(f"Не удалось отправить уведомление о недостатке средств: {e}")
async def process_traffic_resets(self) -> dict:
"""
Сбрасывает докупленный трафик у подписок, у которых истёк срок.
Returns:
dict: Статистика обработки
"""
stats = {
"checked": 0,
"reset": 0,
"errors": 0,
}
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)
)
result = await db.execute(query)
subscriptions = result.scalars().all()
stats["checked"] = len(subscriptions)
for subscription in subscriptions:
try:
await self._reset_subscription_traffic(db, subscription)
stats["reset"] += 1
except Exception as e:
logger.error(
f"Ошибка сброса трафика подписки {subscription.id}: {e}",
exc_info=True
)
stats["errors"] += 1
except Exception as e:
logger.error(f"Ошибка при получении подписок для сброса трафика: {e}", exc_info=True)
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
# Получаем тариф для базового лимита
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
# Сбрасываем докупленный трафик
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} ГБ)"
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.warning(f"Не удалось синхронизировать с RemnaWave после сброса трафика: {e}")
# Уведомляем пользователя
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)
async def _notify_traffic_reset(self, user: User, subscription: Subscription, reset_gb: int):
"""Уведомляет пользователя о сбросе докупленного трафика."""
if not self._bot:
return
try:
message = (
f" <b>Сброс докупленного трафика</b>\n\n"
f"Ваш докупленный трафик ({reset_gb} ГБ) был сброшен, "
f"так как прошло 30 дней с момента первой докупки.\n\n"
f"Текущий лимит трафика: {subscription.traffic_limit_gb} ГБ\n\n"
f"Вы можете докупить трафик снова в любое время."
)
await self._bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML",
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление о сбросе трафика: {e}")
async def start_monitoring(self):
"""Запускает периодическую проверку суточных подписок."""
"""Запускает периодическую проверку суточных подписок и сброса трафика."""
self._running = True
interval_minutes = self.get_check_interval_minutes()
@@ -264,6 +373,7 @@ class DailySubscriptionService:
while self._running:
try:
# Обработка суточных списаний
stats = await self.process_daily_charges()
if stats["charged"] > 0 or stats["suspended"] > 0:
@@ -272,6 +382,14 @@ class DailySubscriptionService:
f"списано={stats['charged']}, приостановлено={stats['suspended']}, "
f"ошибок={stats['errors']}"
)
# Обработка сброса докупленного трафика
traffic_stats = await self.process_traffic_resets()
if traffic_stats["reset"] > 0:
logger.info(
f"📊 Сброс трафика: проверено={traffic_stats['checked']}, "
f"сброшено={traffic_stats['reset']}, ошибок={traffic_stats['errors']}"
)
except Exception as e:
logger.error(f"Ошибка в цикле проверки суточных подписок: {e}", exc_info=True)

View File

@@ -2689,3 +2689,7 @@ class RemnaWaveService:
"api_url": settings.REMNAWAVE_API_URL,
"attempts_used": attempts,
}
# Singleton instance for backward compatibility
remnawave_service = RemnaWaveService()

View File

@@ -7019,6 +7019,7 @@ async def switch_tariff_endpoint(
subscription.connected_squads = squads
# Сбрасываем докупленный трафик при смене тарифа
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
# Обработка daily полей при смене тарифа
new_is_daily = getattr(new_tariff, 'is_daily', False)