Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5367874062 | |||
| 321696b5b5 | |||
| b642a5550c | |||
| 0e97632099 | |||
| 4a3c6c4124 | |||
| 3bf9c143d4 | |||
| 285e5e256e | |||
| 5b06b89d7a | |||
| 7e749fa096 | |||
| 13ac3f0e74 | |||
| 32c551999c | |||
| ff2c09d3be | |||
| 4c97ac4497 | |||
| 86516186d9 | |||
| e0581bdc3c |
@@ -19,6 +19,8 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
@@ -98,17 +100,30 @@ async def extend_subscription(
|
||||
subscription: Subscription,
|
||||
days: int
|
||||
) -> Subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
subscription.extend_subscription(days)
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Подписка пользователя {subscription.user_id} продлена на {days} дней")
|
||||
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
|
||||
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -55,31 +55,27 @@ class User(Base):
|
||||
username = Column(String(255), nullable=True)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
|
||||
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
||||
language = Column(String(5), default="ru")
|
||||
|
||||
balance_kopeks = Column(Integer, default=0)
|
||||
|
||||
used_promocodes = Column(Integer, default=0)
|
||||
|
||||
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
referral_code = Column(String(20), unique=True, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_activity = Column(DateTime, default=func.now())
|
||||
|
||||
remnawave_uuid = Column(String(255), nullable=True, unique=True)
|
||||
|
||||
broadcasts = relationship("BroadcastHistory", back_populates="admin")
|
||||
|
||||
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
|
||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
last_remnawave_sync = Column(DateTime, nullable=True)
|
||||
trojan_password = Column(String(255), nullable=True)
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -104,7 +100,7 @@ class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
|
||||
is_trial = Column(Boolean, default=True)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import logging
|
||||
from sqlalchemy import text, inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.database import engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_database_type():
|
||||
"""Определяет тип базы данных"""
|
||||
return engine.dialect.name
|
||||
|
||||
async def check_unique_constraint_exists():
|
||||
"""Проверяет, существует ли ограничение уникальности на user_id"""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
result = await conn.execute(text("PRAGMA table_info(subscriptions)"))
|
||||
columns = result.fetchall()
|
||||
|
||||
check_result = await conn.execute(text("""
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 1
|
||||
"""))
|
||||
|
||||
duplicates = check_result.fetchall()
|
||||
return len(duplicates) == 0
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_name = 'subscriptions'
|
||||
AND constraint_type = 'UNIQUE'
|
||||
AND constraint_name LIKE '%user_id%'
|
||||
"""))
|
||||
constraints = result.fetchall()
|
||||
return len(constraints) > 0
|
||||
|
||||
elif db_type == 'mysql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT CONSTRAINT_NAME
|
||||
FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_NAME = 'subscriptions'
|
||||
AND CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND CONSTRAINT_NAME LIKE '%user_id%'
|
||||
"""))
|
||||
constraints = result.fetchall()
|
||||
return len(constraints) > 0
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки ограничения уникальности: {e}")
|
||||
return False
|
||||
|
||||
async def check_column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""Проверяет, существует ли колонка в таблице"""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
result = await conn.execute(text(f"PRAGMA table_info({table_name})"))
|
||||
columns = result.fetchall()
|
||||
return any(col[1] == column_name for col in columns)
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = :table_name
|
||||
AND column_name = :column_name
|
||||
"""), {"table_name": table_name, "column_name": column_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
elif db_type == 'mysql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_NAME = :table_name
|
||||
AND COLUMN_NAME = :column_name
|
||||
"""), {"table_name": table_name, "column_name": column_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
|
||||
return False
|
||||
|
||||
async def add_remnawave_v2_columns():
|
||||
"""Добавляет колонки для поддержки RemnaWave API v2.1.5"""
|
||||
|
||||
columns_to_add = {
|
||||
'lifetime_used_traffic_bytes': 'BIGINT DEFAULT 0',
|
||||
'last_remnawave_sync': 'TIMESTAMP NULL',
|
||||
'trojan_password': 'VARCHAR(255) NULL',
|
||||
'vless_uuid': 'VARCHAR(255) NULL',
|
||||
'ss_password': 'VARCHAR(255) NULL'
|
||||
}
|
||||
|
||||
logger.info("=== ПРОВЕРКА КОЛОНОК REMNAWAVE V2.1.5 ===")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
columns_added = 0
|
||||
|
||||
for column_name, column_def in columns_to_add.items():
|
||||
exists = await check_column_exists('users', column_name)
|
||||
|
||||
if not exists:
|
||||
logger.info(f"Добавление колонки {column_name} в таблицу users")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
if column_def.startswith('BIGINT'):
|
||||
column_def = column_def.replace('BIGINT', 'INTEGER')
|
||||
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
|
||||
elif db_type == 'mysql':
|
||||
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
|
||||
|
||||
try:
|
||||
await conn.execute(text(f"ALTER TABLE users ADD COLUMN {column_name} {column_def}"))
|
||||
columns_added += 1
|
||||
logger.info(f"Колонка {column_name} успешно добавлена")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонки {column_name}: {e}")
|
||||
continue
|
||||
|
||||
else:
|
||||
logger.debug(f"Колонка {column_name} уже существует")
|
||||
|
||||
if columns_added > 0:
|
||||
logger.info(f"Добавлено {columns_added} новых колонок для RemnaWave v2.1.5")
|
||||
else:
|
||||
logger.info("Все колонки RemnaWave v2.1.5 уже существуют")
|
||||
|
||||
return columns_added
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении колонок RemnaWave v2.1.5: {e}")
|
||||
return 0
|
||||
|
||||
async def fix_subscription_duplicates_universal():
|
||||
"""Универсальная функция очистки дубликатов для разных типов БД"""
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Обнаружен тип базы данных: {db_type}")
|
||||
|
||||
try:
|
||||
result = await conn.execute(text("""
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""))
|
||||
|
||||
duplicates = result.fetchall()
|
||||
|
||||
if not duplicates:
|
||||
logger.info("Дублирующихся подписок не найдено")
|
||||
return 0
|
||||
|
||||
logger.info(f"Найдено {len(duplicates)} пользователей с дублирующимися подписками")
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
for user_id_row, count in duplicates:
|
||||
user_id = user_id_row
|
||||
|
||||
if db_type == 'sqlite':
|
||||
delete_result = await conn.execute(text("""
|
||||
DELETE FROM subscriptions
|
||||
WHERE user_id = :user_id AND id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM subscriptions
|
||||
WHERE user_id = :user_id
|
||||
)
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
elif db_type in ['postgresql', 'mysql']:
|
||||
delete_result = await conn.execute(text("""
|
||||
DELETE FROM subscriptions
|
||||
WHERE user_id = :user_id AND id NOT IN (
|
||||
SELECT max_id FROM (
|
||||
SELECT MAX(id) as max_id
|
||||
FROM subscriptions
|
||||
WHERE user_id = :user_id
|
||||
) as subquery
|
||||
)
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
else:
|
||||
subs_result = await conn.execute(text("""
|
||||
SELECT id FROM subscriptions
|
||||
WHERE user_id = :user_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
sub_ids = [row[0] for row in subs_result.fetchall()]
|
||||
|
||||
if len(sub_ids) > 1:
|
||||
ids_to_delete = sub_ids[1:]
|
||||
for sub_id in ids_to_delete:
|
||||
await conn.execute(text("""
|
||||
DELETE FROM subscriptions WHERE id = :id
|
||||
"""), {"id": sub_id})
|
||||
delete_result = type('Result', (), {'rowcount': len(ids_to_delete)})()
|
||||
else:
|
||||
delete_result = type('Result', (), {'rowcount': 0})()
|
||||
|
||||
deleted_count = delete_result.rowcount
|
||||
total_deleted += deleted_count
|
||||
logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}")
|
||||
|
||||
logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке дублирующихся подписок: {e}")
|
||||
raise
|
||||
|
||||
async def run_universal_migration():
|
||||
"""Запускает универсальную миграцию"""
|
||||
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ПОДПИСОК ===")
|
||||
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
|
||||
await add_remnawave_v2_columns()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
||||
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
|
||||
|
||||
total_count = total_subs.fetchone()[0]
|
||||
unique_count = unique_users.fetchone()[0]
|
||||
|
||||
logger.info(f"Всего подписок: {total_count}")
|
||||
logger.info(f"Уникальных пользователей: {unique_count}")
|
||||
|
||||
if total_count == unique_count:
|
||||
logger.info("База данных уже в корректном состоянии")
|
||||
return True
|
||||
|
||||
deleted_count = await fix_subscription_duplicates_universal()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
final_check = await conn.execute(text("""
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""))
|
||||
|
||||
remaining_duplicates = final_check.fetchall()
|
||||
|
||||
if remaining_duplicates:
|
||||
logger.warning(f"Остались дубликаты у {len(remaining_duplicates)} пользователей")
|
||||
return False
|
||||
else:
|
||||
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
|
||||
return False
|
||||
@@ -33,7 +33,7 @@ class RemnaWaveUser:
|
||||
username: str
|
||||
status: UserStatus
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int # Новое поле
|
||||
traffic_limit_bytes: int
|
||||
traffic_limit_strategy: TrafficLimitStrategy
|
||||
expire_at: datetime
|
||||
@@ -46,6 +46,16 @@ class RemnaWaveUser:
|
||||
active_internal_squads: List[Dict[str, str]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
sub_last_user_agent: Optional[str] = None
|
||||
sub_last_opened_at: Optional[datetime] = None
|
||||
online_at: Optional[datetime] = None
|
||||
sub_revoked_at: Optional[datetime] = None
|
||||
last_traffic_reset_at: Optional[datetime] = None
|
||||
trojan_password: Optional[str] = None
|
||||
vless_uuid: Optional[str] = None
|
||||
ss_password: Optional[str] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_triggered_threshold: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -498,8 +508,23 @@ class RemnaWaveAPI:
|
||||
subscription_url=user_data['subscriptionUrl'],
|
||||
active_internal_squads=user_data['activeInternalSquads'],
|
||||
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00'))
|
||||
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
|
||||
sub_last_user_agent=user_data.get('subLastUserAgent'),
|
||||
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
|
||||
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
|
||||
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
|
||||
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
|
||||
trojan_password=user_data.get('trojanPassword'),
|
||||
vless_uuid=user_data.get('vlessUuid'),
|
||||
ss_password=user_data.get('ssPassword'),
|
||||
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
if date_str:
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return None
|
||||
|
||||
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
|
||||
return RemnaWaveInternalSquad(
|
||||
|
||||
@@ -55,15 +55,46 @@ async def show_balance_history(
|
||||
|
||||
offset = (page - 1) * TRANSACTIONS_PER_PAGE
|
||||
|
||||
transactions = await get_user_transactions(
|
||||
raw_transactions = await get_user_transactions(
|
||||
db, db_user.id,
|
||||
limit=TRANSACTIONS_PER_PAGE,
|
||||
limit=TRANSACTIONS_PER_PAGE * 3,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
total_count = await get_user_transactions_count(db, db_user.id)
|
||||
seen_transactions = set()
|
||||
unique_transactions = []
|
||||
|
||||
if not transactions:
|
||||
for transaction in raw_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
|
||||
if transaction_key not in seen_transactions:
|
||||
seen_transactions.add(transaction_key)
|
||||
unique_transactions.append(transaction)
|
||||
|
||||
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
|
||||
break
|
||||
|
||||
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
|
||||
seen_all = set()
|
||||
total_unique = 0
|
||||
|
||||
for transaction in all_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
if transaction_key not in seen_all:
|
||||
seen_all.add(transaction_key)
|
||||
total_unique += 1
|
||||
|
||||
if not unique_transactions:
|
||||
await callback.message.edit_text(
|
||||
"📊 История операций пуста",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -73,7 +104,7 @@ async def show_balance_history(
|
||||
|
||||
text = "📊 <b>История операций</b>\n\n"
|
||||
|
||||
for transaction in transactions:
|
||||
for transaction in unique_transactions:
|
||||
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
|
||||
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
|
||||
|
||||
@@ -82,7 +113,7 @@ async def show_balance_history(
|
||||
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
keyboard = []
|
||||
total_pages = (total_count + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
||||
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_pagination_keyboard(
|
||||
|
||||
@@ -906,19 +906,30 @@ async def confirm_extend_subscription(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
days = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription:
|
||||
await callback.answer("❌ У вас нет активной подписки", show_alert=True)
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
price = await subscription_service.calculate_renewal_price(subscription, days, db)
|
||||
|
||||
try:
|
||||
price = await subscription_service.calculate_renewal_price(subscription, days, db)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
|
||||
await callback.answer("❌ Ошибка расчета стоимости", show_alert=True)
|
||||
return
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("❌ Недостаточно средств на балансе", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info(f"🔄 Начинаем продление подписки {subscription.id} на {days} дней за {price/100}₽")
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price,
|
||||
f"Продление подписки на {days} дней"
|
||||
@@ -928,18 +939,40 @@ async def confirm_extend_subscription(
|
||||
await callback.answer("❌ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
await extend_subscription(db, subscription, days)
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
if subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Продление подписки на {days} дней"
|
||||
)
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
try:
|
||||
remnawave_result = await subscription_service.update_remnawave_user(db, subscription)
|
||||
if remnawave_result:
|
||||
logger.info(f"✅ RemnaWave обновлен успешно")
|
||||
else:
|
||||
logger.error(f"❌ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
|
||||
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Продление подписки на {days} дней"
|
||||
)
|
||||
logger.info(f"✅ Транзакция создана: ID {transaction.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ОШИБКА СОЗДАНИЯ ТРАНЗАКЦИИ: {e}")
|
||||
|
||||
try:
|
||||
await process_referral_purchase(
|
||||
@@ -948,11 +981,9 @@ async def confirm_extend_subscription(
|
||||
purchase_amount_kopeks=price,
|
||||
transaction_id=None
|
||||
)
|
||||
logger.info(f"✅ Рефералы обработаны")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферальной покупки: {e}")
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
logger.error(f"❌ ОШИБКА ОБРАБОТКИ РЕФЕРАЛОВ: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подписка успешно продлена!\n\n"
|
||||
@@ -965,9 +996,12 @@ async def confirm_extend_subscription(
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка продления подписки: {e}")
|
||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
|
||||
import traceback
|
||||
logger.error(f"TRACEBACK: {traceback.format_exc()}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
"❌ Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@@ -1429,27 +1463,29 @@ async def confirm_purchase(
|
||||
|
||||
existing_subscription = db_user.subscription
|
||||
|
||||
if existing_subscription and existing_subscription.is_trial:
|
||||
logger.info(f"🔄 Обновляем триальную подписку пользователя {db_user.telegram_id}")
|
||||
if existing_subscription:
|
||||
logger.info(f"🔄 Обновляем существующую подписку пользователя {db_user.telegram_id}")
|
||||
|
||||
existing_subscription.is_trial = False
|
||||
existing_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
existing_subscription.traffic_limit_gb = final_traffic_gb
|
||||
existing_subscription.device_limit = data['devices']
|
||||
existing_subscription.connected_squads = data['countries']
|
||||
|
||||
existing_subscription.extend_subscription(data['period_days'])
|
||||
existing_subscription.start_date = datetime.utcnow()
|
||||
existing_subscription.end_date = datetime.utcnow() + timedelta(days=data['period_days'])
|
||||
existing_subscription.updated_at = datetime.utcnow()
|
||||
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
subscription = existing_subscription
|
||||
|
||||
logger.info(f"✅ Триальная подписка обновлена до платной. Новая дата окончания: {subscription.end_date}")
|
||||
logger.info(f"✅ Подписка обновлена. Новая дата окончания: {subscription.end_date}")
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем новую платную подписку для пользователя {db_user.telegram_id}")
|
||||
logger.info(f"🆕 Создаем новую подписку для пользователя {db_user.telegram_id}")
|
||||
subscription = await create_paid_subscription_with_traffic_mode(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -1479,7 +1515,7 @@ async def confirm_purchase(
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
logger.info(f"📝 Обновляем существующего RemnaWave пользователя {db_user.remnawave_uuid}")
|
||||
logger.info(f"🔄 Обновляем существующего RemnaWave пользователя {db_user.remnawave_uuid}")
|
||||
remnawave_user = await subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового RemnaWave пользователя для {db_user.telegram_id}")
|
||||
|
||||
@@ -283,7 +283,7 @@ class RussianTexts(Texts):
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRING_PAID = """
|
||||
⚠️ <b>Подписка истекает через {days} дней!</b>
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {end_date}.
|
||||
|
||||
|
||||
@@ -369,7 +369,10 @@ class MonitoringService:
|
||||
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
|
||||
try:
|
||||
from app.utils.formatters import format_days_declension
|
||||
|
||||
texts = get_texts(user.language)
|
||||
days_text = format_days_declension(days, user.language)
|
||||
|
||||
if subscription.autopay_enabled:
|
||||
autopay_status = "✅ Включен - подписка продлится автоматически"
|
||||
@@ -379,7 +382,7 @@ class MonitoringService:
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days} дней!</b>
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
|
||||
else:
|
||||
return "только что"
|
||||
|
||||
def format_days_declension(days: int, language: str = "ru") -> str:
|
||||
if language != "ru":
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
|
||||
if days % 10 == 1 and days % 100 != 11:
|
||||
return f"{days} день"
|
||||
elif days % 10 in [2, 3, 4] and days % 100 not in [12, 13, 14]:
|
||||
return f"{days} дня"
|
||||
else:
|
||||
return f"{days} дней"
|
||||
|
||||
|
||||
def format_duration(seconds: int) -> str:
|
||||
if seconds < 60:
|
||||
@@ -204,4 +215,4 @@ def format_boolean(value: bool, language: str = "ru") -> str:
|
||||
if language == "ru":
|
||||
return "✅ Да" if value else "❌ Нет"
|
||||
else:
|
||||
return "✅ Yes" if value else "❌ No"
|
||||
return "✅ Yes" if value else "❌ No"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.models import Subscription, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Optional[Subscription]:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
if len(subscriptions) <= 1:
|
||||
return subscriptions[0] if subscriptions else None
|
||||
|
||||
latest_subscription = subscriptions[0]
|
||||
old_subscriptions = subscriptions[1:]
|
||||
|
||||
logger.warning(f"🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.")
|
||||
|
||||
for old_sub in old_subscriptions:
|
||||
await db.delete(old_sub)
|
||||
logger.info(f"🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(latest_subscription)
|
||||
|
||||
logger.info(f"✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}")
|
||||
return latest_subscription
|
||||
|
||||
|
||||
async def update_or_create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
**subscription_data
|
||||
) -> Subscription:
|
||||
existing_subscription = await ensure_single_subscription(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
for key, value in subscription_data.items():
|
||||
if hasattr(existing_subscription, key):
|
||||
setattr(existing_subscription, key, value)
|
||||
|
||||
existing_subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
|
||||
return existing_subscription
|
||||
|
||||
else:
|
||||
new_subscription = Subscription(
|
||||
user_id=user_id,
|
||||
**subscription_data
|
||||
)
|
||||
|
||||
db.add(new_subscription)
|
||||
await db.commit()
|
||||
await db.refresh(new_subscription)
|
||||
|
||||
logger.info(f"🆕 Создана новая подписка ID {new_subscription.id}")
|
||||
return new_subscription
|
||||
|
||||
|
||||
async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
|
||||
result = await db.execute(
|
||||
select(Subscription.user_id)
|
||||
.group_by(Subscription.user_id)
|
||||
.having(func.count(Subscription.id) > 1)
|
||||
)
|
||||
users_with_duplicates = result.scalars().all()
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
for user_id in users_with_duplicates:
|
||||
subscriptions_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
subscriptions = subscriptions_result.scalars().all()
|
||||
|
||||
for old_subscription in subscriptions[1:]:
|
||||
await db.delete(old_subscription)
|
||||
total_deleted += 1
|
||||
logger.info(f"🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
|
||||
|
||||
return total_deleted
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
@@ -10,6 +11,7 @@ from app.config import settings
|
||||
from app.database.database import init_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -23,7 +25,7 @@ async def main():
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🚀 Запуск VPN бота...")
|
||||
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
|
||||
|
||||
webhook_server = None
|
||||
|
||||
@@ -31,12 +33,29 @@ async def main():
|
||||
logger.info("📊 Инициализация базы данных...")
|
||||
await init_db()
|
||||
|
||||
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
||||
|
||||
if not skip_migration:
|
||||
logger.info("🔧 Выполняем проверку и миграцию базы данных...")
|
||||
try:
|
||||
migration_success = await run_universal_migration()
|
||||
|
||||
if migration_success:
|
||||
logger.info("✅ Миграция базы данных завершена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск")
|
||||
|
||||
except Exception as migration_error:
|
||||
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
|
||||
logger.warning("⚠️ Продолжаем запуск без миграции")
|
||||
else:
|
||||
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
|
||||
|
||||
logger.info("🤖 Настройка бота...")
|
||||
bot, dp = await setup_bot()
|
||||
|
||||
monitoring_service.bot = bot
|
||||
|
||||
# Инициализируем webhook сервер если Tribute включен
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info("🌐 Запуск webhook сервера для Tribute...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
@@ -78,4 +97,4 @@ if __name__ == "__main__":
|
||||
print("\n🛑 Бот остановлен пользователем")
|
||||
except Exception as e:
|
||||
print(f"❌ Критическая ошибка: {e}")
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 463 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 240 KiB |