diff --git a/database.py b/database.py index ed8cb718..d869dfb0 100644 --- a/database.py +++ b/database.py @@ -1568,3 +1568,1444 @@ async def get_all_keys(session: Any = None): finally: if conn is not None and session is None: await conn.close() +import json +from datetime import datetime +from typing import Any + +import asyncpg +import pytz + +from config import DATABASE_URL, REFERRAL_BONUS_PERCENTAGES +from logger import logger + + +async def create_temporary_data(session, tg_id: int, state: str, data: dict): + """Сохраняет временные данные пользователя.""" + await session.execute( + """ + INSERT INTO temporary_data (tg_id, state, data, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (tg_id) + DO UPDATE SET state = $2, data = $3, updated_at = $4 + """, + tg_id, + state, + json.dumps(data), + datetime.utcnow(), + ) + + +async def get_temporary_data(session, tg_id: int) -> dict | None: + """Извлекает временные данные пользователя.""" + result = await session.fetchrow("SELECT state, data FROM temporary_data WHERE tg_id = $1", tg_id) + if result: + return {"state": result["state"], "data": json.loads(result["data"])} + return None + + +async def clear_temporary_data(session, tg_id: int): + # TODO rename delete_temporary_data + await session.execute("DELETE FROM temporary_data WHERE tg_id = $1", tg_id) + + +async def create_blocked_user(tg_id: int, conn: asyncpg.Connection): + await conn.execute( + "INSERT INTO blocked_users (tg_id) VALUES ($1) ON CONFLICT (tg_id) DO NOTHING", + tg_id, + ) + + +async def delete_blocked_user(tg_id: int | list[int], conn: asyncpg.Connection): + """ + Удаляет пользователя или список пользователей из списка заблокированных. + + :param tg_id: ID пользователя Telegram или список ID + :param conn: Подключение к базе данных + """ + if isinstance(tg_id, list): + await conn.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", tg_id) + else: + await conn.execute("DELETE FROM blocked_users WHERE tg_id = $1", tg_id) + + +async def init_db(file_path: str = "assets/schema.sql"): + with open(file_path) as file: + sql_content = file.read() + + statements = [stmt.strip() for stmt in sql_content.split(";") if stmt.strip()] + conn = await asyncpg.connect(DATABASE_URL) + + try: + for statement in statements: + await conn.execute(statement) + except Exception as e: + logger.error(f"Error while executing SQL statement: {e}") + finally: + logger.info("Tables created successfully") + await conn.close() + + +async def check_unique_server_name(server_name: str, session: Any, cluster_name: str | None = None) -> bool: + """ + Проверяет уникальность имени сервера. + + :param server_name: Имя сервера. + :param session: Сессия базы данных. + :param cluster_name: Имя кластера (опционально). + :return: True, если имя сервера уникально, False, если уже существует. + """ + if cluster_name: + result = await session.fetchrow( + "SELECT 1 FROM servers WHERE server_name = $1 AND cluster_name = $2 LIMIT 1", server_name, cluster_name + ) + else: + result = await session.fetchrow("SELECT 1 FROM servers WHERE server_name = $1 LIMIT 1", server_name) + + return result is None + + +async def create_coupon(coupon_code: str, amount: float, usage_limit: int, session: Any): + """ + Создает новый купон в базе данных. + + Args: + coupon_code (str): Уникальный код купона. + amount (float): Сумма, которую дает купон. + usage_limit (int): Максимальное количество использований купона. + session (Any): Сессия базы данных для выполнения запроса. + + Raises: + Exception: В случае ошибки при создании купона. + + Example: + await create_coupon('SALE50', 50.0, 5, session) + """ + try: + await session.execute( + """ + INSERT INTO coupons (code, amount, usage_limit, usage_count, is_used) + VALUES ($1, $2, $3, 0, FALSE) + """, + coupon_code, + amount, + usage_limit, + ) + logger.info(f"Успешно создан купон с кодом {coupon_code} на сумму {amount}") + except Exception as e: + logger.error(f"Ошибка при создании купона {coupon_code}: {e}") + raise + + +async def get_coupon_by_code(coupon_code: str, session: Any) -> dict | None: + """ + Получает информацию о купоне по его коду. + + Args: + coupon_code (str): Код купона для поиска + session (Any): Сессия базы данных + + Returns: + dict | None: Словарь с информацией о купоне или None, если купон не найден + - id (int): ID купона + - usage_limit (int): Лимит использований + - usage_count (int): Текущее количество использований + - is_used (bool): Флаг использования + - amount (float): Сумма купона + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + try: + result = await session.fetchrow( + """ + SELECT id, usage_limit, usage_count, is_used, amount + FROM coupons + WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE + """, + coupon_code, + ) + return dict(result) if result else None + except Exception as e: + logger.error(f"Ошибка при получении купона {coupon_code}: {e}") + raise + + +async def get_all_coupons(session: Any, page: int = 1, per_page: int = 10): + """ + Получает список купонов из базы данных с пагинацией. + + Args: + session (Any): Сессия базы данных для выполнения запроса + page (int): Номер страницы (по умолчанию 1) + per_page (int): Количество купонов на странице (по умолчанию 10) + + Returns: + dict: Словарь с информацией о купонах и пагинации: + - coupons (list): Список словарей с информацией о купонах + - total (int): Общее количество купонов + - pages (int): Общее количество страниц + - current_page (int): Текущая страница + + Raises: + Exception: В случае ошибки при получении данных из базы + """ + try: + offset = (page - 1) * per_page + coupons = await session.fetch( + """ + SELECT code, amount, usage_limit, usage_count + FROM coupons + ORDER BY id + LIMIT $1 OFFSET $2 + """, + per_page, + offset, + ) + + total_count = await session.fetchval("SELECT COUNT(*) FROM coupons") + total_pages = -(-total_count // per_page) # Округление вверх + + logger.info(f"Успешно получено {len(coupons)} купонов из базы данных (страница {page})") + + return {"coupons": coupons, "total": total_count, "pages": total_pages, "current_page": page} + except Exception as e: + logger.error(f"Критическая ошибка при получении списка купонов: {e}") + logger.exception("Трассировка стека ошибки получения купонов") + return {"coupons": [], "total": 0, "pages": 0, "current_page": page} + + +async def delete_coupon(coupon_code: str, session: Any): + """ + Удаляет купон из базы данных по его коду. + + Args: + coupon_code (str): Код купона для удаления + session (Any): Сессия базы данных для выполнения запроса + + Returns: + bool: True, если купон успешно удален, False если купон не найден или произошла ошибка + + Raises: + Exception: В случае ошибки при выполнении запроса к базе данных + + Example: + result = await delete_coupon('SALE50', session) + """ + try: + coupon_record = await session.fetchrow( + """ + SELECT id FROM coupons WHERE code = $1 + """, + coupon_code, + ) + + if not coupon_record: + logger.info(f"Купон {coupon_code} не найден в базе данных") + return False + + await session.execute( + """ + DELETE FROM coupons WHERE code = $1 + """, + coupon_code, + ) + + logger.info(f"Купон {coupon_code} успешно удален из базы данных") + return True + + except Exception as e: + logger.error(f"Произошла ошибка при удалении купона {coupon_code}: {e}") + return False + + +async def set_trial(tg_id: int, status: int, session: Any): + """ + Устанавливает статус триального периода для пользователя. + + Args: + tg_id (int): Telegram ID пользователя + status (int): Статус триального периода (0 - доступен, 1 - использован) + session (Any): Сессия базы данных + + Returns: + bool: True, если статус успешно установлен, False в случае ошибки + """ + try: + await session.execute( + """ + INSERT INTO connections (tg_id, trial) + VALUES ($1, $2) + ON CONFLICT (tg_id) + DO UPDATE SET trial = $2 + """, + tg_id, + status, + ) + status_text = "восстановлен" if status == 0 else "использован" + logger.info(f"Триальный период успешно {status_text} для пользователя {tg_id}") + return True + except Exception as e: + logger.error(f"Ошибка при установке статуса триального периода для пользователя {tg_id}: {e}") + return False + + +async def add_connection(tg_id: int, balance: float = 0.0, trial: int = 0, session: Any = None): + """ + Добавляет новое подключение для пользователя в базу данных. + + Args: + tg_id (int): Telegram ID пользователя + balance (float, optional): Начальный баланс пользователя. По умолчанию 0.0. + trial (int, optional): Статус триального периода. По умолчанию 0. + session (Any, optional): Сессия базы данных. + + Raises: + Exception: Если возникает ошибка при добавлении подключения в базу данных. + """ + try: + await session.execute( + """ + INSERT INTO connections (tg_id, balance, trial) + VALUES ($1, $2, $3) + """, + tg_id, + balance, + trial, + ) + logger.info( + f"Успешно добавлено новое подключение для пользователя {tg_id} с балансом {balance} и статусом триала {trial}" + ) + except Exception as e: + logger.error(f"Не удалось добавить подключение для пользователя {tg_id}. Причина: {e}") + raise + + +async def check_connection_exists(tg_id: int): + """ + Проверяет существование подключения для указанного пользователя в базе данных. + + Args: + tg_id (int): Telegram ID пользователя для проверки. + + Returns: + bool: True, если подключение существует, иначе False. + + Raises: + Exception: В случае ошибки при подключении к базе данных. + """ + try: + conn = await asyncpg.connect(DATABASE_URL) + exists = await conn.fetchval( + """ + SELECT EXISTS(SELECT 1 FROM connections WHERE tg_id = $1) + """, + tg_id, + ) + logger.info( + f"Проверка существования подключения для пользователя {tg_id}: {'найдено' if exists else 'не найдено'}" + ) + return exists + except Exception as e: + logger.error(f"Ошибка при проверке подключения для пользователя {tg_id}: {e}") + raise + finally: + if conn: + await conn.close() + + +async def store_key( + tg_id: int, + client_id: str, + email: str, + expiry_time: int, + key: str, + server_id: str, + session: Any, +): + """ + Сохраняет информацию о ключе в базу данных. + + Args: + tg_id (int): Telegram ID пользователя + client_id (str): Уникальный идентификатор клиента + email (str): Электронная почта или имя устройства + expiry_time (int): Время истечения ключа в миллисекундах + key (str): Ключ доступа + server_id (str): Идентификатор сервера + + Raises: + Exception: Если возникает ошибка при сохранении ключа в базу данных + """ + try: + await session.execute( + """ + INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + """, + tg_id, + client_id, + email, + int(datetime.utcnow().timestamp() * 1000), + expiry_time, + key, + server_id, + ) + logger.info(f"Ключ успешно сохранен для пользователя {tg_id} на сервере {server_id}") + except Exception as e: + logger.error(f"Ошибка при сохранении ключа для пользователя {tg_id}: {e}") + raise + + +async def get_keys(tg_id: int, session: Any): + """ + Получает список ключей для указанного пользователя. + + Args: + tg_id (int): Telegram ID пользователя + + Returns: + list: Список записей ключей с информацией о клиенте, электронной почте, времени создания и ключе + + Raises: + Exception: В случае ошибки при подключении к базе данных или выполнении запроса + """ + try: + records = await session.fetch( + """ + SELECT client_id, email, created_at, key + FROM keys + WHERE tg_id = $1 + """, + tg_id, + ) + logger.info(f"Успешно получено {len(records)} ключей для пользователя {tg_id}") + return records + except Exception as e: + logger.error(f"Ошибка при получении ключей для пользователя {tg_id}: {e}") + raise + + +async def get_keys_by_server(tg_id: int | None, server_id: str, session: Any): + """ + Получает список ключей на определенном сервере. Если tg_id=None, возвращает все ключи на сервере. + + Args: + tg_id (int | None): Telegram ID пользователя или None для всех пользователей + server_id (str): Идентификатор сервера + + Returns: + list: Список записей ключей с информацией о клиенте, электронной почте, времени создания и ключе + + Raises: + Exception: В случае ошибки при подключении к базе данных или выполнении запроса + """ + try: + if tg_id is not None: + records = await session.fetch( + """ + SELECT * + FROM keys + WHERE tg_id = $1 AND server_id = $2 + """, + tg_id, + server_id, + ) + logger.info(f"Успешно получено {len(records)} ключей для пользователя {tg_id} на сервере {server_id}") + else: + records = await session.fetch( + """ + SELECT * + FROM keys + WHERE server_id = $1 + """, + server_id, + ) + logger.info(f"Успешно получено {len(records)} ключей на сервере {server_id}") + + return records + except Exception as e: + error_msg = f"Ошибка при получении ключей на сервере {server_id}" + if tg_id is not None: + error_msg += f" для пользователя {tg_id}" + logger.error(f"{error_msg}: {e}") + raise + + +async def get_balance(tg_id: int) -> float: + """ + Получает баланс пользователя из базы данных. + + Args: + tg_id (int): Telegram ID пользователя + + Returns: + float: Баланс пользователя, 0.0 если баланс не найден + + Raises: + Exception: В случае ошибки при подключении к базе данных или выполнении запроса + """ + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + balance = await conn.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id) + logger.info(f"Получен баланс для пользователя {tg_id}: {balance}") + return round(balance, 1) if balance is not None else 0.0 + except Exception as e: + logger.error(f"Ошибка при получении баланса для пользователя {tg_id}: {e}") + return 0.0 + finally: + if conn: + await conn.close() + + +async def update_balance(tg_id: int, amount: float, session: Any = None): + """ + Обновляет баланс пользователя в базе данных. + + Args: + tg_id (int): Telegram ID пользователя + amount (float): Сумма для обновления баланса + session (Any, optional): Сессия базы данных. Если не передана, создается новая. + + Raises: + Exception: В случае ошибки при подключении к базе данных или обновлении баланса + """ + conn = None + try: + if session is None: + conn = await asyncpg.connect(DATABASE_URL) + session = conn + + await session.execute( + """ + UPDATE connections + SET balance = balance + $1 + WHERE tg_id = $2 + """, + amount, + tg_id, + ) + logger.info(f"Баланс пользователя {tg_id} обновлен на сумму {amount}") + + await handle_referral_on_balance_update(tg_id, amount) + + except Exception as e: + logger.error(f"Ошибка при обновлении баланса для пользователя {tg_id}: {e}") + raise + finally: + if conn is not None: + await conn.close() + + +async def get_trial(tg_id: int, session: Any) -> int: + """ + Получает статус триала для пользователя из базы данных. + + Args: + tg_id (int): Telegram ID пользователя + session (Any): Сессия базы данных + + Returns: + int: Статус триала (0 - не использован, 1 - использован) + """ + try: + trial = await session.fetchval("SELECT trial FROM connections WHERE tg_id = $1", tg_id) + logger.info(f"Получен статус триала для пользователя {tg_id}: {trial}") + return trial if trial is not None else 0 + except Exception as e: + logger.error(f"Ошибка при получении статуса триала для пользователя {tg_id}: {e}") + return 0 + + +async def get_key_count(tg_id: int) -> int: + """ + Получает количество ключей для указанного пользователя. + + Args: + tg_id (int): Telegram ID пользователя + + Returns: + int: Количество ключей пользователя, 0 если ключей нет + + Raises: + Exception: В случае ошибки при подключении к базе данных + """ + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE tg_id = $1", tg_id) + logger.info(f"Получено количество ключей для пользователя {tg_id}: {count}") + return count if count is not None else 0 + except Exception as e: + logger.error(f"Ошибка при получении количества ключей для пользователя {tg_id}: {e}") + return 0 + finally: + if conn: + await conn.close() + + +async def add_referral(referred_tg_id: int, referrer_tg_id: int, session: Any): + try: + if referred_tg_id == referrer_tg_id: + logger.warning(f"Пользователь {referred_tg_id} попытался использовать свою собственную реферальную ссылку.") + return + + await session.execute( + """ + INSERT INTO referrals (referred_tg_id, referrer_tg_id) + VALUES ($1, $2) + """, + referred_tg_id, + referrer_tg_id, + ) + logger.info(f"Добавлена реферальная связь: приглашенный {referred_tg_id}, пригласивший {referrer_tg_id}") + except Exception as e: + logger.error(f"Ошибка при добавлении реферала: {e}") + raise + + +async def handle_referral_on_balance_update(tg_id: int, amount: float): + """ + Обработка многоуровневой реферальной системы при обновлении баланса пользователя. + + Метод анализирует цепочку рефералов для указанного пользователя и начисляет + бонусы реферерам на разных уровнях согласно настроенным процентам. + + Args: + tg_id (int): Идентификатор Telegram пользователя, пополнившего баланс + amount (float): Сумма пополнения баланса + """ + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + logger.info(f"Начало обработки реферальной системы для пользователя {tg_id}") + + MAX_REFERRAL_LEVELS = len(REFERRAL_BONUS_PERCENTAGES.keys()) + if MAX_REFERRAL_LEVELS == 0: + logger.warning("Реферальные бонусы отключены.") + return + + visited_tg_ids = set() + current_tg_id = tg_id + referral_chain = [] + + for level in range(1, MAX_REFERRAL_LEVELS + 1): + if current_tg_id in visited_tg_ids: + logger.warning(f"Обнаружен цикл в реферальной цепочке для пользователя {current_tg_id}. Прекращение.") + break + + visited_tg_ids.add(current_tg_id) + + referral = await conn.fetchrow( + """ + SELECT referrer_tg_id + FROM referrals + WHERE referred_tg_id = $1 + """, + current_tg_id, + ) + + if not referral: + logger.info(f"Цепочка рефералов завершена на уровне {level}.") + break + + referrer_tg_id = referral["referrer_tg_id"] + + if referrer_tg_id in visited_tg_ids: + logger.warning(f"Реферер {referrer_tg_id} уже обработан. Пропуск.") + break + + referral_chain.append({"tg_id": referrer_tg_id, "level": level}) + current_tg_id = referrer_tg_id + + for referral in referral_chain: + referrer_tg_id = referral["tg_id"] + level = referral["level"] + + bonus_percent = REFERRAL_BONUS_PERCENTAGES.get(level, 0) + if bonus_percent <= 0: + logger.warning(f"Процент бонуса для уровня {level} равен 0. Пропуск.") + continue + + bonus = round(amount * bonus_percent, 2) + + if bonus > 0: + logger.info(f"Начисление бонуса {bonus} рублей рефереру {referrer_tg_id} на уровне {level}.") + await update_balance(referrer_tg_id, bonus) + + except Exception as e: + logger.error(f"Ошибка при обработке многоуровневой реферальной системы для {tg_id}: {e}") + finally: + if conn: + await conn.close() + + +async def get_referral_stats(referrer_tg_id: int): + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + logger.info( + f"Установлено подключение к базе данных для получения статистики рефералов пользователя {referrer_tg_id}" + ) + + total_referrals = await conn.fetchval( + """ + SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1 + """, + referrer_tg_id, + ) + logger.debug(f"Получено общее количество рефералов: {total_referrals}") + + active_referrals = await conn.fetchval( + """ + SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1 AND reward_issued = TRUE + """, + referrer_tg_id, + ) + logger.debug(f"Получено количество активных рефералов: {active_referrals}") + + MAX_REFERRAL_LEVELS = len(REFERRAL_BONUS_PERCENTAGES.keys()) + + referrals_by_level_records = await conn.fetch( + f""" + WITH RECURSIVE referral_levels AS ( + SELECT referred_tg_id, referrer_tg_id, 1 AS level + FROM referrals + WHERE referrer_tg_id = $1 + + UNION + + SELECT r.referred_tg_id, r.referrer_tg_id, rl.level + 1 + FROM referrals r + JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id + WHERE rl.level < {MAX_REFERRAL_LEVELS} + ) + SELECT level, + COUNT(*) AS level_count, + COUNT(CASE WHEN reward_issued = TRUE THEN 1 END) AS active_level_count + FROM referral_levels rl + JOIN referrals r ON rl.referred_tg_id = r.referred_tg_id + GROUP BY level + ORDER BY level + """, + referrer_tg_id, + ) + + referrals_by_level = { + record["level"]: { + "total": record["level_count"], + "active": record["active_level_count"], + } + for record in referrals_by_level_records + } + logger.debug(f"Получена статистика рефералов по уровням: {referrals_by_level}") + + total_referral_bonus = await conn.fetchval( + f""" + WITH RECURSIVE referral_levels AS ( + SELECT + referred_tg_id, + referrer_tg_id, + 1 AS level + FROM referrals + WHERE referrer_tg_id = $1 + + UNION + + SELECT + r.referred_tg_id, + r.referrer_tg_id, + rl.level + 1 + FROM referrals r + JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id + WHERE rl.level < {MAX_REFERRAL_LEVELS} + ) + SELECT + COALESCE(SUM(p.amount * ( + CASE + {" ".join([f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}" for level in REFERRAL_BONUS_PERCENTAGES])} + ELSE 0 + END)), 0) AS total_bonus + FROM referral_levels rl + JOIN payments p ON rl.referred_tg_id = p.tg_id + WHERE p.status = 'success' AND rl.level <= {MAX_REFERRAL_LEVELS} + """, + referrer_tg_id, + ) + + logger.debug(f"Получена общая сумма бонусов от рефералов: {total_referral_bonus}") + + return { + "total_referrals": total_referrals, + "active_referrals": active_referrals, + "referrals_by_level": referrals_by_level, + "total_referral_bonus": total_referral_bonus, + } + + except Exception as e: + logger.error(f"Ошибка при получении статистики рефералов для пользователя {referrer_tg_id}: {e}") + raise + finally: + if conn: + await conn.close() + logger.info("Закрытие подключения к базе данных") + + +async def update_key_expiry(client_id: str, new_expiry_time: int, session: Any): + """ + Обновление времени истечения ключа для указанного клиента. + + Args: + client_id (str): Уникальный идентификатор клиента + new_expiry_time (int): Новое время истечения ключа + + Raises: + Exception: В случае ошибки при подключении к базе данных или обновлении ключа + """ + try: + await session.execute( + """ + UPDATE keys + SET expiry_time = $1, notified = FALSE, notified_24h = FALSE + WHERE client_id = $2 + """, + new_expiry_time, + client_id, + ) + logger.info(f"Успешно обновлено время истечения ключа для клиента {client_id}") + + except Exception as e: + logger.error(f"Ошибка при обновлении времени истечения ключа для клиента {client_id}: {e}") + raise + + +async def add_balance_to_client(client_id: str, amount: float): + """ + Добавление баланса клиенту по его идентификатору Telegram. + + Args: + client_id (str): Идентификатор клиента в Telegram + amount (float): Сумма для пополнения баланса + + Raises: + Exception: В случае ошибки при подключении к базе данных или обновлении баланса + """ + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + logger.info(f"Установлено подключение к базе данных для пополнения баланса клиента {client_id}") + + await conn.execute( + """ + UPDATE connections + SET balance = balance + $1 + WHERE tg_id = $2 + """, + amount, + client_id, + ) + logger.info(f"Успешно пополнен баланс клиента {client_id} на сумму {amount}") + + except Exception as e: + logger.error(f"Ошибка при пополнении баланса для клиента {client_id}: {e}") + raise + finally: + if conn: + await conn.close() + logger.info("Закрытие подключения к базе данных") + + +async def get_client_id_by_email(email: str): + """ + Получение идентификатора клиента по электронной почте. + + Args: + email (str): Электронная почта клиента + + Returns: + str: Идентификатор клиента или None, если клиент не найден + + Raises: + Exception: В случае ошибки при подключении к базе данных или выполнении запроса + """ + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + logger.info(f"Установлено подключение к базе данных для поиска client_id по email: {email}") + + client_id = await conn.fetchval( + """ + SELECT client_id FROM keys WHERE email = $1 + """, + email, + ) + + if client_id: + logger.info(f"Найден client_id для email: {email}") + else: + logger.warning(f"Не найден client_id для email: {email}") + + return client_id + + except Exception as e: + logger.error(f"Ошибка при получении client_id для email {email}: {e}") + raise + finally: + if conn: + await conn.close() + logger.info("Закрытие подключения к базе данных") + + +async def get_tg_id_by_client_id(client_id: str): + """ + Получение Telegram ID по идентификатору клиента. + + Args: + client_id (str): Идентификатор клиента + + Returns: + int или None: Telegram ID клиента, если найден, иначе None + + Raises: + Exception: В случае ошибки при подключении к базе данных или выполнении запроса + """ + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + logger.info(f"Установлено подключение к базе данных для поиска Telegram ID по client_id: {client_id}") + + result = await conn.fetchrow("SELECT tg_id FROM keys WHERE client_id = $1", client_id) + + if result: + logger.info(f"Найден Telegram ID для client_id: {client_id}") + return result["tg_id"] + else: + logger.warning(f"Не найден Telegram ID для client_id: {client_id}") + return None + + except Exception as e: + logger.error(f"Ошибка при получении Telegram ID для client_id {client_id}: {e}") + raise + finally: + if conn: + await conn.close() + logger.info("Закрытие подключения к базе данных") + + +async def upsert_user( + tg_id: int, + username: str = None, + first_name: str = None, + last_name: str = None, + language_code: str = None, + is_bot: bool = False, +): + """ + Обновляет или вставляет информацию о пользователе в базу данных. + + Args: + tg_id (int): Идентификатор пользователя в Telegram + username (str, optional): Имя пользователя в Telegram + first_name (str, optional): Имя пользователя + last_name (str, optional): Фамилия пользователя + language_code (str, optional): Код языка пользователя + is_bot (bool, optional): Флаг, указывающий является ли пользователь ботом + + Raises: + Exception: В случае ошибки при работе с базой данных + """ + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + logger.info(f"Установлено подключение к базе данных для обновления пользователя {tg_id}") + + await conn.execute( + """ + INSERT INTO users (tg_id, username, first_name, last_name, language_code, is_bot, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (tg_id) DO UPDATE + SET + username = COALESCE(EXCLUDED.username, users.username), + first_name = COALESCE(EXCLUDED.first_name, users.first_name), + last_name = COALESCE(EXCLUDED.last_name, users.last_name), + language_code = COALESCE(EXCLUDED.language_code, users.language_code), + is_bot = EXCLUDED.is_bot, + updated_at = CURRENT_TIMESTAMP + """, + tg_id, + username, + first_name, + last_name, + language_code, + is_bot, + ) + logger.info(f"Успешно обновлена информация о пользователе {tg_id}") + except Exception as e: + logger.error(f"Ошибка при обновлении информации о пользователе {tg_id}: {e}") + raise + finally: + if conn: + await conn.close() + logger.info("Закрытие подключения к базе данных") + + +async def add_payment(tg_id: int, amount: float, payment_system: str): + """ + Добавляет информацию о платеже в базу данных. + + Args: + tg_id (int): Идентификатор пользователя в Telegram + amount (float): Сумма платежа + payment_system (str): Система оплаты + + Raises: + Exception: В случае ошибки при добавлении платежа + """ + conn = None + try: + conn = await asyncpg.connect(DATABASE_URL) + logger.info(f"Установлено подключение к базе данных для добавления платежа пользователя {tg_id}") + + await conn.execute( + """ + INSERT INTO payments (tg_id, amount, payment_system, status) + VALUES ($1, $2, $3, 'success') + """, + tg_id, + amount, + payment_system, + ) + logger.info(f"Успешно добавлен платеж для пользователя {tg_id} на сумму {amount}") + except Exception as e: + logger.error(f"Ошибка при добавлении платежа для пользователя {tg_id}: {e}") + raise + finally: + if conn: + await conn.close() + logger.info("Закрытие подключения к базе данных после добавления платежа") + + +async def add_notification(tg_id: int, notification_type: str, session: Any): + """ + Добавляет запись о notification в базу данных. + + Args: + tg_id (int): Идентификатор пользователя в Telegram + notification_type (str): Тип уведомления + session (Any): Сессия базы данных для выполнения запроса + + Raises: + Exception: В случае ошибки при добавлении notification + """ + try: + await session.execute( + """ + INSERT INTO notifications (tg_id, notification_type) + VALUES ($1, $2) + ON CONFLICT (tg_id, notification_type) + DO UPDATE SET last_notification_time = NOW() + """, + tg_id, + notification_type, + ) + logger.info(f"Успешно добавлено уведомление типа {notification_type} для пользователя {tg_id}") + except Exception as e: + logger.error(f"Ошибка при добавлении notification для пользователя {tg_id}: {e}") + raise + + +async def check_notification_time(tg_id: int, notification_type: str, hours: int = 12, session: Any = None) -> bool: + """ + Проверяет, прошло ли указанное количество часов с момента последнего уведомления. + + Args: + tg_id (int): Идентификатор пользователя в Telegram + notification_type (str): Тип уведомления + hours (int, optional): Количество часов для проверки. По умолчанию 12. + session (Any): Сессия базы данных для выполнения запроса + + Returns: + bool: True, если с момента последнего уведомления прошло больше указанного времени, иначе False + + Raises: + Exception: В случае ошибки при проверке времени уведомления + """ + conn = None + try: + conn = session if session is not None else await asyncpg.connect(DATABASE_URL) + + result = await conn.fetchval( + """ + SELECT + CASE + WHEN MAX(last_notification_time) IS NULL THEN TRUE + WHEN NOW() - MAX(last_notification_time) > ($1 * INTERVAL '1 hour') THEN TRUE + ELSE FALSE + END AS can_notify + FROM notifications + WHERE tg_id = $2 AND notification_type = $3 + """, + hours, + tg_id, + notification_type, + ) + + can_notify = result if result is not None else True + + logger.info( + f"Проверка уведомления типа {notification_type} для пользователя {tg_id}: {'можно отправить' if can_notify else 'слишком рано'}" + ) + + return can_notify + + except Exception as e: + logger.error(f"Ошибка при проверке времени уведомления для пользователя {tg_id}: {e}") + return False + + finally: + if conn is not None and session is None: + await conn.close() + + +async def get_servers(session: Any = None): + conn = None + try: + conn = session if session is not None else await asyncpg.connect(DATABASE_URL) + + result = await conn.fetch( + """ + SELECT cluster_name, server_name, api_url, subscription_url, inbound_id + FROM servers + """ + ) + servers = {} + for row in result: + cluster_name = row["cluster_name"] + if cluster_name not in servers: + servers[cluster_name] = [] + + servers[cluster_name].append( + { + "server_name": row["server_name"], + "api_url": row["api_url"], + "subscription_url": row["subscription_url"], + "inbound_id": row["inbound_id"], + } + ) + + return servers + + finally: + if conn is not None and session is None: + await conn.close() + + +async def delete_user_data(session: Any, tg_id: int): + try: + await session.execute("DELETE FROM gifts WHERE sender_tg_id = $1 OR recipient_tg_id = $1", tg_id) + except Exception as e: + logger.warning(f"У Вас версия без подарков для {tg_id}: {e}") + await session.execute("DELETE FROM payments WHERE tg_id = $1", tg_id) + await session.execute("DELETE FROM users WHERE tg_id = $1", tg_id) + await session.execute("DELETE FROM connections WHERE tg_id = $1", tg_id) + await delete_key(tg_id, session) + await session.execute("DELETE FROM referrals WHERE referrer_tg_id = $1", tg_id) + + +async def store_gift_link( + gift_id: str, + sender_tg_id: int, + selected_months: int, + expiry_time: datetime, + gift_link: str, + session: Any = None, +): + """ + Добавляет информацию о подарке в базу данных. + + Args: + gift_id (str): Уникальный идентификатор подарка + sender_tg_id (int): Идентификатор пользователя, который отправил подарок + selected_months (int): Количество месяцев подписки + expiry_time (datetime): Время окончания подписки + gift_link (str): Ссылка для активации подарка + session (Any): Сессия базы данных для выполнения запроса + + Returns: + bool: True, если информация о подарке успешно добавлена, иначе False + + Raises: + Exception: В случае ошибки при сохранении информации о подарке + """ + conn = None + try: + conn = session if session is not None else await asyncpg.connect(DATABASE_URL) + + result = await conn.execute( + """ + INSERT INTO gifts (gift_id, sender_tg_id, recipient_tg_id, selected_months, expiry_time, gift_link, created_at, is_used) + VALUES ($1, $2, NULL, $3, $4, $5, $6, FALSE) + """, + gift_id, + sender_tg_id, + selected_months, + expiry_time, + gift_link, + datetime.utcnow(), + ) + + if result: + logger.info(f"Подарок с ID {gift_id} успешно добавлен в базу данных.") + return True + else: + logger.error(f"Не удалось добавить подарок с ID {gift_id} в базу данных.") + return False + except Exception as e: + logger.error(f"Ошибка при сохранении подарка с ID {gift_id} в базе данных: {e}") + return False + + finally: + if conn is not None and session is None: + await conn.close() + + +async def get_key_details(email, session): + record = await session.fetchrow( + """ + SELECT k.key, k.email, k.expiry_time, k.server_id,k, k.client_id, k.created_at, c.tg_id, c.balance + FROM keys k + JOIN connections c ON k.tg_id = c.tg_id + WHERE k.email = $1 + """, + email, + ) + + if not record: + return None + + cluster_name = record["server_id"] + + moscow_tz = pytz.timezone("Europe/Moscow") + expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=moscow_tz) + current_date = datetime.now(moscow_tz) + time_left = expiry_date - current_date + + if time_left.total_seconds() <= 0: + days_left_message = "Ключ истек." + elif time_left.days > 0: + days_left_message = f"Осталось дней: {time_left.days}" + else: + hours_left = time_left.seconds // 3600 + days_left_message = f"Осталось часов: {hours_left}" + + return { + "key": record["key"], + "created_at": record["created_at"], + "expiry_time": record["expiry_time"], + "client_id": record["client_id"], + "expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"), + "days_left_message": days_left_message, + "server_name": cluster_name, + "balance": record["balance"], + "tg_id": record["tg_id"], + "email": record["email"], + } + + +async def delete_key(identifier, session): + """ + Удаляет ключ из базы данных по client_id или tg_id + + Args: + identifier (str): client_id или tg_id для удаления + session: Сессия базы данных + + Raises: + Exception: В случае ошибки при удалении ключа + """ + try: + # Проверяем, является ли идентификатор числом (tg_id) или строкой (client_id) + if identifier.isdigit(): + query = "DELETE FROM keys WHERE tg_id = $1" + else: + query = "DELETE FROM keys WHERE client_id = $1" + + await session.execute(query, identifier) + logger.info(f"Ключ с идентификатором {identifier} успешно удалён") + except Exception as e: + logger.error(f"Ошибка при удалении ключа с идентификатором {identifier} из базы данных: {e}") + + +async def create_server( + cluster_name: str, server_name: str, api_url: str, subscription_url: str, inbound_id: int, session: Any +): + """ + Добавляет новый сервер в базу данных. + + Args: + cluster_name (str): Название кластера + server_name (str): Название сервера + api_url (str): URL API сервера + subscription_url (str): URL подписки + inbound_id (int): ID входящего подключения + session (Any): Сессия базы данных + + Raises: + Exception: В случае ошибки при добавлении сервера + """ + try: + await session.execute( + """ + INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id) + VALUES ($1, $2, $3, $4, $5) + """, + cluster_name, + server_name, + api_url, + subscription_url, + inbound_id, + ) + logger.info(f"Сервер {server_name} успешно добавлен в кластер {cluster_name}") + except Exception as e: + logger.error(f"Ошибка при добавлении сервера {server_name} в кластер {cluster_name}: {e}") + raise + + +async def delete_server(server_name: str, session: Any): + """ + Удаляет сервер из базы данных по его названию. + + Args: + server_name (str): Название сервера для удаления + session (Any): Сессия базы данных + + Raises: + Exception: В случае ошибки при удалении сервера + """ + try: + await session.execute( + """ + DELETE FROM servers WHERE server_name = $1 + """, + server_name, + ) + logger.info(f"Сервер {server_name} успешно удалён из базы данных") + except Exception as e: + logger.error(f"Ошибка при удалении сервера {server_name} из базы данных: {e}") + raise + + +async def create_coupon_usage(coupon_id: int, user_id: int, session: Any): + """ + Создаёт запись об использовании купона в базе данных. + + Args: + coupon_id (int): ID купона + user_id (int): ID пользователя + session (Any): Сессия базы данных + + Raises: + Exception: В случае ошибки при создании записи + """ + try: + await session.execute( + """ + INSERT INTO coupon_usages (coupon_id, user_id, used_at) + VALUES ($1, $2, $3) + """, + coupon_id, + user_id, + datetime.utcnow(), + ) + logger.info(f"Создана запись об использовании купона {coupon_id} пользователем {user_id}") + except Exception as e: + logger.error(f"Ошибка при создании записи об использовании купона {coupon_id} пользователем {user_id}: {e}") + raise + + +async def check_coupon_usage(coupon_id: int, user_id: int, session: Any) -> bool: + """ + Проверяет, использовал ли пользователь данный купон. + + Args: + coupon_id (int): ID купона для проверки + user_id (int): ID пользователя для проверки + session (Any): Сессия базы данных + + Returns: + bool: True если купон уже использован, False если нет + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + try: + result = await session.fetchrow( + """ + SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2 + """, + coupon_id, + user_id, + ) + return result is not None + except Exception as e: + logger.error(f"Ошибка при проверке использования купона {coupon_id} пользователем {user_id}: {e}") + raise + + +async def update_coupon_usage_count(coupon_id: int, session: Any): + """ + Обновляет счетчик использования купона и его статус. + + Args: + coupon_id (int): ID купона для обновления + session (Any): Сессия базы данных + + Raises: + Exception: В случае ошибки при обновлении данных купона + """ + try: + await session.execute( + """ + UPDATE coupons + SET usage_count = usage_count + 1, + is_used = CASE WHEN usage_count + 1 >= usage_limit AND usage_limit > 0 THEN TRUE ELSE FALSE END + WHERE id = $1 + """, + coupon_id, + ) + logger.info(f"Успешно обновлен счетчик использования купона {coupon_id}") + except Exception as e: + logger.error(f"Ошибка при обновлении счетчика использования купона {coupon_id}: {e}") + raise + + +async def get_last_payments(tg_id: int, session: Any): + """ + Получает последние 3 платежа пользователя. + + Args: + tg_id (int): Telegram ID пользователя + session (Any): Сессия базы данных + + Returns: + list: Список последних платежей пользователя + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + try: + records = await session.fetch( + """ + SELECT amount, payment_system, status, created_at + FROM payments + WHERE tg_id = $1 + ORDER BY created_at DESC + LIMIT 3 + """, + tg_id, + ) + logger.info(f"Успешно получены последние платежи для пользователя {tg_id}") + return records + except Exception as e: + logger.error(f"Ошибка при получении последних платежей для пользователя {tg_id}: {e}") + raise