mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
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:
@@ -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
@@ -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)")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2689,3 +2689,7 @@ class RemnaWaveService:
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"attempts_used": attempts,
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance for backward compatibility
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user