fix: persist campaign across bot→webapp registration handoff via Redis
This commit is contained in:
+102
-60
@@ -172,74 +172,112 @@ async def _process_campaign_bonus(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user: User,
|
user: User,
|
||||||
campaign_slug: str | None,
|
campaign_slug: str | None,
|
||||||
|
telegram_id: int | None = None,
|
||||||
) -> CampaignBonusInfo | None:
|
) -> CampaignBonusInfo | None:
|
||||||
"""Process campaign bonus for user during auth. Never raises."""
|
"""Process campaign bonus for user during auth. Never raises.
|
||||||
|
|
||||||
|
If ``campaign_slug`` is not provided but ``telegram_id`` is given, the
|
||||||
|
function falls back to Redis ``pending_campaign:{telegram_id}`` -- populated
|
||||||
|
by the bot's /start handler when a user opens an advertising campaign link
|
||||||
|
but then completes registration via the cabinet WebApp (Telegram menu
|
||||||
|
button) instead of the bot dialog. The Redis entry is cleared after a
|
||||||
|
successful consumption attempt.
|
||||||
|
"""
|
||||||
|
pending_campaign_consumed = False
|
||||||
|
if not campaign_slug and telegram_id:
|
||||||
|
try:
|
||||||
|
from app.services.referral_service import get_pending_campaign
|
||||||
|
|
||||||
|
pending = await get_pending_campaign(telegram_id)
|
||||||
|
if pending and pending.get('campaign_slug'):
|
||||||
|
campaign_slug = pending['campaign_slug']
|
||||||
|
pending_campaign_consumed = True
|
||||||
|
logger.info(
|
||||||
|
'Resolved campaign from Redis pending_campaign (cabinet)',
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
campaign_slug=campaign_slug,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Failed to check pending campaign', error=e)
|
||||||
|
|
||||||
if not campaign_slug:
|
if not campaign_slug:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
try:
|
||||||
if not campaign:
|
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||||
return None
|
if not campaign:
|
||||||
|
return None
|
||||||
|
|
||||||
# Skip if user IS the campaign partner — prevent self-referral
|
# Skip if user IS the campaign partner — prevent self-referral
|
||||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'Skipping campaign attribution: user is the campaign partner',
|
'Skipping campaign attribution: user is the campaign partner',
|
||||||
user_id=user.id,
|
|
||||||
campaign_id=campaign.id,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Lock user row to prevent concurrent bonus application (race condition)
|
|
||||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
|
||||||
|
|
||||||
existing = await get_campaign_registration_by_user(db, user.id)
|
|
||||||
if existing:
|
|
||||||
logger.debug('User already has campaign registration', user_id=user.id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
|
||||||
if campaign.partner_user_id and not user.referred_by_id:
|
|
||||||
user.referred_by_id = campaign.partner_user_id
|
|
||||||
await db.flush()
|
|
||||||
try:
|
|
||||||
from app.bot_factory import create_bot
|
|
||||||
|
|
||||||
async with create_bot() as bot:
|
|
||||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
|
||||||
logger.info(
|
|
||||||
'Referral set from campaign partner',
|
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
partner_user_id=campaign.partner_user_id,
|
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
return None
|
||||||
logger.error('Failed to process referral from campaign partner', error=e)
|
|
||||||
|
|
||||||
service = AdvertisingCampaignService()
|
# Lock user row to prevent concurrent bonus application (race condition)
|
||||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||||
if not result.success:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Refresh user to get updated balance after bonus
|
existing = await get_campaign_registration_by_user(db, user.id)
|
||||||
await db.refresh(user)
|
if existing:
|
||||||
|
logger.debug('User already has campaign registration', user_id=user.id)
|
||||||
|
return None
|
||||||
|
|
||||||
return CampaignBonusInfo(
|
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||||
campaign_name=campaign.name,
|
if campaign.partner_user_id and not user.referred_by_id:
|
||||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
user.referred_by_id = campaign.partner_user_id
|
||||||
balance_kopeks=result.balance_kopeks,
|
await db.flush()
|
||||||
subscription_days=result.subscription_days,
|
try:
|
||||||
tariff_name=result.tariff_name,
|
from app.bot_factory import create_bot
|
||||||
)
|
|
||||||
except Exception:
|
async with create_bot() as bot:
|
||||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||||
try:
|
logger.info(
|
||||||
await db.rollback()
|
'Referral set from campaign partner',
|
||||||
# Re-fetch user so session stays usable for the caller
|
user_id=user.id,
|
||||||
|
partner_user_id=campaign.partner_user_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Failed to process referral from campaign partner', error=e)
|
||||||
|
|
||||||
|
service = AdvertisingCampaignService()
|
||||||
|
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||||
|
if not result.success:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Refresh user to get updated balance after bonus
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
return CampaignBonusInfo(
|
||||||
|
campaign_name=campaign.name,
|
||||||
|
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||||
|
balance_kopeks=result.balance_kopeks,
|
||||||
|
subscription_days=result.subscription_days,
|
||||||
|
tariff_name=result.tariff_name,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||||
return None
|
try:
|
||||||
|
await db.rollback()
|
||||||
|
# Re-fetch user so session stays usable for the caller
|
||||||
|
await db.refresh(user)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
# Clear Redis pending_campaign whenever we consumed it. Done regardless
|
||||||
|
# of success — if processing failed (already applied, race, exception),
|
||||||
|
# we don't want to keep retrying on every subsequent login.
|
||||||
|
if pending_campaign_consumed and telegram_id:
|
||||||
|
try:
|
||||||
|
from app.services.referral_service import clear_pending_campaign
|
||||||
|
|
||||||
|
await clear_pending_campaign(telegram_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _process_referral_code(
|
async def _process_referral_code(
|
||||||
@@ -584,8 +622,11 @@ async def auth_telegram(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Process campaign bonus
|
# Process campaign bonus.
|
||||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
# Pass telegram_id so the function can fall back to Redis pending_campaign
|
||||||
|
# if the user came via /start <campaign> in the bot but completed
|
||||||
|
# registration in the WebApp without an explicit campaign_slug.
|
||||||
|
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
|
||||||
if response.campaign_bonus:
|
if response.campaign_bonus:
|
||||||
response.user = _user_to_response(user)
|
response.user = _user_to_response(user)
|
||||||
|
|
||||||
@@ -691,8 +732,8 @@ async def auth_telegram_widget(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Process campaign bonus
|
# Process campaign bonus (pending_campaign Redis fallback for Telegram Login Widget)
|
||||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=request.id)
|
||||||
if response.campaign_bonus:
|
if response.campaign_bonus:
|
||||||
response.user = _user_to_response(user)
|
response.user = _user_to_response(user)
|
||||||
|
|
||||||
@@ -837,7 +878,8 @@ async def auth_telegram_oidc(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
# Process campaign bonus (pending_campaign Redis fallback for Telegram OIDC)
|
||||||
|
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug, telegram_id=telegram_id)
|
||||||
if response.campaign_bonus:
|
if response.campaign_bonus:
|
||||||
response.user = _user_to_response(user)
|
response.user = _user_to_response(user)
|
||||||
|
|
||||||
|
|||||||
+47
-1
@@ -49,7 +49,11 @@ from app.services.pinned_message_service import (
|
|||||||
get_active_pinned_message,
|
get_active_pinned_message,
|
||||||
)
|
)
|
||||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||||
from app.services.referral_service import process_referral_registration, save_pending_referral
|
from app.services.referral_service import (
|
||||||
|
process_referral_registration,
|
||||||
|
save_pending_campaign,
|
||||||
|
save_pending_referral,
|
||||||
|
)
|
||||||
from app.services.subscription_service import SubscriptionService
|
from app.services.subscription_service import SubscriptionService
|
||||||
from app.services.support_settings_service import SupportSettingsService
|
from app.services.support_settings_service import SupportSettingsService
|
||||||
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
|
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
|
||||||
@@ -386,6 +390,17 @@ async def _apply_campaign_bonus_if_needed(
|
|||||||
if not result.success:
|
if not result.success:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Bot-flow successfully applied the campaign — clear the Redis pending entry
|
||||||
|
# (set in cmd_start as a fallback for the cabinet WebApp path) so it isn't
|
||||||
|
# re-evaluated on a subsequent cabinet login.
|
||||||
|
try:
|
||||||
|
from app.services.referral_service import clear_pending_campaign
|
||||||
|
|
||||||
|
if getattr(user, 'telegram_id', None):
|
||||||
|
await clear_pending_campaign(user.telegram_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if result.bonus_type == 'balance':
|
if result.bonus_type == 'balance':
|
||||||
amount_text = texts.format_price(result.balance_kopeks)
|
amount_text = texts.format_price(result.balance_kopeks)
|
||||||
return texts.CAMPAIGN_BONUS_BALANCE.format(
|
return texts.CAMPAIGN_BONUS_BALANCE.format(
|
||||||
@@ -724,6 +739,23 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
|||||||
start_parameter=campaign.start_parameter,
|
start_parameter=campaign.start_parameter,
|
||||||
)
|
)
|
||||||
await state.update_data(campaign_id=campaign.id)
|
await state.update_data(campaign_id=campaign.id)
|
||||||
|
# Persist campaign to Redis immediately so it survives if user opens
|
||||||
|
# miniapp/cabinet (via Telegram menu button) before completing the
|
||||||
|
# bot registration flow. Mirrors the pending_referral mechanism.
|
||||||
|
# Only for new users — existing users already had attribution applied.
|
||||||
|
if not db_user:
|
||||||
|
try:
|
||||||
|
await save_pending_campaign(
|
||||||
|
message.from_user.id,
|
||||||
|
campaign.start_parameter,
|
||||||
|
campaign.id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
'Failed to persist pending campaign',
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
error=exc,
|
||||||
|
)
|
||||||
if campaign.partner_user_id:
|
if campaign.partner_user_id:
|
||||||
await state.update_data(referrer_id=campaign.partner_user_id)
|
await state.update_data(referrer_id=campaign.partner_user_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -2416,6 +2448,20 @@ async def required_sub_channel_check(
|
|||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
partner_user_id=campaign.partner_user_id,
|
partner_user_id=campaign.partner_user_id,
|
||||||
)
|
)
|
||||||
|
# Mirror save in Redis so cabinet WebApp auth can pick it up
|
||||||
|
# if user opens miniapp before completing registration.
|
||||||
|
try:
|
||||||
|
await save_pending_campaign(
|
||||||
|
query.from_user.id,
|
||||||
|
campaign.start_parameter,
|
||||||
|
campaign.id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
'Failed to persist pending campaign after channel check',
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
error=exc,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
state_data['referral_code'] = pending_start_payload
|
state_data['referral_code'] = pending_start_payload
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -99,6 +99,73 @@ async def clear_pending_referral(telegram_id: int) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pending campaign helpers (Redis)
|
||||||
|
#
|
||||||
|
# Mirrors pending_referral: lets us survive the case where /start <campaign>
|
||||||
|
# stored campaign_id only in FSM, but the user opened the cabinet WebApp before
|
||||||
|
# completing bot registration. The cabinet auth route reads this as a fallback
|
||||||
|
# when the HTTP request didn't carry an explicit campaign_slug.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_PENDING_CAMPAIGN_TTL = 7 * 24 * 3600 # 7 days
|
||||||
|
|
||||||
|
|
||||||
|
async def save_pending_campaign(telegram_id: int, campaign_slug: str, campaign_id: int) -> bool:
|
||||||
|
"""Save pending campaign attribution to Redis for a not-yet-registered user.
|
||||||
|
|
||||||
|
Called from /start handler immediately after resolving an advertising campaign.
|
||||||
|
Picked up by the cabinet auth route if the user opens the WebApp before
|
||||||
|
completing the bot registration flow.
|
||||||
|
"""
|
||||||
|
client = _get_redis()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
key = f'pending_campaign:{telegram_id}'
|
||||||
|
data = json.dumps({'campaign_slug': campaign_slug, 'campaign_id': campaign_id})
|
||||||
|
await client.setex(key, _PENDING_CAMPAIGN_TTL, data)
|
||||||
|
logger.info(
|
||||||
|
'Saved pending campaign to Redis',
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
campaign_slug=campaign_slug,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Failed to save pending campaign to Redis', error=exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_campaign(telegram_id: int) -> dict[str, str | int] | None:
|
||||||
|
"""Get pending campaign from Redis.
|
||||||
|
|
||||||
|
Returns ``{'campaign_slug': ..., 'campaign_id': ...}`` or ``None``.
|
||||||
|
"""
|
||||||
|
client = _get_redis()
|
||||||
|
if client is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
key = f'pending_campaign:{telegram_id}'
|
||||||
|
data = await client.get(key)
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Failed to get pending campaign from Redis', error=exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_pending_campaign(telegram_id: int) -> None:
|
||||||
|
"""Clear pending campaign after successful application."""
|
||||||
|
client = _get_redis()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await client.delete(f'pending_campaign:{telegram_id}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
|
async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
|
||||||
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
|
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
|
||||||
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
|
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
|
||||||
|
|||||||
Reference in New Issue
Block a user