382 lines
16 KiB
Python
382 lines
16 KiB
Python
from dataclasses import dataclass
|
|
|
|
import structlog
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database.crud.campaign import record_campaign_registration
|
|
from app.database.crud.subscription import (
|
|
create_paid_subscription,
|
|
get_subscription_by_user_id,
|
|
)
|
|
from app.database.crud.tariff import get_tariff_by_id
|
|
from app.database.crud.user import add_user_balance
|
|
from app.database.models import AdvertisingCampaign, User
|
|
from app.services.subscription_service import SubscriptionService
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
def _format_user_log(user: User) -> str:
|
|
"""Format user identifier for logging (supports email-only users)."""
|
|
if user.telegram_id:
|
|
return str(user.telegram_id)
|
|
if user.email:
|
|
return f'{user.id} ({user.email})'
|
|
return f'#{user.id}'
|
|
|
|
|
|
@dataclass
|
|
class CampaignBonusResult:
|
|
success: bool
|
|
bonus_type: str | None = None
|
|
balance_kopeks: int = 0
|
|
subscription_days: int | None = None
|
|
subscription_traffic_gb: int | None = None
|
|
subscription_device_limit: int | None = None
|
|
subscription_squads: list[str] | None = None
|
|
# Поля для tariff
|
|
tariff_id: int | None = None
|
|
tariff_name: str | None = None
|
|
tariff_duration_days: int | None = None
|
|
|
|
|
|
class AdvertisingCampaignService:
|
|
def __init__(self) -> None:
|
|
self.subscription_service = SubscriptionService()
|
|
|
|
async def apply_campaign_bonus(
|
|
self,
|
|
db: AsyncSession,
|
|
user: User,
|
|
campaign: AdvertisingCampaign,
|
|
) -> CampaignBonusResult:
|
|
if not campaign.is_active:
|
|
logger.warning('⚠️ Попытка выдать бонус по неактивной кампании', campaign_id=campaign.id)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
# Prevent partner from being attributed to their own campaign
|
|
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
|
logger.info(
|
|
'Skipping campaign bonus: user is the campaign partner',
|
|
user_id=user.id,
|
|
campaign_id=campaign.id,
|
|
)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
if campaign.is_balance_bonus:
|
|
return await self._apply_balance_bonus(db, user, campaign)
|
|
|
|
if campaign.is_subscription_bonus:
|
|
return await self._apply_subscription_bonus(db, user, campaign)
|
|
|
|
if campaign.is_none_bonus:
|
|
return await self._apply_none_bonus(db, user, campaign)
|
|
|
|
if campaign.is_tariff_bonus:
|
|
return await self._apply_tariff_bonus(db, user, campaign)
|
|
|
|
logger.error('❌ Неизвестный тип бонуса кампании', bonus_type=campaign.bonus_type)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
async def _apply_balance_bonus(
|
|
self,
|
|
db: AsyncSession,
|
|
user: User,
|
|
campaign: AdvertisingCampaign,
|
|
) -> CampaignBonusResult:
|
|
amount = campaign.balance_bonus_kopeks or 0
|
|
if amount <= 0:
|
|
logger.info('ℹ️ Кампания не имеет бонуса на баланс', campaign_id=campaign.id)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
description = f"Бонус за регистрацию по кампании '{campaign.name}'"
|
|
success = await add_user_balance(
|
|
db,
|
|
user,
|
|
amount,
|
|
description=description,
|
|
)
|
|
|
|
if not success:
|
|
return CampaignBonusResult(success=False)
|
|
|
|
await record_campaign_registration(
|
|
db,
|
|
campaign_id=campaign.id,
|
|
user_id=user.id,
|
|
bonus_type='balance',
|
|
balance_bonus_kopeks=amount,
|
|
)
|
|
|
|
logger.info(
|
|
'💰 Пользователю начислен бонус ₽ по кампании',
|
|
format_user_log=_format_user_log(user),
|
|
amount=amount / 100,
|
|
campaign_id=campaign.id,
|
|
)
|
|
|
|
return CampaignBonusResult(
|
|
success=True,
|
|
bonus_type='balance',
|
|
balance_kopeks=amount,
|
|
)
|
|
|
|
async def _apply_subscription_bonus(
|
|
self,
|
|
db: AsyncSession,
|
|
user: User,
|
|
campaign: AdvertisingCampaign,
|
|
) -> CampaignBonusResult:
|
|
if settings.is_multi_tariff_enabled():
|
|
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
|
|
|
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
|
if active_subs:
|
|
# Multi-tariff: extend the best existing subscription instead of blocking
|
|
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
|
_pool = _non_daily or active_subs
|
|
existing_subscription = max(_pool, key=lambda s: s.days_left)
|
|
else:
|
|
existing_subscription = None
|
|
else:
|
|
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
|
if existing_subscription:
|
|
logger.warning(
|
|
'⚠️ У пользователя уже есть подписка, бонус кампании пропущен',
|
|
format_user_log=_format_user_log(user),
|
|
campaign_id=campaign.id,
|
|
)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
duration_days = campaign.subscription_duration_days or 0
|
|
if duration_days <= 0:
|
|
logger.info('ℹ️ Кампания не содержит корректной длительности подписки', campaign_id=campaign.id)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
traffic_limit = campaign.subscription_traffic_gb
|
|
device_limit = campaign.subscription_device_limit
|
|
if device_limit is None:
|
|
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
|
try:
|
|
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
|
|
|
squads = await get_effective_tariff_squad_uuids(db, campaign.subscription_squads)
|
|
except Exception as error:
|
|
logger.error('Не удалось подобрать сквады для кампании', campaign_id=campaign.id, error=error)
|
|
squads = list(campaign.subscription_squads or [])
|
|
|
|
if existing_subscription:
|
|
# Multi-tariff: extend the best existing subscription
|
|
from app.database.crud.subscription import extend_subscription
|
|
|
|
await extend_subscription(db, existing_subscription, duration_days)
|
|
try:
|
|
await self.subscription_service.update_remnawave_user(db, existing_subscription)
|
|
except Exception as error:
|
|
logger.error(
|
|
'❌ Ошибка синхронизации RemnaWave при продлении кампании', campaign_id=campaign.id, error=error
|
|
)
|
|
|
|
logger.info(
|
|
'🎁 Подписка пользователя продлена по кампании на дней',
|
|
format_user_log=_format_user_log(user),
|
|
campaign_id=campaign.id,
|
|
duration_days=duration_days,
|
|
subscription_id=existing_subscription.id,
|
|
)
|
|
else:
|
|
new_subscription = await create_paid_subscription(
|
|
db=db,
|
|
user_id=user.id,
|
|
duration_days=duration_days,
|
|
traffic_limit_gb=traffic_limit or 0,
|
|
device_limit=device_limit,
|
|
connected_squads=squads,
|
|
update_server_counters=True,
|
|
is_trial=True,
|
|
)
|
|
|
|
try:
|
|
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
|
except Exception as error:
|
|
logger.error('❌ Ошибка синхронизации RemnaWave для кампании', campaign_id=campaign.id, error=error)
|
|
|
|
logger.info(
|
|
'🎁 Пользователю выдана подписка по кампании на дней',
|
|
format_user_log=_format_user_log(user),
|
|
campaign_id=campaign.id,
|
|
duration_days=duration_days,
|
|
)
|
|
|
|
await record_campaign_registration(
|
|
db,
|
|
campaign_id=campaign.id,
|
|
user_id=user.id,
|
|
bonus_type='subscription',
|
|
subscription_duration_days=duration_days,
|
|
)
|
|
|
|
return CampaignBonusResult(
|
|
success=True,
|
|
bonus_type='subscription',
|
|
subscription_days=duration_days,
|
|
subscription_traffic_gb=traffic_limit or 0,
|
|
subscription_device_limit=device_limit,
|
|
subscription_squads=squads,
|
|
)
|
|
|
|
async def _apply_none_bonus(
|
|
self,
|
|
db: AsyncSession,
|
|
user: User,
|
|
campaign: AdvertisingCampaign,
|
|
) -> CampaignBonusResult:
|
|
"""Обычная ссылка без награды - только регистрация для отслеживания."""
|
|
await record_campaign_registration(
|
|
db,
|
|
campaign_id=campaign.id,
|
|
user_id=user.id,
|
|
bonus_type='none',
|
|
)
|
|
|
|
logger.info(
|
|
'📊 Пользователь зарегистрирован по ссылке кампании (без награды)',
|
|
format_user_log=_format_user_log(user),
|
|
campaign_id=campaign.id,
|
|
)
|
|
|
|
return CampaignBonusResult(
|
|
success=True,
|
|
bonus_type='none',
|
|
)
|
|
|
|
async def _apply_tariff_bonus(
|
|
self,
|
|
db: AsyncSession,
|
|
user: User,
|
|
campaign: AdvertisingCampaign,
|
|
) -> CampaignBonusResult:
|
|
"""Выдача тарифа на определённое время."""
|
|
existing_subscription = None
|
|
if settings.is_multi_tariff_enabled():
|
|
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
|
|
|
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
|
if active_subs and campaign.tariff_id:
|
|
# Multi-tariff: only check for THIS specific tariff
|
|
same_tariff_subs = [s for s in active_subs if s.tariff_id == campaign.tariff_id]
|
|
if same_tariff_subs:
|
|
existing_subscription = max(same_tariff_subs, key=lambda s: s.days_left)
|
|
# If no sub for this tariff, existing_subscription stays None -> create new
|
|
else:
|
|
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
|
if existing_subscription:
|
|
logger.warning(
|
|
'⚠️ У пользователя уже есть подписка, бонус тарифа кампании пропущен',
|
|
format_user_log=_format_user_log(user),
|
|
campaign_id=campaign.id,
|
|
)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
if not campaign.tariff_id:
|
|
logger.error('❌ Кампания не имеет указанного тарифа для выдачи', campaign_id=campaign.id)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
duration_days = campaign.tariff_duration_days or 0
|
|
if duration_days <= 0:
|
|
logger.error('❌ Кампания не имеет указанной длительности тарифа', campaign_id=campaign.id)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
# Получаем тариф для извлечения параметров
|
|
tariff = await get_tariff_by_id(db, campaign.tariff_id)
|
|
if not tariff:
|
|
logger.error('❌ Тариф не найден для кампании', tariff_id=campaign.tariff_id, campaign_id=campaign.id)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
if not tariff.is_active:
|
|
logger.warning('⚠️ Тариф неактивен, бонус кампании пропущен', tariff_id=tariff.id, campaign_id=campaign.id)
|
|
return CampaignBonusResult(success=False)
|
|
|
|
traffic_limit = tariff.traffic_limit_gb
|
|
device_limit = tariff.device_limit
|
|
try:
|
|
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
|
|
|
squads = await get_effective_tariff_squad_uuids(db, tariff.allowed_squads)
|
|
except Exception as error:
|
|
logger.error('Не удалось подобрать сквады для тарифа кампании', campaign_id=campaign.id, error=error)
|
|
squads = list(tariff.allowed_squads or [])
|
|
|
|
if existing_subscription:
|
|
# Multi-tariff: extend the existing subscription for this tariff
|
|
from app.database.crud.subscription import extend_subscription
|
|
|
|
await extend_subscription(db, existing_subscription, duration_days, tariff_id=tariff.id)
|
|
try:
|
|
await self.subscription_service.update_remnawave_user(db, existing_subscription)
|
|
except Exception as error:
|
|
logger.error(
|
|
'❌ Ошибка синхронизации RemnaWave при продлении тарифа кампании',
|
|
campaign_id=campaign.id,
|
|
error=error,
|
|
)
|
|
|
|
logger.info(
|
|
'🎁 Подписка пользователя продлена по тарифу кампании на дней',
|
|
format_user_log=_format_user_log(user),
|
|
tariff_name=tariff.name,
|
|
campaign_id=campaign.id,
|
|
duration_days=duration_days,
|
|
subscription_id=existing_subscription.id,
|
|
)
|
|
else:
|
|
# Создаём подписку как платную (не trial) с привязкой к тарифу
|
|
new_subscription = await create_paid_subscription(
|
|
db=db,
|
|
user_id=user.id,
|
|
duration_days=duration_days,
|
|
traffic_limit_gb=traffic_limit or 0,
|
|
device_limit=device_limit,
|
|
connected_squads=squads,
|
|
update_server_counters=True,
|
|
is_trial=False,
|
|
tariff_id=tariff.id,
|
|
)
|
|
|
|
try:
|
|
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
|
except Exception as error:
|
|
logger.error(
|
|
'❌ Ошибка синхронизации RemnaWave для тарифа кампании', campaign_id=campaign.id, error=error
|
|
)
|
|
|
|
logger.info(
|
|
'🎁 Пользователю выдан тариф по кампании на дней',
|
|
format_user_log=_format_user_log(user),
|
|
tariff_name=tariff.name,
|
|
campaign_id=campaign.id,
|
|
duration_days=duration_days,
|
|
)
|
|
|
|
await record_campaign_registration(
|
|
db,
|
|
campaign_id=campaign.id,
|
|
user_id=user.id,
|
|
bonus_type='tariff',
|
|
tariff_id=tariff.id,
|
|
tariff_duration_days=duration_days,
|
|
)
|
|
|
|
return CampaignBonusResult(
|
|
success=True,
|
|
bonus_type='tariff',
|
|
tariff_id=tariff.id,
|
|
tariff_name=tariff.name,
|
|
tariff_duration_days=duration_days,
|
|
subscription_traffic_gb=traffic_limit or 0,
|
|
subscription_device_limit=device_limit,
|
|
subscription_squads=squads,
|
|
)
|