311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""
|
|
Aszinkron ütemező (APScheduler) a napi karbantartási feladatokhoz.
|
|
|
|
Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul,
|
|
és leálláskor megáll.
|
|
|
|
Feladatok:
|
|
1. daily_financial_maintenance (00:15 UTC) — Voucher/Withdrawal/User downgrade
|
|
2. subscription_monitor (01:00 UTC) — UserSubscription & OrganizationSubscription lejárat
|
|
3. inactivity_monitor (02:00 UTC) — 180 napos inaktivitás detektálás
|
|
|
|
Biztonsági Jitter: Minden napi feladat jitter=900 (15 perc) paraméterrel rendelkezik,
|
|
így véletlenszerűen eltolódnak a megadott időponthoz képest.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, time, timedelta
|
|
from typing import Optional
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from apscheduler.jobstores.memory import MemoryJobStore
|
|
|
|
from app.database import AsyncSessionLocal
|
|
from app.services.billing_engine import SmartDeduction
|
|
from app.models.marketplace.payment import WithdrawalRequest, WithdrawalRequestStatus
|
|
from app.models.identity import User
|
|
from app.models import ProcessLog, WalletType, FinancialLedger
|
|
from sqlalchemy import select, update, and_
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Globális scheduler példány
|
|
_scheduler: Optional[AsyncIOScheduler] = None
|
|
|
|
|
|
def get_scheduler() -> AsyncIOScheduler:
|
|
"""Visszaadja a globális scheduler példányt (lazy initialization)."""
|
|
global _scheduler
|
|
if _scheduler is None:
|
|
jobstores = {
|
|
'default': MemoryJobStore()
|
|
}
|
|
_scheduler = AsyncIOScheduler(
|
|
jobstores=jobstores,
|
|
timezone="UTC",
|
|
job_defaults={
|
|
'coalesce': True,
|
|
'max_instances': 1,
|
|
'misfire_grace_time': 3600 # 1 óra
|
|
}
|
|
)
|
|
return _scheduler
|
|
|
|
|
|
async def daily_financial_maintenance() -> None:
|
|
"""
|
|
Napi pénzügyi karbantartási feladatok.
|
|
|
|
A. Voucher lejárat kezelése
|
|
B. Withdrawal Request lejárat (14 nap) és automatikus elutasítás
|
|
C. Soft Downgrade (lejárt előfizetések)
|
|
D. Naplózás ProcessLog-ba
|
|
"""
|
|
logger.info("Daily financial maintenance started")
|
|
stats = {
|
|
"vouchers_expired": 0,
|
|
"withdrawals_rejected": 0,
|
|
"users_downgraded": 0,
|
|
"errors": []
|
|
}
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
# A. Voucher lejárat kezelése
|
|
try:
|
|
voucher_count = await SmartDeduction.process_voucher_expiration(db)
|
|
stats["vouchers_expired"] = voucher_count
|
|
logger.info(f"Expired {voucher_count} vouchers")
|
|
except Exception as e:
|
|
stats["errors"].append(f"Voucher expiration error: {str(e)}")
|
|
logger.error(f"Voucher expiration error: {e}", exc_info=True)
|
|
|
|
# B. Withdrawal Request lejárat (14 nap)
|
|
try:
|
|
# Keresd meg a PENDING státuszú, 14 napnál régebbi kéréseket
|
|
fourteen_days_ago = datetime.utcnow() - timedelta(days=14)
|
|
stmt = select(WithdrawalRequest).where(
|
|
and_(
|
|
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING,
|
|
WithdrawalRequest.created_at < fourteen_days_ago,
|
|
WithdrawalRequest.is_deleted == False
|
|
)
|
|
).options(selectinload(WithdrawalRequest.user))
|
|
|
|
result = await db.execute(stmt)
|
|
expired_requests = result.scalars().all()
|
|
|
|
for req in expired_requests:
|
|
# Állítsd REJECTED-re
|
|
req.status = WithdrawalRequestStatus.REJECTED
|
|
req.reason = "Automatikus elutasítás: 14 napig hiányzó bizonylat"
|
|
|
|
# Refund: pénz vissza a user Earned zsebébe
|
|
# Ehhez létrehozunk egy FinancialLedger bejegyzést (refund)
|
|
refund_transaction = FinancialLedger(
|
|
transaction_id=uuid.uuid4(),
|
|
user_id=req.user_id,
|
|
wallet_type=WalletType.EARNED,
|
|
amount=req.amount,
|
|
currency=req.currency,
|
|
transaction_type="REFUND",
|
|
description=f"Refund for expired withdrawal request #{req.id}",
|
|
metadata={"withdrawal_request_id": req.id}
|
|
)
|
|
db.add(refund_transaction)
|
|
req.refund_transaction_id = refund_transaction.transaction_id
|
|
|
|
stats["withdrawals_rejected"] += 1
|
|
|
|
await db.commit()
|
|
logger.info(f"Rejected {len(expired_requests)} expired withdrawal requests")
|
|
except Exception as e:
|
|
await db.rollback()
|
|
stats["errors"].append(f"Withdrawal expiration error: {str(e)}")
|
|
logger.error(f"Withdrawal expiration error: {e}", exc_info=True)
|
|
|
|
# C. Soft Downgrade (lejárt előfizetések)
|
|
try:
|
|
# Keresd meg a lejárt subscription_expires_at idejű usereket
|
|
stmt = select(User).where(
|
|
and_(
|
|
User.subscription_expires_at < datetime.utcnow(),
|
|
User.subscription_plan != 'FREE',
|
|
User.is_deleted == False
|
|
)
|
|
)
|
|
result = await db.execute(stmt)
|
|
expired_users = result.scalars().all()
|
|
|
|
for user in expired_users:
|
|
# Állítsd a subscription_plan-t 'FREE'-re, role-t 'user'-re
|
|
user.subscription_plan = 'FREE'
|
|
user.role = 'user'
|
|
# Opcionálisan: állítsd be a felfüggesztett státuszt a kapcsolódó entitásokon
|
|
# (pl. Organization.is_active = False) - ez egy külön logika lehet
|
|
stats["users_downgraded"] += 1
|
|
|
|
await db.commit()
|
|
logger.info(f"Downgraded {len(expired_users)} users to FREE plan")
|
|
except Exception as e:
|
|
await db.rollback()
|
|
stats["errors"].append(f"Soft downgrade error: {str(e)}")
|
|
logger.error(f"Soft downgrade error: {e}", exc_info=True)
|
|
|
|
# D. Naplózás ProcessLog-ba (JAVÍTOTT RÉSZ)
|
|
process_log = ProcessLog(
|
|
process_name="Daily-Financial-Maintenance",
|
|
items_processed=stats["vouchers_expired"] + stats["withdrawals_rejected"] + stats["users_downgraded"],
|
|
items_failed=len(stats["errors"]),
|
|
end_time=datetime.utcnow(),
|
|
details={
|
|
"status": "COMPLETED" if not stats["errors"] else "PARTIAL",
|
|
**stats
|
|
}
|
|
)
|
|
db.add(process_log)
|
|
await db.commit()
|
|
|
|
logger.info(f"Daily financial maintenance completed: {stats}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Daily financial maintenance failed: {e}", exc_info=True)
|
|
# Hiba esetén is naplózzuk a modellnek megfelelő mezőkkel
|
|
process_log = ProcessLog(
|
|
process_name="Daily-Financial-Maintenance",
|
|
items_processed=0,
|
|
items_failed=1,
|
|
end_time=datetime.utcnow(),
|
|
details={
|
|
"status": "FAILED",
|
|
"error": str(e),
|
|
**stats
|
|
}
|
|
)
|
|
db.add(process_log)
|
|
await db.commit()
|
|
|
|
|
|
def setup_scheduler() -> None:
|
|
"""Beállítja a scheduler-t a napi feladatokkal."""
|
|
scheduler = get_scheduler()
|
|
|
|
# ── 1. Napi pénzügyi karbantartás 00:15-kor ──
|
|
scheduler.add_job(
|
|
daily_financial_maintenance,
|
|
trigger=CronTrigger(hour=0, minute=15, jitter=900),
|
|
id="daily_financial_maintenance",
|
|
name="Daily Financial Maintenance",
|
|
replace_existing=True
|
|
)
|
|
|
|
# ── 2. Subscription Monitor 01:00-kor ──
|
|
# Kezeli a lejárt UserSubscription és OrganizationSubscription rekordokat.
|
|
from app.workers.system.subscription_monitor_worker import run_full_monitor as run_sub_monitor
|
|
|
|
async def subscription_monitor_job():
|
|
logger.info("Scheduler: Starting subscription monitor job...")
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
stats = await run_sub_monitor()
|
|
total = (
|
|
stats["user_subscriptions"]["processed"]
|
|
+ stats["org_subscriptions"]["processed"]
|
|
)
|
|
# Naplózás ProcessLog-ba
|
|
process_log = ProcessLog(
|
|
process_name="Subscription-Monitor",
|
|
items_processed=total,
|
|
items_failed=len(stats["user_subscriptions"]["errors"]) + len(stats["org_subscriptions"]["errors"]),
|
|
end_time=datetime.utcnow(),
|
|
details={
|
|
"status": "COMPLETED",
|
|
"user_processed": stats["user_subscriptions"]["processed"],
|
|
"org_processed": stats["org_subscriptions"]["processed"],
|
|
"user_downgraded": len(stats["user_subscriptions"]["downgraded"]),
|
|
"org_downgraded": len(stats["org_subscriptions"]["downgraded"]),
|
|
}
|
|
)
|
|
db.add(process_log)
|
|
await db.commit()
|
|
logger.info(f"Scheduler: Subscription monitor completed ({total} processed)")
|
|
except Exception as e:
|
|
logger.error(f"Scheduler: Subscription monitor failed: {e}", exc_info=True)
|
|
await db.rollback()
|
|
|
|
scheduler.add_job(
|
|
subscription_monitor_job,
|
|
trigger=CronTrigger(hour=1, minute=0, jitter=900),
|
|
id="subscription_monitor",
|
|
name="Subscription Monitor",
|
|
replace_existing=True
|
|
)
|
|
|
|
# ── 3. Inactivity Monitor 02:00-kor ──
|
|
# Detektálja a 180+ napja inaktív usereket.
|
|
from app.workers.system.inactivity_monitor_worker import run_full_monitor as run_inactivity_monitor
|
|
|
|
async def inactivity_monitor_job():
|
|
logger.info("Scheduler: Starting inactivity monitor job...")
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
stats = await run_inactivity_monitor()
|
|
# Naplózás ProcessLog-ba
|
|
process_log = ProcessLog(
|
|
process_name="Inactivity-Monitor",
|
|
items_processed=stats["processed"],
|
|
items_failed=len(stats["errors"]),
|
|
end_time=datetime.utcnow(),
|
|
details={
|
|
"status": "COMPLETED",
|
|
"processed": stats["processed"],
|
|
"suspended": len(stats["suspended"]),
|
|
}
|
|
)
|
|
db.add(process_log)
|
|
await db.commit()
|
|
logger.info(f"Scheduler: Inactivity monitor completed ({stats['processed']} processed)")
|
|
except Exception as e:
|
|
logger.error(f"Scheduler: Inactivity monitor failed: {e}", exc_info=True)
|
|
await db.rollback()
|
|
|
|
scheduler.add_job(
|
|
inactivity_monitor_job,
|
|
trigger=CronTrigger(hour=2, minute=0, jitter=900),
|
|
id="inactivity_monitor",
|
|
name="Inactivity Monitor",
|
|
replace_existing=True
|
|
)
|
|
|
|
logger.info("Scheduler jobs registered: daily_financial, subscription_monitor, inactivity_monitor")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def scheduler_lifespan(app):
|
|
"""
|
|
FastAPI lifespan manager, amely elindítja és leállítja a schedulert.
|
|
"""
|
|
# Importáljuk a szükséges modulokat
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
global _scheduler
|
|
scheduler = get_scheduler()
|
|
setup_scheduler()
|
|
|
|
logger.info("Starting scheduler...")
|
|
scheduler.start()
|
|
|
|
# Azonnali tesztfutás (opcionális, csak fejlesztéshez)
|
|
# scheduler.add_job(daily_financial_maintenance, 'date', run_date=datetime.utcnow())
|
|
|
|
yield
|
|
|
|
logger.info("Shutting down scheduler...")
|
|
scheduler.shutdown(wait=False)
|
|
_scheduler = None |