286 lines
12 KiB
Python
286 lines
12 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from fastapi import FastAPI, Request, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from config import FRONTEND_ORIGIN, UPLOADS_DIR
|
|
from database import init_db
|
|
from routers import users, game, hints, leaderboard, sentences, achievements, wordquiz, bubblewords, pronunciation, assistant, learning, tts, typing, teacher, support, admin, feed, admin_content
|
|
from routers.sentences import init_sentences
|
|
from routers.achievements import init_achievements
|
|
from routers.learning_data import init_learning_data
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ============ Версия ============
|
|
# Читается из .env (переменная APP_VERSION). Если не задана — fallback "1.0.0".
|
|
# Фронт живёт на Cloudflare Pages, там своя версия в index.html — они не обязаны совпадать.
|
|
# При деплое новой версии фронта обнови APP_VERSION в .env и перезапусти filwords-api.
|
|
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
|
|
|
# ============ Sentry (мониторинг ошибок) ============
|
|
# ВАЖНО: инициализация ДО создания FastAPI-приложения
|
|
SENTRY_DSN = os.getenv("SENTRY_DSN", "")
|
|
if SENTRY_DSN:
|
|
try:
|
|
import sentry_sdk
|
|
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
|
|
|
sentry_sdk.init(
|
|
dsn=SENTRY_DSN,
|
|
integrations=[
|
|
StarletteIntegration(transaction_style="endpoint"),
|
|
FastApiIntegration(transaction_style="endpoint"),
|
|
],
|
|
traces_sample_rate=0.2, # 20% запросов для performance monitoring
|
|
environment=os.getenv("ENV", "production"),
|
|
release=f"filwords@{APP_VERSION}",
|
|
send_default_pii=False, # НЕ отправляем IP/headers/cookies пользователей
|
|
)
|
|
logger.info(f"Sentry initialized (release=filwords@{APP_VERSION})")
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize Sentry: {e}")
|
|
else:
|
|
logger.warning("SENTRY_DSN not set — Sentry disabled")
|
|
|
|
app = FastAPI(title="Filwords PL API")
|
|
|
|
# ============ Rate Limiting ============
|
|
class RateLimiter:
|
|
def __init__(self):
|
|
self.requests = defaultdict(list)
|
|
self.limits = {
|
|
"default": {"max_requests": 60, "window": 60},
|
|
"/game/check-word": {"max_requests": 30, "window": 60},
|
|
"/game/complete-level": {"max_requests": 10, "window": 60},
|
|
"/sentences/check": {"max_requests": 20, "window": 60},
|
|
"/leaderboard": {"max_requests": 10, "window": 60},
|
|
"/assistant/chat": {"max_requests": 20, "window": 60},
|
|
"/learning/answer": {"max_requests": 30, "window": 60},
|
|
"/tts": {"max_requests": 30, "window": 60},
|
|
"/review": {"max_requests": 10, "window": 60},
|
|
"/typing/check": {"max_requests": 20, "window": 60},
|
|
"/teacher/answer": {"max_requests": 30, "window": 60},
|
|
"/teacher/start": {"max_requests": 10, "window": 60},
|
|
"/teacher/explain": {"max_requests": 10, "window": 60},
|
|
"/teacher/translate": {"max_requests": 10, "window": 60},
|
|
"/teacher/example": {"max_requests": 10, "window": 60},
|
|
"/support/send": {"max_requests": 10, "window": 60},
|
|
"/support/admin-reply": {"max_requests": 30, "window": 60},
|
|
"/support/translate": {"max_requests": 20, "window": 60},
|
|
# 🔧 НОВОЕ (18.09.2026): лимиты для feed
|
|
"/admin/feed/post": {"max_requests": 20, "window": 60},
|
|
"/admin/feed/upload-image": {"max_requests": 10, "window": 60},
|
|
"/admin/feed/translate": {"max_requests": 10, "window": 60},
|
|
}
|
|
self._cleanup_task = None
|
|
|
|
def _cleanup(self):
|
|
now = time.time()
|
|
for key in list(self.requests.keys()):
|
|
self.requests[key] = [t for t in self.requests[key] if now - t < 60]
|
|
if not self.requests[key]:
|
|
del self.requests[key]
|
|
|
|
async def start_cleanup(self):
|
|
while True:
|
|
await asyncio.sleep(60)
|
|
self._cleanup()
|
|
|
|
def is_allowed(self, key: str, path: str) -> bool:
|
|
now = time.time()
|
|
|
|
limit_config = self.limits.get(path, self.limits["default"])
|
|
max_requests = limit_config["max_requests"]
|
|
window = limit_config["window"]
|
|
|
|
self.requests[key] = [t for t in self.requests[key] if now - t < window]
|
|
|
|
if len(self.requests[key]) >= max_requests:
|
|
return False
|
|
|
|
self.requests[key].append(now)
|
|
return True
|
|
|
|
def get_retry_after(self, key: str, path: str) -> int:
|
|
if not self.requests[key]:
|
|
return 0
|
|
limit_config = self.limits.get(path, self.limits["default"])
|
|
window = limit_config["window"]
|
|
oldest = min(self.requests[key])
|
|
return max(0, int(window - (time.time() - oldest)))
|
|
|
|
rate_limiter = RateLimiter()
|
|
|
|
# ============ Rate Limiting Middleware ============
|
|
@app.middleware("http")
|
|
async def rate_limit_middleware(request: Request, call_next):
|
|
path = request.url.path
|
|
|
|
if path in ["/", "/health"]:
|
|
return await call_next(request)
|
|
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
rate_key = f"{client_ip}:{path}"
|
|
|
|
if not rate_limiter.is_allowed(rate_key, path):
|
|
retry_after = rate_limiter.get_retry_after(rate_key, path)
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={
|
|
"error": "Too many requests",
|
|
"retry_after": retry_after
|
|
},
|
|
headers={"Retry-After": str(retry_after)}
|
|
)
|
|
|
|
start_time = time.time()
|
|
response = await call_next(request)
|
|
duration = time.time() - start_time
|
|
|
|
if duration > 1.0:
|
|
logger.warning(f"Slow request: {request.method} {path} - {duration:.2f}s")
|
|
|
|
return response
|
|
|
|
# ============ CORS ============
|
|
# 🔧 ОБНОВЛЕНО: добавлен PUT — нужен для редактирования feed-постов
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[FRONTEND_ORIGIN],
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# ============ 🔧 НОВОЕ (18.09.2026): статика для картинок feed-постов ============
|
|
# Файлы лежат в /root/filwordspl/backend/uploads/feed/{filename}
|
|
# URL на фронте: https://api.filwordspl.com/uploads/feed/{filename}
|
|
app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads")
|
|
|
|
# ============ Роутеры ============
|
|
app.include_router(users.router)
|
|
app.include_router(game.router)
|
|
app.include_router(hints.router)
|
|
app.include_router(leaderboard.router)
|
|
app.include_router(sentences.router)
|
|
app.include_router(achievements.router)
|
|
app.include_router(wordquiz.router)
|
|
app.include_router(bubblewords.router)
|
|
app.include_router(pronunciation.router)
|
|
app.include_router(assistant.router)
|
|
app.include_router(learning.router)
|
|
app.include_router(tts.router)
|
|
app.include_router(typing.router)
|
|
app.include_router(teacher.router)
|
|
app.include_router(support.router)
|
|
app.include_router(admin.router)
|
|
# 🔧 НОВОЕ (18.09.2026): feed — посты карусели (публичный + админский)
|
|
app.include_router(feed.router)
|
|
# 🔧 НОВОЕ (19.09.2026): admin content — контент-менеджер (только для админов)
|
|
app.include_router(admin_content.router)
|
|
|
|
# ============ Фоновое обновление кэша рейтинга ============
|
|
async def refresh_leaderboard_cache_loop():
|
|
await asyncio.sleep(10)
|
|
while True:
|
|
try:
|
|
from database import get_pool
|
|
pool = await get_pool()
|
|
async with pool.acquire() as conn:
|
|
await conn.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard_cache")
|
|
except Exception as e:
|
|
logger.error(f"Failed to refresh leaderboard cache: {e}")
|
|
await asyncio.sleep(30)
|
|
|
|
# ============ Startup ============
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
await init_db()
|
|
await init_sentences()
|
|
await init_achievements()
|
|
await init_learning_data()
|
|
|
|
asyncio.create_task(rate_limiter.start_cleanup())
|
|
|
|
from database import get_pool
|
|
pool = await get_pool()
|
|
async with pool.acquire() as conn:
|
|
# Пересоздаём materialized view с новой структурой (с level)
|
|
await conn.execute("DROP MATERIALIZED VIEW IF EXISTS leaderboard_cache")
|
|
await conn.execute("""
|
|
CREATE MATERIALIZED VIEW IF NOT EXISTS leaderboard_cache AS
|
|
SELECT
|
|
u.telegram_id,
|
|
u.username,
|
|
u.current_level,
|
|
COALESCE(ws.total, 0) + COALESCE(s.total, 0) + COALESCE(wq.total, 0) + COALESCE(bw.total, 0) as total_score,
|
|
COALESCE(ws.total, 0) as wordsearch_score,
|
|
COALESCE(s.total, 0) as sentence_score,
|
|
COALESCE(wq.total, 0) as wordquiz_score,
|
|
COALESCE(wq.words_guessed, 0) as words_guessed,
|
|
COALESCE(bw.total, 0) as bubblewords_score,
|
|
COALESCE(bw.levels_completed, 0) as bubble_levels_completed
|
|
FROM users u
|
|
LEFT JOIN (
|
|
SELECT user_id, SUM(score) as total
|
|
FROM user_progress
|
|
WHERE completed_at IS NOT NULL
|
|
GROUP BY user_id
|
|
) ws ON u.telegram_id = ws.user_id
|
|
LEFT JOIN (
|
|
SELECT user_id, SUM(score) as total
|
|
FROM sentence_progress
|
|
WHERE completed_at IS NOT NULL
|
|
GROUP BY user_id
|
|
) s ON u.telegram_id = s.user_id
|
|
LEFT JOIN (
|
|
SELECT user_id, total_score as total, words_guessed
|
|
FROM word_quiz_progress
|
|
) wq ON u.telegram_id = wq.user_id
|
|
LEFT JOIN (
|
|
SELECT user_id, total_score as total, levels_completed
|
|
FROM bubble_words_progress
|
|
) bw ON u.telegram_id = bw.user_id
|
|
WHERE COALESCE(ws.total, 0) + COALESCE(s.total, 0) + COALESCE(wq.total, 0) + COALESCE(bw.total, 0) > 0
|
|
ORDER BY total_score DESC
|
|
""")
|
|
|
|
await conn.execute("""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_cache_user
|
|
ON leaderboard_cache (telegram_id)
|
|
""")
|
|
|
|
logger.info("Leaderboard cache created")
|
|
|
|
asyncio.create_task(refresh_leaderboard_cache_loop())
|
|
|
|
logger.info(f"Filwords API v{APP_VERSION} started")
|
|
|
|
# ============ Базовые эндпоинты ============
|
|
@app.get("/")
|
|
async def root():
|
|
return {"status": "ok", "app": "Filwords PL", "version": APP_VERSION}
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
try:
|
|
from database import get_pool
|
|
pool = await get_pool()
|
|
async with pool.acquire() as conn:
|
|
await conn.fetchval("SELECT 1")
|
|
return {"status": "ok", "database": "connected"}
|
|
except Exception as e:
|
|
logger.error(f"Healthcheck failed: {e}")
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={"status": "error", "database": "disconnected"}
|
|
) |