1333 lines
40 KiB
Markdown
1333 lines
40 KiB
Markdown
# 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: <Layout />,
|
||
children: [
|
||
{ index: true, element: <HomePage /> },
|
||
{ path: 'login', element: <LoginPage /> },
|
||
{ path: 'register', element: <RegisterPage /> },
|
||
{ path: 'profile', element: <ProfilePage /> },
|
||
{
|
||
path: 'marathons',
|
||
children: [
|
||
{ index: true, element: <MarathonsListPage /> },
|
||
{ path: 'create', element: <CreateMarathonPage /> },
|
||
{ path: 'join/:code', element: <JoinMarathonPage /> },
|
||
{ path: ':id', element: <MarathonPage /> },
|
||
{ path: ':id/lobby', element: <LobbyPage /> },
|
||
{ path: ':id/play', element: <PlayPage /> },
|
||
{ path: ':id/leaderboard', element: <LeaderboardPage /> },
|
||
{ path: ':id/history', element: <HistoryPage /> },
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]);
|
||
```
|
||
|
||
### Основные компоненты
|
||
|
||
#### 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<void>;
|
||
logout: () => void;
|
||
register: (data: RegisterData) => Promise<void>;
|
||
}
|
||
|
||
export const useAuthStore = create<AuthState>()(
|
||
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<void>;
|
||
spin: () => Promise<SpinResult>;
|
||
completeAssignment: (proof: ProofData) => Promise<CompleteResult>;
|
||
dropAssignment: () => Promise<void>;
|
||
refreshLeaderboard: () => Promise<void>;
|
||
}
|
||
```
|
||
|
||
### 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: [],
|
||
}
|
||
```
|