mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-05 05:13:21 +00:00
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:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
'💰 Начислен бонус рефереру ₽',
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user