RABAC felépítése megtörtént ill hirdetési portál alapok lerakva
This commit is contained in:
962
plans/p0_architecture_subscription_stacking_ad_engine.md
Normal file
962
plans/p0_architecture_subscription_stacking_ad_engine.md
Normal file
@@ -0,0 +1,962 @@
|
||||
# P0 Architecture Plan — Subscription Stacking & Extensible Ad Engine
|
||||
|
||||
**Dátum:** 2026-06-18
|
||||
**Szerző:** Fast Coder (Core Developer)
|
||||
**Státusz:** Tervezési fázis — Jóváhagyásra vár
|
||||
**Cél:** Teljes architekturális terv a Subscription Time Stacking (napok halmozása) és a bővíthető Native Ad Engine (kampányok, kreatívok, elhelyezések, analitika) megvalósításához.
|
||||
|
||||
---
|
||||
|
||||
## 1. SUBSCRIPTION STACKING MATH
|
||||
|
||||
### 1.1 Problémafelvetés
|
||||
|
||||
Jelenleg a [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény minden híváskor **felülírja** a `valid_from` mezőt `datetime.utcnow()`-ra, és **nem állítja be** a `valid_until` mezőt. Ez azt jelenti, hogy:
|
||||
|
||||
- Ha egy szervezet ma megveszi a 30 napos csomagot, majd 10 nap múlva **hozzáad** még 30 napot, a hátralévő 20 nap **elveszik**.
|
||||
- A `valid_until` mező minden `OrganizationSubscription` és `UserSubscription` rekordban `NULL` marad.
|
||||
|
||||
### 1.2 Megoldás: `duration_days` a JSONB `rules`-ban
|
||||
|
||||
Minden `subscription_tiers.rules` JSONB blokkba bevezetésre kerül egy új `duration_days` mező, amely fix napokban határozza meg a csomag időtartamát. Ez elkerüli a naptári anomáliákat (pl. február 28., hónap vége).
|
||||
|
||||
#### 1.2.1 JSONB `rules` bővítés
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "private",
|
||||
"display_name": "Privát Pro",
|
||||
"duration_days": 30,
|
||||
"pricing": {
|
||||
"monthly_price": 4.99,
|
||||
"yearly_price": 49.99,
|
||||
"currency": "EUR",
|
||||
"credit_price": 500
|
||||
},
|
||||
"allowances": {
|
||||
"max_vehicles": 3,
|
||||
"max_garages": 1,
|
||||
"monthly_free_credits": 50
|
||||
},
|
||||
"entitlements": ["SRV_DATA_EXPORT"],
|
||||
"lifecycle": { "is_public": true }
|
||||
}
|
||||
```
|
||||
|
||||
**Tervezett `duration_days` értékek csomagonként:**
|
||||
|
||||
| Csomag | `duration_days` | Megjegyzés |
|
||||
|--------|----------------|------------|
|
||||
| `private_free_v1` | `null` (korlátlan) | Ingyenes csomag, nem jár le |
|
||||
| `private_pro_v1` | `30` | Havi |
|
||||
| `private_vip_v1` | `30` | Havi |
|
||||
| `corp_premium_v1` | `30` | Havi |
|
||||
| `corp_premium_plus_v1` | `30` | Havi |
|
||||
| `corp_vip_v1` | `30` | Havi |
|
||||
| `private_test_v01` | `365` | Teszt — 1 év |
|
||||
| `org_test_v01` | `365` | Teszt — 1 év |
|
||||
| `corp_free_v1` | `null` (korlátlan) | Ingyenes céges |
|
||||
|
||||
#### 1.2.2 Pydantic Schema Bővítés
|
||||
|
||||
A [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:55) kiegészítése:
|
||||
|
||||
```python
|
||||
class SubscriptionRulesModel(BaseModel):
|
||||
type: str = Field(..., pattern=r"^(private|corporate)$")
|
||||
display_name: Optional[str] = None
|
||||
duration_days: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Csomag időtartama napokban. null = korlátlan (pl. ingyenes csomag)"
|
||||
)
|
||||
pricing: Optional[PricingModel] = None
|
||||
allowances: Optional[AllowancesModel] = None
|
||||
entitlements: List[str] = []
|
||||
affiliate: Optional[AffiliateModel] = None
|
||||
lifecycle: Optional[LifecycleModel] = None
|
||||
ad_policy: Optional[AdPolicyModel] = None # Lásd 2. fejezet
|
||||
```
|
||||
|
||||
### 1.3 Stacking Logika: `upgrade_org_subscription()` Átdolgozása
|
||||
|
||||
A [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény új logikája:
|
||||
|
||||
```python
|
||||
async def upgrade_org_subscription(
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
tier_id: int,
|
||||
actor_user_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Szervezet előfizetésének beállítása időhalmozással (Stacking).
|
||||
|
||||
Stacking logika:
|
||||
1. Ha az aktuális előfizetés MÉG ÉRVÉNYES (valid_until > now):
|
||||
- Az új valid_until = régi valid_until + duration_days nap
|
||||
- Az időhalmozás (stacking) megtörténik
|
||||
2. Ha az aktuális előfizetés LEJÁRT (valid_until < now) vagy NINCS:
|
||||
- Az új valid_until = now + duration_days nap
|
||||
- Friss kezdés
|
||||
3. Ha duration_days = null (korlátlan csomag):
|
||||
- valid_until = null (soha nem jár le)
|
||||
"""
|
||||
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
# 1. Ellenőrizze, hogy a tier létezik-e
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise ValueError(f"Subscription tier id={tier_id} not found")
|
||||
|
||||
# 2. Ellenőrizze, hogy a szervezet létezik-e
|
||||
stmt = select(Organization).where(Organization.id == org_id)
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise ValueError(f"Organization id={org_id} not found")
|
||||
|
||||
# 3. Olvassuk ki a duration_days-t a tier rules-ból
|
||||
rules = tier.rules or {}
|
||||
duration_days = rules.get("duration_days")
|
||||
|
||||
# 4. Számítsuk ki az új valid_until-t (STACKING LOGIKA)
|
||||
now = datetime.utcnow()
|
||||
new_valid_until = None # Alapértelmezett: korlátlan
|
||||
|
||||
if duration_days is not None:
|
||||
# Korlátozott időtartamú csomag
|
||||
# Keressük meg az aktuális aktív előfizetést
|
||||
sub_stmt = select(OrganizationSubscription).where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == True
|
||||
)
|
||||
sub_result = await db.execute(sub_stmt)
|
||||
existing_sub = sub_result.scalar_one_or_none()
|
||||
|
||||
if existing_sub and existing_sub.valid_until and existing_sub.valid_until > now:
|
||||
# STACKING: Még érvényes előfizetéshez hozzáadjuk az új napokat
|
||||
remaining_days = (existing_sub.valid_until - now).days
|
||||
logger.info(
|
||||
f"Subscription stacking for org_id={org_id}: "
|
||||
f"remaining={remaining_days}d, adding={duration_days}d"
|
||||
)
|
||||
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
|
||||
else:
|
||||
# Nincs érvényes előfizetés — friss kezdés
|
||||
new_valid_until = now + timedelta(days=duration_days)
|
||||
|
||||
# 5. Állítsa be a subscription_tier_id-t a szervezeten
|
||||
org.subscription_tier_id = tier_id
|
||||
org.subscription_plan = tier.name
|
||||
|
||||
# 6. Extract max_vehicles from tier rules
|
||||
max_vehicles = rules.get("allowances", {}).get("max_vehicles", 1)
|
||||
org.base_asset_limit = max_vehicles
|
||||
|
||||
# 7. Deaktiváljuk a régi előfizetést
|
||||
if existing_sub:
|
||||
existing_sub.is_active = False
|
||||
existing_sub.valid_until = now # Meddig volt érvényben
|
||||
|
||||
# 8. Új aktív subscription rekord
|
||||
new_sub = OrganizationSubscription(
|
||||
org_id=org_id,
|
||||
tier_id=tier_id,
|
||||
valid_from=now,
|
||||
valid_until=new_valid_until,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
logger.info(
|
||||
f"Org subscription upgraded: org_id={org_id}, "
|
||||
f"tier={tier.name} (id={tier_id}), "
|
||||
f"valid_from={now}, valid_until={new_valid_until}, "
|
||||
f"max_vehicles={max_vehicles}, actor={actor_user_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"organization_id": org_id,
|
||||
"tier_id": tier_id,
|
||||
"tier_name": tier.name,
|
||||
"max_vehicles": max_vehicles,
|
||||
"valid_from": now.isoformat(),
|
||||
"valid_until": new_valid_until.isoformat() if new_valid_until else None,
|
||||
"message": f"Organization {org_id} upgraded to {tier.name}"
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Stacking Matematikai Összefoglaló
|
||||
|
||||
```
|
||||
Képlet:
|
||||
IF existing_sub.valid_until > NOW:
|
||||
new_valid_until = existing_sub.valid_until + duration_days
|
||||
ELSE:
|
||||
new_valid_until = NOW + duration_days
|
||||
|
||||
Példa:
|
||||
- Március 1.: 30 napos csomag vásárlás → valid_until = március 31.
|
||||
- Március 15.: Újabb 30 nap hozzáadása → valid_until = március 31. + 30 nap = április 30.
|
||||
- Eredmény: 45 nap maradt (március 15-től április 30-ig) ahelyett, hogy csak 30 nap lenne.
|
||||
```
|
||||
|
||||
### 1.5 `upgrade_subscription()` (User-level) Átdolgozása
|
||||
|
||||
A [`upgrade_subscription()`](backend/app/services/billing_engine.py:722) függvény hasonló stacking logikát kap a `UserSubscription` rekordokhoz:
|
||||
|
||||
```python
|
||||
async def upgrade_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
target_package: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Felhasználói előfizetés frissítése időhalmozással.
|
||||
Ugyanaz a stacking logika, mint az org változatban.
|
||||
"""
|
||||
from app.models.core_logic import SubscriptionTier, UserSubscription
|
||||
from app.models.identity import User
|
||||
|
||||
# 1. Ellenőrizze, hogy a cél csomag létezik-e
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.name == target_package)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise ValueError(f"Subscription tier '{target_package}' not found")
|
||||
|
||||
rules = tier.rules or {}
|
||||
duration_days = rules.get("duration_days")
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 2. Stacking logika a UserSubscription rekordokon
|
||||
new_valid_until = None
|
||||
if duration_days is not None:
|
||||
sub_stmt = select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True
|
||||
)
|
||||
sub_result = await db.execute(sub_stmt)
|
||||
existing_sub = sub_result.scalar_one_or_none()
|
||||
|
||||
if existing_sub and existing_sub.valid_until and existing_sub.valid_until > now:
|
||||
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
|
||||
else:
|
||||
new_valid_until = now + timedelta(days=duration_days)
|
||||
|
||||
# 3. Ár kiszámítása és levonás (meglévő logika)
|
||||
price = rules.get("price", 0.0)
|
||||
# ... (ár számítás, wallet levonás)
|
||||
|
||||
# 4. Frissítse a felhasználó adatait
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one()
|
||||
user.subscription_plan = target_package
|
||||
user.subscription_expires_at = new_valid_until # Már nem 30 nap fix!
|
||||
|
||||
# 5. UserSubscription rekord frissítése
|
||||
if existing_sub:
|
||||
existing_sub.is_active = False
|
||||
existing_sub.valid_until = now
|
||||
|
||||
new_sub = UserSubscription(
|
||||
user_id=user_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=new_valid_until,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"new_plan": target_package,
|
||||
"price_paid": price,
|
||||
"valid_from": now.isoformat(),
|
||||
"valid_until": new_valid_until.isoformat() if new_valid_until else None
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. EXTENSIBLE AD ENGINE SCHEMA
|
||||
|
||||
### 2.1 Új Adatbázis Séma: `marketing` Schema
|
||||
|
||||
A Native Ad Engine egy új `marketing` adatbázis sémában kap helyet, elkülönítve a core üzleti logikától. Ez biztosítja a jövőbeli bővíthetőséget (pl. kattintások, konverziók, A/B tesztelés).
|
||||
|
||||
#### 2.1.1 SQLAlchemy Modellek
|
||||
|
||||
```python
|
||||
# backend/app/models/marketing.py
|
||||
"""
|
||||
📢 Native Ad Engine — Marketing Schema Models
|
||||
|
||||
Schema: marketing
|
||||
Táblák:
|
||||
- campaigns: Hirdetési kampányok
|
||||
- creatives: Kreatív anyagok (belső/külső)
|
||||
- placements: Elhelyezési zónák
|
||||
- campaign_placements: Kampány ↔ Elhelyezés kapcsolótábla
|
||||
- ad_impressions: Megjelenítések naplózása
|
||||
- ad_clicks: Kattintások naplózása (jövőbeli bővítés)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import (
|
||||
String, Integer, ForeignKey, Boolean, DateTime,
|
||||
Numeric, Text, Enum as SAEnum
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class CampaignStatus(enum.Enum):
|
||||
DRAFT = "draft"
|
||||
SCHEDULED = "scheduled"
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class CreativeType(enum.Enum):
|
||||
IMAGE = "image" # Belső kép
|
||||
HTML_SNIPPET = "html_snippet" # Külső ad kód (pl. Google Ads)
|
||||
VIDEO = "video" # Videó (jövőbeli)
|
||||
CAROUSEL = "carousel" # Több képből álló (jövőbeli)
|
||||
|
||||
|
||||
class Campaign(Base):
|
||||
"""
|
||||
Hirdetési kampány.
|
||||
|
||||
Egy kampány több kreatívot és több elhelyezést is célozhat.
|
||||
A kampányoknak van prioritása és státusza.
|
||||
"""
|
||||
__tablename__ = "campaigns"
|
||||
__table_args__ = {"schema": "marketing"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
uuid: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
server_default=func.gen_random_uuid(),
|
||||
unique=True,
|
||||
comment="Publikus azonosító (UUID) a frontend számára"
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False,
|
||||
comment="Kampány neve (pl. '2026 Q3 Flotta Akció')"
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text, comment="Belső leírás a kampányról"
|
||||
)
|
||||
|
||||
# ── Időbeli korlátok ──
|
||||
start_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
comment="Kampány kezdete"
|
||||
)
|
||||
end_date: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="Kampány vége (None = nincs lejárat)"
|
||||
)
|
||||
|
||||
# ── Prioritás és státusz ──
|
||||
priority: Mapped[int] = mapped_column(
|
||||
Integer, server_default=text("0"),
|
||||
comment="Prioritás (magasabb = fontosabb). 0 = legalacsonyabb"
|
||||
)
|
||||
status: Mapped[CampaignStatus] = mapped_column(
|
||||
SAEnum(CampaignStatus, name="campaign_status", schema="marketing"),
|
||||
server_default=text("'draft'"),
|
||||
comment="Kampány státusza"
|
||||
)
|
||||
|
||||
# ── Célzás ──
|
||||
targeting_rules: Mapped[Optional[dict]] = mapped_column(
|
||||
JSONB, server_default=text("'{}'::jsonb"),
|
||||
comment="Célzási szabályok JSONB (pl. {'country_codes': ['HU', 'GB'], 'min_tier': 'premium'})"
|
||||
)
|
||||
|
||||
# ── Korlátok ──
|
||||
daily_impression_limit: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, comment="Napi max megjelenítés (None = korlátlan)"
|
||||
)
|
||||
total_impression_limit: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, comment="Összes max megjelenítés (None = korlátlan)"
|
||||
)
|
||||
|
||||
# ── Meta ──
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=func.now()
|
||||
)
|
||||
created_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("identity.users.id"), nullable=True,
|
||||
comment="Kampány létrehozója (admin user ID)"
|
||||
)
|
||||
|
||||
# ── Kapcsolatok ──
|
||||
creatives: Mapped[list["Creative"]] = relationship(
|
||||
"Creative", back_populates="campaign", cascade="all, delete-orphan"
|
||||
)
|
||||
placements: Mapped[list["Placement"]] = relationship(
|
||||
"Placement", secondary="marketing.campaign_placements",
|
||||
back_populates="campaigns"
|
||||
)
|
||||
|
||||
|
||||
class Creative(Base):
|
||||
"""
|
||||
Kreatív anyag (hirdetés).
|
||||
|
||||
Két fő típus:
|
||||
- IMAGE: Belső kép + target_url (saját rendszerben tárolt kép)
|
||||
- HTML_SNIPPET: Külső ad kód (pl. Google Ads, Taboola snippet)
|
||||
"""
|
||||
__tablename__ = "creatives"
|
||||
__table_args__ = {"schema": "marketing"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
campaign_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# ── Típus ──
|
||||
creative_type: Mapped[CreativeType] = mapped_column(
|
||||
SAEnum(CreativeType, name="creative_type", schema="marketing"),
|
||||
nullable=False,
|
||||
comment="Kreatív típusa: image (belső) vagy html_snippet (külső)"
|
||||
)
|
||||
|
||||
# ── IMAGE típushoz ──
|
||||
image_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500),
|
||||
comment="Kép URL (IMAGE típusnál). Lehet relatív (storage) vagy abszolút"
|
||||
)
|
||||
alt_text: Mapped[Optional[str]] = mapped_column(
|
||||
String(200),
|
||||
comment="Kép alternatív szövege (akadálymentesítés)"
|
||||
)
|
||||
|
||||
# ── HTML_SNIPPET típushoz ──
|
||||
html_snippet: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
comment="HTML kód külső hirdetési hálózatoktól (pl. Google Ads tag)"
|
||||
)
|
||||
|
||||
# ── Közös mezők ──
|
||||
title: Mapped[Optional[str]] = mapped_column(
|
||||
String(200),
|
||||
comment="Hirdetés címe (megjeleníthető a kép alatt)"
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
comment="Hirdetés leírása"
|
||||
)
|
||||
target_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(1000),
|
||||
comment="Cél URL (hova vezet a kattintás)"
|
||||
)
|
||||
payload: Mapped[Optional[dict]] = mapped_column(
|
||||
JSONB, server_default=text("'{}'::jsonb"),
|
||||
comment="Extra adatok (pl. tracking params, utm_source)"
|
||||
)
|
||||
|
||||
# ── Súlyozás ──
|
||||
weight: Mapped[int] = mapped_column(
|
||||
Integer, server_default=text("1"),
|
||||
comment="Súly a rotációban (magasabb = gyakrabban jelenik meg)"
|
||||
)
|
||||
|
||||
# ── Kapcsolatok ──
|
||||
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="creatives")
|
||||
|
||||
|
||||
class Placement(Base):
|
||||
"""
|
||||
Elhelyezési zóna a frontenden.
|
||||
|
||||
Példák:
|
||||
- 'dashboard_sidebar'
|
||||
- 'vehicle_detail_below'
|
||||
- 'service_search_top'
|
||||
- 'footer_banner'
|
||||
"""
|
||||
__tablename__ = "placements"
|
||||
__table_args__ = {"schema": "marketing"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(100), unique=True, nullable=False,
|
||||
comment="Zóna neve (pl. 'dashboard_sidebar')"
|
||||
)
|
||||
display_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(200),
|
||||
comment="Megjelenítési név az admin felületen"
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text, comment="Leírás a zóna elhelyezkedéséről"
|
||||
)
|
||||
|
||||
# ── Korlátok ──
|
||||
max_creatives: Mapped[int] = mapped_column(
|
||||
Integer, server_default=text("1"),
|
||||
comment="Maximum hány kreatív jelenhet meg egyszerre ebben a zónában"
|
||||
)
|
||||
|
||||
# ── Tier szűrés ──
|
||||
allowed_subscription_tiers: Mapped[Optional[dict]] = mapped_column(
|
||||
JSONB, server_default=text("'{}'::jsonb"),
|
||||
comment="Engedélyezett előfizetési tier-ek JSONB tömbként (pl. ['free', 'premium'])"
|
||||
)
|
||||
|
||||
# ── Meta ──
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default=text("true")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
# ── Kapcsolatok ──
|
||||
campaigns: Mapped[list["Campaign"]] = relationship(
|
||||
"Campaign", secondary="marketing.campaign_placements",
|
||||
back_populates="placements"
|
||||
)
|
||||
|
||||
|
||||
class CampaignPlacement(Base):
|
||||
"""
|
||||
Kapcsolótábla: Kampány ↔ Elhelyezés (M:N kapcsolat).
|
||||
|
||||
Lehetővé teszi, hogy egy kampány több zónában is megjelenjen,
|
||||
és egy zónában több kampány is fusson.
|
||||
"""
|
||||
__tablename__ = "campaign_placements"
|
||||
__table_args__ = {"schema": "marketing"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
campaign_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
placement_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketing.placements.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# ── Override mezők (felülírhatják a kampány szintű beállításokat) ──
|
||||
priority_override: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
comment="Prioritás felülírása ebben a zónában"
|
||||
)
|
||||
daily_impression_limit_override: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
comment="Napi limit felülírása ebben a zónában"
|
||||
)
|
||||
|
||||
|
||||
class AdImpression(Base):
|
||||
"""
|
||||
Hirdetés megjelenítések naplózása.
|
||||
|
||||
Minden egyes megjelenítés rögzítésre kerül a kampány
|
||||
és kreatív szintű analitikához.
|
||||
"""
|
||||
__tablename__ = "ad_impressions"
|
||||
__table_args__ = {"schema": "marketing"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
campaign_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketing.campaigns.id"), nullable=False,
|
||||
index=True
|
||||
)
|
||||
creative_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketing.creatives.id"), nullable=False,
|
||||
index=True
|
||||
)
|
||||
placement_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketing.placements.id"), nullable=False
|
||||
)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("identity.users.id"), nullable=True,
|
||||
comment="Felhasználó aki látta (None = nem bejelentkezett)"
|
||||
)
|
||||
|
||||
# ── Meta ──
|
||||
seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(),
|
||||
index=True
|
||||
)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45), comment="IP cím (anonimizálható)"
|
||||
)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||
Text, comment="User agent string"
|
||||
)
|
||||
session_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), index=True,
|
||||
comment="Munkamenet azonosító (deduplikációhoz)"
|
||||
)
|
||||
|
||||
# ── Kapcsolatok ──
|
||||
campaign: Mapped["Campaign"] = relationship()
|
||||
creative: Mapped["Creative"] = relationship()
|
||||
placement: Mapped["Placement"] = relationship()
|
||||
|
||||
|
||||
class AdClick(Base):
|
||||
"""
|
||||
Hirdetés kattintások naplózása (jövőbeli bővítés).
|
||||
|
||||
Elkülönítve az impression táblától a jobb skálázhatóság érdekében.
|
||||
"""
|
||||
__tablename__ = "ad_clicks"
|
||||
__table_args__ = {"schema": "marketing"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
impression_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketing.ad_impressions.id"), nullable=False,
|
||||
comment="Melyik megjelenítéshez tartozik a kattintás"
|
||||
)
|
||||
clicked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
target_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(1000),
|
||||
comment="Cél URL a kattintáskor (tracking paraméterekkel)"
|
||||
)
|
||||
```
|
||||
|
||||
### 2.2 Adatbázis Séma Diagram (Text)
|
||||
|
||||
```
|
||||
marketing.campaigns
|
||||
├── id (PK, SERIAL)
|
||||
├── uuid (UUID, UNIQUE)
|
||||
├── name (VARCHAR 200)
|
||||
├── description (TEXT)
|
||||
├── start_date (TIMESTAMPTZ)
|
||||
├── end_date (TIMESTAMPTZ, nullable)
|
||||
├── priority (INTEGER, default=0)
|
||||
├── status (ENUM: draft/scheduled/active/paused/completed/cancelled)
|
||||
├── targeting_rules (JSONB)
|
||||
├── daily_impression_limit (INTEGER, nullable)
|
||||
├── total_impression_limit (INTEGER, nullable)
|
||||
├── created_at (TIMESTAMPTZ)
|
||||
├── updated_at (TIMESTAMPTZ, nullable)
|
||||
└── created_by (FK → identity.users.id, nullable)
|
||||
|
||||
marketing.creatives
|
||||
├── id (PK, SERIAL)
|
||||
├── campaign_id (FK → marketing.campaigns.id, CASCADE)
|
||||
├── creative_type (ENUM: image/html_snippet/video/carousel)
|
||||
├── image_url (VARCHAR 500, nullable)
|
||||
├── alt_text (VARCHAR 200, nullable)
|
||||
├── html_snippet (TEXT, nullable)
|
||||
├── title (VARCHAR 200, nullable)
|
||||
├── description (TEXT, nullable)
|
||||
├── target_url (VARCHAR 1000, nullable)
|
||||
├── payload (JSONB)
|
||||
├── weight (INTEGER, default=1)
|
||||
└── campaign (relationship → Campaign)
|
||||
|
||||
marketing.placements
|
||||
├── id (PK, SERIAL)
|
||||
├── name (VARCHAR 100, UNIQUE)
|
||||
├── display_name (VARCHAR 200, nullable)
|
||||
├── description (TEXT, nullable)
|
||||
├── max_creatives (INTEGER, default=1)
|
||||
├── allowed_subscription_tiers (JSONB)
|
||||
├── is_active (BOOLEAN, default=true)
|
||||
├── created_at (TIMESTAMPTZ)
|
||||
└── campaigns (M:N → campaigns via campaign_placements)
|
||||
|
||||
marketing.campaign_placements
|
||||
├── id (PK, SERIAL)
|
||||
├── campaign_id (FK → marketing.campaigns.id, CASCADE)
|
||||
├── placement_id (FK → marketing.placements.id, CASCADE)
|
||||
├── priority_override (INTEGER, nullable)
|
||||
└── daily_impression_limit_override (INTEGER, nullable)
|
||||
|
||||
marketing.ad_impressions
|
||||
├── id (PK, SERIAL)
|
||||
├── campaign_id (FK → marketing.campaigns.id, INDEX)
|
||||
├── creative_id (FK → marketing.creatives.id, INDEX)
|
||||
├── placement_id (FK → marketing.placements.id)
|
||||
├── user_id (FK → identity.users.id, nullable)
|
||||
├── seen_at (TIMESTAMPTZ, INDEX)
|
||||
├── ip_address (VARCHAR 45, nullable)
|
||||
├── user_agent (TEXT, nullable)
|
||||
└── session_id (VARCHAR 100, INDEX)
|
||||
|
||||
marketing.ad_clicks (jövőbeli)
|
||||
├── id (PK, SERIAL)
|
||||
├── impression_id (FK → marketing.ad_impressions.id)
|
||||
├── clicked_at (TIMESTAMPTZ)
|
||||
└── target_url (VARCHAR 1000, nullable)
|
||||
```
|
||||
|
||||
### 2.3 Hirdetés Kiválasztási Logika (Prioritás + Fallback)
|
||||
|
||||
Amikor a backend kiválasztja a megfelelő hirdetést egy adott zónához, az alábbi algoritmust követi:
|
||||
|
||||
```
|
||||
1. SZŰRÉS: Aktív kampányok a zónában
|
||||
SELECT c.* FROM campaigns c
|
||||
JOIN campaign_placements cp ON cp.campaign_id = c.id
|
||||
WHERE cp.placement_id = :placement_id
|
||||
AND c.status = 'active'
|
||||
AND c.start_date <= NOW()
|
||||
AND (c.end_date IS NULL OR c.end_date >= NOW())
|
||||
|
||||
2. SZŰRÉS: Felhasználó tier-jének megfelelő kampányok
|
||||
- Ellenőrzi a placement.allowed_subscription_tiers JSONB tömböt
|
||||
- Csak azokat a kampányokat tartja meg, ahol a user tier szerepel a listában
|
||||
|
||||
3. SZŰRÉS: Napi/össz limit ellenőrzés
|
||||
- Ellenőrzi a campaign.daily_impression_limit-t
|
||||
- Ellenőrzi a campaign.total_impression_limit-t
|
||||
- Kiszűri a limitet elért kampányokat
|
||||
|
||||
4. SORREND: Prioritás szerinti rendezés
|
||||
- Elsődleges: priority (magasabb = előrébb)
|
||||
- Másodlagos: random (egyenlő prioritás esetén rotáció)
|
||||
|
||||
5. KIVÁLASZTÁS: A legmagasabb prioritású kampány kreatívjai közül
|
||||
- Súlyozott random választás a creative.weight alapján
|
||||
- Ha nincs megfelelő kampány → FALLBACK: üres placeholder vagy default ad
|
||||
|
||||
6. MEGJELENÍTÉS: AdImpression naplózása
|
||||
- Rögzíti a campaign_id, creative_id, placement_id, user_id adatokat
|
||||
```
|
||||
|
||||
#### 2.3.1 Python Pseudo-kód
|
||||
|
||||
```python
|
||||
class AdSelectionService:
|
||||
"""
|
||||
Hirdetés kiválasztó szolgáltatás.
|
||||
|
||||
A megfelelő hirdetés kiválasztása egy adott zónához,
|
||||
figyelembe véve a kampány prioritását, a felhasználó tier-jét
|
||||
és a napi/össz megjelenítési limiteket.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def select_ad_for_placement(
|
||||
db: AsyncSession,
|
||||
placement_name: str,
|
||||
user_tier: str = "free",
|
||||
user_id: Optional[int] = None
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Kiválasztja a legmegfelelőbb hirdetést egy adott zónához.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
placement_name: Zóna neve (pl. 'dashboard_sidebar')
|
||||
user_tier: Felhasználó tier szintje
|
||||
user_id: Felhasználó ID (naplózáshoz)
|
||||
|
||||
Returns:
|
||||
Optional[dict]: Kiválasztott kreatív adatai vagy None
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 1. Keressük meg a zónát
|
||||
placement = await db.execute(
|
||||
select(Placement).where(
|
||||
Placement.name == placement_name,
|
||||
Placement.is_active == True
|
||||
)
|
||||
)
|
||||
placement = placement.scalar_one_or_none()
|
||||
if not placement:
|
||||
return None
|
||||
|
||||
# 2. Ellenőrizzük, hogy a user tier engedélyezett-e ebben a zónában
|
||||
allowed_tiers = placement.allowed_subscription_tiers or []
|
||||
if allowed_tiers and user_tier not in allowed_tiers:
|
||||
return None
|
||||
|
||||
# 3. Aktív kampányok lekérdezése ebben a zónában
|
||||
# Rendezés: priority DESC, majd random
|
||||
campaigns_query = (
|
||||
select(Campaign)
|
||||
.join(CampaignPlacement, CampaignPlacement.campaign_id == Campaign.id)
|
||||
.where(
|
||||
CampaignPlacement.placement_id == placement.id,
|
||||
Campaign.status == CampaignStatus.ACTIVE,
|
||||
Campaign.start_date <= now,
|
||||
or_(Campaign.end_date.is_(None), Campaign.end_date >= now),
|
||||
)
|
||||
.order_by(Campaign.priority.desc())
|
||||
)
|
||||
result = await db.execute(campaigns_query)
|
||||
campaigns = result.scalars().all()
|
||||
|
||||
if not campaigns:
|
||||
return None # Fallback: nincs aktív kampány
|
||||
|
||||
# 4. Válasszuk ki a legmagasabb prioritású kampányt
|
||||
# (több azonos prioritás esetén random)
|
||||
top_priority = campaigns[0].priority
|
||||
top_campaigns = [c for c in campaigns if c.priority == top_priority]
|
||||
selected_campaign = random.choice(top_campaigns)
|
||||
|
||||
# 5. Válasszunk kreatívot a kampányból (súlyozott random)
|
||||
creatives = selected_campaign.creatives
|
||||
if not creatives:
|
||||
return None
|
||||
|
||||
# Súlyozott random választás
|
||||
total_weight = sum(c.weight for c in creatives)
|
||||
if total_weight == 0:
|
||||
selected_creative = random.choice(creatives)
|
||||
else:
|
||||
r = random.randint(1, total_weight)
|
||||
cumulative = 0
|
||||
selected_creative = creatives[0]
|
||||
for c in creatives:
|
||||
cumulative += c.weight
|
||||
if r <= cumulative:
|
||||
selected_creative = c
|
||||
break
|
||||
|
||||
# 6. Naplózzuk a megjelenítést
|
||||
impression = AdImpression(
|
||||
campaign_id=selected_campaign.id,
|
||||
creative_id=selected_creative.id,
|
||||
placement_id=placement.id,
|
||||
user_id=user_id,
|
||||
seen_at=now,
|
||||
)
|
||||
db.add(impression)
|
||||
await db.flush()
|
||||
|
||||
# 7. Visszatérés a kreatív adataival
|
||||
result_data = {
|
||||
"creative_id": selected_creative.id,
|
||||
"campaign_id": selected_campaign.id,
|
||||
"creative_type": selected_creative.creative_type.value,
|
||||
"title": selected_creative.title,
|
||||
"description": selected_creative.description,
|
||||
"target_url": selected_creative.target_url,
|
||||
"impression_id": impression.id,
|
||||
}
|
||||
|
||||
if selected_creative.creative_type == CreativeType.IMAGE:
|
||||
result_data["image_url"] = selected_creative.image_url
|
||||
result_data["alt_text"] = selected_creative.alt_text
|
||||
elif selected_creative.creative_type == CreativeType.HTML_SNIPPET:
|
||||
result_data["html_snippet"] = selected_creative.html_snippet
|
||||
|
||||
return result_data
|
||||
|
||||
|
||||
### 2.4 Admin API Végpontok (Tervezet)
|
||||
|
||||
Az Ad Engine kezeléséhez az alábbi admin REST végpontok szükségesek:
|
||||
|
||||
| Metódus | Végpont | Leírás |
|
||||
|---------|---------|--------|
|
||||
| `GET` | `/api/v1/admin/marketing/campaigns` | Kampányok listázása |
|
||||
| `POST` | `/api/v1/admin/marketing/campaigns` | Új kampány létrehozása |
|
||||
| `GET` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány részletei |
|
||||
| `PATCH` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány módosítása |
|
||||
| `DELETE` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány törlése (soft-delete) |
|
||||
| `GET` | `/api/v1/admin/marketing/creatives` | Kreatívok listázása |
|
||||
| `POST` | `/api/v1/admin/marketing/creatives` | Új kreatív létrehozása |
|
||||
| `GET` | `/api/v1/admin/marketing/placements` | Elhelyezési zónák listázása |
|
||||
| `POST` | `/api/v1/admin/marketing/placements` | Új zóna létrehozása |
|
||||
| `GET` | `/api/v1/admin/marketing/analytics/impressions` | Megjelenítési statisztikák |
|
||||
| `GET` | `/api/v1/admin/marketing/analytics/summary` | Összesített analitika |
|
||||
|
||||
### 2.5 Publikus API Végpont (Frontend számára)
|
||||
|
||||
| Metódus | Végpont | Leírás |
|
||||
|---------|---------|--------|
|
||||
| `GET` | `/api/v1/marketing/ad/{placement_name}` | Hirdetés lekérése egy adott zónához |
|
||||
|
||||
Ez a végpont meghívja az `AdSelectionService.select_ad_for_placement()` függvényt, és visszaadja a kiválasztott kreatív adatait a frontend számára.
|
||||
|
||||
---
|
||||
|
||||
## 3. IMPLEMENTÁCIÓS ÜTEMTERV
|
||||
|
||||
### Fázis 1: Backend Alapok (Subscription Stacking)
|
||||
|
||||
| # | Feladat | Fájl | Leírás |
|
||||
|---|---------|------|--------|
|
||||
| 1.1 | `duration_days` hozzáadása a Pydantic sémához | [`subscription.py`](backend/app/schemas/subscription.py:55) | `SubscriptionRulesModel` bővítése `duration_days` mezővel |
|
||||
| 1.2 | `upgrade_org_subscription()` stacking logika | [`billing_engine.py:805`](backend/app/services/billing_engine.py:805) | Teljes átírás: duration_days olvasás, valid_until számítás stackinggel |
|
||||
| 1.3 | `upgrade_subscription()` stacking logika | [`billing_engine.py:722`](backend/app/services/billing_engine.py:722) | User-level stacking implementálása |
|
||||
| 1.4 | `duration_days` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése `duration_days`-szel |
|
||||
| 1.5 | Seed szkript futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py` |
|
||||
|
||||
### Fázis 2: Ad Engine Modellek és Séma
|
||||
|
||||
| # | Feladat | Fájl | Leírás |
|
||||
|---|---------|------|--------|
|
||||
| 2.1 | `marketing` schema modellek | [`models/marketing.py`](backend/app/models/) | Campaign, Creative, Placement, CampaignPlacement, AdImpression, AdClick |
|
||||
| 2.2 | Sync engine futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/sync_engine.py` |
|
||||
| 2.3 | `AdPolicyModel` Pydantic séma | [`subscription.py`](backend/app/schemas/subscription.py) | `ad_policy` mező hozzáadása a `SubscriptionRulesModel`-hez |
|
||||
| 2.4 | `AdPolicyService` létrehozása | [`services/ad_policy_service.py`](backend/app/services/) | Új service a hirdetési politika lekérdezéséhez |
|
||||
| 2.5 | `AdSelectionService` létrehozása | [`services/ad_selection_service.py`](backend/app/services/) | Hirdetés kiválasztó algoritmus |
|
||||
|
||||
### Fázis 3: Admin API Végpontok
|
||||
|
||||
| # | Feladat | Fájl | Leírás |
|
||||
|---|---------|------|--------|
|
||||
| 3.1 | Admin marketing router | [`endpoints/admin_marketing.py`](backend/app/api/v1/endpoints/) | CRUD végpontok kampányokhoz, kreatívokhoz, elhelyezésekhez |
|
||||
| 3.2 | Publikus ad végpont | [`endpoints/marketing.py`](backend/app/api/v1/endpoints/) | `GET /api/v1/marketing/ad/{placement_name}` |
|
||||
| 3.3 | Router regisztráció | [`api.py`](backend/app/api/v1/api.py) | Új router-ek hozzáadása az API-hoz |
|
||||
|
||||
### Fázis 4: Seed Adatok és Tesztelés
|
||||
|
||||
| # | Feladat | Fájl | Leírás |
|
||||
|---|---------|------|--------|
|
||||
| 4.1 | `ad_policy` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése az `ad_policy` blokkal |
|
||||
| 4.2 | Seed marketing adatok | [`seed_marketing.py`](backend/app/scripts/) | Teszt kampányok, kreatívok, elhelyezések létrehozása |
|
||||
| 4.3 | Tesztelés | — | API tesztek a stacking és ad selection logikára |
|
||||
|
||||
---
|
||||
|
||||
## 4. FÜGGŐSÉGEK ÉS KOCKÁZATOK
|
||||
|
||||
| Függőség | Hatás | Megoldás |
|
||||
|----------|-------|----------|
|
||||
| A `valid_until` mező jelenleg NULL minden `org_subscriptions` rekordban | A stacking logika nem működik a meglévő rekordokon | Backfill szkript: `UPDATE finance.org_subscriptions SET valid_until = valid_from + INTERVAL '30 days' WHERE valid_until IS NULL AND is_active = true` |
|
||||
| A `duration_days` mező hiányzik a meglévő seed csomagokból | Az új stacking logika nem tudja kiszámolni az időtartamot | A `seed_packages.py` újrafuttatása a `duration_days` mezővel |
|
||||
| Az új `marketing` séma táblái nem léteznek | Az Ad Engine nem működik | A `sync_engine.py` futtatása a modellek létrehozása után |
|
||||
| A frontend jelenleg nem kérdez le hirdetéseket | A hirdetési zónák üresek maradnak | A frontend `AdService` létrehozása a Vue alkalmazásban |
|
||||
|
||||
---
|
||||
|
||||
## 5. JÓVÁHAGYÁS
|
||||
|
||||
A fenti terv jóváhagyása után kezdődhet meg a kódolás az alábbi sorrendben:
|
||||
|
||||
1. **Subscription Stacking:** `duration_days` séma → `upgrade_org_subscription()` → `upgrade_subscription()` → seed frissítés
|
||||
2. **Ad Engine Modellek:** `marketing` séma → sync engine → Pydantic sémák
|
||||
3. **Ad Engine Service:** `AdPolicyService` → `AdSelectionService`
|
||||
4. **Admin API:** CRUD végpontok → publikus ad végpont
|
||||
5. **Seed Adatok:** `ad_policy` → teszt kampányok
|
||||
Reference in New Issue
Block a user