🐐
This commit is contained in:
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
161
backend/services/database_functions.py
Normal file
161
backend/services/database_functions.py
Normal file
@@ -0,0 +1,161 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from game.card import _get_cards_async
|
||||
from core.models import Card as CardModel
|
||||
from core.models import GameChallenge as GameChallengeModel
|
||||
from core.models import Notification as NotificationModel
|
||||
from core.models import TradeProposal as TradeProposalModel
|
||||
from core.models import User as UserModel
|
||||
from core.database import SessionLocal
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
## Card pool management
|
||||
|
||||
POOL_MINIMUM = 1000
|
||||
POOL_TARGET = 2000
|
||||
POOL_BATCH_SIZE = 10
|
||||
POOL_SLEEP = 4.0
|
||||
# After this many consecutive empty batches, stop trying and wait for the cooldown.
|
||||
POOL_MAX_CONSECUTIVE_EMPTY = 5
|
||||
POOL_CIRCUIT_BREAKER_COOLDOWN = 600.0 # seconds
|
||||
|
||||
pool_filling = False
|
||||
# asyncio monotonic timestamp; 0 means breaker is closed (no cooldown active)
|
||||
_cb_open_until: float = 0.0
|
||||
|
||||
async def fill_card_pool():
|
||||
global pool_filling, _cb_open_until
|
||||
|
||||
if pool_filling:
|
||||
logger.info("Pool fill already in progress, skipping")
|
||||
return
|
||||
|
||||
loop_time = asyncio.get_event_loop().time()
|
||||
if loop_time < _cb_open_until:
|
||||
remaining = int(_cb_open_until - loop_time)
|
||||
logger.warning(f"Card generation circuit breaker open, skipping fill ({remaining}s remaining)")
|
||||
return
|
||||
|
||||
pool_filling = True
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
unassigned = db.query(CardModel).filter(CardModel.user_id == None, CardModel.ai_used == False).count()
|
||||
logger.info(f"Card pool has {unassigned} unassigned cards")
|
||||
if unassigned >= POOL_MINIMUM:
|
||||
logger.info("Pool sufficiently stocked, skipping fill")
|
||||
return
|
||||
|
||||
needed = POOL_TARGET - unassigned
|
||||
logger.info(f"Filling pool with {needed} cards")
|
||||
|
||||
fetched = 0
|
||||
consecutive_empty = 0
|
||||
while fetched < needed:
|
||||
batch_size = min(POOL_BATCH_SIZE, needed - fetched)
|
||||
cards = await _get_cards_async(batch_size)
|
||||
|
||||
if not cards:
|
||||
consecutive_empty += 1
|
||||
logger.warning(
|
||||
f"Card generation batch returned 0 cards "
|
||||
f"({consecutive_empty}/{POOL_MAX_CONSECUTIVE_EMPTY} consecutive empty batches)"
|
||||
)
|
||||
if consecutive_empty >= POOL_MAX_CONSECUTIVE_EMPTY:
|
||||
_cb_open_until = asyncio.get_event_loop().time() + POOL_CIRCUIT_BREAKER_COOLDOWN
|
||||
logger.error(
|
||||
f"ALERT: Card generation circuit breaker tripped — {consecutive_empty} consecutive "
|
||||
f"empty batches. Wikipedia/Wikirank API may be down. "
|
||||
f"Next retry in {int(POOL_CIRCUIT_BREAKER_COOLDOWN)}s."
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(POOL_SLEEP)
|
||||
continue
|
||||
|
||||
consecutive_empty = 0
|
||||
db.execute(insert(CardModel).values([
|
||||
dict(
|
||||
name=card.name,
|
||||
image_link=card.image_link,
|
||||
card_rarity=card.card_rarity.name,
|
||||
card_type=card.card_type.name,
|
||||
text=card.text,
|
||||
attack=card.attack,
|
||||
defense=card.defense,
|
||||
cost=card.cost,
|
||||
user_id=None,
|
||||
)
|
||||
for card in cards
|
||||
]))
|
||||
db.commit()
|
||||
fetched += len(cards)
|
||||
logger.info(f"Pool fill progress: {fetched}/{needed}")
|
||||
await asyncio.sleep(POOL_SLEEP)
|
||||
|
||||
finally:
|
||||
pool_filling = False
|
||||
db.close()
|
||||
|
||||
## Booster management
|
||||
|
||||
BOOSTER_MAX = 5
|
||||
BOOSTER_COOLDOWN_HOURS = 5
|
||||
|
||||
def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]:
|
||||
if user.boosters_countdown is None:
|
||||
if user.boosters < BOOSTER_MAX:
|
||||
user.boosters = BOOSTER_MAX
|
||||
db.commit()
|
||||
return (user.boosters, user.boosters_countdown)
|
||||
|
||||
now = datetime.now()
|
||||
countdown = user.boosters_countdown
|
||||
|
||||
while user.boosters < BOOSTER_MAX:
|
||||
next_tick = countdown + timedelta(hours=BOOSTER_COOLDOWN_HOURS)
|
||||
if now >= next_tick:
|
||||
user.boosters += 1
|
||||
countdown = next_tick
|
||||
else:
|
||||
break
|
||||
|
||||
user.boosters_countdown = countdown if user.boosters < BOOSTER_MAX else None
|
||||
db.commit()
|
||||
return (user.boosters, user.boosters_countdown)
|
||||
|
||||
## Periodic cleanup
|
||||
|
||||
CLEANUP_INTERVAL_SECONDS = 3600 # 1 hour
|
||||
|
||||
|
||||
async def run_cleanup_loop():
|
||||
# Brief startup delay so the DB is fully ready before first run
|
||||
await asyncio.sleep(60)
|
||||
while True:
|
||||
try:
|
||||
_delete_expired_records()
|
||||
except Exception:
|
||||
logger.exception("Periodic cleanup job failed")
|
||||
await asyncio.sleep(CLEANUP_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def _delete_expired_records():
|
||||
now = datetime.now()
|
||||
with SessionLocal() as db:
|
||||
for model in (NotificationModel, TradeProposalModel, GameChallengeModel):
|
||||
# Notification.expires_at is nullable — skip rows without an expiry.
|
||||
# TradeProposal and GameChallenge always have expires_at, but the
|
||||
# guard is harmless and makes the intent explicit.
|
||||
result = db.execute(
|
||||
delete(model).where(
|
||||
model.expires_at != None, # noqa: E711
|
||||
model.expires_at < now,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
logger.info("Cleanup: deleted %d expired %s rows", result.rowcount, model.__tablename__)
|
||||
54
backend/services/email_utils.py
Normal file
54
backend/services/email_utils.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
|
||||
import resend
|
||||
|
||||
from core.config import RESEND_API_KEY, EMAIL_FROM, FRONTEND_URL
|
||||
|
||||
def send_verification_email(to_email: str, username: str, token: str):
|
||||
resend.api_key = RESEND_API_KEY
|
||||
verify_url = f"{FRONTEND_URL}/verify-email?token={token}"
|
||||
|
||||
resend.Emails.send({
|
||||
"from": EMAIL_FROM,
|
||||
"to": to_email,
|
||||
"subject": "Verify your WikiTCG email",
|
||||
"html": f"""
|
||||
<div style="font-family: serif; max-width: 480px; margin: 0 auto; color: #1a1208;">
|
||||
<h2 style="color: #b87830;">Welcome to WikiTCG</h2>
|
||||
<p>Hi {username},</p>
|
||||
<p>Please verify your email address to complete your registration:</p>
|
||||
<p style="margin: 2rem 0;">
|
||||
<a href="{verify_url}" style="background: #c8861a; color: #fff8e0; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-family: sans-serif; font-size: 14px;">
|
||||
Verify Email
|
||||
</a>
|
||||
</p>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
<p style="color: #888; font-size: 13px;">- WikiTCG</p>
|
||||
</div>
|
||||
""",
|
||||
})
|
||||
|
||||
|
||||
def send_password_reset_email(to_email: str, username: str, reset_token: str):
|
||||
resend.api_key = RESEND_API_KEY
|
||||
reset_url = f"{FRONTEND_URL}/forgot-password/reset?token={reset_token}"
|
||||
|
||||
resend.Emails.send({
|
||||
"from": EMAIL_FROM,
|
||||
"to": to_email,
|
||||
"subject": "Reset your WikiTCG password",
|
||||
"html": f"""
|
||||
<div style="font-family: serif; max-width: 480px; margin: 0 auto; color: #1a1208;">
|
||||
<h2 style="color: #b87830;">WikiTCG Password Reset</h2>
|
||||
<p>Hi {username},</p>
|
||||
<p>Someone requested a password reset for your account. If this was you, click the link below:</p>
|
||||
<p style="margin: 2rem 0;">
|
||||
<a href="{reset_url}" style="background: #c8861a; color: #fff8e0; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-family: sans-serif; font-size: 14px;">
|
||||
Reset Password
|
||||
</a>
|
||||
</p>
|
||||
<p>This link expires in 1 hour. If you didn't request this, you can safely ignore this email.</p>
|
||||
<p style="color: #888; font-size: 13px;">- WikiTCG</p>
|
||||
</div>
|
||||
""",
|
||||
})
|
||||
41
backend/services/notification_manager.py
Normal file
41
backend/services/notification_manager.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Manages persistent per-user WebSocket connections for the notification channel.
|
||||
The DB is the source of truth — this layer just delivers live pushes to connected clients.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
# user_id (str) -> active WebSocket; replaced on reconnect
|
||||
connections: dict[str, WebSocket] = {}
|
||||
|
||||
|
||||
def register(user_id: str, ws: WebSocket) -> None:
|
||||
connections[user_id] = ws
|
||||
|
||||
|
||||
def unregister(user_id: str) -> None:
|
||||
connections.pop(user_id, None)
|
||||
|
||||
|
||||
async def send_notification(user_id: str, notification: dict) -> None:
|
||||
"""Push a single notification to the user if they're connected. No-op otherwise."""
|
||||
ws = connections.get(user_id)
|
||||
if ws:
|
||||
try:
|
||||
await ws.send_json({"type": "push", "notification": notification})
|
||||
except Exception as e:
|
||||
# Stale connection — the disconnect handler will clean it up
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
|
||||
async def send_delete(user_id: str, notification_id: str) -> None:
|
||||
"""Tell the client to remove a notification from its local list."""
|
||||
ws = connections.get(user_id)
|
||||
if ws:
|
||||
try:
|
||||
await ws.send_json({"type": "delete", "notification_id": notification_id})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
315
backend/services/trade_manager.py
Normal file
315
backend/services/trade_manager.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.models import Card as CardModel, DeckCard as DeckCardModel
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
## Card transfer
|
||||
|
||||
def transfer_cards(
|
||||
from_user_id: uuid.UUID,
|
||||
to_user_id: uuid.UUID,
|
||||
card_ids: list[uuid.UUID],
|
||||
db: Session,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""
|
||||
Reassigns card ownership, stamps received_at, removes deck memberships, and clears WTT.
|
||||
Does NOT commit — caller owns the transaction.
|
||||
Clearing WTT on transfer prevents a card from auto-appearing as tradeable on the new owner's
|
||||
profile without them explicitly opting in.
|
||||
"""
|
||||
if not card_ids:
|
||||
return
|
||||
|
||||
matched_cards = db.query(CardModel).filter(
|
||||
CardModel.id.in_(card_ids),
|
||||
CardModel.user_id == from_user_id,
|
||||
).all()
|
||||
|
||||
# Bail out if any card is missing or no longer owned by the sender — a partial
|
||||
# transfer would silently give the receiver fewer cards than agreed upon.
|
||||
if len(matched_cards) != len(card_ids):
|
||||
raise ValueError(
|
||||
f"Expected {len(card_ids)} cards owned by {from_user_id}, "
|
||||
f"found {len(matched_cards)}"
|
||||
)
|
||||
|
||||
for card in matched_cards:
|
||||
card.user_id = to_user_id
|
||||
card.received_at = now
|
||||
card.willing_to_trade = False
|
||||
db.query(DeckCardModel).filter(DeckCardModel.card_id == card.id).delete(synchronize_session=False)
|
||||
|
||||
|
||||
## Storage
|
||||
|
||||
@dataclass
|
||||
class TradeOffer:
|
||||
username: str
|
||||
cards: list[dict] = field(default_factory=list)
|
||||
accepted: bool = False
|
||||
|
||||
@dataclass
|
||||
class TradeSession:
|
||||
trade_id: str
|
||||
offers: dict[str, TradeOffer] # user_id -> TradeOffer
|
||||
connections: dict[str, WebSocket] = field(default_factory=dict)
|
||||
|
||||
active_trades: dict[str, TradeSession] = {}
|
||||
|
||||
@dataclass
|
||||
class TradeQueueEntry:
|
||||
user_id: str
|
||||
username: str
|
||||
websocket: WebSocket
|
||||
|
||||
trade_queue: list[TradeQueueEntry] = []
|
||||
trade_queue_lock = asyncio.Lock()
|
||||
|
||||
## Serialization
|
||||
|
||||
def serialize_card_model(card: CardModel) -> dict:
|
||||
return {
|
||||
"id": str(card.id),
|
||||
"name": card.name,
|
||||
"card_rarity": card.card_rarity,
|
||||
"card_type": card.card_type,
|
||||
"image_link": card.image_link,
|
||||
"attack": card.attack,
|
||||
"defense": card.defense,
|
||||
"cost": card.cost,
|
||||
"text": card.text,
|
||||
"generated_at": card.generated_at.isoformat() if card.generated_at else None,
|
||||
"received_at": card.received_at.isoformat() if card.received_at else None,
|
||||
"is_favorite": card.is_favorite,
|
||||
"willing_to_trade": card.willing_to_trade,
|
||||
}
|
||||
|
||||
def serialize_trade(session: TradeSession, perspective_user_id: str) -> dict:
|
||||
partner_id = next(uid for uid in session.offers if uid != perspective_user_id)
|
||||
my_offer = session.offers[perspective_user_id]
|
||||
their_offer = session.offers[partner_id]
|
||||
return {
|
||||
"trade_id": session.trade_id,
|
||||
"partner_username": their_offer.username,
|
||||
"my_offer": {
|
||||
"cards": my_offer.cards,
|
||||
"accepted": my_offer.accepted,
|
||||
},
|
||||
"their_offer": {
|
||||
"cards": their_offer.cards,
|
||||
"accepted": their_offer.accepted,
|
||||
},
|
||||
}
|
||||
|
||||
## Broadcasting
|
||||
|
||||
async def broadcast_trade(session: TradeSession) -> None:
|
||||
for user_id, ws in list(session.connections.items()):
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "state",
|
||||
"state": serialize_trade(session, user_id),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
## Matchmaking
|
||||
|
||||
async def try_trade_match() -> None:
|
||||
async with trade_queue_lock:
|
||||
if len(trade_queue) < 2:
|
||||
return
|
||||
# Guard: same user queued twice
|
||||
if trade_queue[0].user_id == trade_queue[1].user_id:
|
||||
return
|
||||
p1 = trade_queue.pop(0)
|
||||
p2 = trade_queue.pop(0)
|
||||
|
||||
trade_id = str(uuid.uuid4())
|
||||
session = TradeSession(
|
||||
trade_id=trade_id,
|
||||
offers={
|
||||
p1.user_id: TradeOffer(username=p1.username),
|
||||
p2.user_id: TradeOffer(username=p2.username),
|
||||
},
|
||||
connections={
|
||||
p1.user_id: p1.websocket,
|
||||
p2.user_id: p2.websocket,
|
||||
},
|
||||
)
|
||||
active_trades[trade_id] = session
|
||||
|
||||
for entry in [p1, p2]:
|
||||
try:
|
||||
await entry.websocket.send_json({"type": "trade_start", "trade_id": trade_id})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
## Action handling
|
||||
|
||||
async def handle_trade_action(
|
||||
trade_id: str,
|
||||
user_id: str,
|
||||
message: dict,
|
||||
db: Session,
|
||||
) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
action = message.get("type")
|
||||
ws = session.connections.get(user_id)
|
||||
|
||||
if action == "update_offer":
|
||||
card_ids = message.get("card_ids", [])
|
||||
|
||||
if card_ids:
|
||||
try:
|
||||
parsed_ids = [uuid.UUID(cid) for cid in card_ids]
|
||||
except ValueError:
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Invalid card IDs"})
|
||||
return
|
||||
|
||||
db_cards = db.query(CardModel).filter(
|
||||
CardModel.id.in_(parsed_ids),
|
||||
CardModel.user_id == uuid.UUID(user_id),
|
||||
).all()
|
||||
|
||||
if len(db_cards) != len(card_ids):
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Some cards are not in your collection"})
|
||||
return
|
||||
|
||||
# Preserve the order of card_ids
|
||||
card_map = {str(c.id): c for c in db_cards}
|
||||
ordered = [card_map[cid] for cid in card_ids if cid in card_map]
|
||||
session.offers[user_id].cards = [serialize_card_model(c) for c in ordered]
|
||||
else:
|
||||
session.offers[user_id].cards = []
|
||||
|
||||
# Any offer change unaccepts both sides
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
|
||||
await broadcast_trade(session)
|
||||
|
||||
elif action == "accept":
|
||||
either_has_cards = any(len(o.cards) > 0 for o in session.offers.values())
|
||||
if not either_has_cards:
|
||||
return
|
||||
|
||||
# Validate ownership of offered cards one more time
|
||||
my_offer = session.offers[user_id]
|
||||
if my_offer.cards:
|
||||
owned_count = db.query(CardModel).filter(
|
||||
CardModel.id.in_([uuid.UUID(c["id"]) for c in my_offer.cards]),
|
||||
CardModel.user_id == uuid.UUID(user_id),
|
||||
).count()
|
||||
if owned_count != len(my_offer.cards):
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Some offered cards are no longer in your collection"})
|
||||
return
|
||||
|
||||
my_offer.accepted = True
|
||||
|
||||
if all(o.accepted for o in session.offers.values()):
|
||||
await _complete_trade(trade_id, db)
|
||||
else:
|
||||
await broadcast_trade(session)
|
||||
|
||||
elif action == "unaccept":
|
||||
session.offers[user_id].accepted = False
|
||||
await broadcast_trade(session)
|
||||
|
||||
## Trade completion
|
||||
|
||||
async def _complete_trade(trade_id: str, db: Session) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
# Re-check that both sides are still accepted and have a non-empty offer.
|
||||
# A last-second unaccept or offer change (race or client bug) should abort.
|
||||
if not all(o.accepted for o in session.offers.values()):
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
if not any(len(o.cards) > 0 for o in session.offers.values()):
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
|
||||
user_ids = list(session.offers.keys())
|
||||
u1, u2 = user_ids[0], user_ids[1]
|
||||
cards_u1 = session.offers[u1].cards # u1 gives these to u2
|
||||
cards_u2 = session.offers[u2].cards # u2 gives these to u1
|
||||
|
||||
# Final ownership double-check before writing
|
||||
def verify(from_id: str, card_dicts: list[dict]) -> bool:
|
||||
if not card_dicts:
|
||||
return True
|
||||
card_uuids = [uuid.UUID(c["id"]) for c in card_dicts]
|
||||
count = db.query(CardModel).filter(
|
||||
CardModel.id.in_(card_uuids),
|
||||
CardModel.user_id == uuid.UUID(from_id),
|
||||
).count()
|
||||
return count == len(card_uuids)
|
||||
|
||||
if not verify(u1, cards_u1) or not verify(u2, cards_u2):
|
||||
db.rollback()
|
||||
for ws in list(session.connections.values()):
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"message": "Trade failed: ownership check failed. Offers have been reset.",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
|
||||
# Transfer ownership and clear deck relationships
|
||||
now = datetime.now()
|
||||
transfer_cards(uuid.UUID(u1), uuid.UUID(u2), [uuid.UUID(c["id"]) for c in cards_u1], db, now)
|
||||
transfer_cards(uuid.UUID(u2), uuid.UUID(u1), [uuid.UUID(c["id"]) for c in cards_u2], db, now)
|
||||
db.commit()
|
||||
|
||||
active_trades.pop(trade_id, None)
|
||||
|
||||
for ws in list(session.connections.values()):
|
||||
try:
|
||||
await ws.send_json({"type": "trade_complete"})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
## Disconnect handling
|
||||
|
||||
async def handle_trade_disconnect(trade_id: str, user_id: str) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
active_trades.pop(trade_id, None)
|
||||
|
||||
for uid, ws in list(session.connections.items()):
|
||||
if uid == user_id:
|
||||
continue
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"message": "Your trade partner disconnected. Trade cancelled.",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
Reference in New Issue
Block a user