diff --git a/app/database/crud/referral.py b/app/database/crud/referral.py index acbb6ef7..bf5a4990 100644 --- a/app/database/crud/referral.py +++ b/app/database/crud/referral.py @@ -195,14 +195,16 @@ async def get_referral_statistics(db: AsyncSession) -> dict: display_name += f" {user.last_name}" elif user.username: display_name = f"@{user.username}" - else: + elif user.telegram_id: display_name = f"ID{user.telegram_id}" - + else: + display_name = user.email or f"#{user.id}" + top_referrers.append({ - "user_id": user.telegram_id, + "user_id": user.id, # Use internal ID, not telegram_id "display_name": display_name, "username": user.username, - "telegram_id": user.telegram_id, + "telegram_id": user.telegram_id, # Can be None for email users "total_earned_kopeks": stats['total_earned'], "referrals_count": stats['referrals_count'] }) @@ -424,12 +426,14 @@ async def get_top_referrers_by_period( display_name += f" {user.last_name}" elif user.username: display_name = f"@{user.username}" - else: + elif user.telegram_id: display_name = f"ID{user.telegram_id}" + else: + display_name = user.email or f"#{user.id}" result.append({ 'user_id': user.id, - 'telegram_id': user.telegram_id, + 'telegram_id': user.telegram_id, # Can be None for email users 'username': user.username, 'display_name': display_name, 'invited_count': data['invited_count'], diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 22fc1ab7..1a23f4c5 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -417,7 +417,8 @@ async def add_user_balance( await db.refresh(user) - logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})") + user_id_display = user.telegram_id or user.email or f"#{user.id}" + logger.info(f"💰 Баланс пользователя {user_id_display} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})") return True except Exception as e: @@ -464,8 +465,9 @@ async def subtract_user_balance( *, consume_promo_offer: bool = False, ) -> bool: + user_id_display = user.telegram_id or user.email or f"#{user.id}" logger.info(f"💸 ОТЛАДКА subtract_user_balance:") - logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})") + logger.info(f" 👤 User ID: {user.id} (ID: {user_id_display})") logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек") logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек") logger.info(f" 📝 Описание: {description}") @@ -958,7 +960,8 @@ async def delete_user(db: AsyncSession, user: User) -> bool: user.updated_at = datetime.utcnow() await db.commit() - logger.info(f"🗑️ Пользователь {user.telegram_id} помечен как удаленный") + user_id_display = user.telegram_id or user.email or f"#{user.id}" + logger.info(f"🗑️ Пользователь {user_id_display} помечен как удаленный") return True @@ -1040,3 +1043,82 @@ async def get_users_with_active_subscriptions(db: AsyncSession) -> List[User]: ) return result.scalars().unique().all() + + +async def create_user_by_email( + db: AsyncSession, + email: str, + password_hash: str, + first_name: Optional[str] = None, + language: str = "ru", + referred_by_id: Optional[int] = None, +) -> User: + """ + Создать пользователя через email регистрацию (без Telegram). + + Args: + db: Database session + email: Email address (will be unverified initially) + password_hash: Hashed password + first_name: Optional first name + language: User language + referred_by_id: Referrer user ID + + Returns: + Created User object + """ + referral_code = await create_unique_referral_code(db) + default_group = await _get_or_create_default_promo_group(db) + + user = User( + telegram_id=None, # Email-only user + auth_type="email", + email=email, + email_verified=False, + password_hash=password_hash, + username=None, + first_name=sanitize_telegram_name(first_name) if first_name else None, + last_name=None, + language=language, + referred_by_id=referred_by_id, + referral_code=referral_code, + balance_kopeks=0, + has_had_paid_subscription=False, + has_made_first_topup=False, + promo_group_id=default_group.id, + ) + + db.add(user) + await db.commit() + await db.refresh(user) + + user.promo_group = default_group + logger.info(f"✅ Создан email-пользователь {email} с id={user.id}") + + # Emit event + try: + from app.services.event_emitter import event_emitter + await event_emitter.emit( + "user.created", + { + "user_id": user.id, + "email": user.email, + "auth_type": "email", + "first_name": user.first_name, + "referral_code": user.referral_code, + "referred_by_id": user.referred_by_id, + }, + db=db, + ) + except Exception as error: + logger.warning("Failed to emit user.created event: %s", error) + + return user + + +async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]: + """Get user by email address.""" + result = await db.execute( + select(User).where(User.email == email) + ) + return result.scalar_one_or_none() diff --git a/app/database/models.py b/app/database/models.py index b9892107..9ea2aad7 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -997,7 +997,8 @@ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) - telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) + telegram_id = Column(BigInteger, unique=True, index=True, nullable=True) # Nullable для email-only пользователей + auth_type = Column(String(20), default="telegram", nullable=False) # "telegram" или "email" username = Column(String(255), nullable=True) first_name = Column(String(255), nullable=True) last_name = Column(String(255), nullable=True) @@ -1065,8 +1066,23 @@ class User(Base): @property def full_name(self) -> str: + """Полное имя пользователя с поддержкой email-only юзеров.""" parts = [self.first_name, self.last_name] - return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" + name = " ".join(filter(None, parts)) + if name: + return name + if self.username: + return self.username + if self.telegram_id: + return f"ID{self.telegram_id}" + if self.email: + return self.email.split('@')[0] + return f"User{self.id}" + + @property + def is_email_user(self) -> bool: + """Пользователь зарегистрирован через email (без Telegram).""" + return self.auth_type == "email" and self.telegram_id is None def get_primary_promo_group(self): """Возвращает промогруппу с максимальным приоритетом.""" @@ -2066,7 +2082,7 @@ class SupportAuditLog(Base): id = Column(Integer, primary_key=True, index=True) actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - actor_telegram_id = Column(BigInteger, nullable=False) + actor_telegram_id = Column(BigInteger, nullable=True) # Can be None for email-only users is_moderator = Column(Boolean, default=False) action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True) @@ -2398,7 +2414,7 @@ class ButtonClickLog(Base): id = Column(Integer, primary_key=True, index=True) button_id = Column(String(100), nullable=False, index=True) # ID кнопки - user_id = Column(BigInteger, ForeignKey("users.telegram_id", ondelete="SET NULL"), nullable=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) callback_data = Column(String(255), nullable=True) # callback_data кнопки clicked_at = Column(DateTime, default=func.now(), index=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c175cb9a..6958212f 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -6295,6 +6295,93 @@ async def add_subscription_traffic_reset_at_column() -> bool: return False +async def add_user_email_auth_columns() -> bool: + """ + Миграция для поддержки email-регистрации без Telegram. + + 1. Делает telegram_id nullable (для email-only пользователей) + 2. Добавляет колонку auth_type ('telegram' или 'email') + """ + try: + db_type = await get_database_type() + + # Проверяем существование колонки auth_type + auth_type_exists = await check_column_exists('users', 'auth_type') + + async with engine.begin() as conn: + # 1. Добавляем колонку auth_type если её нет + if not auth_type_exists: + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE users ADD COLUMN auth_type VARCHAR(20) DEFAULT 'telegram' NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE users ADD COLUMN auth_type VARCHAR(20) DEFAULT 'telegram' NOT NULL" + )) + elif db_type == 'mysql': + await conn.execute(text( + "ALTER TABLE users ADD COLUMN auth_type VARCHAR(20) DEFAULT 'telegram' NOT NULL" + )) + else: + logger.error(f"Неподдерживаемый тип БД: {db_type}") + return False + logger.info("✅ Добавлена колонка users.auth_type") + else: + logger.info("ℹ️ Колонка auth_type уже существует") + + # 2. Делаем telegram_id nullable (только PostgreSQL и MySQL поддерживают ALTER COLUMN) + # SQLite не поддерживает ALTER COLUMN, но мы можем просто не делать это - + # новые email-пользователи будут создаваться с telegram_id=NULL если БД уже nullable + + if db_type == 'postgresql': + # Проверяем является ли telegram_id nullable + result = await conn.execute(text(""" + SELECT is_nullable + FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'telegram_id' + """)) + row = result.fetchone() + + if row and row[0] == 'NO': + # telegram_id NOT NULL - нужно сделать nullable + await conn.execute(text( + "ALTER TABLE users ALTER COLUMN telegram_id DROP NOT NULL" + )) + logger.info("✅ Колонка users.telegram_id теперь nullable") + else: + logger.info("ℹ️ Колонка telegram_id уже nullable") + + elif db_type == 'mysql': + # MySQL требует полное определение колонки при ALTER + result = await conn.execute(text(""" + SELECT IS_NULLABLE + FROM information_schema.COLUMNS + WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'telegram_id' + """)) + row = result.fetchone() + + if row and row[0] == 'NO': + await conn.execute(text( + "ALTER TABLE users MODIFY COLUMN telegram_id BIGINT NULL" + )) + logger.info("✅ Колонка users.telegram_id теперь nullable") + else: + logger.info("ℹ️ Колонка telegram_id уже nullable") + + elif db_type == 'sqlite': + # SQLite не поддерживает ALTER COLUMN + # Для SQLite нужна пересоздание таблицы, но это сложно + # Оставляем как есть - при необходимости нужна ручная миграция + logger.info("ℹ️ SQLite: изменение nullable требует ручной миграции") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка миграции email auth: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -6912,6 +6999,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей withdrawal_requests") + logger.info("=== НАСТРОЙКА EMAIL АУТЕНТИФИКАЦИИ ===") + email_auth_ready = await add_user_email_auth_columns() + if email_auth_ready: + logger.info("✅ Колонки для email-аутентификации готовы") + else: + logger.warning("⚠️ Проблемы с настройкой email-аутентификации") + logger.info("=== СОЗДАНИЕ ТАБЛИЦ КОЛЕСА УДАЧИ ===") wheel_tables_ready = await create_wheel_tables() if wheel_tables_ready: @@ -7079,6 +7173,7 @@ async def check_migration_status(): status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') status["users_notification_settings_column"] = await check_column_exists('users', 'notification_settings') + status["users_auth_type_column"] = await check_column_exists('users', 'auth_type') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled') status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb') @@ -7163,6 +7258,7 @@ async def check_migration_status(): "users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей", "users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей", "users_notification_settings_column": "Колонка notification_settings у пользователей", + "users_auth_type_column": "Колонка auth_type у пользователей (email-регистрация)", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", "subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions", "subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",