372 lines
16 KiB
Python
372 lines
16 KiB
Python
import os
|
|
import json
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from contextlib import asynccontextmanager
|
|
from typing import AsyncGenerator, Generator, Optional
|
|
|
|
from sqlalchemy import (
|
|
create_engine, Column, Integer, String, Float, Boolean,
|
|
DateTime, ForeignKey, Text, text, inspect, Index
|
|
)
|
|
from sqlalchemy.orm import sessionmaker, relationship, DeclarativeBase
|
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
|
|
# ─── Config ──────────────────────────────────────────────────────────────────
|
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./cs2_simulator.db")
|
|
ASYNC_DATABASE_URL = os.getenv("ASYNC_DATABASE_URL", "sqlite+aiosqlite:///./cs2_simulator.db")
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
|
USE_ASYNC = os.getenv("USE_ASYNC", "0") == "1" or "postgresql" in ASYNC_DATABASE_URL
|
|
|
|
class Base(DeclarativeBase):
|
|
pass
|
|
|
|
# ─── Sync engine (SQLite fallback / admin) ──────────────────────────────────
|
|
is_sqlite = DATABASE_URL.startswith("sqlite")
|
|
sync_connect_args = {"check_same_thread": False} if is_sqlite else {}
|
|
engine = create_engine(DATABASE_URL, connect_args=sync_connect_args, pool_pre_ping=True)
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
|
|
if is_sqlite:
|
|
with engine.connect() as conn:
|
|
conn.execute(text("PRAGMA journal_mode=WAL"))
|
|
conn.execute(text("PRAGMA synchronous=NORMAL"))
|
|
conn.execute(text("PRAGMA cache_size=-64000"))
|
|
conn.execute(text("PRAGMA busy_timeout=5000"))
|
|
conn.commit()
|
|
|
|
# ─── Async engine (PostgreSQL production) ───────────────────────────────────
|
|
async_engine = create_async_engine(
|
|
ASYNC_DATABASE_URL,
|
|
pool_size=20,
|
|
max_overflow=10,
|
|
pool_pre_ping=True,
|
|
echo=False,
|
|
)
|
|
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)
|
|
|
|
# ─── Models ─────────────────────────────────────────────────────────────────
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
__table_args__ = (
|
|
Index("ix_users_username", "username"),
|
|
Index("ix_users_created_at", "created_at"),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
username = Column(String(50), unique=True, nullable=False)
|
|
hashed_password = Column(String(200), nullable=False)
|
|
balance = Column(Float, default=0.0)
|
|
is_admin = Column(Boolean, default=False)
|
|
is_banned = Column(Boolean, default=False)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
last_login = Column(DateTime, nullable=True)
|
|
card_balance_cached = Column(Float, default=1000.0)
|
|
total_deposited = Column(Float, default=0.0)
|
|
total_withdrawn = Column(Float, default=0.0)
|
|
last_deposit_date = Column(DateTime, nullable=True)
|
|
|
|
inventory_items = relationship("InventoryItem", back_populates="user", cascade="all, delete-orphan")
|
|
case_openings = relationship("CaseOpening", back_populates="user", cascade="all, delete-orphan")
|
|
contracts = relationship("Contract", back_populates="user", cascade="all, delete-orphan")
|
|
rpu_settings = relationship("UserRPU", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
|
upgrades = relationship("Upgrade", back_populates="user", cascade="all, delete-orphan")
|
|
upgrade_rpu = relationship("UpgradeRPU", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
|
transactions = relationship("TransactionLog", back_populates="user", cascade="all, delete-orphan")
|
|
|
|
|
|
class UpgradeRPU(Base):
|
|
__tablename__ = "upgrade_rpu"
|
|
__table_args__ = (Index("ix_upgrade_rpu_user_id", "user_id", unique=True),)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
|
upgrade_multiplier = Column(Float, default=1.0)
|
|
auto_adjust = Column(Boolean, default=False)
|
|
total_attempts = Column(Integer, default=0)
|
|
total_success = Column(Integer, default=0)
|
|
total_spent_value = Column(Float, default=0.0)
|
|
current_win_streak = Column(Integer, default=0)
|
|
current_lose_streak = Column(Integer, default=0)
|
|
best_win_streak = Column(Integer, default=0)
|
|
worst_lose_streak = Column(Integer, default=0)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
user = relationship("User", back_populates="upgrade_rpu")
|
|
|
|
|
|
class Upgrade(Base):
|
|
__tablename__ = "upgrades"
|
|
__table_args__ = (Index("ix_upgrades_user_id_created", "user_id", "created_at"),)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
input_item_id = Column(Integer, nullable=False)
|
|
input_item_name = Column(String(200))
|
|
input_item_image_url = Column(String(500), default="")
|
|
target_item_id = Column(Integer, nullable=False)
|
|
target_item_name = Column(String(200))
|
|
target_item_image_url = Column(String(500), default="")
|
|
success = Column(Boolean, default=False)
|
|
probability = Column(Float)
|
|
rpu_adjusted_probability = Column(Float)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
user = relationship("User", back_populates="upgrades")
|
|
|
|
|
|
class UserRPU(Base):
|
|
__tablename__ = "user_rpu"
|
|
__table_args__ = (Index("ix_user_rpu_user_id", "user_id", unique=True),)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
|
consumer_multiplier = Column(Float, default=1.0)
|
|
industrial_multiplier = Column(Float, default=1.0)
|
|
mil_spec_multiplier = Column(Float, default=1.0)
|
|
restricted_multiplier = Column(Float, default=1.0)
|
|
classified_multiplier = Column(Float, default=1.0)
|
|
covert_multiplier = Column(Float, default=1.0)
|
|
rare_special_multiplier = Column(Float, default=1.0)
|
|
luck_multiplier = Column(Float, default=1.0)
|
|
auto_adjust = Column(Boolean, default=False)
|
|
total_spent = Column(Float, default=0.0)
|
|
total_opened = Column(Integer, default=0)
|
|
total_value_received = Column(Float, default=0.0)
|
|
last_adjustment = Column(DateTime, nullable=True)
|
|
current_streak = Column(Integer, default=0)
|
|
best_streak = Column(Integer, default=0)
|
|
worst_streak = Column(Integer, default=0)
|
|
last_results = Column(String(500), default="")
|
|
luck_budget = Column(Float, default=100.0)
|
|
session_spent = Column(Float, default=0.0)
|
|
session_won = Column(Float, default=0.0)
|
|
session_reset_date = Column(DateTime, nullable=True)
|
|
ceiling_multiplier = Column(Float, default=1.0)
|
|
ceiling_break_count = Column(Integer, default=0)
|
|
ceiling_break_session = Column(Integer, default=0)
|
|
consecutive_loss_value = Column(Float, default=0.0)
|
|
consecutive_loss_count = Column(Integer, default=0)
|
|
comeback_active = Column(Boolean, default=False)
|
|
comeback_openings_left = Column(Integer, default=0)
|
|
comeback_multiplier = Column(Float, default=1.0)
|
|
hot_score = Column(Float, default=0.0)
|
|
last_activity_date = Column(DateTime, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
user = relationship("User", back_populates="rpu_settings")
|
|
|
|
|
|
class PromoCode(Base):
|
|
__tablename__ = "promo_codes"
|
|
__table_args__ = (Index("ix_promo_codes_code", "code"), Index("ix_promo_codes_active", "is_active"))
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
code = Column(String(50), unique=True, nullable=False)
|
|
reward_type = Column(String(30), nullable=False)
|
|
reward_amount = Column(Float, default=0.0)
|
|
reward_data = Column(String(500), default="")
|
|
max_uses = Column(Integer, default=1)
|
|
used_count = Column(Integer, default=0)
|
|
start_date = Column(DateTime, default=datetime.utcnow)
|
|
end_date = Column(DateTime, nullable=True)
|
|
is_active = Column(Boolean, default=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
created_by = Column(Integer, nullable=True)
|
|
|
|
|
|
class TransactionLog(Base):
|
|
__tablename__ = "transaction_log"
|
|
__table_args__ = (
|
|
Index("ix_txlog_user_id", "user_id"),
|
|
Index("ix_txlog_created", "created_at"),
|
|
Index("ix_txlog_user_created", "user_id", "created_at"),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
tx_type = Column(String(30), nullable=False)
|
|
amount = Column(Float, default=0.0)
|
|
fee = Column(Float, default=0.0)
|
|
promo_code = Column(String(50), nullable=True)
|
|
item_name = Column(String(200), nullable=True)
|
|
inventory_item_id = Column(Integer, nullable=True)
|
|
details = Column(String(500), default="")
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
user = relationship("User", back_populates="transactions")
|
|
|
|
|
|
class InventoryItem(Base):
|
|
__tablename__ = "inventory_items"
|
|
__table_args__ = (
|
|
Index("ix_inventory_user_id", "user_id"),
|
|
Index("ix_inventory_user_obtained", "user_id", "obtained_at"),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
item_id = Column(Integer, nullable=False)
|
|
market_hash_name = Column(String(200), nullable=False)
|
|
rarity = Column(String(50))
|
|
wear = Column(String(50))
|
|
float_value = Column(Float, default=0.0)
|
|
type = Column(String(20), default="Normal")
|
|
obtained_from = Column(String(100))
|
|
obtained_at = Column(DateTime, default=datetime.utcnow)
|
|
is_equipped = Column(Boolean, default=False)
|
|
image_url = Column(String(500), default="")
|
|
price_rub = Column(Float, default=0.0)
|
|
user = relationship("User", back_populates="inventory_items")
|
|
|
|
|
|
class CaseOpening(Base):
|
|
__tablename__ = "case_openings"
|
|
__table_args__ = (
|
|
Index("ix_case_openings_user_id", "user_id"),
|
|
Index("ix_case_openings_opened_at", "opened_at"),
|
|
Index("ix_case_openings_user_opened", "user_id", "opened_at"),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
case_name = Column(String(100), nullable=False)
|
|
item_id = Column(Integer, nullable=False)
|
|
item_name = Column(String(200), nullable=False)
|
|
rarity = Column(String(50))
|
|
float_value = Column(Float)
|
|
opened_at = Column(DateTime, default=datetime.utcnow)
|
|
user = relationship("User", back_populates="case_openings")
|
|
|
|
|
|
class Contract(Base):
|
|
__tablename__ = "contracts"
|
|
__table_args__ = (
|
|
Index("ix_contracts_user_id", "user_id"),
|
|
Index("ix_contracts_user_created", "user_id", "created_at"),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
input_item_ids = Column(Text, nullable=False)
|
|
output_item_id = Column(Integer, nullable=False)
|
|
output_item_name = Column(String(200))
|
|
output_float = Column(Float)
|
|
probability = Column(Float)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
user = relationship("User", back_populates="contracts")
|
|
|
|
|
|
class Achievement(Base):
|
|
__tablename__ = "achievements"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String(100), unique=True, nullable=False)
|
|
title = Column(String(200), nullable=False)
|
|
description = Column(String(500))
|
|
icon = Column(String(50), default="🏆")
|
|
category = Column(String(50), default="general")
|
|
requirement_type = Column(String(50), nullable=False)
|
|
requirement_value = Column(Integer, nullable=False)
|
|
reward_amount = Column(Float, default=0.0)
|
|
sort_order = Column(Integer, default=0)
|
|
hidden = Column(Boolean, default=False)
|
|
user_achievements = relationship("UserAchievement", back_populates="achievement")
|
|
|
|
|
|
class UserAchievement(Base):
|
|
__tablename__ = "user_achievements"
|
|
__table_args__ = (
|
|
Index("ix_user_achievements_user", "user_id"),
|
|
Index("ix_user_achievements_ach", "achievement_id"),
|
|
Index("ix_user_achievements_both", "user_id", "achievement_id", unique=True),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False)
|
|
progress = Column(Integer, default=0)
|
|
unlocked_at = Column(DateTime, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
user = relationship("User", backref="user_achievements")
|
|
achievement = relationship("Achievement", back_populates="user_achievements")
|
|
|
|
|
|
class ActivityFeed(Base):
|
|
__tablename__ = "activity_feed"
|
|
__table_args__ = (
|
|
Index("ix_activity_user_id", "user_id"),
|
|
Index("ix_activity_created", "created_at"),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
username = Column(String(50), nullable=False)
|
|
activity_type = Column(String(50), nullable=False)
|
|
message = Column(String(500), nullable=False)
|
|
data_json = Column(Text, default="{}")
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
user = relationship("User", backref="activities")
|
|
|
|
|
|
# ─── Create tables ──────────────────────────────────────────────────────────
|
|
def init_sync_db():
|
|
Base.metadata.create_all(bind=engine)
|
|
_run_migrations(engine, is_sync=True)
|
|
|
|
|
|
async def init_async_db():
|
|
async with async_engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
|
|
def _run_migrations(engine_or_conn, is_sync: bool):
|
|
try:
|
|
from sqlalchemy import inspect as sa_inspect
|
|
inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn)
|
|
existing_tables = inspector.get_table_names()
|
|
for tbl_name in Base.metadata.tables:
|
|
if tbl_name not in existing_tables:
|
|
continue
|
|
model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns}
|
|
db_cols = {c["name"] for c in inspector.get_columns(tbl_name)}
|
|
missing = model_cols - db_cols
|
|
if missing:
|
|
print(f"[DB] Migrating {tbl_name}: adding {missing}")
|
|
except Exception as e:
|
|
print(f"[DB] Migration check: {e}")
|
|
|
|
|
|
# ─── Session helpers ────────────────────────────────────────────────────────
|
|
def get_db() -> Generator:
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
yield db
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
# ─── Thread-safe sync session wrapper for async endpoints ──────────────────
|
|
# Use this as a bridge: runs sync DB ops in executor to avoid blocking event loop.
|
|
# The session is closed when the context exits.
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
@asynccontextmanager
|
|
async def get_db_executor():
|
|
"""Async context manager wrapping sync SessionLocal in executor thread.
|
|
Use for endpoints that haven't been fully async-converted yet."""
|
|
loop = asyncio.get_running_loop()
|
|
db = await loop.run_in_executor(None, lambda: SessionLocal())
|
|
try:
|
|
yield db
|
|
finally:
|
|
await loop.run_in_executor(None, db.close)
|