Files
game-marathon/TECHNICAL_PLAN.md

1333 lines
40 KiB
Markdown
Raw Normal View History

2025-12-14 02:38:35 +07:00
# 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: [],
}
```