diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 30ec3d3c..813fb9ce 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select +from sqlalchemy import Integer, and_, delete as sa_delete, func, literal, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -42,12 +42,15 @@ from app.database.models import ( UserPromoGroup, UserStatus, ) +from app.services.permission_service import PermissionService from app.utils.timezone import panel_datetime_to_utc from ..dependencies import get_cabinet_db, require_permission from ..schemas.users import ( AdminUserGiftItem, AdminUserGiftsResponse, + AssignReferrerRequest, + AssignReferrerResponse, DeleteDeviceResponse, DeleteUserRequest, DeleteUserResponse, @@ -1696,6 +1699,13 @@ async def update_user_referral_commission( db: AsyncSession = Depends(get_cabinet_db), ): """Update user's individual referral commission percentage.""" + # Prevent admin from modifying their own commission + if user_id == admin.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Admin cannot modify their own referral commission', + ) + user = await get_user_by_id(db, user_id) if not user: raise HTTPException( @@ -1706,6 +1716,14 @@ async def update_user_referral_commission( old_commission = user.referral_commission_percent user.referral_commission_percent = request.commission_percent user.updated_at = datetime.now(UTC) + await PermissionService.log_action( + db, + user_id=admin.id, + action='update_referral_commission', + resource_type='user', + resource_id=str(user_id), + details={'old_commission': old_commission, 'new_commission': request.commission_percent}, + ) await db.commit() logger.info( @@ -1724,6 +1742,103 @@ async def update_user_referral_commission( ) +# === Assign Referrer === + + +@router.post('/{user_id}/assign-referrer', response_model=AssignReferrerResponse) +async def assign_user_referrer( + user_id: int, + request: AssignReferrerRequest, + admin: User = Depends(require_permission('users:referral')), + db: AsyncSession = Depends(get_cabinet_db), +): + """Manually assign a referrer to a user (e.g. cabinet-registered users without telegram_id). + + Bonuses are NOT triggered immediately — they will apply on the user's next topup. + """ + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='User not found', + ) + + if user_id == request.referrer_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='User cannot be their own referrer', + ) + + # Prevent admin self-enrichment + if request.referrer_id == admin.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Admin cannot assign themselves as referrer', + ) + + referrer = await get_user_by_id(db, request.referrer_id) + if not referrer: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Referrer user not found', + ) + + # Prevent circular referral chains of any depth via recursive CTE + if await _would_create_referral_cycle(db, user_id, request.referrer_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Circular referral: assigning this referrer would create a cycle in the referral chain', + ) + + old_referrer_id = user.referred_by_id + user.referred_by_id = request.referrer_id + user.updated_at = datetime.now(UTC) + await PermissionService.log_action( + db, + user_id=admin.id, + action='assign_referrer', + resource_type='user', + resource_id=str(user_id), + details={'old_referrer_id': old_referrer_id, 'new_referrer_id': request.referrer_id}, + ) + await db.commit() + + logger.info( + 'Admin assigned referrer to user', + admin_id=admin.id, + user_id=user_id, + old_referrer_id=old_referrer_id, + new_referrer_id=request.referrer_id, + ) + + return AssignReferrerResponse( + success=True, + old_referrer_id=old_referrer_id, + new_referrer_id=request.referrer_id, + message='Referrer assigned successfully. Bonuses will apply on next user topup.', + ) + + +async def _would_create_referral_cycle(db: AsyncSession, user_id: int, referrer_id: int) -> bool: + """Walk the referrer's ancestor chain; if user_id appears, a cycle would form.""" + max_depth = 50 + anchor = ( + select(User.id, User.referred_by_id, literal(0).label('depth')) + .where(User.id == referrer_id) + .cte(name='ancestors', recursive=True) + ) + rpart = ( + select(User.id, User.referred_by_id, (anchor.c.depth + 1).label('depth')) + .join(anchor, User.id == anchor.c.referred_by_id) + .where(anchor.c.depth < max_depth) + ) + ancestors_cte = anchor.union_all(rpart) + result = await db.execute( + select(literal(1)).where(ancestors_cte.c.id == user_id).select_from(ancestors_cte).limit(1) + ) + return result.scalar_one_or_none() is not None + + # === Devices === diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 84482acc..7c1f0a8d 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -387,6 +387,21 @@ class UpdateReferralCommissionResponse(BaseModel): message: str +class AssignReferrerRequest(BaseModel): + """Request to manually assign a referrer to a user.""" + + referrer_id: int = Field(..., gt=0, description='ID of the referrer user') + + +class AssignReferrerResponse(BaseModel): + """Response after referrer assignment.""" + + success: bool + old_referrer_id: int | None = None + new_referrer_id: int | None = None + message: str + + class DeviceInfo(BaseModel): """Individual device info.""" diff --git a/app/database/crud/user.py b/app/database/crud/user.py index cb1570b1..f0f572a2 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -139,6 +139,7 @@ async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User .where( User.telegram_id.is_(None), User.auth_type == 'telegram', + User.status != UserStatus.DELETED.value, func.lower(User.username) == normalized, ) .with_for_update() diff --git a/app/database/models.py b/app/database/models.py index 33a1813d..9ab1821b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -2364,7 +2364,7 @@ class SubscriptionServer(Base): __tablename__ = 'subscription_servers' id = Column(Integer, primary_key=True, index=True) - subscription_id = Column(Integer, ForeignKey('subscriptions.id'), nullable=False) + subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True) server_squad_id = Column(Integer, ForeignKey('server_squads.id'), nullable=False) connected_at = Column(AwareDateTime(), default=func.now()) diff --git a/app/handlers/start.py b/app/handlers/start.py index 339cc1b3..02fcdc6e 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -178,10 +178,16 @@ async def _claim_phantom_user( existing = await get_user_by_telegram_id(db, telegram_id) return False, existing await db.refresh(phantom, ['subscription']) - logger.info( - 'Claimed phantom user from guest purchase', + # SECURITY NOTE: Phantom matched by username only (telegram_id was unknown at purchase time). + # Telegram usernames are changeable/reassignable, so the claimer may not be the intended + # recipient. This is logged at WARNING for admin audit. A confirmation flow would be needed + # to fully prevent username spoofing attacks on phantom claims. + logger.warning( + 'Phantom user claimed by username match (verify intended recipient)', phantom_user_id=phantom.id, telegram_id=telegram_id, + username=username, + has_subscription=phantom.subscription is not None, ) # Sync Remnawave panel with updated user data (telegram_id, username, etc.) @@ -212,10 +218,11 @@ async def _merge_phantom_into_active_user( """ from sqlalchemy import update - logger.info( - 'Merging phantom user into active user', + logger.warning( + 'Merging phantom user into active user (audit: username-only match)', phantom_id=phantom.id, active_user_id=active_user.id, + active_user_telegram_id=active_user.telegram_id, phantom_username=phantom.username, ) @@ -239,10 +246,12 @@ async def _merge_phantom_into_active_user( if phantom.subscription and not active_user.subscription: # Transfer subscription from phantom to active user phantom.subscription.user_id = active_user.id - # Transfer remnawave_uuid + # Transfer remnawave_uuid (clear first to avoid unique constraint violation on flush) if phantom.remnawave_uuid and not active_user.remnawave_uuid: - active_user.remnawave_uuid = phantom.remnawave_uuid + uuid_to_transfer = phantom.remnawave_uuid phantom.remnawave_uuid = None + await db.flush() + active_user.remnawave_uuid = uuid_to_transfer await db.flush() logger.info( 'Transferred subscription from phantom to active user', @@ -263,11 +272,12 @@ async def _merge_phantom_into_active_user( logger.warning('Failed to disable phantom Remnawave user', error=str(exc)) await decrement_subscription_server_counts(db, phantom.subscription) - # Soft-delete phantom: clear identifiers to prevent future matches, - # preserve record for audit trail and avoid CASCADE deletion of payments/transactions + # Soft-delete phantom: clear unique identifiers to prevent future matches + # and constraint violations. Preserve record for audit trail. phantom.status = UserStatus.DELETED.value phantom.username = None phantom.remnawave_uuid = None + phantom.referral_code = None await db.flush() logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id) @@ -705,6 +715,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if phantom and phantom.id != user.id: try: await _merge_phantom_into_active_user(db, phantom, user) + await db.commit() await db.refresh(user, ['subscription']) except Exception: await db.rollback() @@ -1528,7 +1539,20 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta referrer_id=referrer_id, ) if not claimed and user: - # IntegrityError fallback — use existing user + # Phantom claim failed (IntegrityError — user with this telegram_id already exists). + # Merge phantom's subscription + GuestPurchase records into the existing user. + if phantom.id != user.id: + try: + await db.refresh(phantom, ['subscription']) + await _merge_phantom_into_active_user(db, phantom, user) + await db.commit() + except Exception: + await db.rollback() + logger.exception( + 'Failed to merge phantom into existing user during registration', + phantom_id=phantom.id, + active_user_id=user.id, + ) await db.refresh(user, ['subscription']) elif not claimed: logger.critical( @@ -1828,6 +1852,20 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A referrer_id=referrer_id, ) if not claimed and user: + # Phantom claim failed (IntegrityError — user with this telegram_id already exists). + # Merge phantom's subscription + GuestPurchase records into the existing user. + if phantom.id != user.id: + try: + await db.refresh(phantom, ['subscription']) # Re-sync after rollback in _claim_phantom_user + await _merge_phantom_into_active_user(db, phantom, user) + await db.commit() + except Exception: + await db.rollback() + logger.exception( + 'Failed to merge phantom into existing user during registration', + phantom_id=phantom.id, + active_user_id=user.id, + ) await db.refresh(user, ['subscription']) elif not claimed: logger.critical( @@ -1899,7 +1937,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A logger.warning( '⚠️ Не удалось активировать промокод', promocode_to_activate=promocode_to_activate, - get=promocode_result.get('error'), + error=promocode_result.get('error'), ) except Exception as e: logger.error('❌ Ошибка при активации промокода', promocode_to_activate=promocode_to_activate, error=e) @@ -2433,6 +2471,20 @@ async def required_sub_channel_check( referrer_id=referrer_id, ) if not claimed and user: + # Phantom claim failed (IntegrityError — user with this telegram_id already exists). + # Merge phantom's subscription + GuestPurchase records into the existing user. + if phantom.id != user.id: + try: + await db.refresh(phantom, ['subscription']) + await _merge_phantom_into_active_user(db, phantom, user) + await db.commit() + except Exception: + await db.rollback() + logger.exception( + 'Failed to merge phantom into existing user during registration', + phantom_id=phantom.id, + active_user_id=user.id, + ) await db.refresh(user, ['subscription']) elif not claimed: logger.critical( diff --git a/app/services/account_merge_service.py b/app/services/account_merge_service.py index 4e01a63d..2c70b8c4 100644 --- a/app/services/account_merge_service.py +++ b/app/services/account_merge_service.py @@ -23,9 +23,11 @@ from app.database.models import ( CryptoBotPayment, DiscountOffer, FreekassaPayment, + GuestPurchase, HeleketPayment, KassaAiPayment, MulenPayPayment, + NewsArticle, Pal24Payment, PartnerApplication, PartnerStatus, @@ -40,10 +42,14 @@ from app.database.models import ( ReferralContest, ReferralContestEvent, ReferralEarning, + RioPayPayment, + SavedPaymentMethod, SentNotification, + SeverPayPayment, Subscription, SubscriptionConversion, SubscriptionEvent, + SubscriptionServer, SupportAuditLog, Ticket, TicketMessage, @@ -78,6 +84,8 @@ _PAYMENT_MODELS: tuple[type, ...] = ( MulenPayPayment, Pal24Payment, PlategaPayment, + RioPayPayment, + SeverPayPayment, WataPayment, YooKassaPayment, ) @@ -301,6 +309,8 @@ async def _handle_subscription_merge( if primary.remnawave_uuid: await _delete_remnawave_user_with_fallback(primary.remnawave_uuid) primary.remnawave_uuid = None + # Явно удаляем subscription_servers перед подпиской (CASCADE настроен, но делаем явно для ясности) + await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == primary_sub.id)) # Удаляем запись подписки primary await db.delete(primary_sub) await db.flush() @@ -323,6 +333,8 @@ async def _handle_subscription_merge( if secondary.remnawave_uuid: await _delete_remnawave_user_with_fallback(secondary.remnawave_uuid) secondary.remnawave_uuid = None + # Явно удаляем subscription_servers перед подпиской (CASCADE настроен, но делаем явно для ясности) + await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == secondary_sub.id)) # Удаляем запись подписки secondary await db.delete(secondary_sub) await db.flush() @@ -492,6 +504,11 @@ async def execute_merge( for payment_model in _PAYMENT_MODELS: await db.execute(update(payment_model).where(payment_model.user_id == secondary.id).values(user_id=primary.id)) + # 7b. Переназначение saved_payment_methods (FK без ondelete) + await db.execute( + update(SavedPaymentMethod).where(SavedPaymentMethod.user_id == secondary.id).values(user_id=primary.id) + ) + # 8. Переназначение referral_earnings # 8a. Удаляем cross-referral записи между участниками мержа (иначе станут self-referral) await db.execute( @@ -702,6 +719,13 @@ async def execute_merge( await db.execute(update(PinnedMessage).where(PinnedMessage.created_by == secondary.id).values(created_by=None)) await db.execute(update(AdminRole).where(AdminRole.created_by == secondary.id).values(created_by=None)) await db.execute(update(AccessPolicy).where(AccessPolicy.created_by == secondary.id).values(created_by=None)) + await db.execute(update(NewsArticle).where(NewsArticle.created_by == secondary.id).values(created_by=None)) + + # 10s. Переназначение guest_purchases (оба FK — buyer_user_id и user_id) + await db.execute( + update(GuestPurchase).where(GuestPurchase.buyer_user_id == secondary.id).values(buyer_user_id=primary.id) + ) + await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == secondary.id).values(user_id=primary.id)) # 11. Инвалидация refresh-токенов обоих пользователей (после мержа будет создан новый) now = datetime.now(UTC) diff --git a/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py b/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py new file mode 100644 index 00000000..0ced8d60 --- /dev/null +++ b/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py @@ -0,0 +1,74 @@ +"""add ON DELETE CASCADE and index to subscription_servers.subscription_id + +Revision ID: 0047 +Revises: 0046 +Create Date: 2026-03-23 + +Recreates the FK constraint on subscription_servers.subscription_id +with ON DELETE CASCADE so that deleting a subscription automatically +removes dependent subscription_servers rows. Also adds an index +on subscription_id for efficient CASCADE deletes and joins. +""" + +from collections.abc import Sequence + +from alembic import op +from sqlalchemy import text + +revision: str = '0047' +down_revision: str | None = '0046' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_actual_fk_name(connection, table: str, column: str) -> str | None: + """Look up actual FK constraint name from pg_constraint.""" + result = connection.execute( + text(""" + SELECT con.conname + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + JOIN pg_attribute att ON att.attrelid = con.conrelid + AND att.attnum = ANY(con.conkey) + WHERE rel.relname = :table + AND att.attname = :column + AND con.contype = 'f' + AND nsp.nspname = 'public' + LIMIT 1 + """), + {'table': table, 'column': column}, + ) + row = result.fetchone() + return row[0] if row else None + + +def upgrade() -> None: + connection = op.get_bind() + actual_fk = _get_actual_fk_name(connection, 'subscription_servers', 'subscription_id') + if actual_fk: + op.drop_constraint(actual_fk, 'subscription_servers', type_='foreignkey') + op.create_foreign_key( + 'subscription_servers_subscription_id_fkey', + 'subscription_servers', + 'subscriptions', + ['subscription_id'], + ['id'], + ondelete='CASCADE', + ) + op.create_index('ix_subscription_servers_subscription_id', 'subscription_servers', ['subscription_id']) + + +def downgrade() -> None: + op.drop_index('ix_subscription_servers_subscription_id', 'subscription_servers') + connection = op.get_bind() + actual_fk = _get_actual_fk_name(connection, 'subscription_servers', 'subscription_id') + if actual_fk: + op.drop_constraint(actual_fk, 'subscription_servers', type_='foreignkey') + op.create_foreign_key( + 'subscription_servers_subscription_id_fkey', + 'subscription_servers', + 'subscriptions', + ['subscription_id'], + ['id'], + ) diff --git a/migrations/alembic/versions/0048_add_lower_username_index.py b/migrations/alembic/versions/0048_add_lower_username_index.py new file mode 100644 index 00000000..c30000a5 --- /dev/null +++ b/migrations/alembic/versions/0048_add_lower_username_index.py @@ -0,0 +1,35 @@ +"""add functional index on lower(username) for phantom user lookup + +Revision ID: 0048 +Revises: 0047 +Create Date: 2026-03-23 + +The find_phantom_user_by_username query uses func.lower(User.username) +which cannot use a regular B-tree index on username. This adds a +functional index to avoid sequential scans on the users table. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = '0048' +down_revision: str | None = '0047' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.execute( + sa.text( + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_users_username_lower ' + 'ON users (lower(username))' + ) + ) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_users_username_lower'))