# Game Marathon — Технический план ## Обзор | Компонент | Технология | |-----------|------------| | Frontend | React 18 + TypeScript + Vite + Tailwind CSS | | Backend | FastAPI + SQLAlchemy + PostgreSQL | | AI | OpenAI GPT API (gpt-4o-mini) | | Telegram | aiogram 3.x | | Деплой | Docker + Docker Compose на VPS | ### Ограничения | Параметр | Значение | |----------|----------| | Макс. размер пруфа | 15 МБ | | Домен | Пока нет (локальная разработка) | | HTTPS | Пока не требуется | ### MVP скоуп **Включено:** - Авторизация (логин/пароль, JWT) - Создание и управление марафонами - Добавление игр с ссылками - Генерация челленджей через GPT - Колесо рандома (игра → челлендж) - Выполнение заданий с загрузкой пруфов - Система очков (streak-бонусы, дроп-штрафы) - Таблица лидеров - Лента активности - Telegram-бот с командами **Отложено на будущее:** - События (золотой час и т.д.) - Ставки - Вызовы между участниками - Оспаривание пруфов --- ## Структура проекта ``` game-marathon/ ├── frontend/ # React приложение │ ├── src/ │ │ ├── components/ # UI компоненты │ │ │ ├── ui/ # Базовые (Button, Input, Card...) │ │ │ ├── layout/ # Layout компоненты │ │ │ ├── wheel/ # Колесо │ │ │ └── marathon/ # Компоненты марафона │ │ ├── pages/ # Страницы │ │ ├── hooks/ # React hooks │ │ ├── api/ # API клиент (axios/fetch) │ │ ├── store/ # Zustand store │ │ ├── types/ # TypeScript типы │ │ └── utils/ # Утилиты │ ├── public/ │ ├── index.html │ ├── package.json │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.ts │ ├── backend/ # FastAPI приложение │ ├── app/ │ │ ├── api/ │ │ │ ├── v1/ │ │ │ │ ├── auth.py # Авторизация │ │ │ │ ├── users.py # Пользователи │ │ │ │ ├── marathons.py # Марафоны │ │ │ │ ├── games.py # Игры │ │ │ │ ├── challenges.py # Челленджи │ │ │ │ ├── assignments.py # Задания │ │ │ │ ├── wheel.py # Колесо │ │ │ │ └── feed.py # Лента активности │ │ │ └── deps.py # Зависимости (get_db, get_current_user) │ │ ├── models/ # SQLAlchemy модели │ │ │ ├── user.py │ │ │ ├── marathon.py │ │ │ ├── participant.py │ │ │ ├── game.py │ │ │ ├── challenge.py │ │ │ ├── assignment.py │ │ │ └── activity.py │ │ ├── schemas/ # Pydantic схемы │ │ │ ├── user.py │ │ │ ├── marathon.py │ │ │ ├── game.py │ │ │ ├── challenge.py │ │ │ ├── assignment.py │ │ │ └── common.py │ │ ├── services/ # Бизнес-логика │ │ │ ├── auth.py │ │ │ ├── marathon.py │ │ │ ├── wheel.py │ │ │ ├── points.py │ │ │ ├── gpt.py # Генерация челленджей │ │ │ └── telegram.py # Отправка уведомлений │ │ ├── core/ │ │ │ ├── config.py # Настройки (pydantic-settings) │ │ │ ├── security.py # JWT, хеширование паролей │ │ │ └── database.py # Подключение к БД │ │ └── main.py # Точка входа FastAPI │ ├── alembic/ # Миграции БД │ │ ├── versions/ │ │ └── env.py │ ├── uploads/ # Загруженные файлы (пруфы, обложки) │ ├── alembic.ini │ ├── requirements.txt │ └── Dockerfile │ ├── bot/ # Telegram бот │ ├── handlers/ │ │ ├── start.py # /start, привязка аккаунта │ │ ├── status.py # /status │ │ ├── leaderboard.py # /leaderboard │ │ ├── current.py # /current (текущее задание) │ │ └── help.py # /help │ ├── services/ │ │ ├── api.py # Клиент к backend API │ │ └── notifications.py # Логика уведомлений │ ├── keyboards/ # Inline/Reply клавиатуры │ ├── config.py │ ├── main.py │ ├── requirements.txt │ └── Dockerfile │ ├── docker-compose.yml ├── .env.example ├── CONCEPT.md ├── TECHNICAL_PLAN.md └── README.md ``` --- ## База данных ### ER-диаграмма (упрощённая) ``` ┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ │ User │ │ Participant │ │ Marathon │ ├─────────────┤ ├─────────────────┤ ├─────────────┤ │ id (PK) │──┐ │ id (PK) │ ┌──│ id (PK) │ │ login │ │ │ user_id (FK) │────┘ │ title │ │ password │ └───▶│ marathon_id(FK) │◀──────│ organizer_id│ │ nickname │ │ total_points │ │ status │ │ telegram_id │ │ current_streak │ │ start_date │ │ created_at │ │ drop_count │ │ end_date │ └─────────────┘ └─────────────────┘ └─────────────┘ │ │ 1:N ▼ ┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ │ Game │ │ Assignment │ │ Challenge │ ├─────────────┤ ├─────────────────┤ ├─────────────┤ │ id (PK) │──┐ │ id (PK) │ ┌──│ id (PK) │ │ marathon_id │ │ │ participant_id │ │ │ game_id(FK) │ │ title │ │ │ challenge_id │────┘ │ title │ │ cover_url │ │ │ status │ │ description │ │ download_url│ └───▶│ proof_file │ │ difficulty │ │ added_by │ │ points_earned │ │ points │ │ created_at │ │ started_at │ │ proof_type │ └─────────────┘ │ completed_at │ │ est_time │ └─────────────────┘ └─────────────┘ ┌─────────────────┐ │ Activity │ (лента активности) ├─────────────────┤ │ id (PK) │ │ marathon_id(FK) │ │ user_id (FK) │ │ type │ (spin, complete, drop, join, etc.) │ data (JSON) │ │ created_at │ └─────────────────┘ ``` ### Таблицы SQL #### users ```sql CREATE TABLE users ( id SERIAL PRIMARY KEY, login VARCHAR(50) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, nickname VARCHAR(50) NOT NULL, avatar_url VARCHAR(500), telegram_id BIGINT UNIQUE, telegram_username VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ``` #### marathons ```sql CREATE TABLE marathons ( id SERIAL PRIMARY KEY, title VARCHAR(100) NOT NULL, description TEXT, organizer_id INTEGER REFERENCES users(id) ON DELETE CASCADE, status VARCHAR(20) DEFAULT 'preparing', -- preparing, active, finished invite_code VARCHAR(20) UNIQUE NOT NULL, start_date TIMESTAMP, end_date TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_marathons_status ON marathons(status); CREATE INDEX idx_marathons_invite ON marathons(invite_code); ``` #### participants ```sql CREATE TABLE participants ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, marathon_id INTEGER REFERENCES marathons(id) ON DELETE CASCADE, total_points INTEGER DEFAULT 0, current_streak INTEGER DEFAULT 0, drop_count INTEGER DEFAULT 0, -- счётчик для прогрессивного штрафа joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, marathon_id) ); CREATE INDEX idx_participants_marathon ON participants(marathon_id); CREATE INDEX idx_participants_points ON participants(marathon_id, total_points DESC); ``` #### games ```sql CREATE TABLE games ( id SERIAL PRIMARY KEY, marathon_id INTEGER REFERENCES marathons(id) ON DELETE CASCADE, title VARCHAR(100) NOT NULL, cover_path VARCHAR(500), -- путь к файлу на сервере download_url VARCHAR(500) NOT NULL, genre VARCHAR(50), added_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_games_marathon ON games(marathon_id); ``` #### challenges ```sql CREATE TABLE challenges ( id SERIAL PRIMARY KEY, game_id INTEGER REFERENCES games(id) ON DELETE CASCADE, title VARCHAR(100) NOT NULL, description TEXT NOT NULL, type VARCHAR(30) NOT NULL, -- completion, no_death, speedrun, etc. difficulty VARCHAR(10) NOT NULL, -- easy, medium, hard points INTEGER NOT NULL, estimated_time INTEGER, -- в минутах proof_type VARCHAR(20) NOT NULL, -- screenshot, video, steam proof_hint TEXT, -- подсказка что должно быть на пруфе is_generated BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_challenges_game ON challenges(game_id); ``` #### assignments ```sql CREATE TABLE assignments ( id SERIAL PRIMARY KEY, participant_id INTEGER REFERENCES participants(id) ON DELETE CASCADE, challenge_id INTEGER REFERENCES challenges(id) ON DELETE CASCADE, status VARCHAR(20) DEFAULT 'active', -- active, completed, dropped proof_path VARCHAR(500), -- путь к файлу пруфа proof_url VARCHAR(500), -- или внешняя ссылка proof_comment TEXT, points_earned INTEGER DEFAULT 0, streak_at_completion INTEGER, -- какой был streak при выполнении started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP ); CREATE INDEX idx_assignments_participant ON assignments(participant_id); CREATE INDEX idx_assignments_status ON assignments(participant_id, status); ``` #### activities ```sql CREATE TABLE activities ( id SERIAL PRIMARY KEY, marathon_id INTEGER REFERENCES marathons(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, type VARCHAR(30) NOT NULL, -- spin, complete, drop, join, start_marathon, etc. data JSONB, -- дополнительные данные created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_activities_marathon ON activities(marathon_id, created_at DESC); ``` --- ## API Эндпоинты ### Авторизация | Метод | Путь | Описание | |-------|------|----------| | POST | `/api/v1/auth/register` | Регистрация | | POST | `/api/v1/auth/login` | Вход (возвращает JWT) | | POST | `/api/v1/auth/refresh` | Обновление токена | | GET | `/api/v1/auth/me` | Текущий пользователь | #### Схемы ```python # Register class UserRegister(BaseModel): login: str # 3-50 символов, только a-z, 0-9, _ password: str # минимум 6 символов nickname: str # 2-50 символов # Login class UserLogin(BaseModel): login: str password: str # Response class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" user: UserPublic ``` ### Пользователи | Метод | Путь | Описание | |-------|------|----------| | GET | `/api/v1/users/{id}` | Профиль пользователя | | PATCH | `/api/v1/users/me` | Обновить свой профиль | | POST | `/api/v1/users/me/avatar` | Загрузить аватар | | POST | `/api/v1/users/me/telegram` | Привязать Telegram | ### Марафоны | Метод | Путь | Описание | |-------|------|----------| | GET | `/api/v1/marathons` | Список марафонов пользователя | | POST | `/api/v1/marathons` | Создать марафон | | GET | `/api/v1/marathons/{id}` | Детали марафона | | PATCH | `/api/v1/marathons/{id}` | Обновить (только организатор) | | DELETE | `/api/v1/marathons/{id}` | Удалить (только организатор) | | POST | `/api/v1/marathons/{id}/start` | Запустить марафон | | POST | `/api/v1/marathons/{id}/finish` | Завершить досрочно | | POST | `/api/v1/marathons/join` | Присоединиться по invite_code | | GET | `/api/v1/marathons/{id}/participants` | Список участников | | GET | `/api/v1/marathons/{id}/leaderboard` | Таблица лидеров | #### Схемы ```python class MarathonCreate(BaseModel): title: str description: str | None = None start_date: datetime # когда планируется старт duration_days: int = 30 # длительность в днях class MarathonResponse(BaseModel): id: int title: str description: str | None organizer: UserPublic status: str invite_code: str start_date: datetime | None end_date: datetime | None participants_count: int games_count: int my_participation: ParticipantInfo | None # если текущий юзер участник ``` ### Игры | Метод | Путь | Описание | |-------|------|----------| | GET | `/api/v1/marathons/{id}/games` | Список игр марафона | | POST | `/api/v1/marathons/{id}/games` | Добавить игру | | GET | `/api/v1/games/{id}` | Детали игры | | DELETE | `/api/v1/games/{id}` | Удалить игру | | POST | `/api/v1/games/{id}/cover` | Загрузить обложку | #### Схемы ```python class GameCreate(BaseModel): title: str download_url: str genre: str | None = None cover_url: str | None = None # внешняя ссылка на обложку class GameResponse(BaseModel): id: int title: str cover_url: str | None download_url: str genre: str | None added_by: UserPublic challenges_count: int ``` ### Челленджи | Метод | Путь | Описание | |-------|------|----------| | GET | `/api/v1/games/{id}/challenges` | Челленджи игры | | POST | `/api/v1/games/{id}/challenges` | Добавить вручную | | POST | `/api/v1/marathons/{id}/generate-challenges` | Сгенерировать через GPT | | PATCH | `/api/v1/challenges/{id}` | Редактировать | | DELETE | `/api/v1/challenges/{id}` | Удалить | #### Схемы ```python class ChallengeCreate(BaseModel): title: str description: str type: ChallengeType # enum difficulty: Difficulty # enum: easy, medium, hard points: int estimated_time: int | None # минуты proof_type: ProofType # enum: screenshot, video, steam proof_hint: str | None class ChallengeResponse(BaseModel): id: int game: GameShort title: str description: str type: str difficulty: str points: int estimated_time: int | None proof_type: str proof_hint: str | None is_generated: bool ``` ### Колесо и задания | Метод | Путь | Описание | |-------|------|----------| | POST | `/api/v1/marathons/{id}/spin` | Крутить колесо | | GET | `/api/v1/marathons/{id}/current-assignment` | Текущее активное задание | | POST | `/api/v1/assignments/{id}/complete` | Завершить задание (с пруфом) | | POST | `/api/v1/assignments/{id}/drop` | Дропнуть задание | | GET | `/api/v1/marathons/{id}/my-history` | История своих заданий | #### Логика спина ```python # POST /api/v1/marathons/{id}/spin # 1. Проверить что марафон активен # 2. Проверить что у участника нет активного задания # 3. Выбрать случайную игру из марафона # 4. Выбрать случайный челлендж этой игры # 5. Создать assignment со статусом 'active' # 6. Записать activity (type='spin') # 7. Вернуть результат class SpinResult(BaseModel): assignment_id: int game: GameResponse challenge: ChallengeResponse can_drop: bool drop_penalty: int # сколько очков потеряет при дропе ``` #### Завершение задания ```python # POST /api/v1/assignments/{id}/complete # multipart/form-data с файлом пруфа class CompleteAssignment(BaseModel): proof_url: str | None = None # если внешняя ссылка comment: str | None = None # + файл proof_file (опционально) class CompleteResult(BaseModel): points_earned: int streak_bonus: int total_points: int new_streak: int ``` ### Лента активности | Метод | Путь | Описание | |-------|------|----------| | GET | `/api/v1/marathons/{id}/feed` | Лента активности | ```python # Query params: limit, offset class ActivityResponse(BaseModel): id: int type: str # spin, complete, drop, join user: UserPublic data: dict # зависит от типа created_at: datetime # Примеры data: # type=spin: {"game": "Hollow Knight", "challenge": "Первые шаги"} # type=complete: {"challenge": "...", "points": 85, "streak": 3} # type=drop: {"challenge": "...", "penalty": -25} ``` --- ## Сервисы (бизнес-логика) ### PointsService ```python class PointsService: # Расчёт очков за выполнение def calculate_completion_points( self, base_points: int, current_streak: int ) -> tuple[int, int]: """ Returns: (total_points, streak_bonus) """ streak_multiplier = { 0: 0, 1: 0, 2: 0.1, 3: 0.2, 4: 0.3 }.get(current_streak, 0.4) bonus = int(base_points * streak_multiplier) return base_points + bonus, bonus # Расчёт штрафа за дроп def calculate_drop_penalty(self, consecutive_drops: int) -> int: """ drop_count=0: 0 (первый дроп бесплатный) drop_count=1: -10 drop_count=2: -25 drop_count=3+: -50 """ penalties = {0: 0, 1: 10, 2: 25} return penalties.get(consecutive_drops, 50) ``` ### GPTService ```python class GPTService: async def generate_challenges( self, game_title: str, game_genre: str | None ) -> list[ChallengeGenerated]: """ Генерирует 5-7 челленджей для игры через OpenAI API """ prompt = f""" Для видеоигры "{game_title}" {f'(жанр: {game_genre})' if game_genre else ''} сгенерируй 6 челленджей для игрового марафона. Требования: - 2 лёгких (15-30 минут игры) - 2 средних (1-2 часа игры) - 2 сложных (3+ часов или высокая сложность) Для каждого челленджа укажи: - title: короткое название - description: что нужно сделать - type: один из [completion, no_death, speedrun, collection, achievement, challenge_run] - difficulty: easy/medium/hard - points: очки (easy: 30-50, medium: 60-100, hard: 120-200) - estimated_time: примерное время в минутах - proof_type: screenshot/video/steam (что лучше подойдёт для проверки) - proof_hint: что должно быть на скриншоте/видео Ответ в формате JSON массива. """ response = await openai.chat.completions.create( model="gpt-4o-mini", # Оптимальный баланс цена/качество messages=[{"role": "user", "content": prompt}], response_format={"type": "json_object"}, temperature=0.7 ) # Парсинг и валидация ответа data = json.loads(response.choices[0].message.content) return [ChallengeGenerated(**ch) for ch in data["challenges"]] ``` ### WheelService ```python class WheelService: async def spin( self, db: AsyncSession, marathon_id: int, participant_id: int ) -> Assignment: # 1. Получить все игры марафона games = await self.get_marathon_games(db, marathon_id) # 2. Выбрать случайную игру game = random.choice(games) # 3. Получить челленджи этой игры challenges = await self.get_game_challenges(db, game.id) # 4. Выбрать случайный челлендж challenge = random.choice(challenges) # 5. Создать задание assignment = Assignment( participant_id=participant_id, challenge_id=challenge.id, status='active', started_at=datetime.utcnow() ) db.add(assignment) await db.commit() return assignment ``` --- ## Frontend ### Зависимости ```json { "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.20.0", "axios": "^1.6.0", "zustand": "^4.4.0", "react-hook-form": "^7.48.0", "zod": "^3.22.0", "@hookform/resolvers": "^3.3.0", "framer-motion": "^10.16.0", "date-fns": "^2.30.0", "lucide-react": "^0.294.0", "clsx": "^2.0.0", "tailwind-merge": "^2.0.0" }, "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "typescript": "^5.3.0", "vite": "^5.0.0", "@vitejs/plugin-react": "^4.2.0", "tailwindcss": "^3.3.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0" } } ``` ### Страницы и роуты ```tsx // src/App.tsx const router = createBrowserRouter([ { path: '/', element: , children: [ { index: true, element: }, { path: 'login', element: }, { path: 'register', element: }, { path: 'profile', element: }, { path: 'marathons', children: [ { index: true, element: }, { path: 'create', element: }, { path: 'join/:code', element: }, { path: ':id', element: }, { path: ':id/lobby', element: }, { path: ':id/play', element: }, { path: ':id/leaderboard', element: }, { path: ':id/history', element: }, ] } ] } ]); ``` ### Основные компоненты #### SpinWheel ```tsx // src/components/wheel/SpinWheel.tsx interface SpinWheelProps { items: Array<{ id: number; label: string; color?: string }>; onSpinEnd: (item: typeof items[0]) => void; spinning: boolean; } export function SpinWheel({ items, onSpinEnd, spinning }: SpinWheelProps) { // Анимация через framer-motion // Колесо крутится, пока spinning=true // При остановке вызывается onSpinEnd с выбранным элементом } ``` #### AssignmentCard ```tsx // src/components/marathon/AssignmentCard.tsx interface AssignmentCardProps { assignment: Assignment; onComplete: () => void; onDrop: () => void; } export function AssignmentCard({ assignment, onComplete, onDrop }: AssignmentCardProps) { // Показывает текущее задание // Игра, челлендж, описание, очки // Кнопки "Выполнено" и "Дроп" } ``` #### ProofUpload ```tsx // src/components/marathon/ProofUpload.tsx interface ProofUploadProps { proofType: 'screenshot' | 'video' | 'steam'; proofHint: string; onSubmit: (file: File | null, url: string | null, comment: string) => void; } export function ProofUpload({ proofType, proofHint, onSubmit }: ProofUploadProps) { // Форма загрузки пруфа // Drag-n-drop для файлов // Поле для URL // Комментарий } ``` ### Store (Zustand) ```typescript // src/store/auth.ts interface AuthState { user: User | null; token: string | null; isAuthenticated: boolean; login: (login: string, password: string) => Promise; logout: () => void; register: (data: RegisterData) => Promise; } export const useAuthStore = create()( persist( (set) => ({ user: null, token: null, isAuthenticated: false, login: async (login, password) => { const response = await api.auth.login({ login, password }); set({ user: response.user, token: response.access_token, isAuthenticated: true }); }, logout: () => { set({ user: null, token: null, isAuthenticated: false }); }, register: async (data) => { await api.auth.register(data); // После регистрации — автоматический вход } }), { name: 'auth-storage' } ) ); ``` ```typescript // src/store/marathon.ts interface MarathonState { currentMarathon: Marathon | null; currentAssignment: Assignment | null; leaderboard: Participant[]; loadMarathon: (id: number) => Promise; spin: () => Promise; completeAssignment: (proof: ProofData) => Promise; dropAssignment: () => Promise; refreshLeaderboard: () => Promise; } ``` ### API клиент ```typescript // src/api/client.ts import axios from 'axios'; import { useAuthStore } from '../store/auth'; const client = axios.create({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1', }); // Интерцептор для добавления токена client.interceptors.request.use((config) => { const token = useAuthStore.getState().token; if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // Интерцептор для обработки 401 client.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { useAuthStore.getState().logout(); } return Promise.reject(error); } ); export default client; ``` --- ## Telegram бот ### Команды | Команда | Описание | |---------|----------| | `/start` | Привязка аккаунта | | `/help` | Список команд | | `/status` | Текущий статус в активных марафонах | | `/current` | Текущее задание | | `/leaderboard` | Таблица лидеров | | `/marathons` | Список марафонов | ### Привязка аккаунта ``` Пользователь: /start Бот: Привет! Для привязки аккаунта введи свой логин: Пользователь: mylogin Бот: Отлично! Теперь введи пароль: Пользователь: mypassword Бот: Аккаунт успешно привязан! Теперь ты будешь получать уведомления. Используй /help для списка команд. ``` ### Уведомления | Событие | Уведомление | |---------|-------------| | Марафон начался | "Марафон 'X' начался! Время крутить колесо" | | Кто-то выполнил | "Vasya выполнил 'No-death run' и получил 120 очков!" | | Марафон завершён | "Марафон 'X' завершён! Победитель: Vasya (1247 очков)" | | Напоминание | "У тебя нет активного задания уже 24ч. Не забудь покрутить колесо!" | ### Структура бота ```python # bot/main.py from aiogram import Bot, Dispatcher from aiogram.types import Message from aiogram.filters import Command bot = Bot(token=config.TELEGRAM_TOKEN) dp = Dispatcher() @dp.message(Command("start")) async def cmd_start(message: Message): await message.answer( "Привет! Это бот Game Marathon.\n" "Для привязки аккаунта введи свой логин:" ) # Установить состояние ожидания логина @dp.message(Command("status")) async def cmd_status(message: Message): user = await get_user_by_telegram(message.from_user.id) if not user: await message.answer("Аккаунт не привязан. Используй /start") return marathons = await api.get_user_marathons(user.id) # Форматировать и отправить статус @dp.message(Command("leaderboard")) async def cmd_leaderboard(message: Message): # Показать инлайн-кнопки для выбора марафона # Затем показать таблицу лидеров ``` --- ## Docker ### docker-compose.yml ```yaml version: '3.8' services: db: image: postgres:15-alpine environment: POSTGRES_USER: marathon POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: marathon volumes: - postgres_data:/var/lib/postgresql/data ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U marathon"] interval: 5s timeout: 5s retries: 5 backend: build: ./backend environment: DATABASE_URL: postgresql+asyncpg://marathon:${DB_PASSWORD}@db:5432/marathon SECRET_KEY: ${SECRET_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} volumes: - ./backend/uploads:/app/uploads ports: - "8000:8000" depends_on: db: condition: service_healthy frontend: build: ./frontend ports: - "3000:80" depends_on: - backend bot: build: ./bot environment: TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} API_URL: http://backend:8000/api/v1 depends_on: - backend nginx: image: nginx:alpine ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./certbot/conf:/etc/letsencrypt - ./certbot/www:/var/www/certbot depends_on: - frontend - backend volumes: postgres_data: ``` ### Backend Dockerfile ```dockerfile # backend/Dockerfile FROM python:3.11-slim WORKDIR /app # Зависимости COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Код приложения COPY . . # Создать директорию для загрузок RUN mkdir -p /app/uploads EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ``` ### Frontend Dockerfile ```dockerfile # frontend/Dockerfile FROM node:20-alpine as build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` --- ## Переменные окружения ```env # .env.example # Database DB_PASSWORD=your_secure_password # Backend SECRET_KEY=your_jwt_secret_key_here OPENAI_API_KEY=sk-... # Telegram TELEGRAM_BOT_TOKEN=123456:ABC-DEF... # Frontend (для сборки) VITE_API_URL=https://your-domain.com/api/v1 ``` --- ## Порядок разработки ### Этап 1: Базовая инфраструктура 1. Настройка проекта (frontend + backend + docker) 2. База данных и модели 3. Авторизация (JWT) 4. Базовые API эндпоинты ### Этап 2: Ядро функционала 5. CRUD марафонов 6. Добавление игр 7. Интеграция GPT для генерации челленджей 8. Колесо и спин-логика 9. Выполнение/дроп заданий 10. Система очков ### Этап 3: UI/UX 11. Страницы авторизации 12. Главная и список марафонов 13. Лобби (подготовка) 14. Игровой экран (колесо, задание) 15. Таблица лидеров и лента ### Этап 4: Telegram и деплой 16. Telegram бот 17. Docker-конфигурация 18. Деплой на VPS --- ## Конфигурация Nginx ### nginx.conf ```nginx events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # Лимит загрузки файлов 15 МБ client_max_body_size 15M; upstream backend { server backend:8000; } upstream frontend { server frontend:80; } server { listen 80; server_name localhost; # Frontend location / { proxy_pass http://frontend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Backend API location /api { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Таймаут для загрузки файлов proxy_read_timeout 300; proxy_connect_timeout 300; proxy_send_timeout 300; } # Статические файлы (загруженные пруфы и обложки) location /uploads { alias /app/uploads; expires 30d; add_header Cache-Control "public, immutable"; } } } ``` --- ## Backend: конфигурация загрузки файлов ### app/core/config.py ```python from pydantic_settings import BaseSettings class Settings(BaseSettings): # Database DATABASE_URL: str # Security SECRET_KEY: str ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 дней # OpenAI OPENAI_API_KEY: str # Telegram TELEGRAM_BOT_TOKEN: str # Uploads UPLOAD_DIR: str = "uploads" MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 МБ ALLOWED_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov"} class Config: env_file = ".env" settings = Settings() ``` ### app/api/v1/assignments.py (загрузка пруфов) ```python from fastapi import UploadFile, HTTPException import aiofiles import uuid from pathlib import Path async def upload_proof( file: UploadFile, assignment_id: int, db: AsyncSession, current_user: User ) -> str: # Проверка размера contents = await file.read() if len(contents) > settings.MAX_UPLOAD_SIZE: raise HTTPException(400, f"Файл слишком большой. Максимум {settings.MAX_UPLOAD_SIZE // 1024 // 1024} МБ") # Проверка расширения ext = file.filename.split(".")[-1].lower() if ext not in settings.ALLOWED_EXTENSIONS: raise HTTPException(400, f"Недопустимый формат файла. Разрешены: {settings.ALLOWED_EXTENSIONS}") # Сохранение файла filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename filepath.parent.mkdir(parents=True, exist_ok=True) async with aiofiles.open(filepath, "wb") as f: await f.write(contents) return str(filepath) ``` --- ## Vite конфигурация ### frontend/vite.config.ts ```typescript import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true, }, }, }, build: { outDir: 'dist', sourcemap: false, }, }) ``` ### frontend/tsconfig.json ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ``` ### frontend/tailwind.config.js ```javascript /** @type {import('tailwindcss').Config} */ export default { content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { colors: { primary: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', }, }, animation: { 'spin-slow': 'spin 3s linear infinite', 'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards', }, keyframes: { 'wheel-spin': { '0%': { transform: 'rotate(0deg)' }, '100%': { transform: 'rotate(var(--wheel-rotation))' }, }, }, }, }, plugins: [], } ```