diff --git a/app/database/crud/user.py b/app/database/crud/user.py index daf69f1f..f2ddb7f6 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -415,9 +415,19 @@ async def lock_user_for_update(db: AsyncSession, user: User) -> User: Returns the refreshed user object with current DB values. Must be called within an active transaction before modifying balance_kopeks. + Eagerly loads key relationships to avoid MissingGreenlet in async context. """ result = await db.execute( - select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) + select(User) + .where(User.id == user.id) + .options( + selectinload(User.subscription), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), + selectinload(User.promo_group), + selectinload(User.referrer), + ) + .with_for_update() + .execution_options(populate_existing=True) ) return result.scalar_one() @@ -434,8 +444,18 @@ async def add_user_balance( ) -> bool: try: # Lock the user row to prevent concurrent balance race conditions + # Eagerly load key relationships to avoid MissingGreenlet in async context locked_result = await db.execute( - select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) + select(User) + .where(User.id == user.id) + .options( + selectinload(User.subscription), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), + selectinload(User.promo_group), + selectinload(User.referrer), + ) + .with_for_update() + .execution_options(populate_existing=True) ) user = locked_result.scalar_one() @@ -534,8 +554,18 @@ async def subtract_user_balance( ) # Lock the user row to prevent concurrent balance race conditions + # Eagerly load key relationships to avoid MissingGreenlet in async context locked_result = await db.execute( - select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) + select(User) + .where(User.id == user.id) + .options( + selectinload(User.subscription), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), + selectinload(User.promo_group), + selectinload(User.referrer), + ) + .with_for_update() + .execution_options(populate_existing=True) ) user = locked_result.scalar_one() diff --git a/app/services/channel_subscription_service.py b/app/services/channel_subscription_service.py index ec7bd810..5751b5f7 100644 --- a/app/services/channel_subscription_service.py +++ b/app/services/channel_subscription_service.py @@ -274,8 +274,9 @@ class ChannelSubscriptionService: ) return False # Fail-closed -- bot cannot verify membership except TelegramBadRequest as e: - if 'user not found' in str(e).lower(): - return False # User never interacted with bot in that context + err_msg = str(e).lower() + if 'user not found' in err_msg or 'participant_id_invalid' in err_msg: + return False # User never interacted with bot/channel logger.error('Bad request checking channel', channel_id=channel_id, error=str(e)) return False # Fail-closed except TelegramNetworkError: diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index b59a62a1..74b5b2db 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -24,6 +24,8 @@ def format_referrer_info(user: User) -> str: try: # Проверяем, является ли referrer обычным объектом или InstrumentedList + # getattr default does NOT catch MissingGreenlet (not an AttributeError), + # so we wrap in try/except to handle lazy-load failures in async context. referrer = getattr(user, 'referrer', None) # Если referrer это InstrumentedList или None, то возвращаем информацию по ID @@ -39,8 +41,9 @@ def format_referrer_info(user: User) -> str: return f'ID {referrer_telegram_id or referred_by_id}' - except (AttributeError, TypeError): - # Если возникла ошибка при обращении к атрибутам, просто возвращаем ID + except Exception: + # MissingGreenlet is not a subclass of AttributeError/TypeError, + # so we must catch broadly to handle lazy-load failures in async context. return f'ID {referred_by_id} (ошибка загрузки)'