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

40 KiB
Raw Permalink Blame History

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

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

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

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

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

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

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

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 Текущий пользователь

Схемы

# 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 Таблица лидеров

Схемы

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 Загрузить обложку

Схемы

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} Удалить

Схемы

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 История своих заданий

Логика спина

# 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  # сколько очков потеряет при дропе

Завершение задания

# 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 Лента активности
# 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

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

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

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

Зависимости

{
  "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"
  }
}

Страницы и роуты

// 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

// 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

// src/components/marathon/AssignmentCard.tsx
interface AssignmentCardProps {
  assignment: Assignment;
  onComplete: () => void;
  onDrop: () => void;
}

export function AssignmentCard({ assignment, onComplete, onDrop }: AssignmentCardProps) {
  // Показывает текущее задание
  // Игра, челлендж, описание, очки
  // Кнопки "Выполнено" и "Дроп"
}

ProofUpload

// 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)

// 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' }
  )
);
// 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 клиент

// 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ч. Не забудь покрутить колесо!"

Структура бота

# 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

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

# 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

# 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.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: Ядро функционала

  1. CRUD марафонов
  2. Добавление игр
  3. Интеграция GPT для генерации челленджей
  4. Колесо и спин-логика
  5. Выполнение/дроп заданий
  6. Система очков

Этап 3: UI/UX

  1. Страницы авторизации
  2. Главная и список марафонов
  3. Лобби (подготовка)
  4. Игровой экран (колесо, задание)
  5. Таблица лидеров и лента

Этап 4: Telegram и деплой

  1. Telegram бот
  2. Docker-конфигурация
  3. Деплой на VPS

Конфигурация Nginx

nginx.conf

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

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 (загрузка пруфов)

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

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

{
  "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

/** @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: [],
}