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
This commit is contained in:
Fringg
2026-02-18 09:12:01 +03:00
parent eb9dba3f47
commit 0c07812ecc
7 changed files with 113 additions and 6 deletions

View File

@@ -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,
)
)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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(
'💰 Начислен бонус рефереру ₽',

View File

@@ -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}'

View File

@@ -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')