279 lines
13 KiB
Python
Executable File
279 lines
13 KiB
Python
Executable File
# /opt/docker/dev/service_finder/backend/app/services/gamification_service.py
|
|
import logging
|
|
import math
|
|
from copy import deepcopy
|
|
from decimal import Decimal
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc
|
|
from app.models import UserStats, PointsLedger, UserBadge, Badge
|
|
from app.models.gamification.gamification import PointRule
|
|
from app.models.identity import User, Wallet
|
|
from app.models import FinancialLedger
|
|
from app.services.config_service import config # 2.0 Központi konfigurátor
|
|
|
|
logger = logging.getLogger("Gamification-Service-2.0")
|
|
|
|
|
|
def _deep_merge_config(base: dict, overlay: dict) -> dict:
|
|
"""
|
|
Rekurzívan egyesít két dictionary-t.
|
|
Az overlay kulcsai felülírják a base kulcsait, de a base-ben lévő
|
|
hiányzó kulcsok megmaradnak az overlay-ből.
|
|
|
|
Ez biztosítja, hogy a GAMIFICATION_MASTER_CONFIG minden szükséges
|
|
kulcsot tartalmazzon, még akkor is, ha az adatbázisban lévő konfiguráció
|
|
hiányos (pl. régebbi verzióból származik).
|
|
"""
|
|
result = deepcopy(base)
|
|
for key, value in overlay.items():
|
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
result[key] = _deep_merge_config(result[key], value)
|
|
elif key not in result:
|
|
result[key] = deepcopy(value)
|
|
return result
|
|
|
|
class GamificationService:
|
|
"""
|
|
Gamification Service 2.0 - A 'Jövevény' lelke.
|
|
Felelős a pontozásért, szintekért, büntetésekért és a jutalom-kreditekért.
|
|
|
|
Refaktor: A process_activity() most már a point_rules táblából is olvas,
|
|
így az admin által definiált pontszabályok elsőbbséget élveznek a master configgal szemben.
|
|
"""
|
|
|
|
@staticmethod
|
|
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True, action_key: str | None = None):
|
|
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
|
|
|
|
Args:
|
|
commit: If True (default), commits the transaction internally.
|
|
Set to False when called from within a larger transaction
|
|
(e.g., from AuthService.complete_kyc).
|
|
action_key: Opcionális. Ha meg van adva, a point_rules táblából
|
|
olvassa a pontértékeket a master config helyett.
|
|
"""
|
|
service = GamificationService()
|
|
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit, action_key=action_key)
|
|
|
|
async def _get_point_rule(self, db: AsyncSession, action_key: str) -> dict | None:
|
|
"""Lekér egy pontszabályt a point_rules táblából action_key alapján.
|
|
|
|
Returns:
|
|
dict with 'points', 'description', 'rule_id' or None if not found/inactive.
|
|
"""
|
|
stmt = select(PointRule).where(
|
|
PointRule.action_key == action_key,
|
|
PointRule.is_active == True
|
|
)
|
|
result = await db.execute(stmt)
|
|
rule = result.scalar_one_or_none()
|
|
if rule:
|
|
return {"points": rule.points, "description": rule.description, "rule_id": rule.id}
|
|
return None
|
|
|
|
async def process_activity(
|
|
self,
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
xp_amount: int,
|
|
social_amount: int,
|
|
reason: str,
|
|
is_penalty: bool = False,
|
|
commit: bool = True,
|
|
action_key: str | None = None,
|
|
source_type: str | None = None,
|
|
source_id: int | None = None,
|
|
):
|
|
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés.
|
|
|
|
Refaktor 2.0:
|
|
- Ha action_key meg van adva, a point_rules táblából olvassa a pontértékeket.
|
|
- A master config csak fallback, ha nincs a point_rules-ben.
|
|
- Támogatja a source_type és source_id mezőket a PointsLedger naplózáshoz.
|
|
|
|
Args:
|
|
commit: If True (default), commits the transaction internally.
|
|
Set to False when called from within a larger transaction.
|
|
action_key: Opcionális. Ha meg van adva, a point_rules táblából
|
|
olvassa a pontértékeket a master config helyett.
|
|
source_type: Opcionális. A pontok forrásának típusa (pl. 'service_submission').
|
|
source_id: Opcionális. A pontok forrásának ID-ja.
|
|
"""
|
|
try:
|
|
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
|
|
# Minden paraméter az admin felületről módosítható JSON-ként
|
|
_DEFAULT_GAMIFICATION_CONFIG = {
|
|
"xp_logic": {"base_xp": 500, "exponent": 1.5},
|
|
"penalty_logic": {
|
|
"recovery_rate": 0.5,
|
|
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
|
|
"multipliers": {"L0": 1.0, "L1": 0.5, "L2": 0.1, "L3": 0.0}
|
|
},
|
|
"conversion_logic": {"social_to_credit_rate": 100},
|
|
"level_rewards": {"credits_per_10_levels": 50}
|
|
}
|
|
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default=_DEFAULT_GAMIFICATION_CONFIG)
|
|
|
|
# 🔐 HIÁNYZÓ KULCSOK VÉDELME (Deep Merge)
|
|
# Ha a GAMIFICATION_MASTER_CONFIG már létezik az adatbázisban, de hiányoznak
|
|
# belőle kulcsok (pl. penalty_logic egy régebbi verzióból), a default értékek
|
|
# automatikusan kiegészítik a hiányzó részeket.
|
|
# Ez megakadályozza a KeyError kivételeket a cfg["penalty_logic"] hívásoknál.
|
|
if isinstance(cfg, dict):
|
|
cfg = _deep_merge_config(cfg, _DEFAULT_GAMIFICATION_CONFIG)
|
|
|
|
# 1/b. POINT RULES TÁBLA LEKÉRÉSE (ha action_key meg van adva)
|
|
# A point_rules tábla elsőbbséget élvez a master config-gal szemben!
|
|
point_rule_id = None
|
|
points_at_time = None
|
|
if action_key:
|
|
rule = await self._get_point_rule(db, action_key)
|
|
if rule:
|
|
# A point_rules-ból jövő pontok felülírják a paraméterként kapott értékeket
|
|
xp_amount = rule["points"]
|
|
point_rule_id = rule.get("rule_id")
|
|
points_at_time = rule["points"]
|
|
if rule.get("description"):
|
|
reason = f"{action_key}: {rule['description']}"
|
|
logger.debug(f"Point rule applied: {action_key} -> {xp_amount} XP")
|
|
else:
|
|
logger.debug(f"No active point rule found for action_key='{action_key}', using fallback values.")
|
|
|
|
# 2. FELHASZNÁLÓ ÉS STATISZTIKA ELLENŐRZÉSE
|
|
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
|
|
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
|
|
|
|
if not stats:
|
|
stats = UserStats(user_id=user_id, total_xp=0, current_level=1, penalty_points=0)
|
|
db.add(stats)
|
|
await db.flush()
|
|
|
|
# 3. BÜNTETŐ LOGIKA (Ha negatív esemény történik)
|
|
if is_penalty:
|
|
return await self._apply_penalty(db, stats, xp_amount, reason, cfg, source_type=source_type, source_id=source_id)
|
|
|
|
# 4. SZORZÓK ALKALMAZÁSA (Büntetés alatt állók 'bírsága')
|
|
multiplier = await self._calculate_multiplier(stats, cfg)
|
|
if multiplier <= 0:
|
|
logger.warning(f"User {user_id} pontszerzése blokkolva a büntetések miatt.")
|
|
return stats
|
|
|
|
# 5. XP SZÁMÍTÁS ÉS SZINTLÉPÉS
|
|
final_xp = int(xp_amount * multiplier)
|
|
if final_xp > 0:
|
|
stats.total_xp += final_xp
|
|
# Büntetés ledolgozás (Recovery)
|
|
if stats.penalty_points > 0:
|
|
recovery = int(final_xp * cfg["penalty_logic"]["recovery_rate"])
|
|
stats.penalty_points = max(0, stats.penalty_points - recovery)
|
|
|
|
# Új szint számítás hatványfüggvénnyel:
|
|
# $Level = \sqrt[exponent]{\frac{XP}{Base}} + 1$
|
|
xp_cfg = cfg["xp_logic"]
|
|
new_level = int((stats.total_xp / xp_cfg["base_xp"]) ** (1 / xp_cfg["exponent"])) + 1
|
|
|
|
if new_level > stats.current_level:
|
|
await self._handle_level_up(db, user_id, stats.current_level, new_level, cfg)
|
|
stats.current_level = new_level
|
|
|
|
# 6. SOCIAL PONT ÉS KREDIT KONVERZIÓ
|
|
final_social = int(social_amount * multiplier)
|
|
if final_social > 0:
|
|
stats.social_points += final_social
|
|
rate = cfg["conversion_logic"]["social_to_credit_rate"]
|
|
|
|
if stats.social_points >= rate:
|
|
credits_to_add = stats.social_points // rate
|
|
stats.social_points %= rate # A maradék pont megmarad
|
|
await self._add_earned_credits(db, user_id, credits_to_add, "SOCIAL_ACTIVITY_CONVERSION")
|
|
|
|
# 7. NAPLÓZÁS (kibővítve xp, source_type, source_id, points_snapshot mezőkkel)
|
|
# A points_snapshot JSONB tárolja a kiosztáskor érvényes point_rules adatokat,
|
|
# hogy a pontértékek megőrződjenek a ledger-ben, még akkor is, ha a
|
|
# point_rules táblában később módosítják a pontértékeket.
|
|
snapshot = None
|
|
if action_key and point_rule_id and points_at_time is not None:
|
|
snapshot = {
|
|
"action_key": action_key,
|
|
"point_rule_id": point_rule_id,
|
|
"points_at_time": points_at_time,
|
|
"multiplier": multiplier,
|
|
}
|
|
db.add(PointsLedger(
|
|
user_id=user_id,
|
|
points=final_xp,
|
|
xp=final_xp,
|
|
reason=reason,
|
|
source_type=source_type,
|
|
source_id=source_id,
|
|
points_snapshot=snapshot,
|
|
))
|
|
|
|
# Only commit if caller wants us to manage the transaction
|
|
if commit:
|
|
await db.commit()
|
|
await db.refresh(stats)
|
|
return stats
|
|
|
|
except Exception as e:
|
|
if commit:
|
|
await db.rollback()
|
|
logger.error(f"Gamification Error for user {user_id}: {e}")
|
|
raise e
|
|
|
|
# --- PRIVÁT SEGÉDFÜGGVÉNYEK ---
|
|
|
|
async def _apply_penalty(self, db: AsyncSession, stats: UserStats, amount: int, reason: str, cfg: dict,
|
|
source_type: str | None = None, source_id: int | None = None):
|
|
"""Büntetőpontok hozzáadása és korlátozási szintek emelése.
|
|
|
|
Refaktor: Most már támogatja a source_type és source_id mezőket a naplózáshoz.
|
|
"""
|
|
stats.penalty_points += amount
|
|
th = cfg["penalty_logic"]["thresholds"]
|
|
|
|
if stats.penalty_points >= th["level_3"]: stats.restriction_level = 3
|
|
elif stats.penalty_points >= th["level_2"]: stats.restriction_level = 2
|
|
elif stats.penalty_points >= th["level_1"]: stats.restriction_level = 1
|
|
|
|
db.add(PointsLedger(
|
|
user_id=stats.user_id,
|
|
points=0,
|
|
penalty_change=amount,
|
|
reason=f"🔴 PENALTY: {reason}",
|
|
source_type=source_type,
|
|
source_id=source_id,
|
|
))
|
|
await db.commit()
|
|
return stats
|
|
|
|
async def _calculate_multiplier(self, stats: UserStats, cfg: dict) -> float:
|
|
"""Kiszámolja a szorzót a jelenlegi büntetési szint alapján."""
|
|
m = cfg["penalty_logic"]["multipliers"]
|
|
if stats.restriction_level == 3: return m["L3"]
|
|
if stats.restriction_level == 2: return m["L2"]
|
|
if stats.restriction_level == 1: return m["L1"]
|
|
return m["L0"]
|
|
|
|
async def _handle_level_up(self, db: AsyncSession, user_id: int, old_lvl: int, new_lvl: int, cfg: dict):
|
|
"""Szintlépési jutalmak (pl. minden 10. szintnél kredit)."""
|
|
logger.info(f"✨ Level Up: User {user_id} ({old_lvl} -> {new_lvl})")
|
|
if new_lvl % 10 == 0:
|
|
reward = cfg["level_rewards"]["credits_per_10_levels"]
|
|
await self._add_earned_credits(db, user_id, reward, f"LEVEL_{new_lvl}_REWARD")
|
|
|
|
async def _add_earned_credits(self, db: AsyncSession, user_id: int, amount: int, reason: str):
|
|
"""Kredit jóváírása a Wallet-ben és a pénzügyi naplóban."""
|
|
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
|
|
wallet = (await db.execute(wallet_stmt)).scalar_one_or_none()
|
|
if wallet:
|
|
wallet.earned_credits += Decimal(str(amount))
|
|
db.add(FinancialLedger(
|
|
user_id=user_id,
|
|
amount=float(amount),
|
|
transaction_type="GAMIFICATION_CREDIT",
|
|
details={"reason": reason}
|
|
))
|
|
|
|
gamification_service = GamificationService()
|