Files
service-finder/backend/app/services/gamification_service.py
2026-07-01 02:27:38 +00:00

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