Files
game-marathon/TECHNICAL_PLAN.md
2025-12-14 02:42:32 +07:00

1333 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: [],
}
```