initial commit (backend)

This commit is contained in:
2026-09-20 11:45:58 +00:00
commit 033adf023e
65 changed files with 28292 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
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"}
)