From 0c07812ecc9502f54a7745a77b086fc52bdc0e34 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 18 Feb 2026 09:12:01 +0300 Subject: [PATCH] feat: add campaign_id to ReferralEarning for campaign attribution Adds nullable FK campaign_id to referral_earnings table, enabling direct campaign ROI analytics without JOINing through registrations. - Model: campaign_id column + AdvertisingCampaign relationship - CRUD: get_user_campaign_id() helper, campaign_id param in create_referral_earning - Service: resolve campaign_id in all earning creation paths - Cabinet API: campaign_name in earnings response - Migration 0002: add column + deterministic backfill via DISTINCT ON --- app/cabinet/routes/referral.py | 12 +++- app/cabinet/schemas/referral.py | 1 + app/database/crud/referral.py | 21 ++++++- app/database/models.py | 2 + app/services/referral_diagnostics_service.py | 6 +- app/services/referral_service.py | 16 ++++- ...02_add_campaign_id_to_referral_earnings.py | 61 +++++++++++++++++++ 7 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 migrations/alembic/versions/0002_add_campaign_id_to_referral_earnings.py diff --git a/app/cabinet/routes/referral.py b/app/cabinet/routes/referral.py index e06d7e60..483de776 100644 --- a/app/cabinet/routes/referral.py +++ b/app/cabinet/routes/referral.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings -from app.database.models import ReferralEarning, User +from app.database.models import AdvertisingCampaign, ReferralEarning, User from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.referral import ( @@ -158,9 +158,18 @@ async def get_referral_earnings( else: referral_users_map = {} + # Batch-fetch campaigns to avoid N+1 + campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id}) + if campaign_ids: + campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids))) + campaigns_map = {c.id: c for c in campaigns_result.scalars().all()} + else: + campaigns_map = {} + items = [] for e in earnings: referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None + campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None items.append( ReferralEarningResponse( @@ -170,6 +179,7 @@ async def get_referral_earnings( reason=e.reason or 'Referral commission', referral_username=referral_user.username if referral_user else None, referral_first_name=referral_user.first_name if referral_user else None, + campaign_name=campaign.name if campaign else None, created_at=e.created_at, ) ) diff --git a/app/cabinet/schemas/referral.py b/app/cabinet/schemas/referral.py index bacf2e61..3b990272 100644 --- a/app/cabinet/schemas/referral.py +++ b/app/cabinet/schemas/referral.py @@ -47,6 +47,7 @@ class ReferralEarningResponse(BaseModel): reason: str referral_username: str | None = None referral_first_name: str | None = None + campaign_name: str | None = None created_at: datetime class Config: diff --git a/app/database/crud/referral.py b/app/database/crud/referral.py index 180e81e7..fe163f30 100644 --- a/app/database/crud/referral.py +++ b/app/database/crud/referral.py @@ -5,12 +5,23 @@ from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import ReferralEarning, User +from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, User logger = structlog.get_logger(__name__) +async def get_user_campaign_id(db: AsyncSession, user_id: int) -> int | None: + """Получить campaign_id первой регистрации пользователя.""" + result = await db.execute( + select(AdvertisingCampaignRegistration.campaign_id) + .where(AdvertisingCampaignRegistration.user_id == user_id) + .order_by(AdvertisingCampaignRegistration.created_at.asc()) + .limit(1) + ) + return result.scalar_one_or_none() + + async def create_referral_earning( db: AsyncSession, user_id: int, @@ -18,6 +29,7 @@ async def create_referral_earning( amount_kopeks: int, reason: str, referral_transaction_id: int | None = None, + campaign_id: int | None = None, ) -> ReferralEarning: earning = ReferralEarning( user_id=user_id, @@ -25,6 +37,7 @@ async def create_referral_earning( amount_kopeks=amount_kopeks, reason=reason, referral_transaction_id=referral_transaction_id, + campaign_id=campaign_id, ) db.add(earning) @@ -42,7 +55,11 @@ async def get_referral_earnings_by_user( ) -> list[ReferralEarning]: result = await db.execute( select(ReferralEarning) - .options(selectinload(ReferralEarning.referral), selectinload(ReferralEarning.referral_transaction)) + .options( + selectinload(ReferralEarning.referral), + selectinload(ReferralEarning.referral_transaction), + selectinload(ReferralEarning.campaign), + ) .where(ReferralEarning.user_id == user_id) .order_by(ReferralEarning.created_at.desc()) .offset(offset) diff --git a/app/database/models.py b/app/database/models.py index dd92f8e2..ba99babb 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1476,12 +1476,14 @@ class ReferralEarning(Base): reason = Column(String(100), nullable=False) referral_transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + campaign_id = Column(Integer, ForeignKey('advertising_campaigns.id', ondelete='SET NULL'), nullable=True, index=True) created_at = Column(DateTime(timezone=True), default=func.now()) user = relationship('User', foreign_keys=[user_id], back_populates='referral_earnings') referral = relationship('User', foreign_keys=[referral_id]) referral_transaction = relationship('Transaction') + campaign = relationship('AdvertisingCampaign') @property def amount_rubles(self) -> float: diff --git a/app/services/referral_diagnostics_service.py b/app/services/referral_diagnostics_service.py index 7dcdd7be..a45cd7ba 100644 --- a/app/services/referral_diagnostics_service.py +++ b/app/services/referral_diagnostics_service.py @@ -18,7 +18,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.referral import create_referral_earning +from app.database.crud.referral import create_referral_earning, get_user_campaign_id from app.database.crud.user import add_user_balance from app.database.models import ReferralEarning, User @@ -802,12 +802,14 @@ class ReferralDiagnosticsService: ) # Создаём запись ReferralEarning + campaign_id = await get_user_campaign_id(db, user.id) await create_referral_earning( db=db, user_id=referrer.id, referral_id=user.id, amount_kopeks=inviter_bonus, reason='referral_first_topup', + campaign_id=campaign_id, ) logger.info( @@ -1039,12 +1041,14 @@ class ReferralDiagnosticsService: ) # Создаём ReferralEarning чтобы не начислять повторно + campaign_id = await get_user_campaign_id(db, referral.id) await create_referral_earning( db=db, user_id=referrer.id, referral_id=referral.id, amount_kopeks=missing.referrer_bonus_amount, reason='referral_first_topup', + campaign_id=campaign_id, ) logger.info( '💰 Начислен бонус рефереру ₽', diff --git a/app/services/referral_service.py b/app/services/referral_service.py index 78710a1b..418a9b1a 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -4,7 +4,7 @@ from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.referral import create_referral_earning +from app.database.crud.referral import create_referral_earning, get_user_campaign_id from app.database.crud.user import add_user_balance, get_user_by_id from app.database.models import ReferralEarning, User from app.services.notification_delivery_service import ( @@ -74,8 +74,14 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe logger.error('Пользователь не привязан к рефереру', new_user_id=new_user_id, referrer_id=referrer_id) return False + campaign_id = await get_user_campaign_id(db, new_user_id) await create_referral_earning( - db=db, user_id=referrer_id, referral_id=new_user_id, amount_kopeks=0, reason='referral_registration_pending' + db=db, + user_id=referrer_id, + referral_id=new_user_id, + amount_kopeks=0, + reason='referral_registration_pending', + campaign_id=campaign_id, ) try: @@ -132,6 +138,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko logger.error('Реферер не найден', referred_by_id=user.referred_by_id) return False + campaign_id = await get_user_campaign_id(db, user.id) commission_percent = get_effective_referral_commission_percent(referrer) qualifies_for_first_bonus = topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS commission_amount = 0 @@ -161,6 +168,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko referral_id=user.id, amount_kopeks=commission_amount, reason='referral_commission_topup', + campaign_id=campaign_id, ) logger.info( @@ -248,6 +256,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko referral_id=user.id, amount_kopeks=inviter_bonus, reason='referral_first_topup', + campaign_id=campaign_id, ) referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' logger.info('💰 Реферер получил бонус ₽', referrer_id=referrer_id, inviter_bonus=inviter_bonus / 100) @@ -283,6 +292,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko referral_id=user.id, amount_kopeks=commission_amount, reason='referral_commission_topup', + campaign_id=campaign_id, ) referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' @@ -339,6 +349,7 @@ async def process_referral_purchase( db, referrer, commission_amount, f'Комиссия {commission_percent}% с покупки {user.full_name}', bot=bot ) + campaign_id = await get_user_campaign_id(db, user.id) await create_referral_earning( db=db, user_id=referrer.id, @@ -346,6 +357,7 @@ async def process_referral_purchase( amount_kopeks=commission_amount, reason='referral_commission', referral_transaction_id=transaction_id, + campaign_id=campaign_id, ) referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' diff --git a/migrations/alembic/versions/0002_add_campaign_id_to_referral_earnings.py b/migrations/alembic/versions/0002_add_campaign_id_to_referral_earnings.py new file mode 100644 index 00000000..5d99f80c --- /dev/null +++ b/migrations/alembic/versions/0002_add_campaign_id_to_referral_earnings.py @@ -0,0 +1,61 @@ +"""add campaign_id to referral_earnings + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-02-18 + +Adds campaign_id FK to referral_earnings table and backfills +existing rows from advertising_campaign_registrations. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '0002' +down_revision: Union[str, None] = '0001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add column (idempotent check) + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [c['name'] for c in inspector.get_columns('referral_earnings')] + + if 'campaign_id' not in columns: + op.add_column('referral_earnings', sa.Column('campaign_id', sa.Integer(), nullable=True)) + op.create_foreign_key( + 'fk_referral_earnings_campaign_id', + 'referral_earnings', + 'advertising_campaigns', + ['campaign_id'], + ['id'], + ondelete='SET NULL', + ) + op.create_index('ix_referral_earnings_campaign_id', 'referral_earnings', ['campaign_id']) + + # Backfill existing data — pick earliest campaign registration per user + # (matches runtime logic in get_user_campaign_id: ORDER BY created_at ASC LIMIT 1) + op.execute( + """ + UPDATE referral_earnings re + SET campaign_id = sub.campaign_id + FROM ( + SELECT DISTINCT ON (user_id) user_id, campaign_id + FROM advertising_campaign_registrations + ORDER BY user_id, created_at ASC + ) sub + WHERE sub.user_id = re.referral_id + AND re.campaign_id IS NULL + """ + ) + + +def downgrade() -> None: + op.drop_index('ix_referral_earnings_campaign_id', table_name='referral_earnings') + op.drop_constraint('fk_referral_earnings_campaign_id', 'referral_earnings', type_='foreignkey') + op.drop_column('referral_earnings', 'campaign_id')