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: [],
|
|||
|
|
}
|
|||
|
|
```
|