PostgreSQL + async refactoring, WebSocket server, Redis caching, minified assets

This commit is contained in:
2026-07-25 13:31:40 +00:00
parent 47c7f17c90
commit 17da0b6d79
28 changed files with 1840 additions and 3244 deletions
+190 -234
View File
@@ -1,27 +1,59 @@
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text, text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from datetime import datetime
import os
import json
from pathlib import Path
from datetime import datetime
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Generator, Optional
DATABASE_URL = "sqlite:///./cs2_simulator.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
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)
Base = declarative_base()
# Включаем WAL mode для параллельных чтений и уменьшения SQLITE_BUSY
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()
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"
id = Column(Integer, primary_key=True, index=True)
__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)
@@ -29,13 +61,11 @@ class User(Base):
is_banned = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True)
# Карта (кэш, источник правды — Bank API)
card_balance_cached = Column(Float, default=1000.0)
total_deposited = Column(Float, default=0.0) # всего закинуто card→site
total_withdrawn = Column(Float, default=0.0) # всего выведено site→card
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")
@@ -44,39 +74,32 @@ class User(Base):
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"
id = Column(Integer, primary_key=True, index=True)
__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)
# Множитель шанса апгрейда (1.0 = стандартный)
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) # Стоимость потерянных предметов
# Серии
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"
id = Column(Integer, primary_key=True, index=True)
__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))
@@ -85,19 +108,18 @@ class Upgrade(Base):
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) # Шанс с учетом РПУ
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"
id = Column(Integer, primary_key=True, index=True)
__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)
# Множители шансов для каждой редкости (1.0 = стандартный шанс)
consumer_multiplier = Column(Float, default=1.0)
industrial_multiplier = Column(Float, default=1.0)
mil_spec_multiplier = Column(Float, default=1.0)
@@ -105,64 +127,44 @@ class UserRPU(Base):
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="")
# ── RPU v2: Session (сессии) ──
luck_budget = Column(Float, default=100.0) # 0-100, остаток удачи на сессию
session_spent = Column(Float, default=0.0) # потрачено в текущей сессии
session_won = Column(Float, default=0.0) # выиграно (ценность) в сессии
session_reset_date = Column(DateTime, nullable=True) # когда сброшена сессия
# ── RPU v2: Ceiling (потолок) ──
ceiling_multiplier = Column(Float, default=1.0) # временный множитель потолка (0.5-3.0)
ceiling_break_count = Column(Integer, default=0) # сколько раз пробивал потолок
ceiling_break_session = Column(Integer, default=0) # пробитий за сессию
# ── RPU v2: Comeback (комбек) ──
consecutive_loss_value = Column(Float, default=0.0) # стоимость последовательных проигрышей
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) # множитель удачи в режиме комбека
# ── RPU v2: Hot/Cold анализ ──
hot_score = Column(Float, default=0.0) # >0 = горячий, <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"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, nullable=False, index=True)
reward_type = Column(String(30), nullable=False) # card_to_site, luck_boost, ceiling_boost, free_case, reset_streak
__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="") # доп. параметры (JSON)
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)
@@ -174,44 +176,57 @@ class PromoCode(Base):
class TransactionLog(Base):
__tablename__ = "transaction_log"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
tx_type = Column(String(30), nullable=False) # card_to_site, site_to_card, promo_bonus, admin
__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) # при выводе скина
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"
id = Column(Integer, primary_key=True, index=True)
__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) # ID из ALL_ITEMS
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)) # "case: название" или "contract"
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"
id = Column(Integer, primary_key=True, index=True)
__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)
@@ -219,197 +234,138 @@ class CaseOpening(Base):
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"
id = Column(Integer, primary_key=True, index=True)
__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) # JSON список ID из инвентаря
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, index=True)
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") # cases, contracts, upgrade, social, general
requirement_type = Column(String(50), nullable=False) # open_cases, contracts, upgrade_wins, total_spent, valuable_item, etc.
requirement_value = Column(Integer, nullable=False) # how many
reward_amount = Column(Float, default=0.0) # bonus rubles
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) # секретные достижения
hidden = Column(Boolean, default=False)
user_achievements = relationship("UserAchievement", back_populates="achievement")
class UserAchievement(Base):
__tablename__ = "user_achievements"
id = Column(Integer, primary_key=True, index=True)
__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) # текущий прогресс
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"
id = Column(Integer, primary_key=True, index=True)
__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) # case_open, contract, upgrade, achievement, trade, crash
activity_type = Column(String(50), nullable=False)
message = Column(String(500), nullable=False)
data_json = Column(Text, default="{}") # дополнительные данные (item_name, price, etc.)
data_json = Column(Text, default="{}")
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", backref="activities")
Base.metadata.create_all(bind=engine)
# Миграция: добавляем недостающие индексы для производительности
try:
from sqlalchemy import inspect
inspector = inspect(engine)
indices_to_add = {
"inventory_items": ["user_id"],
"case_openings": ["user_id", "opened_at"],
"upgrades": ["user_id"],
"contracts": ["user_id"],
"activity_feed": ["user_id"],
"user_achievements": ["user_id", "achievement_id"],
}
with engine.connect() as conn:
for tbl, cols in indices_to_add.items():
existing = [ix["name"] for ix in inspector.get_indexes(tbl)]
for col in cols:
ix_name = f"ix_{tbl}_{col}"
if ix_name not in existing:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {ix_name} ON {tbl} ({col})"))
conn.commit()
print("[DB] Индексы: OK")
except Exception as e:
print(f"[DB] Миграция индексов: {e}")
# ─── Create tables ──────────────────────────────────────────────────────────
def init_sync_db():
Base.metadata.create_all(bind=engine)
_run_migrations(engine, is_sync=True)
# Миграция: добавляем колонку hidden если её нет
try:
from sqlalchemy import inspect
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("achievements")]
if "hidden" not in cols:
from sqlalchemy import text
with engine.connect() as conn:
conn.execute(text("ALTER TABLE achievements ADD COLUMN hidden BOOLEAN DEFAULT 0"))
conn.commit()
except Exception as e:
print(f"[DB] Миграция hidden: {e}")
# Миграция: добавляем image_url и price_rub в inventory_items
try:
from sqlalchemy import text
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("inventory_items")]
if "image_url" not in cols:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE inventory_items ADD COLUMN image_url VARCHAR(500) DEFAULT ''"))
conn.execute(text("ALTER TABLE inventory_items ADD COLUMN price_rub FLOAT DEFAULT 0.0"))
conn.commit()
# Backfill existing rows from ALL_ITEMS
try:
from backend import ALL_ITEMS
with engine.connect() as conn:
rows = conn.execute(text("SELECT id, item_id FROM inventory_items WHERE image_url = '' OR price_rub = 0.0")).fetchall()
for row_id, item_id in rows:
if 0 <= item_id < len(ALL_ITEMS):
item = ALL_ITEMS[item_id]
img = item.get("image_url", "")
pr = item.get("price_rub", 0.0)
conn.execute(
text("UPDATE inventory_items SET image_url = :img, price_rub = :pr WHERE id = :rid"),
{"img": img, "pr": pr, "rid": row_id}
)
conn.commit()
print(f"[DB] Backfilled {len(rows)} inventory_items from ALL_ITEMS")
except Exception as e2:
print(f"[DB] Backfill inventory_items: {e2}")
except Exception as e:
print(f"[DB] Миграция inventory_items: {e}")
async def init_async_db():
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Миграция: новые поля в users (карта)
try:
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("users")]
if "card_balance_cached" not in cols:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE users ADD COLUMN card_balance_cached FLOAT DEFAULT 1000.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN total_deposited FLOAT DEFAULT 0.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN total_withdrawn FLOAT DEFAULT 0.0"))
conn.execute(text("ALTER TABLE users ADD COLUMN last_deposit_date TIMESTAMP"))
conn.commit()
print("[DB] Миграция users (card): OK")
except Exception as e:
print(f"[DB] Миграция users card: {e}")
# Миграция: новые поля в user_rpu (v2)
try:
inspector = inspect(engine)
cols = [c["name"] for c in inspector.get_columns("user_rpu")]
for col, coltype in [
("luck_budget", "FLOAT DEFAULT 100.0"),
("session_spent", "FLOAT DEFAULT 0.0"),
("session_won", "FLOAT DEFAULT 0.0"),
("session_reset_date", "TIMESTAMP"),
("ceiling_multiplier", "FLOAT DEFAULT 1.0"),
("ceiling_break_count", "INTEGER DEFAULT 0"),
("ceiling_break_session", "INTEGER DEFAULT 0"),
("consecutive_loss_value", "FLOAT DEFAULT 0.0"),
("consecutive_loss_count", "INTEGER DEFAULT 0"),
("comeback_active", "BOOLEAN DEFAULT 0"),
("comeback_openings_left", "INTEGER DEFAULT 0"),
("comeback_multiplier", "FLOAT DEFAULT 1.0"),
("hot_score", "FLOAT DEFAULT 0.0"),
("last_activity_date", "TIMESTAMP"),
]:
if col not in cols:
with engine.connect() as conn:
conn.execute(text(f"ALTER TABLE user_rpu ADD COLUMN {col} {coltype}"))
conn.commit()
print(f"[DB] Миграция user_rpu: {col}")
except Exception as e:
print(f"[DB] Миграция user_rpu: {e}")
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}")
# Миграция: создаём PromoCode если нет
try:
Base.metadata.tables["promo_codes"].create(bind=engine, checkfirst=True)
except:
pass
# Миграция: создаём TransactionLog если нет
try:
Base.metadata.tables["transaction_log"].create(bind=engine, checkfirst=True)
except:
pass
def get_db():
# ─── Session helpers ────────────────────────────────────────────────────────
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()
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)