Spaces:
Sleeping
Sleeping
Student Hub
commited on
Commit
·
2f4298a
0
Parent(s):
Initial backend for Hugging Face Spaces
Browse files- DEPLOYMENT.md +189 -0
- Dockerfile +23 -0
- README.md +78 -0
- __init__.py +0 -0
- __pycache__/auth.cpython-311.pyc +0 -0
- __pycache__/config.cpython-311.pyc +0 -0
- __pycache__/database.cpython-311.pyc +0 -0
- __pycache__/main.cpython-311.pyc +0 -0
- __pycache__/models.cpython-311.pyc +0 -0
- __pycache__/schemas.cpython-311.pyc +0 -0
- auth.py +142 -0
- config.py +20 -0
- database.py +23 -0
- game.db +0 -0
- main.py +77 -0
- models.py +105 -0
- requirements.txt +12 -0
- routes/__init__.py +0 -0
- routes/__pycache__/__init__.cpython-311.pyc +0 -0
- routes/__pycache__/admin.cpython-311.pyc +0 -0
- routes/__pycache__/auth_routes.cpython-311.pyc +0 -0
- routes/__pycache__/game.cpython-311.pyc +0 -0
- routes/admin.py +330 -0
- routes/auth_routes.py +67 -0
- routes/game.py +210 -0
- schemas.py +127 -0
- test_api.py +189 -0
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎮 2D Game Backend - Инструкция по деплою на Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
## Шаг 1: Подготовка
|
| 4 |
+
|
| 5 |
+
1. Создайте аккаунт на [Hugging Face](https://huggingface.co)
|
| 6 |
+
2. Перейдите в раздел [Spaces](https://huggingface.co/spaces)
|
| 7 |
+
3. Нажмите **"Create new Space"**
|
| 8 |
+
|
| 9 |
+
## Шаг 2: Настройка Space
|
| 10 |
+
|
| 11 |
+
1. **Space name**: Выберите имя (например, `2d-game-backend`)
|
| 12 |
+
2. **License**: Выберите лицензию (MIT рекомендуется)
|
| 13 |
+
3. **SDK**: Выберите **Docker**
|
| 14 |
+
4. **Space hardware**: Free CPU (достаточно для начала)
|
| 15 |
+
5. Нажмите **"Create Space"**
|
| 16 |
+
|
| 17 |
+
## Шаг 3: Загрузка файлов
|
| 18 |
+
|
| 19 |
+
### Вариант A: Через веб-интерфейс
|
| 20 |
+
|
| 21 |
+
1. Загрузите все файлы из папки `backend/`:
|
| 22 |
+
- `main.py`
|
| 23 |
+
- `models.py`
|
| 24 |
+
- `schemas.py`
|
| 25 |
+
- `database.py`
|
| 26 |
+
- `auth.py`
|
| 27 |
+
- `config.py`
|
| 28 |
+
- `requirements.txt`
|
| 29 |
+
- `Dockerfile`
|
| 30 |
+
- `README.md`
|
| 31 |
+
- Папку `routes/` со всеми файлами
|
| 32 |
+
|
| 33 |
+
2. Убедитесь, что файл `README.md` начинается с frontmatter:
|
| 34 |
+
```yaml
|
| 35 |
+
---
|
| 36 |
+
title: 2D Game Backend
|
| 37 |
+
emoji: 🎮
|
| 38 |
+
colorFrom: blue
|
| 39 |
+
colorTo: purple
|
| 40 |
+
sdk: docker
|
| 41 |
+
pinned: false
|
| 42 |
+
app_port: 7860
|
| 43 |
+
---
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Вариант B: Через Git
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
# Клонируйте ваш Space
|
| 50 |
+
git clone https://huggingface.co/spaces/<your-username>/<space-name>
|
| 51 |
+
cd <space-name>
|
| 52 |
+
|
| 53 |
+
# Скопируйте все файлы из backend/
|
| 54 |
+
cp -r ../backend/* .
|
| 55 |
+
|
| 56 |
+
# Добавьте и закоммитьте
|
| 57 |
+
git add .
|
| 58 |
+
git commit -m "Initial backend deployment"
|
| 59 |
+
git push
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Шаг 4: Настройка переменных окружения (опционально)
|
| 63 |
+
|
| 64 |
+
В настройках Space добавьте секреты:
|
| 65 |
+
|
| 66 |
+
1. Перейдите в **Settings** вашего Space
|
| 67 |
+
2. В разделе **Repository secrets** добавьте:
|
| 68 |
+
- `SECRET_KEY` - случайная строка для JWT
|
| 69 |
+
- `ADMIN_PASSWORD` - пароль администратора (по умолчанию: admin123)
|
| 70 |
+
|
| 71 |
+
## Шаг 5: Запуск
|
| 72 |
+
|
| 73 |
+
Space автоматически запустится после загрузки файлов. Процесс займет 2-5 минут.
|
| 74 |
+
|
| 75 |
+
## Шаг 6: Проверка
|
| 76 |
+
|
| 77 |
+
1. Откройте URL вашего Space: `https://<your-username>-<space-name>.hf.space`
|
| 78 |
+
2. Должна появиться JSON-ответ от API
|
| 79 |
+
3. Перейдите на `/docs` для Swagger документации: `https://<your-username>-<space-name>.hf.space/docs`
|
| 80 |
+
|
| 81 |
+
## Шаг 7: Тестирование API
|
| 82 |
+
|
| 83 |
+
### Регистрация пользователя
|
| 84 |
+
```bash
|
| 85 |
+
curl -X POST "https://<your-space-url>/api/auth/register" \
|
| 86 |
+
-H "Content-Type: application/json" \
|
| 87 |
+
-d '{
|
| 88 |
+
"username": "testuser",
|
| 89 |
+
"email": "test@example.com",
|
| 90 |
+
"password": "password123"
|
| 91 |
+
}'
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Вход администратора
|
| 95 |
+
```bash
|
| 96 |
+
curl -X POST "https://<your-space-url>/api/auth/login" \
|
| 97 |
+
-H "Content-Type: application/x-www-form-urlencoded" \
|
| 98 |
+
-d "username=admin&password=admin123"
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
### Получение статистики (нужен токен)
|
| 102 |
+
```bash
|
| 103 |
+
curl -X GET "https://<your-space-url>/api/game/stats" \
|
| 104 |
+
-H "Authorization: Bearer <your-token>"
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
## Структура проекта
|
| 108 |
+
|
| 109 |
+
```
|
| 110 |
+
backend/
|
| 111 |
+
├── main.py # Главный файл приложения
|
| 112 |
+
├── models.py # Модели базы данных
|
| 113 |
+
├── schemas.py # Pydantic схемы
|
| 114 |
+
├── database.py # Настройка БД
|
| 115 |
+
├── auth.py # Авторизация и JWT
|
| 116 |
+
├── config.py # Конфигурация
|
| 117 |
+
├── requirements.txt # Зависимости Python
|
| 118 |
+
├── Dockerfile # Docker конфигурация
|
| 119 |
+
├── README.md # Документация
|
| 120 |
+
└── routes/
|
| 121 |
+
├── __init__.py
|
| 122 |
+
├── auth_routes.py # Роуты авторизации
|
| 123 |
+
├── game.py # Игровые роуты
|
| 124 |
+
└── admin.py # Админ-панель
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
## Важные замечания
|
| 128 |
+
|
| 129 |
+
1. **База данных**: По умолчанию используется SQLite. Данные сохраняются в `/app/data/game.db`
|
| 130 |
+
2. **Безопасность**: Обязательно измените `SECRET_KEY` и пароль администратора в продакшене
|
| 131 |
+
3. **CORS**: Настроен для всех источников (`*`). Для продакшена укажите конкретные домены
|
| 132 |
+
4. **Логи**: Доступны в интерфейсе Space во вкладке "Logs"
|
| 133 |
+
|
| 134 |
+
## Обновление Space
|
| 135 |
+
|
| 136 |
+
При изменении кода просто загрузите новые файлы или сделайте git push. Space автоматически пересоберется.
|
| 137 |
+
|
| 138 |
+
## Подключение к ��ронтенду
|
| 139 |
+
|
| 140 |
+
В вашем фронтенд-коде используйте URL API:
|
| 141 |
+
|
| 142 |
+
```typescript
|
| 143 |
+
const API_URL = 'https://<your-username>-<space-name>.hf.space';
|
| 144 |
+
|
| 145 |
+
// Пример регистрации
|
| 146 |
+
async function register(username: string, email: string, password: string) {
|
| 147 |
+
const response = await fetch(`${API_URL}/api/auth/register`, {
|
| 148 |
+
method: 'POST',
|
| 149 |
+
headers: { 'Content-Type': 'application/json' },
|
| 150 |
+
body: JSON.stringify({ username, email, password })
|
| 151 |
+
});
|
| 152 |
+
return response.json();
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// Пример получения статистики
|
| 156 |
+
async function getStats(token: string) {
|
| 157 |
+
const response = await fetch(`${API_URL}/api/game/stats`, {
|
| 158 |
+
headers: { 'Authorization': `Bearer ${token}` }
|
| 159 |
+
});
|
| 160 |
+
return response.json();
|
| 161 |
+
}
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
## Мониторинг
|
| 165 |
+
|
| 166 |
+
- **Health check**: `GET /health`
|
| 167 |
+
- **API docs**: `/docs`
|
| 168 |
+
- **ReDoc**: `/redoc`
|
| 169 |
+
|
| 170 |
+
## Troubleshooting
|
| 171 |
+
|
| 172 |
+
### Space не запускается
|
| 173 |
+
1. Проверьте логи в интерфейсе HF
|
| 174 |
+
2. Убедитесь что все файлы загружены
|
| 175 |
+
3. Проверьте Dockerfile на ошибки
|
| 176 |
+
|
| 177 |
+
### База данных не инициализируется
|
| 178 |
+
- Проверьте права на запись в `/app/data`
|
| 179 |
+
- Убедитесь что SQLite установлен (включен в образ)
|
| 180 |
+
|
| 181 |
+
### Ошибки импорта
|
| 182 |
+
- Проверьте `requirements.txt`
|
| 183 |
+
- Убедитесь что все файлы в нужных директориях
|
| 184 |
+
|
| 185 |
+
## Полезные ссылки
|
| 186 |
+
|
| 187 |
+
- [Hugging Face Spaces Documentation](https://huggingface.co/docs/hub/spaces)
|
| 188 |
+
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
| 189 |
+
- [Docker Documentation](https://docs.docker.com/)
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Установка зависимостей
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Копирование кода приложения
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
# Создание директории для базы данных
|
| 13 |
+
RUN mkdir -p /app/data
|
| 14 |
+
|
| 15 |
+
# Переменные окружения
|
| 16 |
+
ENV DATABASE_URL=sqlite:///./data/game.db
|
| 17 |
+
ENV PYTHONUNBUFFERED=1
|
| 18 |
+
|
| 19 |
+
# Экспонирование порта
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
# Команда запуска
|
| 23 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: 2D Game Backend
|
| 3 |
+
emoji: 🎮
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# 2D Game Backend API
|
| 12 |
+
|
| 13 |
+
Backend сервер для 2D игры с полной системой авторизации, управлением игроками и админ-панелью.
|
| 14 |
+
|
| 15 |
+
## Функции
|
| 16 |
+
|
| 17 |
+
### 🔐 Авторизация
|
| 18 |
+
- Регистрация пользователей
|
| 19 |
+
- JWT аутентификация
|
| 20 |
+
- Управление профилями
|
| 21 |
+
|
| 22 |
+
### 🎮 Игровая механика
|
| 23 |
+
- Управление энергией и сытостью
|
| 24 |
+
- Статистика игроков (уровень, опыт, монеты)
|
| 25 |
+
- Трекинг игровых сессий
|
| 26 |
+
- Логирование действий игроков
|
| 27 |
+
|
| 28 |
+
### 👨💼 Админ-панель
|
| 29 |
+
- Общая статистика игры
|
| 30 |
+
- Управление пользователями
|
| 31 |
+
- Топ игроков по различным метрикам
|
| 32 |
+
- Аналитика активности
|
| 33 |
+
- Временные графики
|
| 34 |
+
- Метрики удержания игроков
|
| 35 |
+
- История действий
|
| 36 |
+
|
| 37 |
+
## API Документация
|
| 38 |
+
|
| 39 |
+
После запуска доступна по адресу: `/docs`
|
| 40 |
+
|
| 41 |
+
## Эндпоинты
|
| 42 |
+
|
| 43 |
+
### Авторизация
|
| 44 |
+
- `POST /api/auth/register` - Регистрация
|
| 45 |
+
- `POST /api/auth/login` - Вход
|
| 46 |
+
- `GET /api/auth/me` - Текущий пользователь
|
| 47 |
+
|
| 48 |
+
### Игра
|
| 49 |
+
- `GET /api/game/stats` - Статистика игрока
|
| 50 |
+
- `PATCH /api/game/stats` - Обновление статистики
|
| 51 |
+
- `POST /api/game/stats/consume-energy` - Расход энергии
|
| 52 |
+
- `POST /api/game/stats/restore-energy` - Восстановление энергии
|
| 53 |
+
- `POST /api/game/stats/consume-hunger` - Расход сытости
|
| 54 |
+
- `POST /api/game/stats/restore-hunger` - Восстановление сытости
|
| 55 |
+
- `POST /api/game/session/start` - Начало сессии
|
| 56 |
+
- `POST /api/game/session/end` - Завершение сессии
|
| 57 |
+
|
| 58 |
+
### Админка (требуется admin права)
|
| 59 |
+
- `GET /api/admin/stats/overview` - Общая статистика
|
| 60 |
+
- `GET /api/admin/users` - Список пользователей
|
| 61 |
+
- `GET /api/admin/stats/top-players` - Топ игроков
|
| 62 |
+
- `GET /api/admin/stats/sessions-timeline` - График сессий
|
| 63 |
+
- `GET /api/admin/stats/activity-heatmap` - Тепловая карта активности
|
| 64 |
+
- `GET /api/admin/stats/user-retention` - Удержание пользователей
|
| 65 |
+
- `GET /api/admin/stats/game-metrics` - Игровые метрики
|
| 66 |
+
- `GET /api/admin/actions/recent` - Последние действия
|
| 67 |
+
|
| 68 |
+
## Настройка
|
| 69 |
+
|
| 70 |
+
По умолчанию создается администратор:
|
| 71 |
+
- Username: `admin`
|
| 72 |
+
- Password: `admin123`
|
| 73 |
+
|
| 74 |
+
⚠️ **Важно:** Измените пароль администратора после первого входа!
|
| 75 |
+
|
| 76 |
+
## База данных
|
| 77 |
+
|
| 78 |
+
Использует SQLite для простоты деплоя. Для продакшена можно переключиться на PostgreSQL через переменную окружения `DATABASE_URL`.
|
__init__.py
ADDED
|
File without changes
|
__pycache__/auth.cpython-311.pyc
ADDED
|
Binary file (7.8 kB). View file
|
|
|
__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (1.47 kB). View file
|
|
|
__pycache__/database.cpython-311.pyc
ADDED
|
Binary file (1.14 kB). View file
|
|
|
__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (3.08 kB). View file
|
|
|
__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (6.49 kB). View file
|
|
|
__pycache__/schemas.cpython-311.pyc
ADDED
|
Binary file (7.12 kB). View file
|
|
|
auth.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from jose import JWTError, jwt
|
| 4 |
+
from passlib.context import CryptContext
|
| 5 |
+
from fastapi import Depends, HTTPException, status
|
| 6 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from database import get_db
|
| 9 |
+
from models import User, UserStats
|
| 10 |
+
from schemas import TokenData
|
| 11 |
+
from config import get_settings
|
| 12 |
+
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
|
| 15 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 16 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 20 |
+
"""Проверка пароля"""
|
| 21 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_password_hash(password: str) -> str:
|
| 25 |
+
"""Хеширование пароля"""
|
| 26 |
+
return pwd_context.hash(password)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
| 30 |
+
"""Создание JWT токена"""
|
| 31 |
+
to_encode = data.copy()
|
| 32 |
+
if expires_delta:
|
| 33 |
+
expire = datetime.utcnow() + expires_delta
|
| 34 |
+
else:
|
| 35 |
+
expire = datetime.utcnow() + timedelta(minutes=15)
|
| 36 |
+
to_encode.update({"exp": expire})
|
| 37 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 38 |
+
return encoded_jwt
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def authenticate_user(db: Session, username: str, password: str):
|
| 42 |
+
"""Аутентификация пользователя"""
|
| 43 |
+
user = db.query(User).filter(User.username == username).first()
|
| 44 |
+
if not user:
|
| 45 |
+
return False
|
| 46 |
+
if not verify_password(password, user.hashed_password):
|
| 47 |
+
return False
|
| 48 |
+
return user
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def get_current_user(
|
| 52 |
+
token: str = Depends(oauth2_scheme),
|
| 53 |
+
db: Session = Depends(get_db)
|
| 54 |
+
) -> User:
|
| 55 |
+
"""Получение текущего пользователя из токена"""
|
| 56 |
+
credentials_exception = HTTPException(
|
| 57 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 58 |
+
detail="Could not validate credentials",
|
| 59 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 60 |
+
)
|
| 61 |
+
try:
|
| 62 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
| 63 |
+
username: str = payload.get("sub")
|
| 64 |
+
if username is None:
|
| 65 |
+
raise credentials_exception
|
| 66 |
+
token_data = TokenData(username=username)
|
| 67 |
+
except JWTError:
|
| 68 |
+
raise credentials_exception
|
| 69 |
+
|
| 70 |
+
user = db.query(User).filter(User.username == token_data.username).first()
|
| 71 |
+
if user is None:
|
| 72 |
+
raise credentials_exception
|
| 73 |
+
return user
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def get_current_active_user(
|
| 77 |
+
current_user: User = Depends(get_current_user)
|
| 78 |
+
) -> User:
|
| 79 |
+
"""Проверка активности пользователя"""
|
| 80 |
+
if not current_user.is_active:
|
| 81 |
+
raise HTTPException(status_code=400, detail="Inactive user")
|
| 82 |
+
return current_user
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def get_current_admin_user(
|
| 86 |
+
current_user: User = Depends(get_current_active_user)
|
| 87 |
+
) -> User:
|
| 88 |
+
"""Проверка прав администратора"""
|
| 89 |
+
if not current_user.is_admin:
|
| 90 |
+
raise HTTPException(
|
| 91 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 92 |
+
detail="Not enough permissions"
|
| 93 |
+
)
|
| 94 |
+
return current_user
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def create_user(db: Session, username: str, email: str, password: str, is_admin: bool = False) -> User:
|
| 98 |
+
"""Создание нового пользователя"""
|
| 99 |
+
# Проверка существования пользователя
|
| 100 |
+
if db.query(User).filter(User.username == username).first():
|
| 101 |
+
raise HTTPException(
|
| 102 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 103 |
+
detail="Username already registered"
|
| 104 |
+
)
|
| 105 |
+
if db.query(User).filter(User.email == email).first():
|
| 106 |
+
raise HTTPException(
|
| 107 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 108 |
+
detail="Email already registered"
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Создание пользователя
|
| 112 |
+
hashed_password = get_password_hash(password)
|
| 113 |
+
user = User(
|
| 114 |
+
username=username,
|
| 115 |
+
email=email,
|
| 116 |
+
hashed_password=hashed_password,
|
| 117 |
+
is_admin=is_admin
|
| 118 |
+
)
|
| 119 |
+
db.add(user)
|
| 120 |
+
db.commit()
|
| 121 |
+
db.refresh(user)
|
| 122 |
+
|
| 123 |
+
# Создание статистики пользователя
|
| 124 |
+
user_stats = UserStats(user_id=user.id)
|
| 125 |
+
db.add(user_stats)
|
| 126 |
+
db.commit()
|
| 127 |
+
|
| 128 |
+
return user
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def init_admin_user(db: Session):
|
| 132 |
+
"""Инициализация администратора при первом запуске"""
|
| 133 |
+
admin = db.query(User).filter(User.username == settings.ADMIN_USERNAME).first()
|
| 134 |
+
if not admin:
|
| 135 |
+
create_user(
|
| 136 |
+
db=db,
|
| 137 |
+
username=settings.ADMIN_USERNAME,
|
| 138 |
+
email=settings.ADMIN_EMAIL,
|
| 139 |
+
password=settings.ADMIN_PASSWORD,
|
| 140 |
+
is_admin=True
|
| 141 |
+
)
|
| 142 |
+
print(f"Admin user created: {settings.ADMIN_USERNAME}")
|
config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
DATABASE_URL: str = "sqlite:///./game.db"
|
| 7 |
+
SECRET_KEY: str = "your-secret-key-change-in-production"
|
| 8 |
+
ALGORITHM: str = "HS256"
|
| 9 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
| 10 |
+
ADMIN_USERNAME: str = "admin"
|
| 11 |
+
ADMIN_PASSWORD: str = "admin123"
|
| 12 |
+
ADMIN_EMAIL: str = "admin@example.com"
|
| 13 |
+
|
| 14 |
+
class Config:
|
| 15 |
+
env_file = ".env"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@lru_cache()
|
| 19 |
+
def get_settings():
|
| 20 |
+
return Settings()
|
database.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
from config import get_settings
|
| 5 |
+
|
| 6 |
+
settings = get_settings()
|
| 7 |
+
|
| 8 |
+
engine = create_engine(
|
| 9 |
+
settings.DATABASE_URL,
|
| 10 |
+
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 14 |
+
|
| 15 |
+
Base = declarative_base()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_db():
|
| 19 |
+
db = SessionLocal()
|
| 20 |
+
try:
|
| 21 |
+
yield db
|
| 22 |
+
finally:
|
| 23 |
+
db.close()
|
game.db
ADDED
|
Binary file (57.3 kB). View file
|
|
|
main.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from database import engine, get_db
|
| 5 |
+
from models import Base
|
| 6 |
+
from routes import auth_routes, game, admin
|
| 7 |
+
from auth import init_admin_user
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@asynccontextmanager
|
| 11 |
+
async def lifespan(app: FastAPI):
|
| 12 |
+
"""Lifecycle events"""
|
| 13 |
+
# Startup
|
| 14 |
+
Base.metadata.create_all(bind=engine)
|
| 15 |
+
|
| 16 |
+
# Инициализация администратора
|
| 17 |
+
db = next(get_db())
|
| 18 |
+
try:
|
| 19 |
+
init_admin_user(db)
|
| 20 |
+
finally:
|
| 21 |
+
db.close()
|
| 22 |
+
|
| 23 |
+
print("✅ Database initialized")
|
| 24 |
+
print("✅ Admin user initialized")
|
| 25 |
+
|
| 26 |
+
yield
|
| 27 |
+
|
| 28 |
+
# Shutdown
|
| 29 |
+
print("👋 Shutting down...")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
app = FastAPI(
|
| 33 |
+
title="2D Game API",
|
| 34 |
+
description="Backend API для 2D игры с авторизацией и статистикой",
|
| 35 |
+
version="1.0.0",
|
| 36 |
+
lifespan=lifespan
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# CORS настройки
|
| 40 |
+
app.add_middleware(
|
| 41 |
+
CORSMiddleware,
|
| 42 |
+
allow_origins=["*"], # В продакшене укажите конкретные домены
|
| 43 |
+
allow_credentials=True,
|
| 44 |
+
allow_methods=["*"],
|
| 45 |
+
allow_headers=["*"],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Подключение роутеров
|
| 49 |
+
app.include_router(auth_routes.router)
|
| 50 |
+
app.include_router(game.router)
|
| 51 |
+
app.include_router(admin.router)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@app.get("/")
|
| 55 |
+
async def root():
|
| 56 |
+
"""Корневой эндпоинт"""
|
| 57 |
+
return {
|
| 58 |
+
"message": "2D Game API",
|
| 59 |
+
"version": "1.0.0",
|
| 60 |
+
"docs": "/docs",
|
| 61 |
+
"endpoints": {
|
| 62 |
+
"auth": "/api/auth",
|
| 63 |
+
"game": "/api/game",
|
| 64 |
+
"admin": "/api/admin"
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.get("/health")
|
| 70 |
+
async def health_check():
|
| 71 |
+
"""Проверка работоспособности"""
|
| 72 |
+
return {"status": "healthy"}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
import uvicorn
|
| 77 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
models.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from sqlalchemy.sql import func
|
| 4 |
+
from database import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class User(Base):
|
| 8 |
+
__tablename__ = "users"
|
| 9 |
+
|
| 10 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 11 |
+
username = Column(String(50), unique=True, index=True, nullable=False)
|
| 12 |
+
email = Column(String(100), unique=True, index=True, nullable=False)
|
| 13 |
+
hashed_password = Column(String(255), nullable=False)
|
| 14 |
+
is_active = Column(Boolean, default=True)
|
| 15 |
+
is_admin = Column(Boolean, default=False)
|
| 16 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 17 |
+
last_login = Column(DateTime(timezone=True), onupdate=func.now())
|
| 18 |
+
|
| 19 |
+
# Relationships
|
| 20 |
+
stats = relationship("UserStats", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
| 21 |
+
sessions = relationship("GameSession", back_populates="user", cascade="all, delete-orphan")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class UserStats(Base):
|
| 25 |
+
__tablename__ = "user_stats"
|
| 26 |
+
|
| 27 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 28 |
+
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
| 29 |
+
|
| 30 |
+
# Игровые параметры
|
| 31 |
+
energy = Column(Float, default=100.0)
|
| 32 |
+
max_energy = Column(Float, default=100.0)
|
| 33 |
+
hunger = Column(Float, default=100.0) # сытость
|
| 34 |
+
max_hunger = Column(Float, default=100.0)
|
| 35 |
+
|
| 36 |
+
# Статистика
|
| 37 |
+
total_playtime = Column(Integer, default=0) # в секундах
|
| 38 |
+
level = Column(Integer, default=1)
|
| 39 |
+
experience = Column(Integer, default=0)
|
| 40 |
+
coins = Column(Integer, default=0)
|
| 41 |
+
|
| 42 |
+
# Игровые достижения
|
| 43 |
+
rooms_visited = Column(Integer, default=0)
|
| 44 |
+
items_collected = Column(Integer, default=0)
|
| 45 |
+
enemies_defeated = Column(Integer, default=0)
|
| 46 |
+
deaths = Column(Integer, default=0)
|
| 47 |
+
|
| 48 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
| 49 |
+
|
| 50 |
+
# Relationship
|
| 51 |
+
user = relationship("User", back_populates="stats")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class GameSession(Base):
|
| 55 |
+
__tablename__ = "game_sessions"
|
| 56 |
+
|
| 57 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 58 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 59 |
+
|
| 60 |
+
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 61 |
+
ended_at = Column(DateTime(timezone=True), nullable=True)
|
| 62 |
+
duration = Column(Integer, default=0) # в секундах
|
| 63 |
+
|
| 64 |
+
# Статистика сессии
|
| 65 |
+
energy_consumed = Column(Float, default=0.0)
|
| 66 |
+
hunger_consumed = Column(Float, default=0.0)
|
| 67 |
+
rooms_visited_session = Column(Integer, default=0)
|
| 68 |
+
items_collected_session = Column(Integer, default=0)
|
| 69 |
+
enemies_defeated_session = Column(Integer, default=0)
|
| 70 |
+
deaths_session = Column(Integer, default=0)
|
| 71 |
+
|
| 72 |
+
# Relationship
|
| 73 |
+
user = relationship("User", back_populates="sessions")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class GameAction(Base):
|
| 77 |
+
"""Лог действий пользователя для детальной аналитики"""
|
| 78 |
+
__tablename__ = "game_actions"
|
| 79 |
+
|
| 80 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 81 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 82 |
+
action_type = Column(String(50), nullable=False) # move, attack, collect, eat, rest
|
| 83 |
+
action_data = Column(Text, nullable=True) # JSON данные
|
| 84 |
+
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class SystemStats(Base):
|
| 88 |
+
"""Системная статистика для админки"""
|
| 89 |
+
__tablename__ = "system_stats"
|
| 90 |
+
|
| 91 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 92 |
+
date = Column(DateTime(timezone=True), server_default=func.now())
|
| 93 |
+
|
| 94 |
+
# Общие показатели
|
| 95 |
+
total_users = Column(Integer, default=0)
|
| 96 |
+
active_users_today = Column(Integer, default=0)
|
| 97 |
+
new_users_today = Column(Integer, default=0)
|
| 98 |
+
total_sessions = Column(Integer, default=0)
|
| 99 |
+
avg_session_duration = Column(Float, default=0.0)
|
| 100 |
+
|
| 101 |
+
# Игровая статистика
|
| 102 |
+
total_playtime = Column(Integer, default=0)
|
| 103 |
+
total_rooms_visited = Column(Integer, default=0)
|
| 104 |
+
total_items_collected = Column(Integer, default=0)
|
| 105 |
+
total_enemies_defeated = Column(Integer, default=0)
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn[standard]==0.24.0
|
| 3 |
+
sqlalchemy==2.0.23
|
| 4 |
+
psycopg2-binary==2.9.9
|
| 5 |
+
python-jose[cryptography]==3.3.0
|
| 6 |
+
passlib[bcrypt]==1.7.4
|
| 7 |
+
python-multipart==0.0.6
|
| 8 |
+
pydantic==2.5.0
|
| 9 |
+
pydantic-settings==2.1.0
|
| 10 |
+
python-dotenv==1.0.0
|
| 11 |
+
alembic==1.12.1
|
| 12 |
+
email-validator==2.1.0
|
routes/__init__.py
ADDED
|
File without changes
|
routes/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (184 Bytes). View file
|
|
|
routes/__pycache__/admin.cpython-311.pyc
ADDED
|
Binary file (19.3 kB). View file
|
|
|
routes/__pycache__/auth_routes.cpython-311.pyc
ADDED
|
Binary file (3.72 kB). View file
|
|
|
routes/__pycache__/game.cpython-311.pyc
ADDED
|
Binary file (11.3 kB). View file
|
|
|
routes/admin.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from sqlalchemy import func, and_, desc
|
| 4 |
+
from typing import List
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from database import get_db
|
| 7 |
+
from models import User, UserStats, GameSession, GameAction, SystemStats
|
| 8 |
+
from schemas import AdminStats, UserListItem, UserDetail, UserStatsResponse
|
| 9 |
+
from auth import get_current_admin_user
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/stats/overview", response_model=AdminStats)
|
| 15 |
+
async def get_admin_stats(
|
| 16 |
+
current_user: User = Depends(get_current_admin_user),
|
| 17 |
+
db: Session = Depends(get_db)
|
| 18 |
+
):
|
| 19 |
+
"""Общая статистика для админ-панели"""
|
| 20 |
+
|
| 21 |
+
# Общее количество пользователей
|
| 22 |
+
total_users = db.query(func.count(User.id)).scalar()
|
| 23 |
+
|
| 24 |
+
# Активные пользователи сегодня (имели сессию за последние 24 часа)
|
| 25 |
+
today = datetime.utcnow() - timedelta(days=1)
|
| 26 |
+
active_users_today = db.query(func.count(func.distinct(GameSession.user_id))).filter(
|
| 27 |
+
GameSession.started_at >= today
|
| 28 |
+
).scalar()
|
| 29 |
+
|
| 30 |
+
# Новые пользователи сегодня
|
| 31 |
+
new_users_today = db.query(func.count(User.id)).filter(
|
| 32 |
+
User.created_at >= today
|
| 33 |
+
).scalar()
|
| 34 |
+
|
| 35 |
+
# Всего сессий
|
| 36 |
+
total_sessions = db.query(func.count(GameSession.id)).scalar()
|
| 37 |
+
|
| 38 |
+
# Средняя длительность сессии
|
| 39 |
+
avg_duration = db.query(func.avg(GameSession.duration)).filter(
|
| 40 |
+
GameSession.ended_at != None
|
| 41 |
+
).scalar() or 0
|
| 42 |
+
|
| 43 |
+
# Общее время игры
|
| 44 |
+
total_playtime = db.query(func.sum(UserStats.total_playtime)).scalar() or 0
|
| 45 |
+
|
| 46 |
+
# Общая статистика по комнатам
|
| 47 |
+
total_rooms = db.query(func.sum(UserStats.rooms_visited)).scalar() or 0
|
| 48 |
+
|
| 49 |
+
# Общая статистика по предметам
|
| 50 |
+
total_items = db.query(func.sum(UserStats.items_collected)).scalar() or 0
|
| 51 |
+
|
| 52 |
+
# Общая статистика по врагам
|
| 53 |
+
total_enemies = db.query(func.sum(UserStats.enemies_defeated)).scalar() or 0
|
| 54 |
+
|
| 55 |
+
return AdminStats(
|
| 56 |
+
total_users=total_users,
|
| 57 |
+
active_users_today=active_users_today or 0,
|
| 58 |
+
new_users_today=new_users_today or 0,
|
| 59 |
+
total_sessions=total_sessions or 0,
|
| 60 |
+
avg_session_duration=float(avg_duration),
|
| 61 |
+
total_playtime=total_playtime,
|
| 62 |
+
total_rooms_visited=total_rooms,
|
| 63 |
+
total_items_collected=total_items,
|
| 64 |
+
total_enemies_defeated=total_enemies
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.get("/users", response_model=List[UserListItem])
|
| 69 |
+
async def get_all_users(
|
| 70 |
+
skip: int = 0,
|
| 71 |
+
limit: int = 100,
|
| 72 |
+
current_user: User = Depends(get_current_admin_user),
|
| 73 |
+
db: Session = Depends(get_db)
|
| 74 |
+
):
|
| 75 |
+
"""Список всех пользователей"""
|
| 76 |
+
users = db.query(User).offset(skip).limit(limit).all()
|
| 77 |
+
return users
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.get("/users/{user_id}", response_model=UserDetail)
|
| 81 |
+
async def get_user_detail(
|
| 82 |
+
user_id: int,
|
| 83 |
+
current_user: User = Depends(get_current_admin_user),
|
| 84 |
+
db: Session = Depends(get_db)
|
| 85 |
+
):
|
| 86 |
+
"""Детальная информация о пользователе"""
|
| 87 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 88 |
+
if not user:
|
| 89 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 90 |
+
|
| 91 |
+
return user
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@router.patch("/users/{user_id}/toggle-active")
|
| 95 |
+
async def toggle_user_active(
|
| 96 |
+
user_id: int,
|
| 97 |
+
current_user: User = Depends(get_current_admin_user),
|
| 98 |
+
db: Session = Depends(get_db)
|
| 99 |
+
):
|
| 100 |
+
"""Активация/деактивация пользователя"""
|
| 101 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 102 |
+
if not user:
|
| 103 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 104 |
+
|
| 105 |
+
user.is_active = not user.is_active
|
| 106 |
+
db.commit()
|
| 107 |
+
|
| 108 |
+
return {"message": f"User {'activated' if user.is_active else 'deactivated'}", "is_active": user.is_active}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.patch("/users/{user_id}/toggle-admin")
|
| 112 |
+
async def toggle_user_admin(
|
| 113 |
+
user_id: int,
|
| 114 |
+
current_user: User = Depends(get_current_admin_user),
|
| 115 |
+
db: Session = Depends(get_db)
|
| 116 |
+
):
|
| 117 |
+
"""Назначение/снятие прав администратора"""
|
| 118 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 119 |
+
if not user:
|
| 120 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 121 |
+
|
| 122 |
+
if user.id == current_user.id:
|
| 123 |
+
raise HTTPException(status_code=400, detail="Cannot modify your own admin status")
|
| 124 |
+
|
| 125 |
+
user.is_admin = not user.is_admin
|
| 126 |
+
db.commit()
|
| 127 |
+
|
| 128 |
+
return {"message": f"Admin rights {'granted' if user.is_admin else 'revoked'}", "is_admin": user.is_admin}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@router.delete("/users/{user_id}")
|
| 132 |
+
async def delete_user(
|
| 133 |
+
user_id: int,
|
| 134 |
+
current_user: User = Depends(get_current_admin_user),
|
| 135 |
+
db: Session = Depends(get_db)
|
| 136 |
+
):
|
| 137 |
+
"""Удаление пользовате��я"""
|
| 138 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 139 |
+
if not user:
|
| 140 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 141 |
+
|
| 142 |
+
if user.id == current_user.id:
|
| 143 |
+
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
| 144 |
+
|
| 145 |
+
db.delete(user)
|
| 146 |
+
db.commit()
|
| 147 |
+
|
| 148 |
+
return {"message": "User deleted successfully"}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.get("/stats/top-players")
|
| 152 |
+
async def get_top_players(
|
| 153 |
+
metric: str = "level",
|
| 154 |
+
limit: int = 10,
|
| 155 |
+
current_user: User = Depends(get_current_admin_user),
|
| 156 |
+
db: Session = Depends(get_db)
|
| 157 |
+
):
|
| 158 |
+
"""Топ игроков по различным метрикам"""
|
| 159 |
+
|
| 160 |
+
valid_metrics = ["level", "experience", "total_playtime", "enemies_defeated", "items_collected", "coins"]
|
| 161 |
+
if metric not in valid_metrics:
|
| 162 |
+
raise HTTPException(status_code=400, detail=f"Invalid metric. Choose from: {valid_metrics}")
|
| 163 |
+
|
| 164 |
+
# Получаем топ игроков
|
| 165 |
+
query = db.query(User, UserStats).join(UserStats).order_by(desc(getattr(UserStats, metric))).limit(limit)
|
| 166 |
+
|
| 167 |
+
results = []
|
| 168 |
+
for user, stats in query:
|
| 169 |
+
results.append({
|
| 170 |
+
"user_id": user.id,
|
| 171 |
+
"username": user.username,
|
| 172 |
+
"metric_value": getattr(stats, metric),
|
| 173 |
+
"level": stats.level,
|
| 174 |
+
"experience": stats.experience
|
| 175 |
+
})
|
| 176 |
+
|
| 177 |
+
return {"metric": metric, "top_players": results}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.get("/stats/sessions-timeline")
|
| 181 |
+
async def get_sessions_timeline(
|
| 182 |
+
days: int = 7,
|
| 183 |
+
current_user: User = Depends(get_current_admin_user),
|
| 184 |
+
db: Session = Depends(get_db)
|
| 185 |
+
):
|
| 186 |
+
"""Временная шкала сессий за последние N дней"""
|
| 187 |
+
|
| 188 |
+
start_date = datetime.utcnow() - timedelta(days=days)
|
| 189 |
+
|
| 190 |
+
# Группировка по дням
|
| 191 |
+
sessions_by_day = db.query(
|
| 192 |
+
func.date(GameSession.started_at).label('date'),
|
| 193 |
+
func.count(GameSession.id).label('count'),
|
| 194 |
+
func.avg(GameSession.duration).label('avg_duration')
|
| 195 |
+
).filter(
|
| 196 |
+
GameSession.started_at >= start_date
|
| 197 |
+
).group_by(
|
| 198 |
+
func.date(GameSession.started_at)
|
| 199 |
+
).all()
|
| 200 |
+
|
| 201 |
+
timeline = []
|
| 202 |
+
for day in sessions_by_day:
|
| 203 |
+
timeline.append({
|
| 204 |
+
"date": str(day.date),
|
| 205 |
+
"sessions_count": day.count,
|
| 206 |
+
"avg_duration": float(day.avg_duration) if day.avg_duration else 0
|
| 207 |
+
})
|
| 208 |
+
|
| 209 |
+
return {"days": days, "timeline": timeline}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@router.get("/stats/activity-heatmap")
|
| 213 |
+
async def get_activity_heatmap(
|
| 214 |
+
current_user: User = Depends(get_current_admin_user),
|
| 215 |
+
db: Session = Depends(get_db)
|
| 216 |
+
):
|
| 217 |
+
"""Тепловая карта активности игроков по часам"""
|
| 218 |
+
|
| 219 |
+
# Группировка по часам дня
|
| 220 |
+
activity = db.query(
|
| 221 |
+
func.extract('hour', GameSession.started_at).label('hour'),
|
| 222 |
+
func.count(GameSession.id).label('count')
|
| 223 |
+
).group_by(
|
| 224 |
+
func.extract('hour', GameSession.started_at)
|
| 225 |
+
).all()
|
| 226 |
+
|
| 227 |
+
heatmap = {int(hour): count for hour, count in activity}
|
| 228 |
+
|
| 229 |
+
# Заполняем отсутствующие часы нулями
|
| 230 |
+
full_heatmap = {hour: heatmap.get(hour, 0) for hour in range(24)}
|
| 231 |
+
|
| 232 |
+
return {"heatmap": full_heatmap}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@router.get("/stats/user-retention")
|
| 236 |
+
async def get_user_retention(
|
| 237 |
+
current_user: User = Depends(get_current_admin_user),
|
| 238 |
+
db: Session = Depends(get_db)
|
| 239 |
+
):
|
| 240 |
+
"""Статистика удержания пользователей"""
|
| 241 |
+
|
| 242 |
+
# Пользователи, зарегистрированные в последние 30 дней
|
| 243 |
+
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
| 244 |
+
new_users = db.query(User).filter(User.created_at >= thirty_days_ago).all()
|
| 245 |
+
|
| 246 |
+
# Из них активных в последние 7 дней
|
| 247 |
+
seven_days_ago = datetime.utcnow() - timedelta(days=7)
|
| 248 |
+
active_new_users = 0
|
| 249 |
+
|
| 250 |
+
for user in new_users:
|
| 251 |
+
has_recent_session = db.query(GameSession).filter(
|
| 252 |
+
GameSession.user_id == user.id,
|
| 253 |
+
GameSession.started_at >= seven_days_ago
|
| 254 |
+
).first()
|
| 255 |
+
if has_recent_session:
|
| 256 |
+
active_new_users += 1
|
| 257 |
+
|
| 258 |
+
retention_rate = (active_new_users / len(new_users) * 100) if new_users else 0
|
| 259 |
+
|
| 260 |
+
return {
|
| 261 |
+
"new_users_30d": len(new_users),
|
| 262 |
+
"active_users_7d": active_new_users,
|
| 263 |
+
"retention_rate": round(retention_rate, 2)
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@router.get("/stats/game-metrics")
|
| 268 |
+
async def get_game_metrics(
|
| 269 |
+
current_user: User = Depends(get_current_admin_user),
|
| 270 |
+
db: Session = Depends(get_db)
|
| 271 |
+
):
|
| 272 |
+
"""Метрики игрового процесса"""
|
| 273 |
+
|
| 274 |
+
# Средние показатели
|
| 275 |
+
avg_stats = db.query(
|
| 276 |
+
func.avg(UserStats.level).label('avg_level'),
|
| 277 |
+
func.avg(UserStats.energy).label('avg_energy'),
|
| 278 |
+
func.avg(UserStats.hunger).label('avg_hunger'),
|
| 279 |
+
func.avg(UserStats.coins).label('avg_coins'),
|
| 280 |
+
func.avg(UserStats.deaths).label('avg_deaths')
|
| 281 |
+
).first()
|
| 282 |
+
|
| 283 |
+
# Общие показатели
|
| 284 |
+
total_stats = db.query(
|
| 285 |
+
func.sum(UserStats.rooms_visited).label('total_rooms'),
|
| 286 |
+
func.sum(UserStats.items_collected).label('total_items'),
|
| 287 |
+
func.sum(UserStats.enemies_defeated).label('total_enemies'),
|
| 288 |
+
func.sum(UserStats.deaths).label('total_deaths')
|
| 289 |
+
).first()
|
| 290 |
+
|
| 291 |
+
return {
|
| 292 |
+
"averages": {
|
| 293 |
+
"level": float(avg_stats.avg_level) if avg_stats.avg_level else 0,
|
| 294 |
+
"energy": float(avg_stats.avg_energy) if avg_stats.avg_energy else 0,
|
| 295 |
+
"hunger": float(avg_stats.avg_hunger) if avg_stats.avg_hunger else 0,
|
| 296 |
+
"coins": float(avg_stats.avg_coins) if avg_stats.avg_coins else 0,
|
| 297 |
+
"deaths": float(avg_stats.avg_deaths) if avg_stats.avg_deaths else 0
|
| 298 |
+
},
|
| 299 |
+
"totals": {
|
| 300 |
+
"rooms_visited": total_stats.total_rooms or 0,
|
| 301 |
+
"items_collected": total_stats.total_items or 0,
|
| 302 |
+
"enemies_defeated": total_stats.total_enemies or 0,
|
| 303 |
+
"deaths": total_stats.total_deaths or 0
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
@router.get("/actions/recent")
|
| 309 |
+
async def get_recent_actions(
|
| 310 |
+
limit: int = 50,
|
| 311 |
+
current_user: User = Depends(get_current_admin_user),
|
| 312 |
+
db: Session = Depends(get_db)
|
| 313 |
+
):
|
| 314 |
+
"""Последние действия игроков"""
|
| 315 |
+
|
| 316 |
+
actions = db.query(GameAction, User).join(User).order_by(
|
| 317 |
+
desc(GameAction.timestamp)
|
| 318 |
+
).limit(limit).all()
|
| 319 |
+
|
| 320 |
+
results = []
|
| 321 |
+
for action, user in actions:
|
| 322 |
+
results.append({
|
| 323 |
+
"id": action.id,
|
| 324 |
+
"username": user.username,
|
| 325 |
+
"action_type": action.action_type,
|
| 326 |
+
"action_data": action.action_data,
|
| 327 |
+
"timestamp": action.timestamp
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
return {"recent_actions": results}
|
routes/auth_routes.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import timedelta
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from database import get_db
|
| 6 |
+
from schemas import UserCreate, UserResponse, Token
|
| 7 |
+
from auth import (
|
| 8 |
+
authenticate_user,
|
| 9 |
+
create_access_token,
|
| 10 |
+
create_user,
|
| 11 |
+
get_current_active_user
|
| 12 |
+
)
|
| 13 |
+
from config import get_settings
|
| 14 |
+
from models import User
|
| 15 |
+
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
| 21 |
+
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
| 22 |
+
"""Регистрация нового пользователя"""
|
| 23 |
+
user = create_user(
|
| 24 |
+
db=db,
|
| 25 |
+
username=user_data.username,
|
| 26 |
+
email=user_data.email,
|
| 27 |
+
password=user_data.password
|
| 28 |
+
)
|
| 29 |
+
return user
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/login", response_model=Token)
|
| 33 |
+
async def login(
|
| 34 |
+
form_data: OAuth2PasswordRequestForm = Depends(),
|
| 35 |
+
db: Session = Depends(get_db)
|
| 36 |
+
):
|
| 37 |
+
"""Вход в систему"""
|
| 38 |
+
user = authenticate_user(db, form_data.username, form_data.password)
|
| 39 |
+
if not user:
|
| 40 |
+
raise HTTPException(
|
| 41 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 42 |
+
detail="Incorrect username or password",
|
| 43 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Обновление времени последнего входа
|
| 47 |
+
from datetime import datetime
|
| 48 |
+
user.last_login = datetime.utcnow()
|
| 49 |
+
db.commit()
|
| 50 |
+
|
| 51 |
+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 52 |
+
access_token = create_access_token(
|
| 53 |
+
data={"sub": user.username}, expires_delta=access_token_expires
|
| 54 |
+
)
|
| 55 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.get("/me", response_model=UserResponse)
|
| 59 |
+
async def read_users_me(current_user: User = Depends(get_current_active_user)):
|
| 60 |
+
"""Получение информации о текущем пользователе"""
|
| 61 |
+
return current_user
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.post("/logout")
|
| 65 |
+
async def logout(current_user: User = Depends(get_current_active_user)):
|
| 66 |
+
"""Выход из системы"""
|
| 67 |
+
return {"message": "Successfully logged out"}
|
routes/game.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from sqlalchemy import func
|
| 4 |
+
from typing import List
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from database import get_db
|
| 7 |
+
from models import User, UserStats, GameSession, GameAction
|
| 8 |
+
from schemas import (
|
| 9 |
+
UserStatsResponse,
|
| 10 |
+
UserStatsUpdate,
|
| 11 |
+
GameSessionCreate,
|
| 12 |
+
GameSessionResponse
|
| 13 |
+
)
|
| 14 |
+
from auth import get_current_active_user
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api/game", tags=["Game"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/stats", response_model=UserStatsResponse)
|
| 20 |
+
async def get_user_stats(
|
| 21 |
+
current_user: User = Depends(get_current_active_user),
|
| 22 |
+
db: Session = Depends(get_db)
|
| 23 |
+
):
|
| 24 |
+
"""Получение статистики пользователя"""
|
| 25 |
+
stats = db.query(UserStats).filter(UserStats.user_id == current_user.id).first()
|
| 26 |
+
if not stats:
|
| 27 |
+
# Создаем статистику если не существует
|
| 28 |
+
stats = UserStats(user_id=current_user.id)
|
| 29 |
+
db.add(stats)
|
| 30 |
+
db.commit()
|
| 31 |
+
db.refresh(stats)
|
| 32 |
+
return stats
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.patch("/stats", response_model=UserStatsResponse)
|
| 36 |
+
async def update_user_stats(
|
| 37 |
+
stats_update: UserStatsUpdate,
|
| 38 |
+
current_user: User = Depends(get_current_active_user),
|
| 39 |
+
db: Session = Depends(get_db)
|
| 40 |
+
):
|
| 41 |
+
"""Обновление статистики пользователя"""
|
| 42 |
+
stats = db.query(UserStats).filter(UserStats.user_id == current_user.id).first()
|
| 43 |
+
if not stats:
|
| 44 |
+
raise HTTPException(status_code=404, detail="Stats not found")
|
| 45 |
+
|
| 46 |
+
# Обновление полей
|
| 47 |
+
update_data = stats_update.dict(exclude_unset=True)
|
| 48 |
+
for field, value in update_data.items():
|
| 49 |
+
setattr(stats, field, value)
|
| 50 |
+
|
| 51 |
+
# Автоматический расчет уровня на основе опыта
|
| 52 |
+
if stats_update.experience is not None:
|
| 53 |
+
stats.level = 1 + (stats.experience // 100)
|
| 54 |
+
|
| 55 |
+
db.commit()
|
| 56 |
+
db.refresh(stats)
|
| 57 |
+
return stats
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post("/stats/consume-energy")
|
| 61 |
+
async def consume_energy(
|
| 62 |
+
amount: float,
|
| 63 |
+
current_user: User = Depends(get_current_active_user),
|
| 64 |
+
db: Session = Depends(get_db)
|
| 65 |
+
):
|
| 66 |
+
"""Расход энергии"""
|
| 67 |
+
stats = db.query(UserStats).filter(UserStats.user_id == current_user.id).first()
|
| 68 |
+
if not stats:
|
| 69 |
+
raise HTTPException(status_code=404, detail="Stats not found")
|
| 70 |
+
|
| 71 |
+
stats.energy = max(0, stats.energy - amount)
|
| 72 |
+
db.commit()
|
| 73 |
+
|
| 74 |
+
return {"energy": stats.energy, "message": f"Consumed {amount} energy"}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.post("/stats/restore-energy")
|
| 78 |
+
async def restore_energy(
|
| 79 |
+
amount: float,
|
| 80 |
+
current_user: User = Depends(get_current_active_user),
|
| 81 |
+
db: Session = Depends(get_db)
|
| 82 |
+
):
|
| 83 |
+
"""Восстановление энергии"""
|
| 84 |
+
stats = db.query(UserStats).filter(UserStats.user_id == current_user.id).first()
|
| 85 |
+
if not stats:
|
| 86 |
+
raise HTTPException(status_code=404, detail="Stats not found")
|
| 87 |
+
|
| 88 |
+
stats.energy = min(stats.max_energy, stats.energy + amount)
|
| 89 |
+
db.commit()
|
| 90 |
+
|
| 91 |
+
return {"energy": stats.energy, "message": f"Restored {amount} energy"}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@router.post("/stats/consume-hunger")
|
| 95 |
+
async def consume_hunger(
|
| 96 |
+
amount: float,
|
| 97 |
+
current_user: User = Depends(get_current_active_user),
|
| 98 |
+
db: Session = Depends(get_db)
|
| 99 |
+
):
|
| 100 |
+
"""Расход сытости"""
|
| 101 |
+
stats = db.query(UserStats).filter(UserStats.user_id == current_user.id).first()
|
| 102 |
+
if not stats:
|
| 103 |
+
raise HTTPException(status_code=404, detail="Stats not found")
|
| 104 |
+
|
| 105 |
+
stats.hunger = max(0, stats.hunger - amount)
|
| 106 |
+
db.commit()
|
| 107 |
+
|
| 108 |
+
return {"hunger": stats.hunger, "message": f"Consumed {amount} hunger"}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.post("/stats/restore-hunger")
|
| 112 |
+
async def restore_hunger(
|
| 113 |
+
amount: float,
|
| 114 |
+
current_user: User = Depends(get_current_active_user),
|
| 115 |
+
db: Session = Depends(get_db)
|
| 116 |
+
):
|
| 117 |
+
"""Восстановление сытости (еда)"""
|
| 118 |
+
stats = db.query(UserStats).filter(UserStats.user_id == current_user.id).first()
|
| 119 |
+
if not stats:
|
| 120 |
+
raise HTTPException(status_code=404, detail="Stats not found")
|
| 121 |
+
|
| 122 |
+
stats.hunger = min(stats.max_hunger, stats.hunger + amount)
|
| 123 |
+
db.commit()
|
| 124 |
+
|
| 125 |
+
return {"hunger": stats.hunger, "message": f"Restored {amount} hunger"}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@router.post("/session/start", response_model=GameSessionResponse)
|
| 129 |
+
async def start_game_session(
|
| 130 |
+
current_user: User = Depends(get_current_active_user),
|
| 131 |
+
db: Session = Depends(get_db)
|
| 132 |
+
):
|
| 133 |
+
"""Начало игровой сессии"""
|
| 134 |
+
# Проверка активной сессии
|
| 135 |
+
active_session = db.query(GameSession).filter(
|
| 136 |
+
GameSession.user_id == current_user.id,
|
| 137 |
+
GameSession.ended_at == None
|
| 138 |
+
).first()
|
| 139 |
+
|
| 140 |
+
if active_session:
|
| 141 |
+
return active_session
|
| 142 |
+
|
| 143 |
+
# Создание новой сессии
|
| 144 |
+
session = GameSession(user_id=current_user.id)
|
| 145 |
+
db.add(session)
|
| 146 |
+
db.commit()
|
| 147 |
+
db.refresh(session)
|
| 148 |
+
|
| 149 |
+
return session
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@router.post("/session/end", response_model=GameSessionResponse)
|
| 153 |
+
async def end_game_session(
|
| 154 |
+
current_user: User = Depends(get_current_active_user),
|
| 155 |
+
db: Session = Depends(get_db)
|
| 156 |
+
):
|
| 157 |
+
"""Завершение игровой сессии"""
|
| 158 |
+
session = db.query(GameSession).filter(
|
| 159 |
+
GameSession.user_id == current_user.id,
|
| 160 |
+
GameSession.ended_at == None
|
| 161 |
+
).first()
|
| 162 |
+
|
| 163 |
+
if not session:
|
| 164 |
+
raise HTTPException(status_code=404, detail="No active session found")
|
| 165 |
+
|
| 166 |
+
session.ended_at = datetime.utcnow()
|
| 167 |
+
session.duration = int((session.ended_at - session.started_at).total_seconds())
|
| 168 |
+
|
| 169 |
+
# Обновление общей статистики
|
| 170 |
+
stats = db.query(UserStats).filter(UserStats.user_id == current_user.id).first()
|
| 171 |
+
if stats:
|
| 172 |
+
stats.total_playtime += session.duration
|
| 173 |
+
|
| 174 |
+
db.commit()
|
| 175 |
+
db.refresh(session)
|
| 176 |
+
|
| 177 |
+
return session
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.get("/sessions", response_model=List[GameSessionResponse])
|
| 181 |
+
async def get_user_sessions(
|
| 182 |
+
limit: int = 10,
|
| 183 |
+
current_user: User = Depends(get_current_active_user),
|
| 184 |
+
db: Session = Depends(get_db)
|
| 185 |
+
):
|
| 186 |
+
"""Получение истории игровых сессий"""
|
| 187 |
+
sessions = db.query(GameSession).filter(
|
| 188 |
+
GameSession.user_id == current_user.id
|
| 189 |
+
).order_by(GameSession.started_at.desc()).limit(limit).all()
|
| 190 |
+
|
| 191 |
+
return sessions
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@router.post("/action/{action_type}")
|
| 195 |
+
async def log_game_action(
|
| 196 |
+
action_type: str,
|
| 197 |
+
action_data: str = None,
|
| 198 |
+
current_user: User = Depends(get_current_active_user),
|
| 199 |
+
db: Session = Depends(get_db)
|
| 200 |
+
):
|
| 201 |
+
"""Логирование игрового действия"""
|
| 202 |
+
action = GameAction(
|
| 203 |
+
user_id=current_user.id,
|
| 204 |
+
action_type=action_type,
|
| 205 |
+
action_data=action_data
|
| 206 |
+
)
|
| 207 |
+
db.add(action)
|
| 208 |
+
db.commit()
|
| 209 |
+
|
| 210 |
+
return {"message": "Action logged", "action_type": action_type}
|
schemas.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# User schemas
|
| 7 |
+
class UserBase(BaseModel):
|
| 8 |
+
username: str = Field(..., min_length=3, max_length=50)
|
| 9 |
+
email: EmailStr
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class UserCreate(UserBase):
|
| 13 |
+
password: str = Field(..., min_length=6)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class UserResponse(UserBase):
|
| 17 |
+
id: int
|
| 18 |
+
is_active: bool
|
| 19 |
+
is_admin: bool
|
| 20 |
+
created_at: datetime
|
| 21 |
+
last_login: Optional[datetime] = None
|
| 22 |
+
|
| 23 |
+
class Config:
|
| 24 |
+
from_attributes = True
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Auth schemas
|
| 28 |
+
class Token(BaseModel):
|
| 29 |
+
access_token: str
|
| 30 |
+
token_type: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TokenData(BaseModel):
|
| 34 |
+
username: Optional[str] = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class LoginRequest(BaseModel):
|
| 38 |
+
username: str
|
| 39 |
+
password: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# UserStats schemas
|
| 43 |
+
class UserStatsBase(BaseModel):
|
| 44 |
+
energy: float = 100.0
|
| 45 |
+
max_energy: float = 100.0
|
| 46 |
+
hunger: float = 100.0
|
| 47 |
+
max_hunger: float = 100.0
|
| 48 |
+
level: int = 1
|
| 49 |
+
experience: int = 0
|
| 50 |
+
coins: int = 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class UserStatsResponse(UserStatsBase):
|
| 54 |
+
id: int
|
| 55 |
+
user_id: int
|
| 56 |
+
total_playtime: int
|
| 57 |
+
rooms_visited: int
|
| 58 |
+
items_collected: int
|
| 59 |
+
enemies_defeated: int
|
| 60 |
+
deaths: int
|
| 61 |
+
updated_at: datetime
|
| 62 |
+
|
| 63 |
+
class Config:
|
| 64 |
+
from_attributes = True
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class UserStatsUpdate(BaseModel):
|
| 68 |
+
energy: Optional[float] = None
|
| 69 |
+
hunger: Optional[float] = None
|
| 70 |
+
experience: Optional[int] = None
|
| 71 |
+
coins: Optional[int] = None
|
| 72 |
+
rooms_visited: Optional[int] = None
|
| 73 |
+
items_collected: Optional[int] = None
|
| 74 |
+
enemies_defeated: Optional[int] = None
|
| 75 |
+
deaths: Optional[int] = None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# GameSession schemas
|
| 79 |
+
class GameSessionCreate(BaseModel):
|
| 80 |
+
pass
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class GameSessionResponse(BaseModel):
|
| 84 |
+
id: int
|
| 85 |
+
user_id: int
|
| 86 |
+
started_at: datetime
|
| 87 |
+
ended_at: Optional[datetime] = None
|
| 88 |
+
duration: int
|
| 89 |
+
energy_consumed: float
|
| 90 |
+
hunger_consumed: float
|
| 91 |
+
rooms_visited_session: int
|
| 92 |
+
items_collected_session: int
|
| 93 |
+
enemies_defeated_session: int
|
| 94 |
+
deaths_session: int
|
| 95 |
+
|
| 96 |
+
class Config:
|
| 97 |
+
from_attributes = True
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# Admin schemas
|
| 101 |
+
class AdminStats(BaseModel):
|
| 102 |
+
total_users: int
|
| 103 |
+
active_users_today: int
|
| 104 |
+
new_users_today: int
|
| 105 |
+
total_sessions: int
|
| 106 |
+
avg_session_duration: float
|
| 107 |
+
total_playtime: int
|
| 108 |
+
total_rooms_visited: int
|
| 109 |
+
total_items_collected: int
|
| 110 |
+
total_enemies_defeated: int
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class UserListItem(BaseModel):
|
| 114 |
+
id: int
|
| 115 |
+
username: str
|
| 116 |
+
email: str
|
| 117 |
+
is_active: bool
|
| 118 |
+
is_admin: bool
|
| 119 |
+
created_at: datetime
|
| 120 |
+
last_login: Optional[datetime] = None
|
| 121 |
+
|
| 122 |
+
class Config:
|
| 123 |
+
from_attributes = True
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class UserDetail(UserListItem):
|
| 127 |
+
stats: Optional[UserStatsResponse] = None
|
test_api.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import json
|
| 3 |
+
from time import sleep
|
| 4 |
+
|
| 5 |
+
BASE_URL = "http://localhost:8000"
|
| 6 |
+
|
| 7 |
+
def print_response(title, response):
|
| 8 |
+
"""Красивый вывод ответа"""
|
| 9 |
+
print(f"\n{'='*60}")
|
| 10 |
+
print(f"🔹 {title}")
|
| 11 |
+
print(f"{'='*60}")
|
| 12 |
+
print(f"Status: {response.status_code}")
|
| 13 |
+
try:
|
| 14 |
+
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
| 15 |
+
except:
|
| 16 |
+
print(response.text)
|
| 17 |
+
|
| 18 |
+
def test_api():
|
| 19 |
+
"""Тестирование всех эндпоинтов API"""
|
| 20 |
+
|
| 21 |
+
# 1. Проверка корневого эндпоинта
|
| 22 |
+
print("\n🚀 НАЧАЛО ТЕСТИРОВАНИЯ API")
|
| 23 |
+
response = requests.get(f"{BASE_URL}/")
|
| 24 |
+
print_response("GET / - Корневой эндпоинт", response)
|
| 25 |
+
|
| 26 |
+
# 2. Health check
|
| 27 |
+
response = requests.get(f"{BASE_URL}/health")
|
| 28 |
+
print_response("GET /health - Проверка здоровья", response)
|
| 29 |
+
|
| 30 |
+
# 3. Регистрация нового пользователя
|
| 31 |
+
user_data = {
|
| 32 |
+
"username": "testuser",
|
| 33 |
+
"email": "test@example.com",
|
| 34 |
+
"password": "password123"
|
| 35 |
+
}
|
| 36 |
+
response = requests.post(f"{BASE_URL}/api/auth/register", json=user_data)
|
| 37 |
+
print_response("POST /api/auth/register - Регистрация", response)
|
| 38 |
+
|
| 39 |
+
# 4. Вход пользователя
|
| 40 |
+
login_data = {
|
| 41 |
+
"username": "testuser",
|
| 42 |
+
"password": "password123"
|
| 43 |
+
}
|
| 44 |
+
response = requests.post(
|
| 45 |
+
f"{BASE_URL}/api/auth/login",
|
| 46 |
+
data=login_data,
|
| 47 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
| 48 |
+
)
|
| 49 |
+
print_response("POST /api/auth/login - Вход пользователя", response)
|
| 50 |
+
|
| 51 |
+
if response.status_code == 200:
|
| 52 |
+
user_token = response.json()["access_token"]
|
| 53 |
+
print(f"✅ Получен токен пользователя")
|
| 54 |
+
else:
|
| 55 |
+
print("❌ Не удалось получить токен")
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
# 5. Информация о текущем пользователе
|
| 59 |
+
headers = {"Authorization": f"Bearer {user_token}"}
|
| 60 |
+
response = requests.get(f"{BASE_URL}/api/auth/me", headers=headers)
|
| 61 |
+
print_response("GET /api/auth/me - Текущий пользователь", response)
|
| 62 |
+
|
| 63 |
+
# 6. Получение статистики игрока
|
| 64 |
+
response = requests.get(f"{BASE_URL}/api/game/stats", headers=headers)
|
| 65 |
+
print_response("GET /api/game/stats - Статистика игрока", response)
|
| 66 |
+
|
| 67 |
+
# 7. Начало игровой сессии
|
| 68 |
+
response = requests.post(f"{BASE_URL}/api/game/session/start", headers=headers)
|
| 69 |
+
print_response("POST /api/game/session/start - Начало сессии", response)
|
| 70 |
+
|
| 71 |
+
# 8. Расход энергии
|
| 72 |
+
response = requests.post(f"{BASE_URL}/api/game/stats/consume-energy?amount=10", headers=headers)
|
| 73 |
+
print_response("POST /api/game/stats/consume-energy - Расход энергии", response)
|
| 74 |
+
|
| 75 |
+
# 9. Восстановление энергии
|
| 76 |
+
response = requests.post(f"{BASE_URL}/api/game/stats/restore-energy?amount=5", headers=headers)
|
| 77 |
+
print_response("POST /api/game/stats/restore-energy - Восстановление энергии", response)
|
| 78 |
+
|
| 79 |
+
# 10. Расход сытости
|
| 80 |
+
response = requests.post(f"{BASE_URL}/api/game/stats/consume-hunger?amount=15", headers=headers)
|
| 81 |
+
print_response("POST /api/game/stats/consume-hunger - Расход сытости", response)
|
| 82 |
+
|
| 83 |
+
# 11. Восстановление сытости (еда)
|
| 84 |
+
response = requests.post(f"{BASE_URL}/api/game/stats/restore-hunger?amount=20", headers=headers)
|
| 85 |
+
print_response("POST /api/game/stats/restore-hunger - Еда", response)
|
| 86 |
+
|
| 87 |
+
# 12. Обновление статистики
|
| 88 |
+
stats_update = {
|
| 89 |
+
"experience": 50,
|
| 90 |
+
"coins": 100,
|
| 91 |
+
"rooms_visited": 5,
|
| 92 |
+
"items_collected": 10,
|
| 93 |
+
"enemies_defeated": 3
|
| 94 |
+
}
|
| 95 |
+
response = requests.patch(f"{BASE_URL}/api/game/stats", json=stats_update, headers=headers)
|
| 96 |
+
print_response("PATCH /api/game/stats - Обновление статистики", response)
|
| 97 |
+
|
| 98 |
+
# 13. Логирование действия
|
| 99 |
+
response = requests.post(
|
| 100 |
+
f"{BASE_URL}/api/game/action/move?action_data=room_1_to_room_2",
|
| 101 |
+
headers=headers
|
| 102 |
+
)
|
| 103 |
+
print_response("POST /api/game/action/move - Логирование действия", response)
|
| 104 |
+
|
| 105 |
+
# Подождем 2 секунды для сессии
|
| 106 |
+
print("\n⏳ Ждем 2 секунды для статистики сессии...")
|
| 107 |
+
sleep(2)
|
| 108 |
+
|
| 109 |
+
# 14. Завершение сессии
|
| 110 |
+
response = requests.post(f"{BASE_URL}/api/game/session/end", headers=headers)
|
| 111 |
+
print_response("POST /api/game/session/end - Завершение сессии", response)
|
| 112 |
+
|
| 113 |
+
# 15. История сессий
|
| 114 |
+
response = requests.get(f"{BASE_URL}/api/game/sessions?limit=5", headers=headers)
|
| 115 |
+
print_response("GET /api/game/sessions - История сессий", response)
|
| 116 |
+
|
| 117 |
+
# ТЕСТИРОВАНИЕ АДМИНКИ
|
| 118 |
+
print("\n" + "="*60)
|
| 119 |
+
print("👨💼 ТЕСТИРОВАНИЕ АДМИН-ПАНЕЛИ")
|
| 120 |
+
print("="*60)
|
| 121 |
+
|
| 122 |
+
# 16. Вход администратора
|
| 123 |
+
admin_login = {
|
| 124 |
+
"username": "admin",
|
| 125 |
+
"password": "admin123"
|
| 126 |
+
}
|
| 127 |
+
response = requests.post(
|
| 128 |
+
f"{BASE_URL}/api/auth/login",
|
| 129 |
+
data=admin_login,
|
| 130 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
| 131 |
+
)
|
| 132 |
+
print_response("POST /api/auth/login - Вход администратора", response)
|
| 133 |
+
|
| 134 |
+
if response.status_code == 200:
|
| 135 |
+
admin_token = response.json()["access_token"]
|
| 136 |
+
print(f"✅ Получен токен администратора")
|
| 137 |
+
else:
|
| 138 |
+
print("❌ Не удалось получить токен администратора")
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
admin_headers = {"Authorization": f"Bearer {admin_token}"}
|
| 142 |
+
|
| 143 |
+
# 17. Общая статистика
|
| 144 |
+
response = requests.get(f"{BASE_URL}/api/admin/stats/overview", headers=admin_headers)
|
| 145 |
+
print_response("GET /api/admin/stats/overview - Общая статистика", response)
|
| 146 |
+
|
| 147 |
+
# 18. Список пользователей
|
| 148 |
+
response = requests.get(f"{BASE_URL}/api/admin/users", headers=admin_headers)
|
| 149 |
+
print_response("GET /api/admin/users - Список пользователей", response)
|
| 150 |
+
|
| 151 |
+
# 19. Топ игроков
|
| 152 |
+
response = requests.get(f"{BASE_URL}/api/admin/stats/top-players?metric=level&limit=5", headers=admin_headers)
|
| 153 |
+
print_response("GET /api/admin/stats/top-players - Топ по уровню", response)
|
| 154 |
+
|
| 155 |
+
# 20. График сессий
|
| 156 |
+
response = requests.get(f"{BASE_URL}/api/admin/stats/sessions-timeline?days=7", headers=admin_headers)
|
| 157 |
+
print_response("GET /api/admin/stats/sessions-timeline - График сессий", response)
|
| 158 |
+
|
| 159 |
+
# 21. Тепловая карта активности
|
| 160 |
+
response = requests.get(f"{BASE_URL}/api/admin/stats/activity-heatmap", headers=admin_headers)
|
| 161 |
+
print_response("GET /api/admin/stats/activity-heatmap - Тепловая карта", response)
|
| 162 |
+
|
| 163 |
+
# 22. Удержание пользователей
|
| 164 |
+
response = requests.get(f"{BASE_URL}/api/admin/stats/user-retention", headers=admin_headers)
|
| 165 |
+
print_response("GET /api/admin/stats/user-retention - Удержание", response)
|
| 166 |
+
|
| 167 |
+
# 23. Игровые метрики
|
| 168 |
+
response = requests.get(f"{BASE_URL}/api/admin/stats/game-metrics", headers=admin_headers)
|
| 169 |
+
print_response("GET /api/admin/stats/game-metrics - Игровые метрики", response)
|
| 170 |
+
|
| 171 |
+
# 24. Последние действия
|
| 172 |
+
response = requests.get(f"{BASE_URL}/api/admin/actions/recent?limit=10", headers=admin_headers)
|
| 173 |
+
print_response("GET /api/admin/actions/recent - Последние действия", response)
|
| 174 |
+
|
| 175 |
+
# Итоги
|
| 176 |
+
print("\n" + "="*60)
|
| 177 |
+
print("✅ ТЕСТИРОВАНИЕ ЗАВЕРШЕНО!")
|
| 178 |
+
print("="*60)
|
| 179 |
+
print("\n📊 Проверьте документацию API: http://localhost:8000/docs")
|
| 180 |
+
print("📊 Альтернативная документация: http://localhost:8000/redoc")
|
| 181 |
+
|
| 182 |
+
if __name__ == "__main__":
|
| 183 |
+
try:
|
| 184 |
+
test_api()
|
| 185 |
+
except requests.exceptions.ConnectionError:
|
| 186 |
+
print("\n❌ Ошибка: Не удалось подключиться к серверу")
|
| 187 |
+
print("Убедитесь, что сервер запущен на http://localhost:8000")
|
| 188 |
+
except Exception as e:
|
| 189 |
+
print(f"\n❌ Ошибка: {e}")
|